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 template uploaded', Http::STATUS_BAD_REQUEST);
}
if (($uploadedFile['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
throw new Exception('Library template upload failed', Http::STATUS_BAD_REQUEST);
}

$content = file_get_contents($uploadedFile['tmp_name']);
if ($content === false) {
throw new Exception('Failed to read uploaded library template', 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);
}
}
}
33 changes: 29 additions & 4 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 Down Expand Up @@ -110,8 +111,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->getTemplates($userId);

return new DataResponse(['data' => $data]);
} catch (Exception $e) {
Expand All @@ -126,15 +127,39 @@ public function updateLib(): DataResponse {
try {
$jwt = $this->getJwtFromRequest();
$userId = $this->jwtService->getUserIdFromJWT($jwt);
$items = $this->request->getParam('items', []);
$this->libraryService->updateUserLib($userId, $items);
$templates = $this->request->getParam('templates');
if (!is_array($templates)) {
throw new InvalidArgumentException('Library templates payload is required', 400);
}
$this->libraryService->updateUserTemplates($userId, $templates);

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

#[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 template payload', 400);
}

$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
189 changes: 189 additions & 0 deletions lib/Listener/FileCreatedFromTemplateListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?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 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 VOLATILE_ELEMENT_KEYS = [
'id' => true,
'seed' => true,
'version' => true,
'versionNonce' => true,
'updated' => true,
'index' => true,
'groupIds' => true,
'frameId' => true,
'boundElements' => true,
'containerId' => true,
];

/**
* @psalm-suppress PossiblyUnusedMethod
*/
public function __construct(
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->isLibraryTemplate($template) || !$this->isWhiteboardTarget($target)) {
return;
}

$libraryItems = $this->parseLibraryItems($template);
if ($libraryItems === []) {
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 template', [
'app' => 'whiteboard',
'template' => $template->getPath(),
'target' => $target->getPath(),
'exception' => $e,
]);
}
}

private function isLibraryTemplate(File $file): bool {
return str_ends_with(strtolower($file->getName()), self::LIB_EXTENSION);
}

private function isWhiteboardTarget(File $file): bool {
return str_ends_with(strtolower($file->getName()), self::WHITEBOARD_EXTENSION);
}

private function parseLibraryItems(File $file): array {
try {
$data = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable) {
return [];
}

if (!is_array($data)) {
return [];
}

if (isset($data['libraryItems']) && is_array($data['libraryItems'])) {
return $this->normalizeLibraryItems($data['libraryItems']);
}

if (isset($data['library']) && is_array($data['library'])) {
$items = [];
foreach ($data['library'] as $elements) {
if (!is_array($elements) || count($elements) === 0) {
continue;
}
$items[] = [
'id' => $this->createLibraryItemId($elements),
'elements' => array_values($elements),
'status' => 'published',
];
}
return $this->normalizeLibraryItems($items);
}

return [];
}

private function normalizeLibraryItems(array $items): array {
$normalized = [];
$seen = [];

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']);
$key = $this->createLibraryItemId($item['elements']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;

$item['id'] = isset($item['id']) && is_string($item['id']) && $item['id'] !== ''
? $item['id']
: $key;
$item['created'] = isset($item['created']) && is_numeric($item['created'])
? (int)$item['created']
: $this->nowMs();
$item['status'] = isset($item['status']) && is_string($item['status'])
? $item['status']
: 'unpublished';

$normalized[] = $item;
}

return $normalized;
}

private function createLibraryItemId(array $elements): string {
$canonicalElements = $this->canonicalizeLibraryValue($elements);
$encoded = json_encode($canonicalElements);
return substr(hash('sha256', $encoded !== false ? $encoded : serialize($canonicalElements)), 0, 20);
}

private function canonicalizeLibraryValue(mixed $value): mixed {
if (!is_array($value)) {
return $value;
}

if ($this->isListArray($value)) {
return array_map(fn ($item) => $this->canonicalizeLibraryValue($item), $value);
}

ksort($value);
$normalized = [];
foreach ($value as $key => $item) {
if (is_string($key) && isset(self::VOLATILE_ELEMENT_KEYS[$key])) {
continue;
}
$normalized[$key] = $this->canonicalizeLibraryValue($item);
}
return $normalized;
}

private function nowMs(): int {
return (int)floor((float)microtime(true) * 1000.0);
}

private function isListArray(array $value): bool {
return $value === [] || array_keys($value) === range(0, count($value) - 1);
}
}
Loading
Loading