diff --git a/.htaccess b/.htaccess index fae6d760a..648195c5e 100644 --- a/.htaccess +++ b/.htaccess @@ -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/$ diff --git a/LocalValetDriver.php b/LocalValetDriver.php index 416be1970..5d9e78923 100644 --- a/LocalValetDriver.php +++ b/LocalValetDriver.php @@ -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/' diff --git a/_api_app/app/Configuration/SettingValueValidator.php b/_api_app/app/Configuration/SettingValueValidator.php new file mode 100644 index 000000000..e7977591a --- /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', 'toggle' => $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..d0c9e97ef 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'), @@ -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'); } @@ -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 */ diff --git a/_api_app/app/Http/Middleware/EditorSync.php b/_api_app/app/Http/Middleware/EditorSync.php new file mode 100644 index 000000000..1bc3db6f5 --- /dev/null +++ b/_api_app/app/Http/Middleware/EditorSync.php @@ -0,0 +1,124 @@ +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; + } +} 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/Providers/AppServiceProvider.php b/_api_app/app/Providers/AppServiceProvider.php index ee04ba199..86f11e782 100644 --- a/_api_app/app/Providers/AppServiceProvider.php +++ b/_api_app/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Shared\ImageHelpers; +use App\Shared\UndoFiles; use Illuminate\Support\Facades\Validator; use Illuminate\Support\ServiceProvider; @@ -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())); + } } } 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..564943b43 100644 --- a/_api_app/app/Shared/Storage.php +++ b/_api_app/app/Shared/Storage.php @@ -3,7 +3,6 @@ namespace App\Shared; use Illuminate\Support\Facades\Config; -use Illuminate\Support\Str; use Swaggest\JsonSchema\Schema; class Storage @@ -217,19 +216,63 @@ protected function getUniqueFileName($dir, $fileName) return $name . '_' . ($maxFileIdx + 1) . '.' . $extension; } - public static function removeImageWithThumbnails($dir, $fileName) + /** + * The names of the media files gallery file records use: each file's src and a video's poster image. + * + * @param array> $files raw file records + * @return list + */ + public static function galleryFileNames(array $files): array { - if (file_exists($dir . '/' . $fileName)) { - unlink($dir . '/' . $fileName); - } + $names = []; - // Remove thumbnails - foreach (scandir($dir) as $file) { - if (! is_file($dir . '/' . $file) || substr($file, 0, 1) !== '_' || ! Str::endsWith($file, $fileName)) { - continue; + foreach ($files as $file) { + foreach (['src', 'poster_frame'] as $attribute) { + if ((string) ($file['@attributes'][$attribute] ?? '') !== '') { + $names[] = (string) $file['@attributes'][$attribute]; + } } - unlink($dir . '/' . $file); } + + return $names; + } + + /** + * Removes an image and its resizedCopies(). + */ + public static function removeImageWithThumbnails(string $dir, string $fileName): void + { + $dir = rtrim($dir, '/'); + + if (is_file($dir . '/' . $fileName)) { + @unlink($dir . '/' . $fileName); + } + + foreach (self::resizedCopies($dir, $fileName) as $copy) { + @unlink($dir . '/' . $copy); + } + } + + /** + * The names of an image's resized copies in its folder: the editor's thumbnail, the background and grid images + * (config app.*_prefix) and the sizes made on demand (_x_, ImageHelpers::getResizedSrc()). Only + * copies of exactly this file: those of 11.jpg aren't copies of 1.jpg. + * + * @return list + */ + public static function resizedCopies(string $dir, string $fileName): array + { + $dir = rtrim($dir, '/'); + $prefixes = array_map( + fn (string $prefix): string => preg_quote($prefix, '/'), + [config('app.small_thumb_prefix'), config('app.bg_image_prefix'), config('app.grid_image_prefix')], + ); + $pattern = '/^(?:' . implode('|', $prefixes) . '|_\d+x\d+_)' . preg_quote($fileName, '/') . '$/'; + + return array_values(array_filter( + is_dir($dir) ? scandir($dir) : [], + fn (string $file): bool => preg_match($pattern, $file) === 1 && is_file($dir . '/' . $file), + )); } /** @@ -305,30 +348,109 @@ 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; + } + + /** + * Whether a gallery other than the given one refers to the media folder: other than the entry's gallery, or, + * without an entry id, other than the section's background. Deleting a gallery file keeps it in such a folder, + * as the other gallery may show it too. + */ + protected function isMediaFolderReferencedElsewhere(string $folder, string $section, ?string $entryId = null): bool + { + foreach ($this->mediaFolderReferences()[$folder] ?? [] as $reference) { + if ($reference !== [$section, $entryId]) { + return true; + } + } + + return false; } public function moveFolderContents($src, $dst, $ignore = []) diff --git a/_api_app/app/Shared/UndoFiles.php b/_api_app/app/Shared/UndoFiles.php new file mode 100644 index 000000000..d22c1776a --- /dev/null +++ b/_api_app/app/Shared/UndoFiles.php @@ -0,0 +1,65 @@ +XML_MAIN_ROOT . '/' . self::FOLDER . ($changeId !== '' ? '/' . $changeId : ''); + } + + /** + * Removes the kept files older than KEEP_DAYS, unless that was done in the last CLEAN_UP_INTERVAL seconds. + * Only the time of each change's kept.json is read; the files themselves are never walked. + */ + public function cleanUpIfDue(): void + { + $folder = $this->path(); + + if (! is_dir($folder)) { + return; + } + + $marker = $folder . '/' . self::CLEANED_UP_MARKER; + + if (is_file($marker) && filemtime($marker) > time() - self::CLEAN_UP_INTERVAL) { + return; + } + + // Touched first, so requests arriving meanwhile skip the clean-up. + touch($marker); + $oldest = time() - self::KEEP_DAYS * 86400; + + foreach (glob($folder . '/*', GLOB_ONLYDIR) ?: [] as $changeFolder) { + $dataFile = $changeFolder . '/' . self::DATA_FILE; + + if ((is_file($dataFile) ? filemtime($dataFile) : filemtime($changeFolder)) < $oldest) { + $this->delFolder($changeFolder); + } + } + } +} diff --git a/_api_app/app/Sites/Sections/Entries/Galleries/EntryGallerySettings.php b/_api_app/app/Sites/Sections/Entries/Galleries/EntryGallerySettings.php new file mode 100644 index 000000000..d3df53890 --- /dev/null +++ b/_api_app/app/Sites/Sections/Entries/Galleries/EntryGallerySettings.php @@ -0,0 +1,134 @@ + [ + 'title' => 'Gallery type', + 'format' => 'select', + 'values' => ['slideshow' => 'Slideshow', 'row' => 'Row', 'column' => 'Column', 'pile' => 'Pile', 'link' => 'Link', 'grid' => 'Grid'], + ], + 'autoplay' => [ + 'title' => 'Autoplay seconds', + 'format' => 'text', + 'validation' => 'zero_or_positive_integer', + 'default' => '0', + 'types' => ['slideshow'], + ], + 'slide_numbers_visible' => [ + 'title' => 'Show image numbers', + 'format' => 'toggle', + 'values' => ['yes', 'no'], + 'types' => ['slideshow'], + ], + 'gallery_width_by_widest_slide' => [ + 'title' => 'Width by widest slide', + 'format' => 'toggle', + 'values' => ['yes', 'no'], + 'default' => 'no', + 'types' => ['slideshow'], + 'templates' => ['messy'], + ], + 'row_gallery_padding' => [ + 'title' => 'Image padding', + 'format' => 'text', + 'types' => ['row'], + ], + 'link_address' => [ + 'title' => 'Link address', + 'format' => 'url', + 'types' => ['link'], + ], + 'linkTarget' => [ + 'title' => 'Open in', + 'format' => 'select', + 'values' => ['_self' => 'Same window', '_blank' => 'New window'], + 'default' => '_self', + 'types' => ['link'], + ], + 'grid_columns_mobile' => [ + 'title' => 'Columns (mobile)', + 'format' => 'text', + 'validation' => 'positive_integer', + 'default' => '1', + 'types' => ['grid'], + ], + 'grid_columns_desktop' => [ + 'title' => 'Columns (desktop)', + 'format' => 'text', + 'validation' => 'positive_integer', + 'default' => '2', + 'types' => ['grid'], + ], + 'grid_columns_large_desktop' => [ + 'title' => 'Columns (large desktop)', + 'format' => 'text', + 'validation' => 'positive_integer', + 'default' => '3', + 'types' => ['grid'], + ], + 'grid_show_captions' => [ + 'title' => 'Show captions', + 'format' => 'toggle', + 'values' => ['yes', 'no'], + 'default' => 'yes', + 'types' => ['grid'], + ], + 'grid_gap' => [ + 'title' => 'Gap between images', + 'format' => 'text', + 'allow_blank' => true, + 'css_units' => true, + 'types' => ['grid'], + ], + 'fullscreen' => [ + 'title' => 'Fullscreen mode', + 'format' => 'toggle', + 'values' => ['yes', 'no'], + ], + 'size' => [ + 'title' => 'File size', + 'format' => 'select', + 'values' => ['large' => 'Large', 'medium' => 'Medium', 'small' => 'Small'], + 'default' => 'large', + ], + ]; + + /** + * Fields of each file: its caption, and for videos whether they play by themselves. + */ + public const FILE_FIELDS = [ + 'caption' => [ + 'title' => 'Caption', + 'format' => 'richtext', + ], + 'autoplay' => [ + 'title' => 'Autoplay', + 'format' => 'toggle', + 'values' => ['0', '1'], + 'default' => '0', + 'file_types' => ['video'], + ], + ]; + + /** + * The settings the editor shows for a gallery of this type on this template (its name without version). + * + * @return array> + */ + public static function shownFor(string $type, string $template): array + { + return array_filter(self::DEFINITIONS, fn (array $definition): bool => in_array($type, $definition['types'] ?? [$type], true) + && in_array($template, $definition['templates'] ?? [$template], true)); + } +} diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php b/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php index 178123e1b..0d4d84cff 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(); @@ -1188,11 +1230,19 @@ public function galleryDelete($section_name, $entry_id, $file) return ['error_message' => 'File "' . $file . '" not found!']; } - $mediafolder = $this->MEDIA_ROOT . '/' . $entry['mediafolder'] . '/'; - $this->deleteMedia($mediafolder, $file); - + $poster = (string) ($files[$file_order]['@attributes']['poster_frame'] ?? ''); $file = current(array_splice($files, $file_order, 1)); + if (! $this->isMediaFolderReferencedElsewhere((string) $entry['mediafolder'], $this->SECTION_NAME, (string) $entry['id'])) { + $folder = $this->MEDIA_ROOT . '/' . $entry['mediafolder']; + $this->removeImageWithThumbnails($folder, $file['@attributes']['src']); + + // A video's poster goes with it, unless another file of the gallery uses that image too. + if ($poster !== '' && ! in_array($poster, self::galleryFileNames($files), true)) { + $this->removeImageWithThumbnails($folder, $poster); + } + } + $entries['entry'][$entry_order]['mediaCacheData']['file'] = array_filter($files, function ($f) use ($file) { return $f['@attributes']['src'] != $file; }); @@ -1210,24 +1260,6 @@ public function galleryDelete($section_name, $entry_id, $file) return ['error_message' => 'Entry with ID "' . $entry_id . '" not found!']; } - // @todo video poster should also be removed - private function deleteMedia($folder, $file = '') - { - @unlink($folder . $file); - - if ($handle = opendir($folder)) { - while (false !== ($f = readdir($handle))) { - if (! $file || strpos($f, $file) !== false) { - if (substr($f, 0, 1) == '_') { - @unlink($folder . $f); - } - } - } - - closedir($handle); - } - } - /** * Copy all section entries gallery files to destination folder * 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/SectionBackgroundGalleryRenderService.php b/_api_app/app/Sites/Sections/SectionBackgroundGalleryRenderService.php index 770b4151c..2eb77a7d7 100644 --- a/_api_app/app/Sites/Sections/SectionBackgroundGalleryRenderService.php +++ b/_api_app/app/Sites/Sections/SectionBackgroundGalleryRenderService.php @@ -7,8 +7,6 @@ class SectionBackgroundGalleryRenderService { - private $USED_IN_TEMPLATES = ['messy']; - private function getWrapperAttributes($currentSection, $isEditMode) { $attributes['id'] = 'xBackground'; @@ -123,9 +121,7 @@ public function render( $request, $isEditMode ) { - $templateName = explode('-', $siteSettings['template']['template'])[0]; - - if (! in_array($templateName, $this->USED_IN_TEMPLATES)) { + if (! SectionBackgroundSettings::shownOn($siteSettings['template']['template'])) { return ''; } diff --git a/_api_app/app/Sites/Sections/SectionBackgroundSettings.php b/_api_app/app/Sites/Sections/SectionBackgroundSettings.php new file mode 100644 index 000000000..269dc1b53 --- /dev/null +++ b/_api_app/app/Sites/Sections/SectionBackgroundSettings.php @@ -0,0 +1,85 @@ + [ + 'title' => 'Image size', + 'format' => 'select', + 'values' => ['large' => 'Large', 'medium' => 'Medium', 'small' => 'Small'], + 'default' => 'medium', + ], + 'autoplay' => [ + 'title' => 'Autoplay seconds', + 'format' => 'text', + 'validation' => 'zero_or_positive_integer', + 'default' => '0', + ], + 'sectionBgColor' => [ + 'title' => 'Background color', + 'format' => 'color', + 'allow_blank' => true, + 'on_section' => true, + ], + 'caption_color' => [ + 'title' => 'Caption text color', + 'format' => 'color', + 'allow_blank' => true, + ], + 'caption_bg_color' => [ + 'title' => 'Caption background color', + 'format' => 'color', + 'allow_blank' => true, + ], + 'hide_navigation' => [ + 'title' => 'Hide navigation arrows', + 'format' => 'toggle', + 'values' => ['yes', 'no'], + 'default' => 'no', + ], + 'animation' => [ + 'title' => 'Animation', + 'format' => 'toggle', + 'values' => ['enabled', 'disabled'], + 'default' => 'enabled', + ], + 'fade_content' => [ + 'title' => 'Fade content', + 'format' => 'toggle', + 'values' => ['enabled', 'disabled'], + 'default' => 'disabled', + ], + ]; + + /** + * Fields of each file: its caption. Background galleries hold images only. + */ + public const FILE_FIELDS = [ + 'caption' => [ + 'title' => 'Caption', + 'format' => 'richtext', + ], + ]; + + /** + * Whether the template (its name, with or without version) shows background galleries. + */ + public static function shownOn(string $template): bool + { + return in_array(explode('-', $template)[0], self::TEMPLATES, true); + } +} 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..c114f7b14 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)) { @@ -522,11 +534,7 @@ public function backgroundGalleryDelete($name, $file) if ($section_order !== false) { $section = &$sections['section'][$section_order]; - if (! isset($section['mediaCacheData'])) { - return ['error_message' => 'File "' . $file . '" not found!']; - } - - $files = $this->asList($section['mediaCacheData']['file']); + $files = $this->asList($section['mediaCacheData']['file'] ?? []); $file_order = array_search( $file, array_column( @@ -542,8 +550,9 @@ public function backgroundGalleryDelete($name, $file) return ['error_message' => 'File "' . $file . '" not found!']; } - $mediafolder = $this->MEDIA_ROOT . '/' . $section['mediafolder'] . '/'; - $this->deleteMedia($mediafolder, $file); + if (! $this->isMediaFolderReferencedElsewhere((string) $section['mediafolder'], $name)) { + $this->removeImageWithThumbnails($this->MEDIA_ROOT . '/' . $section['mediafolder'], $file); + } $file = current(array_splice($files, $file_order, 1)); @@ -685,7 +694,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 +709,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; @@ -715,23 +728,6 @@ private function getUniqueSlug($title) return $slug; } - private function deleteMedia($folder, $file = '') - { - @unlink($folder . $file); - - if ($handle = opendir($folder)) { - while (false !== ($f = readdir($handle))) { - if (! $file || strpos($f, $file) !== false) { - if (substr($f, 0, 1) == '_') { - @unlink($folder . $f); - } - } - } - - closedir($handle); - } - } - /** * Merge site sections from other source folder * 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/Settings/SiteSettingsDataService.php b/_api_app/app/Sites/Settings/SiteSettingsDataService.php index 73ed2b4d0..dec4a2ec4 100644 --- a/_api_app/app/Sites/Settings/SiteSettingsDataService.php +++ b/_api_app/app/Sites/Settings/SiteSettingsDataService.php @@ -317,6 +317,26 @@ public function getDefaultSettings() return $this->SITE_SETTINGS; } + /** + * Writes a new site's settings file with the default template, as the engine does when it first loads a site + * without one, so its settings can be saved right away. Does nothing when the file exists. + */ + public function createDefaultFile(): void + { + if (file_exists($this->XML_FILE)) { + return; + } + + $settings = []; + + foreach (self::$DEFAULT_SITE_SETTINGS as $path => $value) { + $this->setValueByPath($settings, $path, str_contains($path, 'lastUpdated') ? gmdate($value, time()) . ' GMT' : $value); + } + + $this->array2xmlFile($settings, $this->XML_FILE, $this->ROOT_ELEMENT); + $this->SITE_SETTINGS = []; + } + /** * Returns settings of site as an array * diff --git a/_api_app/app/Sites/SitesDataService.php b/_api_app/app/Sites/SitesDataService.php index 03d1bb572..aa07a2556 100644 --- a/_api_app/app/Sites/SitesDataService.php +++ b/_api_app/app/Sites/SitesDataService.php @@ -8,6 +8,7 @@ use App\Plugins\Shop\ShopProductsDataService; use App\Shared\Helpers; use App\Shared\Storage; +use App\Shared\UndoFiles; use App\Sites\Sections\SiteSectionsDataService; use App\Sites\Settings\SiteSettingsDataService; use App\Sites\TemplateSettings\SiteTemplateSettingsDataService; @@ -166,10 +167,11 @@ public function create(Request $request, ?string $cloneFrom = null) } $dir = $this->XML_SITES_ROOT . '/' . $name; - // The shop files belong to the source's orders, which the clone doesn't keep - $this->copyFolder($src, $dir, [self::SHOP_FILES_FOLDER]); + // The shop files belong to the source's orders, and the undo files to its changes, which the clone doesn't keep + $this->copyFolder($src, $dir, [self::SHOP_FILES_FOLDER, UndoFiles::FOLDER]); } else { @mkdir($dir, 0777, true); + (new SiteSettingsDataService($name))->createDefaultFile(); } $site = [ @@ -282,7 +284,7 @@ public function createPrieview($themeName) // generate new preview content // for now just copy all the existing contents to preview - $this->copyFolder($this->XML_STORAGE_ROOT, $this->XML_PREVIEW_ROOT, [self::SHOP_FILES_FOLDER]); + $this->copyFolder($this->XML_STORAGE_ROOT, $this->XML_PREVIEW_ROOT, [self::SHOP_FILES_FOLDER, UndoFiles::FOLDER]); // Merge site settings $siteSettingsDS = new SiteSettingsDataService($this->SITE, $this->XML_PREVIEW_ROOT); @@ -317,6 +319,11 @@ public function delete($name) $sites['site'] = $this->get(); $order = array_search($name, array_column($sites['site'], 'name')); + // The main site has no folder of its own: its name would point at the folder of all subsites. + if ((string) $name === '') { + return ['error_message' => 'The main site can not be deleted!']; + } + if ($order !== false) { $dir = $this->XML_SITES_ROOT . '/' . $name; $this->delFolder($dir); @@ -329,6 +336,33 @@ public function delete($name) return ['error_message' => 'Site "' . $name . '" not found!']; } + /** + * Adds back the record of a deleted subsite whose storage folder is in place again, at the end: the counterpart + * of delete(), for undoing it. + * + * @param array $record the site's record as delete() returned it + * @return array the record, or error_message + */ + public function restore(array $record): array + { + $name = (string) ($record['name'] ?? ''); + $sites['site'] = $this->get(); + + if ($name === '' || in_array($name, array_map('strval', array_column($sites['site'], 'name')), true)) { + return ['error_message' => 'Site "' . $name . '" can not be restored: a site with this name exists.']; + } + + if (! is_dir($this->XML_SITES_ROOT . '/' . $name)) { + return ['error_message' => 'Site "' . $name . '" can not be restored: its storage folder is missing.']; + } + + $sites['site'][] = $record; + $this->array2xmlFile($sites, $this->XML_FILE, $this->ROOT_ELEMENT); + $this->SITES = []; + + return $record; + } + /** * Reorder sites and save to XML file * 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/