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/