%PDF- %PDF-
| Direktori : /www/varak.net/nextcloud.varak.net/apps_old/apps/text/lib/Service/ |
| Current File : /www/varak.net/nextcloud.varak.net/apps_old/apps/text/lib/Service/DocumentService.php |
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Text\Service;
use InvalidArgumentException;
use OCA\Text\AppInfo\Application;
use OCA\Text\Db\Document;
use OCA\Text\Db\DocumentMapper;
use OCA\Text\Db\Session;
use OCA\Text\Db\SessionMapper;
use OCA\Text\Db\Step;
use OCA\Text\Db\StepMapper;
use OCA\Text\Exception\DocumentHasUnsavedChangesException;
use OCA\Text\Exception\DocumentSaveConflictException;
use OCA\Text\YjsMessage;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Constants;
use OCP\DB\Exception;
use OCP\DirectEditing\IManager;
use OCP\Files\AlreadyExistsException;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IAppData;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
use OCP\Files\Lock\ILock;
use OCP\Files\Lock\ILockManager;
use OCP\Files\Lock\LockContext;
use OCP\Files\Lock\NoLockProviderException;
use OCP\Files\Lock\OwnerLockedException;
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\IConfig;
use OCP\IRequest;
use OCP\Lock\LockedException;
use OCP\PreConditionNotMetException;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as ShareManager;
use Psr\Log\LoggerInterface;
use function json_encode;
class DocumentService {
/**
* Delay to wait for between autosave versions
*/
public const AUTOSAVE_MINIMUM_DELAY = 10;
private bool $saveFromText = false;
private ?string $userId;
private DocumentMapper $documentMapper;
private SessionMapper $sessionMapper;
private LoggerInterface $logger;
private ShareManager $shareManager;
private StepMapper $stepMapper;
private IRootFolder $rootFolder;
private ICache $cache;
private IAppData $appData;
private ILockManager $lockManager;
private IUserMountCache $userMountCache;
private IConfig $config;
public function __construct(DocumentMapper $documentMapper, StepMapper $stepMapper, SessionMapper $sessionMapper, IAppData $appData, ?string $userId, IRootFolder $rootFolder, ICacheFactory $cacheFactory, LoggerInterface $logger, ShareManager $shareManager, IRequest $request, IManager $directManager, ILockManager $lockManager, IUserMountCache $userMountCache, IConfig $config) {
$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->lockManager = $lockManager;
$this->userMountCache = $userMountCache;
$this->config = $config;
$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) {
}
}
}
public function getDocument(int $id): ?Document {
try {
return $this->documentMapper->find($id);
} catch (DoesNotExistException|NotFoundException $e) {
return null;
}
}
public function isSaveFromText(): bool {
return $this->saveFromText;
}
/**
* @throws NotFoundException
* @throws InvalidPathException
* @throws NotPermittedException
* @throws Exception
*/
public function createDocument(File $file): Document {
try {
$document = $this->documentMapper->find($file->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
$stepsVersion = $this->stepMapper->getLatestVersion($document->getId());
if ($stepsVersion !== null && ($document->getLastSavedVersion() !== $stepsVersion)) {
$this->logger->debug('Unsaved steps, 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');
}
$document = new Document();
$document->setId($file->getId());
$document->setLastSavedVersion(0);
$document->setLastSavedVersionTime($file->getMTime());
$document->setLastSavedVersionEtag($file->getEtag());
$document->setBaseVersionEtag(uniqid());
try {
/** @var Document $document */
$document = $this->documentMapper->insert($document);
$this->cache->set('document-version-'.$document->getId(), 0);
} catch (Exception $e) {
if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
// Document might have been created in the meantime
throw new AlreadyExistsException();
}
throw $e;
}
return $document;
}
/**
* @param int $documentId
* @return ISimpleFile
* @throws NotFoundException
*/
public function getStateFile(int $documentId): ISimpleFile {
$filename = $documentId . '.yjs';
if (!$this->ensureDocumentsFolder()) {
throw new NotFoundException('No app data folder present for text documents');
}
return $this->appData->getFolder('documents')->getFile($filename);
}
/**
* @param int $documentId
*
* @return ISimpleFile
* @throws NotPermittedException
*/
public function createStateFile(int $documentId): ISimpleFile {
$filename = $documentId . '.yjs';
return $this->appData->getFolder('documents')->newFile($filename);
}
/**
* @param int $documentId
* @param string $content
*/
public function writeDocumentState(int $documentId, string $content): void {
try {
$documentStateFile = $this->getStateFile($documentId);
} catch (NotFoundException $e) {
$documentStateFile = $this->createStateFile($documentId);
} catch (NotPermittedException $e) {
$this->logger->error('Failed to create document state file', ['exception' => $e]);
return;
}
$documentStateFile->putContent($content);
}
/**
* @throws InvalidArgumentException
* @throws NotFoundException
* @throws NotPermittedException
* @throws DoesNotExistException
*/
public function addStep(Document $document, Session $session, array $steps, int $version, ?string $shareToken): array {
$documentId = $session->getDocumentId();
$readOnly = $this->isReadOnly($this->getFileForSession($session, $shareToken), $shareToken);
$stepsToInsert = [];
$stepsIncludeQuery = false;
$documentState = null;
$newVersion = $version;
foreach ($steps as $step) {
$message = YjsMessage::fromBase64($step);
if ($readOnly && $message->isUpdate()) {
continue;
}
// Filter out query steps as they would just trigger clients to send their steps again
if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_STEP1) {
$stepsIncludeQuery = true;
} else {
$stepsToInsert[] = $step;
}
}
if (count($stepsToInsert) > 0) {
if ($readOnly) {
throw new NotPermittedException('Read-only client tries to push steps with changes');
}
$newVersion = $this->insertSteps($document, $session, $stepsToInsert);
}
// By default send all steps the user has not received yet.
$getStepsSinceVersion = $version;
if ($stepsIncludeQuery) {
$this->logger->debug('Loading document state for ' . $documentId);
try {
$stateFile = $this->getStateFile($documentId);
$documentState = $stateFile->getContent();
$this->logger->debug('Existing document, state file loaded ' . $documentId);
// If there were any queries in the steps send all steps since last save.
$getStepsSinceVersion = $document->getLastSavedVersion();
} catch (NotFoundException $e) {
$this->logger->debug('Existing document, but no state file found for ' . $documentId);
// If there is no state file include all the steps.
$getStepsSinceVersion = 0;
}
}
$allSteps = $this->getSteps($documentId, $getStepsSinceVersion);
$stepsToReturn = [];
foreach ($allSteps as $step) {
$message = YjsMessage::fromBase64($step->getData());
if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_UPDATE) {
$stepsToReturn[] = $step;
}
}
return [
'steps' => $stepsToReturn,
'version' => $newVersion,
'documentState' => $documentState
];
}
/**
* @param Document $document
* @param Session $session
* @param Step[] $steps
*
* @return int
*
* @throws DoesNotExistException
* @throws InvalidArgumentException
*
* @psalm-param non-empty-list<mixed> $steps
*/
private function insertSteps(Document $document, Session $session, array $steps): int {
$stepsVersion = null;
try {
$stepsJson = json_encode($steps, JSON_THROW_ON_ERROR);
$stepsVersion = $this->stepMapper->getLatestVersion($document->getId());
$step = new Step();
$step->setData($stepsJson);
$step->setSessionId($session->getId());
$step->setDocumentId($document->getId());
$step->setVersion(Step::VERSION_STORED_IN_ID);
$step->setTimestamp(time());
$step = $this->stepMapper->insert($step);
$newVersion = $step->getId();
$this->logger->debug("Adding steps to " . $document->getId() . ": bumping version from $stepsVersion to $newVersion");
$this->cache->set('document-version-' . $document->getId(), $newVersion);
// TODO write steps to cache for quicker reading
return $newVersion;
} catch (\Throwable $e) {
if ($stepsVersion !== null) {
$this->logger->error('This should never happen. An error occurred when storing the version, trying to recover the last stable one', ['exception' => $e]);
$this->cache->set('document-version-' . $document->getId(), $stepsVersion);
$this->stepMapper->deleteAfterVersion($document->getId(), $stepsVersion);
}
throw $e;
}
}
/** @return Step[] */
public function getSteps(int $documentId, int $lastVersion): array {
if ($lastVersion === $this->cache->get('document-version-' . $documentId)) {
return [];
}
return $this->stepMapper->find($documentId, $lastVersion);
}
/**
* @throws DocumentSaveConflictException
* @throws InvalidPathException
* @throws NotFoundException
*/
public function assertNoOutsideConflict(Document $document, File $file, bool $force = false, ?string $shareToken = null): void {
$documentId = $document->getId();
$savedEtag = $file->getEtag();
$lastMTime = $document->getLastSavedVersionTime();
if ($lastMTime > 0
&& $force === false
&& !$this->isReadOnly($file, $shareToken)
&& $savedEtag !== $document->getLastSavedVersionEtag()
&& $lastMTime !== $file->getMtime()
&& !$this->cache->get('document-save-lock-' . $documentId)
) {
throw new DocumentSaveConflictException('File changed in the meantime from outside');
}
}
/**
* @throws DocumentSaveConflictException
* @throws DoesNotExistException
* @throws InvalidPathException
* @throws NotFoundException
* @throws NotPermittedException
* @throws Exception
*/
public function autosave(Document $document, ?File $file, int $version, ?string $autoSaveDocument, ?string $documentState, bool $force = false, bool $manualSave = false, ?string $shareToken = null): Document {
$documentId = $document->getId();
if ($file === null) {
throw new NotFoundException();
}
if ($this->isReadOnly($file, $shareToken)) {
return $document;
}
$this->assertNoOutsideConflict($document, $file, $force);
if ($autoSaveDocument === null) {
return $document;
}
// Do not save if newer version already saved
// Note that $version is the version of the steps the client has fetched.
// It may have added steps on top of that - so if the versions match we still save.
$stepsVersion = $this->stepMapper->getLatestVersion($documentId) ?? 0;
$savedVersion = $document->getLastSavedVersion();
$outdated = $savedVersion > 0 && $savedVersion > $version;
if (!$force && ($outdated || $version > (string)$stepsVersion)) {
return $document;
}
// Only save once every AUTOSAVE_MINIMUM_DELAY seconds
$lastMTime = $document->getLastSavedVersionTime();
if ($file->getMTime() === $lastMTime && $lastMTime > time() - self::AUTOSAVE_MINIMUM_DELAY && $manualSave === false) {
return $document;
}
if (empty($autoSaveDocument)) {
$this->logger->warning('Saving empty document', [
'requestVersion' => $version,
'requestAutosaveDocument' => $autoSaveDocument,
'requestDocumentState' => $documentState,
'document' => $document->jsonSerialize(),
'fileSizeBeforeSave' => $file->getSize(),
'steps' => array_map(static function (Step $step) {
return $step->jsonSerialize();
}, $this->stepMapper->find($documentId, 0)),
'sessions' => array_map(static function (Session $session) {
return $session->jsonSerialize();
}, $this->sessionMapper->findAll($documentId))
]);
}
// Version changed but the content remains the same
if ($autoSaveDocument === $file->getContent()) {
if ($documentState !== null) {
$this->writeDocumentState($file->getId(), $documentState);
}
$document->setLastSavedVersion($stepsVersion);
$document->setLastSavedVersionTime($file->getMTime());
$document->setLastSavedVersionEtag($file->getEtag());
$this->documentMapper->update($document);
return $document;
}
$this->cache->set('document-save-lock-' . $documentId, true, 10);
try {
$this->lockManager->runInScope(new LockContext(
$file,
ILock::TYPE_APP,
Application::APP_NAME
), function () use ($file, $autoSaveDocument, $documentState) {
$this->saveFromText = true;
$file->putContent($autoSaveDocument);
if ($documentState !== null) {
$this->writeDocumentState($file->getId(), $documentState);
}
});
$document->setLastSavedVersion($stepsVersion);
$document->setLastSavedVersionTime($file->getMTime());
$document->setLastSavedVersionEtag($file->getEtag());
$this->documentMapper->update($document);
} catch (LockedException $e) {
// Ignore lock since it might occur when multiple people save at the same time
return $document;
} finally {
$this->cache->remove('document-save-lock-' . $documentId);
}
return $document;
}
/**
* @throws DocumentHasUnsavedChangesException
* @throws Exception
* @throws NotPermittedException
*/
public function resetDocument(int $documentId, bool $force = false): void {
try {
$document = $this->documentMapper->find($documentId);
if (!$force && $this->hasUnsavedChanges($document)) {
$this->logger->debug('did not reset document for ' . $documentId);
throw new DocumentHasUnsavedChangesException('Did not reset document, as it has unsaved changes');
}
$this->unlock($documentId);
$this->stepMapper->deleteAll($documentId);
$this->sessionMapper->deleteByDocumentId($documentId);
$this->documentMapper->delete($document);
$this->getStateFile($documentId)->delete();
$this->logger->debug('document reset for ' . $documentId);
} catch (DoesNotExistException|NotFoundException $e) {
// Ignore if document not found or state file not found
}
}
public function getAll(): \Generator {
return $this->documentMapper->findAll();
}
/**
* @throws NotPermittedException
* @throws NotFoundException
*/
public function getFileForSession(Session $session, ?string $shareToken = null): File {
if (!$session->isGuest()) {
try {
return $this->getFileById($session->getDocumentId(), $session->getUserId());
} catch (NotFoundException) {
// We may still have a user session but on a public share link so move on
}
}
if ($shareToken === null) {
throw new \InvalidArgumentException('No proper share data');
}
try {
$share = $this->shareManager->getShareByToken($shareToken);
} catch (ShareNotFound $e) {
throw new NotFoundException();
}
$node = $share->getNode();
if ($node instanceof Folder) {
$node = $node->getFirstNodeById($session->getDocumentId());
}
if ($node instanceof File) {
return $node;
}
throw new \InvalidArgumentException('No proper share data');
}
/**
* @throws NotFoundException
* @throws NotPermittedException
*/
public function getFileById(int $fileId, ?string $userId = null): File {
$userId = $userId ?? $this->userId;
// If no user is provided we need to get any file from existing mounts for cleanup jobs
if ($userId === null) {
$mounts = $this->userMountCache->getMountsForFileId($fileId);
$anyMount = array_shift($mounts);
if ($anyMount === null) {
throw new NotFoundException('Could not fallback to file from mounts');
}
$userId = $anyMount->getUser()->getUID();
}
try {
$userFolder = $this->rootFolder->getUserFolder($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();
}
// We currently don't know the path nor care about which file mount it is when getting by id
// therefore we can take a shortcut on the cached node if we have edit permissions on that
$file = $userFolder->getFirstNodeById($fileId);
if ($file instanceof File && $file->getPermissions() & Constants::PERMISSION_UPDATE) {
return $file;
}
// Ideally we'd optimize this part in the future by storing the path and getting the acutal target directly
$files = $userFolder->getById($fileId);
if (count($files) === 0) {
throw new NotFoundException();
}
// Workaround to always open files with edit permissions if multiple occurrences of
// the same file id are in the user home, ideally we should also track the path of the file when opening
usort($files, static function (Node $a, Node $b) {
return ($b->getPermissions() & Constants::PERMISSION_UPDATE) <=> ($a->getPermissions() & Constants::PERMISSION_UPDATE);
});
$file = array_shift($files);
if (!$file instanceof File) {
throw new NotFoundException();
}
if (($file->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ) {
throw new NotPermittedException();
}
return $file;
}
/**
* @throws NotFoundException
*/
public function getFileByShareToken(string $shareToken, ?string $path = null): File {
try {
$share = $this->shareManager->getShareByToken($shareToken);
} catch (ShareNotFound $e) {
throw new NotFoundException();
}
$node = $share->getNode();
if ($path !== null && $node instanceof Folder) {
$node = $node->get($path);
}
if ($node instanceof File) {
return $node;
}
throw new \InvalidArgumentException('No proper share data');
}
public function isReadOnly(File $file, ?string $token): bool {
$readOnly = true;
if ($token !== null) {
try {
$this->checkSharePermissions($token, Constants::PERMISSION_UPDATE);
$readOnly = false;
} catch (NotFoundException $e) {
}
} else {
$readOnly = !$file->isUpdateable();
}
$lockInfo = $this->getLockInfo($file);
$isTextLock = (
$lockInfo && $lockInfo->getType() === ILock::TYPE_APP && $lockInfo->getOwner() === Application::APP_NAME
);
if ($isTextLock) {
return $readOnly;
}
return $readOnly || $lockInfo !== null;
}
public function getLockInfo(File $file): ?ILock {
try {
$locks = $this->lockManager->getLocks($file->getId());
} catch (NoLockProviderException|PreConditionNotMetException $e) {
return null;
}
return array_shift($locks);
}
/**
* @param $shareToken
*
* @return void
*
* @throws NotFoundException|NotPermittedException
*
* @psalm-param 1|2 $permission
*/
public function checkSharePermissions(string $shareToken, int $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): bool {
$stepsVersion = $this->stepMapper->getLatestVersion($document->getId()) ?: 0;
$docVersion = $document->getLastSavedVersion();
return $stepsVersion !== $docVersion;
}
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->error($e->getMessage(), ['exception' => $e]);
return false;
}
return true;
}
public function lock(int $fileId): bool {
if (!$this->lockManager->isLockProviderAvailable()) {
return true;
}
try {
$file = $this->getFileById($fileId, $this->userId);
$this->lockManager->lock(new LockContext(
$file,
ILock::TYPE_APP,
Application::APP_NAME
));
} catch (NoLockProviderException | PreConditionNotMetException | NotFoundException $e) {
} catch (OwnerLockedException $e) {
return false;
}
return true;
}
public function unlock(int $fileId): void {
if (!$this->lockManager->isLockProviderAvailable()) {
return;
}
try {
$file = $this->getFileById($fileId, $this->userId);
$this->lockManager->unlock(new LockContext(
$file,
ILock::TYPE_APP,
Application::APP_NAME
));
} catch (NoLockProviderException | PreConditionNotMetException | NotFoundException $e) {
}
}
public function countAll(): int {
return $this->documentMapper->countAll();
}
private function getFullAppFolder(): Folder {
$appFolder = $this->rootFolder->get('appdata_' . $this->config->getSystemValueString('instanceid', '') . '/text');
if (!$appFolder instanceof Folder) {
throw new NotFoundException('Folder not found');
}
return $appFolder;
}
public function clearAll(): void {
$this->stepMapper->clearAll();
$this->sessionMapper->clearAll();
$this->documentMapper->clearAll();
try {
$appFolder = $this->getFullAppFolder();
$appFolder->get('documents')->move($appFolder->getPath() . '/documents_old_' . time());
} catch (NotFoundException) {
}
$this->ensureDocumentsFolder();
}
public function cleanupOldDocumentsFolders(): void {
try {
$appFolder = $this->getFullAppFolder();
foreach ($appFolder->getDirectoryListing() as $node) {
if (str_starts_with($node->getName(), 'documents_old_')) {
$node->delete();
}
}
} catch (NotFoundException) {
}
}
}