]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - lib/themeuploader.php
Merge remote-tracking branch 'origin/1.0.x' into 1.0.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             // TRANS: Exception thrown when a compressed theme is uploaded while no support present in PHP configuration.
51             throw new Exception(_('This server cannot handle theme uploads without ZIP support.'));
52         }
53         $this->sourceFile = $filename;
54     }
55
56     public static function fromUpload($name)
57     {
58         if (!isset($_FILES[$name]['error'])) {
59             // TRANS: Server exception thrown when uploading a theme fails.
60             throw new ServerException(_('The theme file is missing or the upload failed.'));
61         }
62         if ($_FILES[$name]['error'] != UPLOAD_ERR_OK) {
63             // TRANS: Server exception thrown when uploading a theme fails.
64             throw new ServerException(_('The theme file is missing or the upload failed.'));
65         }
66         return new ThemeUploader($_FILES[$name]['tmp_name']);
67     }
68
69     /**
70      * @param string $destDir
71      * @throws Exception on bogus files
72      */
73     public function extract($destDir)
74     {
75         $zip = $this->openArchive();
76
77         // First pass: validate but don't save anything to disk.
78         // Any errors will trip an exception.
79         $this->traverseArchive($zip);
80
81         // Second pass: now that we know we're good, actually extract!
82         $tmpDir = $destDir . '.tmp' . getmypid();
83         $this->traverseArchive($zip, $tmpDir);
84
85         $zip->close();
86
87         if (file_exists($destDir)) {
88             $killDir = $tmpDir . '.old';
89             $this->quiet();
90             $ok = rename($destDir, $killDir);
91             $this->loud();
92             if (!$ok) {
93                 common_log(LOG_ERR, "Could not move old custom theme from $destDir to $killDir");
94                 // TRANS: Server exception thrown when saving an uploaded theme after decompressing it fails.
95                 throw new ServerException(_('Failed saving theme.'));
96             }
97         } else {
98             $killDir = false;
99         }
100
101         $this->quiet();
102         $ok = rename($tmpDir, $destDir);
103         $this->loud();
104         if (!$ok) {
105             common_log(LOG_ERR, "Could not move saved theme from $tmpDir to $destDir");
106             // TRANS: Server exception thrown when saving an uploaded theme after decompressing it fails.
107             throw new ServerException(_('Failed saving theme.'));
108         }
109
110         if ($killDir) {
111             $this->recursiveRmdir($killDir);
112         }
113     }
114
115     /**
116      *
117      */
118     protected function traverseArchive($zip, $outdir=false)
119     {
120         $sizeLimit = 2 * 1024 * 1024; // 2 megabyte space limit?
121         $blockSize = 4096; // estimated; any entry probably takes this much space
122
123         $totalSize = 0;
124         $hasMain = false;
125         $commonBaseDir = false;
126
127         for ($i = 0; $i < $zip->numFiles; $i++) {
128             $data = $zip->statIndex($i);
129             $name = str_replace('\\', '/', $data['name']);
130
131             if (substr($name, -1) == '/') {
132                 // A raw directory... skip!
133                 continue;
134             }
135
136             // Is this a safe or skippable file?
137             $path = pathinfo($name);
138             if ($this->skippable($path['filename'], $path['extension'])) {
139                 // Documentation and such... booooring
140                 continue;
141             } else {
142                 $this->validateFile($path['filename'], $path['extension']);
143             }
144
145             // Check the directory structure...
146             $dirs = explode('/', $path['dirname']);
147             $baseDir = array_shift($dirs);
148             if ($commonBaseDir === false) {
149                 $commonBaseDir = $baseDir;
150             } else {
151                 if ($commonBaseDir != $baseDir) {
152                     // TRANS: Server exception thrown when an uploaded theme has an incorrect structure.
153                     throw new ClientException(_('Invalid theme: Bad directory structure.'));
154                 }
155             }
156
157             foreach ($dirs as $dir) {
158                 $this->validateFileOrFolder($dir);
159             }
160
161             $fullPath = $dirs;
162             $fullPath[] = $path['basename'];
163             $localFile = implode('/', $fullPath);
164             if ($localFile == 'css/display.css') {
165                 $hasMain = true;
166             }
167
168             $size = $data['size'];
169             $estSize = $blockSize * max(1, intval(ceil($size / $blockSize)));
170             $totalSize += $estSize;
171             if ($totalSize > $sizeLimit) {
172                 // TRANS: Client exception thrown when an uploaded theme is larger than the limit.
173                 // TRANS: %d is the number of bytes of the uncompressed theme.
174                 $msg = sprintf(_m('Uploaded theme is too large; must be less than %d byte uncompressed.',
175                                   'Uploaded theme is too large; must be less than %d bytes uncompressed.',
176                                   $sizeLimit),
177                                $sizeLimit);
178                 throw new ClientException($msg);
179             }
180
181             if ($outdir) {
182                 $this->extractFile($zip, $data['name'], "$outdir/$localFile");
183             }
184         }
185
186         if (!$hasMain) {
187             // TRANS: Server exception thrown when an uploaded theme is incomplete.
188             throw new ClientException(_('Invalid theme archive: ' .
189                                         "Missing file css/display.css"));
190         }
191     }
192
193     /**
194      * @fixme Probably most unrecognized files should just be skipped...
195      */
196     protected function skippable($filename, $ext)
197     {
198         $skip = array('txt', 'html', 'rtf', 'doc', 'docx', 'odt', 'xcf');
199         if (strtolower($filename) == 'readme') {
200             return true;
201         }
202         if (in_array(strtolower($ext), $skip)) {
203             return true;
204         }
205         if ($filename == '' || substr($filename, 0, 1) == '.') {
206             // Skip Unix-style hidden files
207             return true;
208         }
209         if ($filename == '__MACOSX') {
210             // Skip awful metadata files Mac OS X slips in for you.
211             // Thanks Apple!
212             return true;
213         }
214         return false;
215     }
216
217     protected function validateFile($filename, $ext)
218     {
219         $this->validateFileOrFolder($filename);
220         $this->validateExtension($filename, $ext);
221         // @fixme validate content
222     }
223
224     protected function validateFileOrFolder($name)
225     {
226         if (!preg_match('/^[a-z0-9_\.-]+$/i', $name)) {
227             common_log(LOG_ERR, "Bad theme filename: $name");
228             // TRANS: Server exception thrown when an uploaded theme has an incorrect file or folder name.
229             $msg = _("Theme contains invalid file or folder name. " .
230                      'Stick with ASCII letters, digits, underscore, and minus sign.');
231             throw new ClientException($msg);
232         }
233         if (preg_match('/\.(php|cgi|asp|aspx|js|vb)\w/i', $name)) {
234             common_log(LOG_ERR, "Unsafe theme filename: $name");
235             // TRANS: Server exception thrown when an uploaded theme contains files with unsafe file extensions.
236             $msg = _('Theme contains unsafe file extension names; may be unsafe.');
237             throw new ClientException($msg);
238         }
239         return true;
240     }
241
242     protected function validateExtension($base, $ext)
243     {
244         $allowed = array('css', // CSS may need validation
245                          'png', 'gif', 'jpg', 'jpeg',
246                          'svg', // SVG images/fonts may need validation
247                          'ttf', 'eot', 'woff');
248         if (!in_array(strtolower($ext), $allowed)) {
249             if ($ext == 'ini' && $base == 'theme') {
250                 // theme.ini exception
251                 return true;
252             }
253             // TRANS: Server exception thrown when an uploaded theme contains a file type that is not allowed.
254             // TRANS: %s is the file type that is not allowed.
255             $msg = sprintf(_('Theme contains file of type ".%s", which is not allowed.'),
256                            $ext);
257             throw new ClientException($msg);
258         }
259         return true;
260     }
261
262     /**
263      * @return ZipArchive
264      */
265     protected function openArchive()
266     {
267         $zip = new ZipArchive;
268         $ok = $zip->open($this->sourceFile);
269         if ($ok !== true) {
270             common_log(LOG_ERR, "Error opening theme zip archive: " .
271                                 "{$this->sourceFile} code: {$ok}");
272             // TRANS: Server exception thrown when an uploaded compressed theme cannot be opened.
273             throw new Exception(_('Error opening theme archive.'));
274         }
275         return $zip;
276     }
277
278     /**
279      * @param ZipArchive $zip
280      * @param string $from original path inside ZIP archive
281      * @param string $to final destination path in filesystem
282      */
283     protected function extractFile($zip, $from, $to)
284     {
285         $dir = dirname($to);
286         if (!file_exists($dir)) {
287             $this->quiet();
288             $ok = mkdir($dir, 0755, true);
289             $this->loud();
290             if (!$ok) {
291                 common_log(LOG_ERR, "Failed to mkdir $dir while uploading theme");
292                 // TRANS: Server exception thrown when an uploaded theme cannot be saved during extraction.
293                 throw new ServerException(_('Failed saving theme.'));
294             }
295         } else if (!is_dir($dir)) {
296             common_log(LOG_ERR, "Output directory $dir not a directory while uploading theme");
297             // TRANS: Server exception thrown when an uploaded theme cannot be saved during extraction.
298             throw new ServerException(_('Failed saving theme.'));
299         }
300
301         // ZipArchive::extractTo would be easier, but won't let us alter
302         // the directory structure.
303         $in = $zip->getStream($from);
304         if (!$in) {
305             common_log(LOG_ERR, "Couldn't open archived file $from while uploading theme");
306             // TRANS: Server exception thrown when an uploaded theme cannot be saved during extraction.
307             throw new ServerException(_('Failed saving theme.'));
308         }
309         $this->quiet();
310         $out = fopen($to, "wb");
311         $this->loud();
312         if (!$out) {
313             common_log(LOG_ERR, "Couldn't open output file $to while uploading theme");
314             // TRANS: Server exception thrown when an uploaded theme cannot be saved during extraction.
315             throw new ServerException(_('Failed saving theme.'));
316         }
317         while (!feof($in)) {
318             $buffer = fread($in, 65536);
319             fwrite($out, $buffer);
320         }
321         fclose($in);
322         fclose($out);
323     }
324
325     private function quiet()
326     {
327         $this->prevErrorReporting = error_reporting();
328         error_reporting($this->prevErrorReporting & ~E_WARNING);
329     }
330
331     private function loud()
332     {
333         error_reporting($this->prevErrorReporting);
334     }
335
336     private function recursiveRmdir($dir)
337     {
338         $list = dir($dir);
339         while (($file = $list->read()) !== false) {
340             if ($file == '.' || $file == '..') {
341                 continue;
342             }
343             $full = "$dir/$file";
344             if (is_dir($full)) {
345                 $this->recursiveRmdir($full);
346             } else {
347                 unlink($full);
348             }
349         }
350         $list->close();
351         rmdir($dir);
352     }
353 }