3 * StatusNet, the distributed open-source microblogging tool
5 * Utilities for theme files and paths
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.
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.
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/>.
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/
30 if (!defined('STATUSNET') && !defined('LACONICA')) {
35 * Encapsulation of the validation-and-save process when dealing with
36 * a user-uploaded StatusNet theme archive...
38 * @todo extract theme metadata from css/display.css
39 * @todo allow saving multiple themes
43 protected $sourceFile;
45 private $prevErrorReporting;
47 public function __construct($filename)
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.'));
53 $this->sourceFile = $filename;
56 public static function fromUpload($name)
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.'));
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.'));
66 return new ThemeUploader($_FILES[$name]['tmp_name']);
70 * @param string $destDir
71 * @throws Exception on bogus files
73 public function extract($destDir)
75 $zip = $this->openArchive();
77 // First pass: validate but don't save anything to disk.
78 // Any errors will trip an exception.
79 $this->traverseArchive($zip);
81 // Second pass: now that we know we're good, actually extract!
82 $tmpDir = $destDir . '.tmp' . getmypid();
83 $this->traverseArchive($zip, $tmpDir);
87 if (file_exists($destDir)) {
88 $killDir = $tmpDir . '.old';
90 $ok = rename($destDir, $killDir);
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.'));
102 $ok = rename($tmpDir, $destDir);
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.'));
111 $this->recursiveRmdir($killDir);
118 protected function traverseArchive($zip, $outdir=false)
120 $sizeLimit = 2 * 1024 * 1024; // 2 megabyte space limit?
121 $blockSize = 4096; // estimated; any entry probably takes this much space
125 $commonBaseDir = false;
127 for ($i = 0; $i < $zip->numFiles; $i++) {
128 $data = $zip->statIndex($i);
129 $name = str_replace('\\', '/', $data['name']);
131 if (substr($name, -1) == '/') {
132 // A raw directory... skip!
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
142 $this->validateFile($path['filename'], $path['extension']);
145 // Check the directory structure...
146 $dirs = explode('/', $path['dirname']);
147 $baseDir = array_shift($dirs);
148 if ($commonBaseDir === false) {
149 $commonBaseDir = $baseDir;
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.'));
157 foreach ($dirs as $dir) {
158 $this->validateFileOrFolder($dir);
162 $fullPath[] = $path['basename'];
163 $localFile = implode('/', $fullPath);
164 if ($localFile == 'css/display.css') {
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.',
178 throw new ClientException($msg);
182 $this->extractFile($zip, $data['name'], "$outdir/$localFile");
187 // TRANS: Server exception thrown when an uploaded theme is incomplete.
188 throw new ClientException(_('Invalid theme archive: ' .
189 "Missing file css/display.css"));
194 * @fixme Probably most unrecognized files should just be skipped...
196 protected function skippable($filename, $ext)
198 $skip = array('txt', 'html', 'rtf', 'doc', 'docx', 'odt', 'xcf');
199 if (strtolower($filename) == 'readme') {
202 if (in_array(strtolower($ext), $skip)) {
205 if ($filename == '' || substr($filename, 0, 1) == '.') {
206 // Skip Unix-style hidden files
209 if ($filename == '__MACOSX') {
210 // Skip awful metadata files Mac OS X slips in for you.
217 protected function validateFile($filename, $ext)
219 $this->validateFileOrFolder($filename);
220 $this->validateExtension($filename, $ext);
221 // @fixme validate content
224 protected function validateFileOrFolder($name)
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);
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);
242 protected function validateExtension($base, $ext)
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
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.'),
257 throw new ClientException($msg);
265 protected function openArchive()
267 $zip = new ZipArchive;
268 $ok = $zip->open($this->sourceFile);
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.'));
279 * @param ZipArchive $zip
280 * @param string $from original path inside ZIP archive
281 * @param string $to final destination path in filesystem
283 protected function extractFile($zip, $from, $to)
286 if (!file_exists($dir)) {
288 $ok = mkdir($dir, 0755, true);
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.'));
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.'));
301 // ZipArchive::extractTo would be easier, but won't let us alter
302 // the directory structure.
303 $in = $zip->getStream($from);
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.'));
310 $out = fopen($to, "wb");
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.'));
318 $buffer = fread($in, 65536);
319 fwrite($out, $buffer);
325 private function quiet()
327 $this->prevErrorReporting = error_reporting();
328 error_reporting($this->prevErrorReporting & ~E_WARNING);
331 private function loud()
333 error_reporting($this->prevErrorReporting);
336 private function recursiveRmdir($dir)
339 while (($file = $list->read()) !== false) {
340 if ($file == '.' || $file == '..') {
343 $full = "$dir/$file";
345 $this->recursiveRmdir($full);