diff --git a/changelog/unreleased/enhancement-add-sse-events-for-sharing b/changelog/unreleased/enhancement-add-sse-events-for-sharing new file mode 100644 index 00000000000..581f6c9e398 --- /dev/null +++ b/changelog/unreleased/enhancement-add-sse-events-for-sharing @@ -0,0 +1,7 @@ +Enhancement: Add SSE event for moving + +We've added Server-Sent Events (SSE) for sharing. +This notifies the user when they received or revoked access to a share or membership to a space. + +https://github.com/owncloud/web/pull/10807 +https://github.com/owncloud/web/issues/10647 diff --git a/packages/web-client/src/sse/index.ts b/packages/web-client/src/sse/index.ts index 6514eec4788..867c0ff4de8 100644 --- a/packages/web-client/src/sse/index.ts +++ b/packages/web-client/src/sse/index.ts @@ -10,7 +10,16 @@ export enum MESSAGE_TYPE { ITEM_TRASHED = 'item-trashed', ITEM_RESTORED = 'item-restored', ITEM_MOVED = 'item-moved', - FOLDER_CREATED = 'folder-created' + FOLDER_CREATED = 'folder-created', + SPACE_MEMBER_ADDED = 'space-member-added', + SPACE_MEMBER_REMOVED = 'space-member-removed', + SPACE_SHARE_UPDATED = 'space-share-updated', + SHARE_CREATED = 'share-created', + SHARE_REMOVED = 'share-removed', + SHARE_UPDATED = 'share-updated', + LINK_CREATED = 'link-created', + LINK_REMOVED = 'link-removed', + LINK_UPDATED = 'link-updated' } export class RetriableError extends Error { diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index 20b3216814f..90ac7932cc1 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -27,7 +27,8 @@ import { useResourcesStore, ResourcesStore, SpacesStore, - MessageStore + MessageStore, + SharesStore } from '@ownclouders/web-pkg' import { authService } from '../services/auth' import { @@ -62,7 +63,18 @@ import { onSSEItemTrashedEvent, onSSEFolderCreatedEvent, onSSEFileTouchedEvent, - onSSEItemMovedEvent + onSSEItemMovedEvent, + onSSESpaceMemberAddedEvent, + onSSESpaceMemberRemovedEvent, + onSSESpaceShareUpdatedEvent, + onSSEShareCreatedEvent, + onSSEShareRemovedEvent, + onSSEShareUpdatedEvent, + onSSELinkCreatedEvent, + onSSELinkRemovedEvent, + sseEventWrapper, + SseEventWrapperOptions, + onSSELinkUpdatedEvent } from './sse' const getEmbedConfigFromQuery = ( @@ -633,6 +645,7 @@ export const registerSSEEventListeners = ({ resourcesStore, spacesStore, messageStore, + sharesStore, clientService, previewService, configStore, @@ -643,6 +656,7 @@ export const registerSSEEventListeners = ({ resourcesStore: ResourcesStore spacesStore: SpacesStore messageStore: MessageStore + sharesStore: SharesStore clientService: ClientService previewService: PreviewService configStore: ConfigStore @@ -660,101 +674,178 @@ export const registerSSEEventListeners = ({ } ) + const sseEventWrapperOptions = { + resourcesStore, + spacesStore, + messageStore, + userStore, + sharesStore, + clientService, + previewService, + language, + router, + resourceQueue + } satisfies Partial + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.ITEM_RENAMED, (msg) => - onSSEItemRenamedEvent({ + sseEventWrapper({ topic: MESSAGE_TYPE.ITEM_RENAMED, - resourcesStore, - spacesStore, msg, - clientService, - router + ...sseEventWrapperOptions, + method: onSSEItemRenamedEvent }) ) clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.POSTPROCESSING_FINISHED, (msg) => - onSSEProcessingFinishedEvent({ + sseEventWrapper({ topic: MESSAGE_TYPE.POSTPROCESSING_FINISHED, - resourcesStore, - spacesStore, msg, - clientService, - previewService, - resourceQueue + ...sseEventWrapperOptions, + method: onSSEProcessingFinishedEvent }) ) clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.FILE_LOCKED, (msg) => - onSSEFileLockingEvent({ + sseEventWrapper({ topic: MESSAGE_TYPE.FILE_LOCKED, - resourcesStore, - spacesStore, - userStore, msg, - clientService + ...sseEventWrapperOptions, + method: onSSEFileLockingEvent }) ) clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.FILE_UNLOCKED, (msg) => - onSSEFileLockingEvent({ + sseEventWrapper({ topic: MESSAGE_TYPE.FILE_UNLOCKED, - resourcesStore, - spacesStore, - userStore, msg, - clientService + ...sseEventWrapperOptions, + method: onSSEFileLockingEvent }) ) clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.ITEM_TRASHED, (msg) => - onSSEItemTrashedEvent({ + sseEventWrapper({ topic: MESSAGE_TYPE.ITEM_TRASHED, - language, - resourcesStore, - clientService, - messageStore, - msg + msg, + ...sseEventWrapperOptions, + method: onSSEItemTrashedEvent }) ) clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.ITEM_RESTORED, (msg) => - onSSEItemRestoredEvent({ + sseEventWrapper({ topic: MESSAGE_TYPE.ITEM_RESTORED, - resourcesStore, - spacesStore, - userStore, msg, - clientService + ...sseEventWrapperOptions, + method: onSSEItemRestoredEvent }) ) clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.ITEM_MOVED, (msg) => - onSSEItemMovedEvent({ + sseEventWrapper({ topic: MESSAGE_TYPE.ITEM_MOVED, - resourcesStore, - spacesStore, - userStore, msg, - clientService + ...sseEventWrapperOptions, + method: onSSEItemMovedEvent }) ) clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.FOLDER_CREATED, (msg) => - onSSEFolderCreatedEvent({ + sseEventWrapper({ topic: MESSAGE_TYPE.FOLDER_CREATED, - resourcesStore, - spacesStore, msg, - clientService + ...sseEventWrapperOptions, + method: onSSEFolderCreatedEvent }) ) clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.FILE_TOUCHED, (msg) => - onSSEFileTouchedEvent({ + sseEventWrapper({ topic: MESSAGE_TYPE.FILE_TOUCHED, - resourcesStore, - spacesStore, msg, - clientService + ...sseEventWrapperOptions, + method: onSSEFileTouchedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.SPACE_MEMBER_ADDED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.SPACE_MEMBER_ADDED, + msg, + ...sseEventWrapperOptions, + method: onSSESpaceMemberAddedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.SPACE_MEMBER_REMOVED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.SPACE_MEMBER_REMOVED, + msg, + ...sseEventWrapperOptions, + method: onSSESpaceMemberRemovedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.SPACE_SHARE_UPDATED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.SPACE_SHARE_UPDATED, + msg, + ...sseEventWrapperOptions, + method: onSSESpaceShareUpdatedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.SHARE_CREATED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.SHARE_CREATED, + msg, + ...sseEventWrapperOptions, + method: onSSEShareCreatedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.SHARE_REMOVED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.SHARE_REMOVED, + msg, + ...sseEventWrapperOptions, + method: onSSEShareRemovedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.SHARE_UPDATED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.SHARE_UPDATED, + msg, + ...sseEventWrapperOptions, + method: onSSEShareUpdatedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.LINK_CREATED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.LINK_CREATED, + msg, + ...sseEventWrapperOptions, + method: onSSELinkCreatedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.LINK_REMOVED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.LINK_REMOVED, + msg, + ...sseEventWrapperOptions, + method: onSSELinkRemovedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.LINK_UPDATED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.LINK_UPDATED, + msg, + ...sseEventWrapperOptions, + method: onSSELinkUpdatedEvent }) ) } diff --git a/packages/web-runtime/src/container/sse.ts b/packages/web-runtime/src/container/sse.ts deleted file mode 100644 index ac5259ce4da..00000000000 --- a/packages/web-runtime/src/container/sse.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { - ClientService, - createFileRouteOptions, - getIndicators, - ImageDimension, - MessageStore, - PreviewService, - ResourcesStore, - SpacesStore, - UserStore -} from '@ownclouders/web-pkg' -import PQueue from 'p-queue' -import { extractNodeId, extractStorageId } from '@ownclouders/web-client' -import { z } from 'zod' -import { Router } from 'vue-router' -import { Language } from 'vue3-gettext' - -const eventSchema = z.object({ - itemid: z.string(), - parentitemid: z.string(), - spaceid: z.string().optional(), - initiatorid: z.string().optional(), - etag: z.string().optional() -}) - -const itemInCurrentFolder = ({ - resourcesStore, - parentFolderId -}: { - resourcesStore: ResourcesStore - parentFolderId: string -}) => { - const currentFolder = resourcesStore.currentFolder - if (!currentFolder) { - return false - } - - if (!extractNodeId(currentFolder.id)) { - // if we don't have a nodeId here, we have a space (root) as current folder and can only check against the storageId - if (currentFolder.id !== extractStorageId(parentFolderId)) { - return false - } - } else { - if (currentFolder.id !== parentFolderId) { - return false - } - } - - return true -} - -export const onSSEItemRenamedEvent = async ({ - topic, - resourcesStore, - spacesStore, - msg, - clientService, - router -}: { - topic: string - resourcesStore: ResourcesStore - spacesStore: SpacesStore - msg: MessageEvent - clientService: ClientService - router: Router -}) => { - try { - const sseData = eventSchema.parse(JSON.parse(msg.data)) - console.debug(`SSE event '${topic}'`, sseData) - - if (sseData.initiatorid === clientService.initiatorId) { - /** - * If the request was initiated by the current client (browser tab), - * there's no need to proceed with the action since the web already - * handles its own business logic. Therefore, we'll return early here. - */ - return - } - - const currentFolder = resourcesStore.currentFolder - const resourceIsCurrentFolder = currentFolder.id === sseData.itemid - const resource = resourceIsCurrentFolder - ? currentFolder - : resourcesStore.resources.find((f) => f.id === sseData.itemid) - const space = spacesStore.spaces.find((s) => s.id === resource.storageId) - - if (!resource || !space) { - return - } - - const updatedResource = await clientService.webdav.getFileInfo(space, { - fileId: sseData.itemid - }) - - if (resourceIsCurrentFolder) { - resourcesStore.setCurrentFolder(updatedResource) - return router.push( - createFileRouteOptions(space, { - path: updatedResource.path, - fileId: updatedResource.fileId - }) - ) - } - - resourcesStore.upsertResource(updatedResource) - } catch (e) { - console.error(`Unable to parse sse event ${topic} data`, e) - } -} - -export const onSSEFileLockingEvent = async ({ - topic, - resourcesStore, - spacesStore, - userStore, - msg, - clientService -}: { - topic: string - resourcesStore: ResourcesStore - spacesStore: SpacesStore - userStore: UserStore - msg: MessageEvent - clientService: ClientService -}) => { - try { - const sseData = eventSchema.parse(JSON.parse(msg.data)) - console.debug(`SSE event '${topic}'`, sseData) - - const resource = resourcesStore.resources.find((f) => f.id === sseData.itemid) - const space = spacesStore.spaces.find((s) => s.id === resource.storageId) - - if (!resource || !space) { - return - } - - const updatedResource = await clientService.webdav.getFileInfo(space, { - fileId: sseData.itemid - }) - - resourcesStore.upsertResource(updatedResource) - resourcesStore.updateResourceField({ - id: updatedResource.id, - field: 'indicators', - value: getIndicators({ - space, - resource: updatedResource, - ancestorMetaData: resourcesStore.ancestorMetaData, - user: userStore.user - }) - }) - } catch (e) { - console.error(`Unable to parse sse event ${topic} data`, e) - } -} - -export const onSSEProcessingFinishedEvent = async ({ - topic, - resourcesStore, - spacesStore, - msg, - clientService, - resourceQueue, - previewService -}: { - topic: string - resourcesStore: ResourcesStore - spacesStore: SpacesStore - msg: MessageEvent - clientService: ClientService - resourceQueue: PQueue - previewService: PreviewService -}) => { - try { - const sseData = eventSchema.parse(JSON.parse(msg.data)) - console.debug(`SSE event '${topic}'`, sseData) - - if (!itemInCurrentFolder({ resourcesStore, parentFolderId: sseData.parentitemid })) { - return false - } - const resource = resourcesStore.resources.find((f) => f.id === sseData.itemid) - - /** - * If resource is not loaded, it suggests an upload is in progress. - */ - if (!resource) { - if (sseData.initiatorid === clientService.initiatorId) { - /** - * If the upload is initiated by the current client, - * there's no necessity to retrieve the resources again. - */ - return - } - - return resourceQueue.add(async () => { - const { resource } = await clientService.webdav.listFilesById({ - fileId: sseData.itemid - }) - resourcesStore.upsertResource(resource) - }) - } - - /** - * Resource not changed, don't fetch more data - */ - if (resource.etag === sseData.etag) { - return resourcesStore.updateResourceField({ - id: sseData.itemid, - field: 'processing', - value: false - }) - } - - const space = spacesStore.spaces.find((s) => s.id === sseData.spaceid) - if (!space) { - return - } - - const updatedResource = await clientService.webdav.getFileInfo(space, { - fileId: sseData.itemid - }) - resourcesStore.upsertResource(updatedResource) - - const preview = await previewService.loadPreview({ - resource, - space, - dimensions: ImageDimension.Thumbnail - }) - - if (preview) { - resourcesStore.updateResourceField({ - id: sseData.itemid, - field: 'thumbnail', - value: preview - }) - } - } catch (e) { - console.error(`Unable to parse sse event ${topic} data`, e) - } -} - -export const onSSEItemTrashedEvent = ({ - topic, - language, - messageStore, - resourcesStore, - clientService, - msg -}: { - topic: string - language: Language - resourcesStore: ResourcesStore - clientService: ClientService - messageStore: MessageStore - msg: MessageEvent -}) => { - try { - const sseData = eventSchema.parse(JSON.parse(msg.data)) - console.debug(`SSE event '${topic}'`, sseData) - - if (sseData.initiatorid === clientService.initiatorId) { - /** - * If the request was initiated by the current client (browser tab), - * there's no need to proceed with the action since the web already - * handles its own business logic. Therefore, we'll return early here. - */ - return - } - - const currentFolder = resourcesStore.currentFolder - const resourceIsCurrentFolder = currentFolder.id === sseData.itemid - - if (resourceIsCurrentFolder) { - return messageStore.showMessage({ - title: language.$gettext( - 'The folder you were accessing has been removed. Please navigate to another location.' - ) - }) - } - - const resource = resourcesStore.resources.find((f) => f.id === sseData.itemid) - - if (!resource) { - return - } - - resourcesStore.removeResources([resource]) - } catch (e) { - console.error(`Unable to parse sse event ${topic} data`, e) - } -} - -export const onSSEItemRestoredEvent = async ({ - topic, - resourcesStore, - spacesStore, - userStore, - msg, - clientService -}: { - topic: string - resourcesStore: ResourcesStore - spacesStore: SpacesStore - userStore: UserStore - msg: MessageEvent - clientService: ClientService -}) => { - try { - const sseData = eventSchema.parse(JSON.parse(msg.data)) - console.debug(`SSE event '${topic}'`, sseData) - - if (sseData.initiatorid === clientService.initiatorId) { - /** - * If the request was initiated by the current client (browser tab), - * there's no need to proceed with the action since the web already - * handles its own business logic. Therefore, we'll return early here. - */ - return - } - - const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) - if (!space) { - return - } - - const resource = await clientService.webdav.getFileInfo(space, { - fileId: sseData.itemid - }) - - if (!resource) { - return - } - - if (!itemInCurrentFolder({ resourcesStore, parentFolderId: resource.parentFolderId })) { - return false - } - - resourcesStore.upsertResource(resource) - resourcesStore.updateResourceField({ - id: resource.id, - field: 'indicators', - value: getIndicators({ - space, - resource, - ancestorMetaData: resourcesStore.ancestorMetaData, - user: userStore.user - }) - }) - } catch (e) { - console.error(`Unable to parse sse event ${topic} data`, e) - } -} - -export const onSSEItemMovedEvent = async ({ - topic, - resourcesStore, - spacesStore, - userStore, - msg, - clientService -}: { - topic: string - resourcesStore: ResourcesStore - spacesStore: SpacesStore - userStore: UserStore - msg: MessageEvent - clientService: ClientService -}) => { - try { - const sseData = eventSchema.parse(JSON.parse(msg.data)) - console.debug(`SSE event '${topic}'`, sseData) - - if (sseData.initiatorid === clientService.initiatorId) { - /** - * If the request was initiated by the current client (browser tab), - * there's no need to proceed with the action since the web already - * handles its own business logic. Therefore, we'll return early here. - */ - return - } - - const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) - if (!space) { - return - } - - const resource = await clientService.webdav.getFileInfo(space, { - fileId: sseData.itemid - }) - - if (!resource) { - return - } - - if (resource.parentFolderId !== resourcesStore.currentFolder.id) { - return resourcesStore.removeResources([resource]) - } - - resourcesStore.upsertResource(resource) - resourcesStore.updateResourceField({ - id: resource.id, - field: 'indicators', - value: getIndicators({ - resource, - space, - user: userStore.user, - ancestorMetaData: resourcesStore.ancestorMetaData - }) - }) - } catch (e) { - console.error(`Unable to parse sse event ${topic} data`, e) - } -} - -/** - * The FileTouched event is triggered when a new empty file, such as a new text file, - * is about to be created on the server. This event is necessary because the - * post-processing event won't be triggered in this case. - */ -export const onSSEFileTouchedEvent = async ({ - topic, - resourcesStore, - spacesStore, - msg, - clientService -}: { - topic: string - resourcesStore: ResourcesStore - spacesStore: SpacesStore - msg: MessageEvent - clientService: ClientService -}) => { - try { - const sseData = eventSchema.parse(JSON.parse(msg.data)) - console.debug(`SSE event '${topic}'`, sseData) - - if (sseData.initiatorid === clientService.initiatorId) { - /** - * If the request was initiated by the current client (browser tab), - * there's no need to proceed with the action since the web already - * handles its own business logic. Therefore, we'll return early here. - */ - return - } - - const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) - if (!space) { - return - } - - const resource = await clientService.webdav.getFileInfo(space, { - fileId: sseData.itemid - }) - - if (!resource) { - return - } - - if (!itemInCurrentFolder({ resourcesStore, parentFolderId: resource.parentFolderId })) { - return false - } - - resourcesStore.upsertResource(resource) - } catch (e) { - console.error(`Unable to parse sse event ${topic} data`, e) - } -} - -export const onSSEFolderCreatedEvent = async ({ - topic, - resourcesStore, - spacesStore, - msg, - clientService -}: { - topic: string - resourcesStore: ResourcesStore - spacesStore: SpacesStore - msg: MessageEvent - clientService: ClientService -}) => { - try { - const sseData = eventSchema.parse(JSON.parse(msg.data)) - console.debug(`SSE event '${topic}'`, sseData) - - if (sseData.initiatorid === clientService.initiatorId) { - /** - * If the request was initiated by the current client (browser tab), - * there's no need to proceed with the action since the web already - * handles its own business logic. Therefore, we'll return early here. - */ - return - } - - const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) - if (!space) { - return - } - - const resource = await clientService.webdav.getFileInfo(space, { - fileId: sseData.itemid - }) - - if (!resource) { - return - } - - if (!itemInCurrentFolder({ resourcesStore, parentFolderId: resource.parentFolderId })) { - return false - } - - resourcesStore.upsertResource(resource) - } catch (e) { - console.error(`Unable to parse sse event ${topic} data`, e) - } -} diff --git a/packages/web-runtime/src/container/sse/files.ts b/packages/web-runtime/src/container/sse/files.ts new file mode 100644 index 00000000000..7e5e2a87564 --- /dev/null +++ b/packages/web-runtime/src/container/sse/files.ts @@ -0,0 +1,326 @@ +import { createFileRouteOptions, getIndicators, ImageDimension } from '@ownclouders/web-pkg' +import { SSEEventOptions } from './types' +import { isItemInCurrentFolder } from './helpers' + +export const onSSEItemRenamedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + clientService, + router +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + const currentFolder = resourcesStore.currentFolder + const resourceIsCurrentFolder = currentFolder?.id === sseData.itemid + const resource = resourceIsCurrentFolder + ? currentFolder + : resourcesStore.resources.find((f) => f.id === sseData.itemid) + const space = spacesStore.spaces.find((s) => s.id === resource.storageId) + + if (!resource || !space) { + return + } + + const updatedResource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + if (resourceIsCurrentFolder) { + resourcesStore.setCurrentFolder(updatedResource) + return router.push( + createFileRouteOptions(space, { + path: updatedResource.path, + fileId: updatedResource.fileId + }) + ) + } + + resourcesStore.upsertResource(updatedResource) +} + +export const onSSEFileLockingEvent = async ({ + sseData, + resourcesStore, + spacesStore, + userStore, + clientService +}: SSEEventOptions) => { + const resource = resourcesStore.resources.find((f) => f.id === sseData.itemid) + const space = spacesStore.spaces.find((s) => s.id === resource.storageId) + + if (!resource || !space) { + return + } + + const updatedResource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + resourcesStore.upsertResource(updatedResource) + resourcesStore.updateResourceField({ + id: updatedResource.id, + field: 'indicators', + value: getIndicators({ + space, + resource: updatedResource, + ancestorMetaData: resourcesStore.ancestorMetaData, + user: userStore.user + }) + }) +} + +export const onSSEProcessingFinishedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + clientService, + resourceQueue, + previewService +}: SSEEventOptions) => { + if (!isItemInCurrentFolder({ resourcesStore, parentFolderId: sseData.parentitemid })) { + return false + } + const resource = resourcesStore.resources.find((f) => f.id === sseData.itemid) + + /** + * If resource is not loaded, it suggests an upload is in progress. + */ + if (!resource) { + if (sseData.initiatorid === clientService.initiatorId) { + /** + * If the upload is initiated by the current client, + * there's no necessity to retrieve the resources again. + */ + return + } + + return resourceQueue.add(async () => { + const { resource } = await clientService.webdav.listFilesById({ + fileId: sseData.itemid + }) + resourcesStore.upsertResource(resource) + }) + } + + /** + * Resource not changed, don't fetch more data + */ + if (resource.etag === sseData.etag) { + return resourcesStore.updateResourceField({ + id: sseData.itemid, + field: 'processing', + value: false + }) + } + + const space = spacesStore.spaces.find((s) => s.id === sseData.spaceid) + if (!space) { + return + } + + const updatedResource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + resourcesStore.upsertResource(updatedResource) + + const preview = await previewService.loadPreview({ + resource, + space, + dimensions: ImageDimension.Thumbnail + }) + + if (preview) { + resourcesStore.updateResourceField({ + id: sseData.itemid, + field: 'thumbnail', + value: preview + }) + } +} + +export const onSSEItemTrashedEvent = ({ + sseData, + language, + messageStore, + resourcesStore, + clientService +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + const currentFolder = resourcesStore.currentFolder + const resourceIsCurrentFolder = currentFolder?.id === sseData.itemid + + if (resourceIsCurrentFolder) { + return messageStore.showMessage({ + title: language.$gettext( + 'The folder you were accessing has been removed. Please navigate to another location.' + ) + }) + } + + const resource = resourcesStore.resources.find((f) => f.id === sseData.itemid) + + if (!resource) { + return + } + + resourcesStore.removeResources([resource]) +} + +export const onSSEItemRestoredEvent = async ({ + sseData, + resourcesStore, + spacesStore, + userStore, + clientService +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + if (!resource) { + return + } + + if (!isItemInCurrentFolder({ resourcesStore, parentFolderId: resource.parentFolderId })) { + return false + } + + resourcesStore.upsertResource(resource) + resourcesStore.updateResourceField({ + id: resource.id, + field: 'indicators', + value: getIndicators({ + space, + resource, + ancestorMetaData: resourcesStore.ancestorMetaData, + user: userStore.user + }) + }) +} + +export const onSSEItemMovedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + userStore, + clientService +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + if (!resource) { + return + } + + if (resource.parentFolderId !== resourcesStore.currentFolder?.id) { + return resourcesStore.removeResources([resource]) + } + + resourcesStore.upsertResource(resource) + resourcesStore.updateResourceField({ + id: resource.id, + field: 'indicators', + value: getIndicators({ + resource, + space, + user: userStore.user, + ancestorMetaData: resourcesStore.ancestorMetaData + }) + }) +} + +/** + * The FileTouched event is triggered when a new empty file, such as a new text file, + * is about to be created on the server. This event is necessary because the + * post-processing event won't be triggered in this case. + */ +export const onSSEFileTouchedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + clientService +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + if (!resource) { + return + } + + if (!isItemInCurrentFolder({ resourcesStore, parentFolderId: resource.parentFolderId })) { + return false + } + + resourcesStore.upsertResource(resource) +} + +export const onSSEFolderCreatedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + clientService +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + if (!resource) { + return + } + + if (!isItemInCurrentFolder({ resourcesStore, parentFolderId: resource.parentFolderId })) { + return false + } + + resourcesStore.upsertResource(resource) +} diff --git a/packages/web-runtime/src/container/sse/helpers.ts b/packages/web-runtime/src/container/sse/helpers.ts new file mode 100644 index 00000000000..ab63e7449a5 --- /dev/null +++ b/packages/web-runtime/src/container/sse/helpers.ts @@ -0,0 +1,41 @@ +import { ResourcesStore } from '@ownclouders/web-pkg' +import { extractNodeId } from '@ownclouders/web-client' +import { eventSchema, SseEventWrapperOptions } from './types' + +export const sseEventWrapper = (options: SseEventWrapperOptions) => { + const { topic, msg, method, ...sseEventOptions } = options + try { + const sseData = eventSchema.parse(JSON.parse(msg.data)) + console.debug(`SSE event '${topic}'`, sseData) + + return method({ ...sseEventOptions, sseData }) + } catch (e) { + console.error(`Unable to process sse event ${topic}`, e) + } +} +export const isItemInCurrentFolder = ({ + resourcesStore, + parentFolderId +}: { + resourcesStore: ResourcesStore + parentFolderId: string +}) => { + const currentFolder = resourcesStore.currentFolder + if (!currentFolder) { + return false + } + + if (!extractNodeId(currentFolder.id)) { + // if we don't have a nodeId here, we have a space (root) as current folder and can only check against the storageId + const spaceNodeId = currentFolder.id.split('$')[1] + if (`${currentFolder.id}!${spaceNodeId}` !== parentFolderId) { + return false + } + } else { + if (currentFolder.id !== parentFolderId) { + return false + } + } + + return true +} diff --git a/packages/web-runtime/src/container/sse/index.ts b/packages/web-runtime/src/container/sse/index.ts new file mode 100644 index 00000000000..721b07344f2 --- /dev/null +++ b/packages/web-runtime/src/container/sse/index.ts @@ -0,0 +1,4 @@ +export * from './files' +export * from './shares' +export * from './types' +export * from './helpers' diff --git a/packages/web-runtime/src/container/sse/shares.ts b/packages/web-runtime/src/container/sse/shares.ts new file mode 100644 index 00000000000..5585f54296f --- /dev/null +++ b/packages/web-runtime/src/container/sse/shares.ts @@ -0,0 +1,403 @@ +import { + buildIncomingShareResource, + buildOutgoingShareResource, + buildSpace, + ShareTypes +} from '@ownclouders/web-client' +import { + eventBus, + getIndicators, + isLocationSharesActive, + isLocationSpacesActive +} from '@ownclouders/web-pkg' +import { SSEEventOptions } from './types' +import { isItemInCurrentFolder } from './helpers' + +export const onSSESpaceMemberAddedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + clientService, + router +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + const { data } = await clientService.graphAuthenticated.drives.getDrive(sseData.itemid) + const space = buildSpace(data) + spacesStore.upsertSpace(space) + + if (!isLocationSpacesActive(router, 'files-spaces-projects')) { + return + } + + resourcesStore.upsertResource(space) +} + +export const onSSESpaceMemberRemovedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + messageStore, + clientService, + language, + userStore, + router +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + if (!sseData.affecteduserids?.includes(userStore.user.id)) { + const { data } = await clientService.graphAuthenticated.drives.listMyDrives( + '', + `id eq '${sseData.spaceid}'` + ) + const space = buildSpace(data.value[0]) + return spacesStore.upsertSpace(space) + } + + const removedSpace = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!removedSpace) { + return + } + + spacesStore.removeSpace(removedSpace) + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + removedSpace.id === resourcesStore.currentFolder.storageId + ) { + // Fixme: Message triggers when user membership was revoked, but still is in a group membership, which is wrong + return messageStore.showMessage({ + title: language.$gettext( + 'Your access to this space has been revoked. Please navigate to another location.' + ) + }) + } + + if (isLocationSpacesActive(router, 'files-spaces-projects')) { + return resourcesStore.removeResources([removedSpace]) + } +} + +export const onSSESpaceShareUpdatedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + clientService, + userStore, + router +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + const { data } = await clientService.graphAuthenticated.drives.getDrive(sseData.itemid) + const space = buildSpace(data) + spacesStore.upsertSpace(space) + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + sseData.affecteduserids?.includes(userStore.user.id) && + resourcesStore.currentFolder?.storageId === sseData.spaceid + ) { + return eventBus.publish('app.files.list.load') + } +} + +export const onSSEShareCreatedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + sharesStore, + userStore, + clientService, + router +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + isItemInCurrentFolder({ + resourcesStore, + parentFolderId: sseData.parentitemid + }) + ) { + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + resourcesStore.upsertResource(resource) + return resourcesStore.updateResourceField({ + id: resource.id, + field: 'indicators', + value: getIndicators({ + space, + resource, + ancestorMetaData: resourcesStore.ancestorMetaData, + user: userStore.user + }) + }) + } + + if (isLocationSharesActive(router, 'files-shares-with-me')) { + // FIXME: get drive item by id as soon as server supports it + const { data } = await clientService.graphAuthenticated.drives.listSharedWithMe() + const driveItem = data.value.find(({ remoteItem }) => remoteItem.id === sseData.itemid) + if (!driveItem) { + return + } + const resource = buildIncomingShareResource({ driveItem, graphRoles: sharesStore.graphRoles }) + return resourcesStore.upsertResource(resource) + } + + if (isLocationSharesActive(router, 'files-shares-with-others')) { + // FIXME: get drive item by id as soon as server supports it + const { data } = await clientService.graphAuthenticated.drives.listSharedByMe() + const driveItem = data.value.find(({ id }) => id === sseData.itemid) + if (!driveItem) { + return + } + const resource = buildOutgoingShareResource({ driveItem, user: userStore.user }) + return resourcesStore.upsertResource(resource) + } +} +export const onSSEShareUpdatedEvent = async ({ + sseData, + resourcesStore, + sharesStore, + clientService, + userStore, + router +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + sseData.affecteduserids?.includes(userStore.user.id) && + resourcesStore.currentFolder?.storageId === sseData.spaceid + ) { + return eventBus.publish('app.files.list.load') + } + + if (isLocationSharesActive(router, 'files-shares-with-me')) { + // FIXME: get drive item by id as soon as server supports it + const { data } = await clientService.graphAuthenticated.drives.listSharedWithMe() + const driveItem = data.value.find(({ remoteItem }) => remoteItem.id === sseData.itemid) + if (!driveItem) { + return + } + const resource = buildIncomingShareResource({ driveItem, graphRoles: sharesStore.graphRoles }) + return resourcesStore.upsertResource(resource) + } +} + +export const onSSEShareRemovedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + userStore, + clientService, + messageStore, + language, + router +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + sseData.affecteduserids?.includes(userStore.user.id) && + resourcesStore.currentFolder?.storageId === sseData.spaceid + ) { + // Fixme: Message triggers when user share was revoked, but still is in a group share, which is wrong + return messageStore.showMessage({ + title: language.$gettext( + 'Your access to this share has been revoked. Please navigate to another location.' + ) + }) + } + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + isItemInCurrentFolder({ + resourcesStore, + parentFolderId: sseData.parentitemid + }) + ) { + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + resourcesStore.upsertResource(resource) + return resourcesStore.updateResourceField({ + id: resource.id, + field: 'indicators', + value: getIndicators({ + space, + resource, + ancestorMetaData: resourcesStore.ancestorMetaData, + user: userStore.user + }) + }) + } + + if (isLocationSharesActive(router, 'files-shares-with-others')) { + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + if ( + !resource.shareTypes.includes(ShareTypes.user.value) && + !resource.shareTypes.includes(ShareTypes.group.value) + ) { + return resourcesStore.removeResources([resource]) + } + } + + if (isLocationSharesActive(router, 'files-shares-with-me')) { + const removedShareResource = resourcesStore.resources.find((r) => r.fileId === sseData.itemid) + if (!removedShareResource) { + return + } + return resourcesStore.removeResources([removedShareResource]) + } +} + +export const onSSELinkCreatedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + userStore, + clientService, + router +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + isItemInCurrentFolder({ + resourcesStore, + parentFolderId: sseData.parentitemid + }) + ) { + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + resourcesStore.upsertResource(resource) + return resourcesStore.updateResourceField({ + id: resource.id, + field: 'indicators', + value: getIndicators({ + space, + resource, + ancestorMetaData: resourcesStore.ancestorMetaData, + user: userStore.user + }) + }) + } + + if (isLocationSharesActive(router, 'files-shares-via-link')) { + // FIXME: get drive item by id as soon as server supports it + const { data } = await clientService.graphAuthenticated.drives.listSharedByMe() + const driveItem = data.value.find(({ id }) => id === sseData.itemid) + if (!driveItem) { + return + } + const resource = buildOutgoingShareResource({ driveItem, user: userStore.user }) + return resourcesStore.upsertResource(resource) + } +} + +export const onSSELinkUpdatedEvent = ({ sseData, clientService }: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } +} + +export const onSSELinkRemovedEvent = async ({ + sseData, + resourcesStore, + spacesStore, + userStore, + clientService, + router +}: SSEEventOptions) => { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + isItemInCurrentFolder({ + resourcesStore, + parentFolderId: sseData.parentitemid + }) + ) { + resourcesStore.upsertResource(resource) + resourcesStore.updateResourceField({ + id: resource.id, + field: 'indicators', + value: getIndicators({ + space, + resource, + ancestorMetaData: resourcesStore.ancestorMetaData, + user: userStore.user + }) + }) + } + + if (isLocationSharesActive(router, 'files-shares-via-link')) { + if (!resource.shareTypes.includes(ShareTypes.link.value)) { + return resourcesStore.removeResources([resource]) + } + } +} diff --git a/packages/web-runtime/src/container/sse/types.ts b/packages/web-runtime/src/container/sse/types.ts new file mode 100644 index 00000000000..441cdf2e7e7 --- /dev/null +++ b/packages/web-runtime/src/container/sse/types.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { + ClientService, + MessageStore, + PreviewService, + ResourcesStore, + SharesStore, + SpacesStore, + UserStore +} from '@ownclouders/web-pkg' +import { Router } from 'vue-router' +import { Language } from 'vue3-gettext' +import PQueue from 'p-queue' + +export const eventSchema = z.object({ + itemid: z.string(), + parentitemid: z.string(), + spaceid: z.string().optional(), + initiatorid: z.string().optional(), + etag: z.string().optional(), + affecteduserids: z.array(z.string()).optional().nullable() +}) + +export type EventSchemaType = z.infer + +export interface SSEEventOptions { + resourcesStore: ResourcesStore + spacesStore: SpacesStore + userStore: UserStore + messageStore: MessageStore + sharesStore: SharesStore + clientService: ClientService + previewService: PreviewService + router: Router + language: Language + resourceQueue: PQueue + sseData: EventSchemaType +} + +export interface SseEventWrapperOptions extends Omit { + msg: MessageEvent + topic: string + method: (options: SSEEventOptions) => Promise | unknown +} diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts index 320e8513bc5..0a8c48b4249 100644 --- a/packages/web-runtime/src/index.ts +++ b/packages/web-runtime/src/index.ts @@ -192,6 +192,7 @@ export const bootstrapApp = async (configurationPath: string): Promise => resourcesStore, spacesStore, messageStore: messagesStore, + sharesStore, clientService, userStore, previewService,