Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recording #307

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
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
15 changes: 15 additions & 0 deletions lib/Consts/RecordingConsts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

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

namespace OCA\Whiteboard\Consts;

final class RecordingConsts {
public const RECORDINGS_FOLDER = 'Whiteboard Recordings';
public const RECORDING_JWT_EXPIRATION_TIME = 24 * 60 * 60; // 24 hours
}
196 changes: 196 additions & 0 deletions lib/Controller/RecordingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?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\Consts\RecordingConsts;
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;

/**
* @psalm-suppress MissingDependency
* @psalm-suppress PossiblyInvalidArgument
* @psalm-suppress UndefinedInterfaceMethod
* @psalm-suppress UndefinedDocblockClass
* @psalm-suppress ArgumentTypeCoercion
*/
final class RecordingController extends Controller {
public function __construct(
IRequest $request,
private IInitialState $initialState,
private ConfigService $configService,
private AuthenticateUserServiceFactory $authenticateUserServiceFactory,
private GetFileServiceFactory $getFileServiceFactory,
private JWTService $jwtService,
private IRootFolder $rootFolder,
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);
$content = $this->readUploadedFileContent($uploadedFile);

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

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
* @throws NotFoundException
*/
private function getOrCreateRecordingsFolder(string $userId): Node {
$userFolder = $this->rootFolder->getUserFolder($userId);
if (!$userFolder->nodeExists(RecordingConsts::RECORDINGS_FOLDER)) {
$userFolder->newFolder(RecordingConsts::RECORDINGS_FOLDER);
}
return $userFolder->get(RecordingConsts::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);
}
}
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 getNCUserId(): string {
return $this->userId;
}
}
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->getNCUserId(), $fileId);
}

throw new InvalidUserException();
}
}
Loading
Loading