Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ FROM php:8.2-apache

RUN apt-get update \
# Needed for the imagick php extension install
&& apt-get install -y --no-install-recommends libmagickwand-dev libpq-dev mariadb-client \
&& apt-get install -y --no-install-recommends libmagickwand-dev libpq-dev mariadb-client librsvg2-bin \
&& echo "" | pecl install imagick redis \
&& docker-php-ext-enable imagick \
&& docker-php-ext-enable redis \
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/WikiLogoController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class WikiLogoController extends Controller {
*/
public function update(Request $request) {
$request->validate([
'logo' => 'required|mimes:png',
'logo' => 'required|file|mimes:png,svg|max:2048',
]);

$wiki = $request->attributes->get('wiki');
Expand Down
53 changes: 46 additions & 7 deletions app/Jobs/SetWikiLogo.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

namespace App\Jobs;

use App\Services\SvgLogo;
use App\Wiki;
use App\WikiSetting;
use Illuminate\Database\QueryException;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Intervention\Image\Facades\Image;

/**
Expand Down Expand Up @@ -68,30 +70,58 @@ public function handle(): void {
// Get the directory for storing this site's logos
$logosDir = Wiki::getLogosDirectory($wiki->id);

$file = new File($this->logoPath);
$isSvg = $file->getMimeType() === 'image/svg+xml';
$svg = null;
if ($isSvg) {
$svgLogo = new SvgLogo();
$svg = $svgLogo->sanitize(file_get_contents($this->logoPath));
$image = Image::canvas(135, 135)->insert(Image::make($svgLogo->rasterize($svg)), 'center');
} else {
if ($file->getMimeType() !== 'image/png') {
throw ValidationException::withMessages(['logo' => 'The logo must be a PNG or SVG image.']);
}
$image = Image::make($this->logoPath)->resize(135, 135);
}

// Upload the local image to the cloud storage
$storage->putFileAs($logosDir, new File($this->logoPath), 'raw.png', ['visibility' => 'public']);
if ($svg !== null) {
$stored = $storage->put($logosDir . '/logo.svg', $svg, [
'visibility' => 'public',
'ContentType' => 'image/svg+xml',
]);
} else {
$stored = $storage->putFileAs($logosDir, $file, 'raw.png', ['visibility' => 'public']);
}
if (!$stored) {
throw new \RuntimeException('Failed to store the wiki logo.');
}

// Store a conversion for the actual site logo
$reducedPath = $logosDir . '/135.png';
if ($storage->exists($reducedPath)) {
$storage->delete($reducedPath);
}
$storage->writeStream(
if (!$storage->writeStream(
$reducedPath,
Image::make($this->logoPath)->resize(135, 135)->stream()->detach(),
$image->stream('png')->detach(),
['visibility' => 'public'],
);
)) {
throw new \RuntimeException('Failed to store the PNG wiki logo.');
}

// Store a conversion for the favicon
$faviconPath = $logosDir . '/64.ico';
if ($storage->exists($faviconPath)) {
$storage->delete($faviconPath);
}
$storage->writeStream(
if (!$storage->writeStream(
$faviconPath,
Image::make($this->logoPath)->resize(64, 64)->stream()->detach(),
($isSvg ? $image : Image::make($this->logoPath))->resize(64, 64)->stream('png')->detach(),
['visibility' => 'public'],
);
)) {
throw new \RuntimeException('Failed to store the wiki favicon.');
}

// Get the urls
$logoUrl = $storage->url($reducedPath);
Expand All @@ -111,5 +141,14 @@ public function handle(): void {
['wiki_id' => $wiki->id, 'name' => WikiSetting::wgFavicon],
['value' => $faviconUrl]
);

if ($svg !== null) {
WikiSetting::updateOrCreate(
['wiki_id' => $wiki->id, 'name' => WikiSetting::wwLogoSvg],
['value' => $storage->url($logosDir . '/logo.svg') . '?u=' . time()]
);
} else {
$wiki->settings()->where('name', WikiSetting::wwLogoSvg)->delete();
}
}
}
118 changes: 118 additions & 0 deletions app/Services/SvgLogo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

namespace App\Services;

use DOMDocument;
use DOMElement;
use enshrined\svgSanitize\Sanitizer;
use Illuminate\Validation\ValidationException;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Wikimedia\CSS\Objects\Token;
use Wikimedia\CSS\Parser\Parser;

class SvgLogo {
public function sanitize(string $contents): string {
$this->parseSvg($contents);
$sanitizer = new Sanitizer();
$sanitizer->removeRemoteReferences(true);
$sanitized = $sanitizer->sanitize($contents);
if (!$sanitized) {
throw ValidationException::withMessages(['logo' => 'The SVG logo must be a valid SVG document.']);
}

$document = $this->parseSvg($sanitized);

foreach ($document->getElementsByTagName('*') as $element) {
if (!$element instanceof DOMElement) {
continue;
}
if ($element->localName === 'style') {
$this->checkCss($element->textContent);
}
foreach ($element->attributes as $attribute) {
if ($attribute->localName === 'href' && $attribute->value !== '' &&
!str_starts_with($attribute->value, '#') &&
!preg_match('~^data:image/(png|jpeg|gif);base64,~i', $attribute->value)) {
throw ValidationException::withMessages(['logo' => 'SVG logos must not reference external resources.']);
}
if (in_array($attribute->localName, [
'style', 'font', 'clip-path', 'fill', 'filter', 'marker',
'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke', 'cursor',
], true)) {
$this->checkCss($attribute->value);
}
}
}

return $sanitized;
}

private function parseSvg(string $contents): DOMDocument {
$document = new DOMDocument();
$loaded = $contents !== '' && $document->loadXML($contents, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
$root = $document->documentElement;
if (!$loaded || $root === null || $root->localName !== 'svg' || $root->namespaceURI !== 'http://www.w3.org/2000/svg') {
throw ValidationException::withMessages(['logo' => 'The SVG logo must be a valid SVG document.']);
}

return $document;
}

private function checkCss(string $css): void {
// Like MediaWiki's SVGCSSChecker, inspect parsed tokens, not URL-matching regexes.
if (preg_match('/[\x00-\x08\x0b\x0e-\x1f\x7f]/', $css)) {
throw ValidationException::withMessages(['logo' => 'The SVG logo contains invalid CSS.']);
}
$parser = Parser::newFromString($css);
$tokens = $parser->parseComponentValueList()->toTokenArray();
if ($parser->getParseErrors()) {
throw ValidationException::withMessages(['logo' => 'The SVG logo contains invalid CSS.']);
}
foreach ($tokens as $index => $token) {
$type = $token->type();
$value = strtolower((string) $token->value());
if ($type === Token::T_URL && str_starts_with($value, '#')) {
continue;
}
if ($type === Token::T_FUNCTION && $value === 'url') {
$next = $index + 1;
while (isset($tokens[$next]) && $tokens[$next]->type() === Token::T_WHITESPACE) {
$next++;
}
if (isset($tokens[$next]) && $tokens[$next]->type() === Token::T_STRING &&
str_starts_with($tokens[$next]->value(), '#')) {
continue;
}
} elseif (!in_array($type, [Token::T_URL, Token::T_BAD_URL], true) &&
!($type === Token::T_AT_KEYWORD && in_array($value, ['import', 'charset'], true)) &&
!($type === Token::T_FUNCTION && in_array($value, [
'src', 'image', 'image-set', '-webkit-image-set', 'expression',
], true))) {
continue;
}
throw ValidationException::withMessages(['logo' => 'SVG logos must not reference external resources.']);
}
}

public function rasterize(string $sanitized): string {
// Standard input leaves librsvg without a base URL, disabling external file access.
$process = new Process([
'rsvg-convert', '--format=png', '--width=135', '--height=135', '--keep-aspect-ratio',
]);
$process->setInput($sanitized);
$process->setTimeout(10);
try {
$process->mustRun();
} catch (ProcessFailedException $e) {
if ($process->getExitCode() !== 1) {
throw $e;
}
throw ValidationException::withMessages([
'logo' => 'The SVG logo could not be rendered. Please upload a self-contained SVG with a valid viewBox or dimensions.',
]);
}

return $process->getOutput();
}
}
2 changes: 2 additions & 0 deletions app/WikiSetting.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class WikiSetting extends Model {

public const wgLogo = 'wgLogo';

public const wwLogoSvg = 'wwLogoSvg';

public const wgFavicon = 'wgFavicon';

public const wgOAuth2PrivateKey = 'wgOAuth2PrivateKey';
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"type": "project",
"require": {
"absszero/laravel-stackdriver-error-reporting": "^1.9",
"enshrined/svg-sanitize": "^1.0",
"firebase/php-jwt": "^7.0",
"google/recaptcha": "^1.2",
"guzzlehttp/guzzle": "^7.13",
Expand All @@ -26,7 +27,8 @@
"maclof/kubernetes-client": "^0.31.0",
"mxl/laravel-job": "^1.5",
"php-http/guzzle7-adapter": "^1.0",
"predis/predis": "^3.4"
"predis/predis": "^3.4",
"wikimedia/css-sanitizer": "^6.2.1"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3",
Expand Down
Loading
Loading