]> git.mxchange.org Git - quix0rs-gnu-social.git/blobdiff - lib/themeuploader.php
Basic custom CSS and theme uploading features. 'local' subdir can now be customized...
[quix0rs-gnu-social.git] / lib / themeuploader.php
diff --git a/lib/themeuploader.php b/lib/themeuploader.php
new file mode 100644 (file)
index 0000000..18ef8c4
--- /dev/null
@@ -0,0 +1,311 @@
+<?php
+/**
+ * StatusNet, the distributed open-source microblogging tool
+ *
+ * Utilities for theme files and paths
+ *
+ * PHP version 5
+ *
+ * LICENCE: This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @category  Paths
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @copyright 2010 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET') && !defined('LACONICA')) {
+    exit(1);
+}
+
+/**
+ * Encapsulation of the validation-and-save process when dealing with
+ * a user-uploaded StatusNet theme archive...
+ * 
+ * @todo extract theme metadata from css/display.css
+ * @todo allow saving multiple themes
+ */
+class ThemeUploader
+{
+    protected $sourceFile;
+    protected $isUpload;
+    private $prevErrorReporting;
+
+    public function __construct($filename)
+    {
+        if (!class_exists('ZipArchive')) {
+            throw new Exception(_("This server cannot handle theme uploads without ZIP support."));
+        }
+        $this->sourceFile = $filename;
+    }
+
+    public static function fromUpload($name)
+    {
+        if (!isset($_FILES[$name]['error'])) {
+            throw new ServerException(_("Theme upload missing or failed."));
+        }
+        if ($_FILES[$name]['error'] != UPLOAD_ERR_OK) {
+            throw new ServerException(_("Theme upload missing or failed."));
+        }
+        return new ThemeUploader($_FILES[$name]['tmp_name']);
+    }
+
+    /**
+     * @param string $destDir
+     * @throws Exception on bogus files
+     */
+    public function extract($destDir)
+    {
+        $zip = $this->openArchive();
+
+        // First pass: validate but don't save anything to disk.
+        // Any errors will trip an exception.
+        $this->traverseArchive($zip);
+
+        // Second pass: now that we know we're good, actually extract!
+        $tmpDir = $destDir . '.tmp' . getmypid();
+        $this->traverseArchive($zip, $tmpDir);
+
+        $zip->close();
+
+        if (file_exists($destDir)) {
+            $killDir = $tmpDir . '.old';
+            $this->quiet();
+            $ok = rename($destDir, $killDir);
+            $this->loud();
+            if (!$ok) {
+                common_log(LOG_ERR, "Could not move old custom theme from $destDir to $killDir");
+                throw new ServerException(_("Failed saving theme."));
+            }
+        } else {
+            $killDir = false;
+        }
+
+        $this->quiet();
+        $ok = rename($tmpDir, $destDir);
+        $this->loud();
+        if (!$ok) {
+            common_log(LOG_ERR, "Could not move saved theme from $tmpDir to $destDir");
+            throw new ServerException(_("Failed saving theme."));
+        }
+
+        if ($killDir) {
+            $this->recursiveRmdir($killDir);
+        }
+    }
+
+    /**
+     * 
+     */
+    protected function traverseArchive($zip, $outdir=false)
+    {
+        $sizeLimit = 2 * 1024 * 1024; // 2 megabyte space limit?
+        $blockSize = 4096; // estimated; any entry probably takes this much space
+
+        $totalSize = 0;
+        $hasMain = false;
+        $commonBaseDir = false;
+
+        for ($i = 0; $i < $zip->numFiles; $i++) {
+            $data = $zip->statIndex($i);
+            $name = str_replace('\\', '/', $data['name']);
+
+            if (substr($name, -1) == '/') {
+                // A raw directory... skip!
+                continue;
+            }
+
+            // Check the directory structure...
+            $path = pathinfo($name);
+            $dirs = explode('/', $path['dirname']);
+            $baseDir = array_shift($dirs);
+            if ($commonBaseDir === false) {
+                $commonBaseDir = $baseDir;
+            } else {
+                if ($commonBaseDir != $baseDir) {
+                    throw new ClientException(_("Invalid theme: bad directory structure."));
+                }
+            }
+
+            foreach ($dirs as $dir) {
+                $this->validateFileOrFolder($dir);
+            }
+
+            // Is this a safe or skippable file?
+            if ($this->skippable($path['filename'], $path['extension'])) {
+                // Documentation and such... booooring
+                continue;
+            } else {
+                $this->validateFile($path['filename'], $path['extension']);
+            }
+
+            $fullPath = $dirs;
+            $fullPath[] = $path['basename'];
+            $localFile = implode('/', $fullPath);
+            if ($localFile == 'css/display.css') {
+                $hasMain = true;
+            }
+            
+            $size = $data['size'];
+            $estSize = $blockSize * max(1, intval(ceil($size / $blockSize)));
+            $totalSize += $estSize;
+            if ($totalSize > $sizeLimit) {
+                $msg = sprintf(_("Uploaded theme is too large; " .
+                                 "must be less than %d bytes uncompressed."),
+                                 $sizeLimit);
+                throw new ClientException($msg);
+            }
+
+            if ($outdir) {
+                $this->extractFile($zip, $data['name'], "$outdir/$localFile");
+            }
+        }
+
+        if (!$hasMain) {
+            throw new ClientException(_("Invalid theme archive: " .
+                                        "missing file css/display.css"));
+        }
+    }
+
+    protected function skippable($filename, $ext)
+    {
+        $skip = array('txt', 'rtf', 'doc', 'docx', 'odt');
+        if (strtolower($filename) == 'readme') {
+            return true;
+        }
+        if (in_array(strtolower($ext), $skip)) {
+            return true;
+        }
+        return false;
+    }
+
+    protected function validateFile($filename, $ext)
+    {
+        $this->validateFileOrFolder($filename);
+        $this->validateExtension($ext);
+        // @fixme validate content
+    }
+
+    protected function validateFileOrFolder($name)
+    {
+        if (!preg_match('/^[a-z0-9_-]+$/i', $name)) {
+            $msg = _("Theme contains invalid file or folder name. " .
+                     "Stick with ASCII letters, digits, underscore, and minus sign.");
+            throw new ClientException($msg);
+        }
+        return true;
+    }
+
+    protected function validateExtension($ext)
+    {
+        $allowed = array('css', 'png', 'gif', 'jpg', 'jpeg');
+        if (!in_array(strtolower($ext), $allowed)) {
+            $msg = sprintf(_("Theme contains file of type '.%s', " .
+                             "which is not allowed."),
+                           $ext);
+            throw new ClientException($msg);
+        }
+        return true;
+    }
+
+    /**
+     * @return ZipArchive
+     */
+    protected function openArchive()
+    {
+        $zip = new ZipArchive;
+        $ok = $zip->open($this->sourceFile); 
+        if ($ok !== true) {
+            common_log(LOG_ERR, "Error opening theme zip archive: " .
+                                "{$this->sourceFile} code: {$ok}");
+            throw new Exception(_("Error opening theme archive."));
+        }
+        return $zip;
+    }
+
+    /**
+     * @param ZipArchive $zip
+     * @param string $from original path inside ZIP archive
+     * @param string $to final destination path in filesystem
+     */
+    protected function extractFile($zip, $from, $to)
+    {
+        $dir = dirname($to);
+        if (!file_exists($dir)) {
+            $this->quiet();
+            $ok = mkdir($dir, 0755, true);
+            $this->loud();
+            if (!$ok) {
+                common_log(LOG_ERR, "Failed to mkdir $dir while uploading theme");
+                throw new ServerException(_("Failed saving theme."));
+            }
+        } else if (!is_dir($dir)) {
+            common_log(LOG_ERR, "Output directory $dir not a directory while uploading theme");
+            throw new ServerException(_("Failed saving theme."));
+        }
+
+        // ZipArchive::extractTo would be easier, but won't let us alter
+        // the directory structure.
+        $in = $zip->getStream($from);
+        if (!$in) {
+            common_log(LOG_ERR, "Couldn't open archived file $from while uploading theme");
+            throw new ServerException(_("Failed saving theme."));
+        }
+        $this->quiet();
+        $out = fopen($to, "wb");
+        $this->loud();
+        if (!$out) {
+            common_log(LOG_ERR, "Couldn't open output file $to while uploading theme");
+            throw new ServerException(_("Failed saving theme."));
+        }
+        while (!feof($in)) {
+            $buffer = fread($in, 65536);
+            fwrite($out, $buffer);
+        }
+        fclose($in);
+        fclose($out);
+    }
+
+    private function quiet()
+    {
+        $this->prevErrorReporting = error_reporting();
+        error_reporting($this->prevErrorReporting & ~E_WARNING);
+    }
+
+    private function loud()
+    {
+        error_reporting($this->prevErrorReporting);
+    }
+
+    private function recursiveRmdir($dir)
+    {
+        $list = dir($dir);
+        while (($file = $list->read()) !== false) {
+            if ($file == '.' || $file == '..') {
+                continue;
+            }
+            $full = "$dir/$file";
+            if (is_dir($full)) {
+                $this->recursiveRmdir($full);
+            } else {
+                unlink($full);
+            }
+        }
+        $list->close();
+        rmdir($dir);
+    }
+
+}