Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
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
8 changes: 8 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@
['name' => 'applicationVersions#update', 'url' => '/api/applications/{slug}/versions/{versionSlug}', 'verb' => 'PUT', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'versionSlug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']],
['name' => 'applicationVersions#destroy', 'url' => '/api/applications/{slug}/versions/{versionSlug}', 'verb' => 'DELETE', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'versionSlug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']],

// Icon-serving endpoints (openbuilt-nextcloud-nav REQ-OBICON-002 / REQ-OBICON-003).
// Both are #[NoAdminRequired] on the controller. The dark route uses a longer
// URL pattern ("{slug}-dark.svg") that is unambiguous — it cannot shadow the
// light route because slugs are kebab-case [a-z0-9-] and never end in "-dark".
// Placed before the SPA catch-all; after exports so slug patterns don't collide.
['name' => 'icon#iconLight', 'url' => '/icons/{slug}.svg', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']],
['name' => 'icon#iconDark', 'url' => '/icons/{slug}-dark.svg', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']],

// Export pipeline (Phase-2 graduation).
['name' => 'exports#submit', 'url' => '/api/applications/{slug}/exports', 'verb' => 'POST', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']],
['name' => 'exports#download', 'url' => '/api/exports/{uuid}/download', 'verb' => 'GET'],
Expand Down
17 changes: 15 additions & 2 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@
use OCA\OpenBuilt\Listener\ProductionVersionGuardListener;
use OCA\OpenBuilt\Listener\DeepLinkRegistrationListener;
use OCA\OpenBuilt\Mcp\OpenBuiltToolProvider;
use OCA\OpenBuilt\Service\AppNavigationService;
use OCA\OpenRegister\Event\DeepLinkRegistrationEvent;
use OCA\OpenRegister\Event\ObjectCreatingEvent;
use OCA\OpenRegister\Event\ObjectUpdatingEvent;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\INavigationManager;

/**
* Main application class for the OpenBuilt Nextcloud app.
Expand Down Expand Up @@ -107,13 +109,24 @@ public function register(IRegistrationContext $context): void
/**
* Boot the application.
*
* Registers per-published-app top-bar navigation entries via
* AppNavigationService (REQ-OBNAV-001 / openbuilt-nextcloud-nav).
* Lazily resolved from the DI container to avoid instantiating the
* service tree when OR is not installed.
*
* @param IBootContext $context The boot context
*
* @return void
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function boot(IBootContext $context): void
{
try {
$container = $context->getAppContainer();
$container->get(AppNavigationService::class)
->registerNavEntries($container->get(INavigationManager::class));
} catch (\Throwable $e) {
// Boot must never throw — log and continue.
// OpenRegister may not be installed on this instance.
}//end try
}//end boot()
}//end class
4 changes: 4 additions & 0 deletions lib/Controller/ApplicationsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ public function getManifest(string $slug): JSONResponse
#[NoCSRFRequired]
public function diffVersions(string $slug, string $from, string $to): JSONResponse
{
if ($this->userSession->getUser() === null) {
return $this->errorResponse(code: 'unauthenticated', status: Http::STATUS_UNAUTHORIZED);
}

try {
$registerId = $this->registerMapper->find('openbuilt', _multitenancy: false)->getId();
$routeSchema = $this->schemaMapper->find('built-app-route', _multitenancy: false)->getId();
Expand Down
4 changes: 4 additions & 0 deletions lib/Controller/ExportsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ public function submit(string $slug): JSONResponse
#[NoCSRFRequired]
public function download(string $uuid): Response
{
if ($this->userSession->getUser() === null) {
return new JSONResponse(['error' => 'Unauthenticated.'], Http::STATUS_UNAUTHORIZED);
}

if ($this->isAuthorisedForJob(jobUuid: $uuid) === false) {
// Mask non-authorised as 404 to avoid revealing job UUIDs to
// unauthorised callers (defence in depth on the IDOR vector).
Expand Down
75 changes: 75 additions & 0 deletions lib/Controller/HealthController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

/**
* OpenBuilt Health Controller
*
* Lightweight liveness / readiness endpoint for container orchestrators
* and load-balancer health probes. Returns `{"status":"ok"}` on 200.
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V.
*
* @category Controller
* @package OCA\OpenBuilt\Controller
*
* @author Conduction Development Team <dev@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*/

declare(strict_types=1);

namespace OCA\OpenBuilt\Controller;

use OCA\OpenBuilt\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use OCP\IUserSession;

/**
* Serves a simple health-check endpoint for container probes.
*/
class HealthController extends Controller
{
/**
* Constructor.
*
* @param IRequest $request The incoming HTTP request.
* @param IUserSession $userSession Current user session.
*
* @return void
*/
public function __construct(
IRequest $request,
private readonly IUserSession $userSession,
) {
parent::__construct(appName: Application::APP_ID, request: $request);
}//end __construct()

/**
* Return a simple liveness response.
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @return JSONResponse
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function index(): JSONResponse
{
if ($this->userSession->getUser() === null) {
return new JSONResponse(['error' => 'Unauthenticated.'], Http::STATUS_UNAUTHORIZED);
}

return new JSONResponse(['status' => 'ok'], Http::STATUS_OK);
}//end index()
}//end class
174 changes: 174 additions & 0 deletions lib/Controller/IconController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

/**
* OpenBuilt Icon Controller
*
* Thin controller that serves per-application SVG icons backed by
* IconService's fallback chain (ADR-001, design.md Decision 2).
*
* Endpoints:
* GET /apps/openbuilt/icons/{slug}.svg → iconLight
* GET /apps/openbuilt/icons/{slug}-dark.svg → iconDark
*
* Both methods:
* - Require any valid NC session (#[NoAdminRequired]).
* - Set Content-Type: image/svg+xml.
* - Set Cache-Control: public, max-age=60 (design.md Decision 7).
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V.
*
* @category Controller
* @package OCA\OpenBuilt\Controller
*
* @author Conduction Development Team <dev@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*/

declare(strict_types=1);

namespace OCA\OpenBuilt\Controller;

use OCA\OpenBuilt\AppInfo\Application;
use OCA\OpenBuilt\Service\IconService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\StreamResponse;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;

/**
* Serves per-application SVG icons with an OR-backed fallback chain.
*/
class IconController extends Controller
{
/**
* Cache-Control header value applied to every successful icon response.
*
* 60-second public TTL per design.md Decision 7.
*/
private const CACHE_CONTROL = 'public, max-age=60';

/**
* Constructor.
*
* @param IRequest $request The incoming HTTP request.
* @param IconService $iconService The icon-resolution service.
* @param IUserSession $userSession User session for authentication guard.
* @param LoggerInterface $logger PSR logger.
*
* @return void
*/
public function __construct(
IRequest $request,
private readonly IconService $iconService,
private readonly IUserSession $userSession,
private readonly LoggerInterface $logger,
) {
parent::__construct(appName: Application::APP_ID, request: $request);
}//end __construct()

/**
* Serve the light SVG icon for a virtual app identified by slug.
*
* Fallback chain (REQ-OBICON-002 / design.md Decision 2):
* icon.ref → /img/app.svg
*
* @param string $slug The Application slug.
*
* @return Response The SVG response or a 404.
*
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function iconLight(string $slug): Response
{
if ($this->userSession->getUser() === null) {
$response = new Response();
$response->setStatus(Http::STATUS_UNAUTHORIZED);
return $response;
}

return $this->buildIconResponse(slug: $slug, dark: false);
}//end iconLight()

/**
* Serve the dark SVG icon for a virtual app identified by slug.
*
* Fallback chain (REQ-OBICON-003 / design.md Decision 2):
* iconDark.ref → icon.ref → /img/app-dark.svg → /img/app.svg
*
* @param string $slug The Application slug.
*
* @return Response The SVG response or a 404.
*
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function iconDark(string $slug): Response
{
if ($this->userSession->getUser() === null) {
$response = new Response();
$response->setStatus(Http::STATUS_UNAUTHORIZED);
return $response;
}

return $this->buildIconResponse(slug: $slug, dark: true);
}//end iconDark()

/**
* Shared icon-response builder.
*
* Calls IconService, sets required headers, streams the resource.
* When the service returns a null stream (no icon at all), returns 404.
* Session guard is enforced by the calling public method before this is
* invoked, per ADR-005 / hydra gate-7 (design.md Decision 6).
*
* @param string $slug The Application slug.
* @param bool $dark True for the dark fallback chain.
*
* @return Response
*/
private function buildIconResponse(string $slug, bool $dark): Response
{
try {
['stream' => $stream, 'mimeType' => $mimeType] = $this->iconService->getIconStream(
slug: $slug,
dark: $dark
);
} catch (\Throwable $e) {
$this->logger->error(
'IconController: unexpected error for slug "'.$slug.'": '.$e->getMessage()
);
$response = new Response();
$response->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR);
return $response;
}

if ($stream === null) {
$response = new Response();
$response->setStatus(Http::STATUS_NOT_FOUND);
return $response;
}

$response = new StreamResponse($stream);
$response->setStatus(Http::STATUS_OK);
$response->addHeader('Content-Type', $mimeType);
$response->addHeader('Cache-Control', self::CACHE_CONTROL);

return $response;
}//end buildIconResponse()
}//end class
Loading
Loading