Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
ea7a12b
Keep deleted files for undo out of reach, and remove them after 30 days
uldisrudzitis Nov 3, 2026
43ea8b9
Define entry gallery settings on the server, and keep shared gallery …
uldisrudzitis Nov 3, 2026
f8bdadf
Define background gallery settings on the server, and delete only an …
uldisrudzitis Nov 4, 2026
5733ead
Restore deleted subsites through core, and give new sites their setti…
uldisrudzitis Nov 4, 2026
91e0d2c
Let routes mark their own changes for open editors, and give the edit…
uldisrudzitis Nov 4, 2026
cfc4b19
Upgrade laravel/mcp to 1.1.2 and laravel/ai to 1.3.0
uldisrudzitis Nov 4, 2026
5d15a30
Rebuild the editor's AI chat on the server's change log, and mirror i…
uldisrudzitis Nov 4, 2026
55d6ba1
Let the editor's AI chat ask for approval before deleting
uldisrudzitis Nov 4, 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
3 changes: 2 additions & 1 deletion .htaccess
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ RewriteCond %{REQUEST_URI} !^/engine/editor.*
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ /engine/dist/index.html [L]

# A site's private shop files: invoices and payment records
# A site's private shop files: invoices and payment records; and deleted files kept for undo
RewriteCond %{REQUEST_URI} ^/storage/(-sites/[^/]+/)?_shop_files/ [OR]
RewriteCond %{REQUEST_URI} ^/storage/_undo_files/ [OR]
RewriteCond %{REQUEST_URI} ^/storage.*\.(xml|sqlite|db|log|php)$ [OR]
RewriteCond %{REQUEST_URI} ^/storage/$ [OR]
RewriteCond %{REQUEST_URI} ^/_templates/$
Expand Down
1 change: 1 addition & 0 deletions LocalValetDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ private function resolve(string $sitePath, string $uri): array

if (
preg_match('#^/storage/(-sites/[^/]+/)?_shop_files/#', $uri)
|| preg_match('#^/storage/_undo_files/#', $uri)
|| preg_match('#^/storage.*\.(xml|sqlite|db|log|php)$#', $uri)
|| $uri === '/storage/'
|| $uri === '/_templates/'
Expand Down
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', 'toggle' => $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
18 changes: 18 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 All @@ -53,6 +55,14 @@ public function get($site = '')
$state['urls']['aiChat'] = route('ai_chat');
}

if (Route::has('ai_undo')) {
$state['urls']['aiUndo'] = route('ai_undo');
}

if (Route::has('ai_approve')) {
$state['urls']['aiApprove'] = route('ai_approve');
}

if (Route::has('ai_quota')) {
$state['urls']['aiQuota'] = route('ai_quota');
}
Expand Down Expand Up @@ -139,6 +149,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
124 changes: 124 additions & 0 deletions _api_app/app/Http/Middleware/EditorSync.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?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.
* Routes whose handler marks the changes it makes itself (e.g. the AI chat, whose agent may change nothing or many
* things) use the MARKED_BY_HANDLER parameter: the write is still refused when the window missed a change, but isn't
* marked as a change of its own, and the answer carries the latest marker, the handler's changes included.
*/
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';

/**
* Route middleware parameter, as EditorSync::class . ':' . EditorSync::MARKED_BY_HANDLER.
*/
public const MARKED_BY_HANDLER = 'marked-by-handler';

/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, string $mode = ''): 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 ($mode === self::MARKED_BY_HANDLER) {
$latest = $markers->current();
} elseif ($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;
}
}
6 changes: 6 additions & 0 deletions _api_app/app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Providers;

use App\Shared\ImageHelpers;
use App\Shared\UndoFiles;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;

Expand Down Expand Up @@ -46,5 +47,10 @@ public function boot(): void
Validator::extend('not_corrupted_image', function ($attribute, $value, $parameters) {
return ! ImageHelpers::isCorrupted($value);
});

// Public pages boot the app without terminating it, so a shutdown function is what runs on every web request.
if (! $this->app->runningInConsole()) {
register_shutdown_function(fn () => rescue(fn () => (new UndoFiles)->cleanUpIfDue()));
}
}
}
Loading
Loading