diff --git a/_api_app/app/Configuration/SettingValueValidator.php b/_api_app/app/Configuration/SettingValueValidator.php
new file mode 100644
index 000000000..d685e8bd7
--- /dev/null
+++ b/_api_app/app/Configuration/SettingValueValidator.php
@@ -0,0 +1,101 @@
+|null $features the website's plan features; read from the account when needed if null
+ */
+ public function __construct(private ?array $features = null) {}
+
+ /**
+ * Why a value sent for a setting path can't be saved, or null when it can. Paths without a definition (e.g.
+ * entries of list settings) and values that aren't text are left to the data service, as before.
+ *
+ * @param mixed $definition the setting's definition, if the path has one
+ */
+ public function errorFor(mixed $value, mixed $definition): ?string
+ {
+ return is_array($definition) && is_scalar($value) ? $this->error(trim((string) $value), $definition) : null;
+ }
+
+ /**
+ * Why the value can't be saved, or null when it can.
+ *
+ * @param array $definition the setting's definition
+ */
+ public function error(string $value, array $definition): ?string
+ {
+ if (isset($definition['requires_feature']) && ! in_array($definition['requires_feature'], $this->features ??= (new UserModel)->features, true)) {
+ return "This setting needs the {$definition['requires_feature']} feature, which the website's plan doesn't include.";
+ }
+
+ return match ($definition['format'] ?? 'text') {
+ 'select', 'fontselect' => $this->selectError($value, $definition),
+ 'color' => $this->colorError($value, $definition),
+ 'text' => $this->textError($value, $definition),
+ default => null,
+ };
+ }
+
+ /**
+ * @param array $definition
+ */
+ private function selectError(string $value, array $definition): ?string
+ {
+ $values = $definition['values'] ?? [];
+ $allowed = array_map('strval', array_is_list($values) ? $values : array_keys($values));
+
+ return in_array($value, $allowed, true) ? null : 'Use one of the allowed values: ' . implode(' | ', $allowed) . '.';
+ }
+
+ /**
+ * The editor's color picker saves rgba(); stored values also use #hex and rgb().
+ *
+ * @param array $definition
+ */
+ private function colorError(string $value, array $definition): ?string
+ {
+ if ($value === '') {
+ return ! empty($definition['allow_blank']) ? null : 'This setting can\'t be empty.';
+ }
+
+ return preg_match('/^(#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\))$/i', $value)
+ ? null
+ : 'Use a color like #ff0000 or rgb(255,0,0).';
+ }
+
+ /**
+ * The rules of the editor's text inputs (validation.service.ts).
+ *
+ * @param array $definition
+ */
+ private function textError(string $value, array $definition): ?string
+ {
+ if ($value === '') {
+ return ! empty($definition['allow_blank']) ? null : 'This setting can\'t be empty.';
+ }
+
+ if (! empty($definition['css_units']) && ! preg_match('/^-?\d*\.?\d+(px|em|rem|vw|vh|pt|%)$/', $value)) {
+ return 'Use a number with a CSS unit: px, em, rem, vw, vh, pt or %.';
+ }
+
+ return match ($definition['validation'] ?? null) {
+ 'positive_integer' => preg_match('/^[1-9]\d*$/', $value) ? null : 'Use a positive whole number.',
+ 'zero_or_positive_integer' => preg_match('/^\d+$/', $value) ? null : 'Use zero or a positive whole number.',
+ 'positive_number' => preg_match('/^(?!0$)(?!0\d+$)\d*\.?\d+$/', $value) ? null : 'Use a positive number.',
+ 'email' => preg_match('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $value) ? null : 'Use a valid email address.',
+ default => null,
+ };
+ }
+}
diff --git a/_api_app/app/Http/Controllers/AuthController.php b/_api_app/app/Http/Controllers/AuthController.php
index c6a80dafd..9a67855eb 100644
--- a/_api_app/app/Http/Controllers/AuthController.php
+++ b/_api_app/app/Http/Controllers/AuthController.php
@@ -3,8 +3,8 @@
namespace App\Http\Controllers;
use App\Shared\Helpers;
+use App\User\EditorToken;
use App\User\UserModel;
-use Firebase\JWT\JWT;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -12,17 +12,11 @@ class AuthController extends Controller
{
private $bertaSecurity;
- protected static $expiration_time = 86400; // 24 hours = 86400 seconds
+ protected static $expiration_time = EditorToken::LIFETIME_SECONDS;
protected function generateToken()
{
- $app_key = config('app.key');
- $payload = [
- 'iat' => time(), // Time when JWT was issued.
- 'exp' => time() + self::$expiration_time, // Expiration time
- ];
-
- return JWT::encode($payload, $app_key, 'HS256');
+ return (new EditorToken)->issue();
}
public function authenticate(Request $request)
diff --git a/_api_app/app/Http/Controllers/StateController.php b/_api_app/app/Http/Controllers/StateController.php
index c0372418d..ce732a2cb 100644
--- a/_api_app/app/Http/Controllers/StateController.php
+++ b/_api_app/app/Http/Controllers/StateController.php
@@ -4,6 +4,7 @@
use App\Configuration\SiteSettingsConfigService;
use App\Configuration\SiteTemplatesConfigService;
+use App\Shared\ChangeMarker;
use App\Shared\Helpers;
use App\Sites\Sections\Entries\SectionEntriesDataService;
use App\Sites\Sections\SiteSectionsDataService;
@@ -38,6 +39,7 @@ public function get($site = '')
'siteThemeApply' => route('site_theme_apply'),
'siteSwapContentsBetweenSites' => route('site_swap_contents_between_sites'),
'localeSettings' => route('locale_settings'),
+ 'changeMarker' => route('change_marker'),
'siteSections' => route('site_sections'),
'siteSectionsReset' => route('site_sections_reset'),
'siteSectionBackgrounds' => route('site_section_backgrounds'),
@@ -139,6 +141,14 @@ public function getSentryDSN()
return response(env('SENTRY_FRONTEND_DSN', ''))->header('Content-Type', 'text/plain; charset=UTF-8');
}
+ /**
+ * The website's latest change, for an editor window coming back into view (EditorSync also sends it as headers)
+ */
+ public function getChangeMarker(): JsonResponse
+ {
+ return response()->json((new ChangeMarker)->current());
+ }
+
/**
* Returns translated settings for site localization: templates and settings config
*/
diff --git a/_api_app/app/Http/Middleware/EditorSync.php b/_api_app/app/Http/Middleware/EditorSync.php
new file mode 100644
index 000000000..493c203bc
--- /dev/null
+++ b/_api_app/app/Http/Middleware/EditorSync.php
@@ -0,0 +1,114 @@
+header(self::EDITOR_HEADER, '');
+ $isEditorWrite = preg_match('/^[0-9a-f]{16,64}$/', $editor) === 1 && ! $request->isMethodSafe();
+ // Read before the controller reads any data, so an answer never carries a newer change than its data.
+ $latest = $markers->current();
+
+ if ($isEditorWrite && $request->headers->has(self::CHANGE_HEADER) && $this->missedChange($latest, $editor, (string) $request->header(self::CHANGE_HEADER))) {
+ $message = $latest['source'] === ChangeMarker::SOURCE_AGENT
+ ? 'This website was changed by an AI agent. Reload to see the changes.'
+ : 'This website was changed in another editor window. Reload to see the changes.';
+
+ return $this->withMarker(response()->json(['error_message' => $message, 'change_source' => $latest['source']], 409), $latest);
+ }
+
+ $response = $next($request);
+
+ if ($isEditorWrite && $this->saved($response)) {
+ $latest = $this->markSaved($markers, $latest, $editor);
+ }
+
+ return $this->withMarker($response, $latest);
+ }
+
+ /**
+ * Marks this window's save, so other windows learn about it. When someone else marked a change while the save ran,
+ * the window is told about that change instead of its own, so it reloads rather than missing it. When the cache
+ * can't store the mark, the latest known marker stands.
+ *
+ * @param array{token: string, writer: string, source: string} $before the marker when the request came in
+ * @return array{token: string, writer: string, source: string}
+ */
+ private function markSaved(ChangeMarker $markers, array $before, string $editor): array
+ {
+ $now = $markers->current();
+ $changedMeanwhile = $now['token'] !== $before['token'] && $now['writer'] !== $editor;
+ $mine = $markers->touch(ChangeMarker::SOURCE_EDITOR, $editor);
+
+ return $changedMeanwhile ? $now : ($mine ?? $now);
+ }
+
+ /**
+ * Whether the write changed data: Berta's data services also report refused writes as 200 with an error_message.
+ */
+ private function saved(Response $response): bool
+ {
+ return $response->isSuccessful()
+ && ! ($response instanceof JsonResponse && is_array($response->getData(true)) && isset($response->getData(true)['error_message']));
+ }
+
+ /**
+ * Whether someone other than this editor window changed the website since the window saw the given token.
+ * The window's own changes don't count, so its quick saves in a row pass.
+ *
+ * @param array{token: string, writer: string, source: string} $latest
+ */
+ private function missedChange(array $latest, string $editor, string $seenToken): bool
+ {
+ return $latest['token'] !== '' && $latest['token'] !== $seenToken && $latest['writer'] !== $editor;
+ }
+
+ /**
+ * @param array{token: string, writer: string, source: string} $latest
+ */
+ private function withMarker(Response $response, array $latest): Response
+ {
+ $response->headers->set(self::CHANGE_HEADER, $latest['token']);
+ $response->headers->set(self::CHANGE_WRITER_HEADER, $latest['writer']);
+ $response->headers->set(self::CHANGE_SOURCE_HEADER, $latest['source']);
+
+ return $response;
+ }
+}
diff --git a/_api_app/app/Http/Middleware/RenewEditorToken.php b/_api_app/app/Http/Middleware/RenewEditorToken.php
new file mode 100644
index 000000000..e93efd52f
--- /dev/null
+++ b/_api_app/app/Http/Middleware/RenewEditorToken.php
@@ -0,0 +1,42 @@
+isSuccessful() || ! preg_match('/Bearer\s(\S+)/', (string) $request->headers->get('x-authorization'), $matches)) {
+ return $response;
+ }
+
+ $renewed = (new EditorToken)->renewal($matches[1]);
+
+ if ($renewed !== null) {
+ $response->headers->set(self::TOKEN_HEADER, $renewed);
+ // A response carrying a session token must never be stored by a proxy or CDN.
+ $response->headers->set('Cache-Control', 'no-store, private');
+ EditorToken::setCookie($renewed);
+ }
+
+ return $response;
+ }
+}
diff --git a/_api_app/app/Shared/ChangeMarker.php b/_api_app/app/Shared/ChangeMarker.php
new file mode 100644
index 000000000..149eed03e
--- /dev/null
+++ b/_api_app/app/Shared/ChangeMarker.php
@@ -0,0 +1,70 @@
+ '', 'writer' => '', 'source' => ''];
+
+ /**
+ * The latest change: a token that is new with every change, the editor window that made it (empty for agents)
+ * and its source. All empty before the first marked change.
+ *
+ * @return array{token: string, writer: string, source: string}
+ */
+ public function current(): array
+ {
+ try {
+ $marker = Cache::get(self::key());
+ } catch (Throwable) {
+ return self::NONE;
+ }
+
+ // Anything else in the cache counts as no marker.
+ $isMarker = is_array($marker) && array_keys($marker) === array_keys(self::NONE) && array_filter($marker, is_string(...)) === $marker;
+
+ return $isMarker ? $marker : self::NONE;
+ }
+
+ /**
+ * Marks a new change. Returns the marker, or null when the cache can't store it.
+ *
+ * @param string $writer the editor window's id; empty for changes made outside the editor
+ * @return array{token: string, writer: string, source: string}|null
+ */
+ public function touch(string $source, string $writer = ''): ?array
+ {
+ $marker = ['token' => (string) Str::ulid(), 'writer' => $writer, 'source' => $source];
+
+ try {
+ Cache::forever(self::key(), $marker);
+ } catch (Throwable $exception) {
+ report($exception);
+
+ return null;
+ }
+
+ return $marker;
+ }
+
+ private static function key(): string
+ {
+ return 'editor-sync:change-marker:' . md5(config('app.old_berta_root'));
+ }
+}
diff --git a/_api_app/app/Shared/HtmlCleaner.php b/_api_app/app/Shared/HtmlCleaner.php
new file mode 100644
index 000000000..d50daf24c
--- /dev/null
+++ b/_api_app/app/Shared/HtmlCleaner.php
@@ -0,0 +1,76 @@
+' . $html, LIBXML_NOERROR);
+ $removed = false;
+
+ foreach ($document->body->querySelectorAll('script') as $script) {
+ $script->remove();
+ $removed = true;
+ }
+
+ foreach ($document->body->querySelectorAll('*') as $element) {
+ $removed = $this->cleanAttributes($element) || $removed;
+ }
+
+ return $removed ? $document->body->innerHTML : $html;
+ }
+
+ /**
+ * Removes the element's attributes that can run scripts; true when any was removed.
+ */
+ private function cleanAttributes(Element $element): bool
+ {
+ $unsafe = [];
+
+ foreach ($element->attributes as $attribute) {
+ $name = strtolower($attribute->name);
+
+ if (str_starts_with($name, 'on') || $name === 'srcdoc' || (in_array($name, self::URL_ATTRIBUTES, true) && $this->isScriptUrl($attribute->value))) {
+ $unsafe[] = $attribute->name;
+ }
+ }
+
+ foreach ($unsafe as $name) {
+ $element->removeAttribute($name);
+ }
+
+ return $unsafe !== [];
+ }
+
+ /**
+ * Whether a link runs a script. Browsers ignore whitespace and control characters in the scheme.
+ */
+ private function isScriptUrl(string $url): bool
+ {
+ $scheme = strtolower((string) preg_replace('/[\x00-\x20]+/', '', $url));
+
+ return str_starts_with($scheme, 'javascript:') || str_starts_with($scheme, 'vbscript:');
+ }
+}
diff --git a/_api_app/app/Shared/Storage.php b/_api_app/app/Shared/Storage.php
index 6525f2d52..e05ef55d5 100644
--- a/_api_app/app/Shared/Storage.php
+++ b/_api_app/app/Shared/Storage.php
@@ -305,30 +305,93 @@ protected function xmlFile2array($xml_file)
}
/**
+ * Copies a folder with its subfolders. Returns false when a folder can't be made or a file can't be copied,
+ * so callers can keep the original; a missing source has nothing to copy and returns true.
+ *
* @param list $skipFolders Folders at the top of $src left out of the copy
*/
- protected function copyFolder($src, $dst, array $skipFolders = [])
+ protected function copyFolder($src, $dst, array $skipFolders = []): bool
{
if (! is_dir($src)) {
- return;
+ return true;
+ }
+
+ if (! is_dir($dst) && ! @mkdir($dst, 0777, true)) {
+ return false;
}
$dir = opendir($src);
- @mkdir($dst, 0777, true);
+ $copied = true;
while (false !== ($file = readdir($dir))) {
if (($file != '.') && ($file != '..')) {
if (is_dir($src . '/' . $file)) {
if (! in_array($file, ['-sites', $this->PREVIEW_FOLDER, ...$skipFolders])) {
- self::copyFolder($src . '/' . $file, $dst . '/' . $file);
+ $copied = self::copyFolder($src . '/' . $file, $dst . '/' . $file) && $copied;
}
} else {
- copy($src . '/' . $file, $dst . '/' . $file);
+ $copied = copy($src . '/' . $file, $dst . '/' . $file) && $copied;
}
}
}
closedir($dir);
+
+ return $copied;
+ }
+
+ /**
+ * The site's media folders, each with the entry galleries ([section, entry id]) and section backgrounds
+ * ([section, null]) that refer to it. Sites made by older Berta versions can have folders that more than one refers to.
+ *
+ * @return array>
+ */
+ protected function mediaFolderReferences(): array
+ {
+ $root = dirname($this->MEDIA_ROOT);
+ $references = [];
+
+ foreach (glob($root . '/blog.*.xml') ?: [] as $file) {
+ $section = substr(basename($file), strlen('blog.'), -strlen('.xml'));
+ $blog = $this->xmlFile2array($file);
+
+ foreach ($this->asList($blog['entry'] ?? []) as $entry) {
+ if (is_array($entry) && ! empty($entry['mediafolder'])) {
+ $references[$entry['mediafolder']][] = [$section, (string) ($entry['id'] ?? '')];
+ }
+ }
+ }
+
+ $sections = $this->xmlFile2array($root . '/sections.xml');
+
+ foreach ($this->asList($sections['section'] ?? []) as $section) {
+ if (is_array($section) && ! empty($section['mediafolder'])) {
+ $references[$section['mediafolder']][] = [(string) ($section['name'] ?? ''), null];
+ }
+ }
+
+ return $references;
+ }
+
+ /**
+ * Media folders that something other than the given entry refers to, or, without an entry, something outside the
+ * given section and its entries. Deleting keeps these, so a shared folder never loses another gallery's files.
+ *
+ * @return array
+ */
+ protected function mediaFoldersReferencedOutside(string $section, ?string $entryId = null): array
+ {
+ $referencedOutside = [];
+
+ foreach ($this->mediaFolderReferences() as $folder => $references) {
+ foreach ($references as [$referencingSection, $referencingEntryId]) {
+ if ($referencingSection !== $section || ($entryId !== null && $referencingEntryId !== $entryId)) {
+ $referencedOutside[$folder] = true;
+ }
+ }
+ }
+
+ return $referencedOutside;
}
public function moveFolderContents($src, $dst, $ignore = [])
diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php b/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
index 178123e1b..305dedf71 100644
--- a/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
+++ b/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
@@ -506,22 +506,35 @@ public function create($name = null)
$blog['@attributes']['section'] = $this->SECTION_NAME;
if (isset($blog[self::$ROOT_LIST_ELEMENT])) {
+ $referencedFolders = $this->mediaFolderReferences();
+ $copiedFolders = [];
+
foreach ($blog[self::$ROOT_LIST_ELEMENT] as $idx => $entry) {
$blog[self::$ROOT_LIST_ELEMENT][$idx]['uniqid'] = uniqid();
$blog[self::$ROOT_LIST_ELEMENT][$idx]['date'] = date('d.m.Y H:i:s');
$blog[self::$ROOT_LIST_ELEMENT][$idx]['updated'] = date('d.m.Y H:i:s');
- if (isset($entry['mediafolder'])) {
- $blog[self::$ROOT_LIST_ELEMENT][$idx]['mediafolder'] = str_replace(
- $name,
- $this->SECTION_NAME,
- $entry['mediafolder']
- );
+ if (! empty($entry['mediafolder'])) {
+ // A new folder for each copy: an entry's folder isn't always named after its section, and a
+ // folder shared with the original would lose its files when either one is deleted.
+ $mediafolder = $this->newMediaFolder($referencedFolders);
- $this->copyFolder(
- realpath($this->MEDIA_ROOT) . '/' . $entry['mediafolder'],
- realpath($this->MEDIA_ROOT) . '/' . $blog[self::$ROOT_LIST_ELEMENT][$idx]['mediafolder']
- );
+ if ($mediafolder !== null) {
+ $copiedFolders[] = $mediafolder;
+ }
+
+ if ($mediafolder === null || ! $this->copyFolder(
+ $this->MEDIA_ROOT . '/' . $entry['mediafolder'],
+ $this->MEDIA_ROOT . '/' . $mediafolder
+ )) {
+ foreach ($copiedFolders as $copiedFolder) {
+ $this->delFolder($this->MEDIA_ROOT . '/' . $copiedFolder);
+ }
+
+ return ['error_message' => 'Can\'t copy the media files of the entries! Check permissions and free disk space.'];
+ }
+
+ $blog[self::$ROOT_LIST_ELEMENT][$idx]['mediafolder'] = $mediafolder;
}
}
}
@@ -598,14 +611,17 @@ public function rename($new_name, $new_title)
$entries['@attributes']['section'] = $new_name;
if (isset($entries[self::$ROOT_LIST_ELEMENT])) {
+ $referencedFolders = $this->mediaFolderReferences();
+
foreach ($entries[self::$ROOT_LIST_ELEMENT] as $key => $entry) {
- if (isset($entry['mediafolder'])) {
+ if (! empty($entry['mediafolder'])) {
+ $mediafolder = $new_name . $entry['id'];
$old_media = realpath($this->MEDIA_ROOT) . '/' . $entry['mediafolder'];
- $new_name = $new_name . $entry['id'];
- $new_media = realpath($this->MEDIA_ROOT) . '/' . $new_name;
+ $new_media = realpath($this->MEDIA_ROOT) . '/' . $mediafolder;
- if (@rename($old_media, $new_media)) {
- $entries[self::$ROOT_LIST_ELEMENT][$key]['mediafolder'] = $new_name;
+ // Skip a name that exists or another gallery refers to: the entry keeps its folder instead of sharing one.
+ if (! isset($referencedFolders[$mediafolder]) && ! file_exists($new_media) && @rename($old_media, $new_media)) {
+ $entries[self::$ROOT_LIST_ELEMENT][$key]['mediafolder'] = $mediafolder;
}
}
}
@@ -622,8 +638,10 @@ public function delete()
// delete media files
if (array_key_exists(self::$ROOT_LIST_ELEMENT, $entries) and ! empty($entries[self::$ROOT_LIST_ELEMENT])) {
+ $referencedOutside = $this->mediaFoldersReferencedOutside($this->SECTION_NAME);
+
foreach ($entries[self::$ROOT_LIST_ELEMENT] as $entry) {
- if (! empty($entry['mediafolder'])) {
+ if (! empty($entry['mediafolder']) && ! isset($referencedOutside[$entry['mediafolder']])) {
$mediaFolder = $this->MEDIA_ROOT . '/' . $entry['mediafolder'];
if (file_exists($mediaFolder)) {
@@ -665,14 +683,6 @@ public function delete()
*/
public function createEntry($newEntry, $beforeEntry, $tag)
{
- $mediafolder = $this->SECTION_NAME;
- $counter = 1;
- do {
- $new_mediafolder = $mediafolder . $counter;
- $counter++;
- } while (file_exists($this->MEDIA_ROOT . '/' . $new_mediafolder));
- $mediafolder = $new_mediafolder;
-
$siteSettingsDataService = new SiteSettingsDataService($this->SITE);
$siteSettings = $siteSettingsDataService->getState();
$template = $siteSettings['template']['template'];
@@ -690,12 +700,13 @@ public function createEntry($newEntry, $beforeEntry, $tag)
$galleryFullScreen = $siteSettings['entryLayout']['galleryFullScreenDefault'];
}
- if (! @mkdir($this->MEDIA_ROOT . '/' . $mediafolder, 0777)) {
- $ret['value'] = $mediafolder;
- $ret['error_message'] = 'Can\'t create media folder! Check permissions.';
- $ret['status_code'] = 400;
+ $mediafolder = $this->newMediaFolder();
- return $ret;
+ if ($mediafolder === null) {
+ return [
+ 'error_message' => 'Can\'t create media folder! Check permissions.',
+ 'status_code' => 400,
+ ];
}
$entries = $this->get();
@@ -706,11 +717,16 @@ public function createEntry($newEntry, $beforeEntry, $tag)
// In case creating entry from existing entry from other section
// Update references
if ($newEntry) {
- if (isset($newEntry['mediafolder'])) {
- $this->copyFolder(
- $this->XML_ROOT . '/' . $this->MEDIA_FOLDER . '/' . $newEntry['mediafolder'],
- $this->XML_ROOT . '/' . $this->MEDIA_FOLDER . '/' . $mediafolder
- );
+ if (! empty($newEntry['mediafolder']) && ! $this->copyFolder(
+ $this->XML_ROOT . '/' . $this->MEDIA_FOLDER . '/' . $newEntry['mediafolder'],
+ $this->XML_ROOT . '/' . $this->MEDIA_FOLDER . '/' . $mediafolder
+ )) {
+ $this->delFolder($this->MEDIA_ROOT . '/' . $mediafolder);
+
+ return [
+ 'error_message' => 'Can\'t copy the media files of the entry! Check permissions and free disk space.',
+ 'status_code' => 400,
+ ];
}
$newEntry = array_replace_recursive($newEntry, [
@@ -850,6 +866,11 @@ public function moveEntry($entryId, $toSection)
$toSectionEntriesDS = new self($this->SITE, $toSection);
$data = $toSectionEntriesDS->createEntry($entry, null, null);
+ // Without a copy in the other section, deleting the entry here would lose it and its files.
+ if (isset($data['error_message'])) {
+ return $data;
+ }
+
$deletedEntry = $this->deleteEntry($entryId);
$data['deleted_entry'] = $deletedEntry;
@@ -869,8 +890,9 @@ public function deleteEntry($entry_id)
$entry = $entries['entry'][$entry_order];
- // Delete entry media folder
- if (isset($entry['mediafolder']) && ! empty($entry['mediafolder'])) {
+ // Delete entry media folder, unless another gallery refers to it too
+ if (! empty($entry['mediafolder'])
+ && ! isset($this->mediaFoldersReferencedOutside($this->SECTION_NAME, (string) $entry['id'])[$entry['mediafolder']])) {
$this->delFolder($this->MEDIA_ROOT . '/' . $entry['mediafolder']);
}
@@ -939,6 +961,26 @@ private function setTitle($title)
}
}
+ /**
+ * Creates an empty media folder for an entry of this section, named after the section, and returns its name;
+ * null when it can't be created. Names another gallery refers to are skipped even when their folder is missing,
+ * so no two galleries end up sharing one.
+ *
+ * @param array|null $referencedFolders The site's media folder references, when the caller already has them
+ */
+ private function newMediaFolder(?array $referencedFolders = null): ?string
+ {
+ $referencedFolders ??= $this->mediaFolderReferences();
+ $counter = 1;
+
+ do {
+ $mediafolder = $this->SECTION_NAME . $counter;
+ $counter++;
+ } while (isset($referencedFolders[$mediafolder]) || file_exists($this->MEDIA_ROOT . '/' . $mediafolder));
+
+ return @mkdir($this->MEDIA_ROOT . '/' . $mediafolder, 0777) ? $mediafolder : null;
+ }
+
public function galleryOrder($section_name, $entry_id, $new_files)
{
$entries = $this->get();
diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
index d1464c5bb..08d25b995 100644
--- a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
+++ b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
@@ -35,6 +35,22 @@ public function __construct()
$this->galleryGridRenderService = new GalleryGridRenderService;
}
+ /**
+ * Whether entry titles are shown: on the "default" template and in portfolio sections.
+ */
+ public static function showsEntryTitles(string $templateName, ?string $sectionType): bool
+ {
+ return $sectionType == 'portfolio' || $templateName == 'default';
+ }
+
+ /**
+ * Whether entries can be moved into a section of this type: not into external links or the shopping cart.
+ */
+ public static function acceptsEntries(?string $sectionType): bool
+ {
+ return ! in_array($sectionType, ['external_link', 'shopping_cart'], true);
+ }
+
/**
* Prepare data for view
*/
@@ -57,7 +73,7 @@ public function getViewData(
$isResponsiveTemplate = isset($siteTemplateSettings['pageLayout']['responsive']) && $siteTemplateSettings['pageLayout']['responsive'] == 'yes';
$isResponsive = $sectionType == 'portfolio' || $isResponsiveTemplate;
- if (($sectionType == 'portfolio' || $templateName == 'default') && ($isEditMode || (isset($entry['content']['title']) && ! empty($entry['content']['title'])))) {
+ if (self::showsEntryTitles($templateName, $sectionType) && ($isEditMode || (isset($entry['content']['title']) && ! empty($entry['content']['title'])))) {
$entryTitle = view('Sites/Sections/Entries/_entryTitle', array_merge($entry, [
'attributes' => [
'title' => Helpers::arrayToHtmlAttributes([
@@ -173,9 +189,8 @@ public function getViewData(
// Exclude current section, external link and shopping cart
$sections = array_filter($sections, function ($s) use ($section) {
$isCurrentSection = $s['name'] === $section['name'];
- $validSectionType = empty($s['@attributes']['type']) ? true : ! in_array($s['@attributes']['type'], ['external_link', 'shopping_cart']);
- return ! $isCurrentSection && $validSectionType;
+ return ! $isCurrentSection && self::acceptsEntries($s['@attributes']['type'] ?? null);
});
$entryContents = view('Sites/Sections/Entries/_entryEditor', [
diff --git a/_api_app/app/Sites/Sections/SiteSectionsController.php b/_api_app/app/Sites/Sections/SiteSectionsController.php
index eab3b6314..ea1af96be 100644
--- a/_api_app/app/Sites/Sections/SiteSectionsController.php
+++ b/_api_app/app/Sites/Sections/SiteSectionsController.php
@@ -36,6 +36,10 @@ public function create(Request $request)
);
}
+ if (isset($section['error_message'])) {
+ return response()->json($section);
+ }
+
$tags = $cloneFrom ? new SectionTagsDataService($json['site'], $section['name']) : null;
$entries = $cloneFrom ? new SectionEntriesDataService($json['site'], $section['name']) : null;
diff --git a/_api_app/app/Sites/Sections/SiteSectionsDataService.php b/_api_app/app/Sites/Sections/SiteSectionsDataService.php
index 69e9cdc9b..df2652529 100644
--- a/_api_app/app/Sites/Sections/SiteSectionsDataService.php
+++ b/_api_app/app/Sites/Sections/SiteSectionsDataService.php
@@ -259,6 +259,10 @@ public function cloneSection(?string $name = null, ?string $title = null)
$entries = new SectionEntriesDataService($this->SITE, 'clone-of-' . $name, 'clone of ' . $title);
$section_entries = $entries->create($name);
+ if (isset($section_entries['error_message'])) {
+ return $section_entries;
+ }
+
$section = $sections[$section_order];
$section['name'] = $section_entries['name'];
$section['title'] = $section_entries['title'];
@@ -268,10 +272,15 @@ public function cloneSection(?string $name = null, ?string $title = null)
if (isset($section['mediafolder'])) {
$section['mediafolder'] = $section_entries['name'] . '-background';
- $this->copyFolder(
+ if (! $this->copyFolder(
realpath($this->MEDIA_ROOT) . '/' . $sections[$section_order]['mediafolder'],
realpath($this->MEDIA_ROOT) . '/' . $section['mediafolder']
- );
+ )) {
+ $this->delFolder(realpath($this->MEDIA_ROOT) . '/' . $section['mediafolder']);
+ (new SectionEntriesDataService($this->SITE, $section_entries['name']))->delete();
+
+ return ['error_message' => 'Can\'t copy the background files of the section! Check permissions and free disk space.'];
+ }
}
$section_order = count($sections);
@@ -322,7 +331,7 @@ public function saveValueByPath($path, $value)
if ($prop === 'title') {
$old_name = $sectionName;
$old_title = isset($sections['section'][$order]['title']) ? $sections['section'][$order]['title'] : '';
- $new_name = $this->getUniqueSlug($value);
+ $new_name = $this->getUniqueSlug($value, $sectionName);
if (empty($value)) {
$ret['value'] = $old_title;
@@ -341,19 +350,21 @@ public function saveValueByPath($path, $value)
);
// Rename section background path
- if (isset($sections['section'][$order]['mediafolder'])) {
+ if (! empty($sections['section'][$order]['mediafolder'])) {
$mediafolder = $new_name . '-background';
-
- @rename(
- realpath($this->MEDIA_ROOT) . '/' . $sections['section'][$order]['mediafolder'],
- realpath($this->MEDIA_ROOT) . '/' . $mediafolder
- );
-
- $this->setValueByPath(
- $sections,
- 'section/' . $order . '/mediafolder',
- $mediafolder
- );
+ $old_media = realpath($this->MEDIA_ROOT) . '/' . $sections['section'][$order]['mediafolder'];
+ $new_media = realpath($this->MEDIA_ROOT) . '/' . $mediafolder;
+
+ // Keep pointing at the files when the folder can't be renamed, or the new name exists or another gallery refers to it
+ if (! isset($this->mediaFolderReferences()[$mediafolder])
+ && ! file_exists($new_media)
+ && (! file_exists($old_media) || @rename($old_media, $new_media))) {
+ $this->setValueByPath(
+ $sections,
+ 'section/' . $order . '/mediafolder',
+ $mediafolder
+ );
+ }
}
$entries = new SectionEntriesDataService($this->SITE, $old_name, $old_title);
@@ -453,7 +464,8 @@ public function delete($name)
// delete section media
$section = $sections['section'][$section_idx];
- if (array_key_exists('mediafolder', $section) and ! empty($section['mediafolder'])) {
+ if (array_key_exists('mediafolder', $section) and ! empty($section['mediafolder'])
+ and ! isset($this->mediaFoldersReferencedOutside($name)[$section['mediafolder']])) {
$mediaFolder = $this->MEDIA_ROOT . '/' . $section['mediafolder'];
if (file_exists($mediaFolder)) {
@@ -685,7 +697,11 @@ public function backgroundGalleryUpload($path, $file)
* Private methods
************************************************************/
- private function getUniqueSlug($title)
+ /**
+ * A section name for the title that no other section has. $currentName is the renamed section's own name,
+ * which it may keep, e.g. when only the title's case changes.
+ */
+ private function getUniqueSlug($title, ?string $currentName = null)
{
$sections['section'] = $this->get();
$title = trim($title);
@@ -696,7 +712,7 @@ private function getUniqueSlug($title)
$slug = Helpers::slugify($title, '-', '\._-', true);
$slug = $slug ?: '_';
- $names = array_values(array_column($sections['section'], 'name'));
+ $names = array_values(array_diff(array_column($sections['section'], 'name'), [$currentName]));
$notUnique = true;
$i = 1;
diff --git a/_api_app/app/Sites/Settings/SiteSettingsController.php b/_api_app/app/Sites/Settings/SiteSettingsController.php
index 8418f5444..12517e8a4 100644
--- a/_api_app/app/Sites/Settings/SiteSettingsController.php
+++ b/_api_app/app/Sites/Settings/SiteSettingsController.php
@@ -2,6 +2,8 @@
namespace App\Sites\Settings;
+use App\Configuration\SettingValueValidator;
+use App\Configuration\SiteSettingsConfigService;
use App\Http\Controllers\Controller;
use App\Shared\Helpers;
use Illuminate\Http\Request;
@@ -45,6 +47,12 @@ public function update(Request $request)
$site = $path_arr[0];
$settingsDataService = new SiteSettingsDataService($site);
+ // "/settings//"; deeper paths are entries of list settings
+ $definition = count($path_arr) === 4 && $path_arr[3] !== '_' ? ((new SiteSettingsConfigService)->get()[$path_arr[2]][$path_arr[3]] ?? null) : null;
+ if ($error = (new SettingValueValidator)->errorFor($json['value'], $definition)) {
+ return response()->json(['path' => $json['path'], 'value' => $json['value'], 'error_message' => $error], 422);
+ }
+
$res = $settingsDataService->saveValueByPath($json['path'], $json['value']);
// @@@:TODO: Replace this with something sensible, when migration to redux is done
// `real` returns the user input value
diff --git a/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php b/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php
index 9f243bd98..4ea3f9a4c 100644
--- a/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php
+++ b/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php
@@ -2,6 +2,8 @@
namespace App\Sites\TemplateSettings;
+use App\Configuration\SettingValueValidator;
+use App\Configuration\SiteTemplatesConfigService;
use App\Http\Controllers\Controller;
use App\Shared\Helpers;
use Illuminate\Http\Request;
@@ -17,6 +19,12 @@ public function update(Request $request)
$template = $path_arr[2];
$templateSettingsDataService = new SiteTemplateSettingsDataService($site, $template);
+ // "/template-settings///"
+ $definition = count($path_arr) === 5 && $path_arr[4] !== '_' ? ((new SiteTemplatesConfigService)->get()[$template]['templateConf'][$path_arr[3]][$path_arr[4]] ?? null) : null;
+ if ($error = (new SettingValueValidator)->errorFor($json['value'], $definition)) {
+ return response()->json(['path' => $json['path'], 'value' => $json['value'], 'error_message' => $error], 422);
+ }
+
$res = $templateSettingsDataService->saveValueByPath($json['path'], $json['value']);
// @@@:TODO: Replace this with something sensible, when migration to redux is done
$res['update'] = $res['value'];
diff --git a/_api_app/app/User/EditorToken.php b/_api_app/app/User/EditorToken.php
new file mode 100644
index 000000000..daa2670c5
--- /dev/null
+++ b/_api_app/app/User/EditorToken.php
@@ -0,0 +1,86 @@
+ $claims claims to keep, besides the issue and expiry times
+ */
+ public function issue(array $claims = []): string
+ {
+ return JWT::encode([...$claims, 'iat' => time(), 'exp' => time() + self::LIFETIME_SECONDS], config('app.key'), 'HS256');
+ }
+
+ /**
+ * A new token with the same claims when the given valid token is older than RENEW_AFTER_SECONDS, its login not
+ * older than MAX_SESSION_DAYS and not earlier than the account's last change, else null. The login time is kept
+ * as "auth_time"; tokens from a login carry none, so their issue time is the login time.
+ */
+ public function renewal(string $token): ?string
+ {
+ try {
+ $claims = (array) JWT::decode($token, new Key(config('app.key'), 'HS256'));
+ } catch (Throwable) {
+ return null;
+ }
+
+ $authTime = (int) ($claims['auth_time'] ?? $claims['iat'] ?? 0);
+
+ if (
+ time() - (int) ($claims['iat'] ?? 0) < self::RENEW_AFTER_SECONDS
+ || time() - $authTime > self::MAX_SESSION_DAYS * 86400
+ || $authTime < $this->accountChangedAt()
+ ) {
+ return null;
+ }
+
+ return $this->issue([...$claims, 'auth_time' => $authTime]);
+ }
+
+ /**
+ * When the account's login details last changed; PHP_INT_MAX when unknown, so nothing is renewed.
+ */
+ private function accountChangedAt(): int
+ {
+ $file = config('app.old_berta_root') . '/engine/config/inc.conf.php';
+ // A password change rewrites the file earlier in the same request.
+ clearstatcache(true, $file);
+ $changedAt = @filemtime($file);
+
+ return $changedAt === false ? PHP_INT_MAX : $changedAt;
+ }
+
+ /**
+ * Sets the token cookie the editor's login sets, for requests that send no token header.
+ */
+ public static function setCookie(string $token): void
+ {
+ setcookie('token', $token, time() + self::LIFETIME_SECONDS, '/');
+ }
+}
diff --git a/_api_app/composer.json b/_api_app/composer.json
index 5f5168a5e..bd62949d3 100644
--- a/_api_app/composer.json
+++ b/_api_app/composer.json
@@ -23,6 +23,7 @@
"intervention/image": "^3.11",
"laravel/ai": "^1.0",
"laravel/framework": "^13.30",
+ "laravel/mcp": "^1.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0",
"mobiledetect/mobiledetectlib": "^4.8",
diff --git a/_api_app/composer.lock b/_api_app/composer.lock
index c4b495e5b..13b3e855d 100644
--- a/_api_app/composer.lock
+++ b/_api_app/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "27814c6f2a0f2dd4cfce7cd20e95ec08",
+ "content-hash": "863e8d78c2d8d8dd0e23b9fe373d1dd3",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@@ -1880,6 +1880,81 @@
},
"time": "2026-09-29T12:46:21+00:00"
},
+ {
+ "name": "laravel/mcp",
+ "version": "v1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/e77af70cd009aaa31455626bd463b0e7e39cfcf9",
+ "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "illuminate/console": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/container": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/http": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/json-schema": "^12.41.1|^13.0",
+ "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/support": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
+ "php": "^8.2",
+ "symfony/process": "^7.4.5|^8.0.5"
+ },
+ "require-dev": {
+ "jasonmccreary/double": "^1.0",
+ "laravel/pint": "^1.20",
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "pestphp/pest": "^3.8.5|^4.3.2",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.2.4"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Mcp": "Laravel\\Mcp\\Facades\\Mcp"
+ },
+ "providers": [
+ "Laravel\\Mcp\\Server\\McpServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Mcp\\": "src/",
+ "Laravel\\Mcp\\Server\\": "src/Server/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Rapidly build MCP servers for your Laravel applications.",
+ "homepage": "https://github.com/laravel/mcp",
+ "keywords": [
+ "laravel",
+ "mcp"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/mcp/issues",
+ "source": "https://github.com/laravel/mcp"
+ },
+ "time": "2026-10-21T11:50:42+00:00"
+ },
{
"name": "laravel/prompts",
"version": "v0.3.24",
@@ -8161,80 +8236,6 @@
},
"time": "2026-09-23T09:33:31+00:00"
},
- {
- "name": "laravel/mcp",
- "version": "v1.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/laravel/mcp.git",
- "reference": "92987a9d03847801299ff4abe909ba7686147dde"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/laravel/mcp/zipball/92987a9d03847801299ff4abe909ba7686147dde",
- "reference": "92987a9d03847801299ff4abe909ba7686147dde",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "ext-mbstring": "*",
- "illuminate/console": "^11.45.3|^12.41.1|^13.0",
- "illuminate/container": "^11.45.3|^12.41.1|^13.0",
- "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
- "illuminate/http": "^11.45.3|^12.41.1|^13.0",
- "illuminate/json-schema": "^12.41.1|^13.0",
- "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
- "illuminate/support": "^11.45.3|^12.41.1|^13.0",
- "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
- "php": "^8.2",
- "symfony/process": "^7.4.5|^8.0.5"
- },
- "require-dev": {
- "laravel/pint": "^1.20",
- "orchestra/testbench": "^9.15|^10.8|^11.0",
- "pestphp/pest": "^3.8.5|^4.3.2",
- "phpstan/phpstan": "^2.1.27",
- "rector/rector": "^2.2.4"
- },
- "type": "library",
- "extra": {
- "laravel": {
- "aliases": {
- "Mcp": "Laravel\\Mcp\\Facades\\Mcp"
- },
- "providers": [
- "Laravel\\Mcp\\Server\\McpServiceProvider"
- ]
- }
- },
- "autoload": {
- "psr-4": {
- "Laravel\\Mcp\\": "src/",
- "Laravel\\Mcp\\Server\\": "src/Server/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Taylor Otwell",
- "email": "taylor@laravel.com"
- }
- ],
- "description": "Rapidly build MCP servers for your Laravel applications.",
- "homepage": "https://github.com/laravel/mcp",
- "keywords": [
- "laravel",
- "mcp"
- ],
- "support": {
- "issues": "https://github.com/laravel/mcp/issues",
- "source": "https://github.com/laravel/mcp"
- },
- "time": "2026-09-24T10:45:09+00:00"
- },
{
"name": "laravel/pint",
"version": "v1.32.1",
diff --git a/_api_app/routes/api.php b/_api_app/routes/api.php
index 736da609c..8fb798bb4 100644
--- a/_api_app/routes/api.php
+++ b/_api_app/routes/api.php
@@ -3,6 +3,8 @@
use App\Http\Controllers\AuthController;
use App\Http\Controllers\StateController;
use App\Http\Middleware\Authenticate;
+use App\Http\Middleware\EditorSync;
+use App\Http\Middleware\RenewEditorToken;
use App\Http\Middleware\SetupMiddleware;
use App\Setup\ServerRequirementsController;
use App\Sites\Sections\Entries\SectionEntriesController;
@@ -24,17 +26,20 @@
Route::get('v1/requirements', [ServerRequirementsController::class, 'index'])->name('requirements');
});
-Route::middleware([SetupMiddleware::class, Authenticate::class])->prefix('v1')->group(function () {
- Route::patch('user/changepassword', [AuthController::class, 'changePassword']);
+Route::middleware([SetupMiddleware::class, Authenticate::class, RenewEditorToken::class, EditorSync::class])->prefix('v1')->group(function () {
+ // Changes no site data, so other editor windows aren't asked to reload (EditorSync).
+ Route::patch('user/changepassword', [AuthController::class, 'changePassword'])->name('user_change_password')->withoutMiddleware(EditorSync::class);
Route::get('state/{site?}', [StateController::class, 'get']);
Route::get('locale-settings', [StateController::class, 'getLocaleSettings'])->name('locale_settings');
+ Route::get('change-marker', [StateController::class, 'getChangeMarker'])->name('change_marker');
// Sites
Route::post('sites', [SitesController::class, 'create'])->name('sites');
Route::patch('sites', [SitesController::class, 'update']);
Route::put('sites', [SitesController::class, 'order']);
Route::delete('sites', [SitesController::class, 'delete']);
- Route::post('sites/theme-preview', [SitesController::class, 'themePreview'])->name('site_theme_preview');
+ // Builds a preview copy, not the live site (EditorSync).
+ Route::post('sites/theme-preview', [SitesController::class, 'themePreview'])->name('site_theme_preview')->withoutMiddleware(EditorSync::class);
Route::put('sites/theme-apply', [SitesController::class, 'themeApply'])->name('site_theme_apply');
Route::put('sites/swap-contents-between-sites', [SitesController::class, 'swapContentsBetweenSites'])->name('site_swap_contents_between_sites');
Route::get('sites/render-menu/{site?}', [SitesController::class, 'renderMenu']);
@@ -59,7 +64,7 @@
])->name('site_template_settings_reset_to_defaults');
});
-Route::middleware([SetupMiddleware::class, Authenticate::class])->prefix('v1/sites')->group(function () {
+Route::middleware([SetupMiddleware::class, Authenticate::class, RenewEditorToken::class, EditorSync::class])->prefix('v1/sites')->group(function () {
Route::post('sections', [SiteSectionsController::class, 'create'])->name('site_sections');
Route::patch('sections', [SiteSectionsController::class, 'update']);
Route::patch('sections/reset', [SiteSectionsController::class, 'reset'])->name('site_sections_reset');
@@ -89,7 +94,7 @@
Route::put('sections/tags', [SectionTagsController::class, 'order'])->name('section_tags');
});
-Route::middleware([SetupMiddleware::class, Authenticate::class])->prefix('v1/sites/sections')->group(function () {
+Route::middleware([SetupMiddleware::class, Authenticate::class, RenewEditorToken::class, EditorSync::class])->prefix('v1/sites/sections')->group(function () {
Route::patch('entries', [SectionEntriesController::class, 'update'])->name('section_entries');
Route::post('entries', [SectionEntriesController::class, 'create']);
Route::put('entries', [SectionEntriesController::class, 'order']);
@@ -108,7 +113,7 @@
]);
});
-Route::middleware([SetupMiddleware::class, Authenticate::class])->prefix('v1/plugin')->group(function () {
+Route::middleware([SetupMiddleware::class, Authenticate::class, RenewEditorToken::class])->prefix('v1/plugin')->group(function () {
$app_path = app_path();
foreach (scandir("{$app_path}/Plugins") as $fileOrDir) {
if (in_array($fileOrDir, ['.', '..'])) {
diff --git a/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php b/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php
deleted file mode 100644
index 4d09ad70e..000000000
--- a/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php
+++ /dev/null
@@ -1,137 +0,0 @@
-makePartial();
- $user->shouldReceive('getPlan')->andReturn($plan);
- $user->plans = $plans ?: [
- ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]],
- ['id' => '2', 'name' => 'Pro', 'features' => [], 'limits' => ['ai_assistant_daily' => 100]],
- ['id' => '3', 'name' => 'Shop', 'features' => [], 'limits' => ['ai_assistant_daily' => 100]],
- ];
-
- return $user;
-}
-
-it('returns 30 requests per day for basic plan', function () {
- $service = new AiAssistantQuotaService;
-
- expect($service->getDailyLimit(makeUser(1)))->toBe(30);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 100 requests per day for pro plan', function () {
- $service = new AiAssistantQuotaService;
-
- expect($service->getDailyLimit(makeUser(2)))->toBe(100);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 100 requests per day for shop plan', function () {
- $service = new AiAssistantQuotaService;
-
- expect($service->getDailyLimit(makeUser(3)))->toBe(100);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('falls back to trial plan (id=0) limit when plan id is not found', function () {
- $service = new AiAssistantQuotaService;
- $user = makeUser(9, [
- ['id' => '0', 'name' => 'Trial', 'features' => [], 'limits' => ['ai_assistant_daily' => 5]],
- ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]],
- ]);
-
- expect($service->getDailyLimit($user))->toBe(5);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 0 when plan id is not found and no trial plan exists', function () {
- $service = new AiAssistantQuotaService;
- $user = makeUser(9, [
- ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]],
- ]);
-
- expect($service->getDailyLimit($user))->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 0 when plan has no ai_assistant_daily limit configured', function () {
- $service = new AiAssistantQuotaService;
- $user = makeUser(1, [
- ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => []],
- ]);
-
- expect($service->getDailyLimit($user))->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 0 when plan id is not found in plans list', function () {
- $service = new AiAssistantQuotaService;
- $user = makeUser(9, []);
-
- expect($service->getDailyLimit($user))->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('formats cache key with current date', function () {
- $service = new AiAssistantQuotaService;
-
- expect($service->getCacheKey())->toBe('ai_requests:' . now()->format('Y-m-d'));
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns zero count when no requests have been made', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
-
- expect($service->getCount())->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('increments count on each call', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
-
- expect($service->increment())->toBe(1)
- ->and($service->increment())->toBe(2)
- ->and($service->getCount())->toBe(2);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('reports limit not reached when count is below limit', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
- $user = makeUser(1); // limit = 30
-
- $service->increment(); // count = 1
-
- expect($service->isLimitReached($user))->toBeFalse();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('reports limit reached when count equals the daily limit', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
- $user = makeUser(1); // limit = 30
-
- Cache::put($service->getCacheKey(), 30);
-
- expect($service->isLimitReached($user))->toBeTrue();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('reports limit reached when count exceeds the daily limit', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
- $user = makeUser(1); // limit = 30
-
- Cache::put($service->getCacheKey(), 31);
-
- expect($service->isLimitReached($user))->toBeTrue();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('uses separate keys for different days', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
-
- Cache::put('ai_requests:2024-01-01', 99);
- Cache::put('ai_requests:2024-01-02', 5);
-
- // Only the current day's key is used
- expect($service->getCount())->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php
deleted file mode 100644
index 28c6ca78a..000000000
--- a/_api_app/tests/Feature/AiChatControllerTest.php
+++ /dev/null
@@ -1,337 +0,0 @@
- 'Make the background blue',
- 'site' => '',
- 'template' => 'default',
- ])->assertStatus(401);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses structured json response from anthropic', function () {
- AssistantAgent::fake([
- '{"reply": "Changed background to blue.", "design_changes": [{"group": "background", "setting": "backgroundColor", "value": "#0000ff"}], "settings_changes": []}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'make background blue']]);
-
- expect($result['reply'])->toBe('Changed background to blue.')
- ->and($result['design_changes'])->toHaveCount(1)
- ->and($result['design_changes'][0]['group'])->toBe('background')
- ->and($result['design_changes'][0]['setting'])->toBe('backgroundColor')
- ->and($result['design_changes'][0]['value'])->toBe('#0000ff')
- ->and($result['settings_changes'])->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns empty changes when ai response has no json', function () {
- AssistantAgent::fake([
- 'I cannot help with that.',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'hello']]);
-
- expect($result['reply'])->toBe('I cannot help with that.')
- ->and($result['design_changes'])->toBeEmpty()
- ->and($result['settings_changes'])->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns empty changes when json is embedded in prose', function () {
- AssistantAgent::fake([
- 'Sure! Here is my response: {"reply": "Done!", "design_changes": [], "settings_changes": []} — let me know if you need more.',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'reset']]);
-
- expect($result['reply'])->toBe('Done!')
- ->and($result['design_changes'])->toBeEmpty()
- ->and($result['settings_changes'])->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses site settings changes from ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Updated the page title.", "design_changes": [], "settings_changes": [{"group": "texts", "setting": "pageTitle", "value": "My Site"}]}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'set the page title to My Site']]);
-
- expect($result['reply'])->toBe('Updated the page title.')
- ->and($result['design_changes'])->toBeEmpty()
- ->and($result['settings_changes'])->toHaveCount(1)
- ->and($result['settings_changes'][0]['group'])->toBe('texts')
- ->and($result['settings_changes'][0]['setting'])->toBe('pageTitle')
- ->and($result['settings_changes'][0]['value'])->toBe('My Site');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses is_undo from ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Reverted font size.", "is_undo": true, "design_changes": [{"group": "bodyText", "setting": "fontSize", "value": "12px"}], "settings_changes": []}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'undo']]);
-
- expect($result['is_undo'])->toBeTrue()
- ->and($result['reply'])->toBe('Reverted font size.')
- ->and($result['design_changes'])->toHaveCount(1)
- ->and($result['design_changes'][0]['value'])->toBe('12px');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('enriches changes with previous_value', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'enrichChangesWithPreviousValues');
-
- $changes = [
- ['group' => 'bodyText', 'setting' => 'fontSize', 'value' => '16px'],
- ['group' => 'bodyText', 'setting' => 'fontFamily', 'value' => 'Arial'],
- ];
- $currentSettings = [
- 'bodyText' => ['fontSize' => '12px'],
- ];
-
- $result = $method->invoke($controller, $changes, $currentSettings);
-
- expect($result[0]['previous_value'])->toBe('12px')
- ->and($result[1]['previous_value'])->toBeNull();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes change history in system prompt', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildChangeHistorySection');
-
- $changeHistory = [
- [
- 'user_message' => 'make the font bigger',
- 'design_changes' => [
- ['group' => 'bodyText', 'setting' => 'fontSize', 'value' => '16px', 'previous_value' => '12px'],
- ],
- 'settings_changes' => [],
- ],
- [
- 'user_message' => 'make background dark',
- 'design_changes' => [
- ['group' => 'background', 'setting' => 'backgroundColor', 'value' => '#000000', 'previous_value' => '#ffffff'],
- ],
- 'settings_changes' => [],
- ],
- ];
-
- $result = $method->invoke($controller, $changeHistory);
-
- expect($result)
- ->toContain('Change History')
- ->toContain('make the font bigger')
- ->toContain('bodyText > fontSize')
- ->toContain('"12px" → "16px"')
- ->toContain('make background dark')
- ->toContain('background > backgroundColor')
- ->toContain('"#ffffff" → "#000000"');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('omits change history section when history is empty', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildChangeHistorySection');
-
- $result = $method->invoke($controller, []);
-
- expect($result)->toBe('');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses entry_changes from ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Created an entry.", "is_undo": false, "design_changes": [], "settings_changes": [], "section_changes": [], "entry_changes": [{"operation": "create", "section": "blog", "description": "Hello world
"}]}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'add an entry to blog']]);
-
- expect($result['entry_changes'])->toHaveCount(1)
- ->and($result['entry_changes'][0]['operation'])->toBe('create')
- ->and($result['entry_changes'][0]['section'])->toBe('blog')
- ->and($result['entry_changes'][0]['description'])->toBe('Hello world
');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns empty entry_changes when not in ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Done.", "design_changes": [], "settings_changes": [], "section_changes": []}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'hello']]);
-
- expect($result['entry_changes'])->toBeArray()->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes entry changes in change history section', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildChangeHistorySection');
-
- $changeHistory = [
- [
- 'user_message' => 'add an entry',
- 'design_changes' => [],
- 'settings_changes' => [],
- 'section_changes' => [],
- 'entry_changes' => [
- ['operation' => 'create', 'section' => 'blog', 'entry_id' => '5', 'description' => 'Hello
'],
- ],
- ],
- [
- 'user_message' => 'update entry description',
- 'design_changes' => [],
- 'settings_changes' => [],
- 'section_changes' => [],
- 'entry_changes' => [
- ['operation' => 'update', 'section' => 'blog', 'entry_id' => '5', 'value' => 'New
', 'previous_value' => 'Old
'],
- ],
- ],
- ];
-
- $result = $method->invoke($controller, $changeHistory);
-
- expect($result)
- ->toContain('add an entry')
- ->toContain('entry: create #5 in "blog"')
- ->toContain('undo: delete entry 5')
- ->toContain('update entry description')
- ->toContain('entry: update #5 in "blog"')
- ->toContain('Old')
- ->toContain('New');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('restricts change_history entry_changes operation to create and update', function () {
- $request = new AiChatRequest;
- $rules = $request->rules();
-
- expect($rules['change_history.*.entry_changes.*.operation'])
- ->toContain('in:create,update');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes entries section in system prompt', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildEntriesSection');
-
- $result = $method->invoke($controller);
-
- expect($result)
- ->toContain('Entries')
- ->toContain('list_section_entries')
- ->toContain('get_entry_content')
- ->toContain('description')
- ->toContain('delete')
- ->toContain('manually');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes help articles in system prompt', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildHelpArticlesSection');
-
- $result = $method->invoke($controller);
-
- expect($result)
- ->toContain('Help Articles')
- ->toContain('How to Add a Video')
- ->toContain('https://support.berta.me/en/frequently-asked-questions/how-to-add-a-video')
- ->toContain('support.berta.me')
- ->toContain('Domains')
- ->toContain('SSL Certificates and HTTPS');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses gallery_changes from ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Updated gallery type.", "is_undo": false, "design_changes": [], "settings_changes": [], "section_changes": [], "entry_changes": [], "gallery_changes": [{"operation": "update_setting", "section": "portfolio", "entry_id": "3", "setting": "type", "value": "row"}]}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'change gallery to row']]);
-
- expect($result['gallery_changes'])->toHaveCount(1)
- ->and($result['gallery_changes'][0]['operation'])->toBe('update_setting')
- ->and($result['gallery_changes'][0]['section'])->toBe('portfolio')
- ->and($result['gallery_changes'][0]['entry_id'])->toBe('3')
- ->and($result['gallery_changes'][0]['setting'])->toBe('type')
- ->and($result['gallery_changes'][0]['value'])->toBe('row');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns empty gallery_changes when not in ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Done.", "design_changes": [], "settings_changes": [], "section_changes": []}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'hello']]);
-
- expect($result['gallery_changes'])->toBeArray()->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes gallery changes in change history section', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildChangeHistorySection');
-
- $changeHistory = [
- [
- 'user_message' => 'change gallery to row',
- 'design_changes' => [],
- 'settings_changes' => [],
- 'section_changes' => [],
- 'entry_changes' => [],
- 'gallery_changes' => [
- ['operation' => 'update_setting', 'section' => 'portfolio', 'entry_id' => '3', 'setting' => 'type', 'value' => 'row', 'previous_value' => 'slideshow'],
- ],
- ],
- [
- 'user_message' => 'update image caption',
- 'design_changes' => [],
- 'settings_changes' => [],
- 'section_changes' => [],
- 'entry_changes' => [],
- 'gallery_changes' => [
- ['operation' => 'update_caption', 'section' => 'portfolio', 'entry_id' => '3', 'file_index' => 0, 'value' => 'New caption
', 'previous_value' => 'Old caption
'],
- ],
- ],
- ];
-
- $result = $method->invoke($controller, $changeHistory);
-
- expect($result)
- ->toContain('gallery: update_setting entry #3 in "portfolio"')
- ->toContain('"slideshow" → "row"')
- ->toContain('gallery: update_caption entry #3 in "portfolio" file[0]')
- ->toContain('"Old caption" → "New caption"');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('restricts change_history gallery_changes operation to update_setting and update_caption', function () {
- $request = new AiChatRequest;
- $rules = $request->rules();
-
- expect($rules['change_history.*.gallery_changes.*.operation'])
- ->toContain('in:update_setting,update_caption');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes galleries section in system prompt', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildGalleriesSection');
-
- $result = $method->invoke($controller);
-
- expect($result)
- ->toContain('Galleries')
- ->toContain('get_entry_gallery')
- ->toContain('update_setting')
- ->toContain('update_caption')
- ->toContain('slideshow')
- ->toContain('fullscreen')
- ->toContain('file_index')
- ->toContain('manual');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/EditorSyncTest.php b/_api_app/tests/Feature/EditorSyncTest.php
new file mode 100644
index 000000000..0b110c5b7
--- /dev/null
+++ b/_api_app/tests/Feature/EditorSyncTest.php
@@ -0,0 +1,246 @@
+bertaRoot = sys_get_temp_dir() . '/berta_editor_sync_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'messy-0.4.2');
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+ actingAs(new User);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+/**
+ * Saves the font weight as an editor window that has seen the given change token.
+ */
+function saveFontWeight(string $value, string $editor, string $seenToken): TestResponse
+{
+ return patchJson(
+ route('site_template_settings'),
+ ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => $value],
+ ['X-Berta-Editor' => $editor, 'X-Berta-Change' => $seenToken],
+ );
+}
+
+function savedFontWeight(): string
+{
+ return (new SiteTemplateSettingsDataService('', 'messy-0.4.2'))->getState()['generalFontSettings']['fontWeight'];
+}
+
+it('tells the editor the latest change', function () {
+ $marker = (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ getJson(route('change_marker'))
+ ->assertOk()
+ ->assertExactJson($marker)
+ ->assertHeader('X-Berta-Change', $marker['token'])
+ ->assertHeader('X-Berta-Change-Source', 'agent');
+});
+
+it('marks a change for each save of an editor window', function () {
+ $response = saveFontWeight('bold', EDITOR_WINDOW_A, '')->assertOk();
+ $marker = (new ChangeMarker)->current();
+
+ expect($marker)->toMatchArray(['writer' => EDITOR_WINDOW_A, 'source' => 'editor'])
+ ->and($marker['token'])->not->toBe('')
+ ->and($response->headers->get('X-Berta-Change'))->toBe($marker['token']);
+});
+
+it('lets a window save again over its own changes', function () {
+ saveFontWeight('normal', EDITOR_WINDOW_A, '')->assertOk();
+
+ // The editor sends quick saves before the previous response is back.
+ saveFontWeight('bold', EDITOR_WINDOW_A, '')->assertOk();
+
+ expect(savedFontWeight())->toBe('bold');
+});
+
+it('refuses a save from a window that missed a change in another window', function () {
+ saveFontWeight('normal', EDITOR_WINDOW_A, '')->assertOk();
+
+ saveFontWeight('bold', EDITOR_WINDOW_B, '')
+ ->assertStatus(409)
+ ->assertJsonPath('error_message', 'This website was changed in another editor window. Reload to see the changes.')
+ ->assertJsonPath('change_source', 'editor');
+
+ expect(savedFontWeight())->toBe('normal');
+});
+
+it('refuses saves from every window that missed an agent change', function () {
+ $seen = saveFontWeight('normal', EDITOR_WINDOW_A, '')->headers->get('X-Berta-Change');
+ (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, $seen)
+ ->assertStatus(409)
+ ->assertJsonPath('error_message', 'This website was changed by an AI agent. Reload to see the changes.')
+ ->assertHeader('X-Berta-Change-Source', 'agent');
+
+ expect(savedFontWeight())->toBe('normal');
+});
+
+it('saves from a window that has seen the latest change', function () {
+ $marker = (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, $marker['token'])->assertOk();
+
+ expect(savedFontWeight())->toBe('bold');
+});
+
+it('saves unchecked from a window that has not seen a change yet', function () {
+ (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ // E.g. a save retried right after login, before the fresh data has arrived.
+ patchJson(
+ route('site_template_settings'),
+ ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => 'bold'],
+ ['X-Berta-Editor' => EDITOR_WINDOW_A],
+ )->assertOk();
+
+ expect(savedFontWeight())->toBe('bold')
+ ->and((new ChangeMarker)->current())->toMatchArray(['writer' => EDITOR_WINDOW_A, 'source' => 'editor']);
+});
+
+it('leaves requests without an editor window as before', function () {
+ (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+ $before = (new ChangeMarker)->current();
+
+ patchJson(route('site_template_settings'), ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => 'bold'])
+ ->assertOk();
+
+ expect(savedFontWeight())->toBe('bold')
+ ->and((new ChangeMarker)->current())->toBe($before);
+});
+
+it('lets saves through while no change is marked yet, e.g. right after an update', function () {
+ getJson(route('change_marker'))->assertHeader('X-Berta-Change', '');
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, '')->assertOk();
+
+ expect(savedFontWeight())->toBe('bold');
+});
+
+it('works as before when the cache fails, e.g. on the first request after updating an old install', function () {
+ Cache::shouldReceive('get')->andThrow(new RuntimeException('No cache table.'));
+ Cache::shouldReceive('forever')->andThrow(new RuntimeException('No cache table.'));
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, 'some-token')
+ ->assertOk()
+ ->assertHeader('X-Berta-Change', '');
+
+ expect(savedFontWeight())->toBe('bold');
+});
+
+it('ignores window ids the editor does not make', function () {
+ (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+ $before = (new ChangeMarker)->current();
+
+ saveFontWeight('bold', 'window-a', 'stale')
+ ->assertOk()
+ ->assertHeader('X-Berta-Change-Writer', '');
+
+ expect((new ChangeMarker)->current())->toBe($before);
+});
+
+it('marks nothing when a save is refused with an error message', function () {
+ // A subsite without a settings file: the data service answers 200 with error_message.
+ patchJson(
+ route('site_settings'),
+ ['path' => 'missing-site/settings/texts/pageTitle', 'value' => 'Studio'],
+ ['X-Berta-Editor' => EDITOR_WINDOW_A, 'X-Berta-Change' => ''],
+ )->assertOk()->assertJsonPath('error_message', 'Settings file not found in storage!');
+
+ expect((new ChangeMarker)->current()['token'])->toBe('');
+});
+
+it('leaves out requests that change no site data', function (string $route) {
+ expect(Route::getRoutes()->getByName($route)->excludedMiddleware())->toContain(EditorSync::class);
+})->with(['site_theme_preview', 'user_change_password']);
+
+it('lets the setup wizard of a new site save every step', function () {
+ $seen = '';
+ $steps = [
+ ['siteTexts/siteHeading', 'Studio'],
+ ['texts/ownerName', 'Ann'],
+ ['texts/metaDescription', 'Portfolio'],
+ ['siteTexts/siteFooter', 'Ann © '],
+ ['texts/pageTitle', 'Studio'],
+ ['berta/installed', '1'],
+ ];
+
+ foreach ($steps as [$path, $value]) {
+ $seen = patchJson(route('site_settings'), ['path' => "/settings/{$path}", 'value' => $value], ['X-Berta-Editor' => EDITOR_WINDOW_A, 'X-Berta-Change' => $seen])
+ ->assertOk()
+ ->assertJsonMissingPath('error_message')
+ ->headers->get('X-Berta-Change');
+ }
+
+ $settings = (new SiteSettingsDataService)->getState();
+
+ expect($settings['siteTexts']['siteFooter'])->toBe('Ann ©')
+ ->and($settings['berta']['installed'])->toBe('1')
+ ->and(array_column((new SiteSectionsDataService)->get(), 'name'))->toContain('home')
+ ->and((new ChangeMarker)->current())->toMatchArray(['token' => $seen, 'writer' => EDITOR_WINDOW_A]);
+});
+
+it('tells a window about a change someone else made while its save ran', function () {
+ $request = Request::create('/_api/v1/sites/settings', 'PATCH', server: ['HTTP_X_BERTA_EDITOR' => EDITOR_WINDOW_A, 'HTTP_X_BERTA_CHANGE' => '']);
+ $agentChange = null;
+
+ $response = (new EditorSync)->handle($request, function () use (&$agentChange) {
+ $agentChange = (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ return response()->json([]);
+ });
+
+ // The window reloads to see the agent's change; other windows learn about this save.
+ expect($response->headers->get('X-Berta-Change'))->toBe($agentChange['token'])
+ ->and($response->headers->get('X-Berta-Change-Source'))->toBe('agent')
+ ->and((new ChangeMarker)->current())->toMatchArray(['writer' => EDITOR_WINDOW_A, 'source' => 'editor']);
+});
+
+it('keeps the earlier change when the cache cannot store the save', function () {
+ Cache::shouldReceive('get')->andReturn(['token' => 'earlier', 'writer' => EDITOR_WINDOW_B, 'source' => 'editor']);
+ Cache::shouldReceive('forever')->andThrow(new RuntimeException('Read-only cache.'));
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, 'earlier')
+ ->assertOk()
+ ->assertHeader('X-Berta-Change', 'earlier');
+});
+
+it('treats anything else in the cache as no marker', function (mixed $cached) {
+ Cache::shouldReceive('get')->andReturn($cached);
+
+ expect((new ChangeMarker)->current())->toBe(['token' => '', 'writer' => '', 'source' => '']);
+})->with([
+ 'text' => ['garbage'],
+ 'missing keys' => [['token' => 'x']],
+ 'not text' => [['token' => 1, 'writer' => '', 'source' => '']],
+]);
diff --git a/_api_app/tests/Feature/MediaFoldersTest.php b/_api_app/tests/Feature/MediaFoldersTest.php
new file mode 100644
index 000000000..7b01b0e41
--- /dev/null
+++ b/_api_app/tests/Feature/MediaFoldersTest.php
@@ -0,0 +1,182 @@
+bertaRoot = sys_get_temp_dir() . '/berta_media_folders_' . uniqid();
+ $this->storage = $this->bertaRoot . '/storage';
+ File::ensureDirectoryExists($this->storage);
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'messy');
+
+ // "photographs" has a gallery in a folder named after another section, as on sites from older Berta versions,
+ // and a background; "news" has two entries with their own folders.
+ file_put_contents(
+ $this->storage . '/sections.xml',
+ ''
+ . ''
+ . ''
+ . '',
+ );
+ writeBlog($this->storage, 'photographs', ['1' => 'news1']);
+ writeBlog($this->storage, 'news', ['1' => 'news2', '2' => 'news-old']);
+ writeMediaFile($this->storage, 'news1/sky.jpg');
+ writeMediaFile($this->storage, 'news2/sea.jpg');
+ writeMediaFile($this->storage, 'news-old/field.jpg');
+ writeMediaFile($this->storage, 'photographs-background/bg.jpg');
+
+ Event::fake([SectionUpdated::class]);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+/**
+ * Writes a section's entries file, with each entry's media folder.
+ *
+ * @param array $mediaFolders entry id => media folder
+ */
+function writeBlog(string $storage, string $section, array $mediaFolders): void
+{
+ $entries = '';
+
+ foreach ($mediaFolders as $id => $mediaFolder) {
+ $entries .= ""
+ . '';
+ }
+
+ file_put_contents($storage . "/blog.{$section}.xml", "{$entries}");
+}
+
+function writeMediaFile(string $storage, string $path): void
+{
+ File::ensureDirectoryExists(dirname($storage . '/media/' . $path));
+ file_put_contents($storage . '/media/' . $path, 'image');
+}
+
+/**
+ * The media folder of each entry of a section, by entry id.
+ *
+ * @return array
+ */
+function mediaFolders(string $section): array
+{
+ $entries = (new SectionEntriesDataService('', $section))->get()['entry'];
+
+ return array_combine(array_column($entries, 'id'), array_column($entries, 'mediafolder'));
+}
+
+it('copies a section into new media folders, also where a folder is not named after its section', function () {
+ $copy = (new SiteSectionsDataService)->cloneSection('photographs', 'Photographs');
+
+ $copiedFolder = mediaFolders($copy['name'])['1'];
+ expect($copy['name'])->toBe('clone-of-photographs')
+ ->and($copiedFolder)->not->toBe('news1')
+ ->and($this->storage . "/media/{$copiedFolder}/sky.jpg")->toBeFile()
+ ->and($this->storage . '/media/clone-of-photographs-background/bg.jpg')->toBeFile();
+
+ (new SiteSectionsDataService)->delete($copy['name']);
+
+ expect($this->storage . "/media/{$copiedFolder}")->not->toBeDirectory()
+ ->and($this->storage . '/media/news1/sky.jpg')->toBeFile()
+ ->and($this->storage . '/media/photographs-background/bg.jpg')->toBeFile();
+});
+
+it('moves an entry with its gallery into a folder no other gallery uses', function () {
+ $moved = (new SectionEntriesDataService('', 'photographs'))->moveEntry('1', 'news');
+
+ // news1 is still taken by the moving entry and news2 by another entry, so the copy gets news3.
+ expect($moved)->not->toHaveKey('error_message')
+ ->and(mediaFolders('news')['3'])->toBe('news3')
+ ->and($this->storage . '/media/news3/sky.jpg')->toBeFile()
+ ->and($this->storage . '/media/news1')->not->toBeDirectory()
+ ->and($this->storage . '/media/news2/sea.jpg')->toBeFile();
+});
+
+it('keeps the entry and its files when the move can not copy them', function () {
+ if (function_exists('posix_getuid') && posix_getuid() === 0) {
+ $this->markTestSkipped('Root can write to a read-only folder.');
+ }
+
+ chmod($this->storage . '/media', 0555);
+
+ try {
+ $moved = (new SectionEntriesDataService('', 'photographs'))->moveEntry('1', 'news');
+ } finally {
+ chmod($this->storage . '/media', 0755);
+ }
+
+ expect($moved)->toHaveKey('error_message')
+ ->and(mediaFolders('photographs'))->toBe(['1' => 'news1'])
+ ->and(mediaFolders('news'))->toHaveCount(2)
+ ->and($this->storage . '/media/news1/sky.jpg')->toBeFile();
+});
+
+it('names the media folders after a renamed section, one per entry', function () {
+ (new SiteSectionsDataService)->saveValueByPath('/section/1/title', 'Archive');
+
+ expect(mediaFolders('archive'))->toBe(['1' => 'archive1', '2' => 'archive2'])
+ ->and($this->storage . '/media/archive1/sea.jpg')->toBeFile()
+ ->and($this->storage . '/media/archive2/field.jpg')->toBeFile();
+});
+
+it('keeps an entry in its folder when the new folder name is in use', function () {
+ File::ensureDirectoryExists($this->storage . '/media/archive1');
+
+ (new SiteSectionsDataService)->saveValueByPath('/section/1/title', 'Archive');
+
+ expect(mediaFolders('archive'))->toBe(['1' => 'news2', '2' => 'archive2'])
+ ->and($this->storage . '/media/news2/sea.jpg')->toBeFile();
+});
+
+it('keeps the section name and its folders when only the title case changes', function () {
+ $saved = (new SiteSectionsDataService)->saveValueByPath('/section/1/title', 'NEWS');
+
+ expect($saved['section']['name'])->toBe('news')
+ ->and($saved['section']['title'])->toBe('NEWS')
+ ->and(mediaFolders('news'))->toBe(['1' => 'news2', '2' => 'news-old']);
+});
+
+it('keeps the background where it is when its folder can not be renamed', function () {
+ writeMediaFile($this->storage, 'gallery-background/other.jpg');
+
+ $saved = (new SiteSectionsDataService)->saveValueByPath('/section/0/title', 'Gallery');
+
+ expect($saved['section']['name'])->toBe('gallery')
+ ->and($saved['section']['mediafolder'])->toBe('photographs-background')
+ ->and($this->storage . '/media/photographs-background/bg.jpg')->toBeFile();
+});
+
+it('keeps a media folder that another entry uses when an entry is deleted', function (string $sharingSection) {
+ writeBlog($this->storage, 'news', ['1' => 'news2', '2' => 'news-old', '3' => $sharingSection === 'news' ? 'news2' : 'news1']);
+
+ (new SectionEntriesDataService('', $sharingSection === 'news' ? 'news' : 'photographs'))->deleteEntry('1');
+
+ expect($this->storage . ($sharingSection === 'news' ? '/media/news2/sea.jpg' : '/media/news1/sky.jpg'))->toBeFile();
+})->with([
+ 'in the same section' => 'news',
+ 'in another section' => 'photographs',
+]);
+
+it('still deletes the media folder of a deleted entry that no other gallery uses', function () {
+ (new SectionEntriesDataService('', 'news'))->deleteEntry('2');
+
+ expect($this->storage . '/media/news-old')->not->toBeDirectory();
+});
+
+it('keeps media folders that other sections use when a section is deleted', function () {
+ writeBlog($this->storage, 'news', ['1' => 'news2', '2' => 'news1']);
+
+ (new SiteSectionsDataService)->delete('photographs');
+
+ expect($this->storage . '/media/news1/sky.jpg')->toBeFile()
+ ->and($this->storage . '/media/photographs-background')->not->toBeDirectory()
+ ->and($this->storage . '/blog.photographs.xml')->not->toBeFile();
+});
diff --git a/_api_app/tests/Feature/RenewEditorTokenTest.php b/_api_app/tests/Feature/RenewEditorTokenTest.php
new file mode 100644
index 000000000..c7a3643a7
--- /dev/null
+++ b/_api_app/tests/Feature/RenewEditorTokenTest.php
@@ -0,0 +1,87 @@
+bertaRoot = sys_get_temp_dir() . '/berta_editor_token_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ givePlanFeatures($this->bertaRoot, []);
+ // The account's login details last changed long before the tokens here were issued.
+ touch($this->bertaRoot . '/engine/config/inc.conf.php', time() - 40 * 86400);
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+/**
+ * An editor token as Berta or berta.me hosting issues it, the given number of seconds ago.
+ *
+ * @param array $claims
+ */
+function editorTokenIssuedAgo(int $seconds, array $claims = []): string
+{
+ return JWT::encode([...$claims, 'iat' => time() - $seconds, 'exp' => time() - $seconds + EditorToken::LIFETIME_SECONDS], config('app.key'), 'HS256');
+}
+
+it('renews a token older than an hour, keeping its claims', function () {
+ $response = getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(2 * 3600, ['uid' => 42])])
+ ->assertOk()
+ ->assertHeader('X-Berta-Token');
+
+ // No proxy or CDN may store a response carrying a session token.
+ expect($response->headers->get('Cache-Control'))->toContain('no-store');
+
+ $renewed = (array) JWT::decode($response->headers->get('X-Berta-Token'), new Key(config('app.key'), 'HS256'));
+
+ // The hosting user's id, used by the support chat.
+ expect($renewed['uid'])->toBe(42)
+ ->and($renewed['exp'])->toBeGreaterThanOrEqual(time() + EditorToken::LIFETIME_SECONDS - 5);
+});
+
+it('leaves a recent token as it is', function () {
+ getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(600)])
+ ->assertOk()
+ ->assertHeaderMissing('X-Berta-Token');
+});
+
+it('does not renew an expired token', function () {
+ getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(EditorToken::LIFETIME_SECONDS + 3600)])
+ ->assertUnauthorized()
+ ->assertHeaderMissing('X-Berta-Token');
+});
+
+it('keeps the login time through renewals', function () {
+ $response = getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(2 * 3600, ['auth_time' => time() - 5 * 86400])])
+ ->assertOk();
+
+ $renewed = (array) JWT::decode($response->headers->get('X-Berta-Token'), new Key(config('app.key'), 'HS256'));
+
+ expect($renewed['auth_time'])->toBeLessThanOrEqual(time() - 5 * 86400);
+});
+
+it('stops renewing a month after the login, so a leaked token ends', function () {
+ getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(2 * 3600, ['auth_time' => time() - (EditorToken::MAX_SESSION_DAYS + 1) * 86400])])
+ ->assertOk()
+ ->assertHeaderMissing('X-Berta-Token');
+});
+
+it('stops renewing sessions that started before the account changed, e.g. a password change or website transfer', function () {
+ touch($this->bertaRoot . '/engine/config/inc.conf.php', time() - 3600);
+
+ getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(2 * 3600)])
+ ->assertOk()
+ ->assertHeaderMissing('X-Berta-Token');
+});
diff --git a/_api_app/tests/Feature/SettingsValidationTest.php b/_api_app/tests/Feature/SettingsValidationTest.php
new file mode 100644
index 000000000..4097ae275
--- /dev/null
+++ b/_api_app/tests/Feature/SettingsValidationTest.php
@@ -0,0 +1,69 @@
+bertaRoot = sys_get_temp_dir() . '/berta_settings_validation_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'messy-0.4.2');
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+ actingAs(new User);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+describe('design settings', function () {
+ it('saves a valid value', function () {
+ patchJson(route('site_template_settings'), ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => 'bold'])
+ ->assertOk()
+ ->assertJsonMissingPath('error_message');
+
+ expect(file_get_contents($this->bertaRoot . '/storage/settings.messy.xml'))->toContain('bold');
+ });
+
+ it('refuses a value the editor would not offer, without saving it', function () {
+ patchJson(route('site_template_settings'), ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => 'heavy'])
+ ->assertStatus(422)
+ ->assertJsonPath('error_message', 'Use one of the allowed values: normal | bold.');
+
+ expect(file_exists($this->bertaRoot . '/storage/settings.messy.xml'))->toBeFalse();
+ });
+});
+
+describe('site settings', function () {
+ it('refuses a setting that needs a feature the plan does not include', function () {
+ givePlanFeatures($this->bertaRoot, ['multisite']);
+
+ patchJson(route('site_settings'), ['path' => '/settings/socialMediaButtons/socialMediaHTML', 'value' => ''])
+ ->assertStatus(422)
+ ->assertJsonPath('error_message', 'This setting needs the custom_javascript feature, which the website\'s plan doesn\'t include.');
+
+ expect(file_get_contents($this->bertaRoot . '/storage/settings.xml'))->not->toContain('
', 'Hi
'],
+ 'script in svg' => ['', ''],
+ 'event handler' => ['Hi
', 'Hi
'],
+ 'event handler on an image' => ['
', '
'],
+ 'javascript link' => ['Hi', 'Hi'],
+ 'javascript link with hidden characters' => ['Hi', 'Hi'],
+ 'vbscript link' => ['Hi', 'Hi'],
+ 'javascript form action' => ['', ''],
+ 'iframe document' => ['', ''],
+]);
+
+it('keeps what the editor allows unchanged', function (string $html) {
+ expect((new HtmlCleaner)->clean($html))->toBe($html);
+})->with([
+ 'formatting and links' => ['Released Velvet Blood Noir from the publisher.
'],
+ 'video embed' => [''],
+ 'shop button' => [''],
+ 'styles' => ['Hi
'],
+ 'plain text' => ['Studio'],
+ 'empty' => [''],
+]);
diff --git a/_api_app/tests/Unit/SectionEntryRenderServiceTest.php b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php
new file mode 100644
index 000000000..8dfc7f697
--- /dev/null
+++ b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php
@@ -0,0 +1,23 @@
+toBe($shown);
+})->with([
+ 'default template' => ['default', 'default', true],
+ 'default template, portfolio section' => ['default', 'portfolio', true],
+ 'other template, portfolio section' => ['messy', 'portfolio', true],
+ 'other template' => ['messy', 'default', false],
+ 'other template, no section type' => ['messy', null, false],
+]);
+
+it('accepts entries in sections other than external links and the shopping cart', function (?string $sectionType, bool $accepts) {
+ expect(SectionEntryRenderService::acceptsEntries($sectionType))->toBe($accepts);
+})->with([
+ 'no section type' => [null, true],
+ 'default' => ['default', true],
+ 'portfolio' => ['portfolio', true],
+ 'external link' => ['external_link', false],
+ 'shopping cart' => ['shopping_cart', false],
+]);
diff --git a/_api_app/tests/Unit/SettingValueValidatorTest.php b/_api_app/tests/Unit/SettingValueValidatorTest.php
new file mode 100644
index 000000000..6a5acb74f
--- /dev/null
+++ b/_api_app/tests/Unit/SettingValueValidatorTest.php
@@ -0,0 +1,51 @@
+error($value, $definition))->toBeNull();
+})->with([
+ 'select value' => ['bold', ['format' => 'select', 'values' => ['normal', 'bold']]],
+ 'font by its CSS value' => ['"Comic Sans MS", cursive', ['format' => 'fontselect', 'values' => ['"Comic Sans MS", cursive' => 'Comic Sans MS']]],
+ 'hex color' => ['#00ff00', ['format' => 'color']],
+ 'short hex color' => ['#0f0', ['format' => 'color']],
+ 'rgb color' => ['rgb(0, 44, 255)', ['format' => 'color']],
+ 'color picker rgba' => ['rgba(0,0,0,0.5)', ['format' => 'color']],
+ 'css units' => ['12px', ['format' => 'text', 'css_units' => true]],
+ 'percent' => ['50%', ['format' => 'text', 'css_units' => true]],
+ 'blank when allowed' => ['', ['format' => 'text', 'allow_blank' => true]],
+ 'positive integer' => ['3', ['format' => 'text', 'validation' => 'positive_integer']],
+ 'zero' => ['0', ['format' => 'text', 'validation' => 'zero_or_positive_integer']],
+ 'positive number' => ['1.5', ['format' => 'text', 'validation' => 'positive_number']],
+ 'email' => ['me@example.com', ['format' => 'text', 'validation' => 'email']],
+ // The editor doesn't validate these inputs either.
+ 'blank long text' => ['', ['format' => 'longtext']],
+ 'any url' => ['example.com', ['format' => 'url']],
+ 'image file name' => ['photo.jpg', ['format' => 'image']],
+]);
+
+it('refuses values the editor refuses', function (string $value, array $definition, string $error) {
+ expect((new SettingValueValidator([]))->error($value, $definition))->toContain($error);
+})->with([
+ 'value not allowed' => ['heavy', ['format' => 'select', 'values' => ['normal', 'bold']], 'normal | bold'],
+ 'font by its name' => ['Comic Sans MS', ['format' => 'fontselect', 'values' => ['"Comic Sans MS", cursive' => 'Comic Sans MS']], 'allowed values'],
+ 'not a color' => ['red', ['format' => 'color'], 'color'],
+ 'missing css unit' => ['12', ['format' => 'text', 'css_units' => true], 'CSS unit'],
+ 'blank' => ['', ['format' => 'text'], 'can\'t be empty'],
+ 'not a positive integer' => ['0', ['format' => 'text', 'validation' => 'positive_integer'], 'positive whole number'],
+ 'not an email' => ['me@', ['format' => 'text', 'validation' => 'email'], 'email'],
+]);
+
+it('allows settings that need a plan feature only when the plan includes it', function () {
+ $definition = ['format' => 'longtext', 'requires_feature' => 'custom_javascript'];
+
+ expect((new SettingValueValidator(['custom_javascript']))->error('', $definition))->toBeNull()
+ ->and((new SettingValueValidator(['multisite']))->error('', $definition))->toContain('custom_javascript feature');
+});
+
+it('leaves paths without a definition and non-text values to the data service', function (mixed $value, mixed $definition) {
+ expect((new SettingValueValidator([]))->errorFor($value, $definition))->toBeNull();
+})->with([
+ 'no definition' => ['anything', null],
+ 'list value' => [['url' => 'x'], ['format' => 'text']],
+]);
diff --git a/editor/src/app/app-state/app-state.service.ts b/editor/src/app/app-state/app-state.service.ts
index 794fb0cce..cc8110a7c 100644
--- a/editor/src/app/app-state/app-state.service.ts
+++ b/editor/src/app/app-state/app-state.service.ts
@@ -120,9 +120,13 @@ export class AppStateService {
}),
tap(() => this.hideLoading()),
catchError((error) => {
- // 429 responses are handled by the specific feature (e.g. AI chat panel),
- // so skip the generic error notification to avoid a duplicate message.
- if (!(error instanceof HttpErrorResponse) || error.status !== 429) {
+ // 429 responses are handled by the specific feature (e.g. AI chat panel), and 409 (the
+ // website was changed elsewhere) by the editor sync popup, so skip the generic error
+ // notification to avoid a duplicate message.
+ if (
+ !(error instanceof HttpErrorResponse) ||
+ ![409, 429].includes(error.status)
+ ) {
this.showError(error, data);
}
this.hideLoading();
diff --git a/editor/src/app/app.module.ts b/editor/src/app/app.module.ts
index ff422f011..d749a5585 100644
--- a/editor/src/app/app.module.ts
+++ b/editor/src/app/app.module.ts
@@ -50,6 +50,7 @@ import * as Sentry from '@sentry/angular';
import { Router } from '@angular/router';
import { sentryInitFactory } from './sentry/sentry-init.factory';
import { pendingRequestsInterceptor } from './shared/pending-requests';
+import { editorSyncInterceptor } from './shared/editor-sync';
@NgModule({
declarations: [
@@ -122,7 +123,7 @@ import { pendingRequestsInterceptor } from './shared/pending-requests';
{ provide: APP_BASE_HREF, useValue: '/engine/' },
provideHttpClient(
withInterceptorsFromDi(),
- withInterceptors([pendingRequestsInterceptor]),
+ withInterceptors([pendingRequestsInterceptor, editorSyncInterceptor]),
),
],
})
diff --git a/editor/src/app/popup/popup.interface.ts b/editor/src/app/popup/popup.interface.ts
index cd955ff49..0a546b59e 100644
--- a/editor/src/app/popup/popup.interface.ts
+++ b/editor/src/app/popup/popup.interface.ts
@@ -1,7 +1,7 @@
import { PopupService } from './popup.service';
export interface PopupState {
- type: 'error' | 'warning' | 'info' | 'success' | Object;
+ type: 'error' | 'warn' | 'info' | 'success' | Object;
content: any;
showOverlay?: boolean;
isModal?: boolean;
diff --git a/editor/src/app/preview/sortable/sortable.service.spec.ts b/editor/src/app/preview/sortable/sortable.service.spec.ts
index 69d8df6c4..4daafe07b 100644
--- a/editor/src/app/preview/sortable/sortable.service.spec.ts
+++ b/editor/src/app/preview/sortable/sortable.service.spec.ts
@@ -14,6 +14,13 @@ interface Point {
* Drives the service with synthetic pointer events against a real
* same-origin iframe, the way the preview uses it.
*/
+/**
+ * The synthetic pointer's id. Not 1, the real mouse's id in Chrome: moving the real mouse over the
+ * test page while a spec drags would otherwise move the synthetic drag too (rare failures in a
+ * visible browser, e.g. auto-scroll stopping).
+ */
+const POINTER_ID = 9;
+
describe('SortableService', () => {
let iframe: HTMLIFrameElement;
let doc: Document;
@@ -103,7 +110,7 @@ describe('SortableService', () => {
bubbles: true,
cancelable: true,
isPrimary: true,
- pointerId: 1,
+ pointerId: POINTER_ID,
button: 0,
clientX: point.x,
clientY: point.y,
@@ -431,7 +438,10 @@ describe('SortableService', () => {
el('.xEntriesList').outerHTML = ENTRIES_HTML(['1', '2', '3']);
// Fired at the document once the capturing list is gone.
doc.dispatchEvent(
- new PointerEvent('lostpointercapture', { bubbles: true, pointerId: 1 }),
+ new PointerEvent('lostpointercapture', {
+ bubbles: true,
+ pointerId: POINTER_ID,
+ }),
);
expect(doc.documentElement.classList).not.toContain('xSorting');
@@ -660,7 +670,7 @@ describe('SortableService', () => {
pointer('pointermove', link, at(el('#tag-b'), 0.8, 0.5));
- expect(capture).toHaveBeenCalledOnceWith(1);
+ expect(capture).toHaveBeenCalledOnceWith(POINTER_ID);
expect(capture.calls.mostRecent().object).toBe(el('.subMenu'));
});
diff --git a/editor/src/app/shared/editor-sync.spec.ts b/editor/src/app/shared/editor-sync.spec.ts
new file mode 100644
index 000000000..4da7f5506
--- /dev/null
+++ b/editor/src/app/shared/editor-sync.spec.ts
@@ -0,0 +1,288 @@
+import { TestBed } from '@angular/core/testing';
+import {
+ HttpClient,
+ provideHttpClient,
+ withInterceptors,
+} from '@angular/common/http';
+import {
+ HttpTestingController,
+ provideHttpClientTesting,
+} from '@angular/common/http/testing';
+import { ActionStatus, Actions, Store } from '@ngxs/store';
+import { Subject } from 'rxjs';
+
+import { EditorSyncService, editorSyncInterceptor } from './editor-sync';
+import { PopupService } from '../popup/popup.service';
+import {
+ UserLoginAction,
+ UserLogoutAction,
+ UserTokenRenewedAction,
+} from '../user/user.actions';
+
+describe('editorSyncInterceptor', () => {
+ let http: HttpClient;
+ let backend: HttpTestingController;
+ let sync: EditorSyncService;
+ let popupService: PopupService;
+ let store: jasmine.SpyObj;
+ let actions$: Subject<{ action: unknown; status: ActionStatus }>;
+
+ /** Answers a request with the latest change, as the API's EditorSync middleware does. */
+ function answer(
+ url: string,
+ change: { token: string; writer?: string; source?: string },
+ status = 200,
+ ) {
+ backend.expectOne(url).flush(
+ {},
+ {
+ status,
+ statusText: status === 200 ? 'OK' : 'Conflict',
+ headers: {
+ 'X-Berta-Change': change.token,
+ 'X-Berta-Change-Writer': change.writer ?? '',
+ 'X-Berta-Change-Source': change.source ?? '',
+ },
+ },
+ );
+ }
+
+ beforeEach(() => {
+ popupService = new PopupService();
+ spyOn(popupService, 'showPopup').and.callThrough();
+ store = jasmine.createSpyObj('Store', ['dispatch', 'select']);
+ actions$ = new Subject();
+
+ TestBed.configureTestingModule({
+ providers: [
+ provideHttpClient(withInterceptors([editorSyncInterceptor])),
+ provideHttpClientTesting(),
+ { provide: PopupService, useValue: popupService },
+ { provide: Store, useValue: store },
+ { provide: Actions, useValue: actions$ },
+ ],
+ });
+ http = TestBed.inject(HttpClient);
+ backend = TestBed.inject(HttpTestingController);
+ sync = TestBed.inject(EditorSyncService);
+ });
+
+ afterEach(() => backend.verify());
+
+ it('sends the window and the latest change it has seen to the editor API', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+
+ http.patch('/_api/v1/sites/settings', {}).subscribe();
+ const save = backend.expectOne('/_api/v1/sites/settings');
+
+ expect(save.request.headers.get('X-Berta-Editor')).toBe(sync.editorId);
+ expect(save.request.headers.get('X-Berta-Change')).toBe('change-1');
+ save.flush({});
+ });
+
+ it('sends no change before the window has seen one', () => {
+ http.get('/_api/v1/state').subscribe();
+ const request = backend.expectOne('/_api/v1/state');
+
+ expect(request.request.headers.get('X-Berta-Editor')).toBe(sync.editorId);
+ expect(request.request.headers.has('X-Berta-Change')).toBeFalse();
+ request.flush({});
+ });
+
+ it('starts over after logout, since the login loads all data fresh', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+
+ actions$.next({
+ action: new UserLogoutAction(),
+ status: ActionStatus.Dispatched,
+ });
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-2', source: 'agent' });
+
+ expect(sync.seenChange).toBe('change-2');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ it('ignores an answer still on its way at logout once logged in again', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+ http.get('/_api/v1/sites/render-menu').subscribe();
+
+ actions$.next({
+ action: new UserLogoutAction(),
+ status: ActionStatus.Dispatched,
+ });
+ answer('/_api/v1/sites/render-menu', {
+ token: 'change-1',
+ source: 'agent',
+ });
+ actions$.next({
+ action: new UserLoginAction({ token: 'new-token' }),
+ status: ActionStatus.Dispatched,
+ });
+ // The login reloads the data, with a change made while logged out.
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-2', source: 'agent' });
+
+ expect(sync.seenChange).toBe('change-2');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ it('shows nothing after an update, before any change is marked', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: '' });
+
+ http.patch('/_api/v1/sites/settings', {}).subscribe();
+ answer('/_api/v1/sites/settings', {
+ token: 'change-1',
+ writer: sync.editorId,
+ source: 'editor',
+ });
+
+ expect(sync.seenChange).toBe('change-1');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ it('shows nothing when the answer has no sync headers', () => {
+ // An expired session (401) answers before the API's sync check; a proxy may drop empty headers.
+ http.get('/_api/v1/state').subscribe({ error: () => {} });
+ backend
+ .expectOne('/_api/v1/state')
+ .flush({}, { status: 401, statusText: 'Unauthorized' });
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ backend.expectOne('/_api/v1/sites/render-menu').flush({});
+
+ http.patch('/_api/v1/sites/settings', {}).subscribe();
+ const save = backend.expectOne('/_api/v1/sites/settings');
+
+ expect(save.request.headers.has('X-Berta-Change')).toBeFalse();
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ save.flush({});
+ });
+
+ it('leaves requests to other sites alone', () => {
+ http.get('https://example.com/data').subscribe();
+ const request = backend.expectOne('https://example.com/data');
+
+ expect(request.request.headers.has('X-Berta-Editor')).toBeFalse();
+ request.flush({});
+ });
+
+ it("follows the window's own changes, also when answers come out of order", () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ http.patch('/_api/v1/sites/settings', {}).subscribe();
+ answer('/_api/v1/sites/settings', {
+ token: 'change-2',
+ writer: sync.editorId,
+ source: 'editor',
+ });
+ answer('/_api/v1/sites/render-menu', {
+ token: 'change-1',
+ source: 'agent',
+ });
+
+ expect(sync.seenChange).toBe('change-2');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ it('asks once to reload when someone else changed the website', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', {
+ token: 'change-1',
+ source: 'editor',
+ writer: 'other',
+ });
+
+ // The API refuses the stale save.
+ http.patch('/_api/v1/sites/settings', {}).subscribe({ error: () => {} });
+ answer(
+ '/_api/v1/sites/settings',
+ { token: 'change-2', source: 'agent' },
+ 409,
+ );
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ answer('/_api/v1/sites/render-menu', {
+ token: 'change-2',
+ source: 'agent',
+ });
+
+ expect(popupService.showPopup).toHaveBeenCalledTimes(1);
+ expect(
+ (popupService.showPopup as jasmine.Spy).calls.mostRecent().args[0],
+ ).toEqual(
+ jasmine.objectContaining({
+ type: 'warn',
+ content:
+ 'This website was changed by an AI agent. Reload to see the changes.',
+ isModal: true,
+ }),
+ );
+ expect(sync.seenChange).toBe('change-1');
+ });
+
+ it('ignores an empty change after a real one, e.g. after the cache was cleared', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ answer('/_api/v1/sites/render-menu', { token: '' });
+
+ expect(sync.seenChange).toBe('change-1');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ /** Makes the API answer with a change by an AI agent, which asks to reload. */
+ function changeByAgent() {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ answer('/_api/v1/sites/render-menu', {
+ token: 'change-2',
+ source: 'agent',
+ });
+ }
+
+ it('asks to reload again after another popup replaced the request and closed', async () => {
+ changeByAgent();
+ const reloadPopup = popupService.popupState$.value;
+ expect(reloadPopup).not.toBeNull();
+
+ // An error message replaces it for a moment.
+ popupService.showPopup({
+ type: 'error',
+ content: 'Oops unexpected error!',
+ });
+ popupService.closePopup();
+ await Promise.resolve();
+
+ expect(popupService.popupState$.value).toBe(reloadPopup);
+ });
+
+ it('closes the request to reload on login, which loads all data fresh', async () => {
+ changeByAgent();
+
+ actions$.next({
+ action: new UserLoginAction({ token: 'new-token' }),
+ status: ActionStatus.Dispatched,
+ });
+ await Promise.resolve();
+
+ expect(popupService.popupState$.value).toBeNull();
+ });
+
+ it('stores a renewed session token', () => {
+ http.get('/_api/v1/state').subscribe();
+ backend
+ .expectOne('/_api/v1/state')
+ .flush({}, { headers: { 'X-Berta-Token': 'renewed-token' } });
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new UserTokenRenewedAction('renewed-token'),
+ );
+ });
+});
diff --git a/editor/src/app/shared/editor-sync.ts b/editor/src/app/shared/editor-sync.ts
new file mode 100644
index 000000000..f3dfc562b
--- /dev/null
+++ b/editor/src/app/shared/editor-sync.ts
@@ -0,0 +1,206 @@
+import {
+ HttpClient,
+ HttpErrorResponse,
+ HttpHeaders,
+ HttpInterceptorFn,
+ HttpResponse,
+} from '@angular/common/http';
+import { Injectable, Injector, inject } from '@angular/core';
+import { Actions, Store, ofActionDispatched } from '@ngxs/store';
+import { filter, take, tap } from 'rxjs/operators';
+
+import { PopupState } from '../popup/popup.interface';
+import { PopupService } from '../popup/popup.service';
+import {
+ UserLoginAction,
+ UserLogoutAction,
+ UserTokenRenewedAction,
+} from '../user/user.actions';
+
+/** How often coming back to the editor tab checks for changes made elsewhere, at most. */
+const FOCUS_CHECK_INTERVAL_MS = 10 * 1000;
+
+/**
+ * Keeps this editor window in step with changes made elsewhere: in another editor window (tab,
+ * browser or device) or by an AI agent. The API marks every change (EditorSync middleware) and
+ * sends the latest one with every response. When this window sees a change it didn't make, it asks
+ * the user to reload, because its values are stale; the API also refuses its saves until then, so
+ * it can't overwrite that change. The request to reload stays until the window reloads, since
+ * other popups (e.g. error messages) may replace it for a moment. Checks again whenever the tab
+ * comes back into view. Logout and login start over, since the login loads all data fresh: answers
+ * to requests still on their way at logout must not count as news afterwards.
+ */
+@Injectable({ providedIn: 'root' })
+export class EditorSyncService {
+ /** This window's id, so the API can tell its own changes from others'. */
+ readonly editorId = randomId();
+
+ /** The latest change this window has seen; null until the first API response after loading or login. */
+ seenChange: string | null = null;
+
+ private knownChanges = new Set();
+ /** The request to reload, while one is pending. */
+ private reloadPopup: PopupState | null = null;
+ private lastFocusCheck = 0;
+
+ constructor(
+ private injector: Injector,
+ private popupService: PopupService,
+ private store: Store,
+ actions$: Actions,
+ ) {
+ actions$
+ .pipe(ofActionDispatched(UserLogoutAction, UserLoginAction))
+ .subscribe(() => this.startOver());
+
+ // Another popup replaced the request to reload and has closed: show it again. Deferred, so the
+ // popup's other subscribers have seen it close first.
+ popupService.popupState$
+ .pipe(filter((state) => state === null && this.reloadPopup !== null))
+ .subscribe(() =>
+ queueMicrotask(() => {
+ if (this.reloadPopup && !this.popupService.popupState$.value) {
+ this.popupService.showPopup(this.reloadPopup);
+ }
+ }),
+ );
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') {
+ this.checkForChanges();
+ }
+ });
+ }
+
+ /** Reads the latest change and a renewed session token from an API response. */
+ readResponse(headers: HttpHeaders) {
+ const renewedToken = headers.get('X-Berta-Token');
+ if (renewedToken) {
+ this.store.dispatch(new UserTokenRenewedAction(renewedToken));
+ }
+
+ // Responses can arrive out of order, so an older change this window already knows is no news.
+ // Neither is an empty one once the window has seen a change: the API has no marker then (e.g. its
+ // cache was cleared), which says nothing about changes.
+ const change = headers.get('X-Berta-Change');
+ if (
+ change === null ||
+ this.knownChanges.has(change) ||
+ (change === '' && this.seenChange !== null)
+ ) {
+ return;
+ }
+
+ if (
+ this.seenChange === null ||
+ headers.get('X-Berta-Change-Writer') === this.editorId
+ ) {
+ this.seenChange = change;
+ this.knownChanges.add(change);
+ return;
+ }
+
+ this.showChangedElsewhere(headers.get('X-Berta-Change-Source'));
+ }
+
+ private checkForChanges() {
+ const now = Date.now();
+ if (now - this.lastFocusCheck < FOCUS_CHECK_INTERVAL_MS) {
+ return;
+ }
+ this.lastFocusCheck = now;
+
+ this.store
+ .select((state) => [state.app.urls?.changeMarker, state.user.token])
+ .pipe(
+ take(1),
+ filter(([url, token]) => !!url && !!token),
+ )
+ .subscribe(([url, token]) => {
+ // The interceptor reads the answer; a failed check simply waits for the next one.
+ this.injector
+ .get(HttpClient)
+ .get(url, { headers: { 'X-Authorization': 'Bearer ' + token } })
+ .subscribe({ error: () => {} });
+ });
+ }
+
+ private startOver() {
+ this.seenChange = null;
+ this.knownChanges.clear();
+
+ const reloadPopup = this.reloadPopup;
+ this.reloadPopup = null;
+ if (reloadPopup && this.popupService.popupState$.value === reloadPopup) {
+ this.popupService.closePopup();
+ }
+ }
+
+ private showChangedElsewhere(source: string | null) {
+ if (this.reloadPopup) {
+ return;
+ }
+
+ this.reloadPopup = {
+ type: 'warn',
+ content:
+ source === 'agent'
+ ? 'This website was changed by an AI agent. Reload to see the changes.'
+ : 'This website was changed in another editor window. Reload to see the changes.',
+ showOverlay: true,
+ isModal: true,
+ actions: [
+ {
+ label: 'Reload',
+ type: 'primary',
+ callback: () => window.location.reload(),
+ },
+ ],
+ };
+ this.popupService.showPopup(this.reloadPopup);
+ }
+}
+
+/**
+ * Sends this window's id and the latest change it has seen with every request to the editor's own
+ * API, and reads the answer's sync headers, also of failed requests (a refused save says what
+ * changed).
+ */
+export const editorSyncInterceptor: HttpInterceptorFn = (request, next) => {
+ if (
+ new URL(request.url, window.location.href).origin !== window.location.origin
+ ) {
+ return next(request);
+ }
+
+ const sync = inject(EditorSyncService);
+
+ // Until the window has seen a change, the API leaves its saves unchecked: e.g. a save retried
+ // right after login, before the fresh data has arrived.
+ const headers: Record = { 'X-Berta-Editor': sync.editorId };
+ if (sync.seenChange !== null) {
+ headers['X-Berta-Change'] = sync.seenChange;
+ }
+
+ return next(request.clone({ setHeaders: headers })).pipe(
+ tap({
+ next: (event) => {
+ if (event instanceof HttpResponse) {
+ sync.readResponse(event.headers);
+ }
+ },
+ error: (error) => {
+ if (error instanceof HttpErrorResponse) {
+ sync.readResponse(error.headers);
+ }
+ },
+ }),
+ );
+};
+
+function randomId(): string {
+ // crypto.randomUUID() needs HTTPS; local Berta sites run on plain HTTP.
+ return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) =>
+ byte.toString(16).padStart(2, '0'),
+ ).join('');
+}
diff --git a/editor/src/app/user/user.actions.ts b/editor/src/app/user/user.actions.ts
index ed47edf14..c331ccfff 100644
--- a/editor/src/app/user/user.actions.ts
+++ b/editor/src/app/user/user.actions.ts
@@ -9,6 +9,12 @@ export class UserLoginAction {
) {}
}
+/** The API renewed the session token while the editor is used. */
+export class UserTokenRenewedAction {
+ static readonly type = 'USER:TOKEN_RENEWED';
+ constructor(public token: string) {}
+}
+
export class UserLogoutAction {
static readonly type = 'USER:LOGOUT';
constructor(public saveNextUrl = false) {}
diff --git a/editor/src/app/user/user.state.ts b/editor/src/app/user/user.state.ts
index 9131eff5b..cb0b39fb6 100644
--- a/editor/src/app/user/user.state.ts
+++ b/editor/src/app/user/user.state.ts
@@ -13,6 +13,7 @@ import { UserStateModel } from './user.state.model';
import {
UserLoginAction,
UserLogoutAction,
+ UserTokenRenewedAction,
SetUserNextUrlAction,
} from './user.actions';
import { AppStateService } from '../app-state/app-state.service';
@@ -129,6 +130,20 @@ export class UserState implements NgxsOnInit {
);
}
+ @Action(UserTokenRenewedAction)
+ renewToken(
+ { getState, patchState }: StateContext,
+ action: UserTokenRenewedAction,
+ ) {
+ // A response may arrive after logout; it must not log the user back in.
+ if (!getState().token) {
+ return;
+ }
+
+ window.localStorage.setItem('token', action.token);
+ patchState({ token: action.token });
+ }
+
@Action(UserLogoutAction)
logout(
{ dispatch, setState }: StateContext,