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,97 @@
<?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\AppInfo;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
use OCA\Text\Event\LoadEditor;
use OCA\Text\Listeners\AddMissingIndicesListener;
use OCA\Text\Listeners\BeforeAssistantNotificationListener;
use OCA\Text\Listeners\BeforeNodeDeletedListener;
use OCA\Text\Listeners\BeforeNodeRenamedListener;
use OCA\Text\Listeners\FilesLoadAdditionalScriptsListener;
use OCA\Text\Listeners\FilesSharingLoadAdditionalScriptsListener;
use OCA\Text\Listeners\LoadEditorListener;
use OCA\Text\Listeners\LoadViewerListener;
use OCA\Text\Listeners\NodeCopiedListener;
use OCA\Text\Listeners\RegisterDirectEditorEventListener;
use OCA\Text\Middleware\SessionMiddleware;
use OCA\Text\Notification\Notifier;
use OCA\Text\Service\ConfigService;
use OCA\TPAssistant\Event\BeforeAssistantNotificationEvent;
use OCA\Viewer\Event\LoadViewer;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\DB\Events\AddMissingIndicesEvent;
use OCP\DirectEditing\RegisterDirectEditorEvent;
use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
use OCP\Files\Events\Node\NodeCopiedEvent;
use OCP\Files\Template\ITemplateManager;
use OCP\Files\Template\TemplateFileCreator;
use OCP\IL10N;
class Application extends App implements IBootstrap {
public const APP_NAME = 'text';
public function __construct(array $params = []) {
parent::__construct(self::APP_NAME, $params);
}
public function register(IRegistrationContext $context): void {
$context->registerEventListener(RegisterDirectEditorEvent::class, RegisterDirectEditorEventListener::class);
$context->registerEventListener(LoadViewer::class, LoadViewerListener::class);
$context->registerEventListener(LoadAdditionalScriptsEvent::class, FilesLoadAdditionalScriptsListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, FilesSharingLoadAdditionalScriptsListener::class);
$context->registerEventListener(LoadEditor::class, LoadEditorListener::class);
// for attachments
$context->registerEventListener(NodeCopiedEvent::class, NodeCopiedListener::class);
$context->registerEventListener(BeforeNodeRenamedEvent::class, BeforeNodeRenamedListener::class);
$context->registerEventListener(BeforeNodeDeletedEvent::class, BeforeNodeDeletedListener::class);
$context->registerEventListener(AddMissingIndicesEvent::class, AddMissingIndicesListener::class);
$context->registerEventListener(BeforeAssistantNotificationEvent::class, BeforeAssistantNotificationListener::class);
$context->registerNotifierService(Notifier::class);
$context->registerMiddleware(SessionMiddleware::class);
}
public function boot(IBootContext $context): void {
$context->injectFn(function (ITemplateManager $templateManager, IL10N $l, ConfigService $configService) {
$templateManager->registerTemplateFileCreator(function () use ($l, $configService) {
$markdownFile = new TemplateFileCreator(Application::APP_NAME, $l->t('New text file'), '.' . $configService->getDefaultFileExtension());
$markdownFile->addMimetype('text/markdown');
$markdownFile->addMimetype('text/plain');
$markdownFile->setIconClass('icon-filetype-text');
$markdownFile->setRatio(1);
$markdownFile->setOrder(10);
$markdownFile->setActionLabel($l->t('Create new text file'));
return $markdownFile;
});
});
}
}
@@ -0,0 +1,98 @@
<?php
/**
* @copyright Copyright (c) 2018 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\Command;
use OCA\Text\Db\DocumentMapper;
use OCA\Text\Db\SessionMapper;
use OCA\Text\Db\StepMapper;
use OCA\Text\Service\DocumentService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ResetDocument extends Command {
protected DocumentService $documentService;
protected DocumentMapper $documentMapper;
protected StepMapper $stepMapper;
protected SessionMapper $sessionMapper;
public function __construct(DocumentService $documentService, DocumentMapper $documentMapper, StepMapper $stepMapper, SessionMapper $sessionMapper) {
parent::__construct();
$this->documentService = $documentService;
$this->documentMapper = $documentMapper;
$this->stepMapper = $stepMapper;
$this->sessionMapper = $sessionMapper;
}
protected function configure(): void {
$this
->setName('text:reset')
->setDescription('Reset a text document')
->addArgument(
'file-id',
InputArgument::REQUIRED,
'File id of the document to rest'
)
->addOption(
'full',
'f',
null,
'Drop all existing steps and use the currently saved version'
)
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int
*/
protected function execute(InputInterface $input, OutputInterface $output): int {
$fileId = $input->getArgument('file-id');
$fullReset = $input->getOption('full');
if ($fullReset) {
$output->writeln('Full document reset');
$this->documentService->resetDocument($fileId, true);
return 0;
} else {
$output->writeln('Trying to restore to last saved version');
$document = $this->documentMapper->find($fileId);
$deleted = $this->stepMapper->deleteAfterVersion($fileId, $document->getLastSavedVersion());
if ($deleted > 0) {
$this->sessionMapper->deleteByDocumentId($fileId);
$output->writeln('Reverted document to the last saved version');
return 0;
} else {
$output->writeln('Failed revert changes that are newer than the last saved version');
}
return 1;
}
}
}
@@ -0,0 +1,303 @@
<?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\Controller;
use Exception;
use OCA\Text\Exception\InvalidSessionException;
use OCA\Text\Exception\UploadException;
use OCA\Text\Middleware\Attribute\RequireDocumentSession;
use OCA\Text\Middleware\Attribute\RequireDocumentSessionOrUserOrShareToken;
use OCA\Text\Service\AttachmentService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\Files\IMimeTypeDetector;
use OCP\IL10N;
use OCP\IRequest;
use OCP\Util;
use Psr\Log\LoggerInterface;
class AttachmentController extends ApiController implements ISessionAwareController {
use TSessionAwareController;
public const IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/x-xbitmap',
'image/x-ms-bmp',
'image/bmp',
'image/svg+xml',
'image/webp',
'image/heic',
'image/heif',
];
public const BROWSER_SUPPORTED_IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/x-xbitmap',
'image/x-ms-bmp',
'image/bmp',
'image/svg+xml',
'image/webp',
];
public function __construct(
string $appName,
IRequest $request,
private IL10N $l10n,
private LoggerInterface $logger,
private IMimeTypeDetector $mimeTypeDetector,
private AttachmentService $attachmentService
) {
parent::__construct($appName, $request);
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSessionOrUserOrShareToken]
public function getAttachmentList(?string $shareToken = null): DataResponse {
$documentId = $this->getDocument()->getId();
try {
$session = $this->getSession();
} catch (InvalidSessionException) {
$session = null;
}
if ($shareToken) {
$attachments = $this->attachmentService->getAttachmentList($documentId, null, $session, $shareToken);
} else {
$userId = $this->getUserId();
$attachments = $this->attachmentService->getAttachmentList($documentId, $userId, $session, null);
}
return new DataResponse($attachments);
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
public function insertAttachmentFile(string $filePath): DataResponse {
$userId = $this->getSession()->getUserId();
try {
$insertResult = $this->attachmentService->insertAttachmentFile($this->getSession()->getDocumentId(), $filePath, $userId);
if (isset($insertResult['error'])) {
return new DataResponse($insertResult, Http::STATUS_BAD_REQUEST);
} else {
return new DataResponse($insertResult);
}
} catch (Exception $e) {
$this->logger->error('File insertion error', ['exception' => $e]);
return new DataResponse(['error' => 'File insertion error'], Http::STATUS_BAD_REQUEST);
}
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
public function uploadAttachment(?string $shareToken = null): DataResponse {
$documentId = $this->getSession()->getDocumentId();
try {
$file = $this->getUploadedFile('file');
if (isset($file['tmp_name'], $file['name'], $file['type'])) {
$newFileResource = fopen($file['tmp_name'], 'rb');
if ($newFileResource === false) {
throw new Exception('Could not read file');
}
$newFileName = $file['name'];
if ($shareToken) {
$uploadResult = $this->attachmentService->uploadAttachmentPublic($documentId, $newFileName, $newFileResource, $shareToken);
} else {
$userId = $this->getSession()->getUserId();
$uploadResult = $this->attachmentService->uploadAttachment($documentId, $newFileName, $newFileResource, $userId);
}
if (isset($uploadResult['error'])) {
return new DataResponse($uploadResult, Http::STATUS_BAD_REQUEST);
} else {
return new DataResponse($uploadResult);
}
}
return new DataResponse(['error' => 'No uploaded file'], Http::STATUS_BAD_REQUEST);
} catch (Exception $e) {
$this->logger->error('Upload error', ['exception' => $e]);
return new DataResponse(['error' => 'Upload error'], Http::STATUS_BAD_REQUEST);
}
}
private function getUploadedFile(string $key): array {
$file = $this->request->getUploadedFile($key);
$error = null;
$phpFileUploadErrors = [
UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
];
if (empty($file)) {
$error = $this->l10n->t('No file uploaded or file size exceeds maximum of %s', [Util::humanFileSize(Util::uploadLimit())]);
}
if (!empty($file) && array_key_exists('error', $file) && $file['error'] !== UPLOAD_ERR_OK) {
$error = $phpFileUploadErrors[$file['error']];
}
if ($error !== null) {
throw new UploadException($error);
}
return $file;
}
/**
* Serve the image files in the editor
*
* @return DataDownloadResponse|DataResponse
*
* @psalm-return DataDownloadResponse<200, string, array<never, never>>|DataResponse<404, '', array<never, never>>
*/
#[NoAdminRequired]
#[PublicPage]
#[NoCSRFRequired]
#[RequireDocumentSessionOrUserOrShareToken]
public function getImageFile(string $imageFileName, ?string $shareToken = null,
int $preferRawImage = 0): DataResponse|DataDownloadResponse {
$documentId = $this->getDocument()->getId();
try {
if ($shareToken) {
$imageFile = $this->attachmentService->getImageFilePublic($documentId, $imageFileName, $shareToken, $preferRawImage === 1);
} else {
$userId = $this->getUserId();
$imageFile = $this->attachmentService->getImageFile($documentId, $imageFileName, $userId, $preferRawImage === 1);
}
return $imageFile !== null
? new DataDownloadResponse(
$imageFile->getContent(),
$imageFile->getName(),
$this->getSecureMimeType($imageFile->getMimeType())
)
: new DataResponse('', Http::STATUS_NOT_FOUND);
} catch (Exception $e) {
$this->logger->error('getImageFile error', ['exception' => $e]);
return new DataResponse('', Http::STATUS_NOT_FOUND);
}
}
/**
* Serve the media files in the editor
*
* @return DataDownloadResponse|DataResponse
*
* @psalm-return DataDownloadResponse<200, string, array<never, never>>|DataResponse<404, '', array<never, never>>
*/
#[NoAdminRequired]
#[PublicPage]
#[NoCSRFRequired]
#[RequireDocumentSessionOrUserOrShareToken]
public function getMediaFile(string $mediaFileName, ?string $shareToken = null): DataResponse|DataDownloadResponse {
$documentId = $this->getDocument()->getId();
try {
if ($shareToken) {
$mediaFile = $this->attachmentService->getMediaFilePublic($documentId, $mediaFileName, $shareToken);
} else {
$userId = $this->getUserId();
$mediaFile = $this->attachmentService->getMediaFile($documentId, $mediaFileName, $userId);
}
return $mediaFile !== null
? new DataDownloadResponse(
$mediaFile->getContent(),
$mediaFile->getName(),
$this->getSecureMimeType($mediaFile->getMimeType())
)
: new DataResponse('', Http::STATUS_NOT_FOUND);
} catch (Exception $e) {
$this->logger->error('getMediaFile error', ['exception' => $e]);
return new DataResponse('', Http::STATUS_NOT_FOUND);
}
}
/**
* Serve the media files preview in the editor
* @return DataDownloadResponse|DataResponse|RedirectResponse
*/
#[NoAdminRequired]
#[PublicPage]
#[NoCSRFRequired]
#[RequireDocumentSessionOrUserOrShareToken]
public function getMediaFilePreview(string $mediaFileName, ?string $shareToken = null) {
$documentId = $this->getDocument()->getId();
try {
if ($shareToken) {
$preview = $this->attachmentService->getMediaFilePreviewPublic($documentId, $mediaFileName, $shareToken);
} else {
$userId = $this->getUserId();
$preview = $this->attachmentService->getMediaFilePreview($documentId, $mediaFileName, $userId);
}
if ($preview === null) {
return new DataResponse('', Http::STATUS_NOT_FOUND);
}
if ($preview['type'] === 'file') {
return new DataDownloadResponse(
$preview['file']->getContent(),
$mediaFileName,
$this->getSecureMimeType($preview['file']->getMimeType())
);
} elseif ($preview['type'] === 'icon') {
return new RedirectResponse($preview['iconUrl']);
}
} catch (Exception $e) {
$this->logger->error('getMediaFilePreview error', ['exception' => $e]);
}
return new DataResponse('', Http::STATUS_NOT_FOUND);
}
/**
* Allow all supported mimetypes
* Use mimetype detector for the other ones
*
* @param string $mimetype
* @return string
*/
private function getSecureMimeType(string $mimetype): string {
if (in_array($mimetype, self::IMAGE_MIME_TYPES)) {
return $mimetype;
}
return $this->mimeTypeDetector->getSecureMimeType($mimetype);
}
}
@@ -0,0 +1,15 @@
<?php
namespace OCA\Text\Controller;
use OCA\Text\Db\Document;
use OCA\Text\Db\Session;
interface ISessionAwareController {
public function getSession(): Session;
public function setSession(Session $session): void;
public function getDocument(): Document;
public function setDocument(Document $document): void;
public function getUserId(): string;
public function setUserId(string $userId): void;
}
@@ -0,0 +1,44 @@
<?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\Controller;
use OCA\Text\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\TemplateResponse;
class NavigationController extends Controller {
/**
*
* @NoCSRFRequired
* @NoAdminRequired
*
* @return TemplateResponse
*/
public function navigate(): TemplateResponse {
return new TemplateResponse(Application::APP_NAME, 'main');
}
}
@@ -0,0 +1,123 @@
<?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\Controller;
use OCA\Text\Middleware\Attribute\RequireDocumentSession;
use OCA\Text\Service\ApiService;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\PublicShareController;
use OCP\IRequest;
use OCP\ISession;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as ShareManager;
use OCP\Share\IShare;
class PublicSessionController extends PublicShareController implements ISessionAwareController {
use TSessionAwareController;
private ?IShare $share = null;
public function __construct(
string $appName,
IRequest $request,
ISession $session,
private ShareManager $shareManager,
private ApiService $apiService
) {
parent::__construct($appName, $request, $session);
}
private function getShare(): IShare {
if ($this->share === null) {
throw new \Exception('Share has not been set yet');
}
return $this->share;
}
protected function getPasswordHash(): string {
return $this->getShare()->getPassword();
}
public function isValidToken(): bool {
try {
$this->share = $this->shareManager->getShareByToken($this->getToken());
return true;
} catch (ShareNotFound $e) {
return false;
}
}
protected function isPasswordProtected(): bool {
/** @psalm-suppress RedundantConditionGivenDocblockType */
return $this->getShare()->getPassword() !== null;
}
#[NoAdminRequired]
#[PublicPage]
public function create(string $token, string $file = null, ?string $guestName = null): DataResponse {
return $this->apiService->create(null, $file, $token, $guestName);
}
#[NoAdminRequired]
#[PublicPage]
public function close(int $documentId, int $sessionId, string $sessionToken): DataResponse {
return $this->apiService->close($documentId, $sessionId, $sessionToken);
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
public function push(int $documentId, int $sessionId, string $sessionToken, int $version, array $steps, string $awareness, string $token): DataResponse {
return $this->apiService->push($this->getSession(), $this->getDocument(), $version, $steps, $awareness, $token);
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
public function sync(string $token, int $version = 0): DataResponse {
return $this->apiService->sync($this->getSession(), $this->getDocument(), $version, $token);
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
public function save(string $token, int $version = 0, string $autosaveContent = null, string $documentState = null, bool $force = false, bool $manualSave = false): DataResponse {
return $this->apiService->save($this->getSession(), $this->getDocument(), $version, $autosaveContent, $documentState, $force, $manualSave, $token);
}
/**
* @psalm-return DataResponse<int, array|null|object|scalar, array<string, mixed>>
*/
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
public function updateSession(string $guestName): DataResponse {
return $this->apiService->updateSession($this->getSession(), $guestName);
}
}
@@ -0,0 +1,135 @@
<?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\Controller;
use OCA\Text\Middleware\Attribute\RequireDocumentSession;
use OCA\Text\Service\ApiService;
use OCA\Text\Service\NotificationService;
use OCA\Text\Service\SessionService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
class SessionController extends ApiController implements ISessionAwareController {
use TSessionAwareController;
private bool $restoreUser = false;
private ?IUser $userToRestore = null;
public function __construct(
string $appName,
IRequest $request,
private ApiService $apiService,
private SessionService $sessionService,
private NotificationService $notificationService,
private IUserManager $userManager,
private IUserSession $userSession) {
parent::__construct($appName, $request);
}
#[NoAdminRequired]
public function create(int $fileId = null, string $file = null): DataResponse {
return $this->apiService->create($fileId, $file, null, null);
}
#[NoAdminRequired]
#[PublicPage]
public function close(int $documentId, int $sessionId, string $sessionToken): DataResponse {
return $this->apiService->close($documentId, $sessionId, $sessionToken);
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
public function push(int $version, array $steps, string $awareness): DataResponse {
try {
$this->loginSessionUser();
return $this->apiService->push($this->getSession(), $this->getDocument(), $version, $steps, $awareness);
} finally {
$this->restoreSessionUser();
}
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
public function sync(int $version = 0): DataResponse {
try {
$this->loginSessionUser();
return $this->apiService->sync($this->getSession(), $this->getDocument(), $version);
} finally {
$this->restoreSessionUser();
}
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
public function save(int $version = 0, string $autosaveContent = null, string $documentState = null, bool $force = false, bool $manualSave = false): DataResponse {
try {
$this->loginSessionUser();
return $this->apiService->save($this->getSession(), $this->getDocument(), $version, $autosaveContent, $documentState, $force, $manualSave);
} finally {
$this->restoreSessionUser();
}
}
#[NoAdminRequired]
#[PublicPage]
#[RequireDocumentSession]
#[UserRateLimit(limit: 5, period: 120)]
public function mention(string $mention): DataResponse {
if ($this->getSession()->isGuest() && !$this->sessionService->isUserInDocument($this->getDocument()->getId(), $mention)) {
return new DataResponse([], 403);
}
return new DataResponse($this->notificationService->mention($this->getDocument()->getId(), $mention));
}
private function loginSessionUser(): void {
$currentSession = $this->getSession();
if (!$this->userSession->isLoggedIn()) {
$user = $this->userManager->get($currentSession->getUserId());
if ($user !== null) {
$this->restoreUser = true;
$this->userToRestore = $this->userSession->getUser();
$this->userSession->setUser($user);
}
}
}
private function restoreSessionUser(): void {
if ($this->restoreUser) {
$this->userSession->setUser($this->userToRestore);
}
}
}
@@ -0,0 +1,59 @@
<?php
/**
* @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\Controller;
use OCA\Text\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IConfig;
use OCP\IRequest;
class SettingsController extends Controller {
public const ACCEPTED_KEYS = [
'workspace_enabled'
];
public function __construct(string $appName, IRequest $request, private IConfig $config, private ?string $userId) {
parent::__construct($appName, $request);
}
/**
* @throws \OCP\PreConditionNotMetException
*
* @psalm-return DataResponse<200|400, array{workspace_enabled?: mixed, message?: 'Invalid config key'}, array<never, never>>
*/
#[NoAdminRequired]
public function updateConfig(string $key, int|string $value): DataResponse {
if (!in_array($key, self::ACCEPTED_KEYS, true)) {
return new DataResponse(['message' => 'Invalid config key'], Http::STATUS_BAD_REQUEST);
}
/** @psalm-suppress PossiblyNullArgument */
$this->config->setUserValue($this->userId, Application::APP_NAME, $key, (string)$value);
return new DataResponse([
$key => $value
]);
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace OCA\Text\Controller;
use OCA\Text\Db\Document;
use OCA\Text\Db\Session;
use OCA\Text\Exception\InvalidSessionException;
trait TSessionAwareController {
private ?Session $textSession = null;
private ?Document $document = null;
private ?string $userId = null;
public function setSession(?Session $session): void {
$this->textSession = $session;
}
public function setDocument(?Document $document): void {
$this->document = $document;
}
public function setUserId(?string $userId): void {
$this->userId = $userId;
}
public function getSession(): Session {
if ($this->textSession === null) {
throw new InvalidSessionException();
}
return $this->textSession;
}
public function getDocument(): Document {
if ($this->document === null) {
throw new InvalidSessionException();
}
return $this->document;
}
public function getUserId(): string {
if ($this->userId === null) {
throw new InvalidSessionException();
}
return $this->userId;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace OCA\Text\Controller;
use OCA\Text\Middleware\Attribute\RequireDocumentSession;
use OCA\Text\Service\SessionService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\Collaboration\Collaborators\ISearch;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\Share\IShare;
class UserApiController extends ApiController implements ISessionAwareController {
use TSessionAwareController;
public function __construct(
string $appName,
IRequest $request,
private SessionService $sessionService,
private ISearch $collaboratorSearch,
private IUserManager $userManager
) {
parent::__construct($appName, $request);
}
#[PublicPage]
#[NoAdminRequired]
#[RequireDocumentSession]
public function index(string $filter = '', int $limit = 5): DataResponse {
$sessions = $this->sessionService->getAllSessions($this->getSession()->getDocumentId());
$users = [];
// Add joined users to the autocomplete list
foreach ($sessions as $session) {
$sessionUserId = $session['userId'];
if ($sessionUserId !== null && !isset($users[$sessionUserId])) {
$displayName = $this->userManager->getDisplayName($sessionUserId);
if ($displayName && stripos($displayName, $filter) !== false || stripos($sessionUserId, $filter) !== false) {
$users[$sessionUserId] = $displayName;
}
}
}
if (!$this->getSession()->isGuest()) {
// Add other users to the autocomplete list
[$result] = $this->collaboratorSearch->search($filter, [IShare::TYPE_USER], false, $limit, 0);
$userSearch = array_merge($result['users'], $result['exact']['users']);
foreach ($userSearch as ['label' => $label, 'value' => $value]) {
if (isset($value['shareWith'])) {
$id = $value['shareWith'];
$users[$id] = $label;
}
}
}
return new DataResponse($users);
}
}
@@ -0,0 +1,222 @@
<?php
/**
* @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/>.
*
*/
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\Controller;
use Exception;
use OCA\Text\AppInfo\Application;
use OCA\Text\DirectEditing\TextDocumentCreator;
use OCA\Text\Service\WorkspaceService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\Constants;
use OCP\DirectEditing\IManager as IDirectEditingManager;
use OCP\DirectEditing\RegisterDirectEditorEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\ISession;
use OCP\IURLGenerator;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager;
use Psr\Log\LoggerInterface;
class WorkspaceController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private IRootFolder $rootFolder,
private IManager $shareManager,
private IDirectEditingManager $directEditingManager,
private IURLGenerator $urlGenerator,
private WorkspaceService $workspaceService,
private IEventDispatcher $eventDispatcher,
private LoggerInterface $logger,
private ISession $session,
private ?string $userId
) {
parent::__construct($appName, $request);
}
/**
* Checks for available files in the current folder and returns required details to present
* the rich workspace
*/
#[NoAdminRequired]
public function folder(string $path = '/'): DataResponse {
/** */
try {
/** @psalm-suppress PossiblyNullArgument */
$userFolder = $this->rootFolder->getUserFolder($this->userId);
$folder = $userFolder->get($path);
if ($folder instanceof Folder) {
$file = $this->workspaceService->getFile($folder);
if ($file === null) {
return new DataResponse([
'message' => 'No workspace file found',
'folder' => [
'permissions' => $folder->getPermissions()
]
], Http::STATUS_NOT_FOUND);
}
return new DataResponse([
'file' => [
'id' => $file->getId(),
'mimetype' => $file->getMimetype(),
'name' => $file->getName(),
'path' => $userFolder->getRelativePath($file->getPath())
],
'folder' => [
'permissions' => $folder->getPermissions()
]
]);
}
} catch (NotFoundException|NotPermittedException) {
return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
} catch (Exception $e) {
$this->logger->error('Failed to get workspace file', ['exception' => $e]);
return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
}
return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
}
/**
* Checks for available files in the current folder and returns required details to present
* the rich workspace
* @api
*/
#[NoAdminRequired]
#[PublicPage]
public function publicFolder(string $shareToken, string $path = '/'): DataResponse {
try {
$share = $this->shareManager->getShareByToken($shareToken);
if (!($share->getPermissions() & Constants::PERMISSION_READ)) {
throw new ShareNotFound();
}
/** @psalm-suppress RedundantConditionGivenDocblockType */
if ($share->getPassword() !== null) {
$shareId = $this->session->get('public_link_authenticated');
if ($share->getId() !== $shareId) {
throw new ShareNotFound();
}
}
$shareNode = $share->getNode();
$node = $shareNode instanceof File ? $shareNode : $shareNode->get($path);
if ($node instanceof Folder) {
$file = $this->workspaceService->getFile($node);
if ($file === null) {
return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
}
return new DataResponse([
'file' => [
'id' => $file->getId(),
'mimetype' => $file->getMimetype(),
'name' => $file->getName(),
'path' => $path . '/' . $file->getName()
]
]);
}
} catch (NotFoundException|ShareNotFound) {
return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
} catch (Exception $e) {
$this->logger->error('Failed to get public workspace file', ['exception' => $e]);
return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
}
return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
}
#[NoAdminRequired]
public function direct(string $path): DataResponse {
$this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager));
try {
/** @psalm-suppress PossiblyNullArgument */
$folder = $this->rootFolder->getUserFolder($this->userId)->get($path);
if ($folder instanceof Folder) {
$file = $this->getFile($folder);
if ($file === null) {
$token = $this->directEditingManager->create($path . '/'. $this->workspaceService->getSupportedFilenames()[0], Application::APP_NAME, TextDocumentCreator::CREATOR_ID);
} else {
$token = $this->directEditingManager->open($path . '/'. $file->getName(), Application::APP_NAME);
}
return new DataResponse([
'url' => $this->urlGenerator->linkToRouteAbsolute('files.DirectEditingView.edit', ['token' => $token])
]);
}
} catch (Exception $e) {
$this->logger->error('Exception when creating a new file through direct editing', ['exception' => $e]);
return new DataResponse('Failed to create file', Http::STATUS_FORBIDDEN);
}
return new DataResponse(['message' => 'No workspace file found'], Http::STATUS_NOT_FOUND);
}
private function getFile(Folder $folder): ?File {
$file = null;
foreach ($this->workspaceService->getSupportedFilenames() as $filename) {
try {
$node = $folder->get($filename);
if ($node instanceof File) {
$file = $node;
}
} catch (NotFoundException) {
}
}
return $file;
}
}
@@ -0,0 +1,83 @@
<?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\Cron;
use OCA\Text\Service\AttachmentService;
use OCA\Text\Service\DocumentService;
use OCA\Text\Service\SessionService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
class Cleanup extends TimedJob {
private SessionService $sessionService;
private DocumentService $documentService;
private LoggerInterface $logger;
private AttachmentService $attachmentService;
public function __construct(ITimeFactory $time,
SessionService $sessionService,
DocumentService $documentService,
AttachmentService $attachmentService,
LoggerInterface $logger) {
parent::__construct($time);
$this->sessionService = $sessionService;
$this->documentService = $documentService;
$this->attachmentService = $attachmentService;
$this->logger = $logger;
$this->setInterval(SessionService::SESSION_VALID_TIME);
}
/**
* @param array $argument
* @return void
*/
protected function run($argument) {
$this->logger->debug('Run cleanup job for text documents');
$documents = $this->documentService->getAll();
foreach ($documents as $document) {
$allSessions = $this->sessionService->getAllSessions($document->getId());
if (count($allSessions) > 0) {
// Do not reset if there are any sessions left
// Inactive sessions will get removed further down and will trigger a reset next time
continue;
}
if ($this->documentService->hasUnsavedChanges($document)) {
continue;
}
$this->documentService->resetDocument($document->getId());
}
$this->logger->debug('Run cleanup job for text sessions');
$removedSessions = $this->sessionService->removeInactiveSessionsWithoutSteps(null);
$this->logger->debug('Removed ' . $removedSessions . ' inactive sessions');
}
}
@@ -0,0 +1,130 @@
<?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\DAV;
use OC\Files\Node\File;
use OC\Files\Node\Folder;
use OCA\DAV\Connector\Sabre\Directory;
use OCA\DAV\Files\FilesHome;
use OCA\Text\AppInfo\Application;
use OCA\Text\Service\WorkspaceService;
use OCP\Files\IRootFolder;
use OCP\Files\StorageNotAvailableException;
use OCP\IConfig;
use Sabre\DAV\INode;
use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
class WorkspacePlugin extends ServerPlugin {
public const WORKSPACE_PROPERTY = '{http://nextcloud.org/ns}rich-workspace';
public const WORKSPACE_FILE_PROPERTY = '{http://nextcloud.org/ns}rich-workspace-file';
/** @var Server */
private $server;
/** @var WorkspaceService */
private $workspaceService;
/** @var IRootFolder */
private $rootFolder;
/** @var IConfig */
private $config;
/** @var string|null */
private $userId;
public function __construct(WorkspaceService $workspaceService, IRootFolder $rootFolder, IConfig $config, $userId) {
$this->workspaceService = $workspaceService;
$this->rootFolder = $rootFolder;
$this->config = $config;
$this->userId = $userId;
}
/**
* This initializes the plugin.
*
* This function is called by Sabre\DAV\Server, after
* addPlugin is called.
*
* This method should set up the required event subscriptions.
*
* @param Server $server
* @return void
*/
public function initialize(Server $server) {
$this->server = $server;
$this->server->on('propFind', [$this, 'propFind']);
}
public function propFind(PropFind $propFind, INode $node) {
if (!in_array(self::WORKSPACE_PROPERTY, $propFind->getRequestedProperties())
&& !in_array(self::WORKSPACE_FILE_PROPERTY, $propFind->getRequestedProperties())) {
return;
}
if (!$node instanceof Directory && !$node instanceof FilesHome) {
return;
}
$workspaceAvailable = $this->config->getAppValue(Application::APP_NAME, 'workspace_available', '1') === '1';
$workspaceEnabled = $this->config->getUserValue($this->userId, Application::APP_NAME, 'workspace_enabled', '1') === '1';
if (!$workspaceAvailable || !$workspaceEnabled) {
return;
}
$file = null;
$owner = $this->userId ?? $node->getFileInfo()->getStorage()->getOwner('');
/** @var Folder[] $nodes */
$nodes = $this->rootFolder->getUserFolder($owner)->getById($node->getId());
if (count($nodes) > 0) {
/** @var File $file */
try {
$file = $this->workspaceService->getFile($nodes[0]);
} catch (StorageNotAvailableException $e) {
// If a storage is not available we can for the propfind response assume that there is no rich workspace present
}
}
// Only return the property for the parent node and ignore it for further in depth nodes
$propFind->handle(self::WORKSPACE_PROPERTY, function () use ($file) {
if ($file instanceof File) {
return $file->getContent();
}
return '';
});
$propFind->handle(self::WORKSPACE_FILE_PROPERTY, function () use ($file) {
if ($file instanceof File) {
return $file->getFileInfo()->getId();
}
return '';
});
}
}
@@ -0,0 +1,70 @@
<?php
/**
* @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\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method getId(): int
* @method getCurrentVersion(): int
* @method setCurrentVersion(int $version): void
* @method getLastSavedVersion(): int
* @method setLastSavedVersion(int $version): void
* @method getInitialVersion(): int
* @method setInitialVersion(int $version): void
* @method getLastSavedVersionTime(): int
* @method setLastSavedVersionTime(int $time): void
* @method getLastSavedVersionEtag(): string
* @method setLastSavedVersionEtag(string $etag): void
* @method getBaseVersionEtag(): string
* @method setBaseVersionEtag(string $etag): void
*/
class Document extends Entity implements \JsonSerializable {
public $id = null;
// TODO: Remove obsolete field `currentVersion`
protected int $currentVersion = 0;
protected int $lastSavedVersion = 0;
protected int $initialVersion = 0;
protected int $lastSavedVersionTime = 0;
protected string $lastSavedVersionEtag = '';
protected string $baseVersionEtag = '';
public function __construct() {
$this->addType('id', 'integer');
$this->addType('currentVersion', 'integer');
$this->addType('lastSavedVersion', 'integer');
$this->addType('lastSavedVersionTime', 'integer');
$this->addType('initialVersion', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'lastSavedVersion' => $this->lastSavedVersion,
'lastSavedVersionTime' => $this->lastSavedVersionTime,
'baseVersionEtag' => $this->baseVersionEtag,
'initialVersion' => $this->initialVersion
];
}
}
@@ -0,0 +1,66 @@
<?php
/**
* @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\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/** @extends QBMapper<Document> */
class DocumentMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'text_documents', Document::class);
}
/**
* @param $documentId
* @return Document
* @throws DoesNotExistException
*/
public function find(int $documentId): Document {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$result = $qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($documentId)))
->executeQuery();
$data = $result->fetch();
$result->closeCursor();
if ($data === false) {
throw new DoesNotExistException('Document doesn\'t exist');
}
return Document::fromRow($data);
}
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$result = $qb->select('*')
->from($this->getTableName())
->executeQuery();
return $this->findEntities($qb);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* @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\Db;
use JsonSerializable;
use OCA\Text\Exception\InvalidSessionException;
use OCP\AppFramework\Db\Entity;
/**
* @method void setUserId(?string $userId)
* @method string getToken()
* @method void setToken(string $token)
* @method string getColor()
* @method void setColor(string $color)
* @method string|null getGuestName()
* @method void setGuestName(string $guestName)
* @method string|null getAwarenessMessage()
* @method void setLastAwarenessMessage(string $message)
* @method int getLastContact()
* @method void setLastContact(int $getTime)
* @method int getDocumentId()
* @method void setDocumentId(int $documentId)
*/
class Session extends Entity implements JsonSerializable {
public $id;
protected ?string $userId = null;
protected string $token = '';
protected string $color = '';
protected ?string $guestName = null;
protected ?string $lastAwarenessMessage = '';
protected int $lastContact = 0;
protected int $documentId = 0;
public function __construct() {
$this->addType('id', 'integer');
$this->addType('documentId', 'integer');
$this->addType('lastContact', 'integer');
}
public function isGuest(): bool {
return $this->userId === null;
}
public function getUserId(): string {
if ($this->userId === null) {
throw new InvalidSessionException('No user id found');
}
return $this->userId;
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'userId' => $this->userId,
'token' => $this->token,
'color' => $this->color,
'lastAwarenessMessage' => $this->lastAwarenessMessage,
'lastContact' => $this->lastContact,
'guestName' => $this->guestName,
'documentId' => $this->documentId,
];
}
}
@@ -0,0 +1,174 @@
<?php
/**
* @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\Db;
use OCA\Text\Service\SessionService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/** @extends QBMapper<Session> */
class SessionMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'text_sessions', Session::class);
}
/**
* @return array
*/
public function findAllDocuments(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName());
return $this->findEntities($qb);
}
/**
* @param $documentId
* @param $sessionId
* @param $token
* @return Session
* @throws DoesNotExistException
*/
public function find(int $documentId, int $sessionId, string $token): Session {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$result = $qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($sessionId)))
->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token)))
->executeQuery();
$data = $result->fetch();
$result->closeCursor();
if ($data === false) {
throw new DoesNotExistException('Session is invalid');
}
return Session::fromRow($data);
}
/**
* @return Session[]
*
* @psalm-return array<Session>
*/
public function findAll(int $documentId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'color', 'document_id', 'last_awareness_message', 'last_contact', 'user_id', 'guest_name')
->from($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)));
return $this->findEntities($qb);
}
/**
* @return Session[]
*
* @psalm-return array<Session>
*/
public function findAllActive(int $documentId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'color', 'document_id', 'last_awareness_message', 'last_contact', 'user_id', 'guest_name')
->from($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
->andWhere($qb->expr()->gt('last_contact', $qb->createNamedParameter(time() - SessionService::SESSION_VALID_TIME)));
return $this->findEntities($qb);
}
/**
* @return Session[]
*
* @psalm-return array<Session>
*/
public function findAllInactive(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'color', 'document_id', 'last_awareness_message', 'last_contact', 'user_id', 'guest_name')
->from($this->getTableName())
->where($qb->expr()->lt('last_contact', $qb->createNamedParameter(time() - SessionService::SESSION_VALID_TIME)));
return $this->findEntities($qb);
}
public function deleteInactiveWithoutSteps(?int $documentId = null): int {
$lastContact = time() - SessionService::SESSION_VALID_TIME;
$inactiveSessionBuilder = $this->db->getQueryBuilder();
$inactiveSessionBuilder->select('s.id')
->from('text_sessions', 's')
->leftJoin('s', 'text_steps', 'st', $inactiveSessionBuilder->expr()->eq('st.session_id', 's.id'))
->where($inactiveSessionBuilder->expr()->lt('last_contact', $inactiveSessionBuilder->createNamedParameter($lastContact)))
->andWhere($inactiveSessionBuilder->expr()->isNull('st.id'));
if ($documentId !== null) {
$inactiveSessionBuilder->andWhere($inactiveSessionBuilder->expr()->eq('s.document_id', $inactiveSessionBuilder->createNamedParameter($documentId)));
}
$result = $inactiveSessionBuilder->executeQuery();
$documentIds = array_map(function ($row) {
return (int)$row['id'];
}, $result->fetchAll());
$result->closeCursor();
$chunks = array_chunk($documentIds, 500);
$deleteBuilder = $this->db->getQueryBuilder();
$deleteBuilder->delete($this->getTableName())
->where($deleteBuilder->expr()->in('id', $deleteBuilder->createParameter('ids'), IQueryBuilder::PARAM_INT_ARRAY));
$deletedCount = 0;
foreach ($chunks as $ids) {
$deleteBuilder->setParameter('ids', $ids, IQueryBuilder::PARAM_INT_ARRAY);
$deletedCount += $deleteBuilder->executeStatement();
}
return $deletedCount;
}
public function deleteByDocumentId(int $documentId): int {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)));
return $qb->executeStatement();
}
public function isUserInDocument(int $documentId, string $userId): bool {
$qb = $this->db->getQueryBuilder();
$result = $qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
->setMaxResults(1)
->executeQuery();
$data = $result->fetch();
$result->closeCursor();
if ($data === false) {
return false;
}
return true;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* @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\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method getData(): string
* @method setData(string $data): void
* @method getVersion(): int
* @method setVersion(int $version): void
* @method getSessionId(): int
* @method setSessionId(int $sessionId): void
* @method getDocumentId(): int
* @method setDocumentId(int $documentId): void
*/
class Step extends Entity implements JsonSerializable {
/*
* Transition: We now use the auto-incrementing id as the version.
* To ensure that new steps always have a larger version than those that
* used the version field, use the largest possible 32-bit integer value.
*/
public const VERSION_STORED_IN_ID = 2147483647;
public $id = null;
protected string $data = '';
protected int $version = 0;
protected int $sessionId = 0;
protected int $documentId = 0;
public function __construct() {
$this->addType('id', 'integer');
$this->addType('version', 'integer');
$this->addType('documentId', 'integer');
$this->addType('sessionId', 'integer');
}
public function jsonSerialize(): array {
$jsonData = \json_decode($this->data, false);
if (\json_last_error() !== JSON_ERROR_NONE) {
throw new \InvalidArgumentException('Failed to parse step data');
}
$version = $this->getVersion() === self::VERSION_STORED_IN_ID
? $this->getId()
: $this->getVersion();
return [
'id' => $this->getId(),
'data' => $jsonData,
'version' => $version,
'sessionId' => $this->getSessionId()
];
}
}
@@ -0,0 +1,96 @@
<?php
/**
* @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\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/** @extends QBMapper<Step> */
class StepMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'text_steps', Step::class);
}
/**
* @return Step[]
*/
public function find(int $documentId, int $fromVersion): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
->andWhere($qb->expr()->gt('version', $qb->createNamedParameter($fromVersion)))
->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($fromVersion)));
$qb
->setMaxResults(1000)
->orderBy('id');
return $this->findEntities($qb);
}
public function getLatestVersion(int $documentId): ?int {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$result = $qb->select('id')
->from($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
->setMaxResults(1)
->orderBy('id', 'DESC')
->executeQuery();
$data = $result->fetch();
if ($data === false) {
return null;
}
return $data['id'];
}
public function deleteAll(int $documentId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
->executeStatement();
}
// not in use right now
public function deleteBeforeVersion(int $documentId, int $version): int {
$qb = $this->db->getQueryBuilder();
return $qb->delete($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
->andWhere($qb->expr()->lte('id', $qb->createNamedParameter($version)))
->executeStatement();
}
public function deleteAfterVersion(int $documentId, int $version): int {
$qb = $this->db->getQueryBuilder();
return $qb->delete($this->getTableName())
->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
->andWhere($qb->expr()->gt('version', $qb->createNamedParameter($version)))
->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($version)))
->executeStatement();
}
}
@@ -0,0 +1,170 @@
<?php
/**
* @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\DirectEditing;
use OCA\Text\AppInfo\Application;
use OCA\Text\Service\ApiService;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\DirectEditing\IEditor;
use OCP\DirectEditing\IToken;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IL10N;
use OCP\Util;
class TextDirectEditor implements IEditor {
/** @var IL10N */
private $l10n;
/** @var IInitialState */
private $initialStateService;
/** @var ApiService */
private $apiService;
public function __construct(IL10N $l10n, IInitialState $initialStateService, ApiService $apiService) {
$this->l10n = $l10n;
$this->initialStateService = $initialStateService;
$this->apiService = $apiService;
}
/**
* Return a unique identifier for the editor
*
* e.g. richdocuments
*
* @return string
*/
public function getId(): string {
return Application::APP_NAME;
}
/**
* Return a readable name for the editor
*
* e.g. Collabora Online
*
* @return string
*/
public function getName(): string {
return $this->l10n->t('Nextcloud Text');
}
/**
* A list of mimetypes that should open the editor by default
*
* @return string[]
*/
public function getMimetypes(): array {
return [
'text/markdown',
'text/plain',
'application/cmd',
'application/x-empty',
'application/x-msdos-program',
'application/javascript',
'application/json',
'application/x-perl',
'application/x-php',
'application/x-tex',
'application/xml',
'application/yaml',
'text/css',
'text/csv',
'text/html',
'text/org',
'text/x-c',
'text/x-c++src',
'text/x-h',
'text/x-java-source',
'text/x-ldif',
'text/x-python',
'text/x-shellscript',
];
}
/**
* A list of mimetypes that can be opened in the editor optionally
*
* @return string[]
*/
public function getMimetypesOptional(): array {
return [];
}
/**
* Return a list of file creation options to be presented to the user
*
* @return TextDocumentCreator[]
*/
public function getCreators(): array {
return [
new TextDocumentCreator($this->l10n),
];
}
/**
* Return if the view is able to securely view a file without downloading it to the browser
*
* @return bool
*/
public function isSecure(): bool {
return false;
}
/**
* Return a template response for displaying the editor
*
* open can only be called once when the client requests the editor with a one-time-use token
* For handling editing and later requests, editors need to impelement their own token handling and take care of invalidation
*
* This behavior is similar to the current direct editing implementation in collabora where we generate a one-time token and switch over to the regular wopi token for the actual editing/saving process
*
* @param IToken $token
* @return Response
*/
public function open(IToken $token): Response {
$token->useTokenScope();
try {
$session = $this->apiService->create($token->getFile()->getId());
$this->initialStateService->provideInitialState('file', [
'fileId' => $token->getFile()->getId(),
'mimetype' => $token->getFile()->getMimeType(),
'session' => \json_encode($session->getData())
]);
$this->initialStateService->provideInitialState('directEditingToken', $token->getToken());
Util::addScript('text', 'text-text');
return new TemplateResponse('text', 'main', [], 'base');
} catch (InvalidPathException $e) {
} catch (NotFoundException $e) {
} catch (NotPermittedException $e) {
}
return new NotFoundResponse();
}
}
@@ -0,0 +1,56 @@
<?php
/**
* @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\DirectEditing;
use OCP\DirectEditing\ACreateEmpty;
use OCP\IL10N;
class TextDocumentCreator extends ACreateEmpty {
public const CREATOR_ID = 'textdocument';
/**
* @var IL10N
*/
private $l10n;
public function __construct(IL10N $l10n) {
$this->l10n = $l10n;
}
public function getId(): string {
return self::CREATOR_ID;
}
public function getName(): string {
return $this->l10n->t('text document');
}
public function getExtension(): string {
return 'md';
}
public function getMimetype(): string {
return 'text/markdown';
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* @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\Event;
use OCP\EventDispatcher\Event;
class LoadEditor extends Event {
}
@@ -0,0 +1,30 @@
<?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\Exception;
class DocumentHasUnsavedChangesException extends \Exception {
}
@@ -0,0 +1,30 @@
<?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\Exception;
class DocumentSaveConflictException extends \Exception {
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace OCA\Text\Exception;
class InvalidSessionException extends \Exception {
}
@@ -0,0 +1,30 @@
<?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\Exception;
class UploadException extends \Exception {
}
@@ -0,0 +1,35 @@
<?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\Exception;
use OCP\AppFramework\Http;
class VersionMismatchException extends \Exception {
public function getStatus(): int {
return Http::STATUS_PRECONDITION_FAILED;
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 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\Listeners;
use OCP\DB\Events\AddMissingIndicesEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/**
* @template-implements IEventListener<Event|AddMissingIndicesEvent>
*/
class AddMissingIndicesListener implements IEventListener {
public function handle(Event $event): void {
if (!$event instanceof AddMissingIndicesEvent) {
return;
}
$event->addMissingIndex('text_steps', 'textstep_session', ['session_id']);
}
}
@@ -0,0 +1,36 @@
<?php
namespace OCA\Text\Listeners;
use OCA\Text\AppInfo\Application;
use OCA\TPAssistant\Event\BeforeAssistantNotificationEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IURLGenerator;
/** @template-implements IEventListener<BeforeAssistantNotificationEvent|Event> */
class BeforeAssistantNotificationListener implements \OCP\EventDispatcher\IEventListener {
public function __construct(
private IURLGenerator $urlGenerator,
) {
}
/**
* @inheritDoc
*/
public function handle(Event $event): void {
if (!$event instanceof BeforeAssistantNotificationEvent) {
return;
}
$task = $event->getTask();
if ($task->getAppId() !== Application::APP_NAME || $task->getUserId() === null) {
return;
}
$fileId = (int)str_replace('text-file:', '', $task->getIdentifier());
$fileLink = $this->urlGenerator->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $fileId]);
$event->setWantsNotification(true);
$event->setNotificationTarget($fileLink);
}
}
@@ -0,0 +1,53 @@
<?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\Listeners;
use OCA\Text\Service\AttachmentService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
use OCP\Files\File;
/**
* @template-implements IEventListener<Event|BeforeNodeDeletedEvent>
*/
class BeforeNodeDeletedListener implements IEventListener {
private $attachmentService;
public function __construct(AttachmentService $attachmentService) {
$this->attachmentService = $attachmentService;
}
public function handle(Event $event): void {
if (!$event instanceof BeforeNodeDeletedEvent) {
return;
}
$node = $event->getNode();
if ($node instanceof File && $node->getMimeType() === 'text/markdown') {
$this->attachmentService->deleteAttachments($node);
}
}
}
@@ -0,0 +1,57 @@
<?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\Listeners;
use OCA\Text\Service\AttachmentService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
use OCP\Files\File;
/**
* @template-implements IEventListener<Event|BeforeNodeRenamedEvent>
*/
class BeforeNodeRenamedListener implements IEventListener {
private AttachmentService $attachmentService;
public function __construct(AttachmentService $attachmentService) {
$this->attachmentService = $attachmentService;
}
public function handle(Event $event): void {
if (!$event instanceof BeforeNodeRenamedEvent) {
return;
}
$source = $event->getSource();
$target = $event->getTarget();
if ($source instanceof File
&& $source->getMimeType() === 'text/markdown'
&& $target instanceof File
) {
$this->attachmentService->moveAttachments($source, $target);
}
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020 Morris Jobke <hey@morrisjobke.de>
*
* @author Morris Jobke <hey@morrisjobke.de>
*
* @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\Listeners;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Text\Service\InitialStateProvider;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/**
* @implements IEventListener<Event>
*/
class FilesLoadAdditionalScriptsListener implements IEventListener {
private InitialStateProvider $initialStateProvider;
public function __construct(InitialStateProvider $initialStateProvider) {
$this->initialStateProvider = $initialStateProvider;
}
public function handle(Event $event): void {
if (!$event instanceof LoadAdditionalScriptsEvent) {
return;
}
\OCP\Util::addInitScript('text', 'text-init');
\OCP\Util::addScript('text', 'text-files');
$this->initialStateProvider->provideState();
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020 Morris Jobke <hey@morrisjobke.de>
*
* @author Morris Jobke <hey@morrisjobke.de>
*
* @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\Listeners;
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
use OCA\Text\Service\InitialStateProvider;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IConfig;
use OCP\Util;
/** @implements IEventListener<Event|BeforeTemplateRenderedEvent> */
class FilesSharingLoadAdditionalScriptsListener implements IEventListener {
protected InitialStateProvider $initialStateProvider;
public function __construct(IConfig $config, InitialStateProvider $initialStateProvider) {
$this->initialStateProvider = $initialStateProvider;
}
public function handle(Event $event): void {
if (!$event instanceof BeforeTemplateRenderedEvent) {
return;
}
Util::addScript('text', 'text-public');
$this->initialStateProvider->provideState();
$node = $event->getShare()->getNode();
if ($node instanceof \OCP\Files\File) {
$this->initialStateProvider->provideFileId($node->getId());
}
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* @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\Listeners;
use OCA\Text\Event\LoadEditor;
use OCA\Text\Service\InitialStateProvider;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
/** @template-implements IEventListener<Event|LoadEditor> */
class LoadEditorListener implements \OCP\EventDispatcher\IEventListener {
public function __construct(
private InitialStateProvider $initialStateProvider,
private IEventDispatcher $eventDispatcher
) {
}
public function handle(Event $event): void {
if (!$event instanceof LoadEditor) {
return;
}
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
$this->initialStateProvider->provideState();
Util::addScript('text', 'text-editors');
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020 Morris Jobke <hey@morrisjobke.de>
*
* @author Morris Jobke <hey@morrisjobke.de>
*
* @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\Listeners;
use OCA\Text\Service\InitialStateProvider;
use OCA\Viewer\Event\LoadViewer;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
/** @implements IEventListener<Event|LoadViewer> */
class LoadViewerListener implements IEventListener {
private InitialStateProvider $initialStateProvider;
private IEventDispatcher $eventDispatcher;
public function __construct(InitialStateProvider $initialStateProvider,
IEventDispatcher $eventDispatcher) {
$this->initialStateProvider = $initialStateProvider;
$this->eventDispatcher = $eventDispatcher;
}
public function handle(Event $event): void {
if (!$event instanceof LoadViewer) {
return;
}
Util::addScript('text', 'text-viewer', 'viewer');
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
$this->initialStateProvider->provideState();
}
}
@@ -0,0 +1,58 @@
<?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\Listeners;
use OCA\Text\Service\AttachmentService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\NodeCopiedEvent;
use OCP\Files\File;
/**
* @template-implements IEventListener<Event|NodeCopiedEvent>
*/
class NodeCopiedListener implements IEventListener {
private $attachmentService;
public function __construct(AttachmentService $attachmentService) {
$this->attachmentService = $attachmentService;
}
public function handle(Event $event): void {
if (!$event instanceof NodeCopiedEvent) {
return;
}
$source = $event->getSource();
$target = $event->getTarget();
if ($source instanceof File
&& $source->getMimeType() === 'text/markdown'
&& $target instanceof File
&& $target->getMimeType() === 'text/markdown'
) {
$this->attachmentService->copyAttachments($source, $target);
}
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020 Morris Jobke <hey@morrisjobke.de>
*
* @author Morris Jobke <hey@morrisjobke.de>
*
* @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\Listeners;
use OCA\Text\DirectEditing\TextDirectEditor;
use OCP\DirectEditing\RegisterDirectEditorEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/** @implements IEventListener<Event|RegisterDirectEditorEvent> */
class RegisterDirectEditorEventListener implements IEventListener {
/** @var TextDirectEditor */
protected $editor;
public function __construct(TextDirectEditor $editor) {
$this->editor = $editor;
}
public function handle(Event $event): void {
if (!$event instanceof RegisterDirectEditorEvent) {
return;
}
$event->register($this->editor);
}
}
@@ -0,0 +1,9 @@
<?php
namespace OCA\Text\Middleware\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class RequireDocumentSession {
}
@@ -0,0 +1,9 @@
<?php
namespace OCA\Text\Middleware\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class RequireDocumentSessionOrUserOrShareToken {
}
@@ -0,0 +1,128 @@
<?php
namespace OCA\Text\Middleware;
use OC\User\NoUserException;
use OCA\Text\Controller\ISessionAwareController;
use OCA\Text\Exception\InvalidSessionException;
use OCA\Text\Middleware\Attribute\RequireDocumentSession;
use OCA\Text\Middleware\Attribute\RequireDocumentSessionOrUserOrShareToken;
use OCA\Text\Service\DocumentService;
use OCA\Text\Service\SessionService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Middleware;
use OCP\Files\IRootFolder;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as ShareManager;
use ReflectionException;
class SessionMiddleware extends Middleware {
public function __construct(
private IRequest $request,
private SessionService $sessionService,
private DocumentService $documentService,
private IUserSession $userSession,
private IRootFolder $rootFolder,
private ShareManager $shareManager,
) {
}
/**
* @throws ReflectionException
* @throws InvalidSessionException
*/
public function beforeController(Controller $controller, string $methodName): void {
if (!$controller instanceof ISessionAwareController) {
return;
}
$reflectionMethod = new \ReflectionMethod($controller, $methodName);
if (!empty($reflectionMethod->getAttributes(RequireDocumentSessionOrUserOrShareToken::class))) {
try {
$this->assertDocumentSession($controller);
} catch (InvalidSessionException) {
$this->assertUserOrShareToken($controller);
}
}
if (!empty($reflectionMethod->getAttributes(RequireDocumentSession::class))) {
$this->assertDocumentSession($controller);
}
}
/**
* @throws InvalidSessionException
*/
private function assertDocumentSession(ISessionAwareController $controller): void {
$documentId = (int)$this->request->getParam('documentId');
$sessionId = (int)$this->request->getParam('sessionId');
$token = (string)$this->request->getParam('sessionToken');
$shareToken = (string)$this->request->getParam('token');
$session = $this->sessionService->getValidSession($documentId, $sessionId, $token);
if (!$session) {
throw new InvalidSessionException();
}
$document = $this->documentService->getDocument($documentId);
if (!$document) {
throw new InvalidSessionException();
}
$controller->setSession($session);
$controller->setDocument($document);
if (!$shareToken) {
$controller->setUserId($session->getUserId());
}
}
/**
* @throws NotPermittedException
* @throws NoUserException
* @throws InvalidSessionException
*/
private function assertUserOrShareToken(ISessionAwareController $controller): void {
$documentId = (int)$this->request->getParam('documentId');
if (null !== $userId = $this->userSession->getUser()?->getUID()) {
// Check if user has access to document
if (count($this->rootFolder->getUserFolder($userId)->getById($documentId)) === 0) {
throw new InvalidSessionException();
}
$controller->setUserId($userId);
} elseif ('' !== $shareToken = (string)$this->request->getParam('shareToken')) {
try {
$share = $this->shareManager->getShareByToken($shareToken);
} catch (ShareNotFound) {
throw new InvalidSessionException();
}
// Check if shareToken has access to document
if (count($this->rootFolder->getUserFolder($share->getShareOwner())->getById($documentId)) === 0) {
throw new InvalidSessionException();
}
} else {
throw new InvalidSessionException();
}
$document = $this->documentService->getDocument($documentId);
if (!$document) {
throw new InvalidSessionException();
}
$controller->setDocument($document);
}
public function afterException($controller, $methodName, \Exception $exception): DataResponse|Response {
if ($exception instanceof InvalidSessionException) {
return new DataResponse([], 403);
}
return parent::afterException($controller, $methodName, $exception);
}
}
@@ -0,0 +1,57 @@
<?php
namespace OCA\Text\Migration;
use OCA\Text\Db\SessionMapper;
use OCA\Text\Service\DocumentService;
use OCP\IConfig;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
class ResetSessionsBeforeYjs implements IRepairStep {
private IConfig $config;
private SessionMapper $sessionMapper;
private DocumentService $documentService;
public function __construct(IConfig $config,
SessionMapper $sessionMapper,
DocumentService $documentService) {
$this->config = $config;
$this->sessionMapper = $sessionMapper;
$this->documentService = $documentService;
}
/**
* @return string
*/
public function getName(): string {
return 'Force-reset all Text sessions before Yjs migration';
}
/**
* @param IOutput $output
*
* @return void
*/
public function run(IOutput $output): void {
$appVersion = $this->config->getAppValue('text', 'installed_version');
if (!$appVersion || version_compare($appVersion, '3.7.2') !== -1) {
return;
}
$sessions = $this->sessionMapper->findAllDocuments();
if (!$sessions) {
return;
}
$output->startProgress(count($sessions));
foreach ($sessions as $session) {
$documentId = $session->getDocumentId();
$this->documentService->unlock($documentId);
$this->documentService->resetDocument($documentId, true);
$output->advance();
}
$output->finishProgress();
}
}
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace OCA\Text\Migration;
use Closure;
use Doctrine\DBAL\Types\Types;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version010000Date20190617184535 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*
* @return void
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options) {
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('text_documents')) {
$table = $schema->createTable('text_documents');
$table->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('current_version', Types::BIGINT, [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]);
$table->addColumn('last_saved_version', Types::BIGINT, [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]);
$table->addColumn('last_saved_version_time', Types::BIGINT, [
'notnull' => true,
'length' => 20,
'unsigned' => true,
]);
$table->addColumn('last_saved_version_etag', Types::STRING, [
'notnull' => false,
'length' => 64,
'default' => ''
]);
$table->addColumn('base_version_etag', Types::STRING, [
'notnull' => false,
'length' => 64,
'default' => ''
]);
$table->setPrimaryKey(['id']);
}
if (!$schema->hasTable('text_sessions')) {
$table = $schema->createTable('text_sessions');
$table->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('user_id', Types::STRING, [
'notnull' => false,
'length' => 64,
]);
$table->addColumn('guest_name', Types::STRING, [
'notnull' => false,
'length' => 64,
]);
$table->addColumn('color', Types::STRING, [
'notnull' => false,
'length' => 7,
]);
$table->addColumn('token', Types::STRING, [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('document_id', Types::BIGINT, [
'notnull' => true,
]);
$table->addColumn('last_contact', Types::BIGINT, [
'notnull' => true,
'length' => 20,
'unsigned' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['token'], 'rd_session_token_idx');
$table->addIndex(['last_contact'], 'ts_lastcontact');
}
if (!$schema->hasTable('text_steps')) {
$table = $schema->createTable('text_steps');
$table->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('document_id', Types::BIGINT, [
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('session_id', Types::BIGINT, [
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('data', Types::TEXT, [
'notnull' => true,
]);
$table->addColumn('version', Types::BIGINT, [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['document_id'], 'rd_steps_did_idx');
$table->addIndex(['version'], 'rd_steps_version_idx');
$table->addIndex(['session_id'], 'textstep_session');
}
return $schema;
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*
* @return void
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options) {
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace OCA\Text\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version030001Date20200402075029 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('text_sessions');
$table->addIndex([
'document_id',
'last_contact',
], 'ts_docid_lastcontact');
return $schema;
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace OCA\Text\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\IConfig;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version030201Date20201116110353 extends SimpleMigrationStep {
/** @var bool */
private $isOracle;
public function __construct(IConfig $config) {
$this->isOracle = $config->getSystemValue('dbtype', 'sqlite') === 'oci';
}
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$this->ensureColumnIsNullable($schema, 'text_steps', 'version');
if ($this->isOracle) {
// Drop table if we are on oracle and recreate it with the next migration Version030201Date20201116123153
if ($schema->hasTable('text_documents')) {
$schema->dropTable('text_documents');
}
} else {
$this->ensureColumnIsNullable($schema, 'text_documents', 'current_version');
$this->ensureColumnIsNullable($schema, 'text_documents', 'last_saved_version');
$table = $schema->getTable('text_documents');
$column = $table->getColumn('id');
if ($column->getAutoincrement()) {
$table->changeColumn('id', [
'autoincrement' => false,
]);
}
}
return $schema;
}
protected function ensureColumnIsNullable(ISchemaWrapper $schema, string $tableName, string $columnName): bool {
$table = $schema->getTable($tableName);
$column = $table->getColumn($columnName);
if ($column->getNotnull()) {
$column->setNotnull(false);
return true;
}
return false;
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace OCA\Text\Migration;
use Closure;
use Doctrine\DBAL\Types\Types;
use OCP\DB\ISchemaWrapper;
use OCP\IConfig;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version030201Date20201116123153 extends SimpleMigrationStep {
/** @var bool */
private $isOracle;
public function __construct(IConfig $config) {
$this->isOracle = $config->getSystemValue('dbtype', 'sqlite') === 'oci';
}
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
if (!$this->isOracle) {
return null;
}
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('text_documents')) {
// Recreate table from the first migration since we cannot alter the autoincrement on the id column with oracle
$table = $schema->createTable('text_documents');
$table->addColumn('id', Types::BIGINT, [
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('current_version', Types::BIGINT, [
// 'notnull' => true,
'notnull' => false,
'default' => 0,
'unsigned' => true,
]);
$table->addColumn('last_saved_version', Types::BIGINT, [
// 'notnull' => true,
'notnull' => false,
'default' => 0,
'unsigned' => true,
]);
$table->addColumn('last_saved_version_time', Types::BIGINT, [
'length' => 20,
'unsigned' => true,
]);
$table->addColumn('last_saved_version_etag', Types::STRING, [
'notnull' => false,
'length' => 64,
'default' => ''
]);
$table->addColumn('base_version_etag', Types::STRING, [
'notnull' => false,
'length' => 64,
'default' => ''
]);
$table->setPrimaryKey(['id']);
return $schema;
}
return null;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace OCA\Text\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version030501Date20220202101853 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('text_sessions');
if (!$table->hasIndex('ts_lastcontact')) {
$table->addIndex(['last_contact'], 'ts_lastcontact');
return $schema;
}
return null;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace OCA\Text\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version030701Date20230207131313 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('text_sessions');
if (!$table->hasColumn('last_awareness_message')) {
$table->addColumn('last_awareness_message', \OCP\DB\Types::TEXT, [
'notnull' => false,
'default' => ''
]);
return $schema;
}
return null;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Julius Härtl <jus@bitgrid.net>
*
* @authorJulius 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\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version030901Date20231114150437 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();
if ($schema->hasTable('text_steps')) {
$table = $schema->getTable('text_steps');
if ($table->hasIndex('ts_session')) {
$table->dropIndex('ts_session');
return $schema;
}
}
return null;
}
}
@@ -0,0 +1,133 @@
<?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/>.
*
*/
declare(strict_types=1);
namespace OCA\Text\Notification;
use InvalidArgumentException;
use OC\User\NoUserException;
use OCP\Files\IRootFolder;
use OCP\Files\NotPermittedException;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\L10N\IFactory;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
class Notifier implements INotifier {
public const TYPE_MENTIONED = 'mentioned';
public const SUBJECT_MENTIONED_SOURCE_USER = 'sourceUser';
public const SUBJECT_MENTIONED_TARGET_USER = 'targetUser';
private IFactory $factory;
private IURLGenerator $url;
private IUserManager $userManager;
private IRootFolder $rootFolder;
public function __construct(IFactory $factory, IUserManager $userManager, IURLGenerator $urlGenerator, IRootFolder $rootFolder) {
$this->factory = $factory;
$this->userManager = $userManager;
$this->url = $urlGenerator;
$this->rootFolder = $rootFolder;
}
public function getID(): string {
return 'text';
}
public function getName(): string {
return 'Text';
}
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== 'text') {
throw new InvalidArgumentException('Application should be text instead of ' . $notification->getApp());
}
$l = $this->factory->get('text', $languageCode);
switch ($notification->getSubject()) {
case self::TYPE_MENTIONED:
$parameters = $notification->getSubjectParameters();
$sourceUser = $parameters[self::SUBJECT_MENTIONED_SOURCE_USER];
$sourceUserDisplayName = $this->userManager->getDisplayName($sourceUser);
$targetUser = $notification->getUser();
$fileId = (int)$notification->getObjectId();
if ($sourceUserDisplayName === null) {
throw new InvalidArgumentException();
}
try {
$userFolder = $this->rootFolder->getUserFolder($targetUser);
} catch (NotPermittedException|NoUserException $e) {
throw new InvalidArgumentException();
}
$nodes = $userFolder->getById($fileId);
$node = array_shift($nodes);
if ($node === null) {
throw new InvalidArgumentException();
}
$fileLink = $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $node->getId()]);
$notification->setRichSubject($l->t('{user} has mentioned you in the text document {node}'), [
'user' => [
'type' => 'user',
'id' => $sourceUser,
'name' => $sourceUserDisplayName,
],
'node' => [
'type' => 'file',
'id' => $node->getId(),
'name' => $node->getName(),
'path' => $userFolder->getRelativePath($node->getPath()),
'link' => $fileLink,
],
]);
break;
default:
throw new InvalidArgumentException();
}
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath('text', 'app-dark.svg')));
$notification->setLink($fileLink);
$this->setParsedSubjectFromRichSubject($notification);
return $notification;
}
protected function setParsedSubjectFromRichSubject(INotification $notification): void {
$placeholders = $replacements = [];
foreach ($notification->getRichSubjectParameters() as $placeholder => $parameter) {
$placeholders[] = '{' . $placeholder . '}';
if ($parameter['type'] === 'file') {
$replacements[] = $parameter['path'];
} else {
$replacements[] = $parameter['name'];
}
}
$notification->setParsedSubject(str_replace($placeholders, $replacements, $notification->getRichSubject()));
}
}
@@ -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);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?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;
use OCA\Text\Service\EncodingService;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
/**
* Wrapper around a ISimpleFile object to ensure that it is correctly encoded (UTF-8) for the text app.
*/
class TextFile implements ISimpleFile {
private ISimpleFile $file;
private EncodingService $encodingService;
public function __construct(ISimpleFile $file, EncodingService $encodingService) {
$this->file = $file;
$this->encodingService = $encodingService;
}
public function getName(): string {
return $this->file->getName();
}
public function getSize(): float|int {
return $this->file->getSize();
}
public function getETag(): string {
return $this->file->getETag();
}
public function getMTime(): int {
return $this->file->getMTime();
}
public function getContent(): string {
$content = $this->encodingService->encodeToUtf8($this->file->getContent());
if ($content === null) {
throw new NotFoundException('File not compatible with text because it could not be encoded to UTF-8.');
}
return $content;
}
public function putContent($data): void {
$this->file->putContent($data);
}
public function delete(): void {
$this->file->delete();
}
public function getMimeType(): string {
return 'text/plain;encoding=utf-8';
}
public function getExtension(): string {
return $this->file->getExtension();
}
public function read() {
return $this->file->read();
}
public function write() {
return $this->file->write();
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace OCA\Text;
use InvalidArgumentException;
/**
* Steps are base64 encoded messages of the yjs protocols
* https://github.com/yjs/y-protocols
*
* This class is a simple representation of a message containing some methods
* to decode parts of it for what we need on the backend
*
* Relevant resources:
* https://github.com/yjs/y-protocols/blob/master/PROTOCOL.md
* https://github.com/yjs/y-websocket/blob/master/src/y-websocket.js#L19-L22
* https://github.com/yjs/y-protocols/blob/master/sync.js#L38-L40
* https://github.com/dmonad/lib0/blob/master/decoding.js
*/
class YjsMessage {
public const YJS_MESSAGE_SYNC = 0;
public const YJS_MESSAGE_AWARENESS = 1;
public const YJS_MESSAGE_AWARENESS_QUERY = 3;
public const YJS_MESSAGE_SYNC_STEP1 = 0;
public const YJS_MESSAGE_SYNC_STEP2 = 1;
public const YJS_MESSAGE_SYNC_UPDATE = 2;
private int $pos = 0;
public function __construct(
private string $data = ''
) {
}
public static function fromBase64(string $data = ''): self {
return new self(base64_decode($data));
}
/**
* https://github.com/dmonad/lib0/blob/bd69ab4dc701d77e808f2bab08d96d63acd297da/decoding.js#L242
*/
public function readVarUint(): int {
$bytes = array_values(unpack('C*', $this->data));
$num = 0;
$mult = 1;
$len = count($bytes);
while ($this->pos < $len) {
$r = $bytes[$this->pos++];
// num = num | ((r & binary.BITS7) << len)
$num = $num + ($r & 0b1111111) * $mult;
$mult *= 128;
if ($r <= 0b1111111) {
return $num;
}
// Number.MAX_SAFE_INTEGER in JS
if ($num > 9007199254740990) {
throw new \OutOfBoundsException();
}
}
throw new InvalidArgumentException();
}
public function getYjsMessageType(): int {
$oldPos = $this->pos;
$this->pos = 0;
$messageType = $this->readVarUint();
$this->pos = $oldPos;
return $messageType;
}
public function getYjsSyncType(): int {
$oldPos = $this->pos;
$this->pos = 0;
$messageType = $this->readVarUint();
if ($messageType !== self::YJS_MESSAGE_SYNC) {
throw new \ValueError('Message is not a sync message');
}
$syncType = $this->readVarUint();
$this->pos = $oldPos;
return $syncType;
}
}