Skip to content

Commit

Permalink
merge main
Browse files Browse the repository at this point in the history
  • Loading branch information
hweihwang committed Jan 28, 2025
1 parent 7c99784 commit 3d38fc3
Show file tree
Hide file tree
Showing 24 changed files with 5,529 additions and 1,808 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
/vendor/
/node_modules/
/backup/
/recordings/

.php-cs-fixer.cache
.phpunit.result.cache
Expand Down
4 changes: 4 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@
['name' => 'Whiteboard#show', 'url' => '{fileId}', 'verb' => 'GET'],
/** @see SettingsController::update() */
['name' => 'Settings#update', 'url' => 'settings', 'verb' => 'POST'],
/** @see RecordingController::recording() */
['name' => 'Recording#recording', 'url' => 'recording/{fileId}/{userId}', 'verb' => 'GET'],
/** @see RecordingController::upload() */
['name' => 'Recording#upload', 'url' => 'recording/{fileId}/{userId}/upload', 'verb' => 'POST'],
]
];
6 changes: 6 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
use OCP\IL10N;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
use OCP\Util;
use Psr\Container\ContainerExceptionInterface;
use Throwable;

/**
* @psalm-suppress UndefinedClass
Expand All @@ -46,6 +48,10 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
}

/**
* @throws ContainerExceptionInterface
* @throws Throwable
*/
public function boot(IBootContext $context): void {
[$major] = Util::getVersion();
if ($major < 30) {
Expand Down
190 changes: 190 additions & 0 deletions lib/Controller/RecordingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Whiteboard\Controller;

use InvalidArgumentException;
use OC\User\NoUserException;
use OCA\Whiteboard\Model\User;
use OCA\Whiteboard\Service\Authentication\AuthenticateUserServiceFactory;
use OCA\Whiteboard\Service\ConfigService;
use OCA\Whiteboard\Service\File\GetFileServiceFactory;
use OCA\Whiteboard\Service\JWTService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\Template\PublicTemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\Util;
use RuntimeException;
use Throwable;

final class RecordingController extends Controller {
private const RECORDINGS_FOLDER = 'Whiteboard Recordings';

public function __construct(
IRequest $request,
private IInitialState $initialState,
private ConfigService $configService,
private AuthenticateUserServiceFactory $authenticateUserServiceFactory,
private GetFileServiceFactory $getFileServiceFactory,
private JWTService $jwtService,
private IRootFolder $rootFolder,

Check failure on line 47 in lib/Controller/RecordingController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MissingDependency

lib/Controller/RecordingController.php:47:3: MissingDependency: OCP\Files\IRootFolder depends on class or interface oc\hooks\emitter that does not exist (see https://psalm.dev/157)
private IURLGenerator $urlGenerator,
) {
parent::__construct('whiteboard', $request);
}

/**
* @NoCSRFRequired
* @NoAdminRequired
* @PublicPage
*/
public function recording(int $fileId, string $userId): Http\TemplateResponse {
try {
$sharedToken = $this->validateSharedToken();
$user = $this->authenticateRecordingAgent($fileId, $userId, $sharedToken);
$jwt = $this->generateRecordingJWT($user, $fileId);

$this->initializeRecordingState($fileId, $jwt);
return $this->createRecordingResponse();
} catch (Throwable $e) {
return new Http\TemplateResponse($this->appName, 'recording', [], Http\TemplateResponse::RENDER_AS_BLANK);
}
}

/**
* @NoCSRFRequired
* @NoAdminRequired
* @PublicPage
*/
public function upload(int $fileId, string $userId): Response {
try {
$sharedToken = $this->validateSharedToken();
$user = $this->authenticateRecordingAgent($fileId, $userId, $sharedToken);
$fileService = $this->getFileServiceFactory->create($user, $fileId);

$uploadedFile = $this->validateUploadedFile();
$recordingsFolder = $this->getOrCreateRecordingsFolder($userId);

$filename = $this->generateRecordingFilename($fileService->getFile()->getName() ?: $fileId);

Check failure on line 85 in lib/Controller/RecordingController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

PossiblyInvalidArgument

lib/Controller/RecordingController.php:85:49: PossiblyInvalidArgument: Argument 1 of OCA\Whiteboard\Controller\RecordingController::generateRecordingFilename expects string, but possibly different type int|non-falsy-string provided (see https://psalm.dev/092)
$content = $this->readUploadedFileContent($uploadedFile);

$file = $recordingsFolder->newFile($filename, $content);

Check failure on line 88 in lib/Controller/RecordingController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/Controller/RecordingController.php:88:31: UndefinedInterfaceMethod: Method OCP\Files\Node::newFile does not exist (see https://psalm.dev/181)

return new JSONResponse([
'status' => 'success',
'filename' => $filename,
'fileUrl' => $this->urlGenerator->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $file->getId()]),
]);
} catch (InvalidArgumentException $e) {
return $this->handleError($e, Http::STATUS_BAD_REQUEST);
} catch (Throwable $e) {
return $this->handleError($e, Http::STATUS_INTERNAL_SERVER_ERROR);
}
}

private function validateSharedToken(): string {
$sharedToken = $this->request->getParam('token');
if (!$sharedToken) {
throw new InvalidArgumentException('Shared token is required');
}
return $sharedToken;
}

private function authenticateRecordingAgent(int $fileId, string $userId, string $sharedToken): User {
$recordingParams = [
'fileId' => (string)$fileId,
'userId' => $userId,
'sharedToken' => $sharedToken,
];
return $this->authenticateUserServiceFactory->create(null, $recordingParams)->authenticate();
}

/**
* @throws NotFoundException
* @throws InvalidPathException
*/
private function generateRecordingJWT($user, int $fileId): string {
$fileService = $this->getFileServiceFactory->create($user, $fileId);
$file = $fileService->getFile();
return $this->jwtService->generateJWT($user, $file);
}

private function initializeRecordingState(int $fileId, string $jwt): void {
$this->initialState->provideInitialState('isRecording', true);
$this->initialState->provideInitialState('file_id', $fileId);
$this->initialState->provideInitialState('jwt', $jwt);
$this->initialState->provideInitialState('collabBackendUrl', $this->configService->getCollabBackendUrl());
}

private function createRecordingResponse(): PublicTemplateResponse {
$csp = new ContentSecurityPolicy();
$csp->allowEvalScript();

$response = new PublicTemplateResponse($this->appName, 'recording');
$response->setFooterVisible();
$response->setContentSecurityPolicy($csp);

Util::addScript('whiteboard', 'whiteboard-main');
Util::addStyle('whiteboard', 'whiteboard-main');

return $response;
}

private function validateUploadedFile(): array {
$uploadedFile = $this->request->getUploadedFile('recording');
if ($uploadedFile === null || !isset($uploadedFile['tmp_name'])) {
throw new InvalidArgumentException('No recording file uploaded');
}
return $uploadedFile;
}

/**
* @throws NotPermittedException
* @throws NoUserException

Check failure on line 160 in lib/Controller/RecordingController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedDocblockClass

lib/Controller/RecordingController.php:160:13: UndefinedDocblockClass: Docblock-defined class, interface or enum named OC\User\NoUserException does not exist (see https://psalm.dev/200)
* @throws NotFoundException
*/
private function getOrCreateRecordingsFolder(string $userId): Node {
$userFolder = $this->rootFolder->getUserFolder($userId);

Check failure on line 164 in lib/Controller/RecordingController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MissingDependency

lib/Controller/RecordingController.php:164:17: MissingDependency: OCP\Files\IRootFolder depends on class or interface oc\hooks\emitter that does not exist (see https://psalm.dev/157)
if (!$userFolder->nodeExists(self::RECORDINGS_FOLDER)) {
$userFolder->newFolder(self::RECORDINGS_FOLDER);
}
return $userFolder->get(self::RECORDINGS_FOLDER);
}

private function generateRecordingFilename(string $filename): string {
$sanitizedName = preg_replace('/[^a-zA-Z0-9_\- ]/', '_', pathinfo($filename, PATHINFO_FILENAME));
return sprintf('%s (%s).webm', $sanitizedName, date('Y-m-d H:i'));
}

private function readUploadedFileContent(array $uploadedFile): string {
$content = file_get_contents($uploadedFile['tmp_name']);
if ($content === false) {
throw new RuntimeException('Failed to read uploaded file');
}
return $content;
}

private function handleError(Throwable $e, int $status): JSONResponse {
return new JSONResponse([
'status' => 'error',
'message' => $e->getMessage(),
], $status);

Check failure on line 188 in lib/Controller/RecordingController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

ArgumentTypeCoercion

lib/Controller/RecordingController.php:188:6: ArgumentTypeCoercion: Argument 2 of OCP\AppFramework\Http\JSONResponse::__construct expects 100|101|102|200|201|202|203|204|205|206|207|208|226|300|301|302|303|304|305|306|307|400|401|402|403|404|405|406|407|408|409|410|411|412|413|414|415|416|417|418|422|423|424|426|428|429|431|500|501|502|503|504|505|506|507|508|509|510|511, but parent type int provided (see https://psalm.dev/193)
}
}
39 changes: 39 additions & 0 deletions lib/Model/RecordingAgent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Whiteboard\Model;

final class RecordingAgent implements User {
public function __construct(
private string $fileId,
private string $userId,
private string $sharedToken,
) {
}

public function getUID(): string {
return 'recording_agent_' . $this->fileId . '_' . $this->userId;
}

public function getDisplayName(): string {
return 'Recording Agent ' . $this->fileId . ' for ' . $this->userId;
}

public function getFileId(): string {
return $this->fileId;
}

public function getSharedToken(): string {
return $this->sharedToken;
}

public function getUserId(): string {
return $this->userId;
}
}
47 changes: 47 additions & 0 deletions lib/Service/Authentication/AuthenticateRecordingAgentService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Whiteboard\Service\Authentication;

use OCA\Whiteboard\Exception\UnauthorizedException;
use OCA\Whiteboard\Model\RecordingAgent;
use OCA\Whiteboard\Model\User;
use OCA\Whiteboard\Service\ConfigService;

final class AuthenticateRecordingAgentService implements AuthenticateUserService {
public function __construct(
private ConfigService $configService,
private string $fileId,
private string $userId,
private string $sharedToken,
) {
}

public function authenticate(): User {
if (!$this->verifySharedToken()) {
throw new UnauthorizedException('Invalid recording agent token');
}

return new RecordingAgent($this->fileId, $this->userId, $this->sharedToken);
}

private function verifySharedToken(): bool {
[$roomId, $timestamp, $signature] = explode(':', $this->sharedToken);

if ($roomId !== $this->fileId) {
return false;
}

$sharedSecret = $this->configService->getWhiteboardSharedSecret();
$payload = "$roomId:$timestamp";
$expectedSignature = hash_hmac('sha256', $payload, $sharedSecret);

return hash_equals($expectedSignature, $signature);
}
}
13 changes: 12 additions & 1 deletion lib/Service/Authentication/AuthenticateUserServiceFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,35 @@

namespace OCA\Whiteboard\Service\Authentication;

use OCA\Whiteboard\Service\ConfigService;
use OCP\IUserSession;
use OCP\Share\IManager as ShareManager;

final class AuthenticateUserServiceFactory {
public function __construct(
private ShareManager $shareManager,
private IUserSession $userSession,
private ConfigService $configService,
) {
}

public function create(?string $publicSharingToken): AuthenticateUserService {
public function create(?string $publicSharingToken, ?array $recordingParams = null): AuthenticateUserService {
$authServices = [
//Favor the public sharing token over the session,
//session users sometimes don't have the right permissions
new AuthenticatePublicSharingUserService($this->shareManager, $publicSharingToken),
new AuthenticateSessionUserService($this->userSession),
];

if ($recordingParams !== null) {
$authServices[] = new AuthenticateRecordingAgentService(
$this->configService,
$recordingParams['fileId'],
$recordingParams['userId'],
$recordingParams['sharedToken']
);
}

return new ChainAuthenticateUserService($authServices);
}
}
5 changes: 5 additions & 0 deletions lib/Service/File/GetFileServiceFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use OCA\Whiteboard\Exception\InvalidUserException;
use OCA\Whiteboard\Model\AuthenticatedUser;
use OCA\Whiteboard\Model\PublicSharingUser;
use OCA\Whiteboard\Model\RecordingAgent;
use OCA\Whiteboard\Model\User;
use OCP\Files\IRootFolder;
use OCP\Share\IManager as ShareManager;
Expand All @@ -37,6 +38,10 @@ public function create(User $user, int $fileId): GetFileService {
return new GetFileFromPublicSharingTokenService($this->shareManager, $user->getPublicSharingToken(), $fileId);
}

if ($user instanceof RecordingAgent) {
return new GetFileFromIdService($this->rootFolder, $user->getUserId(), $fileId);
}

throw new InvalidUserException();
}
}
Loading

0 comments on commit 3d38fc3

Please sign in to comment.