Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/files_sharing/lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public function __construct(
* },
* },
* default_permissions?: int,
* include_share_in_edit?: bool,
* federation: array{
* outgoing: bool,
* incoming: bool,
Expand Down Expand Up @@ -159,6 +160,7 @@ public function getCapabilities() {
$res['group']['enabled'] = $this->shareManager->allowGroupSharing();
$res['group']['expire_date']['enabled'] = true;
$res['default_permissions'] = (int)$this->config->getAppValue('core', 'shareapi_default_permissions', (string)Constants::PERMISSION_ALL);
$res['include_share_in_edit'] = $this->appConfig->getValueBool('core', ConfigLexicon::SHARE_INCLUDE_SHARE_IN_EDIT);
}

//Federated sharing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import IconTune from 'vue-material-design-icons/Tune.vue'
import {
ATOMIC_PERMISSIONS,
BUNDLED_PERMISSIONS,
getBundledPermissions,
} from '../lib/SharePermissionsToolBox.js'
import ShareDetails from '../mixins/ShareDetails.js'
import SharesMixin from '../mixins/SharesMixin.js'
Expand Down Expand Up @@ -93,6 +94,10 @@ export default {
return t('files_sharing', 'Custom permissions')
},

bundledPermissions() {
return getBundledPermissions(this.config.includeShareInEdit)
},

preSelectedOption() {
// We remove the share permission for the comparison as it is not relevant for bundled permissions.
const permissionsWithoutShare = this.share.permissions & ~ATOMIC_PERMISSIONS.SHARE
Expand Down Expand Up @@ -140,14 +145,14 @@ export default {
dropDownPermissionValue() {
switch (this.selectedOption) {
case this.canEditText:
return this.isFolder ? BUNDLED_PERMISSIONS.ALL : BUNDLED_PERMISSIONS.ALL_FILE
return this.isFolder ? this.bundledPermissions.ALL : this.bundledPermissions.ALL_FILE
case this.fileDropText:
return BUNDLED_PERMISSIONS.FILE_DROP
return this.bundledPermissions.FILE_DROP
case this.customPermissionsText:
return 'custom'
case this.canViewText:
default:
return BUNDLED_PERMISSIONS.READ_ONLY
return this.bundledPermissions.READ_ONLY
}
},
},
Expand Down
17 changes: 17 additions & 0 deletions apps/files_sharing/src/lib/SharePermissionsToolBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ export const BUNDLED_PERMISSIONS = {
ALL_FILE: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.READ,
}

/**
* Get bundled permissions with optional SHARE permission for editing bundles.
*
* @param {boolean} includeShareInEdit - Whether to include SHARE permission in ALL and ALL_FILE bundles.
* @return {object}
*/
export function getBundledPermissions(includeShareInEdit = false) {
if (includeShareInEdit) {
return {
...BUNDLED_PERMISSIONS,
ALL: BUNDLED_PERMISSIONS.ALL | ATOMIC_PERMISSIONS.SHARE,
ALL_FILE: BUNDLED_PERMISSIONS.ALL_FILE | ATOMIC_PERMISSIONS.SHARE,
}
}
return BUNDLED_PERMISSIONS
}

/**
* Return whether a given permissions set contains some permissions.
*
Expand Down
72 changes: 72 additions & 0 deletions apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ATOMIC_PERMISSIONS,
BUNDLED_PERMISSIONS,
canTogglePermissions,
getBundledPermissions,
hasPermissions,
permissionsSetIsValid,
subtractPermissions,
Expand Down Expand Up @@ -76,4 +77,75 @@ describe('SharePermissionsToolBox', () => {
expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.CREATE)).toBe(true)
expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE, ATOMIC_PERMISSIONS.CREATE)).toBe(true)
})

test('Get bundled permissions without SHARE (default)', () => {
const permissions = getBundledPermissions(false)
expect(permissions.READ_ONLY).toBe(BUNDLED_PERMISSIONS.READ_ONLY)
expect(permissions.FILE_DROP).toBe(BUNDLED_PERMISSIONS.FILE_DROP)
expect(permissions.UPLOAD_AND_UPDATE).toBe(BUNDLED_PERMISSIONS.UPLOAD_AND_UPDATE)
expect(permissions.ALL).toBe(BUNDLED_PERMISSIONS.ALL)
expect(permissions.ALL_FILE).toBe(BUNDLED_PERMISSIONS.ALL_FILE)
expect(permissions.ALL).toBe(15)
expect(permissions.ALL_FILE).toBe(3)
expect(hasPermissions(permissions.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(false)
expect(hasPermissions(permissions.ALL_FILE, ATOMIC_PERMISSIONS.SHARE)).toBe(false)
})

test('Get bundled permissions with SHARE included', () => {
const permissions = getBundledPermissions(true)
expect(permissions.READ_ONLY).toBe(BUNDLED_PERMISSIONS.READ_ONLY)
expect(permissions.FILE_DROP).toBe(BUNDLED_PERMISSIONS.FILE_DROP)
expect(permissions.UPLOAD_AND_UPDATE).toBe(BUNDLED_PERMISSIONS.UPLOAD_AND_UPDATE)
expect(permissions.ALL).toBe(BUNDLED_PERMISSIONS.ALL | ATOMIC_PERMISSIONS.SHARE)
expect(permissions.ALL_FILE).toBe(BUNDLED_PERMISSIONS.ALL_FILE | ATOMIC_PERMISSIONS.SHARE)
expect(permissions.ALL).toBe(31)
expect(permissions.ALL_FILE).toBe(19)
expect(hasPermissions(permissions.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(true)
expect(hasPermissions(permissions.ALL_FILE, ATOMIC_PERMISSIONS.SHARE)).toBe(true)
})

test('Get bundled permissions default argument', () => {
const permissions = getBundledPermissions()
expect(permissions.ALL).toBe(BUNDLED_PERMISSIONS.ALL)
expect(permissions.ALL_FILE).toBe(BUNDLED_PERMISSIONS.ALL_FILE)
expect(hasPermissions(permissions.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(false)
})

test('Operations with bundled permissions including SHARE', () => {
const permissionsWithShare = getBundledPermissions(true)

// Adding permissions to ALL with SHARE should preserve SHARE
expect(addPermissions(permissionsWithShare.ALL, ATOMIC_PERMISSIONS.READ)).toBe(permissionsWithShare.ALL)

// Subtracting READ from ALL with SHARE should leave UPDATE | CREATE | DELETE | SHARE
expect(subtractPermissions(permissionsWithShare.ALL, ATOMIC_PERMISSIONS.READ))
.toBe(ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE)

// Toggle UPLOAD_AND_UPDATE from ALL with SHARE should leave only SHARE
expect(togglePermissions(permissionsWithShare.ALL, BUNDLED_PERMISSIONS.UPLOAD_AND_UPDATE))
.toBe(ATOMIC_PERMISSIONS.SHARE)

// Toggle FILE_DROP from ALL with SHARE
expect(togglePermissions(permissionsWithShare.ALL, BUNDLED_PERMISSIONS.FILE_DROP))
.toBe(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE)

// Adding SHARE to base ALL should equal ALL with SHARE
expect(addPermissions(BUNDLED_PERMISSIONS.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(permissionsWithShare.ALL)

// Subtracting SHARE from ALL with SHARE should equal base ALL
expect(subtractPermissions(permissionsWithShare.ALL, ATOMIC_PERMISSIONS.SHARE)).toBe(BUNDLED_PERMISSIONS.ALL)
})

test('Operations with bundled permissions for files including SHARE', () => {
const permissionsWithShare = getBundledPermissions(true)

// ALL_FILE with SHARE should be READ | UPDATE | SHARE
expect(permissionsWithShare.ALL_FILE).toBe(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.SHARE)

// Subtracting SHARE from ALL_FILE with SHARE should equal base ALL_FILE
expect(subtractPermissions(permissionsWithShare.ALL_FILE, ATOMIC_PERMISSIONS.SHARE)).toBe(BUNDLED_PERMISSIONS.ALL_FILE)

// Adding SHARE to base ALL_FILE should equal ALL_FILE with SHARE
expect(addPermissions(BUNDLED_PERMISSIONS.ALL_FILE, ATOMIC_PERMISSIONS.SHARE)).toBe(permissionsWithShare.ALL_FILE)
})
})
1 change: 1 addition & 0 deletions apps/files_sharing/src/mixins/SharesMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { fetchNode } from '../../../files/src/services/WebdavClient.ts'
import {
ATOMIC_PERMISSIONS,
BUNDLED_PERMISSIONS,
getBundledPermissions,
} from '../lib/SharePermissionsToolBox.js'
import Share from '../models/Share.ts'
import Config from '../services/ConfigService.ts'
Expand Down
8 changes: 8 additions & 0 deletions apps/files_sharing/src/services/ConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type FileSharingCapabilities = {
}
}
default_permissions: number
include_share_in_edit: boolean
federation: {
outgoing: boolean
incoming: boolean
Expand Down Expand Up @@ -103,6 +104,13 @@ export default class Config {
return this._capabilities.files_sharing?.default_permissions
}

/**
* Should SHARE permission be included in "Allow editing" bundled permissions
*/
get includeShareInEdit(): boolean {
return this._capabilities.files_sharing?.include_share_in_edit === true
}

/**
* Is public upload allowed on link shares ?
* This covers File request and Full upload/edit option.
Expand Down
20 changes: 13 additions & 7 deletions apps/files_sharing/src/views/SharingDetailsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ import SidebarTabExternalActionLegacy from '../components/SidebarTabExternal/Sid
import {
ATOMIC_PERMISSIONS,
BUNDLED_PERMISSIONS,
getBundledPermissions,
hasPermissions,
} from '../lib/SharePermissionsToolBox.js'
import ShareRequests from '../mixins/ShareRequests.js'
Expand Down Expand Up @@ -395,7 +396,6 @@ export default {
setCustomPermissions: false,
passwordError: false,
advancedSectionAccordionExpanded: false,
bundledPermissions: BUNDLED_PERMISSIONS,
isFirstComponentLoad: true,
test: false,
creating: false,
Expand Down Expand Up @@ -443,6 +443,10 @@ export default {
}
},

bundledPermissions() {
return getBundledPermissions(this.config.includeShareInEdit)
},

allPermissions() {
return this.isFolder ? this.bundledPermissions.ALL.toString() : this.bundledPermissions.ALL_FILE.toString()
},
Expand Down Expand Up @@ -1022,10 +1026,12 @@ export default {
if (this.isNewShare) {
const defaultPermissions = this.config.defaultPermissions
const permissionsWithoutShare = defaultPermissions & ~ATOMIC_PERMISSIONS.SHARE
if (permissionsWithoutShare === BUNDLED_PERMISSIONS.READ_ONLY
|| permissionsWithoutShare === BUNDLED_PERMISSIONS.ALL
|| permissionsWithoutShare === BUNDLED_PERMISSIONS.ALL_FILE) {
this.sharingPermission = permissionsWithoutShare.toString()
if (permissionsWithoutShare === BUNDLED_PERMISSIONS.READ_ONLY) {
this.sharingPermission = this.bundledPermissions.READ_ONLY.toString()
} else if (permissionsWithoutShare === BUNDLED_PERMISSIONS.ALL) {
this.sharingPermission = this.bundledPermissions.ALL.toString()
} else if (permissionsWithoutShare === BUNDLED_PERMISSIONS.ALL_FILE) {
this.sharingPermission = this.bundledPermissions.ALL_FILE.toString()
} else {
this.sharingPermission = 'custom'
this.share.permissions = defaultPermissions
Expand Down Expand Up @@ -1075,9 +1081,9 @@ export default {
this.share.permissions = sharePermissionsSet
}

if (!this.isFolder && this.share.permissions === BUNDLED_PERMISSIONS.ALL) {
if (!this.isFolder && this.share.permissions === this.bundledPermissions.ALL) {
// It's not possible to create an existing file.
this.share.permissions = BUNDLED_PERMISSIONS.ALL_FILE
this.share.permissions = this.bundledPermissions.ALL_FILE
}
if (!this.writeNoteToRecipientIsChecked) {
this.share.note = ''
Expand Down
2 changes: 2 additions & 0 deletions core/AppInfo/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class ConfigLexicon implements ILexicon {
public const SHARE_LINK_PASSWORD_ENFORCED = 'shareapi_enforce_links_password';
public const SHARE_LINK_EXPIRE_DATE_DEFAULT = 'shareapi_default_expire_date';
public const SHARE_LINK_EXPIRE_DATE_ENFORCED = 'shareapi_enforce_expire_date';
public const SHARE_INCLUDE_SHARE_IN_EDIT = 'shareapi_include_share_in_edit';
public const USER_LANGUAGE = 'lang';
public const OCM_DISCOVERY_ENABLED = 'ocm_discovery_enabled';
public const OCM_INVITE_ACCEPT_DIALOG = 'ocm_invite_accept_dialog';
Expand Down Expand Up @@ -88,6 +89,7 @@ public function getAppConfigs(): array {
},
definition: 'Enforce expiration date for shares via link or mail'
),
new Entry(self::SHARE_INCLUDE_SHARE_IN_EDIT, ValueType::BOOL, false, 'Include reshare permission in "Allow editing" bundled permissions'),
new Entry(self::LASTCRON_TIMESTAMP, ValueType::INT, 0, 'timestamp of last cron execution'),
new Entry(self::OCM_DISCOVERY_ENABLED, ValueType::BOOL, true, 'enable/disable OCM'),
new Entry(self::OCM_INVITE_ACCEPT_DIALOG, ValueType::STRING, '', 'route to local invite accept dialog', note: 'set as empty string to disable feature'),
Expand Down
111 changes: 111 additions & 0 deletions cypress/e2e/files_sharing/share-permissions-bundle.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { User } from '@nextcloud/e2e-test-server/cypress'

import { openSharingPanel } from './FilesSharingUtils.ts'

describe('files_sharing: Share permissions bundle configuration', () => {
let alice: User
let bob: User

before(() => {
cy.createRandomUser().then(($user) => {
alice = $user
})
cy.createRandomUser().then(($user) => {
bob = $user
})
})

beforeEach(() => {
cy.runOccCommand('config:app:delete core shareapi_include_share_in_edit')
})

after(() => {
cy.runOccCommand('config:app:delete core shareapi_include_share_in_edit')
})

/**
* Helper to create a user share and select "Allow editing"
*/
function createUserShareWithEdit(itemName: string) {
openSharingPanel(itemName)

cy.get('#app-sidebar-vue').within(() => {
cy.intercept('GET', '**/apps/files_sharing/api/v1/sharees?*').as('shareeSearch')
cy.findByRole('combobox', { name: /Search for internal recipients/i })
.type(`{selectAll}${bob.userId}`)
cy.wait('@shareeSearch')
})

cy.get(`[user="${bob.userId}"]`).click()

// Select "Allow editing" permission bundle
cy.get('[data-cy-files-sharing-share-permissions-bundle]').should('be.visible')
cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]').click()

cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare')
cy.findByRole('button', { name: 'Save share' }).click()

return cy.wait('@createShare')
}

describe('Default behavior (SHARE not included in edit)', () => {
it('Creates user share with "Allow editing" without SHARE permission for folders', () => {
const folderName = 'test-folder-no-share'
cy.mkdir(alice, `/${folderName}`)
cy.login(alice)
cy.visit('/apps/files')

createUserShareWithEdit(folderName).should(({ response }) => {
// Verify permission value is 15 (ALL without SHARE: READ=1 + UPDATE=2 + CREATE=4 + DELETE=8)
expect(response?.body?.ocs?.data?.permissions).to.equal(15)
})
})

it('Creates user share with "Allow editing" without SHARE permission for files', () => {
const fileName = 'test-file-no-share.txt'
cy.uploadContent(alice, new Blob(['content']), 'text/plain', `/${fileName}`)
cy.login(alice)
cy.visit('/apps/files')

createUserShareWithEdit(fileName).should(({ response }) => {
// Verify permission value is 3 (ALL_FILE without SHARE: READ=1 + UPDATE=2)
expect(response?.body?.ocs?.data?.permissions).to.equal(3)
})
})
})

describe('With SHARE included in edit (config enabled)', () => {
beforeEach(() => {
cy.runOccCommand('config:app:set --value yes core shareapi_include_share_in_edit')
})

it('Creates user share with "Allow editing" with SHARE permission for folders', () => {
const folderName = 'test-folder-with-share'
cy.mkdir(alice, `/${folderName}`)
cy.login(alice)
cy.visit('/apps/files')

createUserShareWithEdit(folderName).should(({ response }) => {
// Verify permission value is 31 (ALL with SHARE: READ=1 + UPDATE=2 + CREATE=4 + DELETE=8 + SHARE=16)
expect(response?.body?.ocs?.data?.permissions).to.equal(31)
})
})

it('Creates user share with "Allow editing" with SHARE permission for files', () => {
const fileName = 'test-file-with-share.txt'
cy.uploadContent(alice, new Blob(['content']), 'text/plain', `/${fileName}`)
cy.login(alice)
cy.visit('/apps/files')

createUserShareWithEdit(fileName).should(({ response }) => {
// Verify permission value is 19 (ALL_FILE with SHARE: READ=1 + UPDATE=2 + SHARE=16)
expect(response?.body?.ocs?.data?.permissions).to.equal(19)
})
})
})
})
Loading