2010-04-21 16:16:42 +01:00
|
|
|
<?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'])) {
|
2010-06-21 19:15:26 +01:00
|
|
|
throw new ServerException(_("The theme file is missing or the upload failed."));
|
2010-04-21 16:16:42 +01:00
|
|
|
}
|
|
|
|
if ($_FILES[$name]['error'] != UPLOAD_ERR_OK) {
|
2010-06-21 19:15:26 +01:00
|
|
|
throw new ServerException(_("The theme file is missing or the upload failed."));
|
2010-04-21 16:16:42 +01:00
|
|
|
}
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2010-09-02 20:11:45 +01:00
|
|
|
// Is this a safe or skippable file?
|
2010-04-21 16:16:42 +01:00
|
|
|
$path = pathinfo($name);
|
2010-09-02 20:11:45 +01:00
|
|
|
if ($this->skippable($path['filename'], $path['extension'])) {
|
|
|
|
// Documentation and such... booooring
|
|
|
|
continue;
|
|
|
|
} else {
|
|
|
|
$this->validateFile($path['filename'], $path['extension']);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check the directory structure...
|
2010-04-21 16:16:42 +01:00
|
|
|
$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);
|
|
|
|
}
|
|
|
|
|
|
|
|
$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"));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2010-09-02 20:11:45 +01:00
|
|
|
/**
|
|
|
|
* @fixme Probably most unrecognized files should just be skipped...
|
|
|
|
*/
|
2010-04-21 16:16:42 +01:00
|
|
|
protected function skippable($filename, $ext)
|
|
|
|
{
|
2010-09-02 20:11:45 +01:00
|
|
|
$skip = array('txt', 'html', 'rtf', 'doc', 'docx', 'odt', 'xcf');
|
2010-04-21 16:16:42 +01:00
|
|
|
if (strtolower($filename) == 'readme') {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
if (in_array(strtolower($ext), $skip)) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
protected function validateFile($filename, $ext)
|
|
|
|
{
|
|
|
|
$this->validateFileOrFolder($filename);
|
2010-09-02 22:11:52 +01:00
|
|
|
$this->validateExtension($filename, $ext);
|
2010-04-21 16:16:42 +01:00
|
|
|
// @fixme validate content
|
|
|
|
}
|
|
|
|
|
|
|
|
protected function validateFileOrFolder($name)
|
|
|
|
{
|
2010-09-02 20:11:45 +01:00
|
|
|
if (!preg_match('/^[a-z0-9_\.-]+$/i', $name)) {
|
2010-04-21 16:16:42 +01:00
|
|
|
$msg = _("Theme contains invalid file or folder name. " .
|
|
|
|
"Stick with ASCII letters, digits, underscore, and minus sign.");
|
|
|
|
throw new ClientException($msg);
|
|
|
|
}
|
2010-09-02 20:11:45 +01:00
|
|
|
if (preg_match('/\.(php|cgi|asp|aspx|js|vb)\w/i', $name)) {
|
|
|
|
$msg = _("Theme contains unsafe file extension names; may be unsafe.");
|
|
|
|
throw new ClientException($msg);
|
|
|
|
}
|
2010-04-21 16:16:42 +01:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2010-09-02 22:11:52 +01:00
|
|
|
protected function validateExtension($base, $ext)
|
2010-04-21 16:16:42 +01:00
|
|
|
{
|
2010-09-02 20:11:45 +01:00
|
|
|
$allowed = array('css', // CSS may need validation
|
|
|
|
'png', 'gif', 'jpg', 'jpeg',
|
|
|
|
'svg', // SVG images/fonts may need validation
|
|
|
|
'ttf', 'eot', 'woff');
|
2010-04-21 16:16:42 +01:00
|
|
|
if (!in_array(strtolower($ext), $allowed)) {
|
2010-09-02 22:11:52 +01:00
|
|
|
if ($ext == 'ini' && $base == 'theme') {
|
|
|
|
// theme.ini exception
|
|
|
|
return true;
|
|
|
|
}
|
2010-04-21 16:16:42 +01:00
|
|
|
$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);
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|