From eacc56364e634291e9bf22b6306aa1807361feb5 Mon Sep 17 00:00:00 2001 From: Melpo Date: Mon, 19 Oct 2026 15:53:20 +0200 Subject: [PATCH 1/6] feat(publish): refactor to more readable error message Signed-off-by: Melpo --- lib/Controller/StaticSiteController.php | 2 +- tests/Unit/Controller/StaticSiteControllerTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/tests/Unit/Controller/StaticSiteControllerTest.php b/tests/Unit/Controller/StaticSiteControllerTest.php index 480368690..e197de24f 100644 --- a/tests/Unit/Controller/StaticSiteControllerTest.php +++ b/tests/Unit/Controller/StaticSiteControllerTest.php @@ -100,7 +100,7 @@ public static function generatorExceptionProvider(): array { 'rejected' => [ new StaticSiteGeneratorException('Rejected'), Http::STATUS_BAD_GATEWAY, - 'The publish service rejected the website. Please contact your administrator.', + 'The publish service could not process the request. Please contact your administrator.', ], ]; } From 8b836abf4863e6abfdd496c08272cb06fddd0fd5 Mon Sep 17 00:00:00 2001 From: Melpo Date: Mon, 19 Oct 2026 15:53:57 +0200 Subject: [PATCH 2/6] feat(publish): store publish_enabled as boolean and disable it by default Signed-off-by: Melpo Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/ConfigLexicon.php | 6 +++--- lib/Listeners/BeforeTemplateRenderedListener.php | 2 +- lib/Settings/Admin.php | 2 +- src/CollectivesApp.vue | 4 +--- 4 files changed, 6 insertions(+), 8 deletions(-) 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/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/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/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) } From e100873c3a172e6e466f65ce90a2682aee002632 Mon Sep 17 00:00:00 2001 From: Melpo Date: Wed, 21 Oct 2026 17:40:07 +0200 Subject: [PATCH 3/6] feat(publish): add check for publish feature enabled to api calls Signed-off-by: Melpo Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/StaticSiteService.php | 18 ++++++- src/views/AdminSettings.vue | 2 +- .../Integration/features/staticSites.feature | 8 ++- tests/Unit/Service/StaticSiteServiceTest.php | 50 +++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/lib/Service/StaticSiteService.php b/lib/Service/StaticSiteService.php index 2a8d783a5..68daab0f3 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); @@ -160,6 +162,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); } @@ -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/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 516cc7ecc..a7d91762e 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -19,7 +19,7 @@ + :description="t('collectives', 'Enable or disable the publish feature for collectives. Disabling it prevents new publications, already published websites are not removed.')">
method('getValueInt') ->with('collectives', ConfigLexicon::STATIC_SITE_MAX_SIZE, ConfigLexicon::STATIC_SITE_MAX_SIZE_DEFAULT) ->willReturnCallback(fn (): int => $this->maxSize); + $appConfig->method('getValueBool') + ->with('collectives', ConfigLexicon::PUBLISH_ENABLED, ConfigLexicon::PUBLISH_ENABLED_DEFAULT) + ->willReturnCallback(fn (): bool => $this->publishFeatureEnabled); $l10n = $this->createMock(IL10N::class); $l10n->method('t')->willReturnCallback(static fn (string $text, array $parameters = []): string => vsprintf($text, $parameters)); @@ -661,6 +665,52 @@ public function testPublishKeepsNotFoundExceptionIfArchiveDeletionFails(): void $this->service->create($this->collectiveId, [1], $this->userId); } + // --- publish feature settings --- + + public function testPublishRejectsIfPublishFeatureIsDisabled(): void { + $this->publishFeatureEnabled = false; + $this->expectValidCollectiveWithPages(); + $this->staticSiteMapper->expects($this->never())->method('create'); + $this->archiver->expects($this->never())->method('store'); + $this->generatorClient->expects($this->never())->method('build'); + + $this->expectException(NotPermittedException::class); + $this->service->create($this->collectiveId, [1], $this->userId); + } + + public function testUpdateRejectsIfPublishFeatureIsDisabled(): void { + $this->publishFeatureEnabled = false; + $this->expectValidCollectiveWithPages(); + $this->staticSiteMapper->method('findByIdAndCollectiveId')->willReturn($this->makeStaticSite(StaticSite::STATUS_PUBLISHED)); + $this->staticSiteMapper->expects($this->never())->method('updateStatusIfUnchanged'); + $this->archiver->expects($this->never())->method('store'); + $this->generatorClient->expects($this->never())->method('build'); + + $this->expectException(NotPermittedException::class); + $this->service->update($this->collectiveId, 7, [1], 'Title', $this->userId); + } + + public function testDeleteRemovesStaticSiteIfPublishFeatureIsDisabled(): void { + $this->publishFeatureEnabled = false; + $this->collectiveService->method('getCollective')->willReturn($this->makeCollective(canEdit: true)); + $staticSite = $this->makeStaticSite(StaticSite::STATUS_PUBLISHED); + $this->staticSiteMapper->method('findByIdAndCollectiveId')->willReturn($staticSite); + + $this->staticSiteMapper->expects($this->once())->method('delete')->with($staticSite); + $this->archiver->expects($this->once())->method('delete')->with($this->staticSiteId); + + $this->service->delete($this->collectiveId, 7, $this->userId); + } + + public function testGetStaticSitesReturnsListIfPublishFeatureIsDisabled(): void { + $this->publishFeatureEnabled = false; + $this->collectiveService->method('getCollective')->willReturn($this->makeCollective(canEdit: false)); + $staticSite = $this->makeStaticSite(StaticSite::STATUS_PUBLISHED); + $this->staticSiteMapper->method('findByCollectiveId')->willReturn([$staticSite]); + + self::assertSame([$staticSite], $this->service->getStaticSites($this->collectiveId, $this->userId)); + } + // --- static site generator --- public function testPublishRejectsIfGeneratorIsNotConfigured(): void { From 10137310736b7f3b641219ea2f00022fce91ff25 Mon Sep 17 00:00:00 2001 From: Melpo Date: Wed, 21 Oct 2026 18:12:57 +0200 Subject: [PATCH 4/6] feat(publish): add page refresh when publish modal is opened Signed-off-by: Melpo Assisted-by: ClaudeCode:claude-opus-5-5 --- playwright/e2e/collective-publish.spec.ts | 11 ++++++++ src/components/Nav/CollectivePublishModal.vue | 25 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/playwright/e2e/collective-publish.spec.ts b/playwright/e2e/collective-publish.spec.ts index 6037f5f9e..5e727d7ec 100644 --- a/playwright/e2e/collective-publish.spec.ts +++ b/playwright/e2e/collective-publish.spec.ts @@ -104,6 +104,17 @@ test.describe('Collective publish', () => { }) }) + test('preselects pages that were created after the page list was loaded', 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('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) => { diff --git a/src/components/Nav/CollectivePublishModal.vue b/src/components/Nav/CollectivePublishModal.vue index 715cedbee..74c8bacdc 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" />
-