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
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions lib/Controller/ManifestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
149 changes: 149 additions & 0 deletions lib/Controller/ResourceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
56 changes: 56 additions & 0 deletions lib/Exception/FileMissingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

/**
* FileMissingException
*
* Raised by the multipart upload endpoint when the request carries no
* usable `file` field (absent entry or a non-zero PHP upload error).
* Maps to HTTP 400 + error code `no_file`.
*
* @category Exception
* @package OCA\LaunchPad\Exception
* @author Conduction b.v. <info@conduction.nl>
* @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. <info@conduction.nl>
* 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
39 changes: 24 additions & 15 deletions lib/Service/FilesWidgetService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Minor — Preview thumbnail size increased 16× in area

Old path requested 64×64; new path requests 256×256 with a=1 (aspect-preserved). Nextcloud will serve the nearest cached size, but on cold cache this triples per-file preview generation cost. If the files widget renders many rows simultaneously, dashboard load will trigger a burst of preview jobs. Worth confirming the frontend <img> display size is constrained (so the browser downscales cleanly) and that pagination/virtualisation is in place before this lands on large deployments.

'y' => 256,
'a' => 1,
]
);
}

$permissions = $node->getPermissions();
Expand Down
Loading