Skip to content

Commit ea99f22

Browse files
authored
Merge pull request #7183 from nextcloud/backport/7154/stable31
[stable31] Disable attachment upload on federated shares
2 parents c7801d7 + 05d2c75 commit ea99f22

File tree

7 files changed

+179
-3
lines changed

7 files changed

+179
-3
lines changed

lib/Service/ApiService.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ public function create(?int $fileId = null, ?string $filePath = null, ?string $b
141141
$lockInfo = null;
142142
}
143143

144+
$hasOwner = $file->getOwner() !== null;
145+
144146
if (!$readOnly) {
145147
$isLocked = $this->documentService->lock($file->getId());
146148
if (!$isLocked) {
@@ -155,6 +157,7 @@ public function create(?int $fileId = null, ?string $filePath = null, ?string $b
155157
'content' => $content,
156158
'documentState' => $documentState,
157159
'lock' => $lockInfo,
160+
'hasOwner' => $hasOwner,
158161
]);
159162
}
160163

src/components/Menu/ActionAttachmentUpload.vue

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
<NcActions class="entry-action entry-action__image-upload"
77
:data-text-action-entry="actionEntry.key"
88
:name="actionEntry.label"
9-
:title="actionEntry.label"
9+
:disabled="isUploadDisabled"
10+
:title="menuTitle"
1011
:aria-label="actionEntry.label"
1112
:container="menuIDSelector">
1213
<template #icon>
@@ -40,7 +41,11 @@
4041
<script>
4142
import { NcActions, NcActionButton } from '@nextcloud/vue'
4243
import { Loading, Folder, Upload } from '../icons.js'
43-
import { useIsPublicMixin, useEditorUpload } from '../Editor.provider.js'
44+
import {
45+
useIsPublicMixin,
46+
useEditorUpload,
47+
useSyncServiceMixin,
48+
} from '../Editor.provider.js'
4449
import { BaseActionEntry } from './BaseActionEntry.js'
4550
import { useMenuIDMixin } from './MenuBar.provider.js'
4651
import {
@@ -62,6 +67,7 @@ export default {
6267
mixins: [
6368
useIsPublicMixin,
6469
useEditorUpload,
70+
useSyncServiceMixin,
6571
useActionAttachmentPromptMixin,
6672
useUploadingStateMixin,
6773
useActionChooseLocalAttachmentMixin,
@@ -76,6 +82,17 @@ export default {
7682
isUploadingAttachments() {
7783
return this.$uploadingState.isUploadingAttachments
7884
},
85+
isUploadDisabled() {
86+
return !this.$syncService.hasOwner
87+
},
88+
menuTitle() {
89+
return this.isUploadDisabled
90+
? t(
91+
'text',
92+
'Attachments cannot be created or uploaded because this file is shared from another cloud.',
93+
)
94+
: this.actionEntry.label
95+
},
7996
},
8097
}
8198
</script>

src/services/SessionApi.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,27 @@ export class Connection {
4949
#session
5050
#lock
5151
#readOnly
52+
#hasOwner
5253
#options
5354

5455
constructor(response, options) {
55-
const { document, session, lock, readOnly, content, documentState } = response.data
56+
const {
57+
document,
58+
session,
59+
lock,
60+
readOnly,
61+
content,
62+
documentState,
63+
hasOwner,
64+
} = response.data
5665
this.#document = document
5766
this.#session = session
5867
this.#lock = lock
5968
this.#readOnly = readOnly
6069
this.#content = content
6170
this.#documentState = documentState
6271
this.#options = options
72+
this.#hasOwner = hasOwner
6373
this.isPublic = !!options.shareToken
6474
this.closed = false
6575
}
@@ -89,6 +99,10 @@ export class Connection {
8999
return this.closed
90100
}
91101

102+
get hasOwner() {
103+
return this.#hasOwner
104+
}
105+
92106
get #defaultParams() {
93107
return {
94108
documentId: this.#document.id,

src/services/SyncService.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ class SyncService {
8888
return this.#connection.state.document.readOnly
8989
}
9090

91+
get hasOwner() {
92+
return this.#connection?.hasOwner
93+
}
94+
9195
get guestName() {
9296
return this.#connection.session.guestName
9397
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { describe, it, vi, expect } from 'vitest'
7+
import axios from '@nextcloud/axios'
8+
import SessionApi, { Connection } from '../../services/SessionApi.js'
9+
10+
vi.mock('@nextcloud/axios', () => {
11+
const put = vi.fn()
12+
return { default: { put } }
13+
})
14+
15+
describe('Session api', () => {
16+
it('opens a connection', async () => {
17+
const api = new SessionApi()
18+
axios.put.mockResolvedValue({ data: { hasOwner: true } })
19+
const connection = await api.open({ fileId: 123, baseBersionEtag: 'abc' })
20+
expect(connection).toBeInstanceOf(Connection)
21+
expect(connection.isClosed).toBe(false)
22+
expect(connection.hasOwner).toBe(true)
23+
})
24+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { describe, it, vi, expect } from 'vitest'
7+
import { SyncService } from '../../services/SyncService.js'
8+
9+
describe('Sync service', () => {
10+
11+
it('opens a connection', async () => {
12+
const api = mockApi({ hasOwner: true })
13+
const service = new SyncService({ api, baseVersionEtag: 'abc' })
14+
await service.open({ fileId: 123 })
15+
expect(service.hasOwner).toBe(true)
16+
})
17+
18+
it('opens a connection to a file without owner', async () => {
19+
const api = mockApi({ hasOwner: false })
20+
const service = new SyncService({ api, baseVersionEtag: 'abc' })
21+
await service.open({ fileId: 123 })
22+
expect(service.hasOwner).toBe(false)
23+
})
24+
25+
it('hasOwner is undefined without connection', async () => {
26+
const service = new SyncService({})
27+
expect(service.hasOwner).toBe(undefined)
28+
})
29+
30+
})
31+
32+
const mockApi = (connectionOptions = {}) => {
33+
const defaults = { document: { baseVersionEtag: 'abc' } }
34+
const open = vi.fn().mockResolvedValue({ ...defaults, ...connectionOptions })
35+
return { open }
36+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace OCA\Text\Tests;
4+
5+
use OCA\Text\Db\Document;
6+
use OCA\Text\Service\ApiService;
7+
use OCA\Text\Service\ConfigService;
8+
use OCA\Text\Service\DocumentService;
9+
use OCA\Text\Service\EncodingService;
10+
use OCA\Text\Service\SessionService;
11+
use OCP\IL10N;
12+
use OCP\IRequest;
13+
use Psr\Log\LoggerInterface;
14+
15+
class ApiServiceTest extends \PHPUnit\Framework\TestCase {
16+
private ApiService $apiService;
17+
18+
private IRequest $request;
19+
private ConfigService $configService;
20+
private SessionService $sessionService;
21+
private DocumentService $documentService;
22+
private EncodingService $encodingService;
23+
private LoggerInterface $loggerInterface;
24+
private IL10N $l10n;
25+
private string $userId;
26+
27+
public function setUp(): void {
28+
$this->request = $this->createMock(IRequest::class);
29+
$this->configService = $this->createMock(ConfigService::class);
30+
$this->sessionService = $this->createMock(SessionService::class);
31+
$this->documentService = $this->createMock(DocumentService::class);
32+
$this->encodingService = $this->createMock(EncodingService::class);
33+
$this->loggerInterface = $this->createMock(LoggerInterface::class);
34+
$this->l10n = $this->createMock(IL10N::class);
35+
$this->userId = 'admin';
36+
37+
$document = new Document();
38+
$document->setId(123);
39+
$this->documentService->method('getDocument')->willReturn($document);
40+
$this->documentService->method('isReadOnly')->willReturn(false);
41+
42+
$this->apiService = new ApiService(
43+
$this->request,
44+
$this->configService,
45+
$this->sessionService,
46+
$this->documentService,
47+
$this->encodingService,
48+
$this->loggerInterface,
49+
$this->l10n,
50+
$this->userId,
51+
null,
52+
);
53+
}
54+
55+
public function testCreateNewSession() {
56+
$file = $this->mockFile(1234, 'admin');
57+
$this->documentService->method('getFileById')->willReturn($file);
58+
$actual = $this->apiService->create(1234);
59+
self::assertTrue($actual->getData()['hasOwner']);
60+
}
61+
62+
public function testCreateNewSessionWithoutOwner() {
63+
$file = $this->mockFile(1234, null);
64+
$this->documentService->method('getFileById')->willReturn($file);
65+
$actual = $this->apiService->create(1234);
66+
self::assertFalse($actual->getData()['hasOwner']);
67+
}
68+
69+
private function mockFile(int $id, ?string $owner) {
70+
$file = $this->createMock(\OCP\Files\File::class);
71+
$storage = $this->createMock(\OCP\Files\Storage\IStorage::class);
72+
$file->method('getStorage')->willReturn($storage);
73+
$file->method('getId')->willReturn($id);
74+
$file->method('getOwner')->willReturn($owner);
75+
return $file;
76+
}
77+
78+
}

0 commit comments

Comments
 (0)