Skip to content

Commit ab81921

Browse files
Merge pull request #7154 from nextcloud/fix/7092-no-attachments-for-federated-shares
Disable attachment upload on federated shares
2 parents e6db745 + 8b9c8fa commit ab81921

8 files changed

Lines changed: 206 additions & 6 deletions

File tree

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>
@@ -56,7 +57,11 @@
5657
import { NcActions, NcActionSeparator, NcActionButton, NcIconSvgWrapper } from '@nextcloud/vue'
5758
import { loadState } from '@nextcloud/initial-state'
5859
import { Loading, Folder, Upload, Plus } from '../icons.js'
59-
import { useIsPublicMixin, useEditorUpload } from '../Editor.provider.js'
60+
import {
61+
useIsPublicMixin,
62+
useEditorUpload,
63+
useSyncServiceMixin,
64+
} from '../Editor.provider.js'
6065
import { BaseActionEntry } from './BaseActionEntry.js'
6166
import { useMenuIDMixin } from './MenuBar.provider.js'
6267
import {
@@ -82,6 +87,7 @@ export default {
8287
mixins: [
8388
useIsPublicMixin,
8489
useEditorUpload,
90+
useSyncServiceMixin,
8591
useActionAttachmentPromptMixin,
8692
useUploadingStateMixin,
8793
useActionChooseLocalAttachmentMixin,
@@ -100,6 +106,17 @@ export default {
100106
templates() {
101107
return loadState('files', 'templates', [])
102108
},
109+
isUploadDisabled() {
110+
return !this.$syncService.hasOwner
111+
},
112+
menuTitle() {
113+
return this.isUploadDisabled
114+
? t(
115+
'text',
116+
"Attachments cannot be created or uploaded because this file is shared from another cloud."
117+
)
118+
: this.actionEntry.label
119+
},
103120
},
104121
methods: {
105122
createAttachment(template) {

src/components/SuggestionsBar.vue

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
type="secondary"
2323
size="normal"
2424
class="suggestions--button"
25+
:disabled="isUploadDisabled"
26+
:title="uploadTitle"
2527
@click="$callChooseLocalAttachment">
2628
<template #icon>
2729
<Upload :size="20" />
@@ -59,7 +61,11 @@ import { NcButton } from '@nextcloud/vue'
5961
import { Document, Shape, Upload, Table as TableIcon } from '../components/icons.js'
6062
import { useActionChooseLocalAttachmentMixin } from './Editor/MediaHandler.provider.js'
6163
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
62-
import { useEditorMixin, useFileMixin } from './Editor.provider.js'
64+
import {
65+
useEditorMixin,
66+
useFileMixin,
67+
useSyncServiceMixin,
68+
} from './Editor.provider.js'
6369
import { generateUrl } from '@nextcloud/router'
6470
import { buildFilePicker } from '../helpers/filePicker.js'
6571
import { isMobileDevice } from '../helpers/isMobileDevice.js'
@@ -73,7 +79,13 @@ export default {
7379
Shape,
7480
Upload,
7581
},
76-
mixins: [useActionChooseLocalAttachmentMixin, useEditorMixin, useFileMixin],
82+
83+
mixins: [
84+
useActionChooseLocalAttachmentMixin,
85+
useEditorMixin,
86+
useFileMixin,
87+
useSyncServiceMixin,
88+
],
7789
7890
setup() {
7991
return {
@@ -92,6 +104,18 @@ export default {
92104
relativePath() {
93105
return this.$file?.relativePath ?? '/'
94106
},
107+
isUploadDisabled() {
108+
return !this.$syncService.hasOwner
109+
},
110+
uploadTitle() {
111+
return (
112+
this.isUploadDisabled
113+
&& t(
114+
'text',
115+
'Uploading attachments is disabled because the file is shared from another cloud.',
116+
)
117+
)
118+
},
95119
},
96120
97121
mounted() {

src/services/SessionApi.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,27 @@ export class Connection {
4848
#session
4949
#lock
5050
#readOnly
51+
#hasOwner
5152
#options
5253

5354
constructor(response, options) {
54-
const { document, session, lock, readOnly, content, documentState } =
55-
response.data
55+
const {
56+
document,
57+
session,
58+
lock,
59+
readOnly,
60+
content,
61+
documentState,
62+
hasOwner,
63+
} = response.data
5664
this.#document = document
5765
this.#session = session
5866
this.#lock = lock
5967
this.#readOnly = readOnly
6068
this.#content = content
6169
this.#documentState = documentState
6270
this.#options = options
71+
this.#hasOwner = hasOwner
6372
this.isPublic = !!options.shareToken
6473
this.closed = false
6574
}
@@ -89,6 +98,10 @@ export class Connection {
8998
return this.closed
9099
}
91100

101+
get hasOwner() {
102+
return this.#hasOwner
103+
}
104+
92105
get #defaultParams() {
93106
return {
94107
documentId: this.#document.id,

src/services/SyncService.js

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

90+
get hasOwner() {
91+
return this.#connection?.hasOwner
92+
}
93+
9094
get guestName() {
9195
return this.#connection.session.guestName
9296
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
})
25+
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)