* * @author Julius Härtl * * @license GNU AGPL version 3 or any later version * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * */ namespace OCA\Text\Service; use \InvalidArgumentException; use OCA\Text\Db\Session; use OCA\Text\Db\SessionMapper; use OCP\DirectEditing\IManager; use OCP\IRequest; use OCP\Lock\ILockingProvider; use function json_encode; use OC\Files\Node\File; use OCA\Text\Db\Document; use OCA\Text\Db\DocumentMapper; use OCA\Text\Db\Step; use OCA\Text\Db\StepMapper; use OCA\Text\DocumentHasUnsavedChangesException; use OCA\Text\DocumentSaveConflictException; use OCA\Text\VersionMismatchException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\Entity; use OCP\Constants; use OCP\Files\Folder; use OCP\Files\GenericFileException; use OCP\Files\IAppData; use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; use OCP\Files\Node; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\ICache; use OCP\ICacheFactory; use OCP\ILogger; use OCP\Lock\LockedException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager as ShareManager; class DocumentService { /** * Delay to wait for between autosave versions */ public const AUTOSAVE_MINIMUM_DELAY = 10; /** * @var string|null */ private $userId; /** * @var DocumentMapper */ private $documentMapper; /** * @var SessionMapper */ private $sessionMapper; /** * @var ILogger */ private $logger; /** * @var ShareManager */ private $shareManager; /** * @var StepMapper */ private $stepMapper; /** * @var IRootFolder */ private $rootFolder; /** * @var ICache */ private $cache; /** * @var IAppData */ private $appData; public function __construct(DocumentMapper $documentMapper, StepMapper $stepMapper, SessionMapper $sessionMapper, IAppData $appData, $userId, IRootFolder $rootFolder, ICacheFactory $cacheFactory, ILogger $logger, ShareManager $shareManager, IRequest $request, IManager $directManager, ILockingProvider $lockingProvider) { $this->documentMapper = $documentMapper; $this->stepMapper = $stepMapper; $this->sessionMapper = $sessionMapper; $this->userId = $userId; $this->appData = $appData; $this->rootFolder = $rootFolder; $this->cache = $cacheFactory->createDistributed('text'); $this->logger = $logger; $this->shareManager = $shareManager; $this->lockingProvider = $lockingProvider; $token = $request->getParam('token'); if ($this->userId === null && $token !== null) { try { $tokenObject = $directManager->getToken($token); $tokenObject->extend(); $tokenObject->useTokenScope(); $this->userId = $tokenObject->getUser(); } catch (\Exception $e) { } } } /** * @param File $file * @return Entity * @throws NotFoundException * @throws InvalidPathException * @throws NotPermittedException */ public function createDocument(File $file): Document { try { $document = $this->documentMapper->find($file->getFileInfo()->getId()); // Do not hard reset if changed from outside since this will throw away possible steps // This way the user can still resolve conflicts in the editor view if ($document->getLastSavedVersion() !== $document->getCurrentVersion()) { $this->logger->debug('Unsaved steps but collission with file, continue collaborative editing'); return $document; } return $document; } catch (DoesNotExistException $e) { } catch (InvalidPathException $e) { } catch (NotFoundException $e) { } if (!$this->ensureDocumentsFolder()) { throw new NotFoundException('No app data folder present for text documents'); } try { $documentBaseFile = $this->getBaseFile((string)$file->getFileInfo()->getId()); } catch (NotFoundException $e) { $documentBaseFile = $this->appData->getFolder('documents')->newFile((string)$file->getFileInfo()->getId()); } $documentBaseFile->putContent($file->fopen('r')); $document = new Document(); $document->setId($file->getFileInfo()->getId()); $document->setCurrentVersion(0); $document->setLastSavedVersion(0); $document->setLastSavedVersionTime($file->getFileInfo()->getMtime()); $document->setLastSavedVersionEtag($file->getEtag()); $document->setBaseVersionEtag($file->getEtag()); $document = $this->documentMapper->insert($document); $this->cache->set('document-version-'.$document->getId(), 0); return $document; } /** * @param $document * @return ISimpleFile * @throws NotFoundException */ public function getBaseFile($document): ISimpleFile { if (!$this->ensureDocumentsFolder()) { throw new NotFoundException('No app data folder present for text documents'); } return $this->appData->getFolder('documents')->getFile((string) $document); } public function get($documentId) { return $this->documentMapper->find($documentId); } /** * @param $documentId * @param $sessionId * @param $steps * @param $version * @return array * @throws DoesNotExistException * @throws VersionMismatchException */ public function addStep($documentId, $sessionId, $steps, $version): array { $document = null; $oldVersion = null; try { $this->lockingProvider->acquireLock('document-push-lock-' . $documentId, ILockingProvider::LOCK_EXCLUSIVE); } catch (LockedException $e) { throw new VersionMismatchException('Version does not match'); } try { $document = $this->documentMapper->find($documentId); if ($version !== $document->getCurrentVersion()) { throw new VersionMismatchException('Version does not match'); } $stepsJson = json_encode($steps); if (!is_array($steps) || $stepsJson === null) { throw new InvalidArgumentException('Failed to encode steps'); } $validStepTypes = ['addMark', 'removeMark', 'replace', 'replaceAround']; foreach ($steps as $step) { if (array_key_exists('stepType', $step) && !in_array($step['stepType'], $validStepTypes, true)) { throw new InvalidArgumentException('Invalid step data'); } } $oldVersion = $document->getCurrentVersion(); $newVersion = $oldVersion + count($steps); $this->cache->set('document-version-' . $document->getId(), $newVersion); $document->setCurrentVersion($newVersion); $this->documentMapper->update($document); $step = new Step(); $step->setData($stepsJson); $step->setSessionId($sessionId); $step->setDocumentId($documentId); $step->setVersion($version + 1); $this->stepMapper->insert($step); // TODO write steps to cache for quicker reading $this->lockingProvider->releaseLock('document-push-lock-' . $documentId, ILockingProvider::LOCK_EXCLUSIVE); return $steps; } catch (DoesNotExistException $e) { $this->lockingProvider->releaseLock('document-push-lock-' . $documentId, ILockingProvider::LOCK_EXCLUSIVE); throw $e; } catch (\Throwable $e) { if ($document !== null && $oldVersion !== null) { $this->logger->logException($e, ['message' => 'This should never happen. An error occured when storing the version, trying to recover the last stable one']); $document->setCurrentVersion($oldVersion); $this->documentMapper->update($document); $this->cache->set('document-version-' . $document->getId(), $oldVersion); $this->stepMapper->deleteAfterVersion($documentId, $oldVersion); } $this->lockingProvider->releaseLock('document-push-lock-' . $documentId, ILockingProvider::LOCK_EXCLUSIVE); throw $e; } } public function getSteps($documentId, $lastVersion): array { if ($lastVersion === $this->cache->get('document-version-' . $documentId)) { return []; } $steps = $this->stepMapper->find($documentId, $lastVersion); $unique_array = []; foreach ($steps as $step) { $version = $step->getVersion(); if (!array_key_exists($version, $unique_array)) { $unique_array[(string)$version] = $step; } else { // found duplicate step // FIXME: verify that this version is the correct one //$this->stepMapper->delete($step); } } return array_values($unique_array); } /** * @param $documentId * @param $version * @param $autoaveDocument * @param bool $force * @param bool $manualSave * @param null $shareToken * @return Document * @throws DocumentSaveConflictException * @throws DoesNotExistException * @throws GenericFileException * @throws InvalidPathException * @throws NotFoundException * @throws NotPermittedException * @throws ShareNotFound */ public function autosave($file, $documentId, $version, $autoaveDocument, $force = false, $manualSave = false, $shareToken = null, $filePath = null): Document { /** @var Document $document */ $document = $this->documentMapper->find($documentId); if ($file === null) { throw new NotFoundException(); } if ($this->isReadOnly($file, $shareToken)) { return $document; } $savedEtag = $file->getEtag(); $lastMTime = $document->getLastSavedVersionTime(); if ($lastMTime > 0 && $savedEtag !== $document->getLastSavedVersionEtag() && $force === false) { if (!$this->cache->get('document-save-lock-' . $documentId)) { throw new DocumentSaveConflictException('File changed in the meantime from outside'); } else { // Only return here if the document is locked, otherwise we can continue to save return $document; } } if ($autoaveDocument === null) { return $document; } // Do not save if version already saved if (!$force && ($version <= (string)$document->getLastSavedVersion() || $version > (string)$document->getCurrentVersion())) { return $document; } // Only save once every AUTOSAVE_MINIMUM_DELAY seconds if ($file->getMTime() === $lastMTime && $lastMTime > time() - self::AUTOSAVE_MINIMUM_DELAY && $manualSave === false) { return $document; } $this->cache->set('document-save-lock-' . $documentId, true, 10); try { $file->putContent($autoaveDocument); } catch (LockedException $e) { // Ignore lock since it might occur when multiple people save at the same time return $document; } $document->setLastSavedVersion($document->getCurrentVersion()); $document->setLastSavedVersionTime(time()); $document->setLastSavedVersionEtag($file->getEtag()); $this->documentMapper->update($document); $this->cache->remove('document-save-lock-' . $documentId); return $document; } /** * @param $documentId * @param bool $force * @throws DocumentHasUnsavedChangesException */ public function resetDocument($documentId, $force = false): void { try { $document = $this->documentMapper->find($documentId); if ($force || !$this->hasUnsavedChanges($document)) { $this->stepMapper->deleteAll($documentId); $this->sessionMapper->deleteByDocumentId($documentId); $this->documentMapper->delete($document); try { $this->getBaseFile($documentId)->delete(); } catch (NotFoundException $e) { } catch (NotPermittedException $e) { } } elseif ($this->hasUnsavedChanges($document)) { throw new DocumentHasUnsavedChangesException('Did not reset document, as it has unsaved changes'); } } catch (DoesNotExistException $e) { } } /** * @param Session $session * @param $shareToken * @return \OCP\Files\File|Folder|Node * @throws NotFoundException */ public function getFileForSession(Session $session, $shareToken) { if ($session->getUserId() !== null && $shareToken === null) { return $this->getFileById($session->getDocumentId(), $session->getUserId()); } try { $share = $this->shareManager->getShareByToken($shareToken); } catch (ShareNotFound $e) { throw new NotFoundException(); } $node = $share->getNode(); if ($node instanceof \OCP\Files\File) { return $node; } if ($node instanceof Folder) { return $node->getById($session->getDocumentId())[0]; } throw new \InvalidArgumentException('No proper share data'); } /** * @throws NotFoundException */ public function getFileById($fileId, $userId = null): Node { try { $userFolder = $this->rootFolder->getUserFolder($this->userId ?? $userId); } catch (\OC\User\NoUserException $e) { // It is a bit hacky to depend on internal exceptions here. But it is the best we can do for now throw new NotFoundException(); } $files = $userFolder->getById($fileId); if (count($files) === 0) { throw new NotFoundException(); } // Workaround to always open files with edit permissions if multiple occurences of // the same file id are in the user home, ideally we should also track the path of the file when opening usort($files, function (Node $a, Node $b) { return ($a->getPermissions() & Constants::PERMISSION_UPDATE) < ($b->getPermissions() & Constants::PERMISSION_UPDATE); }); return array_shift($files); } /** * @param $shareToken * @param null|string $path * @return \OCP\Files\File|Folder|Node * @throws NotFoundException */ public function getFileByShareToken($shareToken, $path = null) { try { $share = $this->shareManager->getShareByToken($shareToken); } catch (ShareNotFound $e) { throw new NotFoundException(); } $node = $share->getNode(); if ($node instanceof \OCP\Files\File) { return $node; } if ($node instanceof Folder) { return $node->get($path); } throw new \InvalidArgumentException('No proper share data'); } public function isReadOnly($file, $token) { $readOnly = true; if ($token) { try { $this->checkSharePermissions($token, Constants::PERMISSION_UPDATE); $readOnly = false; } catch (NotFoundException $e) { } } else { $readOnly = !$file->isUpdateable(); } return $readOnly; } /** * @param $shareToken * @return void * @throws NotFoundException */ public function checkSharePermissions($shareToken, $permission = Constants::PERMISSION_READ): void { try { $share = $this->shareManager->getShareByToken($shareToken); } catch (ShareNotFound $e) { throw new NotFoundException(); } if (($share->getPermissions() & $permission) === 0) { throw new NotFoundException(); } } public function hasUnsavedChanges(Document $document) { return $document->getCurrentVersion() !== $document->getLastSavedVersion(); } private function ensureDocumentsFolder(): bool { try { $this->appData->getFolder('documents'); } catch (NotFoundException $e) { $this->appData->newFolder('documents'); } catch (\RuntimeException $e) { // Do not fail hard $this->logger->logException($e); return false; } return true; } }