From 7a37a1b00118a1f823964b6910e49ebe17d75342 Mon Sep 17 00:00:00 2001 From: Hoang Pham Date: Fri, 23 Aug 2024 15:43:55 +0700 Subject: [PATCH] public sharing Signed-off-by: Hoang Pham --- lib/AppInfo/Application.php | 7 + lib/Controller/JWTController.php | 29 +-- lib/Controller/WhiteboardController.php | 72 +++++++- lib/Exception/InvalidUserException.php | 19 ++ lib/Exception/UnauthorizedException.php | 19 ++ .../BeforeTemplateRenderedListener.php | 43 +++++ .../RegisterTemplateCreatorListener.php | 5 + lib/Model/AuthenticatedUser.php | 25 +++ lib/Model/PublicSharingUser.php | 43 +++++ lib/Model/User.php | 15 ++ .../AuthenticatePublicSharingUserService.php | 37 ++++ .../AuthenticateSessionUserService.php | 35 ++++ .../AuthenticateUserService.php | 16 ++ .../AuthenticateUserServiceFactory.php | 32 ++++ .../ChainAuthenticateUserService.php | 31 ++++ .../GetPublicSharingUserFromIdService.php | 40 ++++ .../GetSessionUserFromIdService.php | 35 ++++ .../Authentication/GetUserFromIdService.php | 16 ++ .../GetUserFromIdServiceFactory.php | 31 ++++ lib/Service/AuthenticationService.php | 110 ----------- lib/Service/ExceptionService.php | 31 ++-- .../GetFileFromIdService.php} | 16 +- .../GetFileFromPublicSharingTokenService.php | 48 +++++ lib/Service/File/GetFileService.php | 16 ++ lib/Service/File/GetFileServiceFactory.php | 42 +++++ lib/Service/JWTService.php | 18 +- src/App.tsx | 5 +- src/collaboration/Portal.ts | 143 +++++++++------ src/collaboration/collab.ts | 9 +- src/main.tsx | 171 ++++++++++++------ 30 files changed, 897 insertions(+), 262 deletions(-) create mode 100644 lib/Exception/InvalidUserException.php create mode 100644 lib/Exception/UnauthorizedException.php create mode 100644 lib/Listener/BeforeTemplateRenderedListener.php create mode 100644 lib/Model/AuthenticatedUser.php create mode 100644 lib/Model/PublicSharingUser.php create mode 100644 lib/Model/User.php create mode 100644 lib/Service/Authentication/AuthenticatePublicSharingUserService.php create mode 100644 lib/Service/Authentication/AuthenticateSessionUserService.php create mode 100644 lib/Service/Authentication/AuthenticateUserService.php create mode 100644 lib/Service/Authentication/AuthenticateUserServiceFactory.php create mode 100644 lib/Service/Authentication/ChainAuthenticateUserService.php create mode 100644 lib/Service/Authentication/GetPublicSharingUserFromIdService.php create mode 100644 lib/Service/Authentication/GetSessionUserFromIdService.php create mode 100644 lib/Service/Authentication/GetUserFromIdService.php create mode 100644 lib/Service/Authentication/GetUserFromIdServiceFactory.php delete mode 100644 lib/Service/AuthenticationService.php rename lib/Service/{FileService.php => File/GetFileFromIdService.php} (76%) create mode 100644 lib/Service/File/GetFileFromPublicSharingTokenService.php create mode 100644 lib/Service/File/GetFileService.php create mode 100644 lib/Service/File/GetFileServiceFactory.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index ebc939e..f5d89fb 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -10,8 +10,10 @@ namespace OCA\Whiteboard\AppInfo; +use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; use OCA\Viewer\Event\LoadViewer; use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener; +use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener; use OCA\Whiteboard\Listener\LoadViewerListener; use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener; use OCP\AppFramework\App; @@ -21,6 +23,10 @@ use OCP\Files\Template\RegisterTemplateCreatorEvent; use OCP\Security\CSP\AddContentSecurityPolicyEvent; +/** + * @psalm-suppress UndefinedClass + * @psalm-suppress InvalidArgument + */ class Application extends App implements IBootstrap { public const APP_ID = 'whiteboard'; @@ -34,6 +40,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(AddContentSecurityPolicyEvent::class, AddContentSecurityPolicyListener::class); $context->registerEventListener(LoadViewer::class, LoadViewerListener::class); $context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class); + $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); } public function boot(IBootContext $context): void { diff --git a/lib/Controller/JWTController.php b/lib/Controller/JWTController.php index 7435ada..7d46b96 100644 --- a/lib/Controller/JWTController.php +++ b/lib/Controller/JWTController.php @@ -9,9 +9,10 @@ namespace OCA\Whiteboard\Controller; -use OCA\Whiteboard\Service\AuthenticationService; +use Exception; +use OCA\Whiteboard\Service\Authentication\AuthenticateUserServiceFactory; use OCA\Whiteboard\Service\ExceptionService; -use OCA\Whiteboard\Service\FileService; +use OCA\Whiteboard\Service\File\GetFileServiceFactory; use OCA\Whiteboard\Service\JWTService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\DataResponse; @@ -23,11 +24,11 @@ */ final class JWTController extends Controller { public function __construct( - IRequest $request, - private AuthenticationService $authService, - private FileService $fileService, - private JWTService $jwtService, - private ExceptionService $exceptionService + IRequest $request, + private GetFileServiceFactory $getFileServiceFactory, + private JWTService $jwtService, + private ExceptionService $exceptionService, + private AuthenticateUserServiceFactory $authenticateUserServiceFactory ) { parent::__construct('whiteboard', $request); } @@ -35,14 +36,20 @@ public function __construct( /** * @NoCSRFRequired * @NoAdminRequired + * @PublicPage */ public function getJWT(int $fileId): DataResponse { try { - $user = $this->authService->getAuthenticatedUser(); - $file = $this->fileService->getUserFileById($user->getUID(), $fileId); - $jwt = $this->jwtService->generateJWT($user, $file, $fileId); + $publicSharingToken = $this->request->getParam('publicSharingToken'); + + $user = $this->authenticateUserServiceFactory->create($publicSharingToken)->authenticate(); + + $file = $this->getFileServiceFactory->create($user, $fileId)->getFile(); + + $jwt = $this->jwtService->generateJWT($user, $file); + return new DataResponse(['token' => $jwt]); - } catch (\Exception $e) { + } catch (Exception $e) { return $this->exceptionService->handleException($e); } } diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php index 99b6568..47199bc 100644 --- a/lib/Controller/WhiteboardController.php +++ b/lib/Controller/WhiteboardController.php @@ -10,9 +10,13 @@ namespace OCA\Whiteboard\Controller; use Exception; -use OCA\Whiteboard\Service\AuthenticationService; +use OCA\Whiteboard\Exception\InvalidUserException; +use OCA\Whiteboard\Exception\UnauthorizedException; +use OCA\Whiteboard\Service\Authentication\GetUserFromIdServiceFactory; +use OCA\Whiteboard\Service\ConfigService; use OCA\Whiteboard\Service\ExceptionService; -use OCA\Whiteboard\Service\FileService; +use OCA\Whiteboard\Service\File\GetFileServiceFactory; +use OCA\Whiteboard\Service\JWTService; use OCA\Whiteboard\Service\WhiteboardContentService; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -29,10 +33,12 @@ final class WhiteboardController extends ApiController { public function __construct( $appName, IRequest $request, - private AuthenticationService $authService, - private FileService $fileService, + private GetUserFromIdServiceFactory $getUserFromIdServiceFactory, + private GetFileServiceFactory $getFileServiceFactory, + private JWTService $jwtService, private WhiteboardContentService $contentService, - private ExceptionService $exceptionService + private ExceptionService $exceptionService, + private ConfigService $configService, ) { parent::__construct($appName, $request); } @@ -42,9 +48,16 @@ public function __construct( #[PublicPage] public function show(int $fileId): DataResponse { try { - $userId = $this->authService->authenticateJWT($this->request); - $file = $this->fileService->getUserFileById($userId, $fileId); + $jwt = $this->getJwtFromRequest(); + + $userId = $this->jwtService->getUserIdFromJWT($jwt); + + $user = $this->getUserFromIdServiceFactory->create($userId)->getUser(); + + $file = $this->getFileServiceFactory->create($user, $fileId)->getFile(); + $data = $this->contentService->getContent($file); + return new DataResponse(['data' => $data]); } catch (Exception $e) { return $this->exceptionService->handleException($e); @@ -56,13 +69,52 @@ public function show(int $fileId): DataResponse { #[PublicPage] public function update(int $fileId, array $data): DataResponse { try { - $this->authService->authenticateSharedToken($this->request, $fileId); - $user = $this->authService->getAndSetUser($this->request); - $file = $this->fileService->getUserFileById($user->getUID(), $fileId); + $this->validateBackendSharedToken($fileId); + + $userId = $this->getUserIdFromRequest(); + + $user = $this->getUserFromIdServiceFactory->create($userId)->getUser(); + + $file = $this->getFileServiceFactory->create($user, $fileId)->getFile(); + $this->contentService->updateContent($file, $data); + return new DataResponse(['status' => 'success']); } catch (Exception $e) { return $this->exceptionService->handleException($e); } } + + private function getJwtFromRequest(): string { + $authHeader = $this->request->getHeader('Authorization'); + if (sscanf($authHeader, 'Bearer %s', $jwt) !== 1) { + throw new UnauthorizedException(); + } + return (string)$jwt; + } + + private function getUserIdFromRequest(): string { + return $this->request->getHeader('X-Whiteboard-User'); + } + + private function validateBackendSharedToken(int $fileId): void { + $backendSharedToken = $this->request->getHeader('X-Whiteboard-Auth'); + if (!$backendSharedToken || !$this->verifySharedToken($backendSharedToken, $fileId)) { + throw new InvalidUserException('Invalid backend shared token'); + } + } + + private function verifySharedToken(string $token, int $fileId): bool { + [$roomId, $timestamp, $signature] = explode(':', $token); + + if ($roomId !== (string)$fileId) { + return false; + } + + $sharedSecret = $this->configService->getWhiteboardSharedSecret(); + $payload = "$roomId:$timestamp"; + $expectedSignature = hash_hmac('sha256', $payload, $sharedSecret); + + return hash_equals($expectedSignature, $signature); + } } diff --git a/lib/Exception/InvalidUserException.php b/lib/Exception/InvalidUserException.php new file mode 100644 index 0000000..e1e792e --- /dev/null +++ b/lib/Exception/InvalidUserException.php @@ -0,0 +1,19 @@ + */ +/** + * @psalm-suppress UndefinedClass + * @psalm-suppress MissingTemplateParam + */ +class BeforeTemplateRenderedListener implements IEventListener { + public function __construct( + private IInitialState $initialState, + ) { + } + + /** + * @throws NotFoundException + */ + public function handle(Event $event): void { + if (!($event instanceof BeforeTemplateRenderedEvent)) { + return; + } + + $this->initialState->provideInitialState( + 'file_id', + $event->getShare()->getNodeId() + ); + } +} diff --git a/lib/Listener/RegisterTemplateCreatorListener.php b/lib/Listener/RegisterTemplateCreatorListener.php index 587813a..7a92dc6 100644 --- a/lib/Listener/RegisterTemplateCreatorListener.php +++ b/lib/Listener/RegisterTemplateCreatorListener.php @@ -15,6 +15,10 @@ use OCP\IL10N; /** @template-implements IEventListener */ +/** + * @psalm-suppress UndefinedClass + * @psalm-suppress MissingTemplateParam + */ final class RegisterTemplateCreatorListener implements IEventListener { public function __construct( private IL10N $l10n @@ -26,6 +30,7 @@ public function handle(Event $event): void { return; } + $event->getTemplateManager()->registerTemplateFileCreator(function () { $whiteboard = new TemplateFileCreator(Application::APP_ID, $this->l10n->t('New whiteboard'), '.whiteboard'); $whiteboard->addMimetype('application/vnd.excalidraw+json'); diff --git a/lib/Model/AuthenticatedUser.php b/lib/Model/AuthenticatedUser.php new file mode 100644 index 0000000..d083a23 --- /dev/null +++ b/lib/Model/AuthenticatedUser.php @@ -0,0 +1,25 @@ +user->getUID(); + } + + public function getDisplayName(): string { + return $this->user->getDisplayName(); + } +} diff --git a/lib/Model/PublicSharingUser.php b/lib/Model/PublicSharingUser.php new file mode 100644 index 0000000..a01622c --- /dev/null +++ b/lib/Model/PublicSharingUser.php @@ -0,0 +1,43 @@ +generateRandomUID(); + } + + public function getDisplayName(): string { + return $this->generateRandomDisplayName(); + } + + public function getPublicSharingToken(): string { + return $this->publicSharingToken; + } + + private function generateRandomUID(): string { + return 'shared_' . $this->publicSharingToken . '_' . bin2hex(random_bytes(8)); + } + + private function generateRandomDisplayName(): string { + $adjectives = ['Anonymous', 'Mysterious', 'Incognito', 'Unknown', 'Unnamed']; + $nouns = ['User', 'Visitor', 'Guest', 'Collaborator', 'Participant']; + + $adjective = $adjectives[array_rand($adjectives)]; + $noun = $nouns[array_rand($nouns)]; + + return $adjective . ' ' . $noun; + } +} diff --git a/lib/Model/User.php b/lib/Model/User.php new file mode 100644 index 0000000..a469d7f --- /dev/null +++ b/lib/Model/User.php @@ -0,0 +1,15 @@ +publicSharingToken) { + throw new UnauthorizedException('Public sharing token not provided'); + } + + try { + $this->shareManager->getShareByToken($this->publicSharingToken); + return new PublicSharingUser($this->publicSharingToken); + } catch (Exception) { + throw new UnauthorizedException('Invalid sharing token'); + } + } +} diff --git a/lib/Service/Authentication/AuthenticateSessionUserService.php b/lib/Service/Authentication/AuthenticateSessionUserService.php new file mode 100644 index 0000000..e51448e --- /dev/null +++ b/lib/Service/Authentication/AuthenticateSessionUserService.php @@ -0,0 +1,35 @@ +userSession->isLoggedIn()) { + throw new UnauthorizedException('User not logged in'); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + throw new UnauthorizedException('User session invalid'); + } + + return new AuthenticatedUser($user); + } +} diff --git a/lib/Service/Authentication/AuthenticateUserService.php b/lib/Service/Authentication/AuthenticateUserService.php new file mode 100644 index 0000000..9c626da --- /dev/null +++ b/lib/Service/Authentication/AuthenticateUserService.php @@ -0,0 +1,16 @@ +shareManager, $publicSharingToken), + new AuthenticateSessionUserService($this->userSession), + ]; + + return new ChainAuthenticateUserService($authServices); + } +} diff --git a/lib/Service/Authentication/ChainAuthenticateUserService.php b/lib/Service/Authentication/ChainAuthenticateUserService.php new file mode 100644 index 0000000..169a2cf --- /dev/null +++ b/lib/Service/Authentication/ChainAuthenticateUserService.php @@ -0,0 +1,31 @@ +strategies as $strategy) { + try { + return $strategy->authenticate(); + } catch (Exception) { + continue; + } + } + + throw new InvalidUserException('No valid authentication method found'); + } +} diff --git a/lib/Service/Authentication/GetPublicSharingUserFromIdService.php b/lib/Service/Authentication/GetPublicSharingUserFromIdService.php new file mode 100644 index 0000000..d93f365 --- /dev/null +++ b/lib/Service/Authentication/GetPublicSharingUserFromIdService.php @@ -0,0 +1,40 @@ +userId); + if (count($parts) < 3) { + throw new InvalidUserException('Invalid public sharing user ID format'); + } + $publicSharingToken = $parts[1]; + + try { + $this->shareManager->getShareByToken($publicSharingToken); + return new PublicSharingUser($publicSharingToken); + } catch (Exception) { + throw new UnauthorizedException('Invalid sharing token'); + } + } +} diff --git a/lib/Service/Authentication/GetSessionUserFromIdService.php b/lib/Service/Authentication/GetSessionUserFromIdService.php new file mode 100644 index 0000000..025887f --- /dev/null +++ b/lib/Service/Authentication/GetSessionUserFromIdService.php @@ -0,0 +1,35 @@ +userManager->get($this->userId); + if (!$user) { + throw new UnauthorizedException('User not found'); + } + $this->userSession->setVolatileActiveUser($user); + + return new AuthenticatedUser($user); + } +} diff --git a/lib/Service/Authentication/GetUserFromIdService.php b/lib/Service/Authentication/GetUserFromIdService.php new file mode 100644 index 0000000..dc13954 --- /dev/null +++ b/lib/Service/Authentication/GetUserFromIdService.php @@ -0,0 +1,16 @@ +shareManager, $userId); + } + + return new GetSessionUserFromIdService($this->userManager, $this->userSession, $userId); + } +} diff --git a/lib/Service/AuthenticationService.php b/lib/Service/AuthenticationService.php deleted file mode 100644 index 2f0d56d..0000000 --- a/lib/Service/AuthenticationService.php +++ /dev/null @@ -1,110 +0,0 @@ -getHeader('Authorization'); - if (!$authHeader || sscanf($authHeader, 'Bearer %s', $jwt) !== 1) { - throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); - } - - if (!is_string($jwt)) { - throw new RuntimeException('JWT token must be a string', Http::STATUS_BAD_REQUEST); - } - - try { - $key = $this->configService->getJwtSecretKey(); - - return JWT::decode($jwt, new Key($key, JWTConsts::JWT_ALGORITHM))->userid; - } catch (Exception) { - throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); - } - } - - /** - * @throws Exception - */ - public function getAuthenticatedUser(): IUser { - if (!$this->userSession->isLoggedIn()) { - throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userSession->getUser(); - if ($user === null) { - throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); - } - - return $user; - } - - /** - * @throws Exception - */ - public function authenticateSharedToken(IRequest $request, int $fileId): void { - $whiteboardAuth = $request->getHeader('X-Whiteboard-Auth'); - if (!$whiteboardAuth || !$this->verifySharedToken($whiteboardAuth, $fileId)) { - throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); - } - } - - private function verifySharedToken(string $token, int $fileId): bool { - [$roomId, $timestamp, $signature] = explode(':', $token); - - if ($roomId !== (string)$fileId) { - return false; - } - - $sharedSecret = $this->configService->getWhiteboardSharedSecret(); - $payload = "$roomId:$timestamp"; - $expectedSignature = hash_hmac('sha256', $payload, $sharedSecret); - - return hash_equals($expectedSignature, $signature); - } - - /** - * @throws Exception - */ - public function getAndSetUser(IRequest $request): IUser { - $whiteboardUser = $request->getHeader('X-Whiteboard-User'); - $user = $this->userManager->get($whiteboardUser); - if (!$user) { - throw new RuntimeException('Invalid user', Http::STATUS_BAD_REQUEST); - } - - $this->userSession->setVolatileActiveUser($user); - - return $user; - } -} diff --git a/lib/Service/ExceptionService.php b/lib/Service/ExceptionService.php index a2050cf..702247a 100644 --- a/lib/Service/ExceptionService.php +++ b/lib/Service/ExceptionService.php @@ -10,6 +10,8 @@ namespace OCA\Whiteboard\Service; use Exception; +use OCA\Whiteboard\Exception\InvalidUserException; +use OCA\Whiteboard\Exception\UnauthorizedException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\Files\NotFoundException; @@ -27,23 +29,22 @@ public function handleException(Exception $e): DataResponse { } private function getStatusCode(Exception $e): int { - if ($e instanceof NotFoundException) { - return Http::STATUS_NOT_FOUND; - } - if ($e instanceof NotPermittedException) { - return Http::STATUS_FORBIDDEN; - } - - return (int)($e->getCode() ?: Http::STATUS_INTERNAL_SERVER_ERROR); + return match (true) { + $e instanceof NotFoundException => Http::STATUS_NOT_FOUND, + $e instanceof NotPermittedException => Http::STATUS_FORBIDDEN, + $e instanceof UnauthorizedException => Http::STATUS_UNAUTHORIZED, + $e instanceof InvalidUserException => Http::STATUS_BAD_REQUEST, + default => (int)($e->getCode() ?: Http::STATUS_INTERNAL_SERVER_ERROR), + }; } private function getMessage(Exception $e): string { - if ($e instanceof NotFoundException) { - return 'File not found'; - } - if ($e instanceof NotPermittedException) { - return 'Permission denied'; - } - return $e->getMessage() ?: 'An error occurred'; + return match (true) { + $e instanceof NotFoundException => 'File not found', + $e instanceof NotPermittedException => 'Permission denied', + $e instanceof UnauthorizedException => 'Unauthorized', + $e instanceof InvalidUserException => 'Invalid user', + default => $e->getMessage() ?: 'An error occurred', + }; } } diff --git a/lib/Service/FileService.php b/lib/Service/File/GetFileFromIdService.php similarity index 76% rename from lib/Service/FileService.php rename to lib/Service/File/GetFileFromIdService.php index e8252e3..621da48 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/File/GetFileFromIdService.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Whiteboard\Service; +namespace OCA\Whiteboard\Service\File; use OC\User\NoUserException; use OCP\Constants; @@ -23,9 +23,11 @@ * @psalm-suppress UndefinedClass * @psalm-suppress MissingDependency */ -final class FileService { +final class GetFileFromIdService implements GetFileService { public function __construct( - private IRootFolder $rootFolder + private IRootFolder $rootFolder, + private string $userId, + private int $fileId ) { } @@ -35,15 +37,15 @@ public function __construct( * @throws NoUserException * @throws InvalidPathException */ - public function getUserFileById(string $userId, int $fileId): File { - $userFolder = $this->rootFolder->getUserFolder($userId); + public function getFile(): File { + $userFolder = $this->rootFolder->getUserFolder($this->userId); - $file = $userFolder->getFirstNodeById($fileId); + $file = $userFolder->getFirstNodeById($this->fileId); if ($file instanceof File && $file->getPermissions() & Constants::PERMISSION_UPDATE) { return $file; } - $files = $userFolder->getById($fileId); + $files = $userFolder->getById($this->fileId); if (empty($files)) { throw new NotFoundException('File not found'); } diff --git a/lib/Service/File/GetFileFromPublicSharingTokenService.php b/lib/Service/File/GetFileFromPublicSharingTokenService.php new file mode 100644 index 0000000..55c5b36 --- /dev/null +++ b/lib/Service/File/GetFileFromPublicSharingTokenService.php @@ -0,0 +1,48 @@ +shareManager->getShareByToken($this->publicSharingToken); + } catch (ShareNotFound) { + throw new NotFoundException(); + } + + $node = $share->getNode(); + + if ($node instanceof File) { + return $node; + } + + throw new InvalidArgumentException('No proper share data'); + } +} diff --git a/lib/Service/File/GetFileService.php b/lib/Service/File/GetFileService.php new file mode 100644 index 0000000..c6e7ff2 --- /dev/null +++ b/lib/Service/File/GetFileService.php @@ -0,0 +1,16 @@ +rootFolder, $user->getUID(), $fileId); + } + + if ($user instanceof PublicSharingUser) { + return new GetFileFromPublicSharingTokenService($this->shareManager, $user->getPublicSharingToken()); + } + + throw new InvalidUserException(); + } +} diff --git a/lib/Service/JWTService.php b/lib/Service/JWTService.php index 8fecbfb..9e7ff1d 100644 --- a/lib/Service/JWTService.php +++ b/lib/Service/JWTService.php @@ -9,12 +9,15 @@ namespace OCA\Whiteboard\Service; +use Exception; use Firebase\JWT\JWT; +use Firebase\JWT\Key; use OCA\Whiteboard\Consts\JWTConsts; +use OCA\Whiteboard\Exception\UnauthorizedException; +use OCA\Whiteboard\Model\User; use OCP\Files\File; use OCP\Files\InvalidPathException; use OCP\Files\NotFoundException; -use OCP\IUser; final class JWTService { public function __construct( @@ -26,13 +29,13 @@ public function __construct( * @throws InvalidPathException * @throws NotFoundException */ - public function generateJWT(IUser $user, File $file, int $fileId): string { + public function generateJWT(User $user, File $file): string { $key = $this->configService->getJwtSecretKey(); $issuedAt = time(); $expirationTime = $issuedAt + JWTConsts::EXPIRATION_TIME; $payload = [ 'userid' => $user->getUID(), - 'fileId' => $fileId, + 'fileId' => $file->getId(), 'permissions' => $file->getPermissions(), 'user' => [ 'id' => $user->getUID(), @@ -44,4 +47,13 @@ public function generateJWT(IUser $user, File $file, int $fileId): string { return JWT::encode($payload, $key, JWTConsts::JWT_ALGORITHM); } + + public function getUserIdFromJWT(string $jwt): string { + try { + $key = $this->configService->getJwtSecretKey(); + return JWT::decode($jwt, new Key($key, JWTConsts::JWT_ALGORITHM))->userid; + } catch (Exception) { + throw new UnauthorizedException(); + } + } } diff --git a/src/App.tsx b/src/App.tsx index 4ba46f8..bd3baca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,9 +32,10 @@ interface WhiteboardAppProps { fileId: number; fileName: string; isEmbedded: boolean; + publicSharingToken: string | null; } -export default function App({ fileId, isEmbedded, fileName }: WhiteboardAppProps) { +export default function App({ fileId, isEmbedded, fileName, publicSharingToken }: WhiteboardAppProps) { const fileNameWithoutExtension = fileName.split('.').slice(0, -1).join('.') const [viewModeEnabled] = useState(isEmbedded) @@ -75,7 +76,7 @@ export default function App({ fileId, isEmbedded, fileName }: WhiteboardAppProps ] = useState(null) const [collab, setCollab] = useState(null) - if (excalidrawAPI && !collab) setCollab(new Collab(excalidrawAPI, fileId)) + if (excalidrawAPI && !collab) setCollab(new Collab(excalidrawAPI, fileId, publicSharingToken)) if (collab && !collab.portal.socket) collab.startCollab() useEffect(() => { const extraTools = document.getElementsByClassName('App-toolbar__extra-tools-trigger')[0] diff --git a/src/collaboration/Portal.ts b/src/collaboration/Portal.ts index 02be2e2..ca33a0d 100644 --- a/src/collaboration/Portal.ts +++ b/src/collaboration/Portal.ts @@ -20,13 +20,13 @@ export class Portal { socket: Socket | null = null roomId: string - roomKey: string collab: Collab + publicSharingToken: string | null - constructor(roomId: string, roomKey: string, collab: Collab) { + constructor(roomId: string, collab: Collab, publicSharingToken: string | null) { this.roomId = roomId - this.roomKey = roomKey this.collab = collab + this.publicSharingToken = publicSharingToken } connectSocket = () => { @@ -42,12 +42,16 @@ export class Portal { auth: { token, }, - transports: ['websocket', 'polling'], + transports: ['websocket'], timeout: 10000, }).connect() socket.on('connect_error', (error) => { - if (error && error.message && !error.message.includes('Authentication error')) { + if ( + error + && error.message + && !error.message.includes('Authentication error') + ) { this.handleConnectionError() } }) @@ -60,7 +64,9 @@ export class Portal { } handleConnectionError = () => { - alert('Failed to connect to the whiteboard server. Redirecting to Files app.') + alert( + 'Failed to connect to the whiteboard server. Redirecting to Files app.', + ) window.location.href = '/index.php/apps/files/files' } @@ -68,7 +74,9 @@ export class Portal { if (this.socket) { this.socket.disconnect() localStorage.removeItem(`jwt-${this.roomId}`) - console.log(`Disconnected from room ${this.roomId} and cleared JWT token`) + console.log( + `Disconnected from room ${this.roomId} and cleared JWT token`, + ) } } @@ -76,23 +84,34 @@ export class Portal { this.socket = socket this.socket?.on('connect_error', async (error) => { - if (error && error.message && error.message.includes('Authentication error')) { + if ( + error + && error.message + && error.message.includes('Authentication error') + ) { await this.handleTokenRefresh() } }) this.socket.on('read-only', () => this.handleReadOnlySocket()) this.socket.on('init-room', () => this.handleInitRoom()) - this.socket.on('room-user-change', (users: { - user: { - id: string, - name: string - }, - socketId: string, - pointer: { x: number, y: number, tool: 'pointer' | 'laser' }, - button: 'down' | 'up', - selectedElementIds: AppState['selectedElementIds'] - }[]) => this.collab.updateCollaborators(users)) - this.socket.on('client-broadcast', (data) => this.handleClientBroadcast(data)) + this.socket.on( + 'room-user-change', + ( + users: { + user: { + id: string + name: string + } + socketId: string + pointer: { x: number; y: number; tool: 'pointer' | 'laser' } + button: 'down' | 'up' + selectedElementIds: AppState['selectedElementIds'] + }[], + ) => this.collab.updateCollaborators(users), + ) + this.socket.on('client-broadcast', (data) => + this.handleClientBroadcast(data), + ) } async handleReadOnlySocket() { @@ -111,7 +130,8 @@ export class Portal { this.socket?.emit('join-room', this.roomId) this.socket?.on('joined-data', (data) => { const remoteElements = JSON.parse(new TextDecoder().decode(data)) - const reconciledElements = this.collab._reconcileElements(remoteElements) + const reconciledElements + = this.collab._reconcileElements(remoteElements) this.collab.handleRemoteSceneUpdate(reconciledElements) this.collab.scrollToContent() }) @@ -136,61 +156,82 @@ export class Portal { async refreshJWT(): Promise { try { - const response = await axios.get(`/index.php/apps/whiteboard/${this.roomId}/token`, { withCredentials: true }) - const token = response.data.token - if (!token) throw new Error('No token received') + let url = `/index.php/apps/whiteboard/${this.roomId}/token` + if (this.publicSharingToken) { + url += `?publicSharingToken=${encodeURIComponent(this.publicSharingToken)}` + } + + const response = await axios.get(url, { withCredentials: true }) + + const token = response.data.token + + console.log('token', token) - localStorage.setItem(`jwt-${this.roomId}`, token) + if (!token) throw new Error('No token received') - return token + localStorage.setItem(`jwt-${this.roomId}`, token) + + return token } catch (error) { - console.error('Error refreshing JWT:', error) - window.location.href = '/index.php/apps/files/files' - return null + console.error('Error refreshing JWT:', error) + window.location.href = '/index.php/apps/files/files' + return null } - } - - async _broadcastSocketData(data: { - type: string; - payload: { - elements?: readonly ExcalidrawElement[]; - socketId?: string; - pointer?: { x: number; y: number; tool: 'pointer' | 'laser' }; - button?: 'down' | 'up'; - selectedElementIds?: AppState['selectedElementIds']; - username?: string; - }; - }, volatile: boolean = false, roomId?: string) { + } + async _broadcastSocketData( + data: { + type: string + payload: { + elements?: readonly ExcalidrawElement[] + socketId?: string + pointer?: { x: number; y: number; tool: 'pointer' | 'laser' } + button?: 'down' | 'up' + selectedElementIds?: AppState['selectedElementIds'] + username?: string + } + }, + volatile: boolean = false, + roomId?: string, + ) { const json = JSON.stringify(data) const encryptedBuffer = new TextEncoder().encode(json) - this.socket?.emit(volatile ? 'server-volatile-broadcast' : 'server-broadcast', roomId ?? this.roomId, encryptedBuffer, []) - + this.socket?.emit( + volatile ? 'server-volatile-broadcast' : 'server-broadcast', + roomId ?? this.roomId, + encryptedBuffer, + [], + ) } - async broadcastScene(updateType: string, elements: readonly ExcalidrawElement[]) { - await this._broadcastSocketData({ type: updateType, payload: { elements } }) + async broadcastScene( + updateType: string, + elements: readonly ExcalidrawElement[], + ) { + await this._broadcastSocketData({ + type: updateType, + payload: { elements }, + }) } async broadcastMouseLocation(payload: { - pointer: { x: number; y: number; tool: 'pointer' | 'laser' }; - button: 'down' | 'up'; - pointersMap: Gesture['pointers']; + pointer: { x: number; y: number; tool: 'pointer' | 'laser' } + button: 'down' | 'up' + pointersMap: Gesture['pointers'] }) { - const data = { type: BroadcastType.MouseLocation, payload: { socketId: this.socket?.id, pointer: payload.pointer, button: payload.button || 'up', - selectedElementIds: this.collab.excalidrawAPI.getAppState().selectedElementIds, + selectedElementIds: + this.collab.excalidrawAPI.getAppState().selectedElementIds, username: this.socket?.id, }, } await this._broadcastSocketData(data, true) - } } diff --git a/src/collaboration/collab.ts b/src/collaboration/collab.ts index bcefdda..4de31a4 100644 --- a/src/collaboration/collab.ts +++ b/src/collaboration/collab.ts @@ -13,13 +13,18 @@ import { hashElementsVersion, reconcileElements } from './util' export class Collab { excalidrawAPI: ExcalidrawImperativeAPI + fileId: number portal: Portal + publicSharingToken: string | null lastBroadcastedOrReceivedSceneVersion: number = -1 private collaborators = new Map() - constructor(excalidrawAPI: ExcalidrawImperativeAPI, fileId: number) { + constructor(excalidrawAPI: ExcalidrawImperativeAPI, fileId: number, publicSharingToken: string | null) { this.excalidrawAPI = excalidrawAPI - this.portal = new Portal(String(fileId), '1', this) + this.fileId = fileId + this.publicSharingToken = publicSharingToken + + this.portal = new Portal(`${fileId}`, this, publicSharingToken) } async startCollab() { diff --git a/src/main.tsx b/src/main.tsx index 942fd83..070c430 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,72 +4,141 @@ */ import { linkTo } from '@nextcloud/router' -import { StrictMode, lazy } from 'react' +import { StrictMode, lazy, Suspense } from 'react' import { createRoot } from 'react-dom' +import { loadState } from '@nextcloud/initial-state' import './viewer.css' -window.EXCALIDRAW_ASSET_PATH = linkTo('whiteboard', 'dist/') - -const Component = { - name: 'Whiteboard', - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - render(createElement: (arg0: string, arg1: { attrs: { id: string } }, arg2: string) => any) { - const App = lazy(() => import('./App')) - this.$emit('update:loaded', true) - const randomId = Math.random().toString(36).replace(/[^a-z]+/g, '').substr(2, 10) - this.$nextTick(() => { - const rootElement = document.getElementById('whiteboard-' + randomId) - this.root = createRoot(rootElement) - - this.root.render( - - - , - ) +const EXCALIDRAW_ASSET_PATH = linkTo('whiteboard', 'dist/') +const App = lazy(() => import('./App')) + +const generateRandomId = () => + Math.random() + .toString(36) + .replace(/[^a-z]+/g, '') + .substr(2, 10) + +const renderApp = (rootElement, props) => { + const root = createRoot(rootElement) + root.render( + + Loading...}> + + + , + ) + return root +} + +window.EXCALIDRAW_ASSET_PATH = EXCALIDRAW_ASSET_PATH + +const publicSharingToken + = document.getElementById('sharingToken')?.value || null + +if (publicSharingToken) { + handlePublicSharing(publicSharingToken) +} else { + handleNonPublicSharing() +} + +// Handler functions +function handlePublicSharing(token) { + const filesTable = document.querySelector('#preview table.files-filestable') + + if (filesTable) { + return + } + + const fileId = loadState('whiteboard', 'file_id') + + document.addEventListener('DOMContentLoaded', () => { + const imgframeElement = document.getElementById('preview') + if (!imgframeElement) { + console.error('#imgframe element not found') + return + } + + imgframeElement.innerHTML = '' + + const whiteboardElement = createWhiteboardElement() + imgframeElement.appendChild(whiteboardElement) + + renderApp(whiteboardElement, { + fileId, + isEmbedded: false, + fileName: document.title, + publicSharingToken: token, }) - return createElement('div', { - attrs: { - id: 'whiteboard-' + randomId, - }, - class: ['whiteboard', { 'whiteboard-viewer__embedding': this.isEmbedded }], - }, '') - }, - beforeDestroy() { - this.root?.unmount() - }, - props: { - filename: { - type: String, - default: null, + }) +} + +function handleNonPublicSharing() { + const Component = createWhiteboardComponent() + + if (typeof OCA.Viewer !== 'undefined') { + registerViewerHandler(Component) + } else { + alert('UNDEFINED') + } +} + +function createWhiteboardElement() { + const element = document.createElement('div') + element.id = `whiteboard-${generateRandomId()}` + element.className = 'whiteboard' + return element +} + +function createWhiteboardComponent() { + return { + name: 'Whiteboard', + render(createElement) { + this.$emit('update:loaded', true) + const randomId = generateRandomId() + + this.$nextTick(() => { + const rootElement = document.getElementById( + `whiteboard-${randomId}`, + ) + this.root = renderApp(rootElement, { + fileId: this.fileid, + isEmbedded: this.isEmbedded, + fileName: this.basename, + }) + }) + + return createElement( + 'div', + { + attrs: { id: `whiteboard-${randomId}` }, + class: [ + 'whiteboard', + { 'whiteboard-viewer__embedding': this.isEmbedded }, + ], + }, + '', + ) }, - fileid: { - type: Number, - default: null, + beforeDestroy() { + this.root?.unmount() }, - isEmbedded: { - type: Boolean, - default: false, + props: { + filename: { type: String, default: null }, + fileid: { type: Number, default: null }, + isEmbedded: { type: Boolean, default: false }, }, - }, - data() { - return { - root: null, - } - }, + data: () => ({ root: null }), + } } -if (typeof OCA.Viewer !== 'undefined') { +function registerViewerHandler(Component) { window.OCA.Viewer.registerHandler({ id: 'whiteboard', - mimes: [ - 'application/vnd.excalidraw+json', - ], + mimes: ['application/vnd.excalidraw+json'], component: Component, group: null, theme: 'default', canCompare: true, }) -} else { - alert('UNDEFINED') }