diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index ea729eb5973..a64b31269c4 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -61,6 +61,7 @@ 'OCA\\Text\\Migration\\Version030701Date20230207131313' => $baseDir . '/../lib/Migration/Version030701Date20230207131313.php', 'OCA\\Text\\Migration\\Version030901Date20231114150437' => $baseDir . '/../lib/Migration/Version030901Date20231114150437.php', 'OCA\\Text\\Migration\\Version040100Date20240611165300' => $baseDir . '/../lib/Migration/Version040100Date20240611165300.php', + 'OCA\\Text\\Migration\\Version070000Date20250925110024' => $baseDir . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\ApiService' => $baseDir . '/../lib/Service/ApiService.php', 'OCA\\Text\\Service\\AttachmentService' => $baseDir . '/../lib/Service/AttachmentService.php', diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index 9568b785776..cde09006658 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -76,6 +76,7 @@ class ComposerStaticInitText 'OCA\\Text\\Migration\\Version030701Date20230207131313' => __DIR__ . '/..' . '/../lib/Migration/Version030701Date20230207131313.php', 'OCA\\Text\\Migration\\Version030901Date20231114150437' => __DIR__ . '/..' . '/../lib/Migration/Version030901Date20231114150437.php', 'OCA\\Text\\Migration\\Version040100Date20240611165300' => __DIR__ . '/..' . '/../lib/Migration/Version040100Date20240611165300.php', + 'OCA\\Text\\Migration\\Version070000Date20250925110024' => __DIR__ . '/..' . '/../lib/Migration/Version070000Date20250925110024.php', 'OCA\\Text\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\Text\\Service\\ApiService' => __DIR__ . '/..' . '/../lib/Service/ApiService.php', 'OCA\\Text\\Service\\AttachmentService' => __DIR__ . '/..' . '/../lib/Service/AttachmentService.php', diff --git a/cypress/e2e/conflict.spec.js b/cypress/e2e/conflict.spec.js index 5e9f191030e..78590f49c66 100644 --- a/cypress/e2e/conflict.spec.js +++ b/cypress/e2e/conflict.spec.js @@ -52,7 +52,7 @@ variants.forEach(function({ fixture, mime }) { }) it(prefix + ': displays conflicts', function() { - createConflict(fileName, mime) + createConflict(fileName, 'edited-' + fileName, mime) cy.openFile(fileName) @@ -61,6 +61,9 @@ variants.forEach(function({ fixture, mime }) { getWrapper() .find('#read-only-editor') .should('contain', 'Hello world') + getWrapper() + .find('#read-only-editor') + .should('not.contain', 'cruel conflicting') getWrapper() .find('.text-editor__main') .should('contain', 'Hello world') @@ -70,7 +73,7 @@ variants.forEach(function({ fixture, mime }) { }) it(prefix + ': resolves conflict using current editing session', function() { - createConflict(fileName, mime) + createConflict(fileName, 'edited-' + fileName, mime) cy.openFile(fileName) cy.intercept({ method: 'POST', url: '**/session/*/push' }) @@ -81,12 +84,11 @@ variants.forEach(function({ fixture, mime }) { getWrapper().should('not.exist') cy.get('[data-cy="resolveThisVersion"]') .should('not.exist') - cy.getContent().should('contain', 'Hello world') cy.getContent().should('contain', 'cruel conflicting') }) it(prefix + ': resolves conflict using server version', function() { - createConflict(fileName, mime) + createConflict(fileName, 'edited-' + fileName, mime) cy.openFile(fileName) cy.get('[data-cy="resolveServerVersion"]') @@ -102,7 +104,7 @@ variants.forEach(function({ fixture, mime }) { }) it(prefix + ': hides conflict in read only session', function() { - createConflict(fileName, mime) + createConflict(fileName, 'edited-' + fileName, mime) cy.testName().then(testName => { cy.shareFile(`/${testName}/${fileName}`) .then((token) => { @@ -114,6 +116,12 @@ variants.forEach(function({ fixture, mime }) { getWrapper().should('not.exist') }) + it(prefix + ': no conflict when uploading same file content', function() { + createConflict(fileName, fileName, mime) + cy.openFile(fileName) + cy.getContent().should('contain', 'Hello world') + getWrapper().should('not.exist') + }) }) }) @@ -129,7 +137,7 @@ describe('conflict dialog scroll behaviour', function() { cy.login(user) cy.createTestFolder() - createConflict(fileName, 'text/markdown') + createConflict(fileName, 'edited-' + fileName, 'text/markdown') cy.openFile(fileName) @@ -148,22 +156,29 @@ describe('conflict dialog scroll behaviour', function() { }) /** - * @param {string} fileName - filename + * @param {string} fileName1 - filename1 + * @param {string} fileName2 - filename2 * @param {string} mime - mimetype */ -function createConflict(fileName, mime) { +function createConflict(fileName1, fileName2, mime) { cy.testName().then(testName => { - cy.uploadFile(fileName, mime, `${testName}/${fileName}`) + cy.uploadFile(fileName1, mime, `${testName}/${fileName1}`) }) cy.visitTestFolder() - cy.openFile(fileName) + cy.openFile(fileName1) cy.log('Inspect editor') cy.getEditor().find('.ProseMirror').should('have.attr', 'contenteditable', 'true') + cy.getContent() .type('Hello you cruel conflicting world') + cy.testName().then(testName => { - cy.uploadFile(fileName, mime, testName + '/' + fileName) + cy.uploadFile(fileName2, mime, testName + '/' + fileName1) }) + + cy.intercept('POST', '**/session/*/sync').as('sync') + cy.wait('@sync', { timeout: 10000 }) + cy.get('#viewer .modal-header button.header-close').click() cy.get('#viewer').should('not.exist') } diff --git a/cypress/fixtures/edited-lines.txt b/cypress/fixtures/edited-lines.txt new file mode 100644 index 00000000000..42e4ff329a7 --- /dev/null +++ b/cypress/fixtures/edited-lines.txt @@ -0,0 +1,9 @@ +This file contains multiple lines + +Hello world + +It's a text file so it should not be parsed as markdown + +But when it is these would turn into paragraphs. + +edited \ No newline at end of file diff --git a/cypress/fixtures/edited-long.md b/cypress/fixtures/edited-long.md new file mode 100644 index 00000000000..251ee450f22 --- /dev/null +++ b/cypress/fixtures/edited-long.md @@ -0,0 +1,23 @@ +# Hello world + +## First subheading + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +## Second subheading + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +## Third subheading + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +edited \ No newline at end of file diff --git a/cypress/fixtures/edited-test.md b/cypress/fixtures/edited-test.md new file mode 100644 index 00000000000..a90390b9c4c --- /dev/null +++ b/cypress/fixtures/edited-test.md @@ -0,0 +1,2 @@ +## Hello world +edited diff --git a/lib/Db/Document.php b/lib/Db/Document.php index d1b3a21171c..c5521d5adf3 100644 --- a/lib/Db/Document.php +++ b/lib/Db/Document.php @@ -22,6 +22,8 @@ * @method setLastSavedVersionEtag(string $etag): void * @method getBaseVersionEtag(): string * @method setBaseVersionEtag(string $etag): void + * @method getChecksum(): ?string + * @method setChecksum(?string $checksum): void */ class Document extends Entity implements \JsonSerializable { public $id = null; @@ -32,6 +34,7 @@ class Document extends Entity implements \JsonSerializable { protected int $lastSavedVersionTime = 0; protected string $lastSavedVersionEtag = ''; protected string $baseVersionEtag = ''; + protected ?string $checksum = null; public function __construct() { $this->addType('id', 'integer'); @@ -39,6 +42,7 @@ public function __construct() { $this->addType('lastSavedVersion', 'integer'); $this->addType('lastSavedVersionTime', 'integer'); $this->addType('initialVersion', 'integer'); + $this->addType('checksum', 'string'); } public function jsonSerialize(): array { @@ -47,7 +51,8 @@ public function jsonSerialize(): array { 'lastSavedVersion' => $this->lastSavedVersion, 'lastSavedVersionTime' => $this->lastSavedVersionTime, 'baseVersionEtag' => $this->baseVersionEtag, - 'initialVersion' => $this->initialVersion + 'initialVersion' => $this->initialVersion, + 'checksum' => $this->checksum ]; } } diff --git a/lib/Migration/Version070000Date20250925110024.php b/lib/Migration/Version070000Date20250925110024.php new file mode 100644 index 00000000000..c48856c9d26 --- /dev/null +++ b/lib/Migration/Version070000Date20250925110024.php @@ -0,0 +1,37 @@ +getTable('text_documents'); + if (!$table->hasColumn('checksum')) { + $table->addColumn('checksum', Types::STRING, [ + 'notnull' => false, + 'length' => 8, + ]); + return $schema; + } + + return null; + } +} diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index e37dfd70dca..bf142e979cd 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -143,6 +143,7 @@ public function createDocument(File $file): Document { $document->setLastSavedVersionTime($file->getMTime()); $document->setLastSavedVersionEtag($file->getEtag()); $document->setBaseVersionEtag(uniqid()); + $document->setChecksum($this->computeCheckSum($file->getContent())); try { /** @var Document $document */ $document = $this->documentMapper->insert($document); @@ -310,6 +311,8 @@ public function getSteps(int $documentId, int $lastVersion): array { return $this->stepMapper->find($documentId, $lastVersion); } + + /** * @throws DocumentSaveConflictException * @throws InvalidPathException @@ -317,18 +320,39 @@ public function getSteps(int $documentId, int $lastVersion): array { */ public function assertNoOutsideConflict(Document $document, File $file, bool $force = false, ?string $shareToken = null): void { $documentId = $document->getId(); - $savedEtag = $file->getEtag(); $lastMTime = $document->getLastSavedVersionTime(); + $lastEtag = $document->getLastSavedVersionEtag(); + + if ($lastMTime <= 0 || $force || $this->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 = $this->computeChecksum($fileContent); - if ($lastMTime > 0 - && $force === false - && !$this->isReadOnly($file, $shareToken) - && $savedEtag !== $document->getLastSavedVersionEtag() - && $lastMTime !== $file->getMtime() - && !$this->cache->get('document-save-lock-' . $documentId) - ) { + 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 + */ + private function computeCheckSum(string $content): string { + return hash('crc32', $content); } /** @@ -414,6 +438,7 @@ public function autosave(Document $document, ?File $file, int $version, ?string $document->setLastSavedVersion($version); $document->setLastSavedVersionTime($file->getMTime()); $document->setLastSavedVersionEtag($file->getEtag()); + $document->setChecksum($this->computeCheckSum($autoSaveDocument)); $this->documentMapper->update($document); } catch (LockedException $e) { // Ignore lock since it might occur when multiple people save at the same time