diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/action.php | 10 | ||||
-rw-r--r-- | lib/adminpanelaction.php | 3 | ||||
-rw-r--r-- | lib/apiaction.php | 42 | ||||
-rw-r--r-- | lib/default.php | 10 | ||||
-rw-r--r-- | lib/installer.php | 9 | ||||
-rw-r--r-- | lib/router.php | 6 | ||||
-rw-r--r-- | lib/theme.php | 82 | ||||
-rw-r--r-- | lib/themeuploader.php | 311 |
8 files changed, 430 insertions, 43 deletions
diff --git a/lib/action.php b/lib/action.php index 98e5ec2c9..2b3b707c5 100644 --- a/lib/action.php +++ b/lib/action.php @@ -235,6 +235,16 @@ class Action extends HTMLOutputter // lawsuit Event::handle('EndShowDesign', array($this)); } Event::handle('EndShowStyles', array($this)); + + if (common_config('custom_css', 'enabled')) { + $css = common_config('custom_css', 'css'); + if (Event::handle('StartShowCustomCss', array($this, &$css))) { + if (trim($css) != '') { + $this->style($css); + } + Event::handle('EndShowCustomCss', array($this)); + } + } } } diff --git a/lib/adminpanelaction.php b/lib/adminpanelaction.php index 6c9947608..9e0b2d041 100644 --- a/lib/adminpanelaction.php +++ b/lib/adminpanelaction.php @@ -284,9 +284,10 @@ class AdminPanelAction extends Action $this->clientError(_("Unable to delete design setting.")); return null; } + return $result; } - return $result; + return null; } function canAdmin($name) diff --git a/lib/apiaction.php b/lib/apiaction.php index 04028aef0..a9fad16f8 100644 --- a/lib/apiaction.php +++ b/lib/apiaction.php @@ -297,6 +297,10 @@ class ApiAction extends Action } } + // StatusNet-specific + + $twitter_user['statusnet:profile_url'] = $profile->profileurl; + return $twitter_user; } @@ -398,6 +402,10 @@ class ApiAction extends Action $twitter_status['user'] = $twitter_user; } + // StatusNet-specific + + $twitter_status['statusnet:html'] = $notice->rendered; + return $twitter_status; } @@ -565,9 +573,13 @@ class ApiAction extends Action } } - function showTwitterXmlStatus($twitter_status, $tag='status') + function showTwitterXmlStatus($twitter_status, $tag='status', $namespaces=false) { - $this->elementStart($tag); + $attrs = array(); + if ($namespaces) { + $attrs['xmlns:statusnet'] = 'http://status.net/schema/api/1/'; + } + $this->elementStart($tag, $attrs); foreach($twitter_status as $element => $value) { switch ($element) { case 'user': @@ -601,9 +613,13 @@ class ApiAction extends Action $this->elementEnd('group'); } - function showTwitterXmlUser($twitter_user, $role='user') + function showTwitterXmlUser($twitter_user, $role='user', $namespaces=false) { - $this->elementStart($role); + $attrs = array(); + if ($namespaces) { + $attrs['xmlns:statusnet'] = 'http://status.net/schema/api/1/'; + } + $this->elementStart($role, $attrs); foreach($twitter_user as $element => $value) { if ($element == 'status') { $this->showTwitterXmlStatus($twitter_user['status']); @@ -685,7 +701,7 @@ class ApiAction extends Action { $this->initDocument('xml'); $twitter_status = $this->twitterStatusArray($notice); - $this->showTwitterXmlStatus($twitter_status); + $this->showTwitterXmlStatus($twitter_status, 'status', true); $this->endDocument('xml'); } @@ -701,7 +717,8 @@ class ApiAction extends Action { $this->initDocument('xml'); - $this->elementStart('statuses', array('type' => 'array')); + $this->elementStart('statuses', array('type' => 'array', + 'xmlns:statusnet' => 'http://status.net/schema/api/1/')); if (is_array($notice)) { foreach ($notice as $n) { @@ -868,9 +885,13 @@ class ApiAction extends Action $this->elementEnd('entry'); } - function showXmlDirectMessage($dm) + function showXmlDirectMessage($dm, $namespaces=false) { - $this->elementStart('direct_message'); + $attrs = array(); + if ($namespaces) { + $attrs['xmlns:statusnet'] = 'http://status.net/schema/api/1/'; + } + $this->elementStart('direct_message', $attrs); foreach($dm as $element => $value) { switch ($element) { case 'sender': @@ -947,7 +968,7 @@ class ApiAction extends Action { $this->initDocument('xml'); $dmsg = $this->directMessageArray($message); - $this->showXmlDirectMessage($dmsg); + $this->showXmlDirectMessage($dmsg, true); $this->endDocument('xml'); } @@ -1064,7 +1085,8 @@ class ApiAction extends Action { $this->initDocument('xml'); - $this->elementStart('users', array('type' => 'array')); + $this->elementStart('users', array('type' => 'array', + 'xmlns:statusnet' => 'http://status.net/schema/api/1/')); if (is_array($user)) { foreach ($user as $u) { diff --git a/lib/default.php b/lib/default.php index 754cf5728..e0081f316 100644 --- a/lib/default.php +++ b/lib/default.php @@ -141,10 +141,17 @@ $default = 'dir' => null, 'path'=> null, 'ssl' => null), + 'theme_upload' => + array('enabled' => extension_loaded('zip')), 'javascript' => array('server' => null, 'path'=> null, 'ssl' => null), + 'local' => // To override path/server for themes in 'local' dir (not currently applied to local plugins) + array('server' => null, + 'dir' => null, + 'path' => null, + 'ssl' => null), 'throttle' => array('enabled' => false, // whether to throttle edits; false by default 'count' => 20, // number of allowed messages in timespan @@ -260,6 +267,9 @@ $default = 'linkcolor' => null, 'backgroundimage' => null, 'disposition' => null), + 'custom_css' => + array('enabled' => true, + 'css' => ''), 'notice' => array('contentlimit' => null), 'message' => diff --git a/lib/installer.php b/lib/installer.php index 1cad2fd20..56b9b6f4a 100644 --- a/lib/installer.php +++ b/lib/installer.php @@ -82,9 +82,12 @@ abstract class Installer { $pass = true; - if (file_exists(INSTALLDIR.'/config.php')) { - $this->warning('Config file "config.php" already exists.'); - $pass = false; + $config = INSTALLDIR.'/config.php'; + if (file_exists($config)) { + if (!is_writable($config) || filesize($config) > 0) { + $this->warning('Config file "config.php" already exists.'); + $pass = false; + } } if (version_compare(PHP_VERSION, '5.2.3', '<')) { diff --git a/lib/router.php b/lib/router.php index ef5fece13..6cbae8247 100644 --- a/lib/router.php +++ b/lib/router.php @@ -540,7 +540,7 @@ class Router $m->connect('api/favorites/:id.:format', array('action' => 'ApiTimelineFavorites', 'id' => '[a-zA-Z0-9]+', - 'format' => '(xmljson|rss|atom)')); + 'format' => '(xml|json|rss|atom)')); $m->connect('api/favorites/create/:id.:format', array('action' => 'ApiFavoriteCreate', @@ -597,7 +597,7 @@ class Router $m->connect('api/statusnet/groups/timeline/:id.:format', array('action' => 'ApiTimelineGroup', 'id' => '[a-zA-Z0-9]+', - 'format' => '(xmljson|rss|atom)')); + 'format' => '(xml|json|rss|atom)')); $m->connect('api/statusnet/groups/show.:format', array('action' => 'ApiGroupShow', @@ -664,7 +664,7 @@ class Router // Tags $m->connect('api/statusnet/tags/timeline/:tag.:format', array('action' => 'ApiTimelineTag', - 'format' => '(xmljson|rss|atom)')); + 'format' => '(xml|json|rss|atom)')); // media related $m->connect( diff --git a/lib/theme.php b/lib/theme.php index 0be8c3b9d..a9d0cbc84 100644 --- a/lib/theme.php +++ b/lib/theme.php @@ -38,6 +38,9 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { * Themes are directories with some expected sub-directories and files * in them. They're found in either local/theme (for locally-installed themes) * or theme/ subdir of installation dir. + * + * Note that the 'local' directory can be overridden as $config['local']['path'] + * and $config['local']['dir'] etc. * * This used to be a couple of functions, but for various reasons it's nice * to have a class instead. @@ -76,7 +79,7 @@ class Theme if (file_exists($fulldir) && is_dir($fulldir)) { $this->dir = $fulldir; - $this->path = common_path('local/theme/'.$name.'/'); + $this->path = $this->relativeThemePath('local', 'local', 'theme/' . $name); return; } @@ -89,42 +92,63 @@ class Theme if (file_exists($fulldir) && is_dir($fulldir)) { $this->dir = $fulldir; + $this->path = $this->relativeThemePath('theme', 'theme', $name); + } + } - $path = common_config('theme', 'path'); + /** + * Build a full URL to the given theme's base directory, possibly + * using an offsite theme server path. + * + * @param string $group configuration section name to pull paths from + * @param string $fallbackSubdir default subdirectory under INSTALLDIR + * @param string $name theme name + * + * @return string URL + * + * @todo consolidate code with that for other customizable paths + */ - if (empty($path)) { - $path = common_config('site', 'path') . '/theme/'; - } + protected function relativeThemePath($group, $fallbackSubdir, $name) + { + $path = common_config($group, 'path'); - if ($path[strlen($path)-1] != '/') { - $path .= '/'; + if (empty($path)) { + $path = common_config('site', 'path') . '/'; + if ($fallbackSubdir) { + $path .= $fallbackSubdir . '/'; } + } - if ($path[0] != '/') { - $path = '/'.$path; - } + if ($path[strlen($path)-1] != '/') { + $path .= '/'; + } - $server = common_config('theme', 'server'); + if ($path[0] != '/') { + $path = '/'.$path; + } - if (empty($server)) { - $server = common_config('site', 'server'); - } + $server = common_config($group, 'server'); - $ssl = common_config('theme', 'ssl'); + if (empty($server)) { + $server = common_config('site', 'server'); + } - if (is_null($ssl)) { // null -> guess - if (common_config('site', 'ssl') == 'always' && - !common_config('theme', 'server')) { - $ssl = true; - } else { - $ssl = false; - } + $ssl = common_config($group, 'ssl'); + + if (is_null($ssl)) { // null -> guess + if (common_config('site', 'ssl') == 'always' && + !common_config($group, 'server')) { + $ssl = true; + } else { + $ssl = false; } + } - $protocol = ($ssl) ? 'https' : 'http'; + $protocol = ($ssl) ? 'https' : 'http'; - $this->path = $protocol . '://'.$server.$path.$name; - } + $path = $protocol . '://'.$server.$path.$name; + return $path; } /** @@ -236,7 +260,13 @@ class Theme protected static function localRoot() { - return INSTALLDIR.'/local/theme'; + $basedir = common_config('local', 'dir'); + + if (empty($basedir)) { + $basedir = INSTALLDIR . '/local'; + } + + return $basedir . '/theme'; } /** diff --git a/lib/themeuploader.php b/lib/themeuploader.php new file mode 100644 index 000000000..18ef8c4d1 --- /dev/null +++ b/lib/themeuploader.php @@ -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); + } + +} |