From 79f97efb1a65654899d1990168a308ec48f4d2c1 Mon Sep 17 00:00:00 2001 From: Melpo Date: Tue, 8 Sep 2026 10:37:56 +0200 Subject: [PATCH 1/4] feat(publish): add publish button and modal Signed-off-by: kayoshi Signed-off-by: Melpo Assisted-by: ClaudeCode:claude-sonnet-5 --- .../BeforeTemplateRenderedListener.php | 5 ++ lib/Settings/Admin.php | 1 + playwright/e2e/admin-settings.spec.ts | 16 ++++ playwright/e2e/collective-publish.spec.ts | 74 ++++++++++++++++++ src/CollectivesApp.vue | 14 ++++ src/apis/collectives/settings.js | 1 + .../Collective/NcActionCollectiveActions.vue | 19 ++++- src/components/Nav/CollectivePublishModal.vue | 76 +++++++++++++++++++ src/components/Nav/CollectiveSelector.vue | 16 ++++ src/stores/collectives.js | 11 +++ src/stores/root.js | 4 + src/views/AdminSettings.vue | 31 +++++++- 12 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 playwright/e2e/collective-publish.spec.ts create mode 100644 src/components/Nav/CollectivePublishModal.vue diff --git a/lib/Listeners/BeforeTemplateRenderedListener.php b/lib/Listeners/BeforeTemplateRenderedListener.php index 8a2c04cff1..3992e4997e 100644 --- a/lib/Listeners/BeforeTemplateRenderedListener.php +++ b/lib/Listeners/BeforeTemplateRenderedListener.php @@ -21,6 +21,7 @@ use OCP\EventDispatcher\IEventDispatcher; use OCP\EventDispatcher\IEventListener; use OCP\Files\Template\ITemplateManager; +use OCP\IAppConfig; use OCP\IUserSession; use OCP\Server; use OCP\Util; @@ -33,6 +34,7 @@ public function __construct( private readonly IEventDispatcher $eventDispatcher, private readonly IInitialState $initialState, private readonly ITemplateManager $templateManager, + private readonly IAppConfig $appConfig, ) { } @@ -73,5 +75,8 @@ public function handle(Event $event): void { $this->initialState->provideInitialState('user_folder', $userFolder); $this->initialState->provideInitialState('templates', $this->templateManager->listCreators()); $this->initialState->provideInitialState('is_guest', $isGuest); + + // Provide admin settings as initial state + $this->initialState->provideInitialState('publish_enabled', $this->appConfig->getValueString('collectives', 'publish_enabled', 'false') === 'true'); } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index c50a53d483..42457b8d48 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -27,6 +27,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', 'publish_enabled', 'false') === 'true', ]; $this->initialState->provideInitialState('adminSettings', $parameters); diff --git a/playwright/e2e/admin-settings.spec.ts b/playwright/e2e/admin-settings.spec.ts index 6e08d782f6..2de190abb7 100644 --- a/playwright/e2e/admin-settings.spec.ts +++ b/playwright/e2e/admin-settings.spec.ts @@ -12,6 +12,7 @@ import { expect, test } from '@playwright/test' test.describe('Admin settings', () => { let defaultUserFolderInput: Locator let defaultUserFolderHelperText: Locator + let publishFeatureSwitch: Locator test.beforeEach(async ({ page }) => { const admin: User = { @@ -23,6 +24,7 @@ test.describe('Admin settings', () => { await page.goto('/index.php/settings/admin/additional') defaultUserFolderInput = page.getByRole('textbox', { name: 'Default user folder' }) defaultUserFolderHelperText = page.locator('#defaultUserFolder-helper-text') + publishFeatureSwitch = page.getByLabel('Enable publish feature') await expect(defaultUserFolderInput).toBeVisible() await expect(defaultUserFolderHelperText).not.toBeVisible() }) @@ -54,4 +56,18 @@ test.describe('Admin settings', () => { await defaultUserFolderInput.blur() await requestPromise }) + + test('Publish feature can be enabled and disabled', async ({ page }) => { + await publishFeatureSwitch.setChecked(true, { force: true }) + await page.waitForTimeout(100) + await page.reload() + publishFeatureSwitch = page.getByLabel('Enable publish feature') + await expect(publishFeatureSwitch).toBeChecked() + + await publishFeatureSwitch.setChecked(false, { force: true }) + await page.waitForTimeout(100) + await page.reload() + publishFeatureSwitch = page.getByLabel('Enable publish feature') + await expect(publishFeatureSwitch).not.toBeChecked() + }) }) diff --git a/playwright/e2e/collective-publish.spec.ts b/playwright/e2e/collective-publish.spec.ts new file mode 100644 index 0000000000..d0532a8839 --- /dev/null +++ b/playwright/e2e/collective-publish.spec.ts @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect, mergeTests } from '@playwright/test' +import { test as createCollectivesTest } from '../support/fixtures/create-collectives.ts' +import { test as navigationTest } from '../support/fixtures/navigation.ts' +import { loginAsUser } from '../support/fixtures/random-user.ts' +import { User } from '../support/fixtures/User.ts' + +type MemberFixture = { page: Page, user: User } + +const baseTest = mergeTests(createCollectivesTest, navigationTest) + +// Extend fixture to add a logged-in member user for permission tests +const test = baseTest.extend<{ member: MemberFixture }>({ + member: async ({ collective, browser, baseURL }, use) => { + const account = await collective.addMember() + const memberPage = await loginAsUser(browser, baseURL, account) + await use({ page: memberPage, user: new User(account) }) + await memberPage.close() + }, +}) + +test.beforeAll(async () => { + await runOcc(['config:app:set', 'collectives', 'publish_enabled', '--value', 'true']) +}) + +test.describe('Collective publish', () => { + test('admin can open publish modal', async ({ collective, navigation, page }) => { + await collective.openCollective() + await page.getByRole('button', { name: 'Collective actions' }).click() + + // Publish button is visible for admin + const publishButton = page.locator('.action-item__popper:visible') + .getByRole('button', { name: 'Publish', exact: true }) + await expect(publishButton).toBeVisible() + + // Publish modal opens on publish button click + await publishButton.click() + const modal = page.getByRole('dialog') + await expect(modal + .filter({ has: page.getByRole('heading', { name: `Publish website for Collective ${collective.data.name}` }) })) + .toBeVisible() + + // Modal can be closed + await modal.getByRole('button', { name: 'Close' }).click() + await expect(modal).toHaveCount(0) + + // Modal can be opened again + await navigation.clickCollectiveMenu(collective.data.name, 'Publish') + const reopenedModal = page.getByRole('dialog') + await expect(reopenedModal).toBeVisible() + await expect(reopenedModal).toContainText(collective.data.name) + }) + + test('regular members cannot see publish button', async ({ collective, member }) => { + await member.page.goto(`/index.php/apps/collectives/${collective.getCollectiveUrlPart()}`) + + // Open the current collective's actions menu + await member.page.getByRole('button', { name: 'Collective actions' }).click() + + // Actions menu is visible for regular member, but without the publish button + const actionsMenu = member.page.locator('.action-item__popper:visible') + await expect(actionsMenu).toBeVisible() + + const publishButton = actionsMenu.getByRole('button', { name: 'Publish', exact: true }) + await expect(publishButton).toHaveCount(0) + }) +}) diff --git a/src/CollectivesApp.vue b/src/CollectivesApp.vue index f759e91e1d..00f445dc28 100644 --- a/src/CollectivesApp.vue +++ b/src/CollectivesApp.vue @@ -41,6 +41,7 @@ + + diff --git a/src/components/Nav/CollectiveSelector.vue b/src/components/Nav/CollectiveSelector.vue index e14f4a2944..cb2714e780 100644 --- a/src/components/Nav/CollectiveSelector.vue +++ b/src/components/Nav/CollectiveSelector.vue @@ -95,6 +95,10 @@ v-if="showCollectiveMembersModal" :collective="membersCollective" @close="onCloseCollectiveMembersModal" /> + @@ -117,6 +121,7 @@ import CollectivesIcon from '../Icon/CollectivesIcon.vue' import SkeletonLoading from '../SkeletonLoading.vue' import CollectiveListItem from './CollectiveListItem.vue' import CollectiveMembersModal from './CollectiveMembersModal.vue' +import CollectivePublishModal from './CollectivePublishModal.vue' import CollectivesGlobalSettings from './CollectivesGlobalSettings.vue' import CollectivesTrash from './CollectivesTrash.vue' import TemplatesDialog from './TemplatesDialog.vue' @@ -132,6 +137,7 @@ export default { ChevronDownIcon, CollectiveListItem, CollectiveMembersModal, + CollectivePublishModal, CollectivesGlobalSettings, CollectivesIcon, CollectivesTrash, @@ -176,6 +182,7 @@ export default { 'collectivePath', 'currentCollective', 'membersCollective', + 'publishCollective', 'sortedCollectives', 'templatesCollectiveId', ]), @@ -183,6 +190,10 @@ export default { showCollectiveMembersModal() { return !!this.membersCollective }, + + showCollectivePublishModal() { + return !!this.publishCollective + }, }, methods: { @@ -192,6 +203,7 @@ export default { 'deleteCollective', 'restoreCollective', 'setMembersCollectiveId', + 'setPublishCollectiveId', ]), onTriggerClick() { @@ -220,6 +232,10 @@ export default { onCloseCollectiveMembersModal() { this.setMembersCollectiveId(null) }, + + onCloseCollectivePublishModal() { + this.setPublishCollectiveId(null) + }, }, } diff --git a/src/stores/collectives.js b/src/stores/collectives.js index f57b8929a0..dc793887dc 100644 --- a/src/stores/collectives.js +++ b/src/stores/collectives.js @@ -27,6 +27,7 @@ export const useCollectivesStore = defineStore('collectives', { templatesCollectiveId: undefined, membersCollectiveId: undefined, settingsCollectiveId: undefined, + publishCollectiveId: undefined, }), getters: { @@ -176,6 +177,12 @@ export const useCollectivesStore = defineStore('collectives', { isFavoritePage: (state) => (id, pageId) => { return state.collectives.find((c) => c.id === id).userFavoritePages.includes(pageId) }, + + publishCollective(state) { + return state.publishCollectiveId + ? state.collectives.find((c) => c.id === state.publishCollectiveId) + : null + }, }, actions: { @@ -195,6 +202,10 @@ export const useCollectivesStore = defineStore('collectives', { this.lastVisitedCollectiveId = id }, + setPublishCollectiveId(id) { + this.publishCollectiveId = id + }, + /** * Get list of all collectives */ diff --git a/src/stores/root.js b/src/stores/root.js index eff91e8852..397300d4ca 100644 --- a/src/stores/root.js +++ b/src/stores/root.js @@ -24,6 +24,8 @@ export const useRootStore = defineStore('root', { fileIdQuery: '', listenPush: false, isGuest: loadState('collectives', 'is_guest', false), + // Safety default - actual value gets loaded from backend by setPublishFeatureEnabled() + isPublishFeatureEnabled: false, }), getters: { @@ -61,5 +63,7 @@ export const useRootStore = defineStore('root', { setPrintView() { this.printView = true }, setActiveSidebarTab(id) { this.activeSidebarTab = id }, + + setPublishFeatureEnabled(enabled) { this.isPublishFeatureEnabled = enabled }, }, }) diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 27422ec8dc..8ad598b10b 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -17,6 +17,17 @@ @keydown.enter="saveDefaultUserFolder" @blur="saveDefaultUserFolder" /> + + + {{ t('collectives', 'Enable publish feature') }} + + From 9649c759adde0312f5c48c428928ef01e06290a4 Mon Sep 17 00:00:00 2001 From: Melpo Date: Tue, 8 Sep 2026 16:12:24 +0200 Subject: [PATCH 2/4] feat(publish): select pages and start publish - create and save static site metadata to db - add update and delete route, add state lock on publish - add symfony/uid and symfony/polyfill-uuid to composer.json - add title and slug input fields to modal Signed-off-by: Melpo Assisted-by: ClaudeCode:claude-sonnet-5 --- appinfo/routes.php | 10 + composer.json | 1 + composer.lock | 165 ++- lib/Controller/StaticSiteController.php | 138 ++ lib/Db/StaticSite.php | 92 ++ lib/Db/StaticSiteMapper.php | 167 +++ .../Version040602Date20260908000000.php | 77 ++ lib/ResponseDefinitions.php | 14 + lib/Service/StaticSiteService.php | 180 +++ openapi.json | 1155 +++++++++++++++-- playwright/e2e/collective-publish.spec.ts | 88 +- src/apis/collectives/index.js | 1 + src/apis/collectives/staticSites.js | 41 + .../Collective/NcActionCollectiveActions.vue | 4 +- src/components/Nav/CollectivePublishModal.vue | 206 ++- src/components/Nav/PublishPageTreeItem.vue | 113 ++ src/components/Nav/PublishTreeRow.vue | 135 ++ src/util/staticSiteSlug.js | 33 + tests/Unit/Service/StaticSiteServiceTest.php | 426 ++++++ 19 files changed, 2874 insertions(+), 172 deletions(-) create mode 100644 lib/Controller/StaticSiteController.php create mode 100644 lib/Db/StaticSite.php create mode 100644 lib/Db/StaticSiteMapper.php create mode 100644 lib/Migration/Version040602Date20260908000000.php create mode 100644 lib/Service/StaticSiteService.php create mode 100644 src/apis/collectives/staticSites.js create mode 100644 src/components/Nav/PublishPageTreeItem.vue create mode 100644 src/components/Nav/PublishTreeRow.vue create mode 100644 src/util/staticSiteSlug.js create mode 100644 tests/Unit/Service/StaticSiteServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index b5a6e47e8f..ce2a1f5c9f 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -67,6 +67,16 @@ ['name' => 'share#deleteCollectiveShare', 'url' => '/api/v{apiVersion}/collectives/{collectiveId}/shares/{token}', 'verb' => 'DELETE', 'requirements' => ['apiVersion' => '(1.0)', 'collectiveId' => '\d+']], + // Collective static sites API + ['name' => 'static_site#index', 'url' => '/api/v{apiVersion}/collectives/{collectiveId}/static-sites', 'verb' => 'GET', + 'requirements' => ['apiVersion' => '(1.0)', 'collectiveId' => '\d+']], + ['name' => 'static_site#create', 'url' => '/api/v{apiVersion}/collectives/{collectiveId}/static-sites', 'verb' => 'POST', + 'requirements' => ['apiVersion' => '(1.0)', 'collectiveId' => '\d+']], + ['name' => 'static_site#update', 'url' => '/api/v{apiVersion}/collectives/{collectiveId}/static-sites/{id}', 'verb' => 'PUT', + 'requirements' => ['apiVersion' => '(1.0)', 'collectiveId' => '\d+', 'id' => '\d+']], + ['name' => 'static_site#delete', 'url' => '/api/v{apiVersion}/collectives/{collectiveId}/static-sites/{id}', 'verb' => 'DELETE', + 'requirements' => ['apiVersion' => '(1.0)', 'collectiveId' => '\d+', 'id' => '\d+']], + // Pages search API ['name' => 'search#searchRecentPages', 'url' => '/api/v{apiVersion}/collectives/search/recent', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(1.0)']], diff --git a/composer.json b/composer.json index d6d8b21a94..d15e16edef 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "patrickschur/language-detection": "^5.3", "symfony/string": "^6.0", "symfony/translation-contracts": "^3.6", + "symfony/uid": "^6.4", "wamania/php-stemmer": "^4.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 4c9d877e04..73383e4e6b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2e2fc4b0f5deb638c1870f86eaf1d107", + "content-hash": "d1da5b3b7a443b7ce5477a94176adcb7", "packages": [ { "name": "dflydev/dot-access-data", @@ -1080,6 +1080,89 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/string", "version": "v6.4.43", @@ -1251,6 +1334,84 @@ ], "time": "2026-06-05T06:23:12+00:00" }, + { + "name": "symfony/uid", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2266f787b76bbbc339f4fdb40111ca461782f282" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2266f787b76bbbc339f4fdb40111ca461782f282", + "reference": "2266f787b76bbbc339f4fdb40111ca461782f282", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T16:05:37+00:00" + }, { "name": "wamania/php-stemmer", "version": "v4.0.0", @@ -1362,7 +1523,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/lib/Controller/StaticSiteController.php b/lib/Controller/StaticSiteController.php new file mode 100644 index 0000000000..780971fdf6 --- /dev/null +++ b/lib/Controller/StaticSiteController.php @@ -0,0 +1,138 @@ +, array{}> + * @throws OCSForbiddenException Not permitted + * @throws OCSNotFoundException Collective not found + * + * 200: Static sites returned + */ + #[NoAdminRequired] + public function index(int $collectiveId): DataResponse { + $uid = $this->getUid(); + $staticSites = $this->handleErrorResponse( + fn (): array => $this->staticSiteService->getStaticSites($collectiveId, $uid), + $this->logger, + ); + return new DataResponse($staticSites); + } + + /** + * Start a static site export for a selection of pages of a collective + * + * @param int $collectiveId ID of the collective + * @param list $pageIds IDs of the pages to publish + * @param ?string $title Title of the static site, defaults to the collective name + * @param ?string $slug Slug of the static site (lowercase ASCII letters and numbers, separated by single hyphens), defaults to the slugified collective name + * + * @return DataResponse + * @throws OCSBadRequestException No pages selected, invalid title or slug, slug already in use + * @throws OCSForbiddenException Not permitted + * @throws OCSNotFoundException Collective or page not found + * + * 200: Static site created + */ + #[NoAdminRequired] + public function create(int $collectiveId, array $pageIds, ?string $title = null, ?string $slug = null): DataResponse { + $uid = $this->getUid(); + $staticSite = $this->handleErrorResponse( + fn (): StaticSite => $this->staticSiteService->create($collectiveId, $pageIds, $uid, $title, $slug), + $this->logger, + ); + return new DataResponse($staticSite); + } + + /** + * Update title and page selection of a static site and publish it again + * + * Every call starts a new publication, even if nothing changed. The slug can't be changed. + * + * @param int $collectiveId ID of the collective + * @param int $id ID of the static site + * @param list $pageIds IDs of the pages to publish + * @param string $title Title of the static site + * + * @return DataResponse + * @throws OCSBadRequestException No pages selected, invalid title or publication in progress + * @throws OCSForbiddenException Not permitted + * @throws OCSNotFoundException Collective, static site or page not found + * + * 200: Static site updated + */ + #[NoAdminRequired] + public function update(int $collectiveId, int $id, array $pageIds, string $title): DataResponse { + $uid = $this->getUid(); + $staticSite = $this->handleErrorResponse( + fn (): StaticSite => $this->staticSiteService->update($collectiveId, $id, $pageIds, $title, $uid), + $this->logger, + ); + return new DataResponse($staticSite); + } + + /** + * Delete a static site + * + * @param int $collectiveId ID of the collective + * @param int $id ID of the static site + * + * @return DataResponse, array{}> + * @throws OCSForbiddenException Not permitted + * @throws OCSNotFoundException Collective or static site not found + * + * 200: Static site deleted + */ + #[NoAdminRequired] + public function delete(int $collectiveId, int $id): DataResponse { + $uid = $this->getUid(); + $this->handleErrorResponse( + fn () => $this->staticSiteService->delete($collectiveId, $id, $uid), + $this->logger, + ); + return new DataResponse([]); + } +} diff --git a/lib/Db/StaticSite.php b/lib/Db/StaticSite.php new file mode 100644 index 0000000000..f7f43b8961 --- /dev/null +++ b/lib/Db/StaticSite.php @@ -0,0 +1,92 @@ +status, self::IN_PROGRESS_STATUSES, true); + } + + public function getSelectedPageIds(): array { + return json_decode($this->selectedPages ?? '[]', true, 512, JSON_THROW_ON_ERROR); + } + + public function setSelectedPageIds(array $pageIds): void { + $this->setSelectedPages(json_encode(array_values($pageIds), JSON_THROW_ON_ERROR)); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'collectiveId' => $this->collectiveId, + 'staticSiteId' => $this->staticSiteId, + 'title' => $this->title, + 'slug' => $this->slug, + 'selectedPageIds' => $this->getSelectedPageIds(), + 'publishedUrl' => $this->publishedUrl, + 'status' => $this->status, + 'createdBy' => $this->createdBy, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } +} diff --git a/lib/Db/StaticSiteMapper.php b/lib/Db/StaticSiteMapper.php new file mode 100644 index 0000000000..a083d9d374 --- /dev/null +++ b/lib/Db/StaticSiteMapper.php @@ -0,0 +1,167 @@ + + */ +class StaticSiteMapper extends QBMapper { + public function __construct( + IDBConnection $db, + private readonly ITimeFactory $timeFactory, + ) { + parent::__construct($db, 'collectives_st_sites', StaticSite::class); + } + + /** + * @return StaticSite[] + */ + public function findByCollectiveId(int $collectiveId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where( + $qb->expr()->eq('collective_id', $qb->createNamedParameter($collectiveId, IQueryBuilder::PARAM_INT)) + ) + ->orderBy('created_at', 'DESC'); + return $this->findEntities($qb); + } + + /** + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function findOneByStaticSiteId(string $staticSiteId): StaticSite { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where( + $qb->expr()->eq('static_site_id', $qb->createNamedParameter($staticSiteId, IQueryBuilder::PARAM_STR)) + ); + return $this->findEntity($qb); + } + + /** + * @param list $pageIds + * + * @throws Exception + */ + public function create(int $collectiveId, array $pageIds, string $title, string $slug, string $createdBy): StaticSite { + $now = $this->timeFactory->getTime(); + + $staticSite = new StaticSite(); + $staticSite->setCollectiveId($collectiveId); + $staticSite->setStaticSiteId(Uuid::v7()->toRfc4122()); + $staticSite->setTitle($title); + $staticSite->setSlug($slug); + $staticSite->setSelectedPageIds($pageIds); + $staticSite->setStatus(StaticSite::STATUS_PENDING); + $staticSite->setCreatedBy($createdBy); + $staticSite->setCreatedAt($now); + $staticSite->setUpdatedAt($now); + + return $this->insert($staticSite); + } + + /** + * Update title and page selection and reset the status for a new publication. + * + * @param list $pageIds + * + * @throws Exception + */ + public function republish(StaticSite $staticSite, string $title, array $pageIds): StaticSite { + $staticSite->setTitle($title); + $staticSite->setSelectedPageIds($pageIds); + $staticSite->setStatus(StaticSite::STATUS_PENDING); + $staticSite->setUpdatedAt($this->timeFactory->getTime()); + return $this->update($staticSite); + } + + /** + * @throws NotFoundException + * @throws Exception + */ + public function updateStatus(int $id, string $status): StaticSite { + try { + $staticSite = $this->find($id); + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + throw new NotFoundException('Static site not found', 0, $e); + } + + $staticSite->setStatus($status); + $staticSite->setUpdatedAt($this->timeFactory->getTime()); + return $this->update($staticSite); + } + + /** + * @throws NotFoundException + * @throws Exception + */ + public function updatePublishedUrl(int $id, string $publishedUrl): StaticSite { + try { + $staticSite = $this->find($id); + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + throw new NotFoundException('Static site not found', 0, $e); + } + + $staticSite->setPublishedUrl($publishedUrl); + $staticSite->setUpdatedAt($this->timeFactory->getTime()); + return $this->update($staticSite); + } + + /** + * @throws NotFoundException + * @throws Exception + */ + public function findByIdAndCollectiveId(int $id, int $collectiveId): StaticSite { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('collective_id', $qb->createNamedParameter($collectiveId, IQueryBuilder::PARAM_INT))); + try { + return $this->findEntity($qb); + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + throw new NotFoundException('Static site not found', 0, $e); + } + } + + /** + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function find(int $id): StaticSite { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)) + ); + return $this->findEntity($qb); + } +} diff --git a/lib/Migration/Version040602Date20260908000000.php b/lib/Migration/Version040602Date20260908000000.php new file mode 100644 index 0000000000..7ac1572c7a --- /dev/null +++ b/lib/Migration/Version040602Date20260908000000.php @@ -0,0 +1,77 @@ +hasTable('collectives_st_sites')) { + $table = $schema->createTable('collectives_st_sites'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('collective_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 6, + ]); + $table->addColumn('static_site_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('title', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('slug', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('selected_pages', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('published_url', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + 'default' => null, + ]); + $table->addColumn('status', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + 'default' => 'pending', + ]); + $table->addColumn('created_by', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('created_at', Types::BIGINT, [ + 'notnull' => true, + ]); + $table->addColumn('updated_at', Types::BIGINT, [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['collective_id'], 'collectives_st_sites_c_id_idx'); + $table->addUniqueIndex(['static_site_id'], 'collectives_st_sites_ssid_idx'); + $table->addUniqueIndex(['slug'], 'collectives_st_sites_slug_idx'); + return $schema; + } + + return null; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4d4cfb624d..b531ded865 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -44,6 +44,20 @@ * hasPassword: bool, * } * + * @psalm-type CollectivesStaticSite = array{ + * id: int, + * collectiveId: int, + * staticSiteId: string, + * title: string, + * slug: string, + * selectedPageIds: list, + * publishedUrl: ?string, + * status: string, + * createdBy: string, + * createdAt: int, + * updatedAt: int, + * } + * * @psalm-type CollectivesPageInfo = array{ * id: int, * slug?: string, diff --git a/lib/Service/StaticSiteService.php b/lib/Service/StaticSiteService.php new file mode 100644 index 0000000000..e15298bf32 --- /dev/null +++ b/lib/Service/StaticSiteService.php @@ -0,0 +1,180 @@ + $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 UnprocessableEntityException No pages selected, invalid title or slug, slug already in use + * @throws DBException + */ + public function create(int $collectiveId, array $pageIds, string $userId, ?string $title = null, ?string $slug = null): StaticSite { + $pageIds = $this->normalizePageIds($pageIds); + $collective = $this->getEditableCollective($collectiveId, $userId); + + $title = trim($title ?? $collective->getName()); + $slug ??= $this->generateSlug($collective->getName()); + $this->validateTitle($title); + $this->validateSlug($slug); + + $this->verifyPagesBelongToCollective($collectiveId, $pageIds, $userId); + + try { + return $this->staticSiteMapper->create($collectiveId, $pageIds, $title, $slug, $userId); + } catch (DBException $e) { + if ($e->getReason() !== DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + throw new UnprocessableEntityException('Slug is already in use: ' . $slug, 0, $e); + } + } + + /** + * Update title and page selection of a static site and publish it again. + * + * The slug can't be changed as it is part of the public URL. + * + * @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 UnprocessableEntityException No pages selected, invalid title or publication in progress + * @throws DBException + */ + public function update(int $collectiveId, int $id, array $pageIds, string $title, string $userId): StaticSite { + $pageIds = $this->normalizePageIds($pageIds); + $this->getEditableCollective($collectiveId, $userId); + + $staticSite = $this->staticSiteMapper->findByIdAndCollectiveId($id, $collectiveId); + if ($staticSite->isInProgress()) { + throw new UnprocessableEntityException('Publication of static site is already in progress'); + } + + $title = trim($title); + $this->validateTitle($title); + $this->verifyPagesBelongToCollective($collectiveId, $pageIds, $userId); + + return $this->staticSiteMapper->republish($staticSite, $title, $pageIds); + } + + /** + * @throws NotFoundException Collective or static site not found + * @throws NotPermittedException User is not allowed to edit the collective + * @throws DBException + */ + public function delete(int $collectiveId, int $id, string $userId): void { + $this->getEditableCollective($collectiveId, $userId); + + $staticSite = $this->staticSiteMapper->findByIdAndCollectiveId($id, $collectiveId); + $this->staticSiteMapper->delete($staticSite); + } + + /** + * @return StaticSite[] + * + * @throws NotFoundException Collective not found + * @throws NotPermittedException User is not allowed to access the collective + */ + public function getStaticSites(int $collectiveId, string $userId): array { + $this->collectiveService->getCollective($collectiveId, $userId); + + return $this->staticSiteMapper->findByCollectiveId($collectiveId); + } + + /** + * @return list + * + * @throws UnprocessableEntityException + */ + private function normalizePageIds(array $pageIds): array { + $pageIds = array_map(intval(...), array_values($pageIds)); + if (empty($pageIds)) { + throw new UnprocessableEntityException('No pages selected for publishing'); + } + return $pageIds; + } + + /** + * @throws NotFoundException + * @throws NotPermittedException + */ + private function getEditableCollective(int $collectiveId, string $userId): Collective { + $collective = $this->collectiveService->getCollective($collectiveId, $userId); + if (!$collective->canEdit()) { + throw new NotPermittedException('Not allowed to edit collective'); + } + return $collective; + } + + private function generateSlug(string $name): string { + $slug = strtolower($this->slugService->generateSlug($name)); + return trim(substr($slug, 0, self::SLUG_MAX_LENGTH), '-'); + } + + /** + * @throws UnprocessableEntityException + */ + private function validateTitle(string $title): void { + if ($title === '' || mb_strlen($title) > self::TITLE_MAX_LENGTH) { + throw new UnprocessableEntityException('Title must be between 1 and ' . self::TITLE_MAX_LENGTH . ' characters'); + } + } + + /** + * @throws UnprocessableEntityException + */ + private function validateSlug(string $slug): void { + if (strlen($slug) > self::SLUG_MAX_LENGTH || !preg_match(self::SLUG_PATTERN, $slug)) { + throw new UnprocessableEntityException('Slug must consist of 1 to ' . self::SLUG_MAX_LENGTH . ' lowercase ASCII letters and numbers, separated by single hyphens'); + } + } + + /** + * @param list $pageIds + * + * @throws NotFoundException If a page doesn't belong to the collective + * @throws NotPermittedException + */ + private function verifyPagesBelongToCollective(int $collectiveId, array $pageIds, string $userId): void { + $validPageIds = array_map( + static fn ($pageInfo) => $pageInfo->getId(), + $this->pageService->findAll($collectiveId, $userId) + ); + + $unknownPageIds = array_diff($pageIds, $validPageIds); + if (!empty($unknownPageIds)) { + throw new NotFoundException('Page(s) not found in collective: ' . implode(', ', $unknownPageIds)); + } + } +} diff --git a/openapi.json b/openapi.json index 83518d4f69..29746585b9 100644 --- a/openapi.json +++ b/openapi.json @@ -332,6 +332,66 @@ } } }, + "StaticSite": { + "type": "object", + "required": [ + "id", + "collectiveId", + "staticSiteId", + "title", + "slug", + "selectedPageIds", + "publishedUrl", + "status", + "createdBy", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "collectiveId": { + "type": "integer", + "format": "int64" + }, + "staticSiteId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "selectedPageIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + }, + "publishedUrl": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "createdBy": { + "type": "string" + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "integer", + "format": "int64" + } + } + }, "Tag": { "type": "object", "required": [ @@ -2439,13 +2499,803 @@ } }, { - "name": "collectiveId", + "name": "collectiveId", + "in": "path", + "description": "ID of the collective", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/CollectiveShare" + } + } + } + } + } + } + } + }, + "403": { + "description": "Not permitted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Collective or page not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/pages/{pageId}/shares": { + "post": { + "operationId": "share-create-page-share", + "summary": "Create a page share", + "tags": [ + "share" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "password": { + "type": "string", + "nullable": true, + "default": null, + "description": "Optional password for the share" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1.0" + ], + "default": "1.0" + } + }, + { + "name": "collectiveId", + "in": "path", + "description": "ID of the collective", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "default": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/CollectiveShare" + } + } + } + } + } + } + } + }, + "403": { + "description": "Not permitted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Collective or page not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/pages/{pageId}/shares/{token}": { + "put": { + "operationId": "share-update-page-share", + "summary": "Update a page share", + "tags": [ + "share" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "editable" + ], + "properties": { + "editable": { + "type": "boolean", + "description": "Whether share has edit permissions" + }, + "password": { + "type": "string", + "nullable": true, + "default": null, + "description": "Optional password for the share" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1.0" + ], + "default": "1.0" + } + }, + { + "name": "collectiveId", + "in": "path", + "description": "ID of the collective", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "token", + "in": "path", + "description": "Token of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/CollectiveShare" + } + } + } + } + } + } + } + }, + "403": { + "description": "Not permitted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Collective, page or share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "share-delete-page-share", + "summary": "Delete a page share", + "tags": [ + "share" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1.0" + ], + "default": "1.0" + } + }, + { + "name": "collectiveId", + "in": "path", + "description": "ID of the collective", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "token", + "in": "path", + "description": "Token of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Not permitted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Collective, page or share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/shares/{token}": { + "put": { + "operationId": "share-update-collective-share", + "summary": "Update a collective share", + "tags": [ + "share" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "editable" + ], + "properties": { + "editable": { + "type": "boolean", + "description": "Whether share has edit permissions" + }, + "password": { + "type": "string", + "nullable": true, + "default": null, + "description": "Optional password for the share" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1.0" + ], + "default": "1.0" + } + }, + { + "name": "collectiveId", + "in": "path", + "description": "ID of the collective", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "token", "in": "path", - "description": "ID of the collective", + "description": "Token of the share", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string" } }, { @@ -2461,7 +3311,7 @@ ], "responses": { "200": { - "description": "Share created", + "description": "Share updated", "content": { "application/json": { "schema": { @@ -2519,7 +3369,7 @@ } }, "404": { - "description": "Collective or page not found", + "description": "Collective, page or share not found", "content": { "application/json": { "schema": { @@ -2575,12 +3425,10 @@ } } } - } - }, - "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/pages/{pageId}/shares": { - "post": { - "operationId": "share-create-page-share", - "summary": "Create a page share", + }, + "delete": { + "operationId": "share-delete-collective-share", + "summary": "Delete a collective share", "tags": [ "share" ], @@ -2592,24 +3440,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "password": { - "type": "string", - "nullable": true, - "default": null, - "description": "Optional password for the share" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -2634,14 +3464,12 @@ } }, { - "name": "pageId", + "name": "token", "in": "path", - "description": "ID of the page", + "description": "Token of the share", "required": true, "schema": { - "type": "integer", - "format": "int64", - "default": 0 + "type": "string" } }, { @@ -2657,7 +3485,7 @@ ], "responses": { "200": { - "description": "Share created", + "description": "Share deleted", "content": { "application/json": { "schema": { @@ -2676,9 +3504,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "$ref": "#/components/schemas/CollectiveShare" - } + "data": {} } } } @@ -2715,7 +3541,7 @@ } }, "404": { - "description": "Collective or page not found", + "description": "Collective or share not found", "content": { "application/json": { "schema": { @@ -2773,12 +3599,12 @@ } } }, - "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/pages/{pageId}/shares/{token}": { - "put": { - "operationId": "share-update-page-share", - "summary": "Update a page share", + "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/static-sites": { + "get": { + "operationId": "static_site-index", + "summary": "Get static sites of a collective", "tags": [ - "share" + "static_site" ], "security": [ { @@ -2788,31 +3614,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "editable" - ], - "properties": { - "editable": { - "type": "boolean", - "description": "Whether share has edit permissions" - }, - "password": { - "type": "string", - "nullable": true, - "default": null, - "description": "Optional password for the share" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -2836,25 +3637,6 @@ "format": "int64" } }, - { - "name": "pageId", - "in": "path", - "description": "ID of the page", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "token", - "in": "path", - "description": "Token of the share", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -2868,7 +3650,7 @@ ], "responses": { "200": { - "description": "Share updated", + "description": "Static sites returned", "content": { "application/json": { "schema": { @@ -2888,7 +3670,10 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CollectiveShare" + "type": "array", + "items": { + "$ref": "#/components/schemas/StaticSite" + } } } } @@ -2926,7 +3711,7 @@ } }, "404": { - "description": "Collective, page or share not found", + "description": "Collective not found", "content": { "application/json": { "schema": { @@ -2983,11 +3768,11 @@ } } }, - "delete": { - "operationId": "share-delete-page-share", - "summary": "Delete a page share", + "post": { + "operationId": "static_site-create", + "summary": "Start a static site export for a selection of pages of a collective", "tags": [ - "share" + "static_site" ], "security": [ { @@ -2997,6 +3782,41 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "pageIds" + ], + "properties": { + "pageIds": { + "type": "array", + "description": "IDs of the pages to publish", + "items": { + "type": "integer", + "format": "int64" + } + }, + "title": { + "type": "string", + "nullable": true, + "default": null, + "description": "Title of the static site, defaults to the collective name" + }, + "slug": { + "type": "string", + "nullable": true, + "default": null, + "description": "Slug of the static site (lowercase ASCII letters and numbers, separated by single hyphens), defaults to the slugified collective name" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -3020,25 +3840,6 @@ "format": "int64" } }, - { - "name": "pageId", - "in": "path", - "description": "ID of the page", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "token", - "in": "path", - "description": "Token of the share", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -3052,7 +3853,37 @@ ], "responses": { "200": { - "description": "Share deleted", + "description": "Static site created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/StaticSite" + } + } + } + } + } + } + } + }, + "400": { + "description": "No pages selected, invalid title or slug, slug already in use", "content": { "application/json": { "schema": { @@ -3108,7 +3939,7 @@ } }, "404": { - "description": "Collective, page or share not found", + "description": "Collective or page not found", "content": { "application/json": { "schema": { @@ -3166,12 +3997,13 @@ } } }, - "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/shares/{token}": { + "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/static-sites/{id}": { "put": { - "operationId": "share-update-collective-share", - "summary": "Update a collective share", + "operationId": "static_site-update", + "summary": "Update title and page selection of a static site and publish it again", + "description": "Every call starts a new publication, even if nothing changed. The slug can't be changed.", "tags": [ - "share" + "static_site" ], "security": [ { @@ -3188,18 +4020,21 @@ "schema": { "type": "object", "required": [ - "editable" + "pageIds", + "title" ], "properties": { - "editable": { - "type": "boolean", - "description": "Whether share has edit permissions" + "pageIds": { + "type": "array", + "description": "IDs of the pages to publish", + "items": { + "type": "integer", + "format": "int64" + } }, - "password": { + "title": { "type": "string", - "nullable": true, - "default": null, - "description": "Optional password for the share" + "description": "Title of the static site" } } } @@ -3230,12 +4065,13 @@ } }, { - "name": "token", + "name": "id", "in": "path", - "description": "Token of the share", + "description": "ID of the static site", "required": true, "schema": { - "type": "string" + "type": "integer", + "format": "int64" } }, { @@ -3251,7 +4087,7 @@ ], "responses": { "200": { - "description": "Share updated", + "description": "Static site updated", "content": { "application/json": { "schema": { @@ -3271,7 +4107,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CollectiveShare" + "$ref": "#/components/schemas/StaticSite" } } } @@ -3280,6 +4116,34 @@ } } }, + "400": { + "description": "No pages selected, invalid title or publication in progress", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, "403": { "description": "Not permitted", "content": { @@ -3309,7 +4173,7 @@ } }, "404": { - "description": "Collective, page or share not found", + "description": "Collective, static site or page not found", "content": { "application/json": { "schema": { @@ -3367,10 +4231,10 @@ } }, "delete": { - "operationId": "share-delete-collective-share", - "summary": "Delete a collective share", + "operationId": "static_site-delete", + "summary": "Delete a static site", "tags": [ - "share" + "static_site" ], "security": [ { @@ -3404,12 +4268,13 @@ } }, { - "name": "token", + "name": "id", "in": "path", - "description": "Token of the share", + "description": "ID of the static site", "required": true, "schema": { - "type": "string" + "type": "integer", + "format": "int64" } }, { @@ -3425,7 +4290,7 @@ ], "responses": { "200": { - "description": "Share deleted", + "description": "Static site deleted", "content": { "application/json": { "schema": { @@ -3481,7 +4346,7 @@ } }, "404": { - "description": "Collective or share not found", + "description": "Collective or static site not found", "content": { "application/json": { "schema": { @@ -15597,6 +16462,10 @@ "name": "share", "description": "Provides access to collective and page shares." }, + { + "name": "static_site", + "description": "Provides access to static site exports of a collective." + }, { "name": "search", "description": "Allows to search collectives." diff --git a/playwright/e2e/collective-publish.spec.ts b/playwright/e2e/collective-publish.spec.ts index d0532a8839..3d7de951eb 100644 --- a/playwright/e2e/collective-publish.spec.ts +++ b/playwright/e2e/collective-publish.spec.ts @@ -31,31 +31,73 @@ test.beforeAll(async () => { }) test.describe('Collective publish', () => { - test('admin can open publish modal', async ({ collective, navigation, page }) => { + test('admin can open the publish modal and publish the collective', async ({ collective, navigation, page }) => { await collective.openCollective() - await page.getByRole('button', { name: 'Collective actions' }).click() + const modal = page.getByRole('dialog') - // Publish button is visible for admin - const publishButton = page.locator('.action-item__popper:visible') - .getByRole('button', { name: 'Publish', exact: true }) - await expect(publishButton).toBeVisible() + await test.step('open and close the publish modal', async () => { + await page.getByRole('button', { name: 'Collective actions' }).click() - // Publish modal opens on publish button click - await publishButton.click() - const modal = page.getByRole('dialog') - await expect(modal - .filter({ has: page.getByRole('heading', { name: `Publish website for Collective ${collective.data.name}` }) })) - .toBeVisible() - - // Modal can be closed - await modal.getByRole('button', { name: 'Close' }).click() - await expect(modal).toHaveCount(0) - - // Modal can be opened again - await navigation.clickCollectiveMenu(collective.data.name, 'Publish') - const reopenedModal = page.getByRole('dialog') - await expect(reopenedModal).toBeVisible() - await expect(reopenedModal).toContainText(collective.data.name) + // Publish button is visible for admin + const publishButton = page.locator('.action-item__popper:visible') + .getByRole('button', { name: 'Publish website', exact: true }) + await expect(publishButton).toBeVisible() + + // Publish modal opens on publish button click + await publishButton.click() + await expect(modal + .filter({ has: page.getByRole('heading', { name: `Publish website for Collective ${collective.data.name}` }) })) + .toBeVisible() + + // Modal can be closed + await modal.getByRole('button', { name: 'Close' }).click() + await expect(modal).toHaveCount(0) + }) + + await test.step('close the publish modal with Escape', async () => { + await navigation.clickCollectiveMenu(collective.data.name, 'Publish website') + await expect(modal).toBeVisible() + + // Escape must also work while a checkbox has the focus + const checkbox = modal.getByRole('checkbox').first() + await checkbox.focus() + await expect(checkbox).toBeFocused() + await page.keyboard.press('Escape') + await expect(modal).toHaveCount(0) + }) + + await test.step('reopen the publish modal and publish', async () => { + // Intercept the API call to verify it is sent and succeeds + const requestPromise = page.waitForRequest((req) => req.url().includes('/static-sites') && req.method() === 'POST') + + // Modal can be opened again + await navigation.clickCollectiveMenu(collective.data.name, 'Publish website') + await expect(modal).toBeVisible() + await expect(modal).toContainText(collective.data.name) + + // Title and web address are prefilled from the collective name + const titleField = modal.getByRole('textbox', { name: 'Website title' }) + const slugField = modal.getByRole('textbox', { name: 'Web address' }) + const submitButton = modal.getByRole('button', { name: 'Publish website' }) + await expect(titleField).toHaveValue(collective.data.name) + const suggestedSlug = await slugField.inputValue() + expect(suggestedSlug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/) + + // Invalid web address prevents publishing + await slugField.fill('Not a valid slug') + await expect(submitButton).toBeDisabled() + await slugField.fill(suggestedSlug) + + // All pages are pre-selected; click publish + await submitButton.click() + + // The request must reach the backend + const response = await (await requestPromise).response() + expect(response?.status()).toBe(200) + + // Modal closes after successful submission + await expect(modal).toHaveCount(0) + }) }) test('regular members cannot see publish button', async ({ collective, member }) => { @@ -68,7 +110,7 @@ test.describe('Collective publish', () => { const actionsMenu = member.page.locator('.action-item__popper:visible') await expect(actionsMenu).toBeVisible() - const publishButton = actionsMenu.getByRole('button', { name: 'Publish', exact: true }) + const publishButton = actionsMenu.getByRole('button', { name: 'Publish website', exact: true }) await expect(publishButton).toHaveCount(0) }) }) diff --git a/src/apis/collectives/index.js b/src/apis/collectives/index.js index efa49ec470..9ef447f3a6 100644 --- a/src/apis/collectives/index.js +++ b/src/apis/collectives/index.js @@ -10,6 +10,7 @@ export * from './search.ts' export * from './sessions.js' export * from './settings.js' export * from './shares.js' +export * from './staticSites.js' export * from './tags.js' export * from './templates.js' export * from './userSettings.js' diff --git a/src/apis/collectives/staticSites.js b/src/apis/collectives/staticSites.js new file mode 100644 index 0000000000..ed52d2407f --- /dev/null +++ b/src/apis/collectives/staticSites.js @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { apiUrl } from './urls.js' + +/** + * URL for the collective static sites API + * + * @param {number} collectiveId - ID of the collective + * @param {...string} parts - URL parts to append - will be joined with `/` + */ +function staticSitesApiUrl(collectiveId, ...parts) { + return apiUrl('v1.0', 'collectives', collectiveId, 'static-sites', ...parts) +} + +/** + * Get static sites of a collective + * + * @param {number} collectiveId Id of the collective + */ +export function getStaticSites(collectiveId) { + return axios.get(staticSitesApiUrl(collectiveId)) +} + +/** + * Start a static site export for a selection of pages of a collective + * + * @param {number} collectiveId Id of the collective + * @param {number[]} pageIds Ids of the pages to publish + * @param {string} title Title of the static site + * @param {string} slug Slug of the static site + */ +export function createStaticSite(collectiveId, pageIds, title, slug) { + return axios.post( + staticSitesApiUrl(collectiveId), + { pageIds, title, slug }, + ) +} diff --git a/src/components/Collective/NcActionCollectiveActions.vue b/src/components/Collective/NcActionCollectiveActions.vue index a8f8c1b400..ad088616ce 100644 --- a/src/components/Collective/NcActionCollectiveActions.vue +++ b/src/components/Collective/NcActionCollectiveActions.vue @@ -28,8 +28,8 @@ v-if="isCollectiveAdmin(collective) && isPublishFeatureEnabled" closeAfterClick @click="openPublishDialog()"> - - {{ t('collectives', 'Publish') }} + + {{ t('collectives', 'Publish website') }} diff --git a/src/components/Nav/CollectivePublishModal.vue b/src/components/Nav/CollectivePublishModal.vue index 1e2dce438b..141b8e8260 100644 --- a/src/components/Nav/CollectivePublishModal.vue +++ b/src/components/Nav/CollectivePublishModal.vue @@ -8,23 +8,87 @@ size="normal" class="collective-publish-modal" @close="onClose"> -