Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ SPDX-FileCopyrightText = "2020-2026 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"

[[annotations]]
path = ["cypress/fixtures/**", "openapi.json", "playwright/support/fixtures/files/**", "tests/phpunit.xml", "tests/psalm-baseline.xml", "tests/Integration/features/fixtures/**", "vendor-bin/**"]
path = ["cypress/fixtures/**", "openapi.json", "openapi-administration.json", "openapi-full.json", "playwright/support/fixtures/files/**", "tests/phpunit.xml", "tests/psalm-baseline.xml", "tests/Integration/features/fixtures/**", "vendor-bin/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2020-2026 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"
Expand Down
8 changes: 8 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
['name' => 'publicStart#showShare', 'url' => '/p/{token}/{path}', 'verb' => 'GET',
'requirements' => ['path' => '.*'], 'defaults' => ['path' => '']],

// Static site archive download route for the external static site generator (not ocs due to simple file download)
['name' => 'static_site_archive#download', 'url' => '/api/v{apiVersion}/p/static-sites/{staticSiteId}/archive', 'verb' => 'GET',
'requirements' => ['apiVersion' => '(1.0)']],

// Vue.js router route (Vue.js frontend)
['name' => 'start#indexPath', 'url' => '/{path}', 'verb' => 'GET',
'requirements' => ['path' => '.*'],
Expand Down Expand Up @@ -77,6 +81,10 @@
['name' => 'static_site#delete', 'url' => '/api/v{apiVersion}/collectives/{collectiveId}/static-sites/{id}', 'verb' => 'DELETE',
'requirements' => ['apiVersion' => '(1.0)', 'collectiveId' => '\d+', 'id' => '\d+']],

// External static site generator API (admin only)
['name' => 'static_site_generator#health', 'url' => '/api/v{apiVersion}/static-site-generator/health', 'verb' => 'GET',
'requirements' => ['apiVersion' => '(1.0)']],

// Pages search API
['name' => 'search#searchRecentPages', 'url' => '/api/v{apiVersion}/collectives/search/recent', 'verb' => 'GET',
'requirements' => ['apiVersion' => '(1.0)']],
Expand Down
1 change: 1 addition & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public function __construct(array $urlParams = []) {

public function register(IRegistrationContext $context): void {
require_once(__DIR__ . '/../../vendor/autoload.php');
$context->registerConfigLexicon(ConfigLexicon::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
$context->registerEventListener(NodeRenamedEvent::class, NodeRenamedListener::class);
$context->registerEventListener(NodeWrittenEvent::class, NodeWrittenListener::class);
Expand Down
36 changes: 36 additions & 0 deletions lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ class ConfigLexicon implements ILexicon {

public const USER_FOLDER = 'user_folder';

public const PUBLISH_ENABLED = 'publish_enabled';
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';
public const STATIC_SITE_GENERATOR_TOKEN = 'static_site_generator_token';

#[\Override]
public function getStrictness(): Strictness {
return Strictness::IGNORE;
Expand Down Expand Up @@ -79,6 +86,35 @@ public function getAppConfigs(): array {
lazy: false,
flags: IAppConfig::FLAG_INTERNAL,
),
new Entry(
key: self::PUBLISH_ENABLED,
type: ValueType::STRING,
defaultRaw: self::PUBLISH_ENABLED_DEFAULT,
definition: 'Whether the publish feature is enabled (`true` or `false`)',
lazy: false,
),
new Entry(
key: self::STATIC_SITE_MAX_SIZE,
type: ValueType::INT,
defaultRaw: self::STATIC_SITE_MAX_SIZE_DEFAULT,
definition: 'Maximum size of all files of a static site in bytes',
lazy: true,
),
new Entry(
key: self::STATIC_SITE_GENERATOR_URL,
type: ValueType::STRING,
defaultRaw: '',
definition: 'Base URL of the external static site generator',
lazy: true,
),
new Entry(
key: self::STATIC_SITE_GENERATOR_TOKEN,
type: ValueType::STRING,
defaultRaw: '',
definition: 'Bearer token shared with the external static site generator',
lazy: true,
flags: IAppConfig::FLAG_SENSITIVE,
),
];
}

Expand Down
69 changes: 69 additions & 0 deletions lib/Controller/StaticSiteArchiveController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Collectives\Controller;

use OCA\Collectives\Service\NotFoundException;
use OCA\Collectives\Service\StaticSiteGeneratorClient;
use OCA\Collectives\Service\StaticSiteService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\Response;
use OCP\DB\Exception as DBException;
use OCP\IRequest;

/**
* Provides static site archives to the external publish service.
*/
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class StaticSiteArchiveController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private readonly StaticSiteService $staticSiteService,
private readonly StaticSiteGeneratorClient $staticSiteGeneratorClient,
) {
parent::__construct($appName, $request);
}

/**
* Download the archive of a static site, authenticated by the bearer token of the external static site generator.
*
* The status of the static site isn't checked, as the generator may download the archive
* before the response to its notification was processed.
*
* @throws DBException
*/
#[PublicPage]
#[NoCSRFRequired]
#[BruteForceProtection(action: 'collectivesStaticSiteArchive')]
public function download(string $staticSiteId): Response {
if (!$this->staticSiteGeneratorClient->isValidToken($this->request->getHeader('Authorization'))) {
$response = new Response(Http::STATUS_UNAUTHORIZED);
$response->throttle();
return $response;
}

try {
$archive = $this->staticSiteService->getArchive($staticSiteId);
} catch (NotFoundException) {
return new Response(Http::STATUS_NOT_FOUND);
}

$response = new FileDisplayResponse($archive);
$response->addHeader('Content-Type', 'application/gzip');
$response->addHeader('Content-Disposition', 'attachment; filename="' . $archive->getName() . '"');
return $response;
}
}
27 changes: 24 additions & 3 deletions lib/Controller/StaticSiteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
use OCA\Collectives\Db\StaticSite;
use OCA\Collectives\ResponseDefinitions;
use OCA\Collectives\Service\ServiceException;
use OCA\Collectives\Service\StaticSiteGeneratorException;
use OCA\Collectives\Service\StaticSiteGeneratorNotConfiguredException;
use OCA\Collectives\Service\StaticSiteGeneratorUnavailableException;
use OCA\Collectives\Service\StaticSiteService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
Expand Down Expand Up @@ -79,7 +82,7 @@ public function index(int $collectiveId): 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
* @throws OCSException Archive of the static site couldn't be provided
* @throws OCSException Archive of the static site couldn't be provided or the publish service is not available or didn't accept it
*
* 200: Static site created
*/
Expand Down Expand Up @@ -107,7 +110,7 @@ public function create(int $collectiveId, array $pageIds, ?string $title = null,
* @throws OCSBadRequestException No pages selected, invalid title or publication in progress
* @throws OCSForbiddenException Not permitted
* @throws OCSNotFoundException Collective, static site or page not found
* @throws OCSException Archive of the static site couldn't be provided
* @throws OCSException Archive of the static site couldn't be provided or the publish service is not available or didn't accept it
*
* 200: Static site updated
*/
Expand Down Expand Up @@ -144,7 +147,9 @@ public function delete(int $collectiveId, int $id): DataResponse {
}

/**
* Logs failures to provide the archive once with context and returns a translated error.
* Returns a translated error for publish failures to tell the user whether trying again later may help. (e.g. publish service is not available)
*
* Requests to the static site generator are logged by its client, other failures are logged once with context.
*
* @throws OCSBadRequestException
* @throws OCSForbiddenException
Expand All @@ -154,6 +159,22 @@ public function delete(int $collectiveId, int $id): DataResponse {
private function handleArchiveErrorResponse(Closure $callback, array $context): StaticSite {
try {
return $this->handleErrorResponse($callback, $this->logger);
} catch (StaticSiteGeneratorNotConfiguredException) {
$this->logger->warning('Static site generator is not configured', $context);
throw new OCSException(
$this->l10n->t('Publishing websites is not set up. Please contact your administrator.'),
Http::STATUS_SERVICE_UNAVAILABLE,
);
} catch (StaticSiteGeneratorUnavailableException) {
throw new OCSException(
$this->l10n->t('The publish service is currently not available. Please try again later.'),
Http::STATUS_SERVICE_UNAVAILABLE,
);
} catch (StaticSiteGeneratorException) {
throw new OCSException(
$this->l10n->t('The publish service rejected the website. Please contact your administrator.'),
Http::STATUS_BAD_GATEWAY,
);
} catch (ServiceException $e) {
$this->logger->error('Failed to provide static site archive', $context + ['exception' => $e]);
throw new OCSException(
Expand Down
50 changes: 50 additions & 0 deletions lib/Controller/StaticSiteGeneratorController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Collectives\Controller;

use OCA\Collectives\Service\StaticSiteGeneratorClient;
use OCA\Collectives\Service\StaticSiteGeneratorException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;

/**
* Provides admins access to the external static site generator.
*/
class StaticSiteGeneratorController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private readonly StaticSiteGeneratorClient $client,
) {
parent::__construct($appName, $request);
}

/**
* Check whether the configured static site generator is reachable and accepts the token
*
* This endpoint requires admin access
*
* @return DataResponse<Http::STATUS_OK, array{status: 'ok'}, array{}>|DataResponse<Http::STATUS_BAD_GATEWAY, array{message: string}, array{}>
*
* 200: Static site generator is healthy
* 502: Static site generator is not configured, not reachable or not healthy
*/
// TODO(Publish): Change when ext service provides a test endpoint to validate url and shared secret
public function health(): DataResponse {
try {
$this->client->checkHealth();
} catch (StaticSiteGeneratorException $e) {
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_GATEWAY);
}
return new DataResponse(['status' => 'ok']);
}
}
5 changes: 3 additions & 2 deletions lib/Db/StaticSite.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ class StaticSite extends Entity implements JsonSerializable {
public const STATUS_PENDING = 'pending';
public const STATUS_PROVIDED = 'provided';
public const STATUS_PUBLISHED = 'published';
// Only set by the publish service, a failed archive build doesn't change the status
// The publish service didn't build the static site, e.g. it wasn't reachable or rejected it.
// A failed archive build keeps the previous status instead.
public const STATUS_FAILED = 'failed';

// TODO: Add STATUS_PROVIDED once the publish service gets informed about the provided tar.gz
// TODO(Publish): Add STATUS_PROVIDED once the publish service reports the build result via callback
public const IN_PROGRESS_STATUSES = [
self::STATUS_PENDING,
];
Expand Down
16 changes: 8 additions & 8 deletions lib/Db/StaticSiteMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,19 @@ public function create(int $collectiveId, array $pageIds, string $title, string
}

/**
* Set the status to pending, unless the static site was changed since it was read.
* Set the status, unless the static site was changed since it was read.
*
* Check and update happen in a single query, so concurrent requests can't start
* a publication twice.
* Check and update happen in a single query, so of concurrent requests only one can
* change the status, e.g. to pending when updating an existing static site.
*
* @return bool False if the static site was changed meanwhile
*
* @throws Exception
*/
public function startPublication(StaticSite $staticSite): bool {
public function updateStatusIfUnchanged(StaticSite $staticSite, string $status): bool {
$qb = $this->db->getQueryBuilder();
$qb->update($this->tableName)
->set('status', $qb->createNamedParameter(StaticSite::STATUS_PENDING, IQueryBuilder::PARAM_STR))
->set('status', $qb->createNamedParameter($status, IQueryBuilder::PARAM_STR))
->set('updated_at', $qb->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('id', $qb->createNamedParameter($staticSite->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('status', $qb->createNamedParameter($staticSite->getStatus(), IQueryBuilder::PARAM_STR)))
Expand All @@ -111,20 +111,20 @@ public function startPublication(StaticSite $staticSite): bool {
}

/**
* Store title and page selection of a successfully provided publication.
* Store title, page selection and the resulting status of a static site with a provided archive.
*
* @param list<int> $pageIds
*
* @throws NotFoundException
* @throws UnprocessableEntityException
* @throws Exception
*/
public function finishPublication(StaticSite $staticSite, string $title, array $pageIds): StaticSite {
public function updateSettingsAndStatus(StaticSite $staticSite, string $title, array $pageIds, string $status): StaticSite {
$qb = $this->db->getQueryBuilder();
$qb->update($this->tableName)
->set('title', $qb->createNamedParameter($title, IQueryBuilder::PARAM_STR))
->set('selected_pages', $qb->createNamedParameter(StaticSite::encodePageIds($pageIds), IQueryBuilder::PARAM_STR))
->set('status', $qb->createNamedParameter(StaticSite::STATUS_PROVIDED, IQueryBuilder::PARAM_STR))
->set('status', $qb->createNamedParameter($status, IQueryBuilder::PARAM_STR))
->set('updated_at', $qb->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('id', $qb->createNamedParameter($staticSite->getId(), IQueryBuilder::PARAM_INT)));

Expand Down
3 changes: 2 additions & 1 deletion lib/Listeners/BeforeTemplateRenderedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OCA\Collectives\Listeners;

use OCA\Collectives\AppInfo\Application;
use OCA\Collectives\ConfigLexicon;
use OCA\Collectives\Fs\UserFolderHelper;
use OCA\Collectives\Service\NotFoundException;
use OCA\Collectives\Service\NotPermittedException;
Expand Down Expand Up @@ -77,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', 'publish_enabled', 'false') === 'true');
$this->initialState->provideInitialState('publish_enabled', $this->appConfig->getValueString('collectives', ConfigLexicon::PUBLISH_ENABLED, ConfigLexicon::PUBLISH_ENABLED_DEFAULT) === 'true');
}
}
12 changes: 12 additions & 0 deletions lib/Service/StaticSiteArchiver.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use OCP\Files\IAppData;
use OCP\Files\NotFoundException as FilesNotFoundException;
use OCP\Files\NotPermittedException as FilesNotPermittedException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IL10N;
use OCP\ITempManager;
Expand Down Expand Up @@ -79,6 +80,17 @@ public function store(string $staticSiteId, array $files): void {
}
}

/**
* @throws NotFoundException No archive stored for the static site
*/
public function get(string $staticSiteId): ISimpleFile {
try {
return $this->appData->getFolder(self::APPDATA_FOLDER)->getFile($staticSiteId . self::ARCHIVE_SUFFIX);
} catch (FilesNotFoundException $e) {
throw new NotFoundException('Static site archive not found', 0, $e);
}
}

/**
* @throws ServiceException
*/
Expand Down
Loading
Loading