diff --git a/appinfo/info.xml b/appinfo/info.xml index 6dc7cdd8b44..c8335c42950 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -15,7 +15,7 @@ - **๐Ÿ’พ Open format:** Files are saved as [Markdown](https://en.wikipedia.org/wiki/Markdown), so you can edit them from any other text app too. - **โœŠ Strong foundation:** We use [๐Ÿˆ tiptap](https://tiptap.scrumpy.io) which is based on [๐Ÿฆ‰ ProseMirror](https://prosemirror.net) โ€“ huge thanks to them! ]]> - 9.0.0-dev.0 + 9.0.0-dev.1 agpl Julius Hรคrtl Text diff --git a/appinfo/routes.php b/appinfo/routes.php index 0eb6c066aad..7495440142d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -28,7 +28,7 @@ ['name' => 'Attachment#getMediaFilePreview', 'url' => '/mediaPreview', 'verb' => 'GET'], /** @see Controller\SessionController::create() */ - ['name' => 'Session#create', 'url' => '/session/{documentId}/create', 'verb' => 'PUT'], + ['name' => 'Session#create', 'url' => '/session/{type}/{id}/create', 'verb' => 'PUT'], /** @see Controller\SessionController::save() */ ['name' => 'Session#save', 'url' => '/session/{documentId}/save', 'verb' => 'POST'], /** @see Controller\SessionController::sync() */ diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index 29e480fbcd4..71ef7775e67 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -10,6 +10,10 @@ 'OCA\\Text\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php', 'OCA\\Text\\Command\\ResetDocument' => $baseDir . '/../lib/Command/ResetDocument.php', 'OCA\\Text\\ConfigLexicon' => $baseDir . '/../lib/ConfigLexicon.php', + 'OCA\\Text\\Context\\ContextManager' => $baseDir . '/../lib/Context/ContextManager.php', + 'OCA\\Text\\Context\\FileContext' => $baseDir . '/../lib/Context/FileContext.php', + 'OCA\\Text\\Context\\FileContextFactory' => $baseDir . '/../lib/Context/FileContextFactory.php', + 'OCA\\Text\\Context\\IContext' => $baseDir . '/../lib/Context/IContext.php', 'OCA\\Text\\Controller\\AiController' => $baseDir . '/../lib/Controller/AiController.php', 'OCA\\Text\\Controller\\AttachmentController' => $baseDir . '/../lib/Controller/AttachmentController.php', 'OCA\\Text\\Controller\\ISessionAwareController' => $baseDir . '/../lib/Controller/ISessionAwareController.php', @@ -32,6 +36,7 @@ 'OCA\\Text\\DirectEditing\\TextDocumentCreator' => $baseDir . '/../lib/DirectEditing/TextDocumentCreator.php', 'OCA\\Text\\Event\\LoadEditor' => $baseDir . '/../lib/Event/LoadEditor.php', 'OCA\\Text\\Event\\MentionEvent' => $baseDir . '/../lib/Event/MentionEvent.php', + 'OCA\\Text\\Event\\RegisterContextEvent' => $baseDir . '/../lib/Event/RegisterContextEvent.php', 'OCA\\Text\\Exception\\AccountDisabledException' => $baseDir . '/../lib/Exception/AccountDisabledException.php', 'OCA\\Text\\Exception\\DocumentHasUnsavedChangesException' => $baseDir . '/../lib/Exception/DocumentHasUnsavedChangesException.php', 'OCA\\Text\\Exception\\DocumentSaveConflictException' => $baseDir . '/../lib/Exception/DocumentSaveConflictException.php', @@ -49,6 +54,7 @@ 'OCA\\Text\\Listeners\\LoadViewerListener' => $baseDir . '/../lib/Listeners/LoadViewerListener.php', 'OCA\\Text\\Listeners\\NodeCopiedListener' => $baseDir . '/../lib/Listeners/NodeCopiedListener.php', 'OCA\\Text\\Listeners\\NodeWrittenResetDocumentListener' => $baseDir . '/../lib/Listeners/NodeWrittenResetDocumentListener.php', + 'OCA\\Text\\Listeners\\RegisterContextEventListener' => $baseDir . '/../lib/Listeners/RegisterContextEventListener.php', 'OCA\\Text\\Listeners\\RegisterDirectEditorEventListener' => $baseDir . '/../lib/Listeners/RegisterDirectEditorEventListener.php', 'OCA\\Text\\Listeners\\RegisterTemplateCreatorListener' => $baseDir . '/../lib/Listeners/RegisterTemplateCreatorListener.php', 'OCA\\Text\\Listeners\\VersionRestoredListener' => $baseDir . '/../lib/Listeners/VersionRestoredListener.php', @@ -67,6 +73,9 @@ 'OCA\\Text\\Migration\\Version040100Date20240611165300' => $baseDir . '/../lib/Migration/Version040100Date20240611165300.php', 'OCA\\Text\\Migration\\Version070000Date20250925110024' => $baseDir . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Migration\\Version080000Date20260331132113' => $baseDir . '/../lib/Migration/Version080000Date20260331132113.php', + 'OCA\\Text\\Migration\\Version090000Date20260817110024' => $baseDir . '/../lib/Migration/Version090000Date20260817110024.php', + 'OCA\\Text\\Migration\\Version090000Date20260819110024' => $baseDir . '/../lib/Migration/Version090000Date20260819110024.php', + 'OCA\\Text\\Migration\\Version090000Date20260820132113' => $baseDir . '/../lib/Migration/Version090000Date20260820132113.php', 'OCA\\Text\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\AiTagService' => $baseDir . '/../lib/Service/AiTagService.php', 'OCA\\Text\\Service\\ApiService' => $baseDir . '/../lib/Service/ApiService.php', diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index a06a64546ec..1173f0425da 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -25,6 +25,10 @@ class ComposerStaticInitText 'OCA\\Text\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php', 'OCA\\Text\\Command\\ResetDocument' => __DIR__ . '/..' . '/../lib/Command/ResetDocument.php', 'OCA\\Text\\ConfigLexicon' => __DIR__ . '/..' . '/../lib/ConfigLexicon.php', + 'OCA\\Text\\Context\\ContextManager' => __DIR__ . '/..' . '/../lib/Context/ContextManager.php', + 'OCA\\Text\\Context\\FileContext' => __DIR__ . '/..' . '/../lib/Context/FileContext.php', + 'OCA\\Text\\Context\\FileContextFactory' => __DIR__ . '/..' . '/../lib/Context/FileContextFactory.php', + 'OCA\\Text\\Context\\IContext' => __DIR__ . '/..' . '/../lib/Context/IContext.php', 'OCA\\Text\\Controller\\AiController' => __DIR__ . '/..' . '/../lib/Controller/AiController.php', 'OCA\\Text\\Controller\\AttachmentController' => __DIR__ . '/..' . '/../lib/Controller/AttachmentController.php', 'OCA\\Text\\Controller\\ISessionAwareController' => __DIR__ . '/..' . '/../lib/Controller/ISessionAwareController.php', @@ -47,6 +51,7 @@ class ComposerStaticInitText 'OCA\\Text\\DirectEditing\\TextDocumentCreator' => __DIR__ . '/..' . '/../lib/DirectEditing/TextDocumentCreator.php', 'OCA\\Text\\Event\\LoadEditor' => __DIR__ . '/..' . '/../lib/Event/LoadEditor.php', 'OCA\\Text\\Event\\MentionEvent' => __DIR__ . '/..' . '/../lib/Event/MentionEvent.php', + 'OCA\\Text\\Event\\RegisterContextEvent' => __DIR__ . '/..' . '/../lib/Event/RegisterContextEvent.php', 'OCA\\Text\\Exception\\AccountDisabledException' => __DIR__ . '/..' . '/../lib/Exception/AccountDisabledException.php', 'OCA\\Text\\Exception\\DocumentHasUnsavedChangesException' => __DIR__ . '/..' . '/../lib/Exception/DocumentHasUnsavedChangesException.php', 'OCA\\Text\\Exception\\DocumentSaveConflictException' => __DIR__ . '/..' . '/../lib/Exception/DocumentSaveConflictException.php', @@ -64,6 +69,7 @@ class ComposerStaticInitText 'OCA\\Text\\Listeners\\LoadViewerListener' => __DIR__ . '/..' . '/../lib/Listeners/LoadViewerListener.php', 'OCA\\Text\\Listeners\\NodeCopiedListener' => __DIR__ . '/..' . '/../lib/Listeners/NodeCopiedListener.php', 'OCA\\Text\\Listeners\\NodeWrittenResetDocumentListener' => __DIR__ . '/..' . '/../lib/Listeners/NodeWrittenResetDocumentListener.php', + 'OCA\\Text\\Listeners\\RegisterContextEventListener' => __DIR__ . '/..' . '/../lib/Listeners/RegisterContextEventListener.php', 'OCA\\Text\\Listeners\\RegisterDirectEditorEventListener' => __DIR__ . '/..' . '/../lib/Listeners/RegisterDirectEditorEventListener.php', 'OCA\\Text\\Listeners\\RegisterTemplateCreatorListener' => __DIR__ . '/..' . '/../lib/Listeners/RegisterTemplateCreatorListener.php', 'OCA\\Text\\Listeners\\VersionRestoredListener' => __DIR__ . '/..' . '/../lib/Listeners/VersionRestoredListener.php', @@ -82,6 +88,9 @@ class ComposerStaticInitText 'OCA\\Text\\Migration\\Version040100Date20240611165300' => __DIR__ . '/..' . '/../lib/Migration/Version040100Date20240611165300.php', 'OCA\\Text\\Migration\\Version070000Date20250925110024' => __DIR__ . '/..' . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Migration\\Version080000Date20260331132113' => __DIR__ . '/..' . '/../lib/Migration/Version080000Date20260331132113.php', + 'OCA\\Text\\Migration\\Version090000Date20260817110024' => __DIR__ . '/..' . '/../lib/Migration/Version090000Date20260817110024.php', + 'OCA\\Text\\Migration\\Version090000Date20260819110024' => __DIR__ . '/..' . '/../lib/Migration/Version090000Date20260819110024.php', + 'OCA\\Text\\Migration\\Version090000Date20260820132113' => __DIR__ . '/..' . '/../lib/Migration/Version090000Date20260820132113.php', 'OCA\\Text\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\AiTagService' => __DIR__ . '/..' . '/../lib/Service/AiTagService.php', 'OCA\\Text\\Service\\ApiService' => __DIR__ . '/..' . '/../lib/Service/ApiService.php', diff --git a/cypress/e2e/api/SessionApi.spec.js b/cypress/e2e/api/SessionApi.spec.js index 50d932b3249..51291027a31 100644 --- a/cypress/e2e/api/SessionApi.spec.js +++ b/cypress/e2e/api/SessionApi.spec.js @@ -36,14 +36,14 @@ describe('The session Api', function() { }) it('returns connection', function() { - cy.openConnection({ fileId }).then(({ connection }) => { - cy.wrap(connection).its('documentId').should('equal', fileId) + cy.openFileConnection({ fileId }).then(({ connection }) => { + cy.wrap(connection).its('documentId').should('be.greaterThan', 0) cy.closeConnection(connection) }) }) it('provides initial content', function() { - cy.openConnection({ fileId, filePath }).then(({ connection, data }) => { + cy.openFileConnection({ fileId, filePath }).then(({ connection, data }) => { cy.wrap(data).its('content').should('eql', '## Hello world\n') cy.closeConnection(connection) }) @@ -63,7 +63,7 @@ describe('The session Api', function() { beforeEach(function() { cy.uploadTestFile() - .then((fileId) => cy.openConnection({ fileId })) + .then((fileId) => cy.openFileConnection({ fileId })) .then(({ connection: con }) => { connection = con }) @@ -116,7 +116,7 @@ describe('The session Api', function() { cy.uploadTestFile() .then((id) => { fileId = id - return cy.openConnection({ fileId, filePath }) + return cy.openFileConnection({ fileId, filePath }) }) .then(({ connection: con }) => { connection = con @@ -151,7 +151,7 @@ describe('The session Api', function() { documentState, manualSave: true, }) - cy.openConnection({ fileId, filePath }) + cy.openFileConnection({ fileId, filePath }) .as('joining') .its('data.documentState') .should('eql', documentState) @@ -183,7 +183,7 @@ describe('The session Api', function() { .then(() => cy.clearCookies()) .then(() => { return cy - .openConnection({ filePath: '', token: shareToken }) + .openShareConnection({ filePath: '', token: shareToken }) .then(({ connection: con }) => { connection = con }) @@ -223,7 +223,7 @@ describe('The session Api', function() { documentState, manualSave: true, }) - cy.openConnection({ filePath: '', token: shareToken }) + cy.openShareConnection({ filePath: '', token: shareToken }) .as('joining') .its('data.documentState') .should('eql', documentState) @@ -247,7 +247,7 @@ describe('The session Api', function() { cy.log(token) shareToken = token cy.clearCookies() - cy.openConnection({ filePath: '', token: shareToken }).then(({ connection: con }) => { + cy.openShareConnection({ filePath: '', token: shareToken }).then(({ connection: con }) => { connection = con }) }) @@ -255,7 +255,7 @@ describe('The session Api', function() { it('does not send initial content if other session is alive but did not push any steps', function() { let joining - cy.openConnection({ filePath: '', token: shareToken }) + cy.openShareConnection({ filePath: '', token: shareToken }) .then(({ connection: con, data }) => { joining = con return data @@ -271,7 +271,7 @@ describe('The session Api', function() { cy.pushSteps({ connection, steps: [messages.update], version }) .its('version') .should('eql', 0) - cy.openConnection({ filePath: '', token: shareToken }) + cy.openShareConnection({ filePath: '', token: shareToken }) .then(({ connection: con, data }) => { joining = con return data @@ -312,7 +312,7 @@ describe('The session Api', function() { .its('version') .should('eql', 0) cy.log('Other user creates session') - cy.openConnection({ filePath: '', token: shareToken }).then(({ connection: con }) => { + cy.openShareConnection({ filePath: '', token: shareToken }).then(({ connection: con }) => { joining = con }) cy.log('Initial user closes session') @@ -330,7 +330,7 @@ describe('The session Api', function() { // Skipped for now since the behaviour chanced by not cleaning up the state on close/create it.skip('ignores steps stored after close cleaned up', function() { cy.pushAndClose({ connection, steps: [messages.update], version }) - cy.openConnection({ filePath: '', token: shareToken }) + cy.openShareConnection({ filePath: '', token: shareToken }) .then(({ connection: con, data }) => { connection = con return data diff --git a/cypress/e2e/api/UsersApi.spec.js b/cypress/e2e/api/UsersApi.spec.js index 90319eea5e5..3f21d11200e 100644 --- a/cypress/e2e/api/UsersApi.spec.js +++ b/cypress/e2e/api/UsersApi.spec.js @@ -16,7 +16,7 @@ describe('The user mention API', function() { cy.login(user) cy.uploadTestFile('test.md') .as('fileId') - .then((fileId) => cy.openConnection({ fileId })) + .then((fileId) => cy.openFileConnection({ fileId })) .its('connection') .as('connection') }) diff --git a/cypress/e2e/sync.spec.js b/cypress/e2e/sync.spec.js index b76414a6fe6..f850a785a16 100644 --- a/cypress/e2e/sync.spec.js +++ b/cypress/e2e/sync.spec.js @@ -102,7 +102,7 @@ describe('Sync', () => { 'contain', 'The document could not be loaded.', ) - cy.intercept('**/apps/text/session/*/create').as('create') + cy.intercept('**/apps/text/session/*/*/create').as('create') cy.get('#editor-container .document-status').find('button').click() // let first attempt fail cy.wait('@create', { timeout: 10000 }) @@ -180,7 +180,7 @@ describe('Sync', () => { it('passes the doc content from one session to the next', () => { cy.closeFile() - cy.intercept({ method: 'PUT', url: '**/apps/text/session/*/create' }).as('create') + cy.intercept({ method: 'PUT', url: '**/apps/text/session/*/*/create' }).as('create') cy.openTestFile() cy.wait('@create', { timeout: 10000 }) .its('response.body') diff --git a/cypress/support/sessions.js b/cypress/support/sessions.js index 4795df43825..bb3d8e25cc1 100644 --- a/cypress/support/sessions.js +++ b/cypress/support/sessions.js @@ -4,20 +4,27 @@ */ import axios from '@nextcloud/axios' -import { close, open } from '../../src/apis/connect.ts' +import { close, openContext, openShare } from '../../src/apis/connect.ts' import { save } from '../../src/apis/save.ts' import { push, sync } from '../../src/apis/sync.ts' const url = Cypress.config('baseUrl').replace(/\/index.php\/?$/g, '') -Cypress.Commands.add('openConnection', open) +Cypress.Commands.add( + 'openFileConnection', + ({ fileId, filePath }) => { + return openContext({ type: 'file', id: fileId, filePath }) + }, +) + +Cypress.Commands.add('openShareConnection', openShare) Cypress.Commands.add('closeConnection', close) Cypress.Commands.add( 'failToCreateTextSession', (fileId, baseVersionEtag = null, options = {}) => { - return open({ fileId, ...options, baseVersionEtag }).then( + return openContext({ type: 'file', id: fileId, ...options, baseVersionEtag }).then( () => { throw new Error('Expected request to fail - but it succeeded!') }, diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index cd1c8f2c703..8447130b01b 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -12,6 +12,7 @@ use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; use OCA\Files_Versions\Events\VersionRestoredEvent; use OCA\Text\Event\LoadEditor; +use OCA\Text\Event\RegisterContextEvent; use OCA\Text\Listeners\AddMissingIndicesListener; use OCA\Text\Listeners\BeforeAssistantNotificationListener; use OCA\Text\Listeners\BeforeNodeDeletedListener; @@ -22,6 +23,7 @@ use OCA\Text\Listeners\LoadViewerListener; use OCA\Text\Listeners\NodeCopiedListener; use OCA\Text\Listeners\NodeWrittenResetDocumentListener; +use OCA\Text\Listeners\RegisterContextEventListener; use OCA\Text\Listeners\RegisterDirectEditorEventListener; use OCA\Text\Listeners\RegisterTemplateCreatorListener; use OCA\Text\Listeners\VersionRestoredListener; @@ -69,6 +71,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(VersionRestoredEvent::class, VersionRestoredListener::class); + $context->registerEventListener(RegisterContextEvent::class, RegisterContextEventListener::class); + $context->registerNotifierService(Notifier::class); $context->registerMiddleware(SessionMiddleware::class); } diff --git a/lib/Context/ContextManager.php b/lib/Context/ContextManager.php new file mode 100644 index 00000000000..893556fd145 --- /dev/null +++ b/lib/Context/ContextManager.php @@ -0,0 +1,56 @@ + */ + private array $contexts = []; + public function __construct( + private readonly IEventDispatcher $eventDispatcher, + private readonly LoggerInterface $logger, + ) { + } + + private function getContexts(): array { + $contexts = $this->contexts; + if (!empty($contexts)) { + return $contexts; + } + $this->eventDispatcher->dispatchTyped(new RegisterContextEvent($this)); + if (empty($this->contexts)) { + $this->logger->warning('Failed to register contexts.'); + } + return $this->contexts; + } + + public function registerContext(string $type, callable $createContext): void { + $this->logger->debug('Registering context for type "' . $type . '".'); + if (array_key_exists($type, $this->contexts)) { + $this->logger->warning('Context of type "' . $type . '" was already registered!'); + return; + } + $this->contexts[$type] = $createContext; + } + + public function getContext(string $type, int $id, ?string $shareToken): IContext { + $createContext = $this->getContexts()[$type]; + if (!is_callable($createContext)) { + throw new NotFoundException('Context of type "' . $type . '" was not registered!'); + } + $context = $createContext($id, $type, $shareToken); + if (!$context instanceof IContext) { + throw new NotFoundException('Failed to create context of type ' . $type . '!'); + } + return $context; + } +} diff --git a/lib/Context/FileContext.php b/lib/Context/FileContext.php new file mode 100644 index 00000000000..aab0dea3a11 --- /dev/null +++ b/lib/Context/FileContext.php @@ -0,0 +1,191 @@ +file->getId(); + } + + #[Override] + public function getType(): string { + return 'file'; + } + + #[Override] + public function toString(): string { + return $this->getType() . ' (' . $this->getId() . ')'; + } + + #[Override] + public function getFile(): ?File { + return $this->file; + } + + #[Override] + public function buildDocument(): Document|string { + // Block using text for disabled download internal shares + if ($this->fileService->isDownloadDisabled($this->file)) { + return $this->l10n->t('This file cannot be displayed as download is disabled by the share'); + } + $document = new Document(); + $document->setContextType('file'); + $document->setContextId($this->getId()); + $document->setLastSavedVersion(0); + $document->setLastSavedVersionTime($this->file->getMTime()); + $document->setLastSavedVersionEtag($this->file->getEtag()); + $document->setChecksum($this->computeChecksum()); + // This is a new document - so it needs a fresh base version etag. + $document->setBaseVersionEtag(uniqid()); + return $document; + } + + #[Override] + public function prepareSession(DocumentData $documentData): SessionInfo { + $document = $documentData->document; + $documentState = $documentData->documentState; + + $content = null; + if ($documentState === null) { + $this->logger->debug('Sending content for ' . $document->toString()); + $content = $this->loadContent(); + } + + $readOnly = $this->isReadOnly(); + $lockInfo = $this->getLockInfo(); + if (!$readOnly) { + $isLocked = $this->lock(); + if (!$isLocked) { + $readOnly = true; + } + } + + return new SessionInfo( + content: $content, + readOnly: $readOnly, + lock: $lockInfo, + hasOwner: $this->getOwner() !== null, + ); + } + + public function isReadOnly(): bool { + return $this->fileService->isReadOnly($this->file, $this->token); + } + + /** + * Update the document last saved version metadata to be in line with the data saved in the context. + * + * @throws DocumentSaveConflictException + * @throws GenericFileException if the file changed and reading the content fails. + * @throws LockedException if the file changed and a lock prevents reading the content. + * @throws NotPermittedException if the file changed and reading is not allowed. + * @return Document|null Updated document if there was an update + */ + public function updateDocument(Document $document): ?Document { + $lastMTime = $document->getLastSavedVersionTime(); + $lastEtag = $document->getLastSavedVersionEtag(); + + if ($lastMTime <= 0 || $this->isReadOnly()) { + return null; + } + + $fileMtime = $this->file->getMtime(); + $fileEtag = $this->file->getEtag(); + + if ($lastEtag === $fileEtag && $lastMTime === $fileMtime) { + return null; + } + + $storedChecksum = $document->getChecksum(); + $fileContent = $this->file->getContent(); + $fileChecksum = self::computeCheckSum($fileContent); + + if ($storedChecksum !== $fileChecksum) { + throw new DocumentSaveConflictException($fileContent); + } + + $document->setLastSavedVersionTime($fileMtime); + $document->setLastSavedVersionEtag($fileEtag); + return $document; + } + + public function loadContent(): ?string { + return $this->fileService->loadContent($this->file); + } + + public function saveWithLock(string $content, callable $doWhileLocked): void { + $this->lockService->runInScope($this->file, function () use ($content, $doWhileLocked): void { + $this->file->putContent($content); + $doWhileLocked(); + }); + } + + #[Override] + public function cleanup(): void { + $this->unlock(); + } + + private function computeCheckSum(?string $content = null): string { + if ($content === null) { + $content = $this->file->getContent(); + } + return hash('crc32', $content); + } + + private function getLockInfo(): ?ILock { + return $this->lockService->getLockByOthers($this->file); + } + + private function getOwner(): ?IUser { + return $this->file->getOwner(); + } + + private function lock(): bool { + // Disable file locking for Readme.md files, because in the + // current setup, this makes it almost impossible to delete these files. + if (strcasecmp($this->file->getName(), 'Readme.md') !== 0) { + return $this->lockService->lock($this->file); + } + return true; + } + + private function unlock(): void { + // Disable file locking for Readme.md files, because in the + // current setup, this makes it almost impossible to delete these files. + if (strcasecmp($this->file->getName(), 'Readme.md') !== 0) { + $this->lockService->unlock($this->file); + } + } + +} diff --git a/lib/Context/FileContextFactory.php b/lib/Context/FileContextFactory.php new file mode 100644 index 00000000000..b1b42ca19a6 --- /dev/null +++ b/lib/Context/FileContextFactory.php @@ -0,0 +1,101 @@ +fileService, + $this->l10n, + $this->lockService, + $this->logger, + $file, + $token, + ); + } + + /** + * @throws NotPermittedException if not logged in + * @throws NotFoundException if the file cannot be found + */ + public function buildForId( + int $id, + ): FileContext { + $userId = $this->userSession->getUser()?->getUID(); + if ($userId === null) { + throw new NotPermittedException(); + } + $file = $this->fileService->getFileById($id, $userId); + return $this->build($file); + } + + /** + * @throws NotFoundException if the file cannot be found + */ + public function buildForShareWithId( + string $token, + int $id, + ): FileContext { + $file = $this->fileService->getFileByIdFromShare($id, $token); + /* + * Check if we have proper read access (files drop) + * If not then well 404 it is. + */ + $this->fileService->checkSharePermissions($token); + return $this->build($file, $token); + } + + /** + * @throws NotFoundException if the file cannot be found + * @throws \InvalidArgumentException if the share token is for a folder and path is missing + */ + public function buildForShareWithPath( + string $token, + ?string $filePath, + ): FileContext { + $file = $this->fileService->getFileByShareToken($token, $filePath); + /* + * Check if we have proper read access (files drop) + * If not then well 404 it is. + */ + $this->fileService->checkSharePermissions($token); + return $this->build($file, $token); + } + + /** + * @throws NotFoundException if the file cannot be found + */ + public function buildForDirectEditing(IToken $token): FileContext { + $file = $token->getFile(); + return $this->build($file, null); + } + +} diff --git a/lib/Context/IContext.php b/lib/Context/IContext.php new file mode 100644 index 00000000000..cc4f9c83d58 --- /dev/null +++ b/lib/Context/IContext.php @@ -0,0 +1,87 @@ + $this->document, + 'documentState' => $this->documentState, + ]; + } +} + +readonly class SessionInfo { + public function __construct( + public ?string $content, + public bool $readOnly, + public ?ILock $lock, + public bool $hasOwner, + ) { + } + + public function jsonSerialize(): array { + return [ + 'content' => $this->content, + 'readOnly' => $this->readOnly, + 'lock' => $this->lock, + 'hasOwner' => $this->hasOwner, + ]; + } +} + +readonly class NewSessionData { + public function __construct( + public DocumentData $documentData, + public SessionInfo $sessionInfo, + public Session $session, + public ?string $displayName, + ) { + } + + public function jsonSerialize(): array { + return array_merge( + $this->documentData->jsonSerialize(), + $this->sessionInfo->jsonSerialize(), + [ + 'session' => array_merge( + $this->session->jsonSerialize(), + ['displayName' => $this->displayName], + ), + ], + ); + } +} diff --git a/lib/Controller/PublicSessionController.php b/lib/Controller/PublicSessionController.php index b12fa1e5f42..d3d44f87f79 100644 --- a/lib/Controller/PublicSessionController.php +++ b/lib/Controller/PublicSessionController.php @@ -8,6 +8,7 @@ namespace OCA\Text\Controller; +use OCA\Text\Context\FileContextFactory; use OCA\Text\Middleware\Attribute\RequireDocumentBaseVersionEtag; use OCA\Text\Middleware\Attribute\RequireDocumentSession; use OCA\Text\Service\ApiService; @@ -34,6 +35,7 @@ public function __construct( string $appName, IRequest $request, ISession $session, + private FileContextFactory $fileContextFactory, private ShareManager $shareManager, private ApiService $apiService, private FileService $fileService, @@ -71,25 +73,19 @@ protected function isPasswordProtected(): bool { #[NoAdminRequired] #[PublicPage] public function create(string $token, ?string $filePath = null, ?string $baseVersionEtag = null, ?string $guestName = null): DataResponse { - $file = $this->fileService->getFileByShareToken($token, $filePath); - /* - * Check if we have proper read access (files drop) - * If not then well 404 it is. - */ try { - $this->fileService->checkSharePermissions($token); + $context = $this->fileContextFactory->buildForShareWithPath($token, $filePath); + return $this->apiService->create($context, $baseVersionEtag, $guestName); } catch (NotFoundException|\InvalidArgumentException) { return new DataResponse([], Http::STATUS_NOT_FOUND); } - return $this->apiService->create($file, $baseVersionEtag, $token, $guestName); } #[NoAdminRequired] #[PublicPage] public function close(int $documentId, int $sessionId, string $sessionToken, string $token): DataResponse { - $file = $this->fileService->getFileByIdFromShare($documentId, $token); - return $this->apiService->close($documentId, $sessionId, $sessionToken, $file); + return $this->apiService->close($documentId, $sessionId, $sessionToken, $token); } #[NoAdminRequired] diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php index e58c20f6b0e..c875c7055af 100644 --- a/lib/Controller/SessionController.php +++ b/lib/Controller/SessionController.php @@ -8,6 +8,7 @@ namespace OCA\Text\Controller; +use OCA\Text\Context\ContextManager; use OCA\Text\Exception\InvalidSessionException; use OCA\Text\Middleware\Attribute\RequireDocumentBaseVersionEtag; use OCA\Text\Middleware\Attribute\RequireDocumentSession; @@ -40,6 +41,7 @@ public function __construct( string $appName, IRequest $request, private ApiService $apiService, + private ContextManager $contextManager, private FileService $fileService, private SessionService $sessionService, private NotificationService $notificationService, @@ -52,22 +54,17 @@ public function __construct( } #[NoAdminRequired] - public function create(?int $fileId = null, ?string $baseVersionEtag = null): DataResponse { - $userId = $this->userSession->getUser()?->getUID(); - if ($fileId === null || $userId === null) { - return new DataResponse(['error' => 'No valid file argument provided'], Http::STATUS_PRECONDITION_FAILED); - } - + public function create(string $type, int $id, ?string $baseVersionEtag = null): DataResponse { try { - $file = $this->fileService->getFileById($fileId, $userId); + $context = $this->contextManager->getContext($type, $id, null); } catch (NotFoundException|NotPermittedException $e) { - $this->logger->error('No permission to access this file', [ 'exception' => $e ]); + $this->logger->error('No context for ' . $type . ' (' . $id . ') ', [ 'exception' => $e ]); return new DataResponse([ 'error' => $this->l10n->t('File not found') ], Http::STATUS_NOT_FOUND); } - return $this->apiService->create($file, $baseVersionEtag); + return $this->apiService->create($context, $baseVersionEtag); } #[NoAdminRequired] @@ -77,8 +74,7 @@ public function close(int $documentId, int $sessionId, string $sessionToken): Da if ($userId === null) { throw new InvalidSessionException(); } - $file = $this->fileService->getFileById($documentId, $userId); - return $this->apiService->close($documentId, $sessionId, $sessionToken, $file); + return $this->apiService->close($documentId, $sessionId, $sessionToken, null); } #[NoAdminRequired] diff --git a/lib/Db/Document.php b/lib/Db/Document.php index 5fdcdffa212..58c0f227265 100644 --- a/lib/Db/Document.php +++ b/lib/Db/Document.php @@ -25,6 +25,10 @@ * @method setBaseVersionEtag(string $etag): void * @method getChecksum(): ?string * @method setChecksum(?string $checksum): void + * @method getContextType(): string + * @method setContextType(string $contextType): void + * @method getContextId(): int + * @method setContextId(int $contextId): void */ class Document extends Entity implements \JsonSerializable { public $id = null; @@ -36,6 +40,8 @@ class Document extends Entity implements \JsonSerializable { protected string $lastSavedVersionEtag = ''; protected string $baseVersionEtag = ''; protected ?string $checksum = null; + protected string $contextType = ''; + protected int $contextId = 0; public function __construct() { $this->addType('currentVersion', 'integer'); @@ -43,6 +49,8 @@ public function __construct() { $this->addType('lastSavedVersionTime', 'integer'); $this->addType('initialVersion', 'integer'); $this->addType('checksum', 'string'); + $this->addType('contextType', 'string'); + $this->addType('contextId', 'integer'); } public function jsonSerialize(): array { @@ -52,7 +60,16 @@ public function jsonSerialize(): array { 'lastSavedVersionTime' => $this->lastSavedVersionTime, 'baseVersionEtag' => $this->baseVersionEtag, 'initialVersion' => $this->initialVersion, - 'checksum' => $this->checksum + 'checksum' => $this->checksum, + 'contextType' => $this->contextType, + 'contextId' => $this->contextId, ]; } + + /** + * Short identifier - mostly for logging + */ + public function toString(): string { + return $this->contextType . ' (' . $this->contextId . ')'; + } } diff --git a/lib/Db/DocumentMapper.php b/lib/Db/DocumentMapper.php index aedf04ad5d0..f94c0564faf 100644 --- a/lib/Db/DocumentMapper.php +++ b/lib/Db/DocumentMapper.php @@ -25,6 +25,7 @@ public function __construct(IDBConnection $db) { * @throws DoesNotExistException */ public function find(int $documentId): Document { + /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); $result = $qb->select('*') @@ -40,6 +41,23 @@ public function find(int $documentId): Document { return Document::fromRow($data); } + public function load(string $type, int $id): ?Document { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('context_type', $qb->createNamedParameter($type))) + ->andWhere($qb->expr()->eq('context_id', $qb->createNamedParameter($id))) + ->executeQuery(); + + $data = $result->fetchAssociative(); + $result->closeCursor(); + if ($data === false) { + return null; + } + return Document::fromRow($data); + } + public function findAll(): Generator { $qb = $this->db->getQueryBuilder(); $result = $qb->select('*') diff --git a/lib/DirectEditing/TextDirectEditor.php b/lib/DirectEditing/TextDirectEditor.php index 9725755cc0b..0d086640ad8 100644 --- a/lib/DirectEditing/TextDirectEditor.php +++ b/lib/DirectEditing/TextDirectEditor.php @@ -8,6 +8,7 @@ namespace OCA\Text\DirectEditing; use OCA\Text\AppInfo\Application; +use OCA\Text\Context\FileContextFactory; use OCA\Text\Service\ApiService; use OCA\Text\Service\InitialStateProvider; use OCP\AppFramework\Http\NotFoundResponse; @@ -29,6 +30,7 @@ public function __construct( private readonly InitialStateProvider $initialStateProvider, private readonly ApiService $apiService, private readonly IAppConfig $appConfig, + private readonly FileContextFactory $fileContextFactory, ) { } @@ -131,7 +133,8 @@ public function isSecure(): bool { public function open(IToken $token): Response { $token->useTokenScope(); try { - $session = $this->apiService->create($token->getFile()); + $context = $this->fileContextFactory->buildForDirectEditing($token); + $session = $this->apiService->create($context, null); $this->initialStateProvider->provideFile([ 'fileId' => $token->getFile()->getId(), 'mimetype' => $token->getFile()->getMimeType(), diff --git a/lib/Event/RegisterContextEvent.php b/lib/Event/RegisterContextEvent.php new file mode 100644 index 00000000000..8cdab36d5db --- /dev/null +++ b/lib/Event/RegisterContextEvent.php @@ -0,0 +1,33 @@ +contextManager; + } +} diff --git a/lib/Exception/DocumentSaveConflictException.php b/lib/Exception/DocumentSaveConflictException.php index 6f7a73139dd..a6556124c33 100644 --- a/lib/Exception/DocumentSaveConflictException.php +++ b/lib/Exception/DocumentSaveConflictException.php @@ -9,5 +9,20 @@ namespace OCA\Text\Exception; +use Throwable; + class DocumentSaveConflictException extends \Exception { + + public function __construct( + private readonly string $content, + int $code = 0, + ?Throwable $previous = null, + ) { + $message = 'File changed in the meantime from outside'; + parent::__construct($message, $code, $previous); + } + + public function getContent(): string { + return $this->content; + } } diff --git a/lib/Listeners/RegisterContextEventListener.php b/lib/Listeners/RegisterContextEventListener.php new file mode 100644 index 00000000000..82e53532817 --- /dev/null +++ b/lib/Listeners/RegisterContextEventListener.php @@ -0,0 +1,42 @@ + */ +class RegisterContextEventListener implements IEventListener { + + public function __construct( + private readonly FileContextFactory $fileContextFactory, + ) { + } + + #[Override] + public function handle(Event $event): void { + if (!$event instanceof RegisterContextEvent) { + return; + } + + $event->getContextManager()->registerContext( + 'file', + function (int $id, string $type, ?string $shareToken) { + if ($shareToken === null) { + return $this->fileContextFactory->buildForId($id); + } else { + return $this->fileContextFactory->buildForShareWithId($shareToken, $id); + } + } + ); + } +} diff --git a/lib/Middleware/SessionMiddleware.php b/lib/Middleware/SessionMiddleware.php index aa6a0644504..c7a08847f17 100644 --- a/lib/Middleware/SessionMiddleware.php +++ b/lib/Middleware/SessionMiddleware.php @@ -94,7 +94,8 @@ private function assertDocumentSession(ISessionAwareController $controller): voi $documentId = (int)$this->request->getParam('documentId'); $sessionId = (int)$this->request->getParam('sessionId'); $token = (string)$this->request->getParam('sessionToken'); - $shareToken = (string)$this->request->getParam('token'); + $shareToken = (string)$this->request->getParam('token') + || (string)$this->request->getParam('shareToken'); $session = $this->sessionService->getValidSession($documentId, $sessionId, $token); if (!$session) { @@ -127,18 +128,24 @@ private function assertDocumentSession(ISessionAwareController $controller): voi * @throws InvalidSessionException */ private function assertUserOrShareToken(ISessionAwareController $controller): void { - $fileId = (int)$this->request->getParam('documentId'); + $documentId = (int)$this->request->getParam('documentId'); $shareToken = (string)$this->request->getParam('shareToken'); $userId = $this->userSession->getUser()?->getUID(); + $document = $this->documentService->getDocument($documentId); + if (!$document || $document->getContextType() !== 'file') { + throw new InvalidSessionException(); + } + $fileId = $document->getContextId(); + if ($shareToken !== '') { - $documentId = $this->fileService->getDocumentIdFromShare($fileId, $shareToken); + $this->fileService->checkFileAccessFromShare($fileId, $shareToken); $controller->setDocumentId($documentId); return; } if ($userId !== null) { - $documentId = $this->fileService->getDocumentIdForUser($fileId, $userId); + $this->fileService->checkFileAccessForUser($fileId, $userId); $controller->setUserId($userId); $controller->setDocumentId($documentId); return; diff --git a/lib/Migration/Version090000Date20260817110024.php b/lib/Migration/Version090000Date20260817110024.php new file mode 100644 index 00000000000..627d673b141 --- /dev/null +++ b/lib/Migration/Version090000Date20260817110024.php @@ -0,0 +1,51 @@ +getTable('text_documents'); + + if (!$table->hasColumn('context_type')) { + $table->addColumn('context_type', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + } + + if (!$table->hasColumn('context_id')) { + $table->addColumn('context_id', Types::BIGINT, [ + 'notnull' => false, + 'unsigned' => true, + ]); + } + + $column = $table->getColumn('id'); + if (!$column->getAutoincrement()) { + $table->modifyColumn('id', [ + 'autoincrement' => true, + ]); + } + return $schema; + } +} diff --git a/lib/Migration/Version090000Date20260819110024.php b/lib/Migration/Version090000Date20260819110024.php new file mode 100644 index 00000000000..9c65cbcd5ee --- /dev/null +++ b/lib/Migration/Version090000Date20260819110024.php @@ -0,0 +1,69 @@ +connection->getQueryBuilder(); + $qb->update('text_documents', 'd') + ->set('context_type', $qb->createNamedParameter('file')) + ->where($qb->expr()->isNull('context_type')) + ->executeStatement(); + $qb->update('text_documents', 'd') + ->set('context_id', 'd.id') + ->where($qb->expr()->isNull('context_id')) + ->executeStatement(); + } + + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->getTable('text_documents'); + + $column = $table->getColumn('context_type'); + if (!$column->getNotnull()) { + $table->modifyColumn('context_type', [ + 'notnull' => true, + ]); + } + + $column = $table->getColumn('context_id'); + if (!$column->getNotnull()) { + $table->modifyColumn('context_id', [ + 'notnull' => true, + ]); + } + return $schema; + } +} diff --git a/lib/Migration/Version090000Date20260820132113.php b/lib/Migration/Version090000Date20260820132113.php new file mode 100644 index 00000000000..e19ba349ad4 --- /dev/null +++ b/lib/Migration/Version090000Date20260820132113.php @@ -0,0 +1,40 @@ +getTable('text_documents'); + if (!$table->hasIndex('text_documents_context_index')) { + $table->addIndex(['context_type', 'context_id'], 'text_documents_context_index'); + } + + return $schema; + } +} diff --git a/lib/Service/ApiService.php b/lib/Service/ApiService.php index 015bcccfd1e..32aaa71f51e 100644 --- a/lib/Service/ApiService.php +++ b/lib/Service/ApiService.php @@ -12,6 +12,9 @@ use Exception; use InvalidArgumentException; use OCA\NotifyPush\Queue\IQueue; +use OCA\Text\Context\ContextManager; +use OCA\Text\Context\IContext; +use OCA\Text\Context\NewSessionData; use OCA\Text\Db\Document; use OCA\Text\Db\Session; use OCA\Text\Exception\DocumentSaveConflictException; @@ -23,16 +26,14 @@ use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IL10N; -use OCP\Lock\LockedException; use Psr\Log\LoggerInterface; class ApiService { public function __construct( private readonly ConfigService $configService, + private readonly ContextManager $contextManager, private readonly SessionService $sessionService, private readonly DocumentService $documentService, - private readonly FileService $fileService, - private readonly EncodingService $encodingService, private readonly LoggerInterface $logger, private readonly LockService $lockService, private readonly IL10N $l10n, @@ -40,79 +41,53 @@ public function __construct( ) { } - public function create(File $file, ?string $baseVersionEtag = null, ?string $token = null, ?string $guestName = null): DataResponse { - try { - // Block using text for disabled download internal shares - if ($this->fileService->isDownloadDisabled($file)) { - return new DataResponse(['error' => $this->l10n->t('This file cannot be displayed as download is disabled by the share')], Http::STATUS_FORBIDDEN); - } - - $readOnly = $this->fileService->isReadOnly($file, $token); - - $this->sessionService->removeInactiveSessionsWithoutSteps($file->getId()); - $document = $this->documentService->getOrCreateDocument($file); - if ($baseVersionEtag !== null && $baseVersionEtag !== $document->getBaseVersionEtag()) { - return new DataResponse(['error' => $this->l10n->t('Editing session has expired. Please reload the page.')], Http::STATUS_PRECONDITION_FAILED); - } + public function create(IContext $context, ?string $baseVersionEtag, ?string $guestName = null): DataResponse { + $document = $context->buildDocument(); + if (!$document instanceof Document) { + return new DataResponse(['error' => $document], Http::STATUS_FORBIDDEN); + } + try { + $document = $this->documentService->getOrCreateDocument($document); } catch (Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); return new DataResponse(['error' => 'Failed to create the document session'], Http::STATUS_INTERNAL_SERVER_ERROR); } + $documentData = $this->documentService->getDocumentData($document); - /** @var Document $document */ - - $session = $this->sessionService->initSession($document->getId(), $guestName); - - $documentState = null; - $content = null; - if ($document->getLastSavedVersion() === 0) { - $this->logger->debug('Sending content for unsaved file ' . $file->getId()); - $content = $this->loadContent($file); - } else { - $this->logger->debug('Loading saved document state for ' . $file->getId()); - try { - $stateFile = $this->documentService->getStateFile($document->getId()); - $documentState = $stateFile->getContent(); - } catch (NotFoundException) { - $this->logger->warning('State file not found for saved document' . $file->getId()); - - // If we have no state file we need to load the content from the file - // On the client side we use this to initialize a idempotent initial y.js document - $content = $this->loadContent($file); - } + if ($baseVersionEtag !== null && $baseVersionEtag !== $document->getBaseVersionEtag()) { + $error = $this->l10n->t('Editing session has expired. Please reload the page.'); + return new DataResponse(['error' => $error], Http::STATUS_PRECONDITION_FAILED); } - $lockInfo = $this->lockService->getLockByOthers($file); + $sessionInfo = $context->prepareSession($documentData); + $session = $this->sessionService->initSession($document->id, $guestName); + $displayName = $this->sessionService->getNameForSession($session); - $hasOwner = $file->getOwner() !== null; + $newSession = new NewSessionData( + documentData: $documentData, + sessionInfo: $sessionInfo, + session: $session, + displayName: $displayName, + ); - // Disable file locking for Readme.md files, because in the - // current setup, this makes it almost impossible to delete these files. - if (!$readOnly && strcasecmp($file->getName(), 'Readme.md') !== 0) { - $isLocked = $this->lockService->lock($file); - 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, - 'hasOwner' => $hasOwner, - ]); + return new DataResponse( + $newSession->jsonSerialize() + ); } - public function close(int $documentId, int $sessionId, string $sessionToken, File $file): DataResponse { + public function close(int $documentId, int $sessionId, string $sessionToken, ?string $shareToken): DataResponse { $this->sessionService->closeSession($documentId, $sessionId, $sessionToken); $this->sessionService->removeInactiveSessionsWithoutSteps($documentId); $activeSessions = $this->sessionService->getActiveSessions($documentId); if (count($activeSessions) === 0) { - $this->lockService->unlock($file); + $document = $this->documentService->getDocument($documentId); + if ($document !== null) { + $type = $document->getContextType(); + $id = $document->getContextId(); + $context = $this->contextManager->getContext($type, $id, $shareToken); + $context->cleanup(); + } } return new DataResponse([]); } @@ -146,7 +121,7 @@ private function addToPushQueue(Document $document, array $steps): void { return; } - $sessions = $this->sessionService->getActiveSessions($document->getId()); + $sessions = $this->sessionService->getActiveSessions($document->id); $userIds = array_values(array_filter(array_unique( array_map(fn ($session): ?string => $session['userId'], $sessions) ))); @@ -173,9 +148,14 @@ public function sync(Session $session, Document $document, int $version = 0, ?st ]; // ensure file is still present and accessible - $file = $this->fileService->getFileForSession($session, $shareToken); - $result['readOnly'] = $this->fileService->isReadOnly($file, $shareToken); - $this->documentService->assertNoOutsideConflict($document, $file); + $type = $document->getContextType(); + $id = $document->getContextId(); + $context = $this->contextManager->getContext($type, $id, $shareToken); + $result['readOnly'] = $context->isReadOnly(); + $document = $context->updateDocument($document); + if ($document) { + $this->documentService->updateDocument($document); + } } catch (NotPermittedException|NotFoundException|InvalidPathException $e) { $this->logger->info($e->getMessage(), ['exception' => $e]); return new DataResponse([ @@ -186,13 +166,8 @@ public function sync(Session $session, Document $document, int $version = 0, ?st return new DataResponse([ 'message' => 'Document no longer exists' ], Http::STATUS_NOT_FOUND); - } 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 - } + } catch (DocumentSaveConflictException $e) { + $result['outsideChange'] = $e->getContent(); } return new DataResponse($result, isset($result['outsideChange']) ? Http::STATUS_CONFLICT : Http::STATUS_OK); @@ -200,28 +175,21 @@ public function sync(Session $session, Document $document, int $version = 0, ?st public function save(Session $session, Document $document, int $version, string $autosaveContent, string $documentState, bool $force = false, bool $manualSave = false, ?string $shareToken = null): DataResponse { try { - $file = $this->fileService->getFileForSession($session, $shareToken); - } catch (NotPermittedException|NotFoundException $e) { + $type = $document->getContextType(); + $id = $document->getContextId(); + $context = $this->contextManager->getContext($type, $id, $shareToken); + } catch (NotFoundException $e) { $this->logger->info($e->getMessage(), ['exception' => $e]); return new DataResponse([ 'message' => 'File not found' ], Http::STATUS_NOT_FOUND); - } catch (DoesNotExistException $e) { - $this->logger->info($e->getMessage(), ['exception' => $e]); - return new DataResponse([ - 'message' => 'Document no longer exists' - ], Http::STATUS_NOT_FOUND); } $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 - } + $result['document'] = $this->documentService->autosave($document, $context, $version, $autosaveContent, $documentState, $force, $manualSave, $shareToken); + } catch (DocumentSaveConflictException $e) { + $result['outsideChange'] = $e->getContent(); } catch (NotPermittedException) { return new DataResponse([ 'error' => $this->l10n->t('Read-only permission cannot save document changes. Please reload the page.') @@ -242,17 +210,4 @@ 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; - } } diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index 7147630afc7..194ed405750 100755 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -12,7 +12,9 @@ use OC\User\NoUserException; use OCA\DAV\Connector\Sabre\PublicAuth; use OCA\Files_Sharing\SharedStorage; +use OCA\Text\Context\ContextManager; use OCA\Text\Controller\AttachmentController; +use OCA\Text\Db\DocumentMapper; use OCA\Text\Db\Session; use OCP\Constants; use OCP\Files\File; @@ -44,6 +46,8 @@ public function __construct( private IFilenameValidator $filenameValidator, private IFilesMetadataManager $filesMetadataManager, private ISession $session, + private DocumentMapper $documentMapper, + private ContextManager $contextManager, ) { } @@ -305,7 +309,7 @@ public function uploadAttachment(int $documentId, string $newFileName, $newFileR * @throws InvalidPathException * @throws NoUserException */ - public function uploadAttachmentPublic(?int $documentId, string $newFileName, $newFileResource, string $shareToken): array { + public function uploadAttachmentPublic(int $documentId, string $newFileName, $newFileResource, string $shareToken): array { try { $share = $this->shareManager->getShareByToken($shareToken); } catch (ShareNotFound) { @@ -515,12 +519,21 @@ private function isDownloadDisabled(File $file): bool { * @throws NotPermittedException */ private function getTextFile(int $documentId, string $userId): File { - $userFolder = $this->rootFolder->getUserFolder($userId); - $file = $userFolder->getFirstNodeById($documentId); + $document = $this->documentMapper->find($documentId); + $type = $document->getContextType(); + $id = $document->getContextId(); + $context = $this->contextManager->getContext($type, $id, null); + $file = $context->getFile(); if ($file instanceof File && !$this->isDownloadDisabled($file)) { return $file; } - throw new NotFoundException('Text file with id=' . $documentId . ' was not found in storage of ' . $userId); + throw new NotFoundException('Text file for document' + . $documentId + . ' (' + . $context->toString() + . ') was not found in storage of ' + . $userId + ); } /** @@ -528,32 +541,23 @@ private function getTextFile(int $documentId, string $userId): File { * * @throws NotFoundException */ - private function getTextFilePublic(?int $documentId, string $shareToken): File { - // is the file shared with this token? + private function getTextFilePublic(int $documentId, string $shareToken): File { + // TODO: Lazy load the context and enable these additional checks inside the context try { $share = $this->shareManager->getShareByToken($shareToken); - if (in_array($share->getShareType(), [IShare::TYPE_LINK, IShare::TYPE_EMAIL])) { - // shared file or folder? - if ($share->getNodeType() === 'file') { - $textFile = $share->getNode(); - if ($textFile instanceof File - && !$this->isDownloadDisabled($textFile) - && $textFile->getId() === $documentId - ) { - return $textFile; - } - } elseif ($documentId !== null && $share->getNodeType() === 'folder') { - $folder = $share->getNode(); - if ($folder instanceof Folder) { - $textFile = $folder->getFirstNodeById($documentId); - if ($textFile instanceof File && !$this->isDownloadDisabled($textFile)) { - return $textFile; - } - } - } - } } catch (ShareNotFound) { - // same as below + throw new NotFoundException(); + } + if (!in_array($share->getShareType(), [IShare::TYPE_LINK, IShare::TYPE_EMAIL])) { + throw new NotFoundException(); + } + $document = $this->documentMapper->find($documentId); + $type = $document->getContextType(); + $id = $document->getContextId(); + $context = $this->contextManager->getContext($type, $id, $shareToken); + $file = $context->getFile(); + if ($file instanceof File && !$this->isDownloadDisabled($file)) { + return $file; } throw new NotFoundException('Text file with id=' . (string)$documentId . ' and shareToken ' . $shareToken . ' was not found.'); } diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index d4bcdf01747..fe0e8087979 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -10,6 +10,9 @@ namespace OCA\Text\Service; use InvalidArgumentException; +use OCA\Text\Context\ContextManager; +use OCA\Text\Context\DocumentData; +use OCA\Text\Context\IContext; use OCA\Text\Db\Document; use OCA\Text\Db\DocumentMapper; use OCA\Text\Db\Session; @@ -50,6 +53,7 @@ class DocumentService { private readonly ICache $cache; public function __construct( + private readonly ContextManager $contextManager, private readonly DocumentMapper $documentMapper, private readonly FileService $fileService, private readonly StepMapper $stepMapper, @@ -96,35 +100,28 @@ public function isSaveFromText(): bool { * @throws NotPermittedException * @throws Exception */ - public function getOrCreateDocument(File $file): Document { - $document = $this->getDocument($file->getId()); - if ($document !== null) { - $this->logger->info('Keep previous document of ' . $file->getId()); - return $document; + public function getOrCreateDocument(Document $document): Document { + $loaded = $this->documentMapper->load($document->getContextType(), $document->getContextId()); + if ($loaded !== null) { + $this->logger->info('Keep previous document of ' . $document->toString()); + return $loaded; } if (!$this->ensureDocumentsFolder()) { throw new NotFoundException('No app data folder present for text documents'); } - $this->logger->info('Create new document of ' . $file->getId()); - $document = new Document(); - $document->setId($file->getId()); - $document->setLastSavedVersion(0); - $document->setLastSavedVersionTime($file->getMTime()); - $document->setLastSavedVersionEtag($file->getEtag()); - $document->setBaseVersionEtag(uniqid()); - $document->setChecksum(self::computeCheckSum($file->getContent())); + $this->logger->info('Create new document of ' . $document->toString()); try { /** @var Document $document */ $document = $this->documentMapper->insert($document); - $this->cache->set('document-version-' . $document->getId(), 0); + $this->cache->set('document-version-' . $document->id, 0); } catch (Exception $e) { if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { throw $e; } // Document might have been created in the meantime - $document = $this->getDocument($file->getId()); + $document = $this->getDocument($document->id); if ($document === null) { throw $e; } @@ -132,6 +129,28 @@ public function getOrCreateDocument(File $file): Document { return $document; } + public function getDocumentData(Document $document): DocumentData { + $documentState = null; + if ($document->getLastSavedVersion() > 0) { + $this->logger->debug('Loading saved document state for ' . $document->toString()); + try { + $stateFile = $this->getStateFile($document->id); + $documentState = $stateFile->getContent(); + } catch (NotFoundException) { + // If we have no state file we need to load the content from the file + // On the client side we use this to initialize a idempotent initial y.js document + $this->logger->warning('State file not found for saved document' . $document->toString()); + } + } + + $documentData = new DocumentData( + document: $document, + documentState: $documentState, + ); + + return $documentData; + } + /** * @param int $documentId * @return ISimpleFile @@ -164,6 +183,14 @@ public function updateDocumentVersionInfo(File $file): void { $this->documentMapper->update($document); } + /** + * @throws Exception + * @throws InvalidPathException + */ + public function updateDocument(Document $document): void { + $this->documentMapper->update($document); + } + /** * @param int $documentId * @@ -216,8 +243,10 @@ public function addStep(Document $document, Session $session, array $steps, int } } if (count($stepsToInsert) > 0) { - $file = $this->fileService->getFileForSession($session, $shareToken); - if (!$this->fileService->isReadOnly($file, $shareToken)) { + $type = $document->getContextType(); + $id = $document->getContextId(); + $context = $this->contextManager->getContext($type, $id, $shareToken); + if (!$context->isReadOnly()) { $this->insertSteps($document, $session, $stepsToInsert); } } @@ -305,40 +334,6 @@ public function getSteps(int $documentId, int $lastVersion): array { 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(); - $lastMTime = $document->getLastSavedVersionTime(); - $lastEtag = $document->getLastSavedVersionEtag(); - - if ($lastMTime <= 0 || $force || $this->fileService->isReadOnly($file, $shareToken) || $this->cache->get('document-save-lock-' . $documentId)) { - return; - } - - $fileMtime = $file->getMtime(); - $fileEtag = $file->getEtag(); - - if ($lastEtag === $fileEtag && $lastMTime === $fileMtime) { - return; - } - - $storedChecksum = $document->getChecksum(); - $fileContent = $file->getContent(); - $fileChecksum = self::computeCheckSum($fileContent); - - if ($storedChecksum !== $fileChecksum) { - throw new DocumentSaveConflictException('File changed in the meantime from outside'); - } - - $document->setLastSavedVersionTime($fileMtime); - $document->setLastSavedVersionEtag($fileEtag); - $this->documentMapper->update($document); - } - /** * @param string $content * @return string @@ -355,19 +350,25 @@ public static function computeCheckSum(string $content): string { * @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 ($this->fileService->isReadOnly($file, $shareToken)) { + public function autosave(Document $document, IContext $context, int $version, string $autoSaveDocument, string $documentState, bool $force = false, bool $manualSave = false, ?string $shareToken = null): Document { + if ($context->isReadOnly()) { throw new NotPermittedException('Read-only permission cannot save document changes. Please reload the page.'); } - $this->assertNoOutsideConflict($document, $file, $force); + $lastMTime = $document->getLastSavedVersionTime(); + if ($lastMTime > 0 && !$force && !$this->cache->get('document-save-lock-' . $document->id)) { + $updatedDocument = $context->updateDocument($document); + if ($updatedDocument !== null) { + $document = $updatedDocument; + $lastMTime = $document->getLastSavedVersionTime(); + $this->documentMapper->update($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; + $stepsVersion = $this->stepMapper->getLatestVersion($document->id) ?? 0; $savedVersion = $document->getLastSavedVersion(); $outdated = $savedVersion > 0 && $savedVersion > $version; if (!$force && ($outdated || $version > (string)$stepsVersion)) { @@ -375,50 +376,45 @@ public function autosave(Document $document, File $file, int $version, string $a } // Only save once every AUTOSAVE_MINIMUM_DELAY seconds - $lastMTime = $document->getLastSavedVersionTime(); - if ($file->getMTime() === $lastMTime && $lastMTime > time() - self::AUTOSAVE_MINIMUM_DELAY && $manualSave === false) { + if ($lastMTime > time() - self::AUTOSAVE_MINIMUM_DELAY && $manualSave === false) { return $document; } if (empty($autoSaveDocument)) { + $file = $context->getFile(); $this->logger->warning('Saving empty document', [ 'requestVersion' => $version, 'requestAutosaveDocument' => $autoSaveDocument, 'requestDocumentState' => $documentState, 'document' => $document->jsonSerialize(), - 'fileSizeBeforeSave' => $file->getSize(), - 'steps' => array_map(static fn (Step $step) => $step->jsonSerialize(), $this->stepMapper->find($documentId, 0)), - 'sessions' => array_map(static fn (Session $session) => $session->jsonSerialize(), $this->sessionMapper->findAll($documentId)) + 'fileSizeBeforeSave' => $file ? $file->getSize() : $context->getType() . ' is not stored in a file', + 'steps' => array_map(static fn (Step $step) => $step->jsonSerialize(), $this->stepMapper->find($document->id, 0)), + 'sessions' => array_map(static fn (Session $session) => $session->jsonSerialize(), $this->sessionMapper->findAll($document->id)) ]); } // Version changed but the content remains the same - if ($autoSaveDocument === $file->getContent()) { - $this->writeDocumentState($file->getId(), $documentState); + if ($autoSaveDocument === $context->loadContent()) { + $this->writeDocumentState($document->id, $documentState); $document->setLastSavedVersion($version); - $document->setLastSavedVersionTime($file->getMTime()); - $document->setLastSavedVersionEtag($file->getEtag()); $this->documentMapper->update($document); return $document; } - $this->cache->set('document-save-lock-' . $documentId, true, 10); + $this->cache->set('document-save-lock-' . $document->id, true, 10); + $this->saveFromText = true; try { - $this->lockService->runInScope($file, function () use ($file, $autoSaveDocument, $documentState): void { - $this->saveFromText = true; - $file->putContent($autoSaveDocument); - $this->writeDocumentState($file->getId(), $documentState); + $context->saveWithLock($autoSaveDocument, function () use ($document, $documentState): void { + $this->writeDocumentState($document->id, $documentState); }); $document->setLastSavedVersion($version); - $document->setLastSavedVersionTime($file->getMTime()); - $document->setLastSavedVersionEtag($file->getEtag()); $document->setChecksum(self::computeCheckSum($autoSaveDocument)); $this->documentMapper->update($document); } catch (LockedException) { // Ignore lock since it might occur when multiple people save at the same time return $document; } finally { - $this->cache->remove('document-save-lock-' . $documentId); + $this->cache->remove('document-save-lock-' . $document->id); } return $document; } diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 471a6770783..b83a30114be 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -8,7 +8,6 @@ namespace OCA\Text\Service; use OCA\Files_Sharing\SharedStorage; -use OCA\Text\Db\Session; use OCA\Text\Exception\InvalidSessionException; use OCP\Constants; use OCP\Files\File; @@ -21,39 +20,20 @@ use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager as ShareManager; use OCP\Share\IShare; +use Psr\Log\LoggerInterface; class FileService { public function __construct( + private readonly EncodingService $encodingService, private readonly ISession $session, private readonly IRootFolder $rootFolder, private readonly LockService $lockService, + private readonly LoggerInterface $logger, private readonly ShareManager $shareManager, ) { } - /** - * @throws NotPermittedException - * @throws NotFoundException - */ - public function getFileForSession(Session $session, ?string $shareToken = null): File { - if (!$session->isGuest()) { - try { - return $this->getFileById($session->getDocumentId(), $session->getUserId()); - } catch (NotFoundException $e) { - if ($shareToken === null) { - throw $e; - } - // 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'); - } - return $this->getFileByIdFromShare($session->getDocumentId(), $shareToken); - } - /** * @throws NotFoundException */ @@ -68,7 +48,7 @@ public function getFileByIdFromShare(int $fileId, string $shareToken): File { if ($node instanceof Folder) { $node = $node->getFirstNodeById($fileId); } - if ($node instanceof File) { + if ($node instanceof File && $node->getId() === $fileId) { return $node; } throw new NotFoundException(); @@ -94,27 +74,29 @@ public function getFileById(int $fileId, string $userId): File { return $file; } - // Ideally we'd optimize this part in the future by storing the path and getting the acutal target directly - $files = $userFolder->getById($fileId); + // Ideally we'd optimize this part in the future by storing the path and getting the actual target directly + $files = array_filter($userFolder->getById($fileId), fn (Node $f) => $f instanceof File); 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 fn (Node $a, Node $b) => ($b->getPermissions() & Constants::PERMISSION_UPDATE) <=> ($a->getPermissions() & Constants::PERMISSION_UPDATE)); - - $file = array_shift($files); - - if (!$file instanceof File) { - throw new NotFoundException(); + $readableFile = null; + foreach ($files as $file) { + $permissions = $file->getPermissions(); + if ($permissions & Constants::PERMISSION_READ && $permissions & Constants::PERMISSION_UPDATE) { + return $file; + } + if ($permissions & Constants::PERMISSION_READ) { + $readableFile = $file; + } } - - if (($file->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ) { - throw new NotPermittedException(); + if ($readableFile !== null) { + return $readableFile; } - return $file; + throw new NotPermittedException(); } /** @@ -186,7 +168,7 @@ public function checkSharePermissions(string $shareToken, int $permission = Cons } } - public function getDocumentIdFromShare(int $fileId, string $shareToken): int { + public function checkFileAccessFromShare(int $fileId, string $shareToken): void { try { $share = $this->shareManager->getShareByToken($shareToken); } catch (ShareNotFound) { @@ -226,15 +208,26 @@ public function getDocumentIdFromShare(int $fileId, string $shareToken): int { if ($attributes !== null && $attributes->getAttribute('permissions', 'download') === false) { throw new InvalidSessionException(); } + } - return $fileId; + public function checkFileAccessForUser(int $fileId, string $userId): void { + if ($this->rootFolder->getUserFolder($userId)->getFirstNodeById($fileId) === null) { + throw new InvalidSessionException(); + } } - public function getDocumentIdForUser(int $fileId, string $userId): int { - if ($this->rootFolder->getUserFolder($userId)->getFirstNodeById($fileId) !== null) { - return $fileId; + public function loadContent(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; } - throw new InvalidSessionException(); + return $content; } } diff --git a/lib/Service/LockService.php b/lib/Service/LockService.php index 9bce9254824..a64fd133b60 100644 --- a/lib/Service/LockService.php +++ b/lib/Service/LockService.php @@ -23,6 +23,9 @@ public function __construct( ) { } + /** + * @throws PreConditionNotMetException if another lock scope is already active + */ public function runInScope(File $file, callable $callback): void { $this->lockManager->runInScope( new LockContext( @@ -49,10 +52,6 @@ public function getLockByOthers(File $file): ?ILock { } public function lock(File $file): bool { - if (!$this->lockManager->isLockProviderAvailable()) { - return true; - } - try { $this->lockManager->lock(new LockContext( $file, @@ -67,10 +66,6 @@ public function lock(File $file): bool { } public function unlock(File $file): void { - if (!$this->lockManager->isLockProviderAvailable()) { - return; - } - try { $this->lockManager->unlock(new LockContext( $file, diff --git a/src/apis/connect.ts b/src/apis/connect.ts index 33e8ebdbb00..e22e41c0669 100644 --- a/src/apis/connect.ts +++ b/src/apis/connect.ts @@ -9,11 +9,18 @@ import type { Document, GuestSession, Session } from '../services/SyncService.ts import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' -export interface OpenParams { - fileId?: number +export interface OpenContextParams { + type: string + id: number + filePath: string // not send to the api but included in the connection baseVersionEtag?: string +} + +export interface OpenShareParams { + token: string + fileId: number filePath: string - token?: string + baseVersionEtag?: string guestName?: string } @@ -27,16 +34,33 @@ export interface OpenData { hasOwner: boolean } +/** + * Open editing connection to a file when logged in + * + * @param params Parameters identifying the document + */ +export async function openContext(params: OpenContextParams): Promise<{ connection: Connection, data: OpenData }> { + const { type, id, baseVersionEtag } = params + const url = generateUrl(`/apps/text/session/${type}/${id}/create`) + const response = await axios.put(url, { baseVersionEtag }) + const { document, session } = response.data + const connection = { + documentId: document.id, + sessionId: session.id, + sessionToken: session.token, + baseVersionEtag: document.baseVersionEtag, + filePath: params.filePath, + } + return { connection, data: response.data } +} + /** * Open editing connection to the document * * @param params Parameters identifying the document */ -export async function open(params: OpenParams): Promise<{ connection: Connection, data: OpenData }> { - const _baseUrl = params.token - ? generateUrl('/apps/text/public') - : generateUrl('/apps/text') - const url = `${_baseUrl}/session/${params.fileId}/create` +export async function openShare(params: OpenShareParams): Promise<{ connection: Connection, data: OpenData }> { + const url = generateUrl('/apps/text/public/session/123/create') const response = await axios.put(url, params) const { document, session } = response.data const connection = { diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue index da0b2f86d22..f1ab97546ca 100644 --- a/src/components/CollaborativeEditor.vue +++ b/src/components/CollaborativeEditor.vue @@ -188,9 +188,9 @@ export default defineComponent({ default: '', }, - fileId: { - type: Number, - default: null, + context: { + type: Object, + required: true, }, active: { @@ -287,7 +287,7 @@ export default defineComponent({ ) const { syncService } = provideSyncService(connection, openConnection) const extensions = [ - Autofocus.configure({ fileId: props.fileId }), + Autofocus.configure({ id: props.context.type + '-' + props.context.id }), Collaboration.configure({ document: ydoc }), CollaborationCaret.configure({ provider: { awareness } }), ] @@ -406,7 +406,7 @@ export default defineComponent({ }, hasDocumentParameters() { - return this.fileId || this.shareToken || this.initialSession + return this.context || this.shareToken || this.initialSession }, hasOutdatedDocument() { @@ -449,7 +449,7 @@ export default defineComponent({ }, indexedDbConflictKey() { - return `text-indexeddb-conflict-${this.fileId}` + return `text-indexeddb-conflict-${this.context.type}-${this.context.id}` }, }, @@ -491,9 +491,7 @@ export default defineComponent({ this.indexedDbConflictKey, JSON.stringify(conflictData), ) - logger.debug('Stored conflict to localStorage', { - fileId: this.fileId, - }) + logger.debug('Stored conflict to localStorage', this.context) } logger.debug('Clearing the outdated cache and connecting without it.') @@ -559,7 +557,7 @@ export default defineComponent({ this.syncProvider = createSyncServiceProvider({ ydoc: this.ydoc, syncService: this.syncService, - fileId: this.fileId, + context: this.context, initialSession: this.initialSession, disableBc: true, awareness: this.awareness, @@ -628,9 +626,9 @@ export default defineComponent({ shareToken: this.shareToken, currentDirectory: this.currentDirectory, }) - if (session.userId && this.relativePath?.length) { + if (session.userId && this.relativePath?.length && this.context.type === 'file') { const node = new File({ - id: this.fileId, + id: this.context.id, root: `/files/${session.userId}`, source: generateRemoteUrl(`dav/files/${session.userId}${this.relativePath}`), @@ -875,7 +873,7 @@ export default defineComponent({ */ debugData() { const yjsData = { - fileId: this.fileId, + context: this.context, filePath: this.relativePath, clientId: this.ydoc.clientID, pendingStructs: this.ydoc.store.pendingStructs, diff --git a/src/components/PublicFilesEditor.vue b/src/components/PublicFilesEditor.vue deleted file mode 100644 index b255c9378f1..00000000000 --- a/src/components/PublicFilesEditor.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/src/components/ViewerComponent.vue b/src/components/ViewerComponent.vue index 5d53d81f68f..2a1cff26eca 100644 --- a/src/components/ViewerComponent.vue +++ b/src/components/ViewerComponent.vue @@ -6,11 +6,11 @@ -