Skip to content

Commit 59db081

Browse files
committed
Add custom butterfly frontend configuration
1 parent f1ff84e commit 59db081

17 files changed

Lines changed: 893 additions & 5 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Open `.bfly` documents directly from Nextcloud Files.
1313
- Load and save documents through the Butterfly embed protocol.
1414
- ETag-based conflict detection when saving.
15+
- Admin configuration for a custom Butterfly editor domain.
16+
- Validated ZIP uploads for self-hosting Butterfly web builds in Nextcloud.

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,15 @@ the Butterfly iframe with `postMessage`, and writes bytes back when Butterfly
9090
emits `save` or `exit`. Saves include the loaded ETag, so an external change is
9191
reported instead of overwritten.
9292

93-
The editor iframe currently uses `https://preview.butterfly.linwood.dev/embed`.
93+
By default, the editor iframe uses `https://preview.butterfly.linwood.dev/embed`.
94+
An administrator can configure another Butterfly origin or upload a Butterfly
95+
web-build ZIP under **Administration settings → Additional settings**. Uploaded
96+
builds are stored in Nextcloud app data and served by this app. The archive is
97+
accepted only when it has an `index.html` next to exactly one `version.json`,
98+
with `package_name` set to `"butterfly"` and a string `build_number` of `"193"`
99+
or higher. Uploading a valid build activates it and clears the custom-domain
100+
override.
101+
94102
The Nextcloud file name is passed through Butterfly's visual-only `fileName`
95103
embed option. Butterfly provides the title and exit controls; exiting saves
96104
the document and returns to its directory in Nextcloud Files. Only messages

appinfo/info.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ Nextcloud and are only loaded into the editor while they are open.
2121
<repository type="git">https://github.com/LinwoodDev/ButterflyNextcloud.git</repository>
2222
<dependencies>
2323
<nextcloud min-version="31" max-version="34"/>
24+
<lib>zip</lib>
2425
</dependencies>
26+
<settings>
27+
<admin>OCA\Butterfly\Settings\Admin</admin>
28+
</settings>
2529
<navigations>
2630
<navigation>
2731
<id>butterfly</id>

lib/Controller/AdminController.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OCA\Butterfly\Controller;
6+
7+
use OCA\Butterfly\Service\BundleValidationException;
8+
use OCA\Butterfly\Service\EditorHostingService;
9+
use OCP\AppFramework\Controller;
10+
use OCP\AppFramework\Http;
11+
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
12+
use OCP\AppFramework\Http\Attribute\OpenAPI;
13+
use OCP\AppFramework\Http\DataResponse;
14+
use OCP\IRequest;
15+
16+
/** @psalm-suppress UnusedClass */
17+
final class AdminController extends Controller {
18+
public function __construct(
19+
string $appName,
20+
IRequest $request,
21+
private EditorHostingService $hostingService,
22+
) {
23+
parent::__construct($appName, $request);
24+
}
25+
26+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
27+
#[FrontpageRoute(verb: 'POST', url: '/admin/domain')]
28+
public function saveDomain(string $domain = ''): DataResponse {
29+
try {
30+
$domain = $this->hostingService->setCustomDomain($domain);
31+
} catch (\InvalidArgumentException $exception) {
32+
return new DataResponse(['message' => $exception->getMessage()], Http::STATUS_BAD_REQUEST);
33+
}
34+
35+
return new DataResponse([
36+
'domain' => $domain,
37+
'embedUrl' => $this->hostingService->getEmbedUrl(),
38+
]);
39+
}
40+
41+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
42+
#[FrontpageRoute(verb: 'POST', url: '/admin/bundle')]
43+
public function uploadBundle(): DataResponse {
44+
$upload = $this->request->getUploadedFile('bundle');
45+
$error = $upload['error'] ?? UPLOAD_ERR_NO_FILE;
46+
$tmpName = $upload['tmp_name'] ?? null;
47+
if ($error !== UPLOAD_ERR_OK || !is_string($tmpName) || !is_file($tmpName)) {
48+
return new DataResponse(['message' => 'Select a ZIP file to upload.'], Http::STATUS_BAD_REQUEST);
49+
}
50+
51+
try {
52+
$version = $this->hostingService->deploy($tmpName);
53+
} catch (BundleValidationException $exception) {
54+
return new DataResponse(['message' => $exception->getMessage()], Http::STATUS_BAD_REQUEST);
55+
} catch (\Throwable) {
56+
return new DataResponse(['message' => 'The editor bundle could not be installed.'], Http::STATUS_INTERNAL_SERVER_ERROR);
57+
}
58+
59+
return new DataResponse([
60+
'version' => $version,
61+
'embedUrl' => $this->hostingService->getEmbedUrl(),
62+
]);
63+
}
64+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OCA\Butterfly\Controller;
6+
7+
use OCA\Butterfly\Service\EditorHostingService;
8+
use OCP\AppFramework\Controller;
9+
use OCP\AppFramework\Http;
10+
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
11+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
12+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
13+
use OCP\AppFramework\Http\Attribute\OpenAPI;
14+
use OCP\AppFramework\Http\ContentSecurityPolicy;
15+
use OCP\AppFramework\Http\DataDisplayResponse;
16+
use OCP\IRequest;
17+
18+
/** @psalm-suppress UnusedClass */
19+
final class EditorController extends Controller {
20+
public function __construct(
21+
string $appName,
22+
IRequest $request,
23+
private EditorHostingService $hostingService,
24+
) {
25+
parent::__construct($appName, $request);
26+
}
27+
28+
#[NoCSRFRequired]
29+
#[NoAdminRequired]
30+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
31+
#[FrontpageRoute(verb: 'GET', url: '/editor/{path}', requirements: ['path' => '.*'])]
32+
public function asset(string $path): DataDisplayResponse {
33+
$file = $this->hostingService->getAsset($path);
34+
if ($file === null) {
35+
return new DataDisplayResponse('Not found', Http::STATUS_NOT_FOUND, [
36+
'Content-Type' => 'text/plain; charset=utf-8',
37+
]);
38+
}
39+
40+
$response = new DataDisplayResponse($file->getContent(), Http::STATUS_OK, [
41+
'Content-Type' => $this->contentType($file->getName()),
42+
'X-Content-Type-Options' => 'nosniff',
43+
]);
44+
$response->setETag($file->getETag());
45+
$response->cacheFor(3600, false);
46+
47+
$policy = new ContentSecurityPolicy();
48+
$policy->allowEvalWasm();
49+
$policy->addAllowedWorkerSrcDomain('blob:');
50+
$policy->addAllowedConnectDomain('blob:');
51+
$response->setContentSecurityPolicy($policy);
52+
53+
return $response;
54+
}
55+
56+
private function contentType(string $fileName): string {
57+
return match (strtolower(pathinfo($fileName, PATHINFO_EXTENSION))) {
58+
'css' => 'text/css; charset=utf-8',
59+
'html' => 'text/html; charset=utf-8',
60+
'js', 'mjs' => 'text/javascript; charset=utf-8',
61+
'json', 'map' => 'application/json; charset=utf-8',
62+
'wasm' => 'application/wasm',
63+
'svg' => 'image/svg+xml',
64+
'png' => 'image/png',
65+
'jpg', 'jpeg' => 'image/jpeg',
66+
'gif' => 'image/gif',
67+
'webp' => 'image/webp',
68+
'ico' => 'image/x-icon',
69+
'woff' => 'font/woff',
70+
'woff2' => 'font/woff2',
71+
'ttf' => 'font/ttf',
72+
'otf' => 'font/otf',
73+
default => 'application/octet-stream',
74+
};
75+
}
76+
}

lib/Controller/PageController.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace OCA\Butterfly\Controller;
66

77
use OCA\Butterfly\AppInfo\Application;
8+
use OCA\Butterfly\Service\EditorHostingService;
89
use OCP\AppFramework\Controller;
910
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
1011
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
@@ -20,14 +21,13 @@
2021
* @psalm-suppress UnusedClass
2122
*/
2223
class PageController extends Controller {
23-
private const EMBED_URL = 'https://preview.butterfly.linwood.dev/embed';
24-
2524
public function __construct(
2625
string $appName,
2726
\OCP\IRequest $request,
2827
private IInitialState $initialState,
2928
private IRootFolder $rootFolder,
3029
private IUserSession $userSession,
30+
private EditorHostingService $hostingService,
3131
) {
3232
parent::__construct($appName, $request);
3333
}
@@ -39,7 +39,7 @@ public function __construct(
3939
public function index(?string $file = null, ?string $create = null): TemplateResponse {
4040
$this->initialState->provideInitialState('config', [
4141
'filePath' => $file,
42-
'embedUrl' => self::EMBED_URL,
42+
'embedUrl' => $this->hostingService->getEmbedUrl(),
4343
'create' => $create === '1',
4444
'existingRootNames' => $file === null
4545
? $this->getExistingRootDocumentNames()
@@ -51,7 +51,7 @@ public function index(?string $file = null, ?string $create = null): TemplateRes
5151
'index',
5252
);
5353
$policy = new ContentSecurityPolicy();
54-
$policy->addAllowedFrameDomain('https://preview.butterfly.linwood.dev');
54+
$policy->addAllowedFrameDomain($this->hostingService->getFrameDomain());
5555
$response->setContentSecurityPolicy($policy);
5656

5757
return $response;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OCA\Butterfly\Service;
6+
7+
final class BundleValidationException extends \RuntimeException {
8+
}

lib/Service/BundleValidator.php

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OCA\Butterfly\Service;
6+
7+
final class BundleValidator {
8+
public const MINIMUM_BUILD_NUMBER = 193;
9+
10+
private const MAX_ENTRIES = 10000;
11+
private const MAX_UNCOMPRESSED_SIZE = 536870912;
12+
13+
/**
14+
* @return array{root: string, packageName: string, buildNumber: string}
15+
*/
16+
public function validate(\ZipArchive $archive): array {
17+
if ($archive->numFiles < 1 || $archive->numFiles > self::MAX_ENTRIES) {
18+
throw new BundleValidationException('The ZIP contains an unsupported number of files.');
19+
}
20+
21+
$versionFiles = [];
22+
$seenNames = [];
23+
$totalSize = 0;
24+
for ($index = 0; $index < $archive->numFiles; $index++) {
25+
$stat = $archive->statIndex($index);
26+
if (!is_array($stat) || !isset($stat['name'], $stat['size'])) {
27+
throw new BundleValidationException('The ZIP contains an unreadable entry.');
28+
}
29+
30+
$name = $this->normalizeEntryName((string)$stat['name']);
31+
if (isset($seenNames[$name])) {
32+
throw new BundleValidationException('The ZIP contains duplicate paths.');
33+
}
34+
$seenNames[$name] = true;
35+
$totalSize += (int)$stat['size'];
36+
if ($totalSize > self::MAX_UNCOMPRESSED_SIZE) {
37+
throw new BundleValidationException('The uncompressed ZIP is larger than 512 MiB.');
38+
}
39+
if ($this->isSymbolicLink($archive, $index)) {
40+
throw new BundleValidationException('Symbolic links are not allowed in the ZIP.');
41+
}
42+
if (basename($name) === 'version.json') {
43+
$versionFiles[] = ['index' => $index, 'path' => $name];
44+
}
45+
}
46+
47+
if (count($versionFiles) !== 1) {
48+
throw new BundleValidationException('The ZIP must contain exactly one version.json.');
49+
}
50+
51+
$versionFile = $versionFiles[0];
52+
$content = $archive->getFromIndex($versionFile['index']);
53+
if (!is_string($content)) {
54+
throw new BundleValidationException('version.json could not be read.');
55+
}
56+
57+
try {
58+
/** @var mixed $decoded */
59+
$decoded = json_decode($content, true, 16, JSON_THROW_ON_ERROR);
60+
} catch (\JsonException $exception) {
61+
throw new BundleValidationException('version.json is not valid JSON.', 0, $exception);
62+
}
63+
64+
if (!is_array($decoded) || ($decoded['package_name'] ?? null) !== 'butterfly') {
65+
throw new BundleValidationException('version.json must have package_name set to "butterfly".');
66+
}
67+
68+
$buildNumber = $decoded['build_number'] ?? null;
69+
if (
70+
!is_string($buildNumber)
71+
|| !ctype_digit($buildNumber)
72+
|| (int)$buildNumber < self::MINIMUM_BUILD_NUMBER
73+
) {
74+
throw new BundleValidationException('version.json must have a string build_number of "193" or higher.');
75+
}
76+
77+
$root = dirname($versionFile['path']);
78+
$root = $root === '.' ? '' : $root . '/';
79+
if ($archive->locateName($root . 'index.html') === false) {
80+
throw new BundleValidationException('The ZIP must contain index.html next to version.json.');
81+
}
82+
83+
return [
84+
'root' => $root,
85+
'packageName' => 'butterfly',
86+
'buildNumber' => $buildNumber,
87+
];
88+
}
89+
90+
public function normalizeEntryName(string $name): string {
91+
if ($name === '' || preg_match('/[\x00-\x1f\x7f]/', $name) === 1 || str_contains($name, '\\')) {
92+
throw new BundleValidationException('The ZIP contains an invalid path.');
93+
}
94+
95+
if (str_starts_with($name, '/')) {
96+
throw new BundleValidationException('The ZIP contains an unsafe path.');
97+
}
98+
$parts = explode('/', rtrim($name, '/'));
99+
if (in_array('', $parts, true) || in_array('.', $parts, true) || in_array('..', $parts, true)) {
100+
throw new BundleValidationException('The ZIP contains an unsafe path.');
101+
}
102+
103+
return implode('/', $parts) . (str_ends_with($name, '/') ? '/' : '');
104+
}
105+
106+
private function isSymbolicLink(\ZipArchive $archive, int $index): bool {
107+
$attributes = 0;
108+
$operationsSystem = 0;
109+
if (!$archive->getExternalAttributesIndex($index, $operationsSystem, $attributes)) {
110+
return false;
111+
}
112+
113+
return (($attributes >> 16) & 0170000) === 0120000;
114+
}
115+
}

0 commit comments

Comments
 (0)