diff --git a/packages/design-system/src/components/OcDrop/OcDrop.spec.ts b/packages/design-system/src/components/OcDrop/OcDrop.spec.ts index 0564e2614a9..36fb3b9ab79 100644 --- a/packages/design-system/src/components/OcDrop/OcDrop.spec.ts +++ b/packages/design-system/src/components/OcDrop/OcDrop.spec.ts @@ -188,4 +188,18 @@ describe('OcDrop', () => { expect(wrapper.find('oc-mobile-drop-stub').exists()).toBeTruthy() }) }) + + it('closes on escape when it was opened by pointer, so the focus never entered it', async () => { + const { wrapper } = dom() + document.querySelector('#trigger').click() + // no flushPromises: the drop is still positioning itself, escape has to + // close it even then + await nextTick() + expect(wrapper.find('.oc-drop').exists()).toBe(true) + + document.body.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape', bubbles: true })) + await nextTick() + + expect(wrapper.find('.oc-drop').exists()).toBe(false) + }) }) diff --git a/packages/design-system/src/components/OcDrop/OcDrop.vue b/packages/design-system/src/components/OcDrop/OcDrop.vue index 08c62a3aa1a..b5404e22ae7 100644 --- a/packages/design-system/src/components/OcDrop/OcDrop.vue +++ b/packages/design-system/src/components/OcDrop/OcDrop.vue @@ -273,6 +273,9 @@ const showDrop = async ({ const anchorEl: HTMLElement | VirtualElement | null = anchorElement || unref(anchor) activeAnchorElement = anchorEl isOpen.value = true + // registered before the drop is positioned: everything below waits for a + // frame, and a key pressed in between has to close the drop as well + registerEventListener(document, 'keydown', handleDocumentKeydown, 'document') await nextTick() if (!anchorEl) { console.warn('OcDrop cannot be opened: anchor element not found') @@ -282,6 +285,11 @@ const showDrop = async ({ // fixes a timing issue with the rendering of the drop await awaitAnimationFrame() + // escape can close the drop again while it is still positioning itself + if (!unref(isOpen) || !unref(drop)) { + return + } + if (isMenu) { // if drop is a menu, set role="menu" on all ul elements in the drop for better screen reader support const uls = unref(drop)?.getElementsByTagName('ul') @@ -317,6 +325,10 @@ const showDrop = async ({ ] }) + if (!unref(isOpen) || !unref(drop)) { + return + } + Object.assign(unref(drop).style, { left: `${x}px`, top: `${y}px` }) unref(anchor)?.setAttribute('aria-expanded', 'true') emit('showDrop') @@ -376,6 +388,18 @@ const handleDropClickOutside = async (event: Event) => { } } +// Escape has to close the drop even when the focus never entered it, which is +// the case whenever it was opened by pointer: a context menu on right click for +// instance. A key pressed inside the drop never reaches this handler, the drop's +// own one below stops it from travelling further. +const handleDocumentKeydown = (event: Event) => { + if (!isKeyboardEvent(event) || event.code !== 'Escape') { + return + } + hideDrop() + unref(anchor)?.focus() +} + const handleDropKeydown = (event: Event) => { if (!isKeyboardEvent(event)) { return diff --git a/packages/web-client/src/graph/driveItems/driveItems.ts b/packages/web-client/src/graph/driveItems/driveItems.ts index 45a4943d7a2..4ceedc5f794 100644 --- a/packages/web-client/src/graph/driveItems/driveItems.ts +++ b/packages/web-client/src/graph/driveItems/driveItems.ts @@ -1,7 +1,25 @@ -import { DriveItemApiFactory, DrivesRootApiFactory, MeDriveApiFactory } from './../generated' +import { + DriveItem, + DriveItemApiAxiosParamCreator, + DriveItemApiFactory, + DrivesRootApiFactory, + MeDriveApiFactory +} from './../generated' import type { GraphFactoryOptions } from './../types' import type { GraphDriveItems } from './types' +// placeholder for the item id in a colon-syntax url. it consists of unreserved +// characters only, so it survives encodeURIComponent and can be swapped for the +// path after the generated param creator has built the url. +const COLON_PATH_PLACEHOLDER = '__colon_path__' + +// the server recognizes a path lookup by a literal ':/' in the encoded url and +// expects every path segment to be percent-encoded, a ':' inside a name as +// '%3A'. encodeURIComponent does exactly that. See ResolveGraphPath in +// services/graph/pkg/middleware/path_lookup.go on the server side. +const colonPathRef = (path: string) => + `root:/${path.split('/').filter(Boolean).map(encodeURIComponent).join('/')}` + export const DriveItemsFactory = ({ axiosClient, config @@ -16,6 +34,7 @@ export const DriveItemsFactory = ({ driveId, itemId, undefined, + undefined, requestOptions ) return data @@ -63,6 +82,55 @@ export const DriveItemsFactory = ({ async listSharedWithMe(options, requestOptions) { const { data } = await meDriveApiFactory.listSharedWithMe(options?.expand, requestOptions) return data?.value || [] + }, + + // statDriveItem stats an item by id or by path. The path form cannot go + // through the generated operation: it percent-encodes the item id, which + // turns the ':/' the server matches on into '%3A%2F'. So the request is + // built by the generated param creator and only the item segment is + // rewritten afterwards, keeping the query and headers generated. + async statDriveItem(driveId, ref, options, requestOptions) { + if (ref.itemId) { + const { data } = await driveItemApiFactory.getDriveItemV1( + driveId, + ref.itemId, + options?.select, + options?.expand, + requestOptions + ) + return data + } + + const { url, options: axiosOptions } = await DriveItemApiAxiosParamCreator( + config + ).getDriveItemV1( + driveId, + COLON_PATH_PLACEHOLDER, + options?.select, + options?.expand, + requestOptions + ) + + const { data } = await axiosClient.request({ + ...axiosOptions, + // the path form is anchored at the drive root, so it replaces the + // whole '/items/{item-id}' segment rather than just the id + url: `${config.basePath}${url.replace( + `items/${COLON_PATH_PLACEHOLDER}`, + colonPathRef(ref.path) + )}` + }) + return data + }, + + async listDriveItemChildren(driveId, itemId, options, requestOptions) { + const { data } = await driveItemApiFactory.getDriveItemChildren( + driveId, + itemId, + options?.select, + requestOptions + ) + return data?.value || [] } } } diff --git a/packages/web-client/src/graph/driveItems/types.ts b/packages/web-client/src/graph/driveItems/types.ts index a4998c3d68c..002b96f38c4 100644 --- a/packages/web-client/src/graph/driveItems/types.ts +++ b/packages/web-client/src/graph/driveItems/types.ts @@ -1,12 +1,41 @@ -import { DriveItem } from '../generated' +import { + DriveItem, + GetDriveItemChildrenSelectEnum, + GetDriveItemV1ExpandEnum, + GetDriveItemV1SelectEnum +} from '../generated' import type { GraphRequestOptions } from '../types' +export interface DriveItemStatOptions { + select?: Set + expand?: Set +} + +export interface DriveItemChildrenOptions { + select?: Set +} + +// a driveItem is addressed either by its id or by its path, never by both +export type DriveItemRef = { itemId: string; path?: never } | { itemId?: never; path: string } + export interface GraphDriveItems { + listDriveItemChildren: ( + driveId: string, + itemId: string, + options?: DriveItemChildrenOptions, + requestOptions?: GraphRequestOptions + ) => Promise getDriveItem: ( driveId: string, itemId: string, requestOptions?: GraphRequestOptions ) => Promise + statDriveItem: ( + driveId: string, + ref: DriveItemRef, + options?: DriveItemStatOptions, + requestOptions?: GraphRequestOptions + ) => Promise createDriveItem: ( driveId: string, data: DriveItem, diff --git a/packages/web-client/src/graph/generated/.openapi-generator/FILES b/packages/web-client/src/graph/generated/.openapi-generator/FILES index 3f3e8315d79..5fd7c6cedff 100644 --- a/packages/web-client/src/graph/generated/.openapi-generator/FILES +++ b/packages/web-client/src/graph/generated/.openapi-generator/FILES @@ -75,6 +75,7 @@ docs/InvitationsApi.md docs/InvitedUserMessageInfo.md docs/ItemReference.md docs/LivePhoto.md +docs/LockInfo.md docs/MeChangepasswordApi.md docs/MeDriveApi.md docs/MeDriveRootApi.md diff --git a/packages/web-client/src/graph/generated/api.ts b/packages/web-client/src/graph/generated/api.ts index 098db666956..96bb7aab827 100644 --- a/packages/web-client/src/graph/generated/api.ts +++ b/packages/web-client/src/graph/generated/api.ts @@ -447,6 +447,7 @@ export interface DriveItem { 'video'?: Video; '@libre.graph.motionPhoto'?: MotionPhoto; '@libre.graph.livePhoto'?: LivePhoto; + 'lockInfo'?: LockInfo; /** * Indicates if the item is synchronized with the underlying storage provider. Read-only. */ @@ -471,7 +472,21 @@ export interface DriveItem { * A list of actions the caller is allowed to perform on this item. Only returned when explicitly requested via `$select` on endpoints that support it. Mirrors the annotation of the same name on the `/permissions` endpoint, allowing clients to learn a caller\'s effective actions on an item without a separate round-trip. */ '@libre.graph.permissions.actions.allowedValues'?: Array; + /** + * The types of shares existing on this item, aggregated over all of its grants. Absent or empty if the item is not shared. This is a summary of the item\'s `permissions` collection. For the full grants use the permissions endpoints, for the caller\'s own capabilities use `@libre.graph.permissions.actions.allowedValues`. Only returned when explicitly requested via `$select`. + */ + '@libre.graph.shareTypes'?: Array; } + +export const DriveItemAtLibreGraphShareTypesEnum = { + User: 'user', + Group: 'group', + Link: 'link', + Remote: 'remote', +} as const; + +export type DriveItemAtLibreGraphShareTypesEnum = typeof DriveItemAtLibreGraphShareTypesEnum[keyof typeof DriveItemAtLibreGraphShareTypesEnum]; + export interface DriveItemCreateLink { 'type'?: SharingLinkType; /** @@ -515,6 +530,10 @@ export interface DriveItemInvite { * Represents a person, group, or other recipient to share a drive item with using the invite action. When using invite to add permissions, the `driveRecipient` object would specify the `email`, `alias`, or `objectId` of the recipient. Only one of these values is required; multiple values are not accepted. */ export interface DriveRecipient { + /** + * The email address for the recipient, if the recipient has an associated email address. + */ + 'email'?: string; /** * The unique identifier for the recipient in the directory. */ @@ -977,6 +996,39 @@ export interface LivePhoto { */ 'vitalityScoringVersion'?: number; } +/** + * Read-only lock metadata for a file, matching the MS Graph beta lockInfo resource. Indicates whether the file is locked, the kind of lock, when it was created, when it expires and who holds it. + */ +export interface LockInfo { + /** + * The type of lock currently held on the file. OpenCloud currently only issues exclusive locks, same as MS Graph, even if it defines more. Read-only. + */ + 'lockType'?: LockInfoLockTypeEnum; + /** + * The date and time when the lock was created, in UTC. Read-only. + */ + 'createdDateTime'?: string; + /** + * The date and time when the lock expires, in UTC. Read-only. + */ + 'expirationDateTime'?: string; + /** + * The collection of users that currently hold the lock on the file. Read-only. + */ + 'owners'?: Array; + /** + * Name of the application holding the lock, for example an office application. Not part of MS Graph. Read-only. + */ + '@libre.graph.appName'?: string; +} + +export const LockInfoLockTypeEnum = { + None: 'none', + Exclusive: 'exclusive', +} as const; + +export type LockInfoLockTypeEnum = typeof LockInfoLockTypeEnum[keyof typeof LockInfoLockTypeEnum]; + export interface MemberReference { '@odata.id'?: string; } @@ -1978,10 +2030,11 @@ export const DriveItemApiAxiosParamCreator = function (configuration?: Configura * @param {string} driveId key: id of drive * @param {string} itemId key: id of item * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getDriveItem: async (driveId: string, itemId: string, $select?: Set, options: RawAxiosRequestConfig = {}): Promise => { + getDriveItem: async (driveId: string, itemId: string, $select?: Set, $expand?: Set, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'driveId' is not null or undefined assertParamExists('getDriveItem', 'driveId', driveId) // verify required parameter 'itemId' is not null or undefined @@ -2010,6 +2063,10 @@ export const DriveItemApiAxiosParamCreator = function (configuration?: Configura localVarQueryParameter['$select'] = Array.from($select).join(COLLECTION_FORMATS.csv); } + if ($expand) { + localVarQueryParameter['$expand'] = Array.from($expand).join(COLLECTION_FORMATS.csv); + } + localVarHeaderParameter['Accept'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -2120,10 +2177,11 @@ export const DriveItemApiAxiosParamCreator = function (configuration?: Configura * @param {string} driveId key: id of drive * @param {string} itemId key: id of item * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getDriveItemV1: async (driveId: string, itemId: string, $select?: Set, options: RawAxiosRequestConfig = {}): Promise => { + getDriveItemV1: async (driveId: string, itemId: string, $select?: Set, $expand?: Set, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'driveId' is not null or undefined assertParamExists('getDriveItemV1', 'driveId', driveId) // verify required parameter 'itemId' is not null or undefined @@ -2152,6 +2210,10 @@ export const DriveItemApiAxiosParamCreator = function (configuration?: Configura localVarQueryParameter['$select'] = Array.from($select).join(COLLECTION_FORMATS.csv); } + if ($expand) { + localVarQueryParameter['$expand'] = Array.from($expand).join(COLLECTION_FORMATS.csv); + } + localVarHeaderParameter['Accept'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -2258,11 +2320,12 @@ export const DriveItemApiFp = function(configuration?: Configuration) { * @param {string} driveId key: id of drive * @param {string} itemId key: id of item * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getDriveItem(driveId: string, itemId: string, $select?: Set, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getDriveItem(driveId, itemId, $select, options); + async getDriveItem(driveId: string, itemId: string, $select?: Set, $expand?: Set, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getDriveItem(driveId, itemId, $select, $expand, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['DriveItemApi.getDriveItem']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -2302,11 +2365,12 @@ export const DriveItemApiFp = function(configuration?: Configuration) { * @param {string} driveId key: id of drive * @param {string} itemId key: id of item * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getDriveItemV1(driveId: string, itemId: string, $select?: Set, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getDriveItemV1(driveId, itemId, $select, options); + async getDriveItemV1(driveId: string, itemId: string, $select?: Set, $expand?: Set, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getDriveItemV1(driveId, itemId, $select, $expand, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['DriveItemApi.getDriveItemV1']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -2366,11 +2430,12 @@ export const DriveItemApiFactory = function (configuration?: Configuration, base * @param {string} driveId key: id of drive * @param {string} itemId key: id of item * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getDriveItem(driveId: string, itemId: string, $select?: Set, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.getDriveItem(driveId, itemId, $select, options).then((request) => request(axios, basePath)); + getDriveItem(driveId: string, itemId: string, $select?: Set, $expand?: Set, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getDriveItem(driveId, itemId, $select, $expand, options).then((request) => request(axios, basePath)); }, /** * List the children of the item identified by `item-id` in the drive identified by `drive-id`. The item must exist and be a folder. Modeled on the MS Graph list driveItem children endpoint (https://learn.microsoft.com/en-us/graph/api/driveitem-list-children). This endpoint also accepts the MS Graph colon-syntax URL forms: GET /v1.0/drives/{drive-id}/root:/{path}:/children GET /v1.0/drives/{drive-id}/items/{item-id}:/{path}:/children OpenAPI cannot express the colon-delimited path segment, so these URL forms are not represented as separate operations in this specification. The server still accepts them, resolves `:/{path}:` as the parent item, and lists its children. @@ -2401,11 +2466,12 @@ export const DriveItemApiFactory = function (configuration?: Configuration, base * @param {string} driveId key: id of drive * @param {string} itemId key: id of item * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getDriveItemV1(driveId: string, itemId: string, $select?: Set, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.getDriveItemV1(driveId, itemId, $select, options).then((request) => request(axios, basePath)); + getDriveItemV1(driveId: string, itemId: string, $select?: Set, $expand?: Set, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getDriveItemV1(driveId, itemId, $select, $expand, options).then((request) => request(axios, basePath)); }, /** * Update a DriveItem. The request body must include a JSON object with the properties to update. Only the properties that are provided will be updated. Currently it supports updating the following properties: * `@UI.Hidden` - Hides the item from the UI. @@ -2459,11 +2525,12 @@ export class DriveItemApi extends BaseAPI { * @param {string} driveId key: id of drive * @param {string} itemId key: id of item * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public getDriveItem(driveId: string, itemId: string, $select?: Set, options?: RawAxiosRequestConfig) { - return DriveItemApiFp(this.configuration).getDriveItem(driveId, itemId, $select, options).then((request) => request(this.axios, this.basePath)); + public getDriveItem(driveId: string, itemId: string, $select?: Set, $expand?: Set, options?: RawAxiosRequestConfig) { + return DriveItemApiFp(this.configuration).getDriveItem(driveId, itemId, $select, $expand, options).then((request) => request(this.axios, this.basePath)); } /** @@ -2497,11 +2564,12 @@ export class DriveItemApi extends BaseAPI { * @param {string} driveId key: id of drive * @param {string} itemId key: id of item * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public getDriveItemV1(driveId: string, itemId: string, $select?: Set, options?: RawAxiosRequestConfig) { - return DriveItemApiFp(this.configuration).getDriveItemV1(driveId, itemId, $select, options).then((request) => request(this.axios, this.basePath)); + public getDriveItemV1(driveId: string, itemId: string, $select?: Set, $expand?: Set, options?: RawAxiosRequestConfig) { + return DriveItemApiFp(this.configuration).getDriveItemV1(driveId, itemId, $select, $expand, options).then((request) => request(this.axios, this.basePath)); } /** @@ -2531,18 +2599,31 @@ export type CreateChildDriveItemAtLibreGraphMissingParentsBehaviorEnum = typeof export const GetDriveItemSelectEnum = { MicrosoftGraphDownloadUrl: '@microsoft.graph.downloadUrl', LibreGraphPermissionsActionsAllowedValues: '@libre.graph.permissions.actions.allowedValues', + LibreGraphShareTypes: '@libre.graph.shareTypes', } as const; export type GetDriveItemSelectEnum = typeof GetDriveItemSelectEnum[keyof typeof GetDriveItemSelectEnum]; +export const GetDriveItemExpandEnum = { + Children: 'children', + Thumbnails: 'thumbnails', +} as const; +export type GetDriveItemExpandEnum = typeof GetDriveItemExpandEnum[keyof typeof GetDriveItemExpandEnum]; export const GetDriveItemChildrenSelectEnum = { MicrosoftGraphDownloadUrl: '@microsoft.graph.downloadUrl', LibreGraphPermissionsActionsAllowedValues: '@libre.graph.permissions.actions.allowedValues', + LibreGraphShareTypes: '@libre.graph.shareTypes', } as const; export type GetDriveItemChildrenSelectEnum = typeof GetDriveItemChildrenSelectEnum[keyof typeof GetDriveItemChildrenSelectEnum]; export const GetDriveItemV1SelectEnum = { MicrosoftGraphDownloadUrl: '@microsoft.graph.downloadUrl', LibreGraphPermissionsActionsAllowedValues: '@libre.graph.permissions.actions.allowedValues', + LibreGraphShareTypes: '@libre.graph.shareTypes', } as const; export type GetDriveItemV1SelectEnum = typeof GetDriveItemV1SelectEnum[keyof typeof GetDriveItemV1SelectEnum]; +export const GetDriveItemV1ExpandEnum = { + Children: 'children', + Thumbnails: 'thumbnails', +} as const; +export type GetDriveItemV1ExpandEnum = typeof GetDriveItemV1ExpandEnum[keyof typeof GetDriveItemV1ExpandEnum]; /** @@ -4009,10 +4090,11 @@ export const DrivesRootApiAxiosParamCreator = function (configuration?: Configur * @summary Get root from arbitrary space * @param {string} driveId key: id of drive * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getRoot: async (driveId: string, $select?: Set, options: RawAxiosRequestConfig = {}): Promise => { + getRoot: async (driveId: string, $select?: Set, $expand?: Set, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'driveId' is not null or undefined assertParamExists('getRoot', 'driveId', driveId) const localVarPath = `/v1.0/drives/{drive-id}/root` @@ -4038,6 +4120,10 @@ export const DrivesRootApiAxiosParamCreator = function (configuration?: Configur localVarQueryParameter['$select'] = Array.from($select).join(COLLECTION_FORMATS.csv); } + if ($expand) { + localVarQueryParameter['$expand'] = Array.from($expand).join(COLLECTION_FORMATS.csv); + } + localVarHeaderParameter['Accept'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -4322,11 +4408,12 @@ export const DrivesRootApiFp = function(configuration?: Configuration) { * @summary Get root from arbitrary space * @param {string} driveId key: id of drive * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getRoot(driveId: string, $select?: Set, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getRoot(driveId, $select, options); + async getRoot(driveId: string, $select?: Set, $expand?: Set, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getRoot(driveId, $select, $expand, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['DrivesRootApi.getRoot']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -4452,11 +4539,12 @@ export const DrivesRootApiFactory = function (configuration?: Configuration, bas * @summary Get root from arbitrary space * @param {string} driveId key: id of drive * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getRoot(driveId: string, $select?: Set, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.getRoot(driveId, $select, options).then((request) => request(axios, basePath)); + getRoot(driveId: string, $select?: Set, $expand?: Set, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getRoot(driveId, $select, $expand, options).then((request) => request(axios, basePath)); }, /** * Sends a sharing invitation for the root of a `drive`. A sharing invitation provides permissions to the recipients and optionally sends them an email with a sharing link. The response will be a permission object with the grantedToV2 property containing the created grant details. ## Roles property values For now, roles are only identified by a uuid. There are no hardcoded aliases like `read` or `write` because role actions can be completely customized. @@ -4569,11 +4657,12 @@ export class DrivesRootApi extends BaseAPI { * @summary Get root from arbitrary space * @param {string} driveId key: id of drive * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public getRoot(driveId: string, $select?: Set, options?: RawAxiosRequestConfig) { - return DrivesRootApiFp(this.configuration).getRoot(driveId, $select, options).then((request) => request(this.axios, this.basePath)); + public getRoot(driveId: string, $select?: Set, $expand?: Set, options?: RawAxiosRequestConfig) { + return DrivesRootApiFp(this.configuration).getRoot(driveId, $select, $expand, options).then((request) => request(this.axios, this.basePath)); } /** @@ -4643,8 +4732,14 @@ export type CreateDriveItemAtLibreGraphMissingParentsBehaviorEnum = typeof Creat export const GetRootSelectEnum = { MicrosoftGraphDownloadUrl: '@microsoft.graph.downloadUrl', LibreGraphPermissionsActionsAllowedValues: '@libre.graph.permissions.actions.allowedValues', + LibreGraphShareTypes: '@libre.graph.shareTypes', } as const; export type GetRootSelectEnum = typeof GetRootSelectEnum[keyof typeof GetRootSelectEnum]; +export const GetRootExpandEnum = { + Children: 'children', + Thumbnails: 'thumbnails', +} as const; +export type GetRootExpandEnum = typeof GetRootExpandEnum[keyof typeof GetRootExpandEnum]; export const ListPermissionsSpaceRootSelectEnum = { LibreGraphPermissionsActionsAllowedValues: '@libre.graph.permissions.actions.allowedValues', LibreGraphPermissionsRolesAllowedValues: '@libre.graph.permissions.roles.allowedValues', @@ -8387,10 +8482,11 @@ export const MeDriveRootApiAxiosParamCreator = function (configuration?: Configu * * @summary Get root from personal space * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - homeGetRoot: async ($select?: Set, options: RawAxiosRequestConfig = {}): Promise => { + homeGetRoot: async ($select?: Set, $expand?: Set, options: RawAxiosRequestConfig = {}): Promise => { const localVarPath = `/v1.0/me/drive/root`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -8413,6 +8509,10 @@ export const MeDriveRootApiAxiosParamCreator = function (configuration?: Configu localVarQueryParameter['$select'] = Array.from($select).join(COLLECTION_FORMATS.csv); } + if ($expand) { + localVarQueryParameter['$expand'] = Array.from($expand).join(COLLECTION_FORMATS.csv); + } + localVarHeaderParameter['Accept'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -8437,11 +8537,12 @@ export const MeDriveRootApiFp = function(configuration?: Configuration) { * * @summary Get root from personal space * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async homeGetRoot($select?: Set, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.homeGetRoot($select, options); + async homeGetRoot($select?: Set, $expand?: Set, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.homeGetRoot($select, $expand, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['MeDriveRootApi.homeGetRoot']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -8459,11 +8560,12 @@ export const MeDriveRootApiFactory = function (configuration?: Configuration, ba * * @summary Get root from personal space * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - homeGetRoot($select?: Set, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.homeGetRoot($select, options).then((request) => request(axios, basePath)); + homeGetRoot($select?: Set, $expand?: Set, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.homeGetRoot($select, $expand, options).then((request) => request(axios, basePath)); }, }; }; @@ -8476,19 +8578,26 @@ export class MeDriveRootApi extends BaseAPI { * * @summary Get root from personal space * @param {Set} [$select] Select additional properties to be returned. + * @param {Set} [$expand] Expand related entities to be returned. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public homeGetRoot($select?: Set, options?: RawAxiosRequestConfig) { - return MeDriveRootApiFp(this.configuration).homeGetRoot($select, options).then((request) => request(this.axios, this.basePath)); + public homeGetRoot($select?: Set, $expand?: Set, options?: RawAxiosRequestConfig) { + return MeDriveRootApiFp(this.configuration).homeGetRoot($select, $expand, options).then((request) => request(this.axios, this.basePath)); } } export const HomeGetRootSelectEnum = { MicrosoftGraphDownloadUrl: '@microsoft.graph.downloadUrl', LibreGraphPermissionsActionsAllowedValues: '@libre.graph.permissions.actions.allowedValues', + LibreGraphShareTypes: '@libre.graph.shareTypes', } as const; export type HomeGetRootSelectEnum = typeof HomeGetRootSelectEnum[keyof typeof HomeGetRootSelectEnum]; +export const HomeGetRootExpandEnum = { + Children: 'children', + Thumbnails: 'thumbnails', +} as const; +export type HomeGetRootExpandEnum = typeof HomeGetRootExpandEnum[keyof typeof HomeGetRootExpandEnum]; /** @@ -8600,6 +8709,7 @@ export class MeDriveRootChildrenApi extends BaseAPI { export const HomeGetChildrenSelectEnum = { MicrosoftGraphDownloadUrl: '@microsoft.graph.downloadUrl', LibreGraphPermissionsActionsAllowedValues: '@libre.graph.permissions.actions.allowedValues', + LibreGraphShareTypes: '@libre.graph.shareTypes', } as const; export type HomeGetChildrenSelectEnum = typeof HomeGetChildrenSelectEnum[keyof typeof HomeGetChildrenSelectEnum]; diff --git a/packages/web-client/src/graph/index.ts b/packages/web-client/src/graph/index.ts index 84a63c5a62e..2e6944dbe5c 100644 --- a/packages/web-client/src/graph/index.ts +++ b/packages/web-client/src/graph/index.ts @@ -5,6 +5,7 @@ import { type GraphGroups, GroupsFactory } from './groups' import { ApplicationsFactory, GraphApplications } from './applications' import { DrivesFactory, GraphDrives } from './drives' import { DriveItemsFactory, GraphDriveItems } from './driveItems' +export type { DriveItemRef, DriveItemStatOptions } from './driveItems' import { TagsFactory, GraphTags } from './tags' import { ActivitiesFactory, GraphActivities } from './activities' import { PermissionsFactory, GraphPermissions } from './permissions' diff --git a/packages/web-client/src/helpers/resource/graph.ts b/packages/web-client/src/helpers/resource/graph.ts new file mode 100644 index 00000000000..beac9924737 --- /dev/null +++ b/packages/web-client/src/helpers/resource/graph.ts @@ -0,0 +1,151 @@ +import { extname } from 'path' +import { urlJoin } from '../../utils' +import { DavPermission } from '../../webdav/constants' +import { ShareTypes } from '../share' +import { extractStorageId } from './functions' +import type { DriveItem } from '../../graph/generated' +import type { SpaceResource } from '../space' +import type { Resource } from './types' + +// graphActionToDavPermission maps the actions a driveItem reports to the DAV +// permission letters the resource helpers built their can* checks on, so +// listings can move to graph without touching every consumer. +const graphActionToDavPermission: Record = { + 'libre.graph/driveItem/permissions/create': DavPermission.Shareable, + 'libre.graph/driveItem/standard/delete': DavPermission.Deletable, + 'libre.graph/driveItem/path/update': DavPermission.Renameable + DavPermission.Moveable, + 'libre.graph/driveItem/children/create': DavPermission.FolderCreateable, + 'libre.graph/driveItem/upload/create': DavPermission.FileUpdateable, + 'libre.graph/driveItem/permissions/deny': DavPermission.Deny +} + +export const davPermissionsFromActions = (actions: string[] = []): string => { + const letters = actions.reduce((acc, action) => { + const mapped = graphActionToDavPermission[action] + if (!mapped) { + return acc + } + for (const letter of mapped) { + if (!acc.includes(letter)) { + acc.push(letter) + } + } + return acc + }, [] as string[]) + + // no read on the content means the item can be viewed but not downloaded + if (!actions.includes('libre.graph/driveItem/content/read')) { + letters.push(DavPermission.SecureView) + } + + return letters.join('') +} + +// buildResourceFromDriveItem turns a graph driveItem into the Resource shape the +// UI works with. The counterpart of buildResource, which reads a PROPFIND entry. +export const buildResourceFromDriveItem = ( + driveItem: DriveItem, + space: SpaceResource, + parentPath = '', + // the drive root reports its own name, so callers that know the path pin it + pathOverride?: string +): Resource => { + // a drive root carries neither facet, it reports itself as a root instead + const isFolder = !!driveItem.folder || !!driveItem.root + const name = driveItem.name || '' + const path = pathOverride ?? urlJoin(parentPath, name, { leadingSlash: true }) + const actions = driveItem['@libre.graph.permissions.actions.allowedValues'] + const lock = driveItem.lockInfo + const permissions = davPermissionsFromActions(actions) + // graph reports share types by key, the resource carries the numeric values + const shareTypes = ShareTypes.getValues( + ShareTypes.getByKeys(driveItem['@libre.graph.shareTypes'] || []).filter(Boolean) + ) + + const r: Resource = { + id: driveItem.id, + fileId: driveItem.id, + storageId: extractStorageId(driveItem.id), + parentFolderId: driveItem.parentReference?.id, + mimeType: driveItem.file?.mimeType, + name, + extension: isFolder ? '' : extname(name).replace(/^\./, ''), + path, + webDavPath: urlJoin(space.webDavPath, path), + type: isFolder ? 'folder' : 'file', + isFolder, + locked: !!lock, + lockOwner: lock?.owners?.[0]?.displayName, + lockTime: lock?.createdDateTime, + processing: !!driveItem.pendingOperations?.pendingContentUpdate, + mdate: driveItem.lastModifiedDateTime, + size: (driveItem.size ?? 0).toString(), + permissions, + isInVault: false, + starred: driveItem['@libre.graph.me.following'] === true, + etag: driveItem.eTag, + shareTypes, + privateLink: driveItem.webUrl, + remoteItemId: driveItem.remoteItem?.id, + remoteItemPath: driveItem.remoteItem?.path, + // the item owner is always the space owner, see node.Owner() in reva + owner: space.owner, + tags: driveItem['@libre.graph.tags'] || [], + audio: driveItem.audio, + location: driveItem.location, + image: driveItem.image, + photo: driveItem.photo, + extraProps: {}, + // the server answers this through $expand=thumbnails, the counterpart of + // PROPFIND's has-preview property + hasPreview: () => !!driveItem.thumbnails?.length, + canUpload: function (this: Resource) { + return this.permissions.indexOf(DavPermission.FolderCreateable) >= 0 + }, + canDownload: function () { + return this.permissions.indexOf(DavPermission.SecureView) === -1 + }, + canBeDeleted: function () { + return this.permissions.indexOf(DavPermission.Deletable) >= 0 + }, + canRename: function () { + return this.permissions.indexOf(DavPermission.Renameable) >= 0 + }, + canShare: function ({ ability }: { ability: any }) { + return ( + ability.can('create-all', 'Share') && this.permissions.indexOf(DavPermission.Shareable) >= 0 + ) + }, + canCreate: function () { + return this.permissions.indexOf(DavPermission.FolderCreateable) >= 0 + }, + canEditTags: function () { + return ( + this.permissions.indexOf(DavPermission.Updateable) >= 0 || + this.permissions.indexOf(DavPermission.FileUpdateable) >= 0 || + this.permissions.indexOf(DavPermission.FolderCreateable) >= 0 + ) + }, + canListVersions: function () { + return !this.isFolder + }, + isMounted: function () { + return this.permissions.indexOf(DavPermission.Mounted) >= 0 + }, + isReceivedShare: function () { + return this.permissions.indexOf(DavPermission.Shared) >= 0 + }, + isShareRoot(): boolean { + return !!driveItem.remoteItem + }, + getDomSelector: () => (driveItem.id || '').replace(/[^A-Za-z0-9\-_]/g, '') + } + + return r +} + +export const buildResourcesFromDriveItems = ( + driveItems: DriveItem[], + space: SpaceResource, + parentPath = '' +): Resource[] => driveItems.map((item) => buildResourceFromDriveItem(item, space, parentPath)) diff --git a/packages/web-client/src/helpers/resource/index.ts b/packages/web-client/src/helpers/resource/index.ts index ab6b35419db..32817eafe0a 100644 --- a/packages/web-client/src/helpers/resource/index.ts +++ b/packages/web-client/src/helpers/resource/index.ts @@ -1,2 +1,3 @@ export * from './functions' export * from './types' +export * from './graph' diff --git a/packages/web-client/src/helpers/space/graphDrive.ts b/packages/web-client/src/helpers/space/graphDrive.ts new file mode 100644 index 00000000000..d4fbd4eaae2 --- /dev/null +++ b/packages/web-client/src/helpers/space/graphDrive.ts @@ -0,0 +1,35 @@ +import { isPublicSpaceResource, SpaceResource } from './types' +import type { DriveItemRef } from '../../graph/driveItems' + +// reva's PublicStorageProviderID: every public link lives in this one mountpoint +// space, the link token is the item below it +const publicStorageProviderId = '7993447f-687f-490d-875c-ac95e89a62a4' + +/** + * The graph drive a space is addressed by. For a public link the client builds + * it from the link token, everywhere else the space id is the drive id. + */ +export const graphDriveIdOfSpace = (space: SpaceResource): string => { + if (isPublicSpaceResource(space)) { + return `${publicStorageProviderId}$${publicStorageProviderId}!${space.id}` + } + return space.id.toString() +} + +/** + * How graph addresses an item of the space: by id where there is one, by path + * otherwise. A root has no path to look up, it is addressed by its id, and for + * a public link that is the mountpoint drive itself. + */ +export const graphRefOfSpace = ( + space: SpaceResource, + { path, fileId }: { path?: string; fileId?: string } +): DriveItemRef => { + if (fileId) { + return { itemId: fileId } + } + if (!path || path === '/') { + return { itemId: isPublicSpaceResource(space) ? graphDriveIdOfSpace(space) : space.root?.id } + } + return { path } +} diff --git a/packages/web-client/src/helpers/space/index.ts b/packages/web-client/src/helpers/space/index.ts index ab6b35419db..9d028908fe2 100644 --- a/packages/web-client/src/helpers/space/index.ts +++ b/packages/web-client/src/helpers/space/index.ts @@ -1,2 +1,4 @@ export * from './functions' export * from './types' +export * from './graphDrive' +export * from './publicLink' diff --git a/packages/web-client/src/helpers/space/publicLink.ts b/packages/web-client/src/helpers/space/publicLink.ts new file mode 100644 index 00000000000..45195b171a6 --- /dev/null +++ b/packages/web-client/src/helpers/space/publicLink.ts @@ -0,0 +1,64 @@ +import { SharePermissionBit } from '../share/constants' +import { buildPublicSpaceResource } from './functions' +import { PublicSpaceResource, SpaceResource } from './types' +import type { DriveItem } from '../../graph/generated' +import type { Resource } from '../resource' + +// The actions a public link grants, capped at the link role by the server. +const actionToPermissionBit: Record = { + 'libre.graph/driveItem/content/read': SharePermissionBit.Read, + 'libre.graph/driveItem/path/update': SharePermissionBit.Update, + 'libre.graph/driveItem/upload/create': SharePermissionBit.Create, + 'libre.graph/driveItem/children/create': SharePermissionBit.Create, + 'libre.graph/driveItem/standard/delete': SharePermissionBit.Delete, + 'libre.graph/driveItem/permissions/create': SharePermissionBit.Share +} + +/** + * The link role as the permission bits the callers test against. Graph reports + * the role as the actions it allows, there is no permission number on a public + * link item. + */ +export const publicLinkPermissionFromActions = (actions: string[] = []): number => + actions.reduce((bits, action) => bits | (actionToPermissionBit[action] ?? 0), 0) + +/** + * Turn the stat of a public link's root into the space the app works with. The + * counterpart of the PROPFIND based buildPublicSpaceResource, for the graph + * listing. + * + * The link's expiration and its share date came from dav properties that graph + * has no counterpart for. Nothing reads them. The owner comes from the + * mountpoint drive. + */ +export const buildPublicSpaceResourceFromDriveItem = ({ + driveItem, + resource, + space, + drive +}: { + driveItem: DriveItem + resource: Resource + space: PublicSpaceResource + drive?: SpaceResource +}): PublicSpaceResource => { + const actions = driveItem['@libre.graph.permissions.actions.allowedValues'] + + return Object.assign( + buildPublicSpaceResource({ + ...resource, + id: space.id, + driveAlias: space.driveAlias, + webDavPath: space.webDavPath, + publicLinkType: space.publicLinkType + }), + { + publicLinkPermission: publicLinkPermissionFromActions(actions), + // the item behind the link, which dav could not tell apart: it reported + // "folder" for a link to a single file as well + publicLinkItemType: driveItem.folder ? 'folder' : 'file', + fileId: driveItem.id, + ...(drive?.owner?.displayName && { publicLinkShareOwner: drive.owner.displayName }) + } + ) +} diff --git a/packages/web-client/tests/unit/graph/driveItems/driveItems.spec.ts b/packages/web-client/tests/unit/graph/driveItems/driveItems.spec.ts new file mode 100644 index 00000000000..4cf5e452cee --- /dev/null +++ b/packages/web-client/tests/unit/graph/driveItems/driveItems.spec.ts @@ -0,0 +1,94 @@ +import { AxiosInstance } from 'axios' +import { DriveItemsFactory } from '../../../../src/graph/driveItems/driveItems' +import { Configuration } from '../../../../src/graph/generated' + +const basePath = 'https://cloud.test/graph' + +const getClient = () => { + const request = vi.fn().mockResolvedValue({ data: { id: 'item' } }) + const axiosClient = { request, defaults: {} } as unknown as AxiosInstance + const driveItems = DriveItemsFactory({ + axiosClient, + config: new Configuration({ basePath }) + }) + return { driveItems, request } +} + +const requestedUrl = (request: ReturnType) => request.mock.calls[0][0].url + +describe('statDriveItem', () => { + it('stats by id through the generated operation', async () => { + const { driveItems, request } = getClient() + + await driveItems.statDriveItem('storage$space', { itemId: 'storage$space!item' }) + + expect(requestedUrl(request)).toBe( + `${basePath}/v1.0/drives/storage%24space/items/storage%24space!item` + ) + }) + + it('passes select and expand along', async () => { + const { driveItems, request } = getClient() + + await driveItems.statDriveItem( + 'storage$space', + { itemId: 'storage$space!item' }, + { + select: new Set(['@libre.graph.shareTypes' as const]), + expand: new Set(['children' as const]) + } + ) + + const url = requestedUrl(request) + expect(url).toContain('%24select=%40libre.graph.shareTypes') + expect(url).toContain('%24expand=children') + }) + + it('stats by path through the colon syntax', async () => { + const { driveItems, request } = getClient() + + await driveItems.statDriveItem('storage$space', { path: '/Documents/Notes' }) + + expect(requestedUrl(request)).toBe( + `${basePath}/v1.0/drives/storage%24space/root:/Documents/Notes` + ) + }) + + // the server splits the path on a literal ':/', so a colon in a name must + // arrive encoded or it would be read as the delimiter + it('encodes each path segment', async () => { + const { driveItems, request } = getClient() + + await driveItems.statDriveItem('storage$space', { path: '/Urlaub 2026/tag:1/foo&bar.txt' }) + + expect(requestedUrl(request)).toBe( + `${basePath}/v1.0/drives/storage%24space/root:/Urlaub%202026/tag%3A1/foo%26bar.txt` + ) + }) + + it('ignores a leading and trailing slash on the path', async () => { + const { driveItems, request } = getClient() + + await driveItems.statDriveItem('storage$space', { path: 'Documents/' }) + + expect(requestedUrl(request)).toBe(`${basePath}/v1.0/drives/storage%24space/root:/Documents`) + }) +}) + +describe('listDriveItemChildren', () => { + it('lists the children of an item', async () => { + const request = vi.fn().mockResolvedValue({ data: { value: [{ id: 'child' }] } }) + const axiosClient = { request, defaults: {} } as unknown as AxiosInstance + const driveItems = DriveItemsFactory({ + axiosClient, + config: new Configuration({ basePath }) + }) + + const children = await driveItems.listDriveItemChildren('storage$space', 'storage$space!item') + + expect(requestedUrl(request)).toBe( + `${basePath}/v1.0/drives/storage%24space/items/storage%24space!item/children` + ) + expect(children).toEqual([{ id: 'child' }]) + }) +}) diff --git a/packages/web-client/tests/unit/helpers/resource/graph.spec.ts b/packages/web-client/tests/unit/helpers/resource/graph.spec.ts new file mode 100644 index 00000000000..343035eae7a --- /dev/null +++ b/packages/web-client/tests/unit/helpers/resource/graph.spec.ts @@ -0,0 +1,159 @@ +import { + davPermissionsFromActions, + buildResourceFromDriveItem +} from '../../../../src/helpers/resource/graph' +import { ShareTypes } from '../../../../src/helpers/share' +import type { SpaceResource } from '../../../../src/helpers/space' + +// a manager's action list, taken verbatim from a running server +const managerActions = [ + 'libre.graph/driveItem/permissions/create', + 'libre.graph/driveItem/children/create', + 'libre.graph/driveItem/standard/delete', + 'libre.graph/driveItem/path/read', + 'libre.graph/driveItem/quota/read', + 'libre.graph/driveItem/content/read', + 'libre.graph/driveItem/upload/create', + 'libre.graph/driveItem/permissions/read', + 'libre.graph/driveItem/children/read', + 'libre.graph/driveItem/versions/read', + 'libre.graph/driveItem/deleted/read', + 'libre.graph/driveItem/path/update', + 'libre.graph/driveItem/permissions/delete' +] + +const space = { + id: 'storage$space', + webDavPath: '/dav/spaces/storage$space', + owner: { id: 'alice', displayName: 'Alice' } +} as unknown as SpaceResource + +describe('davPermissionsFromActions', () => { + it('maps a manager to the full dav permission set', () => { + const permissions = davPermissionsFromActions(managerActions) + expect(permissions).toContain('R') // shareable + expect(permissions).toContain('D') // deletable + expect(permissions).toContain('N') // renameable + expect(permissions).toContain('CK') // folder createable + expect(permissions).not.toContain('X') // content is readable, so no secure view + }) + + it('marks an item without content read as secure view', () => { + const permissions = davPermissionsFromActions(['libre.graph/driveItem/children/read']) + expect(permissions).toContain('X') + }) + + it('handles an empty action list', () => { + expect(davPermissionsFromActions([])).toBe('X') + expect(davPermissionsFromActions(undefined)).toBe('X') + }) +}) + +describe('buildResourceFromDriveItem', () => { + it('builds a folder resource with working capability checks', () => { + const r = buildResourceFromDriveItem( + { + id: 'storage$space!folder', + name: 'music', + size: 0, + eTag: '"abc"', + folder: {}, + lastModifiedDateTime: '2026-09-01T12:00:00Z', + parentReference: { id: 'storage$space!root' }, + '@libre.graph.permissions.actions.allowedValues': managerActions + } as any, + space + ) + + expect(r.isFolder).toBe(true) + expect(r.name).toBe('music') + expect(r.path).toBe('/music') + expect(r.canUpload({})).toBe(true) + expect(r.canBeDeleted()).toBe(true) + expect(r.canRename()).toBe(true) + expect(r.canDownload()).toBe(true) + expect(r.storageId).toBe('storage$space') + expect(r.owner).toEqual({ id: 'alice', displayName: 'Alice' }) + }) + + it('treats a drive root as a folder, it carries neither facet', () => { + const r = buildResourceFromDriveItem( + { + id: 'storage$space!space', + name: '.', + size: 4897, + root: {}, + parentReference: { id: 'storage$space', path: '.' }, + '@libre.graph.permissions.actions.allowedValues': managerActions + } as any, + space, + '', + '/' + ) + + expect(r.isFolder).toBe(true) + expect(r.type).toBe('folder') + expect(r.path).toBe('/') + }) + + it('carries the facets and the lock through', () => { + const r = buildResourceFromDriveItem( + { + id: 'storage$space!song', + name: 'fight.mp3', + size: 42, + file: { mimeType: 'audio/mpeg' }, + audio: { artist: 'Motörhead', title: 'Fight' }, + lockInfo: { lockType: 'exclusive', owners: [{ displayName: 'Alice' }] }, + pendingOperations: { pendingContentUpdate: {} }, + '@libre.graph.shareTypes': ['user', 'link'], + '@libre.graph.permissions.actions.allowedValues': managerActions + } as any, + space, + '/music' + ) + + expect(r.isFolder).toBe(false) + expect(r.extension).toBe('mp3') + expect(r.path).toBe('/music/fight.mp3') + expect(r.mimeType).toBe('audio/mpeg') + expect((r as any).audio.artist).toBe('Motörhead') + expect(r.locked).toBe(true) + expect(r.lockOwner).toBe('Alice') + expect(r.processing).toBe(true) + // graph reports keys, consumers compare against the numeric share types + expect(r.shareTypes).toEqual([ShareTypes.user.value, ShareTypes.link.value]) + }) + + it('has a preview exactly when the server expanded thumbnails for it', () => { + const withThumbnail = buildResourceFromDriveItem( + { + id: 'x', + name: 'bild.jpg', + file: { mimeType: 'image/jpeg' }, + thumbnails: [{ small: { url: 'https://cloud.test/preview' } }] + } as any, + space + ) + const withoutThumbnail = buildResourceFromDriveItem( + { id: 'y', name: 'notes.json', file: { mimeType: 'application/json' } } as any, + space + ) + + expect(withThumbnail.hasPreview()).toBe(true) + expect(withoutThumbnail.hasPreview()).toBe(false) + }) + + it('reports a shared item as a share root', () => { + const r = buildResourceFromDriveItem( + { + id: 'x', + name: 'shared.txt', + remoteItem: { id: 'other$drive!item', path: '/Project X' } + } as any, + space + ) + expect(r.isShareRoot()).toBe(true) + expect(r.remoteItemPath).toBe('/Project X') + }) +}) diff --git a/packages/web-pkg/src/composables/piniaStores/resources.ts b/packages/web-pkg/src/composables/piniaStores/resources.ts index c6f008a5eee..c16b97aaea1 100644 --- a/packages/web-pkg/src/composables/piniaStores/resources.ts +++ b/packages/web-pkg/src/composables/piniaStores/resources.ts @@ -3,7 +3,7 @@ import { Ref, computed, ref, unref } from 'vue' import { isProjectSpaceResource, SpaceResource, type Resource } from '@opencloud-eu/web-client' import { getParentPaths } from '../../helpers' import { AncestorMetaData, AncestorMetaDataValue } from '../../types' -import { DavProperty, WebDAV } from '@opencloud-eu/web-client/webdav' +import { WebDAV } from '@opencloud-eu/web-client/webdav' import { useSpacesStore } from './spaces' import { eventBus, releaseFilePreviews } from '../../services' @@ -225,7 +225,6 @@ export const useResourcesStore = defineStore('resources', () => { } } const promises = [] - const davProperties = [DavProperty.FileId, DavProperty.ShareTypes, DavProperty.FileParent] const parentPaths = getParentPaths(folder.path) for (const path of parentPaths) { @@ -236,17 +235,15 @@ export const useResourcesStore = defineStore('resources', () => { } promises.push( - client - .listFiles(space, { path }, { depth: 0, davProperties, signal }) - .then(({ resource }) => { - data[path] = { - id: resource.fileId, - shareTypes: resource.shareTypes, - parentFolderId: resource.parentFolderId, - spaceId: space.id, - path - } - }) + client.getFileInfo(space, { path }, { signal }).then((resource) => { + data[path] = { + id: resource.fileId, + shareTypes: resource.shareTypes, + parentFolderId: resource.parentFolderId, + spaceId: space.id, + path + } + }) ) } diff --git a/packages/web-pkg/src/helpers/index.ts b/packages/web-pkg/src/helpers/index.ts index 82c5fe04193..8613bd9a2e6 100644 --- a/packages/web-pkg/src/helpers/index.ts +++ b/packages/web-pkg/src/helpers/index.ts @@ -13,6 +13,7 @@ export * from './fileExtension' export * from './extensionMarker' export * from './vault' export * from './vaultEngine' +export * from './vaultTranslate' export * from './filesize' export * from './fuse' export * from './locale' diff --git a/packages/web-pkg/src/helpers/vault.ts b/packages/web-pkg/src/helpers/vault.ts index db9c27a1591..7bbf6d6d141 100644 --- a/packages/web-pkg/src/helpers/vault.ts +++ b/packages/web-pkg/src/helpers/vault.ts @@ -190,6 +190,11 @@ export async function decryptResourceInPlace( const guessed = mimeTypeForExtension(r.extension) if (guessed) { r.mimeType = guessed + // Same reason for the preview: the server sees an opaque blob and never + // renders a thumbnail for it, so it reports no preview. The client can + // render it once decrypted, and the preview service goes through the + // vault-aware client to get the plaintext. + r.hasPreview = () => true } } // The engine resolved → resource is by definition inside (or *is*) a vault. diff --git a/packages/web-pkg/src/helpers/vaultTranslate.ts b/packages/web-pkg/src/helpers/vaultTranslate.ts new file mode 100644 index 00000000000..83e083cb70e --- /dev/null +++ b/packages/web-pkg/src/helpers/vaultTranslate.ts @@ -0,0 +1,110 @@ +import { Resource, SpaceResource } from '@opencloud-eu/web-client' +import { decryptResourceInPlace, getVaultClaim, markVaultStatus, resolveVaultEngine } from './vault' +import { encryptVaultPath } from './vaultEngine' +import { ExtensionRegistry } from '../composables/piniaStores/extensionRegistry' + +/** + * Vault translation between what the user sees and what the server stores, + * independent of the client that carries the request. The webdav decorator + * (`createVaultWebDav`) and the graph folder listing both translate through + * these, so a vault behaves the same no matter which API served the listing. + */ + +/** + * Encrypt a clear-text path into its server-side form. No-op (sync fast path) + * when the path isn't claimed by any vault. For a *locked* vault we have no + * key, so we leave the path untouched - mutations on a locked vault aren't + * reachable through the UI (the unlock gate stops them first). + */ +export async function toVaultServerPath( + extensionRegistry: ExtensionRegistry, + space: SpaceResource, + path: string | undefined +): Promise { + if (!space || !path) { + return path + } + if (!getVaultClaim(extensionRegistry, space, path)) { + return path + } + const engine = await resolveVaultEngine(extensionRegistry, space, path) + return engine ? await encryptVaultPath(engine, path) : path +} + +/** + * Like `toVaultServerPath`, but for *writes*: refuse to operate when the path + * belongs to a vault that is locked. Reads can fall through and just show + * ciphertext, but a write (create / put / move / copy / delete) with the + * untranslated clear-text path would put a clear-text name on the server and + * corrupt the vault. The UI never reaches a locked vault, so this only ever + * fires as a fail-closed backstop - never silently send clear text. + */ +export async function toVaultServerPathForWrite( + extensionRegistry: ExtensionRegistry, + space: SpaceResource, + path: string | undefined +): Promise { + if (!space || !path) { + return path + } + const claim = getVaultClaim(extensionRegistry, space, path) + if (!claim) { + return path + } + // The vault *root* itself is a clear-text folder name - creating, renaming + // or deleting the vault needs no key, so let it through untouched even when + // no engine exists (e.g. while creating the vault, or for a locked one). + // Only *content* below the root carries an encryptable name. + if (claim.vaultRoot === path) { + return path + } + const engine = await resolveVaultEngine(extensionRegistry, space, path) + if (!engine) { + throw new Error( + `Refusing to write a clear-text path into the locked vault "${claim.vaultRoot}"` + ) + } + return encryptVaultPath(engine, path) +} + +/** + * Decrypt the names of resources coming back from the server and flag their + * vault status. Resources are grouped by vault root so a mixed listing (e.g. + * the trash bin, where each item's original location may sit in a different + * vault) resolves each engine exactly once. `markVaultStatus` is claim-based + * and runs even while a vault is locked; the actual name decrypt only happens + * when the vault is unlocked (the engine resolves). + */ +export async function applyVaultFromServer( + extensionRegistry: ExtensionRegistry, + space: SpaceResource, + resources: Array +): Promise { + const list = resources.filter((r): r is Resource => !!r?.path) + if (!space || !list.length) { + return + } + + const byRoot = new Map() + for (const r of list) { + const claim = getVaultClaim(extensionRegistry, space, r.path) + if (!claim) { + continue + } + const group = byRoot.get(claim.vaultRoot) ?? [] + group.push(r) + byRoot.set(claim.vaultRoot, group) + } + + for (const [vaultRoot, group] of byRoot) { + const engine = await resolveVaultEngine(extensionRegistry, space, vaultRoot) + if (engine) { + await Promise.all(group.map((r) => decryptResourceInPlace(engine, r))) + } + } + + // Always (re-)flag vault status. Idempotent after decryptResourceInPlace, and + // the only thing that fires for a locked vault or for a vault root surfaced + // in a parent listing (where no engine resolves against it). + markVaultStatus(extensionRegistry, space, list) +} diff --git a/packages/web-pkg/src/services/client/client.ts b/packages/web-pkg/src/services/client/client.ts index 519d2e1b4fa..636a72e9a05 100644 --- a/packages/web-pkg/src/services/client/client.ts +++ b/packages/web-pkg/src/services/client/client.ts @@ -11,6 +11,7 @@ import { Language } from 'vue3-gettext' import { FetchEventSourceInit } from '@microsoft/fetch-event-source' import { sse } from '@opencloud-eu/web-client/sse' import { AuthStore, ConfigStore } from '../../composables' +import { createGraphWebDav } from './graphWebDav' import { createVaultWebDav } from './vaultWebDav' const createFetchOptions = (authParams: AuthParameters, language: string): FetchEventSourceInit => { @@ -121,7 +122,7 @@ export class ClientService { private initGraphClient() { const axiosClient = axios.create({ headers: this.staticHeaders }) axiosClient.interceptors.request.use((config) => { - Object.assign(config.headers, this.getDynamicHeaders()) + Object.assign(config.headers, this.getDynamicHeaders(), this.getPublicLinkHeaders()) return config }) this.graphClient = graph(this.configStore.serverUrl, axiosClient) @@ -146,31 +147,41 @@ export class ClientService { } private initWebDavClient() { - const client = webdav(this.configStore.serverUrl, () => { - const headers = { ...this.staticHeaders, ...this.getDynamicHeaders() } - - if (this.authStore.publicLinkToken) { - headers['public-token'] = this.authStore.publicLinkToken - } - - if (this.authStore.publicLinkPassword) { - headers['Authorization'] = - 'Basic ' + - Buffer.from(['public', this.authStore.publicLinkPassword].join(':')).toString('base64') - } - - return headers - }) - // Wrap the raw client so vault path/name translation happens - // transparently for every caller (clear-text in, clear-text out). It's a - // strict pass-through for any path that isn't inside a vault. - this.webDavClient = createVaultWebDav(client) + const client = webdav(this.configStore.serverUrl, () => ({ + ...this.staticHeaders, + ...this.getDynamicHeaders(), + ...this.getPublicLinkHeaders() + })) + // Two wrappers, outside in: vault translation hands clear-text paths in and + // clear-text names out, below it the graph layer answers what graph can + // answer. The vault layer stays outermost so the graph requests carry the + // encrypted names the server stores. + this.webDavClient = createVaultWebDav(createGraphWebDav(client, () => this.graphClient)) } /** * Dynamic headers that should be provided via callback or interceptor because they may * change during the lifetime of the application (e.g. token renewal). */ + // A public link session has no access token: the link token identifies it and + // a link password rides along as basic auth. Graph needs them just like webdav + // does, public links are listed through graph as well. + private getPublicLinkHeaders(): Record { + const headers: Record = {} + + if (this.authStore.publicLinkToken) { + headers['public-token'] = this.authStore.publicLinkToken + } + + if (this.authStore.publicLinkPassword) { + headers['Authorization'] = + 'Basic ' + + Buffer.from(['public', this.authStore.publicLinkPassword].join(':')).toString('base64') + } + + return headers + } + private getDynamicHeaders({ useAuth = true }: { useAuth?: boolean } = {}): Record< string, string diff --git a/packages/web-pkg/src/services/client/graphListing.ts b/packages/web-pkg/src/services/client/graphListing.ts new file mode 100644 index 00000000000..c9b4d0c7c86 --- /dev/null +++ b/packages/web-pkg/src/services/client/graphListing.ts @@ -0,0 +1,83 @@ +import { + buildResourceFromDriveItem, + buildResourcesFromDriveItems, + graphDriveIdOfSpace, + graphRefOfSpace, + isPublicSpaceResource, + isShareSpaceResource, + SpaceResource, + urlJoin +} from '@opencloud-eu/web-client' +import { Graph } from '@opencloud-eu/web-client/graph' +import { + DriveItem, + GetDriveItemV1ExpandEnum, + GetDriveItemV1SelectEnum +} from '@opencloud-eu/web-client/graph/generated' +const graphListingSelect = new Set([ + '@libre.graph.permissions.actions.allowedValues', + '@libre.graph.shareTypes', + // the callers that used to ask for the DownloadURL dav property + '@microsoft.graph.downloadUrl' +]) +// thumbnails answer whether an item has a preview, for the folder and its +// children alike, which saves the client from guessing by mime type +const graphListingExpand = new Set(['children', 'thumbnails']) +const graphThumbnailsExpand = new Set(['thumbnails']) + +// A share space is rooted at the shared item, but graph answers with paths in +// the owner's drive: the stat of a received share reports the share root as +// "/" rather than "/". Inside a share the requested path is therefore +// authoritative, everywhere else the item is (the route correction in the +// loader exists to fix a stale url, and the drive root reports itself as '.'). +const currentPathOf = (driveItem: DriveItem, space: SpaceResource, path: string) => { + if (isShareSpaceResource(space)) { + return path || '/' + } + const parentPath = driveItem.parentReference?.path + return !parentPath || parentPath === '.' + ? '/' + : urlJoin(parentPath, driveItem.name, { leadingSlash: true }) +} + +// listFilesViaGraph lists a folder through graph, folder and children in one +// request via $expand=children, the same shape PROPFIND with Depth: 1 returns. +// Vault translation happens in the decorator above, this is the plain listing. +export const listFilesViaGraph = async ({ + graphClient, + space, + path, + fileId, + signal, + withChildren = true +}: { + graphClient: Graph + space: SpaceResource + path?: string + fileId?: string + signal?: AbortSignal + withChildren?: boolean +}) => { + const driveId = graphDriveIdOfSpace(space) + const driveItem = await graphClient.driveItems.statDriveItem( + driveId, + graphRefOfSpace(space, { path, fileId }), + { + select: graphListingSelect, + expand: withChildren ? graphListingExpand : graphThumbnailsExpand + }, + { + signal, + ...(isPublicSpaceResource(space) && { headers: { 'public-token': space.id.toString() } }) + } + ) + + const currentPath = currentPathOf(driveItem, space, path) + const currentFolder = buildResourceFromDriveItem(driveItem, space, '', currentPath) + + return { + driveItem, + resource: currentFolder, + children: buildResourcesFromDriveItems(driveItem.children || [], space, currentFolder.path) + } +} diff --git a/packages/web-pkg/src/services/client/graphWebDav.ts b/packages/web-pkg/src/services/client/graphWebDav.ts new file mode 100644 index 00000000000..72b36829edc --- /dev/null +++ b/packages/web-pkg/src/services/client/graphWebDav.ts @@ -0,0 +1,122 @@ +import { + buildPublicSpaceResourceFromDriveItem, + DavHttpError, + graphDriveIdOfSpace, + isPublicSpaceResource, + PublicSpaceResource, + Resource, + urlJoin +} from '@opencloud-eu/web-client' +import { ListFilesResult, WebDAV } from '@opencloud-eu/web-client/webdav' +import { Graph } from '@opencloud-eu/web-client/graph' +import { listFilesViaGraph } from './graphListing' + +/** + * Wrap a WebDAV client so everything that only reads metadata goes through + * graph instead of a PROPFIND. Callers keep using `clientService.webdav` and + * get the same shapes back, whichever API answered. + * + * What stays on webdav is what graph has no answer for: the trash bin, which + * has no listing, the file versions, which have no endpoint, and a request for + * custom dav properties, which graph has no mechanism for. + */ +export function createGraphWebDav(inner: WebDAV, graphClient: () => Graph): WebDAV { + const listFiles: WebDAV['listFiles'] = async (space, { path, fileId } = {}, options = {}) => { + // the trash bin has no graph listing, and a caller asking for its own dav + // properties (the vault's integrity token, say) can only be answered by a + // PROPFIND: graph has no arbitrary property mechanism + if (options.isTrash || options.extraProps?.length) { + return inner.listFiles(space, { path, fileId }, options) + } + + try { + const { driveItem, resource, children } = await listFilesViaGraph({ + graphClient: graphClient(), + space, + path, + fileId, + signal: options.signal, + withChildren: options.depth !== 0 + }) + + // the root of a public link is the space the app navigates in, so it + // carries the link's own properties rather than being a plain resource + if (isPublicSpaceResource(space) && !fileId && (!path || path === '/')) { + return { + resource: buildPublicSpaceResourceFromDriveItem({ + driveItem, + resource, + space: space as PublicSpaceResource, + drive: await publicLinkDrive(graphClient, graphDriveIdOfSpace(space), options.signal) + }), + children + } as ListFilesResult + } + + return { resource, children } + } catch (error) { + throw asDavError(error) + } + } + + return { + ...inner, + + listFiles, + + async getFileInfo(space, resource = {}, options): Promise { + return (await listFiles(space, resource, { ...options, depth: 0 })).resource + }, + + async getPathForFileId(id, options) { + try { + // the item knows where it sits, and the drive it sits in is the first + // part of its own id + const driveItem = await graphClient().driveItems.statDriveItem( + id.split('!')[0], + { itemId: id }, + {}, + options + ) + const parentPath = driveItem.parentReference?.path + + return !parentPath || parentPath === '.' + ? urlJoin(driveItem.name, { leadingSlash: true }) + : urlJoin(parentPath, driveItem.name, { leadingSlash: true }) + } catch (error) { + throw asDavError(error) + } + } + } +} + +// The mountpoint drive of a public link carries its owner. Failing to read it +// costs the owner's name on the drop upload page, nothing else, so a link that +// still works stays usable. +const publicLinkDrive = async (graphClient: () => Graph, driveId: string, signal?: AbortSignal) => { + try { + return await graphClient().drives.getDrive(driveId, undefined, { signal }) + } catch { + return undefined + } +} + +// Callers branch on the shape webdav throws: a status code and, for a public +// link, the code that tells "needs a password" from "wrong password" apart. +// Graph carries the same information in its error body. +const asDavError = (error: any) => { + const response = error?.response + if (!response) { + return error + } + + const code = response.data?.error?.code + const message = response.data?.error?.message || error.message + + return new DavHttpError(message, davErrorCodes[code] ?? code, response, response.status) +} + +const davErrorCodes: Record = { + publicLinkPasswordRequired: 'ERR_MISSING_BASIC_AUTH', + publicLinkPasswordInvalid: 'ERR_INVALID_CREDENTIALS' +} diff --git a/packages/web-pkg/src/services/client/vaultWebDav.ts b/packages/web-pkg/src/services/client/vaultWebDav.ts index d8cf9186355..40d7311198e 100644 --- a/packages/web-pkg/src/services/client/vaultWebDav.ts +++ b/packages/web-pkg/src/services/client/vaultWebDav.ts @@ -6,12 +6,12 @@ import { WebDAV } from '@opencloud-eu/web-client/webdav' // before `services`) creates an evaluation cycle that leaves unrelated // composable exports temporarily undefined. import { useExtensionRegistry } from '../../composables/piniaStores/extensionRegistry' +import { getVaultClaim, resolveVaultEngine } from '../../helpers/vault' import { - decryptResourceInPlace, - getVaultClaim, - markVaultStatus, - resolveVaultEngine -} from '../../helpers/vault' + applyVaultFromServer, + toVaultServerPath, + toVaultServerPathForWrite +} from '../../helpers/vaultTranslate' import { encryptVaultPath } from '../../helpers/vaultEngine' import { streamToArrayBuffer } from '../../helpers/streams' @@ -46,104 +46,18 @@ import { streamToArrayBuffer } from '../../helpers/streams' * then this is a known limitation. */ export function createVaultWebDav(inner: WebDAV): WebDAV { - /** - * Encrypt a clear-text path into its server-side form. No-op (sync fast path) - * when the path isn't claimed by any vault. For a *locked* vault we have no - * key, so we leave the path untouched - mutations on a locked vault aren't - * reachable through the UI (the unlock gate stops them first). - */ - async function toServerPath( - space: SpaceResource, - path: string | undefined - ): Promise { - if (!space || !path) { - return path - } - const registry = useExtensionRegistry() - if (!getVaultClaim(registry, space, path)) { - return path - } - const engine = await resolveVaultEngine(registry, space, path) - return engine ? await encryptVaultPath(engine, path) : path + function registry() { + return useExtensionRegistry() } - /** - * Like `toServerPath`, but for *writes*: refuse to operate when the path - * belongs to a vault that is locked. Reads can fall through and just show - * ciphertext, but a write (create / put / move / copy / delete) with the - * untranslated clear-text path would put a clear-text name on the server and - * corrupt the vault. The UI never reaches a locked vault, so this only ever - * fires as a fail-closed backstop - never silently send clear text. - */ - async function toServerPathForWrite( - space: SpaceResource, - path: string | undefined - ): Promise { - if (!space || !path) { - return path - } - const registry = useExtensionRegistry() - const claim = getVaultClaim(registry, space, path) - if (!claim) { - return path - } - // The vault *root* itself is a clear-text folder name - creating, renaming - // or deleting the vault needs no key, so let it through untouched even when - // no engine exists (e.g. while creating the vault, or for a locked one). - // Only *content* below the root carries an encryptable name. - if (claim.vaultRoot === path) { - return path - } - const engine = await resolveVaultEngine(registry, space, path) - if (!engine) { - throw new Error( - `Refusing to write a clear-text path into the locked vault "${claim.vaultRoot}"` - ) - } - return encryptVaultPath(engine, path) - } + const toServerPath = (space: SpaceResource, path: string | undefined) => + toVaultServerPath(registry(), space, path) - /** - * Decrypt the names of resources coming back from the server and flag their - * vault status. Resources are grouped by vault root so a mixed listing (e.g. - * the trash bin, where each item's original location may sit in a different - * vault) resolves each engine exactly once. `markVaultStatus` is claim-based - * and runs even while a vault is locked; the actual name decrypt only happens - * when the vault is unlocked (the engine resolves). - */ - async function fromServer( - space: SpaceResource, - resources: Array - ): Promise { - const list = resources.filter((r): r is Resource => !!r?.path) - if (!space || !list.length) { - return - } - const registry = useExtensionRegistry() + const toServerPathForWrite = (space: SpaceResource, path: string | undefined) => + toVaultServerPathForWrite(registry(), space, path) - const byRoot = new Map() - for (const r of list) { - const claim = getVaultClaim(registry, space, r.path) - if (!claim) { - continue - } - const group = byRoot.get(claim.vaultRoot) ?? [] - group.push(r) - byRoot.set(claim.vaultRoot, group) - } - - for (const [vaultRoot, group] of byRoot) { - const engine = await resolveVaultEngine(registry, space, vaultRoot) - if (engine) { - await Promise.all(group.map((r) => decryptResourceInPlace(engine, r))) - } - } - - // Always (re-)flag vault status. Idempotent after decryptResourceInPlace, - // and the only thing that fires for a locked vault or for a vault root - // surfaced in a parent listing (where no engine resolves against it). - markVaultStatus(registry, space, list) - } + const fromServer = (space: SpaceResource, resources: Array) => + applyVaultFromServer(registry(), space, resources) return { ...inner, diff --git a/packages/web-pkg/src/services/folder/loaders/loaderSpace.ts b/packages/web-pkg/src/services/folder/loaders/loaderSpace.ts index 1c14bfc5b21..18a1dfbc275 100644 --- a/packages/web-pkg/src/services/folder/loaders/loaderSpace.ts +++ b/packages/web-pkg/src/services/folder/loaders/loaderSpace.ts @@ -16,7 +16,6 @@ import { DriveItem } from '@opencloud-eu/web-client/graph/generated' import { isLocationSpacesActive, isLocationPublicActive } from '../../../router' import { getSharedDriveItem, setCurrentUserShareSpacePermissions } from '../../../helpers' import { useFileRouteReplace } from '../../../composables' -import { DavProperties, DavProperty } from '@opencloud-eu/web-client/webdav' export class FolderLoaderSpace implements FolderLoader { public isEnabled(): boolean { @@ -58,15 +57,9 @@ export class FolderLoaderSpace implements FolderLoader { try { resourcesStore.clearResourceList() - const davProperties = DavProperties.Default - if (isPublicSpaceResource(space)) { - // needed for public links for make previews work - davProperties.push(DavProperty.DownloadURL) - } - // eslint-disable-next-line prefer-const let { resource: currentFolder, children: resources } = yield* call( - webdav.listFiles(space, { path, fileId }, { signal: signal1, davProperties }) + webdav.listFiles(space, { path, fileId }, { signal: signal1 }) ) // if current folder has no id (= singe file public link) we must not correct the route diff --git a/packages/web-pkg/tests/unit/composables/piniaStores/resources.spec.ts b/packages/web-pkg/tests/unit/composables/piniaStores/resources.spec.ts index 2f54d43b2ed..d6ec39286d6 100644 --- a/packages/web-pkg/tests/unit/composables/piniaStores/resources.spec.ts +++ b/packages/web-pkg/tests/unit/composables/piniaStores/resources.spec.ts @@ -1,6 +1,7 @@ import { createPinia, setActivePinia } from 'pinia' import { mock } from 'vitest-mock-extended' -import { Resource } from '@opencloud-eu/web-client' +import { Resource, SpaceResource } from '@opencloud-eu/web-client' +import { WebDAV } from '@opencloud-eu/web-client/webdav' import { useResourcesStore } from '../../../../src/composables/piniaStores/resources' import { buildFilePreviewCacheKey, cacheService } from '../../../../src/services' @@ -10,6 +11,59 @@ describe('useResourcesStore', () => { cacheService.filePreview.clear() }) + describe('loadAncestorMetaData', () => { + const space = mock({ id: 'storage$space' }) + + const getClient = () => + mock({ + getFileInfo: vi.fn().mockImplementation((_space, { path }) => + Promise.resolve( + mock({ + fileId: `id-of-${path}`, + parentFolderId: 'parent', + shareTypes: [] + }) + ) + ) + }) + + it('stats every ancestor of the folder', async () => { + const store = useResourcesStore() + const client = getClient() + + await store.loadAncestorMetaData({ + folder: mock({ path: '/a/b/c', fileId: 'id-of-/a/b/c' }), + space, + client + }) + + const statted = vi.mocked(client.getFileInfo).mock.calls.map(([, ref]) => ref.path) + // the root is filled in from the space, not statted + expect(statted).toEqual(['/a/b', '/a']) + expect(store.ancestorMetaData['/a/b'].id).toBe('id-of-/a/b') + expect(store.ancestorMetaData['/a/b/c'].id).toBe('id-of-/a/b/c') + }) + + it('reuses what it already knows about the same space', async () => { + const store = useResourcesStore() + await store.loadAncestorMetaData({ + folder: mock({ path: '/a/b', fileId: 'id-of-/a/b' }), + space, + client: getClient() + }) + + const client = getClient() + await store.loadAncestorMetaData({ + folder: mock({ path: '/a/b/c', fileId: 'id-of-/a/b/c' }), + space, + client + }) + + const statted = vi.mocked(client.getFileInfo).mock.calls.map(([, ref]) => ref.path) + expect(statted).toEqual([]) + }) + }) + describe('preview releasing', () => { const file = mock({ id: '1', name: 'file.png', type: 'file' }) diff --git a/packages/web-pkg/tests/unit/services/client/graphListing.spec.ts b/packages/web-pkg/tests/unit/services/client/graphListing.spec.ts new file mode 100644 index 00000000000..70e49209426 --- /dev/null +++ b/packages/web-pkg/tests/unit/services/client/graphListing.spec.ts @@ -0,0 +1,110 @@ +import { SpaceResource } from '@opencloud-eu/web-client' +import { Graph } from '@opencloud-eu/web-client/graph' +import { DriveItem } from '@opencloud-eu/web-client/graph/generated' +import { listFilesViaGraph } from '../../../../src/services/client/graphListing' +const space = { + id: 'storage$space', + webDavPath: '/dav/spaces/storage$space', + root: { id: 'storage$space!root' } +} as unknown as SpaceResource + +const folder = { + id: 'storage$space!folder', + name: 'Fotos', + folder: {}, + parentReference: { id: 'storage$space!root', path: '/' }, + children: [ + { id: 'storage$space!child', name: 'bild.jpg', file: { mimeType: 'image/jpeg' }, size: 12 } + ] +} as DriveItem + +function getGraphClient(driveItem: DriveItem = folder) { + const statDriveItem = vi.fn().mockResolvedValue(driveItem) + return { + graphClient: { driveItems: { statDriveItem } } as unknown as Graph, + statDriveItem + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('listFilesViaGraph', () => { + it('addresses the drive root by id, it has no path lookup', async () => { + const { graphClient, statDriveItem } = getGraphClient() + + await listFilesViaGraph({ graphClient, space, path: '/', fileId: null, signal: null }) + + expect(statDriveItem).toHaveBeenCalledWith( + 'storage$space', + { itemId: 'storage$space!root' }, + expect.objectContaining({ expand: new Set(['children', 'thumbnails']) }), + { signal: null } + ) + }) + + it('prefers the file id over the path', async () => { + const { graphClient, statDriveItem } = getGraphClient() + + await listFilesViaGraph({ + graphClient, + space, + path: '/Fotos', + fileId: 'storage$space!folder', + signal: null + }) + + expect(statDriveItem.mock.calls[0][1]).toEqual({ itemId: 'storage$space!folder' }) + }) + + it('looks a folder up by path when there is no file id', async () => { + const { graphClient, statDriveItem } = getGraphClient() + + await listFilesViaGraph({ graphClient, space, path: '/Fotos', fileId: null, signal: null }) + + expect(statDriveItem.mock.calls[0][1]).toEqual({ path: '/Fotos' }) + }) + + it('builds the folder and its children in one go', async () => { + const { graphClient } = getGraphClient() + + const { resource, children } = await listFilesViaGraph({ + graphClient, + space, + path: '/Fotos', + fileId: null, + signal: null + }) + + expect(resource.path).toBe('/Fotos') + expect(resource.isFolder).toBe(true) + expect(children).toHaveLength(1) + expect(children[0].path).toBe('/Fotos/bild.jpg') + expect(children[0].mimeType).toBe('image/jpeg') + }) + + it('keeps the paths relative to the share root in a share space', async () => { + // graph answers in drive coordinates: the share root reports itself as + // "/folderToShare", while the space is rooted at exactly that item + const { graphClient } = getGraphClient({ + id: 'storage$space!shared', + name: 'folderToShare', + folder: {}, + parentReference: { path: '/' }, + children: [{ id: 'storage$space!child', name: 'lorem.txt' }] + } as DriveItem) + const shareSpace = { ...space, driveType: 'share' } as unknown as SpaceResource + + const { resource, children } = await listFilesViaGraph({ + graphClient, + space: shareSpace, + path: '/', + fileId: 'storage$space!shared', + signal: null + }) + + expect(resource.path).toBe('/') + expect(children[0].path).toBe('/lorem.txt') + }) +}) diff --git a/packages/web-pkg/tests/unit/services/client/graphWebDav.spec.ts b/packages/web-pkg/tests/unit/services/client/graphWebDav.spec.ts new file mode 100644 index 00000000000..1cb836d96a7 --- /dev/null +++ b/packages/web-pkg/tests/unit/services/client/graphWebDav.spec.ts @@ -0,0 +1,102 @@ +import { SpaceResource } from '@opencloud-eu/web-client' +import { WebDAV } from '@opencloud-eu/web-client/webdav' +import { Graph } from '@opencloud-eu/web-client/graph' +import { DriveItem } from '@opencloud-eu/web-client/graph/generated' +import { createGraphWebDav } from '../../../../src/services/client/graphWebDav' + +const space = { + id: 'storage$space', + webDavPath: '/dav/spaces/storage$space', + driveType: 'personal' +} as unknown as SpaceResource + +const file = { + id: 'storage$space!item', + name: 'lorem.txt', + file: { mimeType: 'text/plain' }, + parentReference: { path: '/Documents' } +} as DriveItem + +function getDav(driveItem: DriveItem = file) { + const statDriveItem = vi.fn().mockResolvedValue(driveItem) + const inner = { getFileInfo: vi.fn() } as unknown as WebDAV + const dav = createGraphWebDav( + inner, + () => ({ driveItems: { statDriveItem } }) as unknown as Graph + ) + return { dav, statDriveItem, inner } +} + +describe('createGraphWebDav', () => { + it('stats by id', async () => { + const { dav, statDriveItem, inner } = getDav() + + const resource = await dav.getFileInfo(space, { fileId: 'storage$space!item' }) + + expect(statDriveItem.mock.calls[0][0]).toBe('storage$space') + expect(statDriveItem.mock.calls[0][1]).toEqual({ itemId: 'storage$space!item' }) + expect(inner.getFileInfo).not.toHaveBeenCalled() + expect(resource.name).toBe('lorem.txt') + expect(resource.mimeType).toBe('text/plain') + }) + + it('stats by path and keeps the requested one', async () => { + const { dav, statDriveItem } = getDav() + + const resource = await dav.getFileInfo(space, { path: '/Documents/lorem.txt' }) + + expect(statDriveItem.mock.calls[0][1]).toEqual({ path: '/Documents/lorem.txt' }) + expect(resource.path).toBe('/Documents/lorem.txt') + }) + + it('derives the path from the item when only an id was given', async () => { + const { dav } = getDav() + + const resource = await dav.getFileInfo(space, { fileId: 'storage$space!item' }) + + expect(resource.path).toBe('/Documents/lorem.txt') + }) + + it('addresses a public link by the drive built from its token', async () => { + const { dav, statDriveItem } = getDav() + const publicSpace = { + ...space, + id: 'sometoken', + driveType: 'public' + } as unknown as SpaceResource + + await dav.getFileInfo(publicSpace, { path: '/' }) + + expect(statDriveItem.mock.calls[0][0]).toBe( + '7993447f-687f-490d-875c-ac95e89a62a4$7993447f-687f-490d-875c-ac95e89a62a4!sometoken' + ) + }) + + it('maps a needed link password onto the code the callers branch on', async () => { + const statDriveItem = vi.fn().mockRejectedValue({ + response: { + status: 401, + data: { error: { code: 'publicLinkPasswordRequired', message: 'password required' } } + } + }) + const dav = createGraphWebDav( + { getFileInfo: vi.fn() } as unknown as WebDAV, + () => ({ driveItems: { statDriveItem } }) as unknown as Graph + ) + + await expect(dav.getFileInfo(space, { path: '/' })).rejects.toMatchObject({ + statusCode: 401, + errorCode: 'ERR_MISSING_BASIC_AUTH' + }) + }) + + it('asks for the previews and the download url', async () => { + const { dav, statDriveItem } = getDav() + + await dav.getFileInfo(space, { fileId: 'storage$space!item' }) + + const options = statDriveItem.mock.calls[0][2] + expect(options.expand).toEqual(new Set(['thumbnails'])) + expect(options.select).toContain('@microsoft.graph.downloadUrl') + }) +}) diff --git a/packages/web-pkg/tests/unit/services/client/vaultWebDav.spec.ts b/packages/web-pkg/tests/unit/services/client/vaultWebDav.spec.ts index 7a9c9ba029b..34ea6be139b 100644 --- a/packages/web-pkg/tests/unit/services/client/vaultWebDav.spec.ts +++ b/packages/web-pkg/tests/unit/services/client/vaultWebDav.spec.ts @@ -11,7 +11,10 @@ import { vi.mock('../../../../src/composables/piniaStores/extensionRegistry', () => ({ useExtensionRegistry: vi.fn(() => ({})) })) -vi.mock('../../../../src/helpers/vault', () => ({ +// only the primitives are mocked, the translation on top of them (which moved +// to vaultTranslate so the graph listing can use it too) runs for real +vi.mock('../../../../src/helpers/vault', async (importOriginal) => ({ + ...(await importOriginal()), getVaultClaim: vi.fn(), resolveVaultEngine: vi.fn(), decryptResourceInPlace: vi.fn((_engine, r) => Promise.resolve(r)), diff --git a/packages/web-runtime/src/pages/resolvePublicLink.vue b/packages/web-runtime/src/pages/resolvePublicLink.vue index ec65192bb77..72c9880597b 100644 --- a/packages/web-runtime/src/pages/resolvePublicLink.vue +++ b/packages/web-runtime/src/pages/resolvePublicLink.vue @@ -217,14 +217,11 @@ const resolvePublicLinkTask = useTask(function* (signal, passwordRequired: boole }) /** - * A public link to a single file has no file id of its own, while a link to a folder does. - * The `public-link-item-type` dav property can't be used here, the server reports "folder" - * in both cases. + * The item type of the link root, which graph reports for what it is. Dav could + * not: it answered "folder" for a link to a single file as well, so the file id + * had to stand in for the distinction. */ -const isSingleFileLink = computed(() => { - const space = unref(loadedSpace) - return !space.fileId || space.fileId === space.id -}) +const isSingleFileLink = computed(() => unref(loadedSpace).publicLinkItemType === 'file') /** * For a public link pointing to a single file, the link root is the file itself. Since the root diff --git a/packages/web-runtime/tests/unit/pages/resolvePublicLink.spec.ts b/packages/web-runtime/tests/unit/pages/resolvePublicLink.spec.ts index 221fedc9161..daf53332e01 100644 --- a/packages/web-runtime/tests/unit/pages/resolvePublicLink.spec.ts +++ b/packages/web-runtime/tests/unit/pages/resolvePublicLink.spec.ts @@ -98,10 +98,11 @@ describe('resolvePublicLink', () => { canBeDeleted: () => false, canRestore: () => false } as Resource - // a link to a single file has no file id of its own + // the item behind the link tells a single file link apart const { mocks } = getWrapper({ redirectUrl: '', spaceFileId: 'token', + publicLinkItemType: 'file', children: [file] }) await flushPromises() @@ -146,12 +147,14 @@ function getWrapper({ getFileInfoErrorStatusCode = null, redirectUrl = 'redirectUrl', spaceFileId = 'folder-id', + publicLinkItemType = 'folder', children = [] }: { passwordRequired?: boolean getFileInfoErrorStatusCode?: number redirectUrl?: string spaceFileId?: string + publicLinkItemType?: 'file' | 'folder' children?: Resource[] } = {}) { const $clientService = mockDeep() @@ -160,7 +163,8 @@ function getWrapper({ fileId: spaceFileId, driveType: 'public', driveAlias: 'public/token', - isFolder: true, + publicLinkItemType, + isFolder: publicLinkItemType === 'folder', getDriveAliasAndItem: ({ path }: Resource) => urlJoin('public/token', path, { leadingSlash: false }) }) diff --git a/tests/e2e/support/api/http.ts b/tests/e2e/support/api/http.ts index 955b0ee7e4f..6755654ca33 100644 --- a/tests/e2e/support/api/http.ts +++ b/tests/e2e/support/api/http.ts @@ -7,7 +7,7 @@ import { TokenEnvironmentFactory } from '../environment' export const getAuthHeader = (user: User, isKeycloakRequest: boolean = false) => { const tokenEnvironment = TokenEnvironmentFactory(isKeycloakRequest ? 'keycloak' : null) const authHeader = { - Authorization: 'Basic ' + Buffer.from(user.id + ':' + user.password).toString('base64') + Authorization: 'Basic ' + Buffer.from(user.username + ':' + user.password).toString('base64') } if (!appConfig.basicAuth) { diff --git a/tests/e2e/support/objects/app-files/resource/actions.ts b/tests/e2e/support/objects/app-files/resource/actions.ts index ab8d2bd0c72..006cf5cec73 100644 --- a/tests/e2e/support/objects/app-files/resource/actions.ts +++ b/tests/e2e/support/objects/app-files/resource/actions.ts @@ -9,7 +9,7 @@ import { File, Space } from '../../../types' import { waitProcessingToFinish } from '../fileEvents' import { state } from '../../../../environment/shared' import { lstatSync, readFileSync } from 'fs' -import { encodeWebDavPath } from '../../../utils' +import { isFolderListingResponse, isResourceStatResponse } from '../../../utils/folderListing' const appLoadingSpinner = '#app-loading-spinner' const topbarFilenameSelector = '#app-top-bar-resource .oc-resource-name' @@ -223,9 +223,7 @@ const clickResourceInEmbedMode = async ({ } await resource.waitFor() - const waitResponse = page.waitForResponse( - (resp) => resp.status() === 207 && resp.request().method() === 'PROPFIND' - ) + const waitResponse = page.waitForResponse(isFolderListingResponse) await resource.click() await waitResponse @@ -248,14 +246,12 @@ export const clickResource = async ({ const folder = name.replace(/'/g, "\\'").replace(/"/g, '\\"') const resource = page.locator(util.format(resourceNameSelector, folder)) - const propfindPromise = page.waitForResponse( - (resp) => resp.status() === 207 && resp.request().method() === 'PROPFIND' - ) + const listingPromise = page.waitForResponse(isFolderListingResponse) await resource.click() if (password && folder.includes('.vault')) { await unlockVault({ page, passphrase: password }) } - await propfindPromise + await listingPromise // wait for the loading spinner to disappear and page is loaded await expect(page.locator('#app-loading-spinner')).toBeHidden() } @@ -275,9 +271,7 @@ export const clickResourceFromBreadcrumb = async ({ await Promise.all([ page.waitForResponse( (resp) => - (resp.status() === 207 && - resp.request().method() === 'PROPFIND' && - resp.url().endsWith(encodeURIComponent(resource))) || + isFolderListingResponse(resp) || resp.url().endsWith(itemId) || resp.url().endsWith(encodeURIComponent(itemId)) ), @@ -615,10 +609,7 @@ const createDocumentFile = async ( "Editor should be either 'Collabora' or 'Euro-Office' but found " + editorToOpen ) } - await Promise.all([ - page.waitForResponse((res) => res.status() === 207 && res.request().method() === 'PROPFIND'), - editor.close(page) - ]) + await Promise.all([page.waitForResponse(isFolderListingResponse), editor.close(page)]) await page.locator(util.format(resourceNameSelector, name)).waitFor() // wait for lock to be removed @@ -744,7 +735,7 @@ export const editTextDocument = async ({ await page.locator(textEditorPlainTextInput).fill(content) const [putRequest] = await Promise.all([ page.waitForResponse((resp) => resp.status() === 204 && resp.request().method() === 'PUT'), - page.waitForResponse((resp) => resp.status() === 207 && resp.request().method() === 'PROPFIND'), + page.waitForResponse(isFolderListingResponse), page.locator(saveTextFileInEditorButton).click() ]) @@ -780,11 +771,15 @@ const performUpload = async (args: uploadResourceArgs): Promise => { await clickResource({ page, path: to, password }) } - const respPromise = page.waitForResponse( - (resp) => - [201, 204].includes(resp.status()) && - ['POST', 'PUT', 'PATCH'].includes(resp.request().method()) - ) + // an upload that is expected to fail never produces this response, and a + // promise left pending rejects once the page closes + const respPromise = expectToFail + ? null + : page.waitForResponse( + (resp) => + [201, 204].includes(resp.status()) && + ['POST', 'PUT', 'PATCH'].includes(resp.request().method()) + ) const inputSelector = type === 'folder' ? folderUploadInput : fileUploadInput let uploadAction: Promise = page @@ -2229,12 +2224,7 @@ export const openFileInViewer = async (args: openFileInViewerArgs): Promise - resp.status() === 207 && - resp.request().method() === 'PROPFIND' && - resp.url().includes(encodeWebDavPath(name)) - ), + page.waitForResponse(isResourceStatResponse), page.locator(util.format(resourceNameSelector, name)).click() ]) } else { @@ -2259,7 +2249,7 @@ export const openFileInViewer = async (args: openFileInViewerArgs): Promise resp.status() === 207 && resp.request().method() === 'PROPFIND' + isResourceStatResponse ), page.locator(util.format(resourceNameSelector, name)).click() ]) @@ -2268,12 +2258,7 @@ export const openFileInViewer = async (args: openFileInViewerArgs): Promise - resp.status() === 207 && - resp.request().method() === 'PROPFIND' && - (!verifyPropfindPath || resp.url().includes(encodeWebDavPath(name))) - ), + page.waitForResponse(isResourceStatResponse), page.locator(util.format(resourceNameSelector, name)).click() ]) await page.locator(textEditorContainer).waitFor() diff --git a/tests/e2e/support/objects/app-files/spaces/actions.ts b/tests/e2e/support/objects/app-files/spaces/actions.ts index 54b3b0ed037..d50c6b31c3b 100644 --- a/tests/e2e/support/objects/app-files/spaces/actions.ts +++ b/tests/e2e/support/objects/app-files/spaces/actions.ts @@ -6,6 +6,7 @@ import Collaborator, { ICollaborator } from '../share/collaborator' import { createLink } from '../link/actions' import { File } from '../../../types' import { closeNotifications } from '../../../utils/closeNotifications' +import { isFolderListingResponse } from '../../../utils/folderListing' const newSpaceMenuButton = '.oc-app-floating-action-button' const spaceContextMenuButton = '#space-context-btn' @@ -220,7 +221,7 @@ export const changeSpaceDescription = async (args: { await page.locator(spacesDescriptionInputArea).fill(value) await Promise.all([ page.waitForResponse((resp) => resp.status() === 204 && resp.request().method() === 'PUT'), - page.waitForResponse((resp) => resp.status() === 207 && resp.request().method() === 'PROPFIND'), + page.waitForResponse(isFolderListingResponse), page.locator(spacesDescriptionSaveTextFileInEditorButton).click() ]) await editor.close(page) diff --git a/tests/e2e/support/utils/folderListing.ts b/tests/e2e/support/utils/folderListing.ts new file mode 100644 index 00000000000..47e18bead83 --- /dev/null +++ b/tests/e2e/support/utils/folderListing.ts @@ -0,0 +1,26 @@ +import { Response } from '@playwright/test' + +/** + * Matches the response that carries a resource, whichever API served it: a + * PROPFIND for what is still on webdav (the trash bin) and a driveItem stat + * for everything that moved to graph. + */ +export const isResourceStatResponse = (resp: Response): boolean => { + if (resp.request().method() === 'PROPFIND') { + return resp.status() === 207 + } + + return ( + resp.request().method() === 'GET' && + resp.status() === 200 && + /\/graph\/v1\.0\/drives\/[^/]+\/(items|root)/.test(resp.url()) + ) +} + +/** + * A stat that carries the folder's children, so a listing rather than a single + * resource. + */ +export const isFolderListingResponse = (resp: Response): boolean => + isResourceStatResponse(resp) && + (resp.request().method() === 'PROPFIND' || resp.url().includes('expand=children')) diff --git a/tests/e2e/support/utils/index.ts b/tests/e2e/support/utils/index.ts index b69bcac81e9..93afe149ff1 100644 --- a/tests/e2e/support/utils/index.ts +++ b/tests/e2e/support/utils/index.ts @@ -5,3 +5,4 @@ export * from './dragDrop' export * from './datePicker' export * from './tokenHelper' export * from './urlJoin' +export * from './folderListing'