-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: Hoang Pham <[email protected]>
- Loading branch information
Showing
25 changed files
with
5,548 additions
and
1,808 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
47 changes: 47 additions & 0 deletions
47
lib/Service/Authentication/AuthenticateRecordingAgentService.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.