migrate therinaldos.com data
Build & Deploy to DigitalOcean Space / build (push) Failing after 2m38s

This commit is contained in:
2024-05-05 15:50:45 -04:00
commit ef1ff240d4
23182 changed files with 3801898 additions and 0 deletions
@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Text\Service;
use Exception;
use InvalidArgumentException;
use OCA\Files_Sharing\SharedStorage;
use OCA\Text\AppInfo\Application;
use OCA\Text\Db\Document;
use OCA\Text\Db\Session;
use OCA\Text\Exception\DocumentSaveConflictException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\Constants;
use OCP\Files\InvalidPathException;
use OCP\Files\Lock\ILock;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IL10N;
use OCP\IRequest;
use OCP\Lock\LockedException;
use OCP\Share\IShare;
use Psr\Log\LoggerInterface;
class ApiService {
private IRequest $request;
private SessionService $sessionService;
private DocumentService $documentService;
private LoggerInterface $logger;
private EncodingService $encodingService;
private IL10N $l10n;
public function __construct(IRequest $request,
SessionService $sessionService,
DocumentService $documentService,
EncodingService $encodingService,
LoggerInterface $logger,
IL10N $l10n
) {
$this->request = $request;
$this->sessionService = $sessionService;
$this->documentService = $documentService;
$this->logger = $logger;
$this->encodingService = $encodingService;
$this->l10n = $l10n;
}
public function create(?int $fileId = null, ?string $filePath = null, ?string $token = null, ?string $guestName = null): DataResponse {
try {
if ($token) {
$file = $this->documentService->getFileByShareToken($token, $this->request->getParam('filePath'));
/*
* Check if we have proper read access (files drop)
* If not then well 404 it is.
*/
try {
$this->documentService->checkSharePermissions($token, Constants::PERMISSION_READ);
} catch (NotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
} catch (NotPermittedException $e) {
return new DataResponse($this->l10n->t('This file cannot be displayed as download is disabled by the share'), 404);
}
} elseif ($fileId) {
try {
$file = $this->documentService->getFileById($fileId);
} catch (NotFoundException|NotPermittedException $e) {
$this->logger->error('No permission to access this file', [ 'exception' => $e ]);
return new DataResponse($this->l10n->t('No permission to access this file.'), Http::STATUS_NOT_FOUND);
}
} else {
return new DataResponse('No valid file argument provided', Http::STATUS_PRECONDITION_FAILED);
}
$storage = $file->getStorage();
// Block using text for disabled download internal shares
if ($storage->instanceOfStorage(SharedStorage::class)) {
/** @var IShare $share */
$share = $storage->getShare();
$shareAttribtues = $share->getAttributes();
if ($shareAttribtues !== null && $shareAttribtues->getAttribute('permissions', 'download') === false) {
return new DataResponse($this->l10n->t('This file cannot be displayed as download is disabled by the share'), 403);
}
}
$readOnly = $this->documentService->isReadOnly($file, $token);
$this->sessionService->removeInactiveSessionsWithoutSteps($file->getId());
$document = $this->documentService->getDocument($file->getId());
$freshSession = $document === null;
if ($freshSession) {
$this->logger->info('Create new document of ' . $file->getId());
$document = $this->documentService->createDocument($file);
} else {
$this->logger->info('Keep previous document of ' . $file->getId());
}
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new DataResponse('Failed to create the document session', 500);
}
/** @var Document $document */
$session = $this->sessionService->initSession($document->getId(), $guestName);
if ($freshSession) {
$this->logger->debug('Starting a fresh editing session for ' . $file->getId());
$documentState = null;
$content = $this->loadContent($file);
} else {
$this->logger->debug('Loading existing session for ' . $file->getId());
$content = null;
try {
$stateFile = $this->documentService->getStateFile($document->getId());
$documentState = $stateFile->getContent();
} catch (NotFoundException $e) {
$this->logger->debug('State file not found for ' . $file->getId());
$documentState = ''; // no state saved yet.
// If there are no steps yet we might still need the content.
$steps = $this->documentService->getSteps($document->getId(), 0);
if (empty($steps)) {
$this->logger->debug('Empty steps, loading content for ' . $file->getId());
$content = $this->loadContent($file);
}
}
}
$lockInfo = $this->documentService->getLockInfo($file);
if ($lockInfo && $lockInfo->getType() === ILock::TYPE_APP && $lockInfo->getOwner() === Application::APP_NAME) {
$lockInfo = null;
}
$isLocked = $this->documentService->lock($file->getId());
if (!$isLocked) {
$readOnly = true;
}
return new DataResponse([
'document' => $document,
'session' => array_merge($session->jsonSerialize(), ['displayName' => $this->sessionService->getNameForSession($session)]),
'readOnly' => $readOnly,
'content' => $content,
'documentState' => $documentState,
'lock' => $lockInfo,
]);
}
public function close(int $documentId, int $sessionId, string $sessionToken): DataResponse {
$this->sessionService->closeSession($documentId, $sessionId, $sessionToken);
$this->sessionService->removeInactiveSessionsWithoutSteps($documentId);
$activeSessions = $this->sessionService->getActiveSessions($documentId);
if (count($activeSessions) === 0) {
$this->documentService->unlock($documentId);
}
return new DataResponse([]);
}
/**
* @throws NotFoundException
* @throws DoesNotExistException
*
* @param null|string $token
*/
public function push(Session $session, Document $document, int $version, array $steps, string $awareness, string|null $token = null): DataResponse {
try {
$session = $this->sessionService->updateSessionAwareness($session, $awareness);
} catch (DoesNotExistException $e) {
// Session was removed in the meantime. #3875
return new DataResponse([], 403);
}
if (empty($steps)) {
return new DataResponse([]);
}
$file = $this->documentService->getFileForSession($session, $token);
if ($this->documentService->isReadOnly($file, $token)) {
return new DataResponse([], 403);
}
try {
$result = $this->documentService->addStep($document, $session, $steps, $version);
} catch (InvalidArgumentException $e) {
return new DataResponse($e->getMessage(), 422);
} catch (DoesNotExistException $e) {
// Session was removed in the meantime. #3875
return new DataResponse([], 403);
}
return new DataResponse($result);
}
public function sync(Session $session, Document $document, int $version = 0, ?string $shareToken = null): DataResponse {
$documentId = $session->getDocumentId();
$result = [];
try {
$result = [
'steps' => $this->documentService->getSteps($documentId, $version),
'sessions' => $this->sessionService->getAllSessions($documentId),
'document' => $document,
];
// ensure file is still present and accessible
$file = $this->documentService->getFileForSession($session, $shareToken);
$this->documentService->assertNoOutsideConflict($document, $file);
} catch (NotFoundException|InvalidPathException $e) {
$this->logger->info($e->getMessage(), ['exception' => $e]);
return new DataResponse([
'message' => 'File not found'
], 404);
} catch (DoesNotExistException $e) {
$this->logger->info($e->getMessage(), ['exception' => $e]);
return new DataResponse([
'message' => 'Document no longer exists'
], 404);
} catch (DocumentSaveConflictException) {
try {
/** @psalm-suppress PossiblyUndefinedVariable */
$result['outsideChange'] = $file->getContent();
} catch (LockedException) {
// Ignore locked exception since it might happen due to an autosave action happening at the same time
}
}
return new DataResponse($result, isset($result['outsideChange']) ? 409 : 200);
}
public function save(Session $session, Document $document, int $version = 0, ?string $autosaveContent = null, ?string $documentState = null, bool $force = false, bool $manualSave = false, ?string $shareToken = null): DataResponse {
try {
$file = $this->documentService->getFileForSession($session, $shareToken);
} catch (NotFoundException $e) {
$this->logger->info($e->getMessage(), ['exception' => $e]);
return new DataResponse([
'message' => 'File not found'
], 404);
} catch (DoesNotExistException $e) {
$this->logger->info($e->getMessage(), ['exception' => $e]);
return new DataResponse([
'message' => 'Document no longer exists'
], 404);
}
$result = [];
try {
$result['document'] = $this->documentService->autosave($document, $file, $version, $autosaveContent, $documentState, $force, $manualSave, $shareToken);
} catch (DocumentSaveConflictException) {
try {
$result['outsideChange'] = $file->getContent();
} catch (LockedException) {
// Ignore locked exception since it might happen due to an autosave action happening at the same time
}
} catch (NotFoundException) {
return new DataResponse([], 404);
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new DataResponse([
'message' => 'Failed to autosave document'
], 500);
}
return new DataResponse($result, isset($result['outsideChange']) ? 409 : 200);
}
public function updateSession(Session $session, string $guestName): DataResponse {
return new DataResponse($this->sessionService->updateSession($session, $guestName));
}
private function loadContent(\OCP\Files\File $file): ?string {
try {
$content = $file->getContent();
$content = $this->encodingService->encodeToUtf8($content);
if ($content === null) {
$this->logger->warning('Failed to encode file to UTF8. File ID: ' . $file->getId());
}
} catch (NotFoundException $e) {
$this->logger->warning($e->getMessage(), ['exception' => $e]);
$content = null;
}
return $content;
}
}
@@ -0,0 +1,688 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021 Julien Veyssier <eneiluj@posteo.net>
*
* @author Julien Veyssier <eneiluj@posteo.net>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Text\Service;
use OC\User\NoUserException;
use OCA\Files_Sharing\SharedStorage;
use OCA\Text\Controller\AttachmentController;
use OCA\Text\Db\Session;
use OCP\Constants;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\IPreview;
use OCP\IURLGenerator;
use OCP\Lock\LockedException;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as ShareManager;
use OCP\Share\IShare;
use OCP\Util;
class AttachmentService {
public function __construct(private IRootFolder $rootFolder,
private ShareManager $shareManager,
private IPreview $previewManager,
private IMimeTypeDetector $mimeTypeDetector,
private IURLGenerator $urlGenerator) {
}
/**
* Get image content or preview from file name
*
* @throws InvalidPathException
* @throws NoUserException
* @throws NotFoundException
* @throws NotPermittedException
*/
public function getImageFile(int $documentId, string $imageFileName, string $userId, bool $preferRawImage): File|ISimpleFile|null {
$textFile = $this->getTextFile($documentId, $userId);
return $this->getImageFileContent($imageFileName, $textFile, $preferRawImage);
}
/**
* Get image content or preview from file id in public context
*
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
public function getImageFilePublic(int $documentId, string $imageFileName, string $shareToken, bool $preferRawImage): File|ISimpleFile|null {
$textFile = $this->getTextFilePublic($documentId, $shareToken);
return $this->getImageFileContent($imageFileName, $textFile, $preferRawImage);
}
/**
* @throws InvalidPathException
* @throws NoUserException
* @throws NotFoundException
* @throws NotPermittedException
*/
private function getImageFileContent(string $imageFileName, File $textFile, bool $preferRawImage): File|ISimpleFile|null {
$attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true);
$imageFile = $attachmentFolder->get($imageFileName);
if ($imageFile instanceof File && in_array($imageFile->getMimetype(), AttachmentController::IMAGE_MIME_TYPES, true)) {
// previews of gifs are static images, always provide the real gif
if ($imageFile->getMimetype() === 'image/gif') {
return $imageFile;
}
// we might prefer the raw image
if ($preferRawImage && in_array($imageFile->getMimetype(), AttachmentController::BROWSER_SUPPORTED_IMAGE_MIME_TYPES, true)) {
return $imageFile;
}
if ($this->previewManager->isMimeSupported($imageFile->getMimeType())) {
return $this->previewManager->getPreview($imageFile, 1024, 1024);
}
// fallback: raw image
return $imageFile;
}
return null;
}
/**
* Get media file from file name
*
* @throws NotFoundException
* @throws InvalidPathException
* @throws NotPermittedException
* @throws NoUserException
*/
public function getMediaFile(int $documentId, string $mediaFileName, string $userId): File|null {
$textFile = $this->getTextFile($documentId, $userId);
return $this->getMediaFullFile($mediaFileName, $textFile);
}
/**
* Get image content or preview from file id in public context
*
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
public function getMediaFilePublic(int $documentId, string $mediaFileName, string $shareToken): File|null {
$textFile = $this->getTextFilePublic($documentId, $shareToken);
return $this->getMediaFullFile($mediaFileName, $textFile);
}
/**
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
private function getMediaFullFile(string $mediaFileName, File $textFile): ?File {
$attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true);
$mediaFile = $attachmentFolder->get($mediaFileName);
if ($mediaFile instanceof File && !$this->isDownloadDisabled($mediaFile)) {
return $mediaFile;
}
return null;
}
/**
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
public function getMediaFilePreview(int $documentId, string $mediaFileName, string $userId): ?array {
$textFile = $this->getTextFile($documentId, $userId);
return $this->getMediaFilePreviewFile($mediaFileName, $textFile);
}
/**
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
public function getMediaFilePreviewPublic(int $documentId, string $mediaFileName, string $shareToken): ?array {
$textFile = $this->getTextFilePublic($documentId, $shareToken);
return $this->getMediaFilePreviewFile($mediaFileName, $textFile);
}
/**
* Get media preview or mimetype icon address
*
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
private function getMediaFilePreviewFile(string $mediaFileName, File $textFile): ?array {
$attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true);
$mediaFile = $attachmentFolder->get($mediaFileName);
if ($mediaFile instanceof File && !$this->isDownloadDisabled($mediaFile)) {
if ($this->previewManager->isMimeSupported($mediaFile->getMimeType())) {
try {
return [
'type' => 'file',
'file' => $this->previewManager->getPreview($mediaFile, 1024, 1024),
];
} catch (NotFoundException $e) {
// the preview might not be found even if the mimetype is supported
}
}
// fallback: mimetype icon URL
return [
'type' => 'icon',
'iconUrl' => $this->mimeTypeDetector->mimeTypeIcon($mediaFile->getMimeType()),
];
}
return null;
}
/**
* @param int $documentId
* @param string|null $userId
* @param Session|null $session
* @param string|null $shareToken
*
* @return array
* @throws InvalidPathException
* @throws NoUserException
* @throws NotFoundException
* @throws NotPermittedException
*/
public function getAttachmentList(int $documentId, ?string $userId = null, ?Session $session = null, ?string $shareToken = null): array {
if ($shareToken) {
$textFile = $this->getTextFilePublic($documentId, $shareToken);
} elseif ($userId) {
$textFile = $this->getTextFile($documentId, $userId);
} else {
throw new NotPermittedException('Unable to read document');
}
try {
$attachmentDir = $this->getAttachmentDirectoryForFile($textFile);
} catch (NotFoundException) {
return [];
}
$shareTokenUrlString = $shareToken
? '&shareToken=' . rawurlencode($shareToken)
: '';
$urlParamsBase = $session
? '?documentId=' . $documentId . '&sessionId=' . $session->getId() . '&sessionToken=' . rawurlencode($session->getToken()) . $shareTokenUrlString
: '?documentId=' . $documentId . $shareTokenUrlString;
$attachments = [];
$userFolder = $userId ? $this->rootFolder->getUserFolder($userId) : null;
foreach ($attachmentDir->getDirectoryListing() as $node) {
if (!($node instanceof File)) {
// Ignore anything but files
continue;
}
$isImage = in_array($node->getMimetype(), AttachmentController::IMAGE_MIME_TYPES, true);
$name = $node->getName();
$attachments[] = [
'fileId' => $node->getId(),
'name' => $name,
'size' => Util::humanFileSize($node->getSize()),
'mimetype' => $node->getMimeType(),
'mtime' => $node->getMTime(),
'isImage' => $isImage,
'davPath' => $userFolder?->getRelativePath($node->getPath()),
'fullUrl' => $isImage
? $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getImageFile') . $urlParamsBase . '&imageFileName=' . rawurlencode($name) . '&preferRawImage=1'
: $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getMediaFile') . $urlParamsBase . '&mediaFileName=' . rawurlencode($name),
'previewUrl' => $isImage
? $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getImageFile') . $urlParamsBase . '&imageFileName=' . rawurlencode($name)
: $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getMediaFilePreview') . $urlParamsBase . '&mediaFileName=' . rawurlencode($name),
];
}
return $attachments;
}
/**
* Save an uploaded file in the attachment folder
*
* @param int $documentId
* @param string $newFileName
* @param resource $newFileResource
* @param string $userId
*
* @return array
* @throws InvalidPathException
* @throws NoUserException
* @throws NotFoundException
* @throws NotPermittedException
*/
public function uploadAttachment(int $documentId, string $newFileName, $newFileResource, string $userId): array {
$textFile = $this->getTextFile($documentId, $userId);
if (!$textFile->isUpdateable()) {
throw new NotPermittedException('No write permissions');
}
$saveDir = $this->getAttachmentDirectoryForFile($textFile, true);
$fileName = self::getUniqueFileName($saveDir, $newFileName);
$savedFile = $saveDir->newFile($fileName, $newFileResource);
return [
'name' => $fileName,
'dirname' => $saveDir->getName(),
'id' => $savedFile->getId(),
'documentId' => $textFile->getId(),
];
}
/**
* Save an uploaded file in the attachment folder in a public context
*
* @param int|null $documentId
* @param string $newFileName
* @param resource $newFileResource
* @param string $shareToken
*
* @return array
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
public function uploadAttachmentPublic(?int $documentId, string $newFileName, $newFileResource, string $shareToken): array {
if (!$this->hasUpdatePermissions($shareToken)) {
throw new NotPermittedException('No write permissions');
}
$textFile = $this->getTextFilePublic($documentId, $shareToken);
$saveDir = $this->getAttachmentDirectoryForFile($textFile, true);
$fileName = self::getUniqueFileName($saveDir, $newFileName);
$savedFile = $saveDir->newFile($fileName, $newFileResource);
return [
'name' => $fileName,
'dirname' => $saveDir->getName(),
'id' => $savedFile->getId(),
'documentId' => $textFile->getId(),
];
}
/**
* Copy a file from a user's storage in the attachment folder
*
* @param int $documentId
* @param string $path
* @param string $userId
*
* @return array
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
public function insertAttachmentFile(int $documentId, string $path, string $userId): array {
$textFile = $this->getTextFile($documentId, $userId);
if (!$textFile->isUpdateable()) {
throw new NotPermittedException('No write permissions');
}
$originalFile = $this->getFileFromPath($path, $userId);
$saveDir = $this->getAttachmentDirectoryForFile($textFile, true);
return $this->copyFile($originalFile, $saveDir, $textFile);
}
/**
* @param File $originalFile
* @param Folder $saveDir
* @param File $textFile
*
* @return array
* @throws NotFoundException
* @throws InvalidPathException
*/
private function copyFile(File $originalFile, Folder $saveDir, File $textFile): array {
$fileName = self::getUniqueFileName($saveDir, $originalFile->getName());
$targetPath = $saveDir->getPath() . '/' . $fileName;
$targetFile = $originalFile->copy($targetPath);
return [
'name' => $fileName,
'dirname' => $saveDir->getName(),
'id' => $targetFile->getId(),
'documentId' => $textFile->getId(),
'mimetype' => $targetFile->getMimetype(),
];
}
/**
* Get unique file name in a directory. Add '(n)' suffix.
*
* @param Folder $dir
* @param string $fileName
*
* @return string
*/
public static function getUniqueFileName(Folder $dir, string $fileName): string {
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
$counter = 1;
$uniqueFileName = $fileName;
if ($extension !== '') {
while ($dir->nodeExists($uniqueFileName)) {
$counter++;
$uniqueFileName = preg_replace('/\.' . $extension . '$/', ' (' . $counter . ').' . $extension, $fileName);
}
} else {
while ($dir->nodeExists($uniqueFileName)) {
$counter++;
$uniqueFileName = preg_replace('/$/', ' (' . $counter . ')', $fileName);
}
}
return $uniqueFileName;
}
/**
* Check if the shared access has write permissions
*
* @param string $shareToken
*
* @return bool
*/
private function hasUpdatePermissions(string $shareToken): bool {
try {
$share = $this->shareManager->getShareByToken($shareToken);
return (
in_array(
$share->getShareType(),
[IShare::TYPE_LINK, IShare::TYPE_EMAIL, IShare::TYPE_ROOM],
true
)
&& $share->getPermissions() & Constants::PERMISSION_UPDATE);
} catch (ShareNotFound $e) {
return false;
}
}
/**
* Get or create file-specific attachment folder
*
* @param File $textFile
* @param bool $create
*
* @return Folder
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
private function getAttachmentDirectoryForFile(File $textFile, bool $create = false): Folder {
$owner = $textFile->getOwner();
if ($owner === null) {
throw new NotFoundException('File has no owner');
}
$ownerId = $owner->getUID();
$ownerUserFolder = $this->rootFolder->getUserFolder($ownerId);
$ownerTextFile = $ownerUserFolder->getById($textFile->getId());
if (count($ownerTextFile) > 0) {
$ownerTextFile = $ownerTextFile[0];
$ownerParentFolder = $ownerTextFile->getParent();
$attachmentFolderName = '.attachments.' . $textFile->getId();
if ($ownerParentFolder->nodeExists($attachmentFolderName)) {
$attachmentFolder = $ownerParentFolder->get($attachmentFolderName);
if ($attachmentFolder instanceof Folder) {
return $attachmentFolder;
}
} elseif ($create) {
return $ownerParentFolder->newFolder($attachmentFolderName);
}
}
throw new NotFoundException('Attachment dir for document ' . $textFile->getId() . ' was not found or could not be created.');
}
/**
* Get a user file from file ID
* @throws NotFoundException
* @throws NotPermittedException
* @throws NoUserException
*/
private function getFileFromPath(string $filePath, string $userId): File {
$userFolder = $this->rootFolder->getUserFolder($userId);
if ($userFolder->nodeExists($filePath)) {
$file = $userFolder->get($filePath);
if ($file instanceof File && !$this->isDownloadDisabled($file)) {
return $file;
}
}
throw new NotFoundException();
}
/**
* @param File $file
*
* @return bool
* @throws NotFoundException
*/
private function isDownloadDisabled(File $file): bool {
$storage = $file->getStorage();
if ($storage->instanceOfStorage(SharedStorage::class)) {
/** @var SharedStorage $storage */
$share = $storage->getShare();
$attributes = $share->getAttributes();
if ($attributes !== null && $attributes->getAttribute('permissions', 'download') === false) {
return true;
}
}
return false;
}
/**
* Get a user file from file ID
*
* @param int $documentId
* @param string $userId
*
* @return File
* @throws NoUserException
* @throws NotFoundException
* @throws NotPermittedException
*/
private function getTextFile(int $documentId, string $userId): File {
$userFolder = $this->rootFolder->getUserFolder($userId);
$files = $userFolder->getById($documentId);
$file = array_shift($files);
if ($file instanceof File && !$this->isDownloadDisabled($file)) {
return $file;
}
throw new NotFoundException('Text file with id=' . $documentId . ' was not found in storage of ' . $userId);
}
/**
* Get file from share token
*
* @param int|null $documentId
* @param string $shareToken
*
* @return File
* @throws NotFoundException
*/
private function getTextFilePublic(?int $documentId, string $shareToken): File {
// is the file shared with this token?
try {
$share = $this->shareManager->getShareByToken($shareToken);
if ($share->getShareType() === IShare::TYPE_LINK) {
// shared file or folder?
if ($share->getNodeType() === 'file') {
$textFile = $share->getNode();
if ($textFile instanceof File && !$this->isDownloadDisabled($textFile)) {
return $textFile;
}
} elseif ($documentId !== null && $share->getNodeType() === 'folder') {
$folder = $share->getNode();
if ($folder instanceof Folder) {
$textFile = $folder->getById($documentId);
$textFile = array_shift($textFile);
if ($textFile instanceof File && !$this->isDownloadDisabled($textFile)) {
return $textFile;
}
}
}
}
} catch (ShareNotFound $e) {
// same as below
}
throw new NotFoundException('Text file with id=' . $documentId . ' and shareToken ' . $shareToken . ' was not found.');
}
/**
* Actually delete attachment files which are not pointed in the markdown content
*
* @param int $fileId
*
* @return int The number of deleted files
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws LockedException
* @throws NoUserException
*/
public function cleanupAttachments(int $fileId): int {
$textFile = $this->rootFolder->getById($fileId);
if (count($textFile) > 0 && $textFile[0] instanceof File) {
$textFile = $textFile[0];
if ($textFile->getMimeType() === 'text/markdown') {
// get IDs of the files inside the attachment dir
try {
$attachmentDir = $this->getAttachmentDirectoryForFile($textFile);
} catch (NotFoundException $e) {
// this only happens if the attachment dir was deleted by the user while editing the document
return 0;
}
$attachmentsByName = [];
foreach ($attachmentDir->getDirectoryListing() as $attNode) {
$attachmentsByName[$attNode->getName()] = $attNode;
}
$contentAttachmentNames = self::getAttachmentNamesFromContent($textFile->getContent(), $fileId);
$toDelete = array_diff(array_keys($attachmentsByName), $contentAttachmentNames);
foreach ($toDelete as $name) {
$attachmentsByName[$name]->delete();
}
return count($toDelete);
}
}
return 0;
}
/**
* Get attachment file names listed in the markdown file content
*
* @param string $content
* @param int $fileId
*
* @return array
*/
public static function getAttachmentNamesFromContent(string $content, int $fileId): array {
$matches = [];
// matches ![ANY_CONSIDERED_CORRECT_BY_PHP-MARKDOWN](.attachments.DOCUMENT_ID/ANY_FILE_NAME) and captures FILE_NAME
preg_match_all(
'/\!\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[\])*\])*\])*\])*\])*\])*\]\(\.attachments\.' . $fileId . '\/([^)&]+)\)/',
$content,
$matches,
PREG_SET_ORDER
);
return array_map(static function (array $match) {
return urldecode($match[1]);
}, $matches);
}
/**
* @param File $source
* @param File $target
*
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws LockedException
*/
public function moveAttachments(File $source, File $target): void {
// if the parent directory has changed
if ($source->getParent()->getPath() !== $target->getParent()->getPath()) {
try {
$sourceAttachmentDir = $this->getAttachmentDirectoryForFile($source);
} catch (NotFoundException $e) {
// silently return if no attachment dir was found for source file
return;
}
// it is in the same directory as the source file in its owner's storage
// in other words, we move the attachment dir only if the .md file is moved by its owner
if ($source->getParent()->getId() === $sourceAttachmentDir->getParent()->getId()
) {
$sourceAttachmentDir->move($target->getParent()->getPath() . '/' . $sourceAttachmentDir->getName());
}
}
}
/**
* @param File $source
*
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
* @throws NoUserException
*/
public function deleteAttachments(File $source): void {
// if there is an attachment dir for this file
try {
$sourceAttachmentDir = $this->getAttachmentDirectoryForFile($source);
} catch (NotFoundException $e) {
// silently return if no attachment dir was found
return;
}
$sourceAttachmentDir->delete();
}
/**
* @param File $source
* @param File $target
*
* @throws InvalidPathException
* @throws NoUserException
* @throws NotFoundException
* @throws NotPermittedException
* @throws LockedException
*/
public function copyAttachments(File $source, File $target): void {
try {
$sourceAttachmentDir = $this->getAttachmentDirectoryForFile($source);
} catch (NotFoundException $e) {
// silently return if no attachment dir was found for source file
return;
}
// create a new attachment dir next to the new file
$targetAttachmentDir = $this->getAttachmentDirectoryForFile($target, true);
// copy the attachment files
foreach ($sourceAttachmentDir->getDirectoryListing() as $sourceAttachment) {
if ($sourceAttachment instanceof File) {
$targetAttachmentDir->newFile($sourceAttachment->getName(), $sourceAttachment->getContent());
}
}
}
}
@@ -0,0 +1,36 @@
<?php
namespace OCA\Text\Service;
use OCA\Text\AppInfo\Application;
use OCP\IConfig;
class ConfigService {
private IConfig $config;
public function __construct(IConfig $config) {
$this->config = $config;
}
public function getDefaultFileExtension(): string {
return $this->config->getAppValue(Application::APP_NAME, 'default_file_extension', 'md');
}
public function isRichEditingEnabled(): bool {
return ($this->config->getAppValue(Application::APP_NAME, 'rich_editing_enabled', '1') === '1');
}
public function isRichWorkspaceAvailable(): bool {
if ($this->config->getSystemValueBool('enable_non-accessible_features', true) === false) {
return false;
}
return $this->config->getAppValue(Application::APP_NAME, 'workspace_available', '1') === '1';
}
public function isRichWorkspaceEnabledForUser(?string $userId): bool {
if ($userId === null) {
return true;
}
return $this->config->getUserValue($userId, Application::APP_NAME, 'workspace_enabled', '1') === '1';
}
}
@@ -0,0 +1,646 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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\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\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 ?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;
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) {
$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;
$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;
}
}
/**
* @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 && ($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($file->getEtag());
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
return $this->documentMapper->find($file->getId());
}
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 DoesNotExistException
* @throws InvalidArgumentException
*/
public function addStep(Document $document, Session $session, array $steps, int $version): array {
$documentId = $session->getDocumentId();
$stepsToInsert = [];
$querySteps = [];
$newVersion = $version;
foreach ($steps as $step) {
$message = YjsMessage::fromBase64($step);
// 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) {
$querySteps[] = $step;
} else {
$stepsToInsert[] = $step;
}
}
if (count($stepsToInsert) > 0) {
$newVersion = $this->insertSteps($document, $session, $stepsToInsert);
}
// If there were any queries in the steps send the entire history
$getStepsSinceVersion = count($querySteps) > 0 ? 0 : $version;
$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
];
}
/**
* @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 = $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) {
$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) {
$file->putContent($autoSaveDocument);
if ($documentState) {
$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);
if ($force) {
$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(): array {
return $this->documentMapper->findAll();
}
/**
* @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->getById($session->getDocumentId())[0];
}
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();
}
$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|null $token): bool {
$readOnly = true;
if ($token) {
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->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->lockManager->unlock(new LockContext(
$file,
ILock::TYPE_APP,
Application::APP_NAME
));
} catch (NoLockProviderException | PreConditionNotMetException | NotFoundException $e) {
}
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Raul Ferreira Fuentes <raul@nextcloud.com>
*
* @author Raul Ferreira Fuentes <raul@nextcloud.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Text\Service;
class EncodingService {
public const COMMON_ENCODINGS = [ 'UTF-8', 'GB2312', 'GBK', 'BIG-5', 'SJIS-win', 'EUC-JP', 'Windows-1252', 'ISO-8859-15', 'ISO-8859-1', 'ASCII'];
public const UTF_BOMs = [
'UTF-32BE' => "\x00\x00\xfe\xff",
'UTF-32LE' => "\xff\xfe\x00\x00",
'UTF-16BE' => "\xfe\xff",
'UTF-16LE' => "\xff\xfe",
'UTF-8' => "\xef\xbb\xbf"
];
public function encodeToUtf8(string $string): ?string {
$encoding = $this->detectEncoding($string);
if (!$encoding) {
return null;
}
$encoded = mb_convert_encoding($string, 'UTF-8', $encoding);
return is_string($encoded) ? $encoded : null;
}
public function detectEncoding(string $string): ?string {
$bomDetect = $this->detectUtfBom($string);
if ($bomDetect) {
return $bomDetect;
}
foreach ($this->getEncodings() as $encoding) {
if (mb_check_encoding($string, $encoding)) {
return $encoding;
}
}
return mb_detect_encoding($string, $this->getEncodings(), true) ?: null;
}
private function detectUtfBom(string $string): ?string {
foreach (self::UTF_BOMs as $encoding => $utfBom) {
$bom = substr($string, 0, strlen($utfBom));
if ($bom === $utfBom) {
return $encoding;
}
}
return null;
}
/**
* @return string[]
*/
private function getEncodings(): array {
$mbOrder = mb_detect_order() ?: [];
return array_merge(is_array($mbOrder) ? $mbOrder : [], self::COMMON_ENCODINGS);
}
}
@@ -0,0 +1,67 @@
<?php
namespace OCA\Text\Service;
use OCP\AppFramework\Services\IInitialState;
use OCP\TextProcessing\IManager;
use OCP\TextProcessing\ITaskType;
use OCP\Translation\ITranslationManager;
class InitialStateProvider {
public function __construct(
private IInitialState $initialState,
private ConfigService $configService,
private ITranslationManager $translationManager,
private IManager $textProcessingManager,
private ?string $userId
) {
}
public function provideState(): void {
$this->initialState->provideInitialState(
'workspace_available',
$this->configService->isRichWorkspaceAvailable()
);
$this->initialState->provideInitialState(
'workspace_enabled',
$this->configService->isRichWorkspaceEnabledForUser($this->userId)
);
$this->initialState->provideInitialState(
'default_file_extension',
$this->configService->getDefaultFileExtension()
);
$this->initialState->provideInitialState(
'rich_editing_enabled',
$this->configService->isRichEditingEnabled()
);
$this->initialState->provideInitialState(
'translation_can_detect',
$this->translationManager->canDetectLanguage()
);
$this->initialState->provideInitialState(
'translation_languages',
$this->translationManager->getLanguages()
);
$this->initialState->provideInitialState(
'textprocessing',
array_map(function (string $className) {
/** @var class-string<ITaskType> $className */
$type = \OCP\Server::get($className);
return [
'task' => $className,
'name' => $type->getName(),
];
}, $this->textProcessingManager->getAvailableTaskTypes()),
);
}
public function provideFileId(int $fileId): void {
$this->initialState->provideInitialState('file_id', $fileId);
}
}
@@ -0,0 +1,59 @@
<?php
/**
* @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @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 <http://www.gnu.org/licenses/>.
*/
namespace OCA\Text\Service;
use OCA\Text\Notification\Notifier;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Notification\IManager;
class NotificationService {
private IManager $manager;
private ITimeFactory $timeFactory;
private ?string $userId;
public function __construct(IManager $manager, ITimeFactory $timeFactory, ?string $userId = null) {
$this->manager = $manager;
$this->timeFactory = $timeFactory;
$this->userId = $userId;
}
public function mention(int $fileId, string $userId): bool {
$notification = $this->manager->createNotification();
$notification->setUser($userId)
->setApp('text')
->setSubject(Notifier::TYPE_MENTIONED, [
Notifier::SUBJECT_MENTIONED_SOURCE_USER => $this->userId,
Notifier::SUBJECT_MENTIONED_TARGET_USER => $userId,
])
->setObject('file', (string)$fileId);
;
if ($this->manager->getCount($notification) === 0) {
$notification->setDateTime(\DateTime::createFromImmutable($this->timeFactory->now()));
$this->manager->notify($notification);
return true;
}
return false;
}
}
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Text\Service;
use OCA\Text\Db\Session;
use OCA\Text\Db\SessionMapper;
use OCA\Text\YjsMessage;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DirectEditing\IManager;
use OCP\IAvatarManager;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\Security\ISecureRandom;
class SessionService {
public const SESSION_VALID_TIME = 5 * 60;
private SessionMapper $sessionMapper;
private ISecureRandom $secureRandom;
private ITimeFactory $timeFactory;
private IUserManager $userManager;
private IAvatarManager $avatarManager;
private ?string $userId;
private ICache $cache;
/** @var ?Session cache current session in the request */
private ?Session $session = null;
public function __construct(
SessionMapper $sessionMapper,
ISecureRandom $secureRandom,
ITimeFactory $timeFactory,
IUserManager $userManager,
IAvatarManager $avatarManager,
IRequest $request,
IManager $directManager,
?string $userId,
ICacheFactory $cacheFactory
) {
$this->sessionMapper = $sessionMapper;
$this->secureRandom = $secureRandom;
$this->timeFactory = $timeFactory;
$this->userManager = $userManager;
$this->avatarManager = $avatarManager;
$this->userId = $userId;
$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) {
}
}
$this->cache = $cacheFactory->createDistributed('text_sessions');
}
public function initSession(int $documentId, string $guestName = null): Session {
$session = new Session();
$session->setDocumentId($documentId);
$userName = $this->userId ? $this->userId : $guestName;
$session->setUserId($this->userId);
$session->setToken($this->secureRandom->generate(64));
$session->setColor($this->getColorForGuestName($guestName));
if ($this->userId === null) {
$session->setGuestName($guestName ?? '');
}
$session->setLastContact($this->timeFactory->now()->getTimestamp());
$session = $this->sessionMapper->insert($session);
$this->cache->set($session->getToken(), json_encode($session), self::SESSION_VALID_TIME);
return $session;
}
public function closeSession(int $documentId, int $sessionId, string $token): void {
try {
$session = $this->sessionMapper->find($documentId, $sessionId, $token);
$this->cache->remove($token);
$this->sessionMapper->delete($session);
} catch (DoesNotExistException $e) {
}
}
public function getAllSessions(int $documentId): array {
$sessions = $this->sessionMapper->findAll($documentId);
return array_map(function (Session $session) {
$result = $session->jsonSerialize();
if (!$session->isGuest()) {
$result['displayName'] = $this->userManager->getDisplayName($session->getUserId());
}
return $result;
}, $sessions);
}
public function getActiveSessions(int $documentId): array {
$sessions = $this->sessionMapper->findAllActive($documentId);
return array_map(function (Session $session) {
$result = $session->jsonSerialize();
if (!$session->isGuest()) {
$result['displayName'] = $this->userManager->getDisplayName($session->getUserId());
}
return $result;
}, $sessions);
}
public function getNameForSession(Session $session): ?string {
if (!$session->isGuest()) {
return $this->userManager->getDisplayName($session->getUserId());
}
return $session->getGuestName();
}
/** @return Session[] */
public function findAllInactive(): array {
return $this->sessionMapper->findAllInactive();
}
public function removeInactiveSessionsWithoutSteps(?int $documentId = null): int {
// No need to clear the cache here as we already set a TTL
return $this->sessionMapper->deleteInactiveWithoutSteps($documentId);
}
public function getSession(int $documentId, int $sessionId, string $token): ?Session {
if ($this->session !== null) {
return $this->session;
}
$data = $this->cache->get($token);
if ($data !== null) {
$this->session = Session::fromRow(json_decode($data, true));
if ($this->session->getId() !== $sessionId || $this->session->getDocumentId() !== $documentId) {
$this->cache->remove($token);
$this->session = null;
}
return $this->session;
}
try {
$this->session = $this->sessionMapper->find($documentId, $sessionId, $token);
$this->cache->set($token, json_encode($this->session), self::SESSION_VALID_TIME - 30);
} catch (DoesNotExistException $e) {
$this->session = null;
$this->cache->remove($token);
}
return $this->session;
}
public function getValidSession(int $documentId, int $sessionId, string $token): ?Session {
$session = $this->getSession($documentId, $sessionId, $token);
if ($session === null) {
return null;
}
$currentTime = $this->timeFactory->now()->getTimestamp();
if (($currentTime - $session->getLastContact()) >= 30) {
/*
* We need to update the timestamp.
* Make sure that the session we got is still in the database
*/
try {
$session = $this->sessionMapper->find($documentId, $sessionId, $token);
} catch (DoesNotExistException $e) {
$this->session = null;
$this->cache->remove($token);
return null;
}
$session->setLastContact($this->timeFactory->now()->getTimestamp());
$this->sessionMapper->update($session);
$this->cache->set($token, json_encode($session), self::SESSION_VALID_TIME - 30);
$this->session = $session;
}
return $session;
}
/**
* @throws DoesNotExistException
*/
public function updateSession(Session $session, string $guestName): Session {
if ($this->userId !== null) {
throw new \Exception('Logged in users cannot set a guest name');
}
$session->setGuestName($guestName);
$session->setColor($this->getColorForGuestName($guestName));
return $this->sessionMapper->update($session);
}
/**
* @throws DoesNotExistException
*/
public function updateSessionAwareness(Session $session, string $message): Session {
if (empty($message)) {
return $session;
}
$decoded = YjsMessage::fromBase64($message);
if ($decoded->getYjsMessageType() !== YjsMessage::YJS_MESSAGE_AWARENESS) {
throw new \ValueError('Message passed was not an awareness message');
}
$session->setLastAwarenessMessage($message);
return $this->sessionMapper->update($session);
}
private function getColorForGuestName(string $guestName = null): string {
$guestName = $this->userId ?? $guestName;
$uniqueGuestId = !empty($guestName) ? $guestName : $this->secureRandom->generate(12);
$color = $this->avatarManager->getGuestAvatar($uniqueGuestId)->avatarBackgroundColor($uniqueGuestId);
return $color->name();
}
public function isUserInDocument(int $documentId, string $mention): bool {
return $this->sessionMapper->isUserInDocument($documentId, $mention);
}
}
@@ -0,0 +1,45 @@
<?php
namespace OCA\Text\Service;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\NotFoundException;
use OCP\IL10N;
class WorkspaceService {
private IL10N $l10n;
private const SUPPORTED_STATIC_FILENAMES = [
'Readme.md',
'README.md',
'readme.md'
];
public function __construct(IL10N $l10n) {
$this->l10n = $l10n;
}
public function getFile(Folder $folder): ?File {
foreach ($this->getSupportedFilenames() as $filename) {
if ($folder->nodeExists($filename)) {
try {
$file = $folder->get($filename);
if ($file instanceof File) {
return $file;
}
} catch (NotFoundException $e) {
}
}
}
return null;
}
/** @return string[] */
public function getSupportedFilenames(): array {
return array_merge([
$this->l10n->t('Readme') . '.md'
], self::SUPPORTED_STATIC_FILENAMES);
}
}