We are no longer offering accounts on this server. Consider https://gitlab.freedesktop.org/ as a place to host projects.

Commit 09208f8d authored by Brion Vibber's avatar Brion Vibber

Basic custom CSS and theme uploading features. 'local' subdir can now be...

Basic custom CSS and theme uploading features. 'local' subdir can now be customized to a distinct directory and URL path to make it easier to separate custom themes for a multi-site farm running a common code base.

Currently only one custom theme may be uploaded per site, saved with the name 'custom' and stored into the local/themes subdirectory.
Administrators can upload a .ZIP archive containing a theme through the design admin panel; its contents are validated to ensure that only legit files are saved, and a 5M size quota is enforced.
Theme upload requires the zip extension for PHP; if not present, theme uploading is disabled by default.

Uploading and the custom CSS can be controlled via $config['theme_upload']['enabled'] and $config['custom_css']['enabled'].

Configurable directory/path/server for 'local' subdirectory (currently only as used for themes; local plugins not yet switched over)

Can set $config['local']['dir'] etc; not currently exposed in the admin panels.

Per-site directories on a separate themes server could be set up such as:
  $config['local']['dir'] = '/path/to/themes/local/' . $_nickname;
  $config['local']['server'] = 'themes.example.com';
  $config['local']['path'] = '/local/' . $_nickname;
  $config['local']['ssl'] = 'never';
parent 8e33cdd3
......@@ -125,9 +125,19 @@ class DesignadminpanelAction extends AdminPanelAction
return;
}
// check for an image upload
// check for file uploads
$bgimage = $this->saveBackgroundImage();
$customTheme = $this->saveCustomTheme();
$oldtheme = common_config('site', 'theme');
if ($customTheme) {
// This feels pretty hacky :D
$this->args['theme'] = $customTheme;
$themeChanged = true;
} else {
$themeChanged = ($this->trimmed('theme') != $oldtheme);
}
static $settings = array('theme', 'logo');
......@@ -139,15 +149,13 @@ class DesignadminpanelAction extends AdminPanelAction
$this->validate($values);
$oldtheme = common_config('site', 'theme');
$config = new Config();
$config->query('BEGIN');
// Only update colors if the theme has not changed.
if ($oldtheme == $values['theme']) {
if (!$themeChanged) {
$bgcolor = new WebColor($this->trimmed('design_background'));
$ccolor = new WebColor($this->trimmed('design_content'));
......@@ -189,6 +197,13 @@ class DesignadminpanelAction extends AdminPanelAction
Config::save('design', 'backgroundimage', $bgimage);
}
if (common_config('custom_css', 'enabled')) {
$css = $this->arg('css');
if ($css != common_config('custom_css', 'css')) {
Config::save('custom_css', 'css', $css);
}
}
$config->query('COMMIT');
}
......@@ -262,6 +277,33 @@ class DesignadminpanelAction extends AdminPanelAction
}
}
/**
* Save the custom theme if the user uploaded one.
*
* @return mixed custom theme name, if succesful, or null if no theme upload.
* @throws ClientException for invalid theme archives
* @throws ServerException if trouble saving the theme files
*/
function saveCustomTheme()
{
if (common_config('theme_upload', 'enabled') &&
$_FILES['design_upload_theme']['error'] == UPLOAD_ERR_OK) {
$upload = ThemeUploader::fromUpload('design_upload_theme');
$basedir = common_config('local', 'dir');
if (empty($basedir)) {
$basedir = INSTALLDIR . '/local';
}
$name = 'custom'; // @todo allow multiples, custom naming?
$outdir = $basedir . '/theme/' . $name;
$upload->extract($outdir);
return $name;
} else {
return null;
}
}
/**
* Attempt to validate setting values
*
......@@ -374,6 +416,7 @@ class DesignAdminPanelForm extends AdminForm
$this->showTheme();
$this->showBackground();
$this->showColors();
$this->showAdvanced();
}
function showLogo()
......@@ -418,6 +461,16 @@ class DesignAdminPanelForm extends AdminForm
false, $this->value('theme'));
$this->unli();
if (common_config('theme_upload', 'enabled')) {
$this->li();
$this->out->element('label', array('for' => 'design_upload_theme'), _('Custom theme'));
$this->out->element('input', array('id' => 'design_upload_theme',
'name' => 'design_upload_theme',
'type' => 'file'));
$this->out->element('p', 'form_guide', _('You can upload a custom StatusNet theme as a .ZIP archive.'));
$this->unli();
}
$this->out->elementEnd('ul');
$this->out->elementEnd('fieldset');
......@@ -502,6 +555,8 @@ class DesignAdminPanelForm extends AdminForm
function showColors()
{
$design = $this->out->design;
$this->out->elementStart('fieldset', array('id' => 'settings_design_color'));
$this->out->element('legend', null, _('Change colours'));
......@@ -586,6 +641,27 @@ class DesignAdminPanelForm extends AdminForm
$this->out->elementEnd('ul');
}
function showAdvanced()
{
if (common_config('custom_css', 'enabled')) {
$this->out->elementStart('fieldset', array('id' => 'settings_design_advanced'));
$this->out->element('legend', null, _('Advanced'));
$this->out->elementStart('ul', 'form_data');
$this->li();
$this->out->element('label', array('for' => 'css'), _('Custom CSS'));
$this->out->element('textarea', array('name' => 'css',
'id' => 'css',
'cols' => '50',
'rows' => '10'),
strval(common_config('custom_css', 'css')));
$this->unli();
$this->out->elementEnd('fieldset');
$this->out->elementEnd('ul');
}
}
/**
* Action elements
*
......
......@@ -233,6 +233,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));
}
}
}
}
......
......@@ -283,9 +283,10 @@ class AdminPanelAction extends Action
$this->clientError(_("Unable to delete design setting."));
return null;
}
return $result;
}
return $result;
return null;
}
function canAdmin($name)
......
......@@ -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' =>
......
......@@ -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';
}
/**
......
<?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);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment