-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,7 @@ | |
/vendor/ | ||
/node_modules/ | ||
/backup/ | ||
/recordings/ | ||
|
||
.php-cs-fixer.cache | ||
.phpunit.result.cache | ||
|
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
|
||
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
|
||
$content = $this->readUploadedFileContent($uploadedFile); | ||
|
||
$file = $recordingsFolder->newFile($filename, $content); | ||
Check failure on line 88 in lib/Controller/RecordingController.php
|
||
|
||
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
|
||
* @throws NotFoundException | ||
*/ | ||
private function getOrCreateRecordingsFolder(string $userId): Node { | ||
$userFolder = $this->rootFolder->getUserFolder($userId); | ||
Check failure on line 164 in lib/Controller/RecordingController.php
|
||
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
|
||
} | ||
} |
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; | ||
} | ||
} |
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); | ||
} | ||
} |