Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cea493a
Require laravel/mcp and test the AI assistant MCP server
uldisrudzitis Oct 22, 2026
8208d50
Share the entry title rule and test the list-entries MCP tool
uldisrudzitis Oct 23, 2026
249b1bd
Test the get-entry-gallery MCP tool and shared gallery reading
uldisrudzitis Oct 23, 2026
86621e6
Test the chat's entry content tool and SiteContentReader::entryContent()
uldisrudzitis Oct 23, 2026
71c85b4
Test the get-site-settings MCP tool and snapshot the chat's settings …
uldisrudzitis Oct 23, 2026
2480a2d
Test the get-design-settings MCP tool and design settings reading
uldisrudzitis Oct 23, 2026
8f07a1a
Run plugin tests from the plugins' own repositories
uldisrudzitis Oct 23, 2026
b9c605b
Share the plan features test helper with all tests
uldisrudzitis Oct 27, 2026
94998cf
Validate setting values on the server, as the editor does
uldisrudzitis Oct 27, 2026
afb1a77
Keep the real mouse out of the sortable specs' synthetic drags
uldisrudzitis Oct 28, 2026
6b693fd
Keep open editors in sync and renew the session while it is used
uldisrudzitis Oct 28, 2026
1d79dca
Merge remote-tracking branch 'origin/master' into mcp-integration
uldisrudzitis Oct 28, 2026
b248e55
Add an HTML cleaner that removes what can run scripts
uldisrudzitis Oct 28, 2026
bed8203
Name the section types that can hold entries in one place
uldisrudzitis Oct 29, 2026
251c4af
Keep gallery files safe when entries and sections are copied, moved, …
uldisrudzitis Oct 29, 2026
c312f9a
Keep a section's name when only the case of its title changes
uldisrudzitis Oct 29, 2026
3b2cd16
Merge branch 'master' into mcp-integration
uldisrudzitis Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions _api_app/app/Configuration/SettingValueValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

namespace App\Configuration;

use App\User\UserModel;

/**
* Checks a setting value against its definition from SiteSettingsConfigService or SiteTemplatesConfigService,
* with the rules the editor applies (editor/src/app/sites/shared/setting.component.ts and
* editor/src/app/inputs/validation.service.ts), so the server enforces them for every client:
* settings needing a plan feature, the allowed values of selects, colors, and the text inputs' rules.
* Formats the editor doesn't validate (url, longtext, richtext, files, editor actions) take any value.
*/
class SettingValueValidator
{
/**
* @param list<string>|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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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,
};
}
}
12 changes: 3 additions & 9 deletions _api_app/app/Http/Controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,20 @@
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;

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)
Expand Down
10 changes: 10 additions & 0 deletions _api_app/app/Http/Controllers/StateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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
*/
Expand Down
114 changes: 114 additions & 0 deletions _api_app/app/Http/Middleware/EditorSync.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

namespace App\Http\Middleware;

use App\Shared\ChangeMarker;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
* Keeps open editors in sync with changes made elsewhere (ChangeMarker). Every response tells the editor the latest
* change. A write from an editor window that hasn't seen a change someone else made since is refused with 409, so it
* can't overwrite that change; the editor then asks the user to reload. Successful writes of an editor window mark a
* new change. Requests without the editor window header (other clients, older editors) pass unchecked, and so do
* writes of a window that hasn't seen a change yet (no change header), e.g. a save retried right after login.
* Routes that change no site data opt out with withoutMiddleware(), so other windows aren't asked to reload for them.
*/
class EditorSync
{
/**
* The editor window's id, random per window: hex characters, as the editor makes it. Other values are ignored,
* since the id is sent back in a response header.
*/
public const EDITOR_HEADER = 'X-Berta-Editor';

/**
* Sent by the editor: the change token it has seen last. Sent back: the latest change token.
*/
public const CHANGE_HEADER = 'X-Berta-Change';

public const CHANGE_WRITER_HEADER = 'X-Berta-Change-Writer';

public const CHANGE_SOURCE_HEADER = 'X-Berta-Change-Source';

/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$markers = new ChangeMarker;
$editor = (string) $request->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;
}
}
42 changes: 42 additions & 0 deletions _api_app/app/Http/Middleware/RenewEditorToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace App\Http\Middleware;

use App\User\EditorToken;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
* Renews the editor's session token while the editor is used (EditorToken::renewal()): the new token goes back in
* the X-Berta-Token header, which the editor stores, and in the token cookie. Runs after Authenticate.
*/
class RenewEditorToken
{
public const TOKEN_HEADER = 'X-Berta-Token';

/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);

if (! $response->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;
}
}
70 changes: 70 additions & 0 deletions _api_app/app/Shared/ChangeMarker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace App\Shared;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Throwable;

/**
* Marks the latest change to a website's data, so an open editor can tell that someone else changed it since it
* loaded: another editor window (tab, browser or device) or an AI agent. One marker for the website, main site and
* subsites. It's app state rather than site content, so it lives in Laravel's cache, not in the site's storage folder
* that backups and transfers copy; it never expires, so a tab left open for days is still checked. EditorSync checks
* it on editor requests. A cache that doesn't work (e.g. the first request after updating an old install, before
* SetupMiddleware has added CACHE_STORE to .env) turns the checks off instead of breaking the editor.
*/
class ChangeMarker
{
public const SOURCE_EDITOR = 'editor';

public const SOURCE_AGENT = 'agent';

private const NONE = ['token' => '', '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'));
}
}
Loading
Loading