From 1eac091767a1653b1a80cb32125967cc49936e9e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 15 Sep 2026 22:44:46 +0200 Subject: [PATCH] fix(files_sharing): keep matching bundles for shares carrying the federation SHARE permission Link and email shares get the SHARE permission added by the server whenever `shareapi_allow_federation_on_public_shares` is enabled, which is the default. Comparing their raw permissions against the bundles therefore classified every public share as "Custom permissions" instead of the bundle it was created with. Match against the bundles through a helper that ignores that permission for public shares, while keeping the `exclude_reshare_from_edit` aware comparison for internal shares. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Ferdinand Thiessen --- .../SharingEntryQuickShareSelect.vue | 20 ++++---- .../src/lib/SharePermissionsToolBox.js | 30 ++++++++++++ .../src/lib/SharePermissionsToolBox.spec.js | 48 +++++++++++++++++++ apps/files_sharing/src/mixins/SharesMixin.js | 17 ++++--- 4 files changed, 96 insertions(+), 19 deletions(-) diff --git a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue index f9173d84c1850..07a422d47dc6e 100644 --- a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue +++ b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue @@ -95,17 +95,17 @@ export default { }, preSelectedOption() { - const permissions = this.share.permissions - const basePermissions = this.bundledPermissions - if (permissions === basePermissions.READ_ONLY) { - return this.canViewText - } else if (permissions === basePermissions.ALL || permissions === basePermissions.ALL_FILE) { - return this.canEditText - } else if (permissions === basePermissions.FILE_DROP) { - return this.fileDropText + switch (this.permissionsBundle) { + case 'READ_ONLY': + return this.canViewText + case 'ALL': + case 'ALL_FILE': + return this.canEditText + case 'FILE_DROP': + return this.fileDropText + default: + return this.customPermissionsText } - - return this.customPermissionsText }, options() { diff --git a/apps/files_sharing/src/lib/SharePermissionsToolBox.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.js index 3638d94f5f607..6462874637fd7 100644 --- a/apps/files_sharing/src/lib/SharePermissionsToolBox.js +++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.js @@ -122,3 +122,33 @@ export function togglePermissions(initialPermissionSet, permissionsToToggle) { export function canTogglePermissions(permissionSet, permissionsToToggle) { return permissionsSetIsValid(togglePermissions(permissionSet, permissionsToToggle)) } + +/** + * The permission bundles the share editor offers, in the order they are matched. + * + * @type {string[]} + */ +const EDITOR_BUNDLES = ['READ_ONLY', 'ALL', 'ALL_FILE', 'FILE_DROP'] + +/** + * Find the permission bundle a share's permissions correspond to. + * + * Link and email shares carry the SHARE permission whenever federation on + * public shares is enabled: the server adds it on top of whatever bundle was + * picked, so it must be ignored when matching those shares against a bundle. + * + * @param {number} permissions - the share permissions. + * @param {object} [options] - matching options. + * @param {boolean} [options.isPublicShare] - whether the share is a link or email share. + * @param {boolean} [options.excludeReshareFromEdit] - whether SHARE is excluded from the editing bundles. + * + * @return {string|null} the name of the matching bundle, or `null` for custom permissions. + */ +export function matchBundledPermissions(permissions, { isPublicShare = false, excludeReshareFromEdit = false } = {}) { + const bundles = getBundledPermissions(isPublicShare || excludeReshareFromEdit) + const comparablePermissions = isPublicShare + ? subtractPermissions(permissions, ATOMIC_PERMISSIONS.SHARE) + : permissions + + return EDITOR_BUNDLES.find((bundle) => bundles[bundle] === comparablePermissions) ?? null +} diff --git a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js index 14ac7bfbbbb74..f8f04d29edd41 100644 --- a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js +++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js @@ -9,6 +9,7 @@ import { canTogglePermissions, getBundledPermissions, hasPermissions, + matchBundledPermissions, permissionsSetIsValid, subtractPermissions, togglePermissions, @@ -144,4 +145,51 @@ describe('SharePermissionsToolBox', () => { // BUNDLED_PERMISSIONS.ALL_FILE already includes SHARE expect(BUNDLED_PERMISSIONS.ALL_FILE).toBe(permissionsWithShare.ALL_FILE) }) + + describe('Matching bundled permissions', () => { + const { READ, UPDATE, CREATE, DELETE, SHARE } = ATOMIC_PERMISSIONS + + test('matches the bundles of an internal share', () => { + expect(matchBundledPermissions(READ)).toBe('READ_ONLY') + expect(matchBundledPermissions(CREATE)).toBe('FILE_DROP') + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE | SHARE)).toBe('ALL') + expect(matchBundledPermissions(READ | UPDATE | SHARE)).toBe('ALL_FILE') + }) + + test('reports permissions outside of a bundle as custom', () => { + expect(matchBundledPermissions(READ | UPDATE)).toBe(null) + expect(matchBundledPermissions(READ | CREATE)).toBe(null) + expect(matchBundledPermissions(ATOMIC_PERMISSIONS.NONE)).toBe(null) + }) + + test('matches the editing bundle without SHARE when resharing is excluded from editing', () => { + const options = { excludeReshareFromEdit: true } + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE, options)).toBe('ALL') + expect(matchBundledPermissions(READ | UPDATE, options)).toBe('ALL_FILE') + // With resharing excluded, a share that grants it is no longer the editing bundle + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE | SHARE, options)).toBe(null) + }) + + test('ignores the SHARE permission the server adds to public shares', () => { + const options = { isPublicShare: true } + // Link and email shares carry SHARE for federation, whatever bundle was picked + expect(matchBundledPermissions(READ | SHARE, options)).toBe('READ_ONLY') + expect(matchBundledPermissions(CREATE | SHARE, options)).toBe('FILE_DROP') + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE | SHARE, options)).toBe('ALL') + expect(matchBundledPermissions(READ | UPDATE | SHARE, options)).toBe('ALL_FILE') + }) + + test('matches public shares the same way with resharing excluded from editing', () => { + const options = { isPublicShare: true, excludeReshareFromEdit: true } + expect(matchBundledPermissions(READ | SHARE, options)).toBe('READ_ONLY') + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE | SHARE, options)).toBe('ALL') + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE, options)).toBe('ALL') + }) + + test('still reports custom permissions on a public share', () => { + const options = { isPublicShare: true } + expect(matchBundledPermissions(READ | CREATE | SHARE, options)).toBe(null) + expect(matchBundledPermissions(READ | UPDATE | DELETE | SHARE, options)).toBe(null) + }) + }) }) diff --git a/apps/files_sharing/src/mixins/SharesMixin.js b/apps/files_sharing/src/mixins/SharesMixin.js index 9a84bee0206c4..bdf07d79160fe 100644 --- a/apps/files_sharing/src/mixins/SharesMixin.js +++ b/apps/files_sharing/src/mixins/SharesMixin.js @@ -10,7 +10,7 @@ import { ShareType } from '@nextcloud/sharing' import debounce from 'debounce' import PQueue from 'p-queue' import { fetchNode } from '../../../files/src/services/WebdavClient.ts' -import { getBundledPermissions } from '../lib/SharePermissionsToolBox.js' +import { matchBundledPermissions } from '../lib/SharePermissionsToolBox.js' import Share from '../models/Share.ts' import Config from '../services/ConfigService.ts' import logger from '../services/logger.ts' @@ -134,15 +134,14 @@ export default { } return this.config.isDefaultInternalExpireDateEnforced }, + permissionsBundle() { + return matchBundledPermissions(this.share.permissions, { + isPublicShare: this.isPublicShare, + excludeReshareFromEdit: this.config.excludeReshareFromEdit, + }) + }, hasCustomPermissions() { - const basePermissions = getBundledPermissions(this.config.excludeReshareFromEdit) - const bundledPermissions = [ - basePermissions.ALL, - basePermissions.ALL_FILE, - basePermissions.READ_ONLY, - basePermissions.FILE_DROP, - ] - return !bundledPermissions.includes(this.share.permissions) + return this.permissionsBundle === null }, maxExpirationDateEnforced() { if (this.isExpiryDateEnforced) {