Skip to content

Commit 2ace2b6

Browse files
committed
feat(sharing): make SHARE permission in bundled edit configurable
Add config option shareapi_include_share_in_edit to include reshare permission in "Allow editing" bundle. Signed-off-by: nfebe <[email protected]>
1 parent 929e165 commit 2ace2b6

File tree

10 files changed

+236
-12
lines changed

10 files changed

+236
-12
lines changed

3rdparty

Submodule 3rdparty updated 268 files

apps/files_sharing/lib/Capabilities.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
namespace OCA\Files_Sharing;
99

10-
use OC\Core\AppInfo\ConfigLexicon;
10+
use OCA\Files_Sharing\Config\ConfigLexicon;
1111
use OCP\App\IAppManager;
1212
use OCP\Capabilities\ICapability;
1313
use OCP\Constants;
@@ -77,6 +77,7 @@ public function __construct(
7777
* },
7878
* },
7979
* default_permissions?: int,
80+
* include_share_in_edit?: bool,
8081
* federation: array{
8182
* outgoing: bool,
8283
* incoming: bool,
@@ -159,6 +160,7 @@ public function getCapabilities() {
159160
$res['group']['enabled'] = $this->shareManager->allowGroupSharing();
160161
$res['group']['expire_date']['enabled'] = true;
161162
$res['default_permissions'] = (int)$this->config->getAppValue('core', 'shareapi_default_permissions', (string)Constants::PERMISSION_ALL);
163+
$res['include_share_in_edit'] = $this->appConfig->getValueBool('files_sharing', ConfigLexicon::SHARE_INCLUDE_SHARE_IN_EDIT);
162164
}
163165

164166
//Federated sharing

apps/files_sharing/lib/Config/ConfigLexicon.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
class ConfigLexicon implements ILexicon {
2424
public const SHOW_FEDERATED_AS_INTERNAL = 'show_federated_shares_as_internal';
2525
public const SHOW_FEDERATED_TO_TRUSTED_AS_INTERNAL = 'show_federated_shares_to_trusted_servers_as_internal';
26+
public const SHARE_INCLUDE_SHARE_IN_EDIT = 'shareapi_include_share_in_edit';
2627

2728
public function getStrictness(): Strictness {
2829
return Strictness::IGNORE;
@@ -32,6 +33,7 @@ public function getAppConfigs(): array {
3233
return [
3334
new Entry(self::SHOW_FEDERATED_AS_INTERNAL, ValueType::BOOL, false, 'shows federated shares as internal shares', true),
3435
new Entry(self::SHOW_FEDERATED_TO_TRUSTED_AS_INTERNAL, ValueType::BOOL, false, 'shows federated shares to trusted servers as internal shares', true),
36+
new Entry(self::SHARE_INCLUDE_SHARE_IN_EDIT, ValueType::BOOL, false, 'Include reshare permission in "Allow editing" bundled permissions'),
3537
];
3638
}
3739

apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import IconTune from 'vue-material-design-icons/Tune.vue'
4242
import {
4343
ATOMIC_PERMISSIONS,
4444
BUNDLED_PERMISSIONS,
45+
getBundledPermissions,
4546
} from '../lib/SharePermissionsToolBox.js'
4647
import ShareDetails from '../mixins/ShareDetails.js'
4748
import SharesMixin from '../mixins/SharesMixin.js'
@@ -93,6 +94,10 @@ export default {
9394
return t('files_sharing', 'Custom permissions')
9495
},
9596
97+
bundledPermissions() {
98+
return getBundledPermissions(this.config.includeShareInEdit)
99+
},
100+
96101
preSelectedOption() {
97102
// We remove the share permission for the comparison as it is not relevant for bundled permissions.
98103
const permissionsWithoutShare = this.share.permissions & ~ATOMIC_PERMISSIONS.SHARE
@@ -140,14 +145,14 @@ export default {
140145
dropDownPermissionValue() {
141146
switch (this.selectedOption) {
142147
case this.canEditText:
143-
return this.isFolder ? BUNDLED_PERMISSIONS.ALL : BUNDLED_PERMISSIONS.ALL_FILE
148+
return this.isFolder ? this.bundledPermissions.ALL : this.bundledPermissions.ALL_FILE
144149
case this.fileDropText:
145-
return BUNDLED_PERMISSIONS.FILE_DROP
150+
return this.bundledPermissions.FILE_DROP
146151
case this.customPermissionsText:
147152
return 'custom'
148153
case this.canViewText:
149154
default:
150-
return BUNDLED_PERMISSIONS.READ_ONLY
155+
return this.bundledPermissions.READ_ONLY
151156
}
152157
},
153158
},

apps/files_sharing/src/lib/SharePermissionsToolBox.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ export const BUNDLED_PERMISSIONS = {
2020
ALL_FILE: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.READ,
2121
}
2222

23+
/**
24+
* Get bundled permissions with optional SHARE permission for editing bundles.
25+
*
26+
* @param {boolean} includeShareInEdit - Whether to include SHARE permission in ALL and ALL_FILE bundles.
27+
* @return {object}
28+
*/
29+
export function getBundledPermissions(includeShareInEdit = false) {
30+
if (includeShareInEdit) {
31+
return {
32+
...BUNDLED_PERMISSIONS,
33+
ALL: BUNDLED_PERMISSIONS.ALL | ATOMIC_PERMISSIONS.SHARE,
34+
ALL_FILE: BUNDLED_PERMISSIONS.ALL_FILE | ATOMIC_PERMISSIONS.SHARE,
35+
}
36+
}
37+
return BUNDLED_PERMISSIONS
38+
}
39+
2340
/**
2441
* Return whether a given permissions set contains some permissions.
2542
*

apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ATOMIC_PERMISSIONS,
99
BUNDLED_PERMISSIONS,
1010
canTogglePermissions,
11+
getBundledPermissions,
1112
hasPermissions,
1213
permissionsSetIsValid,
1314
subtractPermissions,
@@ -76,4 +77,75 @@ describe('SharePermissionsToolBox', () => {
7677
expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.CREATE)).toBe(true)
7778
expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE, ATOMIC_PERMISSIONS.CREATE)).toBe(true)
7879
})
80+
81+
test('Get bundled permissions without SHARE (default)', () => {
82+
const permissions = getBundledPermissions(false)
83+
expect(permissions.READ_ONLY).toBe(BUNDLED_PERMISSIONS.READ_ONLY)
84+
expect(permissions.FILE_DROP).toBe(BUNDLED_PERMISSIONS.FILE_DROP)
85+
expect(permissions.UPLOAD_AND_UPDATE).toBe(BUNDLED_PERMISSIONS.UPLOAD_AND_UPDATE)
86+
expect(permissions.ALL).toBe(BUNDLED_PERMISSIONS.ALL)
87+
expect(permissions.ALL_FILE).toBe(BUNDLED_PERMISSIONS.ALL_FILE)
88+
expect(permissions.ALL).toBe(15)
89+
expect(permissions.ALL_FILE).toBe(3)
90+
expect(hasPermissions(permissions.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(false)
91+
expect(hasPermissions(permissions.ALL_FILE, ATOMIC_PERMISSIONS.SHARE)).toBe(false)
92+
})
93+
94+
test('Get bundled permissions with SHARE included', () => {
95+
const permissions = getBundledPermissions(true)
96+
expect(permissions.READ_ONLY).toBe(BUNDLED_PERMISSIONS.READ_ONLY)
97+
expect(permissions.FILE_DROP).toBe(BUNDLED_PERMISSIONS.FILE_DROP)
98+
expect(permissions.UPLOAD_AND_UPDATE).toBe(BUNDLED_PERMISSIONS.UPLOAD_AND_UPDATE)
99+
expect(permissions.ALL).toBe(BUNDLED_PERMISSIONS.ALL | ATOMIC_PERMISSIONS.SHARE)
100+
expect(permissions.ALL_FILE).toBe(BUNDLED_PERMISSIONS.ALL_FILE | ATOMIC_PERMISSIONS.SHARE)
101+
expect(permissions.ALL).toBe(31)
102+
expect(permissions.ALL_FILE).toBe(19)
103+
expect(hasPermissions(permissions.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(true)
104+
expect(hasPermissions(permissions.ALL_FILE, ATOMIC_PERMISSIONS.SHARE)).toBe(true)
105+
})
106+
107+
test('Get bundled permissions default argument', () => {
108+
const permissions = getBundledPermissions()
109+
expect(permissions.ALL).toBe(BUNDLED_PERMISSIONS.ALL)
110+
expect(permissions.ALL_FILE).toBe(BUNDLED_PERMISSIONS.ALL_FILE)
111+
expect(hasPermissions(permissions.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(false)
112+
})
113+
114+
test('Operations with bundled permissions including SHARE', () => {
115+
const permissionsWithShare = getBundledPermissions(true)
116+
117+
// Adding permissions to ALL with SHARE should preserve SHARE
118+
expect(addPermissions(permissionsWithShare.ALL, ATOMIC_PERMISSIONS.READ)).toBe(permissionsWithShare.ALL)
119+
120+
// Subtracting READ from ALL with SHARE should leave UPDATE | CREATE | DELETE | SHARE
121+
expect(subtractPermissions(permissionsWithShare.ALL, ATOMIC_PERMISSIONS.READ))
122+
.toBe(ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE)
123+
124+
// Toggle UPLOAD_AND_UPDATE from ALL with SHARE should leave only SHARE
125+
expect(togglePermissions(permissionsWithShare.ALL, BUNDLED_PERMISSIONS.UPLOAD_AND_UPDATE))
126+
.toBe(ATOMIC_PERMISSIONS.SHARE)
127+
128+
// Toggle FILE_DROP from ALL with SHARE
129+
expect(togglePermissions(permissionsWithShare.ALL, BUNDLED_PERMISSIONS.FILE_DROP))
130+
.toBe(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE)
131+
132+
// Adding SHARE to base ALL should equal ALL with SHARE
133+
expect(addPermissions(BUNDLED_PERMISSIONS.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(permissionsWithShare.ALL)
134+
135+
// Subtracting SHARE from ALL with SHARE should equal base ALL
136+
expect(subtractPermissions(permissionsWithShare.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(BUNDLED_PERMISSIONS.ALL)
137+
})
138+
139+
test('Operations with bundled permissions for files including SHARE', () => {
140+
const permissionsWithShare = getBundledPermissions(true)
141+
142+
// ALL_FILE with SHARE should be READ | UPDATE | SHARE
143+
expect(permissionsWithShare.ALL_FILE).toBe(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.SHARE)
144+
145+
// Subtracting SHARE from ALL_FILE with SHARE should equal base ALL_FILE
146+
expect(subtractPermissions(permissionsWithShare.ALL_FILE, ATOMIC_PERMISSIONS.SHARE)).toBe(BUNDLED_PERMISSIONS.ALL_FILE)
147+
148+
// Adding SHARE to base ALL_FILE should equal ALL_FILE with SHARE
149+
expect(addPermissions(BUNDLED_PERMISSIONS.ALL_FILE, ATOMIC_PERMISSIONS.SHARE)).toBe(permissionsWithShare.ALL_FILE)
150+
})
79151
})

apps/files_sharing/src/mixins/SharesMixin.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { fetchNode } from '../../../files/src/services/WebdavClient.ts'
1313
import {
1414
ATOMIC_PERMISSIONS,
1515
BUNDLED_PERMISSIONS,
16+
getBundledPermissions,
1617
} from '../lib/SharePermissionsToolBox.js'
1718
import Share from '../models/Share.ts'
1819
import Config from '../services/ConfigService.ts'

apps/files_sharing/src/services/ConfigService.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type FileSharingCapabilities = {
5353
}
5454
}
5555
default_permissions: number
56+
include_share_in_edit: boolean
5657
federation: {
5758
outgoing: boolean
5859
incoming: boolean
@@ -103,6 +104,13 @@ export default class Config {
103104
return this._capabilities.files_sharing?.default_permissions
104105
}
105106

107+
/**
108+
* Should SHARE permission be included in "Allow editing" bundled permissions
109+
*/
110+
get includeShareInEdit(): boolean {
111+
return this._capabilities.files_sharing?.include_share_in_edit === true
112+
}
113+
106114
/**
107115
* Is public upload allowed on link shares ?
108116
* This covers File request and Full upload/edit option.

apps/files_sharing/src/views/SharingDetailsTab.vue

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ import SidebarTabExternalActionLegacy from '../components/SidebarTabExternal/Sid
332332
import {
333333
ATOMIC_PERMISSIONS,
334334
BUNDLED_PERMISSIONS,
335+
getBundledPermissions,
335336
hasPermissions,
336337
} from '../lib/SharePermissionsToolBox.js'
337338
import ShareRequests from '../mixins/ShareRequests.js'
@@ -395,7 +396,6 @@ export default {
395396
setCustomPermissions: false,
396397
passwordError: false,
397398
advancedSectionAccordionExpanded: false,
398-
bundledPermissions: BUNDLED_PERMISSIONS,
399399
isFirstComponentLoad: true,
400400
test: false,
401401
creating: false,
@@ -443,6 +443,10 @@ export default {
443443
}
444444
},
445445
446+
bundledPermissions() {
447+
return getBundledPermissions(this.config.includeShareInEdit)
448+
},
449+
446450
allPermissions() {
447451
return this.isFolder ? this.bundledPermissions.ALL.toString() : this.bundledPermissions.ALL_FILE.toString()
448452
},
@@ -1022,10 +1026,12 @@ export default {
10221026
if (this.isNewShare) {
10231027
const defaultPermissions = this.config.defaultPermissions
10241028
const permissionsWithoutShare = defaultPermissions & ~ATOMIC_PERMISSIONS.SHARE
1025-
if (permissionsWithoutShare === BUNDLED_PERMISSIONS.READ_ONLY
1026-
|| permissionsWithoutShare === BUNDLED_PERMISSIONS.ALL
1027-
|| permissionsWithoutShare === BUNDLED_PERMISSIONS.ALL_FILE) {
1028-
this.sharingPermission = permissionsWithoutShare.toString()
1029+
if (permissionsWithoutShare === BUNDLED_PERMISSIONS.READ_ONLY) {
1030+
this.sharingPermission = this.bundledPermissions.READ_ONLY.toString()
1031+
} else if (permissionsWithoutShare === BUNDLED_PERMISSIONS.ALL) {
1032+
this.sharingPermission = this.bundledPermissions.ALL.toString()
1033+
} else if (permissionsWithoutShare === BUNDLED_PERMISSIONS.ALL_FILE) {
1034+
this.sharingPermission = this.bundledPermissions.ALL_FILE.toString()
10291035
} else {
10301036
this.sharingPermission = 'custom'
10311037
this.share.permissions = defaultPermissions
@@ -1075,9 +1081,9 @@ export default {
10751081
this.share.permissions = sharePermissionsSet
10761082
}
10771083
1078-
if (!this.isFolder && this.share.permissions === BUNDLED_PERMISSIONS.ALL) {
1084+
if (!this.isFolder && this.share.permissions === this.bundledPermissions.ALL) {
10791085
// It's not possible to create an existing file.
1080-
this.share.permissions = BUNDLED_PERMISSIONS.ALL_FILE
1086+
this.share.permissions = this.bundledPermissions.ALL_FILE
10811087
}
10821088
if (!this.writeNoteToRecipientIsChecked) {
10831089
this.share.note = ''
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { User } from '@nextcloud/e2e-test-server/cypress'
7+
8+
import { openSharingPanel } from './FilesSharingUtils.ts'
9+
10+
describe('files_sharing: Share permissions bundle configuration', () => {
11+
let alice: User
12+
let bob: User
13+
14+
before(() => {
15+
cy.createRandomUser().then(($user) => {
16+
alice = $user
17+
})
18+
cy.createRandomUser().then(($user) => {
19+
bob = $user
20+
})
21+
})
22+
23+
beforeEach(() => {
24+
cy.runOccCommand('config:app:delete files_sharing shareapi_include_share_in_edit')
25+
})
26+
27+
after(() => {
28+
cy.runOccCommand('config:app:delete files_sharing shareapi_include_share_in_edit')
29+
})
30+
31+
/**
32+
* Helper to create a user share and select "Allow editing"
33+
*/
34+
function createUserShareWithEdit(itemName: string) {
35+
openSharingPanel(itemName)
36+
37+
cy.get('#app-sidebar-vue').within(() => {
38+
cy.intercept('GET', '**/apps/files_sharing/api/v1/sharees?*').as('shareeSearch')
39+
cy.findByRole('combobox', { name: /Search for internal recipients/i })
40+
.type(`{selectAll}${bob.userId}`)
41+
cy.wait('@shareeSearch')
42+
})
43+
44+
cy.get(`[user="${bob.userId}"]`).click()
45+
46+
// Select "Allow editing" permission bundle
47+
cy.get('[data-cy-files-sharing-share-permissions-bundle]').should('be.visible')
48+
cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]').click()
49+
50+
cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare')
51+
cy.findByRole('button', { name: 'Save share' }).click()
52+
53+
return cy.wait('@createShare')
54+
}
55+
56+
describe('Default behavior (SHARE not included in edit)', () => {
57+
it('Creates user share with "Allow editing" without SHARE permission for folders', () => {
58+
const folderName = 'test-folder-no-share'
59+
cy.mkdir(alice, `/${folderName}`)
60+
cy.login(alice)
61+
cy.visit('/apps/files')
62+
63+
createUserShareWithEdit(folderName).should(({ response }) => {
64+
// Verify permission value is 15 (ALL without SHARE: READ=1 + UPDATE=2 + CREATE=4 + DELETE=8)
65+
expect(response?.body?.ocs?.data?.permissions).to.equal(15)
66+
})
67+
})
68+
69+
it('Creates user share with "Allow editing" without SHARE permission for files', () => {
70+
const fileName = 'test-file-no-share.txt'
71+
cy.uploadContent(alice, new Blob(['content']), 'text/plain', `/${fileName}`)
72+
cy.login(alice)
73+
cy.visit('/apps/files')
74+
75+
createUserShareWithEdit(fileName).should(({ response }) => {
76+
// Verify permission value is 3 (ALL_FILE without SHARE: READ=1 + UPDATE=2)
77+
expect(response?.body?.ocs?.data?.permissions).to.equal(3)
78+
})
79+
})
80+
})
81+
82+
describe('With SHARE included in edit (config enabled)', () => {
83+
beforeEach(() => {
84+
cy.runOccCommand('config:app:set --value yes files_sharing shareapi_include_share_in_edit')
85+
})
86+
87+
it('Creates user share with "Allow editing" with SHARE permission for folders', () => {
88+
const folderName = 'test-folder-with-share'
89+
cy.mkdir(alice, `/${folderName}`)
90+
cy.login(alice)
91+
cy.visit('/apps/files')
92+
93+
createUserShareWithEdit(folderName).should(({ response }) => {
94+
// Verify permission value is 31 (ALL with SHARE: READ=1 + UPDATE=2 + CREATE=4 + DELETE=8 + SHARE=16)
95+
expect(response?.body?.ocs?.data?.permissions).to.equal(31)
96+
})
97+
})
98+
99+
it('Creates user share with "Allow editing" with SHARE permission for files', () => {
100+
const fileName = 'test-file-with-share.txt'
101+
cy.uploadContent(alice, new Blob(['content']), 'text/plain', `/${fileName}`)
102+
cy.login(alice)
103+
cy.visit('/apps/files')
104+
105+
createUserShareWithEdit(fileName).should(({ response }) => {
106+
// Verify permission value is 19 (ALL_FILE with SHARE: READ=1 + UPDATE=2 + SHARE=16)
107+
expect(response?.body?.ocs?.data?.permissions).to.equal(19)
108+
})
109+
})
110+
})
111+
})

0 commit comments

Comments
 (0)