Skip to content

Commit 2dc7f85

Browse files
Merge pull request #8751 from nextcloud/backport/8746/stable34
[stable34] Fix permission change in text doc
2 parents e2505e7 + cf3f94e commit 2dc7f85

8 files changed

Lines changed: 100 additions & 22 deletions

File tree

lib/Service/ApiService.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,11 @@ public function push(Session $session, Document $document, int $version, array $
174174
$this->addToPushQueue($document, [$awareness, ...array_values($steps)]);
175175
} catch (InvalidArgumentException $e) {
176176
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY);
177-
} catch (DoesNotExistException|NotPermittedException) {
177+
} catch (DoesNotExistException) {
178178
// Either no write access or session was removed in the meantime (#3875).
179179
return new DataResponse(['error' => $this->l10n->t('Editing session has expired. Please reload the page.')], Http::STATUS_PRECONDITION_FAILED);
180+
} catch (NotPermittedException) {
181+
return new DataResponse(['error' => $this->l10n->t('This document is read-only.')], Http::STATUS_FORBIDDEN);
180182
}
181183
return new DataResponse($result);
182184
}
@@ -214,6 +216,7 @@ public function sync(Session $session, Document $document, int $version = 0, ?st
214216

215217
// ensure file is still present and accessible
216218
$file = $this->documentService->getFileForSession($session, $shareToken);
219+
$result['readOnly'] = $this->documentService->isReadOnly($file, $shareToken);
217220
$this->documentService->assertNoOutsideConflict($document, $file);
218221
} catch (NotPermittedException|NotFoundException|InvalidPathException $e) {
219222
$this->logger->info($e->getMessage(), ['exception' => $e]);
@@ -261,6 +264,10 @@ public function save(Session $session, Document $document, int $version, string
261264
} catch (LockedException) {
262265
// Ignore locked exception since it might happen due to an autosave action happening at the same time
263266
}
267+
} catch (NotPermittedException) {
268+
return new DataResponse([
269+
'error' => $this->l10n->t('Read-only permission cannot save document changes. Please reload the page.')
270+
], Http::STATUS_FORBIDDEN);
264271
} catch (NotFoundException) {
265272
return new DataResponse([], Http::STATUS_NOT_FOUND);
266273
} catch (Exception $e) {

lib/Service/DocumentService.php

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,8 @@ public function writeDocumentState(int $documentId, string $content): void {
218218
*/
219219
public function addStep(Document $document, Session $session, array $steps, int $version, ?int $recoveryAttempt, ?string $shareToken): array {
220220
$documentId = $session->getDocumentId();
221-
$readOnly = $this->isReadOnlyCached($session, $shareToken);
221+
$file = $this->getFileForSession($session, $shareToken);
222+
$readOnly = $this->isReadOnly($file, $shareToken);
222223
$stepsToInsert = [];
223224
$stepsIncludeQuery = false;
224225
$documentState = null;
@@ -384,7 +385,7 @@ public function autosave(Document $document, File $file, int $version, string $a
384385
$documentId = $document->getId();
385386

386387
if ($this->isReadOnly($file, $shareToken)) {
387-
return $document;
388+
throw new NotPermittedException('Read-only permission cannot save document changes. Please reload the page.');
388389
}
389390

390391
$this->assertNoOutsideConflict($document, $file, $force);
@@ -600,29 +601,14 @@ public function getFileByShareToken(string $shareToken, ?string $path = null): F
600601
throw new \InvalidArgumentException('No proper share data');
601602
}
602603

603-
public function isReadOnlyCached(Session $session, ?string $shareToken = null): bool {
604-
$cacheKey = 'read-only-' . $session->getId();
605-
$isReadOnly = $this->cache->get($cacheKey);
606-
if ($isReadOnly === null) {
607-
$file = $this->getFileForSession($session, $shareToken);
608-
$isReadOnly = $this->isReadOnly($file, $shareToken);
609-
$this->cache->set($cacheKey, $isReadOnly, 60 * 5);
610-
return $isReadOnly;
611-
}
612-
613-
return $isReadOnly;
614-
}
615-
616604
public function isReadOnly(File $file, ?string $token): bool {
617-
$readOnly = true;
605+
$readOnly = !$file->isUpdateable();
618606
if ($token !== null) {
619607
try {
620608
$this->checkSharePermissions($token, Constants::PERMISSION_UPDATE);
621-
$readOnly = false;
622609
} catch (NotFoundException $e) {
610+
$readOnly = true;
623611
}
624-
} else {
625-
$readOnly = !$file->isUpdateable();
626612
}
627613

628614
$lockInfo = $this->getLockInfo($file);

src/apis/sync.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ interface SyncResponse {
5656
awareness: Record<string, string>
5757
document: Document
5858
sessions: Session[]
59+
readOnly?: boolean
5960
}
6061
}
6162

src/components/Editor.vue

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import {
9797
} from './Editor.provider.ts'
9898
import ReadonlyBar from './Menu/ReadonlyBar.vue'
9999
100+
import { showWarning } from '@nextcloud/dialogs'
100101
import { loadState } from '@nextcloud/initial-state'
101102
import { generateRemoteUrl } from '@nextcloud/router'
102103
import { Awareness } from 'y-protocols/awareness.js'
@@ -548,6 +549,7 @@ export default defineComponent({
548549
bus.on('stateChange', this.onStateChange)
549550
bus.on('idle', this.onIdle)
550551
bus.on('save', this.onSave)
552+
bus.on('permissionChange', this.onPermissionChange)
551553
},
552554
553555
unlistenSyncServiceEvents() {
@@ -559,6 +561,7 @@ export default defineComponent({
559561
bus.off('stateChange', this.onStateChange)
560562
bus.off('idle', this.onIdle)
561563
bus.off('save', this.onSave)
564+
bus.off('permissionChange', this.onPermissionChange)
562565
},
563566
564567
reconnect() {
@@ -693,7 +696,15 @@ export default defineComponent({
693696
}
694697
695698
if (type === ERROR_TYPE.PUSH_FORBIDDEN) {
696-
this.hasConnectionIssue = true
699+
this.readOnly = true
700+
this.editMode = false
701+
this.setEditable(this.editMode)
702+
showWarning(
703+
t(
704+
'text',
705+
'Your editing permissions have been revoked. The document is now read-only.',
706+
),
707+
)
697708
this.emit('push:forbidden')
698709
return
699710
}
@@ -747,6 +758,24 @@ export default defineComponent({
747758
})
748759
},
749760
761+
onPermissionChange({ readOnly }) {
762+
this.readOnly = readOnly
763+
this.editMode = !readOnly && !this.openReadOnlyEnabled
764+
this.setEditable(this.editMode)
765+
if (readOnly) {
766+
showWarning(
767+
t(
768+
'text',
769+
'Your editing permissions have been revoked. The document is now read-only.',
770+
),
771+
)
772+
} else {
773+
showWarning(
774+
t('text', 'You now have edit permissions for this document.'),
775+
)
776+
}
777+
},
778+
750779
onFocus() {
751780
this.emit('focus')
752781
},

src/services/PollingBackend.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ interface PollData {
6060
document: Document
6161
sessions: Session[]
6262
steps: Step[]
63+
readOnly?: boolean
6364
}
6465

6566
interface ConflictData extends PollData {
@@ -145,6 +146,16 @@ class PollingBackend {
145146
const { document, sessions } = data
146147
this.#fetchRetryCounter = 0
147148

149+
if (data.readOnly !== undefined && data.readOnly !== this.#readOnly) {
150+
this.#readOnly = data.readOnly
151+
this.#syncService.bus.emit('permissionChange', {
152+
readOnly: this.#readOnly,
153+
})
154+
if (data.readOnly) {
155+
this.maximumReadOnlyTimer()
156+
}
157+
}
158+
148159
this.#syncService.bus.emit('change', { document, sessions })
149160
this.#syncService.receiveSteps(data)
150161

src/services/SaveService.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6+
import { showError } from '@nextcloud/dialogs'
67
import debounce from 'debounce'
78

89
import type { ShallowRef } from 'vue'
910
import { save, saveViaSendBeacon } from '../apis/save'
1011
import type { Connection } from '../composables/useConnection.ts'
1112
import { logger } from '../helpers/logger.js'
12-
import type { SyncService } from './SyncService'
13+
import { ERROR_TYPE, type SyncService } from './SyncService'
1314

1415
/**
1516
* Interval to save the serialized document and the document state
@@ -74,6 +75,22 @@ class SaveService {
7475
this.autosave.clear()
7576
} catch (e) {
7677
logger.error('Failed to save document.', { error: e })
78+
const response = (
79+
e as { response?: { status?: number; data?: { error?: string } } }
80+
).response
81+
if (response?.status === 403) {
82+
// Document is now read-only; permissionChange from sync will update the UI
83+
return
84+
}
85+
if (response?.status === 412) {
86+
this.emit('error', {
87+
type: ERROR_TYPE.LOAD_ERROR,
88+
data: response,
89+
})
90+
if (response.data?.error) {
91+
showError(response.data.error)
92+
}
93+
}
7794
throw e
7895
}
7996
}

src/services/SyncService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ export declare type EventTypes = {
129129

130130
/* Emitted if the connection has been closed */
131131
close: void
132+
133+
/* Emitted if the read only state of the document has changed */
134+
permissionChange: { readOnly: boolean }
132135
}
133136

134137
class SyncService {

tests/unit/Service/ApiServiceTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace OCA\Text\Tests;
44

55
use OCA\Text\Db\Document;
6+
use OCA\Text\Db\Session;
67
use OCA\Text\Service\ApiService;
78
use OCA\Text\Service\ConfigService;
89
use OCA\Text\Service\DocumentService;
@@ -70,6 +71,29 @@ public function testCreateNewSessionWithoutOwner() {
7071
self::assertFalse($actual->getData()['hasOwner']);
7172
}
7273

74+
public function testSaveWithNotPermittedException() {
75+
$session = new Session();
76+
$session->setDocumentId(123);
77+
78+
$document = new Document();
79+
80+
$file = $this->mockFile(123, 'admin');
81+
82+
$this->documentService->method('getFileForSession')->willReturn($file);
83+
$this->documentService->method('autosave')->willThrowException(new \OCP\Files\NotPermittedException());
84+
85+
$this->l10n->method('t')
86+
->with('Read-only permission cannot save document changes. Please reload the page.')
87+
->willReturn('Read-only permission cannot save document changes. Please reload the page.');
88+
89+
$response = $this->apiService->save($session, $document, 1, 'content', 'state');
90+
91+
self::assertEquals(\OCP\AppFramework\Http::STATUS_FORBIDDEN, $response->getStatus());
92+
self::assertEquals('Read-only permission cannot save document changes. Please reload the page.',
93+
$response->getData()['error']
94+
);
95+
}
96+
7397
private function mockFile(int $id, ?string $owner) {
7498
$file = $this->createMock(\OCP\Files\File::class);
7599
$storage = $this->createMock(\OCP\Files\Storage\IStorage::class);

0 commit comments

Comments
 (0)