Skip to content
Open
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 @@ -23,6 +23,8 @@
['name' => 'Whiteboard#getLib', 'url' => 'library', 'verb' => 'GET'],
/** @see WhiteboardController::updateLib() */
['name' => 'Whiteboard#updateLib', 'url' => 'library', 'verb' => 'PUT'],
/** @see WhiteboardController::saveLibTemplate() */
['name' => 'Whiteboard#saveLibTemplate', 'url' => 'library/template', 'verb' => 'POST'],
/** @see WhiteboardController::update() */
['name' => 'Whiteboard#update', 'url' => '{fileId}', 'verb' => 'PUT'],
/** @see WhiteboardController::show() */
Expand All @@ -33,6 +35,12 @@
['name' => 'Recording#upload', 'url' => 'recording/{fileId}/upload', 'verb' => 'POST'],
/** @see SettingsController::update() */
['name' => 'Settings#update', 'url' => 'settings', 'verb' => 'POST'],
/** @see SettingsController::listGlobalLibraryTemplates() */
['name' => 'Settings#listGlobalLibraryTemplates', 'url' => 'settings/global-library', 'verb' => 'GET'],
/** @see SettingsController::uploadGlobalLibraryTemplate() */
['name' => 'Settings#uploadGlobalLibraryTemplate', 'url' => 'settings/global-library', 'verb' => 'POST'],
/** @see SettingsController::deleteGlobalLibraryTemplate() */
['name' => 'Settings#deleteGlobalLibraryTemplate', 'url' => 'settings/global-library/{templateName}', 'verb' => 'DELETE'],
/** @see SettingsController::updatePersonal() */
['name' => 'Settings#updatePersonal', 'url' => 'settings/personal', 'verb' => 'POST'],
]
Expand Down
8 changes: 8 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@
use OCA\Viewer\Event\LoadViewer;
use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener;
use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener;
use OCA\Whiteboard\Listener\FileCreatedFromTemplateListener;
use OCA\Whiteboard\Listener\LoadTextEditorListener;
use OCA\Whiteboard\Listener\LoadViewerListener;
use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener;
use OCA\Whiteboard\Settings\SetupCheck;
use OCA\Whiteboard\Template\GlobalLibraryTemplateProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Files\Template\FileCreatedFromTemplateEvent;
use OCP\Files\Template\ITemplateManager;
use OCP\Files\Template\RegisterTemplateCreatorEvent;
use OCP\IL10N;
Expand All @@ -47,7 +50,12 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(LoadViewer::class, LoadViewerListener::class);
$context->registerEventListener(LoadViewer::class, LoadTextEditorListener::class);
$context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class);
$context->registerEventListener(FileCreatedFromTemplateEvent::class, FileCreatedFromTemplateListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
[$major] = Util::getVersion();
if ($major >= 30) {
$context->registerTemplateProvider(GlobalLibraryTemplateProvider::class);
}
$context->registerSetupCheck(SetupCheck::class);
}

Expand Down
43 changes: 43 additions & 0 deletions lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
use OCA\Whiteboard\Service\ConfigService;
use OCA\Whiteboard\Service\ExceptionService;
use OCA\Whiteboard\Service\JWTService;
use OCA\Whiteboard\Service\WhiteboardLibraryService;
use OCA\Whiteboard\Settings\SetupCheck;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use OCP\IUserSession;
Expand All @@ -31,6 +33,7 @@ public function __construct(
private ConfigService $configService,
private SetupCheck $setupCheck,
private IUserSession $userSession,
private WhiteboardLibraryService $libraryService,
) {
parent::__construct('whiteboard', $request);
}
Expand Down Expand Up @@ -91,4 +94,44 @@ public function updatePersonal(): DataResponse {
return $this->exceptionService->handleException($e);
}
}

public function listGlobalLibraryTemplates(): DataResponse {
try {
return new DataResponse($this->libraryService->getGlobalTemplateMetadata());
} catch (Exception $e) {
return $this->exceptionService->handleException($e);
}
}

public function uploadGlobalLibraryTemplate(): DataResponse {
try {
$uploadedFile = $this->request->getUploadedFile('file');
if (!is_array($uploadedFile) || !isset($uploadedFile['tmp_name'], $uploadedFile['name'])) {
throw new Exception('No library preset uploaded', Http::STATUS_BAD_REQUEST);
}
if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
throw new Exception('Library preset upload failed', Http::STATUS_BAD_REQUEST);
}

$content = file_get_contents($uploadedFile['tmp_name']);
if ($content === false) {
throw new Exception('Failed to read uploaded library preset', Http::STATUS_BAD_REQUEST);
}

return new DataResponse([
'template' => $this->libraryService->saveGlobalTemplateFromUpload($uploadedFile['name'], $content),
], Http::STATUS_CREATED);
} catch (Exception $e) {
return $this->exceptionService->handleException($e);
}
}

public function deleteGlobalLibraryTemplate(string $templateName): DataResponse {
try {
$this->libraryService->deleteGlobalTemplate($templateName);
return new DataResponse(['status' => 'success']);
} catch (Exception $e) {
return $this->exceptionService->handleException($e);
}
}
}
28 changes: 26 additions & 2 deletions lib/Controller/WhiteboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OCA\Whiteboard\Controller;

use Exception;
use InvalidArgumentException;
use OCA\Whiteboard\Exception\InvalidUserException;
use OCA\Whiteboard\Exception\UnauthorizedException;
use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory;
Expand All @@ -20,6 +21,7 @@
use OCA\Whiteboard\Service\WhiteboardContentService;
use OCA\Whiteboard\Service\WhiteboardLibraryService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
Expand Down Expand Up @@ -110,8 +112,8 @@ public function update(int $fileId, array $data): DataResponse {
public function getLib(): DataResponse {
try {
$jwt = $this->getJwtFromRequest();
$this->jwtService->getUserIdFromJWT($jwt);
$data = $this->libraryService->getUserLib();
$userId = $this->jwtService->getUserIdFromJWT($jwt);
$data = $this->libraryService->getUserLib($userId);

return new DataResponse(['data' => $data]);
} catch (Exception $e) {
Expand All @@ -135,6 +137,28 @@ public function updateLib(): DataResponse {
}
}

#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
public function saveLibTemplate(): DataResponse {
try {
$jwt = $this->getJwtFromRequest();
$userId = $this->jwtService->getUserIdFromJWT($jwt);
$templateName = $this->request->getParam('templateName', '');
$items = $this->request->getParam('items', []);

if (!is_string($templateName) || !is_array($items)) {
throw new InvalidArgumentException('Invalid library preset payload', Http::STATUS_BAD_REQUEST);
}

$template = $this->libraryService->saveUserTemplate($userId, $templateName, $items);

return new DataResponse(['status' => 'success', 'template' => $template]);
} catch (Exception $e) {
return $this->exceptionService->handleException($e);
}
}

private function getJwtFromRequest(): string {
$authHeader = $this->request->getHeader('Authorization');
if (sscanf($authHeader, 'Bearer %s', $jwt) !== 1) {
Expand Down
81 changes: 81 additions & 0 deletions lib/Listener/FileCreatedFromTemplateListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Whiteboard\Listener;

use OCA\Whiteboard\Service\WhiteboardLibraryService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\File;
use OCP\Files\Template\FileCreatedFromTemplateEvent;
use Psr\Log\LoggerInterface;

/** @template-implements IEventListener<FileCreatedFromTemplateEvent|Event> */
/**
* @psalm-suppress MissingTemplateParam
*/
final class FileCreatedFromTemplateListener implements IEventListener {
private const LIB_EXTENSION = '.excalidrawlib';
private const WHITEBOARD_EXTENSION = '.whiteboard';
private const GLOBAL_TEMPLATE_DIR = '/whiteboard/global-libraries/';

/**
* @psalm-suppress PossiblyUnusedMethod
*/
public function __construct(
private WhiteboardLibraryService $libraryService,
private LoggerInterface $logger,
) {
}

#[\Override]
public function handle(Event $event): void {
if (!($event instanceof FileCreatedFromTemplateEvent)) {
return;
}

$template = $event->getTemplate();
$target = $event->getTarget();
if (!($template instanceof File)) {
return;
}

if (!$this->isOrganizationLibraryTemplate($template) || !$this->isWhiteboardTarget($target)) {
return;
}

$libraryItems = $this->libraryService->parseLibraryContent($template->getContent());
if ($libraryItems === null) {
return;
}

try {
$target->putContent(json_encode([
'elements' => [],
'files' => [],
'libraryItems' => $libraryItems,
'scrollToContent' => true,
], JSON_THROW_ON_ERROR));
} catch (\Throwable $e) {
$this->logger->warning('Failed to normalize whiteboard created from library preset', [
'app' => 'whiteboard',
'template' => $template->getPath(),
'target' => $target->getPath(),
'exception' => $e,
]);
}
}

private function isOrganizationLibraryTemplate(File $file): bool {
return str_ends_with(strtolower($file->getName()), self::LIB_EXTENSION)
&& str_contains($file->getPath(), self::GLOBAL_TEMPLATE_DIR);
}

private function isWhiteboardTarget(File $file): bool {
return str_ends_with(strtolower($file->getName()), self::WHITEBOARD_EXTENSION);
}
}
39 changes: 38 additions & 1 deletion lib/Service/WhiteboardContentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ private function normalizeIncomingData(array $incoming): array {
: [];
}

if (array_key_exists('libraryItems', $incoming) && is_array($incoming['libraryItems'])) {
$normalized['libraryItems'] = $this->sanitizeLibraryItems($incoming['libraryItems']);
}

if (array_key_exists('appState', $incoming) && is_array($incoming['appState'])) {
$normalized['appState'] = $this->sanitizeAppState($incoming['appState']);
}
Expand All @@ -183,6 +187,10 @@ private function normalizeIncomingData(array $incoming): array {
* @param array<string,mixed> $payload
*/
private function isEffectivelyEmptyPayload(array $payload): bool {
if (array_key_exists('libraryItems', $payload)) {
return false;
}

$hasFiles = array_key_exists('files', $payload)
&& is_array($payload['files'])
&& !empty($payload['files']);
Expand Down Expand Up @@ -212,7 +220,7 @@ private function isEffectivelyEmptyPayload(array $payload): bool {
}

foreach ($payload as $key => $_value) {
if (!in_array($key, ['elements', 'files', 'appState', 'scrollToContent'], true)) {
if (!in_array($key, ['elements', 'files', 'libraryItems', 'appState', 'scrollToContent'], true)) {
return false;
}
}
Expand Down Expand Up @@ -244,6 +252,10 @@ private function normalizeStoredData(array $stored): array {
$normalized['files'] = $this->sanitizeFiles($stored['files']);
}

if (array_key_exists('libraryItems', $stored) && is_array($stored['libraryItems'])) {
$normalized['libraryItems'] = $this->sanitizeLibraryItems($stored['libraryItems']);
}

if (array_key_exists('appState', $stored) && is_array($stored['appState'])) {
$normalized['appState'] = $this->sanitizeAppState($stored['appState']);
} elseif (array_key_exists('appState', $stored) && $stored['appState'] === null) {
Expand Down Expand Up @@ -274,6 +286,10 @@ private function mergeData(array $current, array $incoming): array {
$merged['files'] = $incoming['files'];
}

if (array_key_exists('libraryItems', $incoming)) {
$merged['libraryItems'] = $incoming['libraryItems'];
}

if (array_key_exists('appState', $incoming)) {
if ($incoming['appState'] === null) {
unset($merged['appState']);
Expand Down Expand Up @@ -331,6 +347,27 @@ private function sanitizeFiles(array $files): array {
return $sanitized;
}

/**
* @param array<mixed> $items
*
* @return array<int,mixed>
*/
private function sanitizeLibraryItems(array $items): array {
$sanitized = [];

foreach ($items as $item) {
if (!is_array($item) || !isset($item['elements']) || !is_array($item['elements']) || count($item['elements']) === 0) {
continue;
}

unset($item['templateName'], $item['scope'], $item['filename'], $item['basename']);
$item['elements'] = array_values($item['elements']);
$sanitized[] = $item;
}

return $sanitized;
}

/**
* @param array<string,mixed> $appState
*
Expand Down
Loading
Loading