From 065234ef0131267763561f1c9264a3b575a750ba Mon Sep 17 00:00:00 2001 From: Jannik Stehle <jannik.stehle@gmail.com> Date: Wed, 22 Nov 2023 14:33:47 +0100 Subject: [PATCH] feat: respect default link permissions --- .../enhancement-default-link-permissions | 6 ++ .../components/EmbedActions/EmbedActions.vue | 6 +- .../components/SideBar/Shares/FileLinks.vue | 9 ++- .../SideBar/Shares/Links/CreateQuickLink.vue | 40 ++---------- .../EmbedActions/EmbedActions.spec.ts | 10 ++- .../SideBar/Shares/FileLinks.spec.ts | 42 ++++++++----- .../composables/capability/useCapability.ts | 9 +-- packages/web-pkg/src/helpers/share/link.ts | 61 ++++++++++--------- .../tests/unit/helpers/share/link.spec.ts | 45 ++++++++++++-- 9 files changed, 135 insertions(+), 93 deletions(-) create mode 100644 changelog/unreleased/enhancement-default-link-permissions diff --git a/changelog/unreleased/enhancement-default-link-permissions b/changelog/unreleased/enhancement-default-link-permissions new file mode 100644 index 00000000000..5bc9670ebad --- /dev/null +++ b/changelog/unreleased/enhancement-default-link-permissions @@ -0,0 +1,6 @@ +Enhancement: Default link permission + +When creating a new link, Web now respects the default permissions coming from the server. + +https://github.com/owncloud/web/pull/10037 +https://github.com/owncloud/web/issues/9919 diff --git a/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue b/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue index 54613e111c1..b1921639137 100644 --- a/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue +++ b/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue @@ -28,6 +28,7 @@ import { computed } from 'vue' import { createQuicklink, + getDefaultLinkPermissions, showQuickLinkPasswordModal, useAbility, useClientService, @@ -37,6 +38,7 @@ import { } from '@ownclouders/web-pkg' import { Resource } from '@ownclouders/web-client' import { useGettext } from 'vue3-gettext' +import { SharePermissionBit } from '@ownclouders/web-client/src/helpers' export default { setup() { @@ -88,7 +90,9 @@ export default { store.getters.capabilities?.files_sharing?.public?.password?.enforced_for?.read_only === true - if (passwordEnforced) { + const permissions = getDefaultLinkPermissions({ ability, store }) + + if (passwordEnforced && permissions > SharePermissionBit.Internal) { showQuickLinkPasswordModal( { store, $gettext: language.$gettext, passwordPolicyService }, async (password) => { diff --git a/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue b/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue index 47d4903308d..dc17fcd2f45 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue @@ -122,7 +122,8 @@ import { useCapabilityFilesSharingPublicCanContribute, useCapabilityFilesSharingPublicAlias, useAbility, - usePasswordPolicyService + usePasswordPolicyService, + getDefaultLinkPermissions } from '@ownclouders/web-pkg' import { shareViaLinkHelp, shareViaIndirectLinkHelp } from '../../../helpers/contextualHelpers' import { @@ -219,6 +220,7 @@ export default defineComponent({ return { $store: store, + ability, space, resource, incomingParentShare: inject<Share>('incomingParentShare'), @@ -417,7 +419,10 @@ export default defineComponent({ this.checkLinkToCreate({ link: { name: this.$gettext('Link'), - permissions: this.canCreatePublicLinks ? 1 : 0, + permissions: getDefaultLinkPermissions({ + ability: this.ability, + store: this.$store + }).toString(), expiration: this.expirationDate.default, password: false } diff --git a/packages/web-app-files/src/components/SideBar/Shares/Links/CreateQuickLink.vue b/packages/web-app-files/src/components/SideBar/Shares/Links/CreateQuickLink.vue index d1d2a5ab7b2..133fc766b2e 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/Links/CreateQuickLink.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/Links/CreateQuickLink.vue @@ -26,22 +26,9 @@ </template> <script lang="ts"> -import { computed, defineComponent, inject, unref } from 'vue' -import { - useAbility, - useCapabilityFilesSharingPublicAlias, - useCapabilityFilesSharingPublicCanContribute, - useCapabilityFilesSharingPublicCanEdit, - useCapabilityFilesSharingQuickLinkDefaultRole, - useCapabilityFilesSharingResharing -} from '@ownclouders/web-pkg' -import { Resource } from '@ownclouders/web-client/src' +import { defineComponent } from 'vue' +import { useAbility, getDefaultLinkPermissions, useStore } from '@ownclouders/web-pkg' import { useGettext } from 'vue3-gettext' -import { - LinkShareRoles, - linkRoleInternalFolder, - linkRoleViewerFolder -} from '@ownclouders/web-client/src/helpers/share' export default defineComponent({ name: 'CreateQuickLink', @@ -54,32 +41,15 @@ export default defineComponent({ }, emits: ['createPublicLink'], setup(props, { emit }) { - const { can } = useAbility() + const store = useStore() + const ability = useAbility() const { $gettext } = useGettext() - const canCreatePublicLinks = computed(() => can('create-all', 'PublicLink')) - const resource = inject<Resource>('resource') - const allowResharing = useCapabilityFilesSharingResharing() - const canEdit = useCapabilityFilesSharingPublicCanEdit() - const canContribute = useCapabilityFilesSharingPublicCanContribute() - const alias = useCapabilityFilesSharingPublicAlias() - const capabilitiesRoleName = useCapabilityFilesSharingQuickLinkDefaultRole() const createQuickLink = () => { - const roleName = !unref(canCreatePublicLinks) - ? linkRoleInternalFolder.name - : unref(capabilitiesRoleName) || linkRoleViewerFolder.name const emitData = { link: { name: $gettext('Link'), - permissions: LinkShareRoles.getByName( - roleName, - unref(resource).isFolder, - unref(canEdit), - unref(canContribute), - unref(alias) - ) - .bitmask(unref(allowResharing)) - .toString(), + permissions: getDefaultLinkPermissions({ ability, store }).toString(), expiration: props.expirationDate.enforced ? props.expirationDate.default : null, quicklink: true, password: false diff --git a/packages/web-app-files/tests/unit/components/EmbedActions/EmbedActions.spec.ts b/packages/web-app-files/tests/unit/components/EmbedActions/EmbedActions.spec.ts index 68a6e1bf19d..e6fb202b862 100644 --- a/packages/web-app-files/tests/unit/components/EmbedActions/EmbedActions.spec.ts +++ b/packages/web-app-files/tests/unit/components/EmbedActions/EmbedActions.spec.ts @@ -5,13 +5,16 @@ import { shallowMount } from 'web-test-helpers' import EmbedActions from 'web-app-files/src/components/EmbedActions/EmbedActions.vue' +import { getDefaultLinkPermissions } from '@ownclouders/web-pkg' +import { SharePermissionBit } from '@ownclouders/web-client/src/helpers' jest.mock('@ownclouders/web-pkg', () => ({ ...jest.requireActual('@ownclouders/web-pkg'), createQuicklink: jest.fn().mockImplementation(({ resource, password }) => ({ url: (password ? password + '-' : '') + 'link-' + resource.id })), - showQuickLinkPasswordModal: jest.fn().mockImplementation((_options, cb) => cb('password')) + showQuickLinkPasswordModal: jest.fn().mockImplementation((_options, cb) => cb('password')), + getDefaultLinkPermissions: jest.fn() })) const selectors = Object.freeze({ @@ -192,6 +195,7 @@ describe('EmbedActions', () => { const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }], abilities: [{ action: 'create-all', subject: 'PublicLink' }], + defaultLinkPermissions: SharePermissionBit.Read, capabilities: jest.fn().mockReturnValue({ files_sharing: { public: { password: { enforced_for: { read_only: true } } } } }) @@ -245,13 +249,15 @@ function getWrapper( abilities = [], capabilities = jest.fn().mockReturnValue({}), configuration = { options: {} }, - currentFolder = {} + currentFolder = {}, + defaultLinkPermissions = SharePermissionBit.Internal } = { selectedFiles: [], abilities: [], capabilities: jest.fn().mockReturnValue({}) } ) { + jest.mocked(getDefaultLinkPermissions).mockReturnValue(defaultLinkPermissions) const storeOptions = { ...defaultStoreMockOptions, getters: { diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/FileLinks.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Shares/FileLinks.spec.ts index ae8156af497..0f3eb48a5e2 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/FileLinks.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/FileLinks.spec.ts @@ -7,8 +7,9 @@ import { } from 'web-test-helpers' import { mockDeep } from 'jest-mock-extended' import { Resource } from '@ownclouders/web-client' -import { SharePermissions } from '@ownclouders/web-client/src/helpers/share' +import { SharePermissionBit, SharePermissions } from '@ownclouders/web-client/src/helpers/share' import { AbilityRule } from '@ownclouders/web-client/src/helpers/resource/types' +import { getDefaultLinkPermissions } from '@ownclouders/web-pkg' const defaultLinksList = [ { @@ -38,6 +39,11 @@ const selectors = { const linkListItemNameAndCopy = 'name-and-copy-stub' const linkListItemDetailsAndEdit = 'details-and-edit-stub' +jest.mock('@ownclouders/web-pkg', () => ({ + ...jest.requireActual('@ownclouders/web-pkg'), + getDefaultLinkPermissions: jest.fn() +})) + describe('FileLinks', () => { describe('links', () => { describe('when links list is not empty', () => { @@ -143,31 +149,39 @@ describe('FileLinks', () => { expect(availableRoleOptions[0].permissions()).toEqual([SharePermissions.internal]) expect(isModifiable).toBeTruthy() }) - it('creates new links with permission 0', async () => { - const { wrapper, storeOptions } = getWrapper({ abilities: [] }) - await wrapper.find(selectors.linkAddButton).trigger('click') - expect(storeOptions.modules.Files.actions.addLink).toHaveBeenCalledTimes(1) - expect(storeOptions.modules.Files.actions.addLink).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - params: expect.objectContaining({ - permissions: '0' + }) + describe('new links', () => { + it.each([SharePermissionBit.Internal, SharePermissionBit.Read])( + 'creates new links according to the default link permissions', + async (defaultLinkPermissions) => { + const { wrapper, storeOptions } = getWrapper({ abilities: [], defaultLinkPermissions }) + await wrapper.find(selectors.linkAddButton).trigger('click') + expect(storeOptions.modules.Files.actions.addLink).toHaveBeenCalledTimes(1) + expect(storeOptions.modules.Files.actions.addLink).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + permissions: defaultLinkPermissions.toString() + }) }) - }) - ) - }) + ) + } + ) }) }) function getWrapper({ resource = mockDeep<Resource>({ isFolder: false, canShare: () => true }), links = defaultLinksList, - abilities = [{ action: 'create-all', subject: 'PublicLink' }] + abilities = [{ action: 'create-all', subject: 'PublicLink' }], + defaultLinkPermissions = 0 }: { resource?: Resource links?: typeof defaultLinksList abilities?: AbilityRule[] + defaultLinkPermissions?: number } = {}) { + jest.mocked(getDefaultLinkPermissions).mockReturnValue(defaultLinkPermissions) const storeOptions = { ...defaultStoreMockOptions, getters: { diff --git a/packages/web-pkg/src/composables/capability/useCapability.ts b/packages/web-pkg/src/composables/capability/useCapability.ts index 332e3ab4df1..b82aeba37c3 100644 --- a/packages/web-pkg/src/composables/capability/useCapability.ts +++ b/packages/web-pkg/src/composables/capability/useCapability.ts @@ -8,6 +8,7 @@ import { MediaTypeCapability, PasswordPolicyCapability } from '@ownclouders/web-client/src/ocs/capabilities' +import { SharePermissionBit } from '@ownclouders/web-client/src/helpers' export const useCapability = <T>( store: Store<any>, @@ -39,10 +40,6 @@ export const useCapabilityGraphPersonalDataExport = createCapabilityComposable( 'graph.personal-data-export', false ) -export const useCapabilityFilesSharingQuickLinkDefaultRole = createCapabilityComposable( - 'files_sharing.quick_link.default_role', - 'viewer' -) export const useCapabilityFilesSharingResharing = createCapabilityComposable( 'files_sharing.resharing', true @@ -136,6 +133,10 @@ export const useCapabilityFilesSharingPublicAlias = createCapabilityComposable( 'files_sharing.public.alias', false ) +export const useCapabilityFilesSharingPublicDefaultPermissions = createCapabilityComposable( + 'files_sharing.public.default_permissions', + SharePermissionBit.Read +) export const useCapabilityNotifications = createCapabilityComposable( 'notifications.ocs-endpoints', [] diff --git a/packages/web-pkg/src/helpers/share/link.ts b/packages/web-pkg/src/helpers/share/link.ts index 88050dc7778..7d8b01ccae1 100644 --- a/packages/web-pkg/src/helpers/share/link.ts +++ b/packages/web-pkg/src/helpers/share/link.ts @@ -1,11 +1,9 @@ import { DateTime } from 'luxon' import { - LinkShareRoles, Share, - linkRoleInternalFolder, - linkRoleViewerFolder, ShareTypes, - buildShare + buildShare, + SharePermissionBit } from '@ownclouders/web-client/src/helpers/share' import { Store } from 'vuex' import { ClientService, PasswordPolicyService } from '../../services' @@ -39,7 +37,7 @@ export interface CopyQuickLink extends CreateQuicklink { // it has a fallback to the vue-use implementation. // // https://webkit.org/blog/10855/ -const copyToClipboard = async (quickLinkUrl: string) => { +const copyToClipboard = (quickLinkUrl: string) => { if (typeof ClipboardItem && navigator?.clipboard?.write) { return navigator.clipboard.write([ new ClipboardItem({ @@ -52,7 +50,7 @@ const copyToClipboard = async (quickLinkUrl: string) => { } } export const copyQuicklink = async (args: CopyQuickLink) => { - const { store, language, resource, clientService, passwordPolicyService } = args + const { ability, store, language, resource, clientService, passwordPolicyService } = args const { $gettext } = language const linkSharesForResource = await clientService.owncloudSdk.shares.getShares(resource.path, { @@ -82,7 +80,9 @@ export const copyQuicklink = async (args: CopyQuickLink) => { const isPasswordEnforced = store.getters.capabilities?.files_sharing?.public?.password?.enforced_for?.read_only === true - if (unref(isPasswordEnforced)) { + const permissions = getDefaultLinkPermissions({ ability, store }) + + if (unref(isPasswordEnforced) && permissions > SharePermissionBit.Internal) { return showQuickLinkPasswordModal( { $gettext, store, passwordPolicyService }, async (password: string) => { @@ -125,33 +125,13 @@ export const copyQuicklink = async (args: CopyQuickLink) => { } } -export const createQuicklink = async (args: CreateQuicklink): Promise<Share> => { +export const createQuicklink = (args: CreateQuicklink): Promise<Share> => { const { clientService, resource, store, password, language, ability } = args const { $gettext } = language - const canCreatePublicLink = ability.can('create-all', 'PublicLink') - const allowResharing = store.state.user.capabilities.files_sharing?.resharing - const capabilitiesRoleName = - store.state.user.capabilities.files_sharing?.quick_link?.default_role || - linkRoleViewerFolder.name - const canEdit = store.state.user.capabilities.files_sharing?.public?.can_edit || false - const canContribute = store.state.user.capabilities.files_sharing?.public?.can_contribute || false - const alias = store.state.user.capabilities.files_sharing?.public?.alias - const roleName = !canCreatePublicLink - ? linkRoleInternalFolder.name - : capabilitiesRoleName || linkRoleViewerFolder.name - const permissions = LinkShareRoles.getByName( - roleName, - resource.isFolder, - canEdit, - canContribute, - alias - ).bitmask(allowResharing) - const params: { - [key: string]: unknown - } = { + const params: Record<string, unknown> = { name: $gettext('Link'), - permissions: permissions.toString(), + permissions: getDefaultLinkPermissions({ ability, store }).toString(), quicklink: true } @@ -180,3 +160,24 @@ export const createQuicklink = async (args: CreateQuicklink): Promise<Share> => storageId: resource.fileId || resource.id }) } + +export const getDefaultLinkPermissions = ({ + ability, + store +}: { + ability: Ability + store: Store<any> +}) => { + const canCreatePublicLink = ability.can('create-all', 'PublicLink') + if (!canCreatePublicLink) { + return SharePermissionBit.Internal + } + + let defaultPermissions: number = + store.state.user.capabilities.files_sharing?.public?.default_permissions + if (defaultPermissions === undefined) { + defaultPermissions = SharePermissionBit.Read + } + + return defaultPermissions +} diff --git a/packages/web-pkg/tests/unit/helpers/share/link.spec.ts b/packages/web-pkg/tests/unit/helpers/share/link.spec.ts index 65fee84f516..68b790c6c27 100644 --- a/packages/web-pkg/tests/unit/helpers/share/link.spec.ts +++ b/packages/web-pkg/tests/unit/helpers/share/link.spec.ts @@ -1,4 +1,9 @@ -import { copyQuicklink, createQuicklink, CreateQuicklink } from '../../../../src/helpers/share' +import { + copyQuicklink, + createQuicklink, + CreateQuicklink, + getDefaultLinkPermissions +} from '../../../../src/helpers/share' import { DateTime } from 'luxon' import { Store } from 'vuex' import { ClientService, PasswordPolicyService } from '../../../../src/services' @@ -7,6 +12,7 @@ import { Ability } from '@ownclouders/web-client/src/helpers/resource/types' import { mock, mockDeep } from 'jest-mock-extended' import { Language } from 'vue3-gettext' import { Resource } from '@ownclouders/web-client' +import { SharePermissionBit } from '@ownclouders/web-client/src/helpers' jest.mock('@vueuse/core', () => ({ useClipboard: jest.fn().mockReturnValue({ copy: jest.fn() }) @@ -17,6 +23,7 @@ const mockStore = { capabilities: { files_sharing: { public: { + default_permissions: 1, password: { enforced_for: { read_only: false @@ -30,10 +37,8 @@ const mockStore = { user: { capabilities: { files_sharing: { - quickLink: { - default_role: 'viewer' - }, public: { + default_permissions: 1, expire_date: { enforced: true, days: 5 @@ -60,6 +65,7 @@ const getAbilityMock = (hasPermission) => mock<Ability>({ can: () => hasPermissi let returnBitmask = 1 jest.mock('@ownclouders/web-client/src/helpers/share', () => ({ + ...jest.requireActual('@ownclouders/web-client/src/helpers/share'), LinkShareRoles: { getByName: jest.fn().mockReturnValue({ bitmask: jest.fn(() => returnBitmask) }) }, @@ -114,7 +120,7 @@ describe('createQuicklink', () => { clientService.owncloudSdk.shares.getShares.mockResolvedValue([]) const passwordPolicyService = mockDeep<PasswordPolicyService>() returnBitmask = role === 'viewer' ? 1 : 0 - mockStore.state.user.capabilities.files_sharing.quickLink.default_role = role + mockStore.state.user.capabilities.files_sharing.public.default_permissions = returnBitmask const args: CreateQuicklink = { store: mockStore as unknown as Store<any>, @@ -151,3 +157,32 @@ describe('createQuicklink', () => { } ) }) + +describe('getDefaultLinkPermissions', () => { + it('returns internal if user is not allowed to create public links', () => { + const permissions = getDefaultLinkPermissions({ + ability: mock<Ability>({ can: () => false }), + store: mockStore as any + }) + expect(permissions).toBe(SharePermissionBit.Internal) + }) + it.each([SharePermissionBit.Internal, SharePermissionBit.Read])( + 'returns the defined default permissions from the capabilities if user is allowed to create public links', + (defaultPermissions) => { + const store = { + state: { + user: { + capabilities: { + files_sharing: { public: { default_permissions: defaultPermissions } } + } + } + } + } + const permissions = getDefaultLinkPermissions({ + ability: mock<Ability>({ can: () => true }), + store: store as any + }) + expect(permissions).toBe(defaultPermissions) + } + ) +})