diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 4db0e0956..6b4b31467 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -32,7 +32,7 @@ class ConfigLexicon implements ILexicon { public const USER_FOLDER = 'user_folder'; public const PUBLISH_ENABLED = 'publish_enabled'; - public const PUBLISH_ENABLED_DEFAULT = 'false'; + public const PUBLISH_ENABLED_DEFAULT = false; public const STATIC_SITE_MAX_SIZE = 'static_site_max_size'; public const STATIC_SITE_MAX_SIZE_DEFAULT = 100 * 1024 * 1024; public const STATIC_SITE_GENERATOR_URL = 'static_site_generator_url'; @@ -88,9 +88,9 @@ public function getAppConfigs(): array { ), new Entry( key: self::PUBLISH_ENABLED, - type: ValueType::STRING, + type: ValueType::BOOL, defaultRaw: self::PUBLISH_ENABLED_DEFAULT, - definition: 'Whether the publish feature is enabled (`true` or `false`)', + definition: 'Whether the publish feature is enabled', lazy: false, ), new Entry( diff --git a/lib/Controller/StaticSiteController.php b/lib/Controller/StaticSiteController.php index 4a76bc28b..895628962 100644 --- a/lib/Controller/StaticSiteController.php +++ b/lib/Controller/StaticSiteController.php @@ -172,7 +172,7 @@ private function handleArchiveErrorResponse(Closure $callback, array $context): ); } catch (StaticSiteGeneratorException) { throw new OCSException( - $this->l10n->t('The publish service rejected the website. Please contact your administrator.'), + $this->l10n->t('The publish service could not process the request. Please contact your administrator.'), Http::STATUS_BAD_GATEWAY, ); } catch (ServiceException $e) { diff --git a/lib/Db/StaticSiteMapper.php b/lib/Db/StaticSiteMapper.php index 443d494c3..e3f155ed2 100644 --- a/lib/Db/StaticSiteMapper.php +++ b/lib/Db/StaticSiteMapper.php @@ -155,6 +155,26 @@ public function updateStatus(int $id, string $status): StaticSite { return $this->update($staticSite); } + /** + * Reset status and update time to the values of the given static site as it was read before. + * + * Keeping the update time of an aborted publication allows the user to start the publish process again right away. + * + * @throws NotFoundException + * @throws Exception + */ + public function restoreStatus(StaticSite $previous): StaticSite { + try { + $staticSite = $this->find($previous->getId()); + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + throw new NotFoundException('Static site not found', 0, $e); + } + + $staticSite->setStatus($previous->getStatus()); + $staticSite->setUpdatedAt($previous->getUpdatedAt()); + return $this->update($staticSite); + } + /** * @throws NotFoundException * @throws Exception diff --git a/lib/Listeners/BeforeTemplateRenderedListener.php b/lib/Listeners/BeforeTemplateRenderedListener.php index c49dd114f..6cc70e6e7 100644 --- a/lib/Listeners/BeforeTemplateRenderedListener.php +++ b/lib/Listeners/BeforeTemplateRenderedListener.php @@ -78,6 +78,6 @@ public function handle(Event $event): void { $this->initialState->provideInitialState('is_guest', $isGuest); // Provide admin settings as initial state - $this->initialState->provideInitialState('publish_enabled', $this->appConfig->getValueString('collectives', ConfigLexicon::PUBLISH_ENABLED, ConfigLexicon::PUBLISH_ENABLED_DEFAULT) === 'true'); + $this->initialState->provideInitialState('publish_enabled', $this->appConfig->getValueBool('collectives', ConfigLexicon::PUBLISH_ENABLED, ConfigLexicon::PUBLISH_ENABLED_DEFAULT)); } } diff --git a/lib/Service/StaticSiteService.php b/lib/Service/StaticSiteService.php index 2a8d783a5..f26b3bfcd 100644 --- a/lib/Service/StaticSiteService.php +++ b/lib/Service/StaticSiteService.php @@ -60,13 +60,14 @@ public function __construct( * @param list $pageIds IDs of the pages to publish, incl. the collective's root page * * @throws NotFoundException Collective or one of the pages not found - * @throws NotPermittedException User is not allowed to edit the collective + * @throws NotPermittedException Publish feature is disabled or user is not allowed to edit the collective * @throws UnprocessableEntityException No pages selected, invalid title or slug, slug already in use, path too long * @throws StaticSiteGeneratorException Generator not configured or didn't accept the build * @throws ServiceException Archive couldn't be provided * @throws DBException */ public function create(int $collectiveId, array $pageIds, string $userId, ?string $title = null, ?string $slug = null): StaticSite { + $this->assertPublishFeatureEnabled(); $pageIds = $this->normalizePageIds($pageIds); $collective = $this->getEditableCollective($collectiveId, $userId); @@ -109,13 +110,14 @@ public function create(int $collectiveId, array $pageIds, string $userId, ?strin * @param list $pageIds IDs of the pages to publish, incl. the collective's root page * * @throws NotFoundException Collective, static site or one of the pages not found - * @throws NotPermittedException User is not allowed to edit the collective + * @throws NotPermittedException Publish feature is disabled or user is not allowed to edit the collective * @throws UnprocessableEntityException No pages selected, invalid title, publication in progress, path too long * @throws StaticSiteGeneratorException Generator not configured or didn't accept the build * @throws ServiceException Archive couldn't be provided * @throws DBException */ public function update(int $collectiveId, int $id, array $pageIds, string $title, string $userId): StaticSite { + $this->assertPublishFeatureEnabled(); $pageIds = $this->normalizePageIds($pageIds); $this->getEditableCollective($collectiveId, $userId); @@ -129,7 +131,6 @@ public function update(int $collectiveId, int $id, array $pageIds, string $title $this->verifyPagesBelongToCollective($collectiveId, $pageIds, $userId); $this->generatorClient->assertConfigured(); - $previousStatus = $staticSite->getStatus(); if (!$this->staticSiteMapper->updateStatusIfUnchanged($staticSite, StaticSite::STATUS_PENDING)) { throw new UnprocessableEntityException('Publication of static site is already in progress'); } @@ -137,7 +138,7 @@ public function update(int $collectiveId, int $id, array $pageIds, string $title try { $this->buildArchive($staticSite); } catch (ServiceException $e) { - $this->restoreStatus($staticSite, $previousStatus); + $this->restoreStatus($staticSite); throw $e; } @@ -160,6 +161,7 @@ public function delete(int $collectiveId, int $id, string $userId): void { $this->getEditableCollective($collectiveId, $userId); $staticSite = $this->staticSiteMapper->findByIdAndCollectiveId($id, $collectiveId); + // TODO(Publish): Send delete request to publish service once it provides an endpoint $this->staticSiteMapper->delete($staticSite); $this->deleteArchive($staticSite); } @@ -246,6 +248,7 @@ public function updateStatus(string $staticSiteId, string $status, array $data = * @throws ServiceException */ private function buildArchive(StaticSite $staticSite): void { + // TODO(Publish): Consider building the archive in a queued background job once the publish service reports the build result via callback try { $files = $this->contentCollector->collect($staticSite->getCollectiveId()); $this->validateSize($files); @@ -309,9 +312,9 @@ private function updateSettingsAndStatus(StaticSite $staticSite, string $title, /** * @throws DBException */ - private function restoreStatus(StaticSite $staticSite, string $status): void { + private function restoreStatus(StaticSite $previous): void { try { - $this->staticSiteMapper->updateStatus($staticSite->getId(), $status); + $this->staticSiteMapper->restoreStatus($previous); } catch (NotFoundException) { // Static site was deleted meanwhile, nothing to restore } @@ -348,6 +351,17 @@ private function normalizePageIds(array $pageIds): array { return $pageIds; } + /** + * Only creating and updating static sites is blocked, so editors can still list and delete existing ones. + * + * @throws NotPermittedException + */ + private function assertPublishFeatureEnabled(): void { + if (!$this->appConfig->getValueBool('collectives', ConfigLexicon::PUBLISH_ENABLED, ConfigLexicon::PUBLISH_ENABLED_DEFAULT)) { + throw new NotPermittedException('Publish feature is disabled'); + } + } + /** * @throws NotFoundException * @throws NotPermittedException diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 9c5acc8f0..82e4229bd 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -29,7 +29,7 @@ public function __construct( public function getForm(): TemplateResponse { $parameters = [ 'default_user_folder' => $this->appConfig->getValueString('collectives', ConfigLexicon::DEFAULT_USER_FOLDER, ''), - 'publish_enabled' => $this->appConfig->getValueString('collectives', ConfigLexicon::PUBLISH_ENABLED, ConfigLexicon::PUBLISH_ENABLED_DEFAULT) === 'true', + 'publish_enabled' => $this->appConfig->getValueBool('collectives', ConfigLexicon::PUBLISH_ENABLED, ConfigLexicon::PUBLISH_ENABLED_DEFAULT), 'static_site_generator_url' => $this->appConfig->getValueString('collectives', ConfigLexicon::STATIC_SITE_GENERATOR_URL), // The token itself must not be rendered into the page, so a set token is replaced by a placeholder 'static_site_generator_token' => $this->appConfig->getValueString('collectives', ConfigLexicon::STATIC_SITE_GENERATOR_TOKEN) === '' ? '' : self::TOKEN_PLACEHOLDER, diff --git a/playwright/e2e/collective-publish.spec.ts b/playwright/e2e/collective-publish.spec.ts index 6037f5f9e..7dbf1766e 100644 --- a/playwright/e2e/collective-publish.spec.ts +++ b/playwright/e2e/collective-publish.spec.ts @@ -104,6 +104,59 @@ test.describe('Collective publish', () => { }) }) + test('preselects pages created after the page list was loaded if the collective was never published', async ({ collective, navigation, page, user }) => { + await collective.openCollective() + // Created via API, so the page list in the browser doesn't contain it yet + await collective.createPage({ title: 'Page created meanwhile', user, page }) + + await navigation.clickCollectiveMenu(collective.data.name, 'Publish website') + const modal = page.getByRole('dialog') + await expect(modal.getByRole('checkbox', { name: 'Page created meanwhile' })).toBeChecked() + await expect(modal.getByRole('button', { name: 'Publish website' })).toBeEnabled() + }) + + test('restores the page selection of an already published website without deleted and new pages', async ({ collective, navigation, page, user }) => { + const publishedPage = await collective.createPage({ title: 'Published page', user, page }) + const rootPageId = collective.getRootPage().data.id + const deletedPageId = 999999 + const staticSite = { + id: 1, + title: 'Published website', + slug: 'published-website', + selectedPageIds: [rootPageId, publishedPage.data.id, deletedPageId], + publishedUrl: 'https://example.org/published-website', + status: 'published', + } + let sentPageIds: number[] = [] + await page.route('**/static-sites', async (route) => { + const request = route.request() + if (request.method() === 'POST') { + // The publish service isn't part of this environment, only the sent page selection matters + sentPageIds = request.postDataJSON().pageIds + return route.fulfill({ json: { ocs: { meta: { status: 'ok', statuscode: 200, message: 'OK' }, data: staticSite } } }) + } + if (request.method() !== 'GET') { + return route.fallback() + } + await route.fulfill({ json: { ocs: { meta: { status: 'ok', statuscode: 200, message: 'OK' }, data: [staticSite] } } }) + }) + + await collective.openCollective() + await collective.createPage({ title: 'Page created after publication', user, page }) + + await navigation.clickCollectiveMenu(collective.data.name, 'Publish website') + const modal = page.getByRole('dialog') + await expect(modal.getByRole('textbox', { name: 'Website title' })).toHaveValue(staticSite.title) + await expect(modal.getByRole('textbox', { name: 'Web address' })).toHaveValue(staticSite.slug) + await expect(modal.getByRole('checkbox', { name: 'Published page' })).toBeChecked() + await expect(modal.getByRole('checkbox', { name: 'Page created after publication' })).not.toBeChecked() + + await modal.getByRole('button', { name: 'Publish website' }).click() + await expect(modal).toHaveCount(0) + expect(sentPageIds).toHaveLength(2) + expect(sentPageIds).toEqual(expect.arrayContaining([rootPageId, publishedPage.data.id])) + }) + test('keeps the modal open if the website files could not be provided', async ({ collective, navigation, page }) => { // A failed archive build can't be provoked reliably, so the response is replaced await page.route('**/static-sites', async (route) => { @@ -132,7 +185,7 @@ test.describe('Collective publish', () => { if (route.request().method() !== 'GET') { return route.fallback() } - await route.fulfill({ json: { ocs: { meta: { status: 'ok', statuscode: 200, message: 'OK' }, data: [{ id: 1, publishedUrl }] } } }) + await route.fulfill({ json: { ocs: { meta: { status: 'ok', statuscode: 200, message: 'OK' }, data: [{ id: 1, title: 'Published website', slug: 'published-website', selectedPageIds: [], publishedUrl }] } } }) }) await collective.openCollective() diff --git a/src/CollectivesApp.vue b/src/CollectivesApp.vue index 10937312e..11f9000e5 100644 --- a/src/CollectivesApp.vue +++ b/src/CollectivesApp.vue @@ -162,9 +162,7 @@ export default { loadAdminSettings() { try { - const publishEnabledState = loadState('collectives', 'publish_enabled', true) - const isPublishEnabled = publishEnabledState === true || publishEnabledState === 'true' - this.setPublishFeatureEnabled(isPublishEnabled) + this.setPublishFeatureEnabled(loadState('collectives', 'publish_enabled', false)) } catch (e) { console.error('Failed to load admin settings:', e) } diff --git a/src/components/Nav/CollectivePublishModal.vue b/src/components/Nav/CollectivePublishModal.vue index 715cedbee..128ce213b 100644 --- a/src/components/Nav/CollectivePublishModal.vue +++ b/src/components/Nav/CollectivePublishModal.vue @@ -30,7 +30,8 @@ :helperText="t('collectives', 'Lowercase letters, numbers and single hyphens')" :maxlength="slugMaxLength" /> -