]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - lib/themeuploader.php
Merge branch 'master' into 0.9.x
[quix0rs-gnu-social.git] / lib / themeuploader.php
1 <?php
2 /**
3  * StatusNet, the distributed open-source microblogging tool
4  *
5  * Utilities for theme files and paths
6  *
7  * PHP version 5
8  *
9  * LICENCE: This program is free software: you can redistribute it and/or modify
10  * it under the terms of the GNU Affero General Public License as published by
11  * the Free Software Foundation, either version 3 of the License, or
12  * (at your option) any later version.
13  *
14  * This program is distributed in the hope that it will be useful,
15  * but WITHOUT ANY WARRANTY; without even the implied warranty of
16  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17  * GNU Affero General Public License for more details.
18  *
19  * You should have received a copy of the GNU Affero General Public License
20  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21  *
22  * @category  Paths
23  * @package   StatusNet
24  * @author    Brion Vibber <brion@status.net>
25  * @copyright 2010 StatusNet, Inc.
26  * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
27  * @link      http://status.net/
28  */
29
30 if (!defined('STATUSNET') && !defined('LACONICA')) {
31     exit(1);
32 }
33
34 /**
35  * Encapsulation of the validation-and-save process when dealing with
36  * a user-uploaded StatusNet theme archive...
37  * 
38  * @todo extract theme metadata from css/display.css
39  * @todo allow saving multiple themes
40  */
41 class ThemeUploader
42 {
43     protected $sourceFile;
44     protected $isUpload;
45     private $prevErrorReporting;
46
47     public function __construct($filename)
48     {
49         if (!class_exists('ZipArchive')) {
50             throw new Exception(_("This server cannot handle theme uploads without ZIP support."));
51         }
52         $this->sourceFile = $filename;
53     }
54
55     public static function fromUpload($name)
56     {
57         if (!isset($_FILES[$name]['error'])) {
58             throw new ServerException(_("The theme file is missing or the upload failed."));
59         }
60         if ($_FILES[$name]['error'] != UPLOAD_ERR_OK) {
61             throw new ServerException(_("The theme file is missing or the upload failed."));
62         }
63         return new ThemeUploader($_FILES[$name]['tmp_name']);
64     }
65
66     /**
67      * @param string $destDir
68      * @throws Exception on bogus files
69      */
70     public function extract($destDir)
71     {
72         $zip = $this->openArchive();
73
74         // First pass: validate but don't save anything to disk.
75         // Any errors will trip an exception.
76         $this->traverseArchive($zip);
77
78         // Second pass: now that we know we're good, actually extract!
79         $tmpDir = $destDir . '.tmp' . getmypid();
80         $this->traverseArchive($zip, $tmpDir);
81
82         $zip->close();
83
84         if (file_exists($destDir)) {
85             $killDir = $tmpDir . '.old';
86             $this->quiet();
87             $ok = rename($destDir, $killDir);
88             $this->loud();
89             if (!$ok) {
90                 common_log(LOG_ERR, "Could not move old custom theme from $destDir to $killDir");
91                 throw new ServerException(_("Failed saving theme."));
92             }
93         } else {
94             $killDir = false;
95         }
96
97         $this->quiet();
98         $ok = rename($tmpDir, $destDir);
99         $this->loud();
100         if (!$ok) {
101             common_log(LOG_ERR, "Could not move saved theme from $tmpDir to $destDir");
102             throw new ServerException(_("Failed saving theme."));
103         }
104
105         if ($killDir) {
106             $this->recursiveRmdir($killDir);
107         }
108     }
109
110     /**
111      * 
112      */
113     protected function traverseArchive($zip, $outdir=false)
114     {
115         $sizeLimit = 2 * 1024 * 1024; // 2 megabyte space limit?
116         $blockSize = 4096; // estimated; any entry probably takes this much space
117
118         $totalSize = 0;
119         $hasMain = false;
120         $commonBaseDir = false;
121
122         for ($i = 0; $i < $zip->numFiles; $i++) {
123             $data = $zip->statIndex($i);
124             $name = str_replace('\\', '/', $data['name']);
125
126             if (substr($name, -1) == '/') {
127                 // A raw directory... skip!
128                 continue;
129             }
130
131             // Is this a safe or skippable file?
132             $path = pathinfo($name);
133             if ($this->skippable($path['filename'], $path['extension'])) {
134                 // Documentation and such... booooring
135                 continue;
136             } else {
137                 $this->validateFile($path['filename'], $path['extension']);
138             }
139
140             // Check the directory structure...
141             $dirs = explode('/', $path['dirname']);
142             $baseDir = array_shift($dirs);
143             if ($commonBaseDir === false) {
144                 $commonBaseDir = $baseDir;
145             } else {
146                 if ($commonBaseDir != $baseDir) {
147                     throw new ClientException(_("Invalid theme: bad directory structure."));
148                 }
149             }
150
151             foreach ($dirs as $dir) {
152                 $this->validateFileOrFolder($dir);
153             }
154
155             $fullPath = $dirs;
156             $fullPath[] = $path['basename'];
157             $localFile = implode('/', $fullPath);
158             if ($localFile == 'css/display.css') {
159                 $hasMain = true;
160             }
161             
162             $size = $data['size'];
163             $estSize = $blockSize * max(1, intval(ceil($size / $blockSize)));
164             $totalSize += $estSize;
165             if ($totalSize > $sizeLimit) {
166                 $msg = sprintf(_m('Uploaded theme is too large; must be less than %d byte uncompressed.',
167                                   'Uploaded theme is too large; must be less than %d bytes uncompressed.',
168                                   $sizeLimit),
169                                $sizeLimit);
170                 throw new ClientException($msg);
171             }
172
173             if ($outdir) {
174                 $this->extractFile($zip, $data['name'], "$outdir/$localFile");
175             }
176         }
177
178         if (!$hasMain) {
179             throw new ClientException(_("Invalid theme archive: " .
180                                         "missing file css/display.css"));
181         }
182     }
183
184     /**
185      * @fixme Probably most unrecognized files should just be skipped...
186      */
187     protected function skippable($filename, $ext)
188     {
189         $skip = array('txt', 'html', 'rtf', 'doc', 'docx', 'odt', 'xcf');
190         if (strtolower($filename) == 'readme') {
191             return true;
192         }
193         if (in_array(strtolower($ext), $skip)) {
194             return true;
195         }
196         if ($filename == '' || substr($filename, 0, 1) == '.') {
197             // Skip Unix-style hidden files
198             return true;
199         }
200         if ($filename == '__MACOSX') {
201             // Skip awful metadata files Mac OS X slips in for you.
202             // Thanks Apple!
203             return true;
204         }
205         return false;
206     }
207
208     protected function validateFile($filename, $ext)
209     {
210         $this->validateFileOrFolder($filename);
211         $this->validateExtension($filename, $ext);
212         // @fixme validate content
213     }
214
215     protected function validateFileOrFolder($name)
216     {
217         if (!preg_match('/^[a-z0-9_\.-]+$/i', $name)) {
218             common_log(LOG_ERR, "Bad theme filename: $name");
219             $msg = _("Theme contains invalid file or folder name. " .
220                      "Stick with ASCII letters, digits, underscore, and minus sign.");
221             throw new ClientException($msg);
222         }
223         if (preg_match('/\.(php|cgi|asp|aspx|js|vb)\w/i', $name)) {
224             common_log(LOG_ERR, "Unsafe theme filename: $name");
225             $msg = _("Theme contains unsafe file extension names; may be unsafe.");
226             throw new ClientException($msg);
227         }
228         return true;
229     }
230
231     protected function validateExtension($base, $ext)
232     {
233         $allowed = array('css', // CSS may need validation
234                          'png', 'gif', 'jpg', 'jpeg',
235                          'svg', // SVG images/fonts may need validation
236                          'ttf', 'eot', 'woff');
237         if (!in_array(strtolower($ext), $allowed)) {
238             if ($ext == 'ini' && $base == 'theme') {
239                 // theme.ini exception
240                 return true;
241             }
242             $msg = sprintf(_("Theme contains file of type '.%s', " .
243                              "which is not allowed."),
244                            $ext);
245             throw new ClientException($msg);
246         }
247         return true;
248     }
249
250     /**
251      * @return ZipArchive
252      */
253     protected function openArchive()
254     {
255         $zip = new ZipArchive;
256         $ok = $zip->open($this->sourceFile); 
257         if ($ok !== true) {
258             common_log(LOG_ERR, "Error opening theme zip archive: " .
259                                 "{$this->sourceFile} code: {$ok}");
260             throw new Exception(_("Error opening theme archive."));
261         }
262         return $zip;
263     }
264
265     /**
266      * @param ZipArchive $zip
267      * @param string $from original path inside ZIP archive
268      * @param string $to final destination path in filesystem
269      */
270     protected function extractFile($zip, $from, $to)
271     {
272         $dir = dirname($to);
273         if (!file_exists($dir)) {
274             $this->quiet();
275             $ok = mkdir($dir, 0755, true);
276             $this->loud();
277             if (!$ok) {
278                 common_log(LOG_ERR, "Failed to mkdir $dir while uploading theme");
279                 throw new ServerException(_("Failed saving theme."));
280             }
281         } else if (!is_dir($dir)) {
282             common_log(LOG_ERR, "Output directory $dir not a directory while uploading theme");
283             throw new ServerException(_("Failed saving theme."));
284         }
285
286         // ZipArchive::extractTo would be easier, but won't let us alter
287         // the directory structure.
288         $in = $zip->getStream($from);
289         if (!$in) {
290             common_log(LOG_ERR, "Couldn't open archived file $from while uploading theme");
291             throw new ServerException(_("Failed saving theme."));
292         }
293         $this->quiet();
294         $out = fopen($to, "wb");
295         $this->loud();
296         if (!$out) {
297             common_log(LOG_ERR, "Couldn't open output file $to while uploading theme");
298             throw new ServerException(_("Failed saving theme."));
299         }
300         while (!feof($in)) {
301             $buffer = fread($in, 65536);
302             fwrite($out, $buffer);
303         }
304         fclose($in);
305         fclose($out);
306     }
307
308     private function quiet()
309     {
310         $this->prevErrorReporting = error_reporting();
311         error_reporting($this->prevErrorReporting & ~E_WARNING);
312     }
313
314     private function loud()
315     {
316         error_reporting($this->prevErrorReporting);
317     }
318
319     private function recursiveRmdir($dir)
320     {
321         $list = dir($dir);
322         while (($file = $list->read()) !== false) {
323             if ($file == '.' || $file == '..') {
324                 continue;
325             }
326             $full = "$dir/$file";
327             if (is_dir($full)) {
328                 $this->recursiveRmdir($full);
329             } else {
330                 unlink($full);
331             }
332         }
333         $list->close();
334         rmdir($dir);
335     }
336
337 }