crypt->readHash($doc); if ($hashData === null) { $this->logger->error("Download with empty or not correct hash: $error"); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } if ($hashData->action !== "download") { $this->logger->error("Download with other action"); return new JSONResponse(["message" => $this->trans->t("Invalid request")], Http::STATUS_BAD_REQUEST); } $fileId = $hashData->fileId; $version = $hashData->version ?? 0; $changes = $hashData->changes ?? false; $template = $hashData->template ?? false; $filePath = $hashData->filePath ?? ""; $this->logger->debug("Download: $fileId ($version)" . ($changes ? " changes" : "")); if (!empty($this->appConfig->getDocumentServerSecret())) { $header = $this->request->getHeader($this->appConfig->jwtHeader()); if (empty($header)) { $this->logger->error("Download without jwt"); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } $header = substr((string) $header, strlen("Bearer ")); try { $decodedHeader = \Firebase\JWT\JWT::decode($header, new \Firebase\JWT\Key($this->appConfig->getDocumentServerSecret(), "HS256")); } catch (\UnexpectedValueException $e) { $this->logger->error("Download with invalid jwt", ["exception" => $e]); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } } $userId = null; $user = null; if ($this->userSession->isLoggedIn()) { $this->logger->debug("Download: by $userId instead of " . $hashData->userId); } $this->setupManager->tearDown(); if (isset($hashData->userId)) { $userId = $hashData->userId; $user = $this->userManager->get($userId); if (!empty($user)) { \OC_User::setUserId($userId); $this->setupManager->setupForUser($user); } } $shareToken = $hashData->shareToken ?? null; [$file, $error, $share] = empty($shareToken) ? $this->getFile($userId, $fileId, $filePath, $changes ? 0 : $version, $template) : $this->getFileByToken($fileId, $shareToken, $changes ? 0 : $version); if (isset($error)) { return $error; } $canDownload = true; $fileStorage = $file->getStorage(); if ($fileStorage->instanceOfStorage(\OCA\Files_Sharing\SharedStorage::class) || !empty($shareToken)) { $share = empty($share) ? $fileStorage->getShare() : $share; $canDownload = FileUtility::canShareDownload($share); if (!$canDownload && !empty($this->appConfig->getDocumentServerSecret())) { $canDownload = true; } } if ((!empty($user) && !$file->isReadable()) || !$canDownload) { if ($this->userSession->getUser()?->getUID() != $userId) { $this->logger->error("Download error: expected $userId instead of " . $this->userSession->getUser()?->getUID()); } $this->logger->error("Download without access right"); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } if (empty($user)) { $owner = $file->getFileInfo()->getOwner(); if ($owner !== null) { $this->setupManager->setupForUser($owner); } } if ($changes) { if (!$this->versionManager instanceof IVersionManager) { $this->logger->error("Download changes: versionManager is null"); return new JSONResponse(["message" => $this->trans->t("Invalid request")], Http::STATUS_BAD_REQUEST); } $owner = $file->getFileInfo()->getOwner(); if ($owner === null) { $this->logger->error("Download: changes owner of $fileId was not found"); return new JSONResponse(["message" => $this->trans->t("Files not found")], Http::STATUS_NOT_FOUND); } $versions = FileVersions::processVersionsArray($this->versionManager->getVersionsForFile($owner, $file)); $versionId = null; if ($version > count($versions)) { $versionId = $file->getFileInfo()->getMtime(); } else { $fileVersion = array_values($versions)[$version - 1]; $versionId = $fileVersion->getRevisionId(); } $changesFile = FileVersions::getChangesFile($owner->getUID(), $file->getFileInfo(), $versionId); if (!$changesFile instanceof \OC\Files\Node\File) { $this->logger->error("Download: changes $fileId ($version) was not found"); return new JSONResponse(["message" => $this->trans->t("Files not found")], Http::STATUS_NOT_FOUND); } $file = $changesFile; } try { $handle = $file->fopen('rb'); if ($handle !== false && $handle !== null) { $response = new StreamResponse($handle); $response->addHeader('Content-Disposition', 'attachment; filename="' . rawurldecode((string) $file->getName()) . '"'); $response->addHeader('Content-Type', $file->getMimeType()); return $response; } } catch (NotPermittedException $e) { $this->logger->error("Download Not permitted: $fileId ($version)", ["exception" => $e]); return new JSONResponse(["message" => $this->trans->t("Not permitted")], Http::STATUS_FORBIDDEN); } return new JSONResponse(["message" => $this->trans->t("Download failed")], Http::STATUS_INTERNAL_SERVER_ERROR); } /** * Downloading empty file by the document service * * @param string $doc - verification token with the file identifier * * @return DataDownloadResponse|JSONResponse */ #[CORS] #[NoAdminRequired] #[NoCSRFRequired] #[PublicPage] public function emptyfile(string $doc): DataDownloadResponse|JSONResponse { $this->logger->debug("Download empty"); [$hashData, $error] = $this->crypt->readHash($doc); if ($hashData === null) { $this->logger->error("Download empty with empty or not correct hash: $error"); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } if ($hashData->action !== "empty") { $this->logger->error("Download empty with other action"); return new JSONResponse(["message" => $this->trans->t("Invalid request")], Http::STATUS_BAD_REQUEST); } if (!empty($this->appConfig->getDocumentServerSecret())) { $header = $this->request->getHeader($this->appConfig->jwtHeader()); if (empty($header)) { $this->logger->error("Download empty without jwt"); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } $header = substr((string) $header, strlen("Bearer ")); try { $decodedHeader = \Firebase\JWT\JWT::decode($header, new \Firebase\JWT\Key($this->appConfig->getDocumentServerSecret(), "HS256")); } catch (\UnexpectedValueException $e) { $this->logger->error("Download empty with invalid jwt", ["exception" => $e]); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } } $templatePath = TemplateManager::getEmptyTemplatePath("default", ".docx"); $template = file_get_contents($templatePath); if (!$template) { $this->logger->info("Template for download empty not found: $templatePath"); return new JSONResponse(["message" => $this->trans->t("File not found")], Http::STATUS_NOT_FOUND); } try { return new DataDownloadResponse($template, "new.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); } catch (NotPermittedException $e) { $this->logger->error("Download Not permitted", ["exception" => $e]); return new JSONResponse(["message" => $this->trans->t("Not permitted")], Http::STATUS_FORBIDDEN); } return new JSONResponse(["message" => $this->trans->t("Download failed")], Http::STATUS_INTERNAL_SERVER_ERROR); } /** * Handle request from the document server with the document status information * * @param string $doc verification token with the file identifier * @param string $key the edited document identifier * @param int $status the edited status * @param array $actions the array of action * @param array $users the list of the identifiers of the users * @param string $changesurl link to file changes * @param string $filetype extension of the document that is downloaded from the link specified with the url parameter * @param mixed $forcesavetype the type of force save action * @param array $history file history * @param string $url the link to the edited document to be saved * @param string $token request signature * * @return JSONResponse */ #[CORS] #[NoAdminRequired] #[NoCSRFRequired] #[PublicPage] public function track( string $doc, string $key, int $status, ?array $actions = null, ?array $users = null, ?string $changesurl = null, ?string $filetype = null, ?int $forcesavetype = null, ?array $history = null, ?string $url = null, ?string $token = null ): JSONResponse { [$hashData, $error] = $this->crypt->readHash($doc); if ($hashData === null) { $this->logger->error("Track with empty or not correct hash: $error"); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } if ($hashData->action !== "track") { $this->logger->error("Track with other action"); return new JSONResponse(["message" => $this->trans->t("Invalid request")], Http::STATUS_BAD_REQUEST); } $fileId = $hashData->fileId; $this->logger->debug("Track: $fileId status $status"); if (!empty($this->appConfig->getDocumentServerSecret())) { if (!empty($token)) { try { $payload = \Firebase\JWT\JWT::decode($token, new \Firebase\JWT\Key($this->appConfig->getDocumentServerSecret(), "HS256")); } catch (\UnexpectedValueException $e) { $this->logger->error("Track with invalid jwt in body", ["exception" => $e]); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } } else { $header = $this->request->getHeader($this->appConfig->jwtHeader()); if (empty($header)) { $this->logger->error("Track without jwt"); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } $header = substr((string) $header, strlen("Bearer ")); try { $decodedHeader = \Firebase\JWT\JWT::decode($header, new \Firebase\JWT\Key($this->appConfig->getDocumentServerSecret(), "HS256")); $payload = $decodedHeader->payload; } catch (\UnexpectedValueException $e) { $this->logger->error("Track with invalid jwt", ["exception" => $e]); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } } $users = $payload->users ?? null; $key = $payload->key; $status = $payload->status; $url = $payload->url ?? null; $mailMergeData = $payload->mailMerge ?? null; } $shareToken = $hashData->shareToken ?? null; $filePath = $hashData->filePath; $this->setupManager->tearDown(); $isForcesave = $status === self::TRACKERSTATUS_FORCESAVE || $status === self::TRACKERSTATUS_CORRUPTEDFORCESAVE; $callbackUserId = $hashData->userId; $userId = null; if (!empty($users) && $status !== self::TRACKERSTATUS_EDITING) { // author of the latest changes $userId = $this->parseUserId($users[0]); } else { $userId = $callbackUserId; } if ($isForcesave && $forcesavetype === 1 && !empty($actions)) { // the user who clicked Save $userId = $this->parseUserId($actions[0]["userid"]); } $user = $this->userManager->get($userId); if (!empty($user)) { \OC_User::setUserId($userId); } else { if (empty($shareToken)) { $this->logger->error("Track without token: $fileId status $status"); return new JSONResponse(["message" => $this->trans->t("Access denied")], Http::STATUS_FORBIDDEN); } $this->logger->debug("Track $fileId by token for $userId"); } // owner of file from the callback link $ownerId = $hashData->userId; $owner = $this->userManager->get($ownerId); if (!empty($owner)) { $userId = $ownerId; } else { $callbackUser = $this->userManager->get($callbackUserId); if (!empty($callbackUser)) { // author of the callback link $userId = $callbackUserId; // path for author of the callback link $filePath = $hashData->filePath; } } if (!empty($userId) && empty($shareToken) && ($setupUser = $this->userManager->get($userId))) { $this->setupManager->setupForUser($setupUser); } [$file, $error, $share] = empty($shareToken) ? $this->getFile($userId, $fileId, $filePath) : $this->getFileByToken($fileId, $shareToken); if (isset($error)) { $this->logger->error("track error $fileId " . json_encode($error->getData())); return $error; } $result = 1; switch ($status) { case self::TRACKERSTATUS_MUSTSAVE: case self::TRACKERSTATUS_CORRUPTED: case self::TRACKERSTATUS_FORCESAVE: case self::TRACKERSTATUS_CORRUPTEDFORCESAVE: if (empty($url)) { $this->logger->error("Track without url: $fileId status $status"); return new JSONResponse(["message" => "Url not found"], Http::STATUS_BAD_REQUEST); } try { $url = $this->appConfig->replaceDocumentServerUrlToInternal($url); $prevVersion = $file->getFileInfo()->getMtime(); $fileName = $file->getName(); $curExt = strtolower(pathinfo((string) $fileName, PATHINFO_EXTENSION)); $downloadExt = $filetype; if ($downloadExt !== $curExt) { $key = DocumentService::generateRevisionId($fileId . $url); try { $this->logger->debug("Converted from $downloadExt to $curExt"); $url = $this->documentService->getConvertedUri($url, $downloadExt, $curExt, $key); } catch (\Exception $e) { $this->logger->error("Converted on save error", ["exception" => $e]); return new JSONResponse(["message" => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); } } $newData = $this->documentService->request($url); $prevIsForcesave = $this->keyManager->wasForcesave($fileId); if (RemoteInstance::isRemoteFile($file)) { $isLock = RemoteInstance::lockRemoteKey($file, $isForcesave, false); if ($isForcesave && !$isLock) { break; } } else { $this->keyManager->lock($fileId, $isForcesave); } $this->logger->debug("Track put content " . $file->getPath()); $retryOperation = function () use ($file, $newData): void { $this->retryOperation(fn() => $file->putContent($newData)); }; try { $lockContext = new LockContext($file, ILock::TYPE_APP, $this->appName); $this->lockManager->runInScope($lockContext, $retryOperation); } catch (NoLockProviderException $e) { $retryOperation(); } if (!$isForcesave) { $this->unlock($file); } if (RemoteInstance::isRemoteFile($file)) { if ($isForcesave) { RemoteInstance::lockRemoteKey($file, false, $isForcesave); } } else { $this->keyManager->lock($fileId, false); $this->keyManager->setForcesave($fileId, $isForcesave); } if (!$isForcesave && !$prevIsForcesave && $this->versionManager instanceof IVersionManager && $this->appConfig->getVersionHistory()) { $changes = null; if (!empty($changesurl)) { $changesurl = $this->appConfig->replaceDocumentServerUrlToInternal($changesurl); try { $changes = $this->documentService->request($changesurl); } catch (\Exception $e) { $this->logger->error("Failed to download changes", ["exception" => $e]); } } FileVersions::saveHistory($file->getFileInfo(), $history, $changes, $prevVersion); } if (!empty($user) && $this->appConfig->getVersionHistory()) { FileVersions::saveAuthor($file->getFileInfo(), $user); } $result = 0; } catch (\Exception | \Error $e) { $this->logger->error("Track: $fileId status $status error", ["exception" => $e]); // if ($status === self::TRACKERSTATUS_MUSTSAVE) { // $this->eventDispatcher->dispatchTyped(new DocumentUnsavedEvent($userId, $fileId, $file->getName())); // } } break; case self::TRACKERSTATUS_MAILMERGE: $mailMergeStatusMessage = null; if (empty($url)) { $this->logger->error("MailMerge: file url cannot be empty"); return new JSONResponse(["message" => "Url not found"], Http::STATUS_BAD_REQUEST); } if (empty($user)) { $this->logger->error("MailMerge: user not found"); return new JSONResponse(["message" => "User not found"], Http::STATUS_BAD_REQUEST); } if ($mailMergeData === null) { $this->logger->error("MailMerge: data cannot be empty"); return new JSONResponse(["message" => "MailMerge data not found"], Http::STATUS_BAD_REQUEST); } try { $url = $this->appConfig->replaceDocumentServerUrlToInternal($url); $data = $this->documentService->request($url); $isHtml = $mailMergeData->type === self::TYPE_HTML; $bodyPlain = $mailMergeData->message ?? ''; $bodyHtml = $isHtml ? $data : ''; $mailMergeMessage = (new MailMergeMessage()) ->setFrom($mailMergeData->from) ->setTo($mailMergeData->to) ->setSubject($mailMergeData->subject) ->setBodyPlain($bodyPlain) ->setBodyHtml($bodyHtml); if (!$isHtml) { $attachmentExtension = $mailMergeData->type === self::TYPE_ATTACH_DOCX ? 'docx' : 'pdf'; $attachmentName = pathinfo($mailMergeData->title ?? 'Attach', PATHINFO_FILENAME) . ".$attachmentExtension"; $mailMergeAttachment = (new MailMergeAttachment()) ->setName($attachmentName) ->setExtension($attachmentExtension) ->setContent($data); $mailMergeMessage->setAttachment($mailMergeAttachment); } $this->mailMergeService->send($userId, $mailMergeMessage); $recordIndex = $mailMergeData->recordIndex + 1; $this->logger->info( "DocService MailMerge {$recordIndex}/{$mailMergeData->recordCount}" ); } catch (\Exception $e) { $recordCounterString = empty($mailMergeData) ? "" : " {$mailMergeData->recordIndex}/{$mailMergeData->recordCount} "; $userIdString = $userId ?? ""; $urlString = $url ?? ""; $this->logger->error( "DocService MailMerge$recordCounterString error: userId - $userIdString, url - $urlString", ['exception' => $e] ); $mailMergeStatusMessage = $e->getMessage(); } if ($mailMergeData !== null && $mailMergeData->recordIndex === ($mailMergeData->recordCount - 1)) { $errorCount = !$mailMergeStatusMessage ? $mailMergeData->recordErrorCount : $mailMergeData->recordErrorCount + 1; $mailMergeEndedEvent = new MailMergeEndedEvent( $userId, $mailMergeData->from, $mailMergeData->recordCount, $errorCount ); $this->eventDispatcher->dispatchTyped($mailMergeEndedEvent); } if ($mailMergeStatusMessage) { return new JSONResponse( ["error" => 1, "message" => $mailMergeStatusMessage], Http::STATUS_BAD_REQUEST ); } $result = 0; break; case self::TRACKERSTATUS_EDITING: $this->lock($file); $result = 0; break; case self::TRACKERSTATUS_CLOSED: $this->unlock($file); $result = 0; break; } $this->logger->debug("Track: $fileId status $status result $result"); return new JSONResponse(["error" => $result], Http::STATUS_OK); } /** * Getting file by identifier * * @param string $userId - user identifier * @param integer $fileId - file identifier * @param string $filePath - file path * @param integer $version - file version * @param bool $template - file is template */ private function getFile( ?string $userId, ?int $fileId, ?string $filePath = null, int $version = 0, bool $template = false ): array { if (empty($fileId)) { return [null, new JSONResponse(["message" => $this->trans->t("FileId is empty")], Http::STATUS_BAD_REQUEST), null]; } try { $folder = $template ? TemplateManager::getGlobalTemplateDir() : $this->root->getUserFolder($userId); $files = $folder->getById($fileId); } catch (\Exception $e) { $this->logger->error("getFile: $fileId", ["exception" => $e]); return [null, new JSONResponse(["message" => $this->trans->t("Invalid request")], Http::STATUS_BAD_REQUEST), null]; } if (empty($files)) { $this->logger->error("Files not found: $fileId"); return [null, new JSONResponse(["message" => $this->trans->t("Files not found")], Http::STATUS_NOT_FOUND), null]; } $file = $files[0]; if (count($files) > 1 && !empty($filePath)) { $filePath = "/" . $userId . "/files" . $filePath; foreach ($files as $curFile) { if ($curFile->getPath() === $filePath) { $file = $curFile; break; } } } if (!($file instanceof File)) { $this->logger->error("File not found: $fileId"); return [null, new JSONResponse(["message" => $this->trans->t("File not found")], Http::STATUS_NOT_FOUND), null]; } if ($version > 0 && $this->versionManager instanceof IVersionManager) { $owner = $file->getOwner(); if ($owner !== null) { if ($owner->getUID() !== $userId) { [$file, $error, $share] = $this->getFile($owner->getUID(), $file->getId()); if (isset($error)) { return [null, $error, null]; } } $versions = FileVersions::processVersionsArray($this->versionManager->getVersionsForFile($owner, $file)); if ($version <= count($versions)) { $fileVersion = array_values($versions)[$version - 1]; $file = $this->versionManager->getVersionFile($owner, $file->getFileInfo(), $fileVersion->getRevisionId()); } } } return [$file, null, null]; } /** * Getting file by token * * @param integer $fileId - file identifier * @param string $shareToken - access token * @param integer $version - file version */ private function getFileByToken(int $fileId, string $shareToken, int $version = 0): array { [$share, $error] = $this->getShare($shareToken); if (isset($error)) { return [null, $error, null]; } try { $node = $share->getNode(); } catch (NotFoundException $e) { $this->logger->error("getFileByToken error", ["exception" => $e]); return [null, new JSONResponse(["message" => $this->trans->t("File not found")], Http::STATUS_NOT_FOUND), null]; } if ($node instanceof Folder) { try { $files = $node->getById($fileId); } catch (\Exception $e) { $this->logger->error("getFileByToken: $fileId", ["exception" => $e]); return [null, new JSONResponse(["message" => $this->trans->t("Invalid request")], Http::STATUS_NOT_FOUND), null]; } if (empty($files)) { $this->logger->error("getFileByToken Files not found: $fileId"); return [null, new JSONResponse(["message" => $this->trans->t("File not found")], Http::STATUS_NOT_FOUND), null]; } $file = $files[0]; } else { $file = $node; } if ($version > 0 && $this->versionManager instanceof IVersionManager) { $owner = $file->getFileInfo()->getOwner(); if ($owner !== null) { $versions = FileVersions::processVersionsArray($this->versionManager->getVersionsForFile($owner, $file)); if ($version <= count($versions)) { $fileVersion = array_values($versions)[$version - 1]; $file = $this->versionManager->getVersionFile($owner, $file->getFileInfo(), $fileVersion->getRevisionId()); } } } return [$file, null, $share]; } /** * Getting share by token * * @param string $shareToken - access token */ private function getShare(?string $shareToken): array { if (empty($shareToken)) { return [null, new JSONResponse(["message" => $this->trans->t("FileId is empty")], Http::STATUS_BAD_REQUEST)]; } $share = null; try { $share = $this->shareManager->getShareByToken($shareToken); } catch (ShareNotFound $e) { $this->logger->error("getShare error", ["exception" => $e]); $share = null; } if ($share === null || $share === false) { return [null, new JSONResponse(["message" => $this->trans->t("You do not have enough permissions to view the file")], Http::STATUS_FORBIDDEN)]; } return [$share, null]; } /** * Parse user identifier for current instance * * @param string $userId - unique user identifier */ private function parseUserId(string $userId): string { $instanceId = $this->appConfig->getSystemValue("instanceid", true); $instanceId .= "_"; if (str_starts_with($userId, $instanceId)) { return substr($userId, strlen($instanceId)); } return $userId; } /** * Lock file by lock provider if exists * * @param File $file - file */ private function lock(File $file): void { if (!$this->lockManager->isLockProviderAvailable()) { return; } $fileId = $file->getId(); try { if (empty($this->lockManager->getLocks($fileId))) { $this->lockManager->lock(new LockContext($file, ILock::TYPE_APP, $this->appName)); $this->logger->debug("$this->appName has locked file $fileId"); } } catch (PreConditionNotMetException | OwnerLockedException | NoLockProviderException) { } } /** * Unlock file by lock provider if exists * * @param File $file - file */ private function unlock(File $file): void { if (!$this->lockManager->isLockProviderAvailable()) { return; } $fileId = $file->getId(); try { $this->lockManager->unlock(new LockContext($file, ILock::TYPE_APP, $this->appName)); $this->logger->debug("$this->appName has unlocked file $fileId"); } catch (PreConditionNotMetException | NoLockProviderException) { } } /** * Retry operation if a LockedException occurred * Other exceptions will still be thrown * * @throws LockedException */ private function retryOperation(callable $operation) { $i = 0; while (true) { try { return $operation(); } catch (LockedException $e) { if (++$i === 4) { throw $e; } } usleep(500000); } } }