diff --git a/appinfo/routes.php b/appinfo/routes.php index 9c29d3fa..62adc3ea 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -319,6 +319,11 @@ // streamer is intentionally NOT under `/api/...` because it // returns binary bytes, not a JSON envelope. ['name' => 'resource#upload', 'url' => '/api/resources', 'verb' => 'POST'], + // Raw multipart upload — REQ-RES-014. Admin-only (same security as the + // base64 endpoint above); accepts a single `file` multipart field with + // no base64 so large images/GIFs never become a huge in-browser string. + // Registered before the wildcard `/resource/{filename}` streamer. + ['name' => 'resource#uploadMultipart', 'url' => '/api/resources/upload', 'verb' => 'POST'], // Resource listing — REQ-RES-007. Logged-in user only (no admin // gate); the listed names are already referenced from rendered // dashboards so admin gating would lock dashboards out of their diff --git a/lib/Controller/ManifestController.php b/lib/Controller/ManifestController.php index e2226c91..768e46c3 100644 --- a/lib/Controller/ManifestController.php +++ b/lib/Controller/ManifestController.php @@ -25,6 +25,7 @@ use OCA\LaunchPad\AppInfo\Application; use OCA\LaunchPad\Service\ActionAuthService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; @@ -205,6 +206,17 @@ private function fetchUserDashboards(object $objectService, string $userId): arr } } } + } catch (DoesNotExistException $e) { + // The 'mydash' register or 'dashboard' schema has not been + // provisioned in OpenRegister on this instance yet. That simply + // means the user has no dashboards — degrade to an empty manifest + // so the frontend renders its "no dashboards yet" CTA instead of a + // 500 (OpenRegister surfaces this as DoesNotExistException, which + // extends \Exception and so is not a RuntimeException). + $this->logger->info( + 'MyDash: OpenRegister register/schema not provisioned — returning empty manifest. '.$e->getMessage(), + ['app' => Application::APP_ID, 'userId' => $userId] + ); } catch (\RuntimeException | \InvalidArgumentException $e) { // Narrow catch: only handle recoverable OR API errors. Let // unexpected errors propagate so they are visible in the logs. diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 50b59962..3a403964 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -349,6 +349,10 @@ public function index(string $deepLink=''): TemplateResponse $csp->addAllowedFrameDomain(domain: 'https://player.vimeo.com'); $csp->addAllowedImageDomain(domain: 'https://i.ytimg.com'); $csp->addAllowedImageDomain(domain: 'https://i.vimeocdn.com'); + // REQ-MMW: the map widget (CnMapWidget) falls back to OpenStreetMap tiles + // when no basemap is configured; Nextcloud's default `img-src` blocks + // external images, so allow the OSM tile hosts or the map paints white. + $csp->addAllowedImageDomain(domain: 'https://*.tile.openstreetmap.org'); $response->setContentSecurityPolicy(csp: $csp); return $response; @@ -388,6 +392,9 @@ public function publicShare(): TemplateResponse $csp = new ContentSecurityPolicy(); $csp->addAllowedImageDomain(domain: 'data:'); + // A shared dashboard may contain a map widget, which falls back to + // OpenStreetMap tiles when no basemap is configured (see index()). + $csp->addAllowedImageDomain(domain: 'https://*.tile.openstreetmap.org'); $response->setContentSecurityPolicy(csp: $csp); return $response; diff --git a/lib/Controller/ResourceController.php b/lib/Controller/ResourceController.php index a5cbc943..e37e1dd2 100644 --- a/lib/Controller/ResourceController.php +++ b/lib/Controller/ResourceController.php @@ -32,6 +32,8 @@ namespace OCA\LaunchPad\Controller; +use OCA\LaunchPad\Exception\FileMissingException; +use OCA\LaunchPad\Exception\FileTooLargeException; use OCA\LaunchPad\Exception\ForbiddenException; use OCA\LaunchPad\Exception\ResourceException; use OCA\LaunchPad\Exception\StorageFailureException; @@ -144,6 +146,153 @@ public function upload(): JSONResponse }//end try }//end upload() + /** + * Handle `POST /api/resources/upload` — raw multipart upload. + * + * Accepts `multipart/form-data` with a single `file` entry and stores + * the raw bytes directly — no base64, so large images and GIFs never + * become a huge in-memory string in the browser. Admin-only, enforced + * both by the `AuthorizedAdminSetting` attribute and a defensive + * `assertAdmin()`. The response mirrors the base64 endpoint's + * `{status, url, name, size}` envelope. + * + * CSRF stays enforced (no `@NoCSRFRequired`): admin-only is not + * CSRF-safe — a logged-in admin lured to a hostile page could otherwise + * be made to POST a resource. The frontend uses `@nextcloud/axios`, + * which auto-sends the `requesttoken` header, so this is zero-cost. + * + * @return JSONResponse The success or error envelope. + * + * @spec openspec/specs/resource-uploads/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function uploadMultipart(): JSONResponse + { + try { + $this->assertAdmin(); + + $file = $this->readUploadedFile(); + if ($file === null) { + throw new FileMissingException(); + } + + $result = $this->resourceService->uploadRaw( + bytes: $file['bytes'], + declaredType: $this->declaredTypeFromName(name: $file['name']) + ); + + return new JSONResponse( + data: [ + 'status' => 'success', + 'url' => $result['url'], + 'name' => $result['name'], + 'size' => $result['size'], + ], + statusCode: Http::STATUS_OK + ); + } catch (ResourceException $e) { + if ($e instanceof StorageFailureException) { + $this->logger->error( + message: 'Resource multipart upload storage failure', + context: ['exception' => $e->getMessage()] + ); + } + + return $this->errorResponse(exception: $e); + } catch (Throwable $e) { + $this->logger->error( + message: 'Unexpected resource multipart upload failure', + context: ['exception' => $e->getMessage()] + ); + + return $this->errorResponse( + exception: new StorageFailureException( + message: 'Failed to store resource' + ) + ); + }//end try + }//end uploadMultipart() + + /** + * Read the single uploaded file from the `file` multipart field. + * + * Extracted so PHPUnit can override the `$_FILES` source. Returns the + * original filename and the raw bytes, or `null` when no usable file + * was uploaded (missing entry or a non-zero PHP upload error). + * + * @return array{name: string, bytes: string}|null The uploaded file, or null. + * + * @throws FileTooLargeException When the reported upload size exceeds the cap. + * + * @SuppressWarnings(PHPMD.Superglobals) — required for multipart uploads. + * + * @spec openspec/specs/resource-uploads/spec.md + */ + protected function readUploadedFile(): ?array + { + // @phpstan-ignore-next-line — superglobal access is mixed. + $raw = ($_FILES['file'] ?? null); + if (is_array(value: $raw) === false) { + return null; + } + + // A multi-file `file[]` submission makes each entry an array + // (`error`/`tmp_name` become arrays). This endpoint takes a single + // file, so reject that shape up front — casting an array to int/string + // below would otherwise emit an "Array to string conversion" warning. + if (is_array(value: ($raw['error'] ?? null)) === true) { + return null; + } + + $error = (int) ($raw['error'] ?? UPLOAD_ERR_NO_FILE); + $tmp = (string) ($raw['tmp_name'] ?? ''); + if ($error !== UPLOAD_ERR_OK || $tmp === '') { + return null; + } + + // Cheap pre-read cap using PHP's reported upload size, so a large file + // (allowed by a generous upload_max_filesize) is rejected before it is + // pulled into memory by file_get_contents(). The authoritative check on + // the actual bytes still runs in ResourceService::storeImageBytes(). + if ((int) ($raw['size'] ?? 0) > ResourceService::MAX_BYTES) { + throw new FileTooLargeException(); + } + + // Assert the temp file came through a real PHP upload to prevent + // local-file-inclusion via a crafted tmp_name (mirrors the defence in + // FilesWidgetService). Without this, file_get_contents() below would + // read any path the superglobal points at. + if (is_uploaded_file(filename: $tmp) === false) { + return null; + } + + $bytes = (string) file_get_contents(filename: $tmp); + + return [ + 'name' => (string) ($raw['name'] ?? ''), + 'bytes' => $bytes, + ]; + }//end readUploadedFile() + + /** + * Derive a declared image type from an uploaded filename's extension. + * + * The bytes are cross-checked against this type downstream (raster MIME + * validation / SVG sanitisation), so a mislabelled extension is caught + * there rather than trusted here. An empty / extension-less name yields + * an empty string, which the service rejects as an invalid format. + * + * @param string $name The original uploaded filename. + * + * @return string The lowercased extension (without the dot), or ''. + * + * @spec openspec/specs/resource-uploads/spec.md + */ + private function declaredTypeFromName(string $name): string + { + return strtolower(string: (string) pathinfo(path: $name, flags: PATHINFO_EXTENSION)); + }//end declaredTypeFromName() + /** * Throw if the current session user is not an admin. * diff --git a/lib/Exception/FileMissingException.php b/lib/Exception/FileMissingException.php new file mode 100644 index 00000000..8289c1e2 --- /dev/null +++ b/lib/Exception/FileMissingException.php @@ -0,0 +1,56 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT:auto + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Exception; + +/** + * No usable file was present in the multipart upload request. + */ +class FileMissingException extends ResourceException +{ + + /** + * Stable error code. + * + * @var string + */ + protected string $errorCode = 'no_file'; + + /** + * HTTP status. + * + * @var integer + */ + protected int $httpStatus = 400; + + /** + * Constructor. + * + * @param string $message Display message. + */ + public function __construct( + string $message='No file was uploaded' + ) { + parent::__construct(message: $message); + }//end __construct() +}//end class diff --git a/lib/Service/FilesWidgetService.php b/lib/Service/FilesWidgetService.php index 2281f6c6..ef0bc04d 100644 --- a/lib/Service/FilesWidgetService.php +++ b/lib/Service/FilesWidgetService.php @@ -47,6 +47,7 @@ use OCP\Files\Node; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; +use OCP\IPreview; use OCP\IURLGenerator; use RuntimeException; use Throwable; @@ -92,13 +93,16 @@ class FilesWidgetService /** * Constructor. * - * @param IRootFolder $rootFolder Filesystem root accessor. - * @param IURLGenerator $urlGenerator URL builder for thumbnails and - * deep links into the Files app. + * @param IRootFolder $rootFolder Filesystem root accessor. + * @param IURLGenerator $urlGenerator URL builder for thumbnails and + * deep links into the Files app. + * @param IPreview $previewManager Decides which files have a + * previewable thumbnail. */ public function __construct( private readonly IRootFolder $rootFolder, private readonly IURLGenerator $urlGenerator, + private readonly IPreview $previewManager, ) { }//end __construct() @@ -383,19 +387,24 @@ public function buildFileMetadata(Node $node): array { $isFolder = $node instanceof Folder; + // Emit a preview URL for any file Nextcloud can render a thumbnail + // for (images, PDFs, videos, …) — not just images. `isAvailable` + // gates on a registered preview provider, so we never hand the client + // a URL that 404s. Non-previewable files keep `null` and the frontend + // falls back to a generic file icon. $thumbnailUrl = null; - if ($isFolder === false && $node instanceof File) { - $mime = $node->getMimetype(); - if (str_starts_with(haystack: $mime, needle: 'image/') === true) { - $thumbnailUrl = $this->urlGenerator->linkToRouteAbsolute( - routeName: 'core.Preview.getPreviewByFileId', - arguments: [ - 'fileId' => $node->getId(), - 'x' => 64, - 'y' => 64, - ] - ); - } + if ($isFolder === false && $node instanceof File + && $this->previewManager->isAvailable(file: $node) === true + ) { + $thumbnailUrl = $this->urlGenerator->linkToRouteAbsolute( + routeName: 'core.Preview.getPreviewByFileId', + arguments: [ + 'fileId' => $node->getId(), + 'x' => 256, + 'y' => 256, + 'a' => 1, + ] + ); } $permissions = $node->getPermissions(); diff --git a/lib/Service/ResourceService.php b/lib/Service/ResourceService.php index 2f7ec836..c840361d 100644 --- a/lib/Service/ResourceService.php +++ b/lib/Service/ResourceService.php @@ -43,7 +43,9 @@ use Throwable; /** - * Admin-only base64 upload pipeline for branding assets. + * Admin-only upload pipeline for branding assets (base64 + raw multipart). + * + * @spec openspec/specs/resource-uploads/spec.md */ class ResourceService { @@ -119,10 +121,86 @@ public function __construct( */ public function upload(string $base64DataUrl): array { - $parsed = $this->parseDataUrl(input: $base64DataUrl); - $declaredType = $parsed['type']; - $bytes = $parsed['bytes']; + $parsed = $this->parseDataUrl(input: $base64DataUrl); + + return $this->storeImageBytes( + bytes: $parsed['bytes'], + declaredType: $parsed['type'] + ); + }//end upload() + /** + * Upload already-decoded raw image bytes (multipart path). + * + * The declared type comes from the uploaded file's extension / MIME + * rather than a data-URL prefix; it is normalised and vetted against + * the same allow-list before running the shared validation and + * persistence pipeline. No base64 is involved — the caller has the + * raw bytes already (REQ-RES-014). + * + * @param string $bytes The raw (decoded) image bytes. + * @param string $declaredType The declared image type (e.g. from the + * file extension or `image/` MIME). + * + * @return array{url: string, name: string, size: int} The created + * resource. + * + * @throws InvalidDataUrlException When the byte payload is empty. + * @throws InvalidImageFormatException When the declared type is not + * in the allowed list. + * @throws InvalidSvgException When an SVG payload fails to + * parse or is fully stripped. + * @throws FileTooLargeException When the bytes exceed 5 MB. + * @throws StorageFailureException When writing to IAppData fails. + * + * @spec openspec/specs/resource-uploads/spec.md + */ + public function uploadRaw(string $bytes, string $declaredType): array + { + if ($bytes === '') { + throw new InvalidDataUrlException( + message: 'Uploaded file is empty' + ); + } + + $declaredType = $this->normaliseDeclaredType( + raw: strtolower(string: $declaredType) + ); + if (in_array( + needle: $declaredType, + haystack: self::ALLOWED_TYPES, + strict: true + ) === false + ) { + throw new InvalidImageFormatException(); + } + + return $this->storeImageBytes( + bytes: $bytes, + declaredType: $declaredType + ); + }//end uploadRaw() + + /** + * Validate and persist decoded image bytes, returning the resource info. + * + * Shared tail for both the base64 ({@see upload}) and raw multipart + * ({@see uploadRaw}) entry points: SVG sanitise → 5 MB cap → raster + * MIME cross-check → persist → build the public URL. The declared + * type MUST already be normalised and allow-listed by the caller. + * + * @param string $bytes The decoded image bytes. + * @param string $declaredType The normalised, allow-listed declared type. + * + * @return array{url: string, name: string, size: int} The created + * resource. + * + * @throws InvalidSvgException When an SVG payload is invalid. + * @throws FileTooLargeException When the bytes exceed 5 MB. + * @throws StorageFailureException When writing to IAppData fails. + */ + private function storeImageBytes(string $bytes, string $declaredType): array + { // SVG branch: sanitise BEFORE the size check so the 5 MB cap // is measured against the persisted (sanitised) byte count // (REQ-RES-009). Sanitiser returns null on parse failure or @@ -163,7 +241,7 @@ public function upload(string $base64DataUrl): array 'name' => $filename, 'size' => strlen(string: $bytes), ]; - }//end upload() + }//end storeImageBytes() /** * Parse a `data:image/;base64,` string. diff --git a/openspec/specs/resource-uploads/spec.md b/openspec/specs/resource-uploads/spec.md index 968d5619..e694f224 100644 --- a/openspec/specs/resource-uploads/spec.md +++ b/openspec/specs/resource-uploads/spec.md @@ -6,7 +6,7 @@ status: done ## Purpose -The `resource-uploads` capability owns a small mini file API for binary assets that LaunchPad widgets reference directly: dashboard icons, image-widget images, link-button icons, etc. Resources are stored in LaunchPad's app-data folder (NOT the user's Files), addressed by a stable URL, uploaded admin-only via a base64-data-URL JSON request, and served back to any logged-in user via a non-OCS streaming endpoint plus an OCS listing endpoint. SVG sanitisation is specified in the sibling `svg-sanitisation` capability. +The `resource-uploads` capability owns a small mini file API for binary assets that LaunchPad widgets reference directly: dashboard icons, image-widget images, link-button icons, etc. Resources are stored in LaunchPad's app-data folder (NOT the user's Files), addressed by a stable URL, and served back to any logged-in user via a non-OCS streaming endpoint plus an OCS listing endpoint. Two admin-only upload paths exist: a base64-data-URL JSON request (REQ-RES-001) and a raw `multipart/form-data` upload (REQ-RES-014) — the latter is what the image widget uses so large files never become a huge in-browser base64 string. SVG sanitisation is specified in the sibling `svg-sanitisation` capability. ## Data Model @@ -373,3 +373,48 @@ The sanitiser MUST parse SVG via `DOMDocument::loadXML($bytes, LIBXML_NONET | LI - THEN `libxml_use_internal_errors(true)` MUST have been called before parse - AND `libxml_clear_errors()` MUST have been called after parse - AND no libxml warning text MUST appear in the HTTP response body + +### Requirement: Raw multipart upload endpoint (REQ-RES-014) + +The system MUST expose `POST /api/resources/upload` accepting `multipart/form-data` with a single `file` field carrying the raw image bytes (NOT base64). This is the path used by the dashboard image widget so a large image or GIF never has to be base64-encoded in the browser (which can freeze the tab) nor embedded inline in dashboard content. + +The endpoint MUST enforce the SAME security as the base64 endpoint (REQ-RES-001): admin-only, via the `AuthorizedAdminSetting` attribute plus a defensive `assertAdmin()` check. A non-admin or unauthenticated request MUST receive HTTP 403 with `{status: 'error', error: 'forbidden'}` and MUST NOT write a file. + +The declared image type MUST be derived from the uploaded filename's extension and validated against the same allow-list, size cap (5 MB), raster MIME cross-check, and SVG sanitisation as the base64 endpoint (REQ-RES-002, REQ-RES-003, REQ-RES-009). On success the response MUST be HTTP 200 with the same `{status: 'success', url, name, size}` envelope and URL form (`/apps/launchpad/resource/`) as REQ-RES-001. A missing/failed `file` field MUST return HTTP 400 with `{status: 'error', error: 'no_file'}` (a multipart-specific code, distinct from the base64 endpoint's `invalid_data_url`). + +Uploaded resources are served by REQ-RES-006, which requires an authenticated user — so widget images are viewable by logged-in users but do NOT render for anonymous viewers in public dashboard shares or kiosk mode. This is an accepted limitation of storing widget images as app-data resources. + +#### Scenario: Admin uploads a PNG file + +- GIVEN an authenticated admin user +- WHEN they POST `multipart/form-data` with a `file` field containing a valid 200×200 PNG +- THEN the system MUST return HTTP 200 with `{status: 'success', url: '/apps/launchpad/resource/resource_.png', name: 'resource_.png', size: }` +- AND a file MUST exist at the corresponding app-data path +- AND the request body MUST NOT contain any base64 encoding + +#### Scenario: Non-admin rejected + +- GIVEN an authenticated non-admin user +- WHEN they POST a file to `/api/resources/upload` +- THEN the system MUST return HTTP 403 with `{status: 'error', error: 'forbidden'}` +- AND no file MUST be written + +#### Scenario: Unauthenticated request rejected + +- GIVEN no authenticated user +- WHEN a request is POSTed to `/api/resources/upload` +- THEN the system MUST return HTTP 403 with `{status: 'error', error: 'forbidden'}` +- AND no file MUST be written + +#### Scenario: Missing file field rejected + +- GIVEN an admin user +- WHEN they POST with no usable `file` field (absent or a non-zero PHP upload error) +- THEN the system MUST return HTTP 400 with `{status: 'error', error: 'no_file'}` + +#### Scenario: Oversize file rejected + +- GIVEN an admin user +- WHEN they upload a `file` whose bytes exceed 5 MB +- THEN the system MUST return HTTP 400 with `{status: 'error', error: 'file_too_large'}` +- AND no file MUST be written diff --git a/src/main.js b/src/main.js index c31dd76b..d232e4de 100644 --- a/src/main.js +++ b/src/main.js @@ -30,6 +30,8 @@ import { translate as t, translatePlural as n } from '@nextcloud/l10n' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' +import './services/widgetBridge.js' + import App from './App.vue' import { loadInitialState } from './utils/loadInitialState.js' import { mergeManifestFragments } from './utils/mergeManifestFragments.js' @@ -37,6 +39,15 @@ import bundledStub from './manifest.json' import 'gridstack/dist/gridstack.min.css' import './styles/workspace.css' +// Populate the shared dashboard widget catalog. Each widget type self-registers +// via an import-time side effect aggregated in nc-vue; `sideEffects` +// tree-shaking (ADR-061) drops those bare side-effect imports unless a binding +// is used, which collapses the Add-Widget picker to only the types launchpad +// references directly (link + nc-widget). Calling this exported no-op forces +// the aggregator — and therefore every widget registration — into the bundle. +import { registerBuiltinDashboardWidgets } from '@conduction/nextcloud-vue' +registerBuiltinDashboardWidgets() + // Tier 1 manifest adoption (ADR-024): register the bundled manifest with // nc-vue so the shared shell can read menu/page declarations. The vue-router // definition below remains hand-wired (Tier 1 — not yet manifest-driven). diff --git a/src/public.js b/src/public.js index 89c7d2e9..7e0709d1 100644 --- a/src/public.js +++ b/src/public.js @@ -18,6 +18,13 @@ import { loadState } from '@nextcloud/initial-state' import DashboardPublicShareView from './views/DashboardPublicShareView.vue' import 'gridstack/dist/gridstack.min.css' +// Populate the shared dashboard widget catalog so shared placements render. +// Widget types self-register via import-time side effects that `sideEffects` +// tree-shaking (ADR-061) would otherwise drop; calling this no-op forces the +// aggregator — and every widget registration — into the bundle. See main.js. +import { registerBuiltinDashboardWidgets } from '@conduction/nextcloud-vue' +registerBuiltinDashboardWidgets() + Vue.use(PiniaVuePlugin) const pinia = createPinia() diff --git a/src/services/__tests__/resourceService.spec.js b/src/services/__tests__/resourceService.spec.js index f0f6b5ce..18efdca2 100644 --- a/src/services/__tests__/resourceService.spec.js +++ b/src/services/__tests__/resourceService.spec.js @@ -23,12 +23,14 @@ vi.mock('@nextcloud/axios', () => ({ })) let uploadDataUrl +let uploadFile let ResourceUploadError beforeEach(async () => { postMock.mockReset() const mod = await import('../resourceService.js') uploadDataUrl = mod.uploadDataUrl + uploadFile = mod.uploadFile ResourceUploadError = mod.ResourceUploadError }) @@ -97,3 +99,52 @@ describe('resourceService.uploadDataUrl', () => { .toMatchObject({ code: 'unknown_error' }) }) }) + +describe('resourceService.uploadFile', () => { + it('posts multipart FormData (no base64) and returns {url, name, size}', async () => { + postMock.mockResolvedValueOnce({ + status: 200, + data: { + status: 'success', + url: '/apps/launchpad/resource/resource_abc.png', + name: 'resource_abc.png', + size: 4321, + }, + }) + + const file = new File(['bytes'], 'photo.png', { type: 'image/png' }) + const result = await uploadFile(file) + + expect(postMock).toHaveBeenCalledTimes(1) + const [calledUrl, body] = postMock.mock.calls[0] + expect(calledUrl).toBe('http://localhost/apps/launchpad/api/resources/upload') + expect(body).toBeInstanceOf(FormData) + expect(body.get('file')).toBe(file) + // The transport returns the server's logical path unchanged; the renderer + // resolves it via generateUrl() at display time. + expect(result).toEqual({ + url: '/apps/launchpad/resource/resource_abc.png', + name: 'resource_abc.png', + size: 4321, + }) + }) + + it('rethrows the server error envelope as a ResourceUploadError', async () => { + const err = new Error('Request failed') + err.response = { + status: 400, + data: { status: 'error', error: 'invalid_image_format', message: 'Bad type' }, + } + postMock.mockRejectedValueOnce(err) + + await expect(uploadFile(new File(['x'], 'a.bmp'))).rejects + .toMatchObject({ name: 'ResourceUploadError', code: 'invalid_image_format', httpStatus: 400 }) + }) + + it('throws network_error when the transport itself fails', async () => { + postMock.mockRejectedValueOnce(new Error('boom')) + + await expect(uploadFile(new File(['x'], 'a.png'))).rejects + .toMatchObject({ code: 'network_error' }) + }) +}) diff --git a/src/services/resourceService.js b/src/services/resourceService.js index d5eb2700..23518d26 100644 --- a/src/services/resourceService.js +++ b/src/services/resourceService.js @@ -99,6 +99,65 @@ export async function uploadDataUrl(dataUrl) { } } +/** + * Upload a raw `File` via `POST /apps/launchpad/api/resources/upload` + * (multipart). Unlike {@link uploadDataUrl}, the file is streamed as-is — + * no base64 conversion — so large images and GIFs never become a huge + * in-memory string that can freeze the browser tab. Prefer this for + * user-picked files; the endpoint returns the same envelope. + * + * @param {File|Blob} file A `File` (typically from an ``). + * @return {Promise<{url: string, name: string, size: number}>} The persisted + * resource info. `url` is the server's logical `/apps/launchpad/resource/` + * path; the renderer resolves it through `generateUrl()` at display time so it + * works on index.php-routed instances without persisting routing into content. + * @throws {ResourceUploadError} On any server-side rejection (forbidden, + * no_file, invalid_image_format, file_too_large, invalid_svg, etc.) or + * transport failure (`code === 'network_error'`). + */ +/** @spec openspec/specs/resource-uploads/spec.md */ +export async function uploadFile(file) { + const url = generateUrl('/apps/launchpad/api/resources/upload') + + const formData = new FormData() + formData.append('file', file) + + let response + try { + // Let the browser/axios set the multipart boundary and CSRF token. + response = await axios.post(url, formData) + } catch (err) { + const data = err?.response?.data + if (data && data.status === 'error' && typeof data.error === 'string') { + throw new ResourceUploadError( + data.error, + typeof data.message === 'string' ? data.message : data.error, + err?.response?.status, + ) + } + throw new ResourceUploadError( + 'network_error', + err?.message || 'Network error', + err?.response?.status, + ) + } + + const body = response?.data + if (!body || body.status !== 'success' || typeof body.url !== 'string') { + throw new ResourceUploadError( + body?.error || 'unknown_error', + body?.message || 'Unexpected server response', + response?.status, + ) + } + + return { + url: body.url, + name: typeof body.name === 'string' ? body.name : '', + size: typeof body.size === 'number' ? body.size : 0, + } +} + /** * Read a `File` object as a base64 data URL — convenience wrapper around * `FileReader` that returns a promise so callers can `await` it. Used by @@ -117,4 +176,4 @@ export function readFileAsDataUrl(file) { }) } -export default { uploadDataUrl, ResourceUploadError, readFileAsDataUrl } +export default { uploadDataUrl, uploadFile, ResourceUploadError, readFileAsDataUrl } diff --git a/src/views/Views.vue b/src/views/Views.vue index 13877b66..0dc6c255 100644 --- a/src/views/Views.vue +++ b/src/views/Views.vue @@ -200,6 +200,7 @@ :preselected-type="customWidgetPreselectedType" :editing-widget="customWidgetEditing" :upload-fn="iconUploadFn" + :file-upload-fn="imageFileUpload" :calendars-fetcher="fetchCalendars" @close="closeCustomWidgetModal" @submit="saveCustomWidget" /> @@ -283,7 +284,7 @@ import WidgetPickerModal from '../components/WidgetPickerModal.vue' import TileEditor from '../components/TileEditor.vue' import DashboardConfigModal from '../components/DashboardConfigModal.vue' import WidgetContextMenu from '../components/Widgets/WidgetContextMenu.vue' -import { uploadDataUrl } from '../services/resourceService.js' +import { uploadDataUrl, uploadFile } from '../services/resourceService.js' import VisibilityRulesModal from '../components/Widgets/VisibilityRulesModal.vue' import { getWidgetTypeEntry } from '../constants/widgetRegistry.js' import DashboardSwitcherSidebar from '../components/Workspace/DashboardSwitcherSidebar.vue' @@ -892,8 +893,12 @@ export default { // the `widgetId` IS the registry type key, so resolve the type // from the registry and open the content editor (AddWidgetModal) // with `type` set so `loadEditingWidget` can pre-fill the form. - // Stock Nextcloud-widget / tile placements have no registry entry - // and fall through to the legacy style editor. + // `tile` now resolves to the shared registry entry (CnDashTileWidget + // + CnDashTileWidgetForm), so preset/registry tiles open the content + // editor below; the form reads their flat tile* columns. Legacy + // `custom` tiles are handled earlier via TileEditor (isTilePlacement). + // Stock Nextcloud-widget placements still have no registry entry and + // fall through to the legacy style editor. const resolvedType = this.resolveWidgetType(placement) if (resolvedType) { this.openCustomWidgetEdit({ ...placement, type: resolvedType }) @@ -1095,6 +1100,20 @@ export default { return uploadDataUrl(file) }, + /** + * Raw-file upload transport for the shared CnAddWidgetModal's image + * widget sub-form. Streams the picked file to LaunchPad's resource + * service as multipart (no base64) on submit and returns the hosted + * URL in the `{ url }` shape the sub-form's uploadFn expects. + * + * @param {File} file the picked image file. + * @return {Promise<{url: string}>} the hosted resource URL. + * @spec openspec/specs/resource-uploads/spec.md + */ + imageFileUpload(file) { + return uploadFile(file).then((result) => ({ url: result.url })) + }, + /** * Calendar list fetcher for the shared CnAddWidgetModal's calendar * widget picker — returns the user's calendars so authors select them diff --git a/tests/Unit/Controller/ResourceControllerTest.php b/tests/Unit/Controller/ResourceControllerTest.php index 08eac422..de0f8b6a 100644 --- a/tests/Unit/Controller/ResourceControllerTest.php +++ b/tests/Unit/Controller/ResourceControllerTest.php @@ -36,7 +36,6 @@ use OCA\LaunchPad\Exception\UnsupportedMediaTypeException; use OCA\LaunchPad\Service\ResourceService; use OCP\AppFramework\Http; -use OCP\AppFramework\Http\JSONResponse; use OCP\IGroupManager; use OCP\IRequest; use OCP\IUser; @@ -129,6 +128,52 @@ protected function readRequestBody(): string }; } + /** + * Build a controller subclass that injects a fake uploaded file (name + + * bytes) so the multipart path can be exercised without `$_FILES`. + * A `null` file simulates a missing/failed upload. + * + * @param array{name: string, bytes: string}|null $file the fake file. + * + * @return ResourceController the test double. + */ + private function buildMultipartController(?array $file): ResourceController + { + return new class ( + $this->request, + $this->service, + $this->parser, + $this->userSession, + $this->groupManager, + $this->logger, + $file, + ) extends ResourceController { + public function __construct( + IRequest $request, + ResourceService $resourceService, + ResourceUploadRequestParser $parser, + IUserSession $userSession, + IGroupManager $groupManager, + LoggerInterface $logger, + private readonly ?array $fakeFile, + ) { + parent::__construct( + request: $request, + resourceService: $resourceService, + parser: $parser, + userSession: $userSession, + groupManager: $groupManager, + logger: $logger, + ); + } + + protected function readUploadedFile(): ?array + { + return $this->fakeFile; + } + }; + } + private function adminUser(string $uid = 'admin'): IUser { $user = $this->createMock(IUser::class); @@ -269,4 +314,172 @@ public function testParserExceptionIsNotShortCircuitedByAdminGuard(): void $this->assertSame(415, $response->getStatus()); $this->assertSame('unsupported_media_type', $body['error']); } + + // --------------------------------------------------------------- + // Multipart upload tests (REQ-RES-014). + // --------------------------------------------------------------- + + public function testMultipartNonAdminReceives403(): void + { + $this->userSession->method('getUser')->willReturn($this->adminUser('alice')); + $this->groupManager->method('isAdmin')->with('alice')->willReturn(false); + $this->service->expects($this->never())->method('uploadRaw'); + + $response = $this->buildMultipartController(['name' => 'a.png', 'bytes' => 'x'])->uploadMultipart(); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + $this->assertSame('forbidden', $response->getData()['error']); + } + + public function testMultipartUnauthenticatedReceives403(): void + { + $this->userSession->method('getUser')->willReturn(null); + $this->service->expects($this->never())->method('uploadRaw'); + + $response = $this->buildMultipartController(['name' => 'a.png', 'bytes' => 'x'])->uploadMultipart(); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + $this->assertSame('forbidden', $response->getData()['error']); + } + + public function testMultipartSuccessReturnsEnvelope(): void + { + $this->userSession->method('getUser')->willReturn($this->adminUser()); + $this->groupManager->method('isAdmin')->with('admin')->willReturn(true); + $this->service->expects($this->once()) + ->method('uploadRaw') + ->with('rawbytes', 'png') + ->willReturn([ + 'url' => '/apps/launchpad/resource/resource_xyz.png', + 'name' => 'resource_xyz.png', + 'size' => 4321, + ]); + + $response = $this->buildMultipartController(['name' => 'photo.PNG', 'bytes' => 'rawbytes'])->uploadMultipart(); + $body = $response->getData(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame('success', $body['status']); + $this->assertSame('/apps/launchpad/resource/resource_xyz.png', $body['url']); + $this->assertSame('resource_xyz.png', $body['name']); + $this->assertSame(4321, $body['size']); + } + + public function testMultipartMissingFileReturnsNoFile(): void + { + $this->userSession->method('getUser')->willReturn($this->adminUser()); + $this->groupManager->method('isAdmin')->willReturn(true); + $this->service->expects($this->never())->method('uploadRaw'); + + $response = $this->buildMultipartController(null)->uploadMultipart(); + $body = $response->getData(); + + $this->assertSame(400, $response->getStatus()); + $this->assertSame('no_file', $body['error']); + } + + public function testMultipartArrayFormFileIsRejectedAsNoFile(): void + { + // A `file[]` (multi-file) submission makes $_FILES['file']['error'] an + // array. Exercise the REAL readUploadedFile() (via $this->controller, + // which does not override it) to confirm it rejects cleanly. + $this->userSession->method('getUser')->willReturn($this->adminUser()); + $this->groupManager->method('isAdmin')->willReturn(true); + $this->service->expects($this->never())->method('uploadRaw'); + + $_FILES['file'] = [ + 'name' => ['a.png'], + 'tmp_name' => ['/tmp/whatever'], + 'size' => [10], + 'error' => [UPLOAD_ERR_OK], + ]; + try { + $response = $this->controller->uploadMultipart(); + } finally { + unset($_FILES['file']); + } + + $this->assertSame(400, $response->getStatus()); + $this->assertSame('no_file', $response->getData()['error']); + } + + public function testMultipartRejectsCraftedTmpNameNotFromUpload(): void + { + // LFI defence: a tmp_name pointing at an arbitrary local path (not a + // real PHP upload) must be rejected. is_uploaded_file() returns false + // for any path in the PHPUnit process, so readUploadedFile() bails and + // the crafted file is never read or stored. + $this->userSession->method('getUser')->willReturn($this->adminUser()); + $this->groupManager->method('isAdmin')->willReturn(true); + $this->service->expects($this->never())->method('uploadRaw'); + + $_FILES['file'] = [ + 'name' => 'passwd.png', + 'tmp_name' => __FILE__, + 'size' => 100, + 'error' => UPLOAD_ERR_OK, + ]; + try { + $response = $this->controller->uploadMultipart(); + } finally { + unset($_FILES['file']); + } + + $this->assertSame(400, $response->getStatus()); + $this->assertSame('no_file', $response->getData()['error']); + } + + public function testMultipartOversizeRejectedBeforeReadingFile(): void + { + // The reported upload size exceeds the cap → rejected in readUploadedFile + // before the bytes are pulled into memory, so uploadRaw is never reached. + $this->userSession->method('getUser')->willReturn($this->adminUser()); + $this->groupManager->method('isAdmin')->willReturn(true); + $this->service->expects($this->never())->method('uploadRaw'); + + $_FILES['file'] = [ + 'name' => 'big.png', + 'tmp_name' => __FILE__, + 'size' => (ResourceService::MAX_BYTES + 1), + 'error' => UPLOAD_ERR_OK, + ]; + try { + $response = $this->controller->uploadMultipart(); + } finally { + unset($_FILES['file']); + } + + $this->assertSame(400, $response->getStatus()); + $this->assertSame('file_too_large', $response->getData()['error']); + } + + public function testMultipartServiceExceptionMapsToEnvelope(): void + { + $this->userSession->method('getUser')->willReturn($this->adminUser()); + $this->groupManager->method('isAdmin')->willReturn(true); + $this->service->method('uploadRaw')->willThrowException(new FileTooLargeException()); + + $response = $this->buildMultipartController(['name' => 'big.png', 'bytes' => 'x'])->uploadMultipart(); + $body = $response->getData(); + + $this->assertSame(400, $response->getStatus()); + $this->assertSame('file_too_large', $body['error']); + $this->assertStringNotContainsString('Exception', $body['message']); + } + + public function testMultipartUnexpectedThrowableIsMaskedAsStorageFailure(): void + { + $this->userSession->method('getUser')->willReturn($this->adminUser()); + $this->groupManager->method('isAdmin')->willReturn(true); + $this->service->method('uploadRaw')->willThrowException( + new \RuntimeException('SECRET /var/lib/secret') + ); + + $response = $this->buildMultipartController(['name' => 'a.png', 'bytes' => 'x'])->uploadMultipart(); + $body = $response->getData(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertSame('storage_failure', $body['error']); + $this->assertStringNotContainsString('SECRET', $body['message']); + } }//end class diff --git a/tests/Unit/Service/FilesWidgetServiceTest.php b/tests/Unit/Service/FilesWidgetServiceTest.php index 57a9edce..4d871c8c 100644 --- a/tests/Unit/Service/FilesWidgetServiceTest.php +++ b/tests/Unit/Service/FilesWidgetServiceTest.php @@ -33,6 +33,7 @@ use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\Node; +use OCP\IPreview; use OCP\IURLGenerator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -47,6 +48,9 @@ class FilesWidgetServiceTest extends TestCase /** @var IURLGenerator&MockObject */ private $urlGenerator; + /** @var IPreview&MockObject */ + private $previewManager; + /** @var Folder&MockObject */ private $userFolder; @@ -57,17 +61,21 @@ protected function setUp(): void { $this->rootFolder = $this->createMock(IRootFolder::class); $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->previewManager = $this->createMock(IPreview::class); $this->userFolder = $this->createMock(Folder::class); $this->configuredFolder = $this->createMock(Folder::class); $this->urlGenerator->method('linkToRouteAbsolute') ->willReturn('https://nc/preview'); + // isAvailable is left unconfigured (returns null → no thumbnail) so it + // stays false-y for existing tests; the thumbnail test opts in. $this->rootFolder->method('getUserFolder')->willReturn($this->userFolder); $this->service = new FilesWidgetService( rootFolder: $this->rootFolder, urlGenerator: $this->urlGenerator, + previewManager: $this->previewManager, ); } @@ -265,6 +273,32 @@ public function testBuildFileMetadataSurfacesPermissionFlags(): void $this->assertFalse($metadata['isFolder']); } + /** + * A preview URL is emitted for any file the preview manager can render + * (not just images), and stays null for non-previewable files. + */ + public function testBuildFileMetadataEmitsThumbnailWhenPreviewAvailable(): void + { + $previewable = $this->buildFile(id: 7, name: 'doc.pdf', mime: 'application/pdf'); + $this->previewManager->method('isAvailable')->willReturn(true); + + $metadata = $this->service->buildFileMetadata(node: $previewable); + $this->assertSame('https://nc/preview', $metadata['thumbnailUrl']); + } + + /** + * Non-previewable files carry no thumbnail URL, so the frontend renders + * the generic icon. + */ + public function testBuildFileMetadataOmitsThumbnailWhenNoPreview(): void + { + $file = $this->buildFile(id: 8, name: 'archive.zip', mime: 'application/zip'); + // isAvailable left unconfigured → null → no thumbnail. + + $metadata = $this->service->buildFileMetadata(node: $file); + $this->assertNull($metadata['thumbnailUrl']); + } + /** * REQ-FLS-007: upload throws NoAccessException when allowUpload * is false in the placement config. diff --git a/tests/Unit/Service/ResourceServiceTest.php b/tests/Unit/Service/ResourceServiceTest.php index 3c7dd00e..481eea0d 100644 --- a/tests/Unit/Service/ResourceServiceTest.php +++ b/tests/Unit/Service/ResourceServiceTest.php @@ -240,4 +240,104 @@ public function testFilenameMatchesSpecPattern(): void $result['name'] ); } + + // --------------------------------------------------------------- + // uploadRaw() — raw multipart path (REQ-RES-014). + // --------------------------------------------------------------- + + public function testUploadRawStoresBytesAndReturnsEnvelope(): void + { + $this->mimeValidator->expects($this->once())->method('validate') + ->with('png', $this->tinyPng()); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->expects($this->once())->method('newFile') + ->willReturnCallback(function (string $name): ISimpleFile { + $this->assertStringStartsWith('resource_', $name); + $this->assertStringEndsWith('.png', $name); + return $this->createMock(ISimpleFile::class); + }); + + $result = $this->service->uploadRaw(bytes: $this->tinyPng(), declaredType: 'png'); + + $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); + $this->assertStringEndsWith('.png', $result['url']); + $this->assertSame(strlen($this->tinyPng()), $result['size']); + } + + public function testUploadRawNormalisesUppercaseAndSvgXmlType(): void + { + $svg = ''; + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('newFile') + ->willReturnCallback(function (string $name): ISimpleFile { + $this->assertStringEndsWith('.svg', $name); + return $this->createMock(ISimpleFile::class); + }); + + // 'SVG+XML' (uppercase, +xml suffix) must normalise to 'svg'. + $result = $this->service->uploadRaw(bytes: $svg, declaredType: 'SVG+XML'); + + $this->assertStringEndsWith('.svg', $result['name']); + } + + public function testUploadRawEmptyBytesRejected(): void + { + $this->expectException(InvalidDataUrlException::class); + $this->appData->expects($this->never())->method('getFolder'); + $this->service->uploadRaw(bytes: '', declaredType: 'png'); + } + + public function testUploadRawDisallowedTypeRejected(): void + { + $this->expectException(InvalidImageFormatException::class); + $this->appData->expects($this->never())->method('getFolder'); + $this->service->uploadRaw(bytes: 'whatever', declaredType: 'bmp'); + } + + public function testUploadRawOversizeRejectedBeforeValidator(): void + { + $oversize = str_repeat('A', (6 * 1024 * 1024)); + $this->mimeValidator->expects($this->never())->method('validate'); + $this->appData->expects($this->never())->method('getFolder'); + + $this->expectException(FileTooLargeException::class); + $this->service->uploadRaw(bytes: $oversize, declaredType: 'png'); + } + + public function testUploadRawSanitisesSvgScriptBeforePersisting(): void + { + // Uses the real SvgSanitiser (wired in setUp): a '; + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + + $persisted = null; + $this->folder->method('newFile') + ->willReturnCallback(function (string $name, $content) use (&$persisted): ISimpleFile { + $persisted = $content; + return $this->createMock(ISimpleFile::class); + }); + + $this->service->uploadRaw(bytes: $svg, declaredType: 'svg'); + + $this->assertIsString($persisted); + $this->assertStringNotContainsStringIgnoringCase('assertStringContainsString('mimeValidator->method('validate') + ->willThrowException(new MimeMismatchException()); + $this->appData->expects($this->never())->method('getFolder'); + + $this->expectException(MimeMismatchException::class); + $this->service->uploadRaw(bytes: $this->tinyPng(), declaredType: 'webp'); + } }