diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index b38b8c026..2fc2c2004 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -32,6 +32,9 @@ type GuiEvent> = { response: R; }; +// The entry arrives under several aliases, for handlers of any vintage. +type FsCreateEvent = { node: FSEntry; entry: FSEntry; uid: string }; + /** * Extension-augmentable half of {@link EventMap}. Extensions that emit their own * events declare the payload here by declaration merging, so both the emitter @@ -342,6 +345,18 @@ export type EventMap = { }; 'fs.remove.node': { node: FSEntry; entry: FSEntry; target: FSEntry }; 'fs.write.file': { node: FSEntry; entry: FSEntry; target: FSEntry }; + /** A new entry, one key per flavor; `fs.write.file` is the overwrite. */ + 'fs.create.file': FsCreateEvent; + 'fs.create.directory': FsCreateEvent; + 'fs.create.shortcut': FsCreateEvent; + 'fs.create.symlink': FsCreateEvent; + /** In-place name change. A move emits `fs.move.node` instead. */ + 'fs.rename': FsCreateEvent & { + old_name: string; + new_name: string; + old_path: string; + new_path: string; + }; 'fs.storage.upload-progress': { upload_tracker: unknown; context: unknown; @@ -435,6 +450,7 @@ export type EventMap = { 'outer.gui.item.moved': GuiEvent; 'outer.gui.item.pending': GuiEvent; 'outer.gui.item.removed': GuiEvent; + 'outer.gui.item.renamed': GuiEvent; 'outer.gui.notif.ack': GuiEvent<{ uid: string }>; 'outer.gui.notif.persisted': GuiEvent<{ uid: string }>; 'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>; diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index db937cbd4..11a52850d 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -18,7 +18,7 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { Actor } from '../../core/actor.js'; import { runWithContext } from '../../core/context.js'; import { PuterServer } from '../../server.js'; @@ -1462,26 +1462,35 @@ describe('ShareService', () => { /** Collect one GUI event's audiences for the life of the callback. */ const captureAudiences = async ( event: + | 'outer.gui.item.added' | 'outer.gui.item.removed' | 'outer.gui.item.moved' + | 'outer.gui.item.renamed' | 'outer.gui.item.updated', - uuid: string, + /** Null accepts any entry — for events whose uuid isn't known yet. */ + uuid: string | null, fn: () => Promise, ) => { const seen: number[][] = []; - server.clients.event.on(event, (_key, data) => { + const listener = (_key: string, data: unknown) => { const payload = data as { user_id_list?: number[]; response?: { uuid?: string }; }; - if (payload.response?.uuid !== uuid) return; + if (uuid !== null && payload.response?.uuid !== uuid) return; seen.push(payload.user_id_list ?? []); - }); - await fn(); - for (let i = 0; i < 50 && seen.length === 0; i++) { - await new Promise((resolve) => setTimeout(resolve, 20)); + }; + server.clients.event.on(event, listener); + try { + await fn(); + for (let i = 0; i < 50 && seen.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return seen; + } finally { + // An `uuid: null` capture would match later tests' events. + server.clients.event.off(event, listener); } - return seen; }; it('tells a recipient when a shared file is deleted', async () => { @@ -1510,6 +1519,279 @@ describe('ShareService', () => { expect(audiences.flat()).toContain(recipient.user.id); }); + it('tells a folder recipient when a file inside it is deleted', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // No share on the file, so the audience is only found upward. + const payload = await capturePayload( + 'outer.gui.item.removed', + recipient.user.id, + async () => { + await server.services.fs.remove(owner.user.id, { + entry: file, + }); + }, + ); + + // Named by the path they knew, masked through the folder they hold. + expect(payload?.path).toBe( + `/${owner.user.username}/${dir.uuid}/${dir.name}/${file.name}`, + ); + }); + + it('tells a recipient once when they hold both the file and its folder', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + for (const uid of [dir.uuid, file.uuid]) { + await share(owner.actor, { + uid, + recipient: { email: recipient.email }, + mode: 'read', + }); + } + + // Both passes reach them; one delete must not arrive as two. + const audiences = await captureAudiences( + 'outer.gui.item.removed', + file.uuid, + async () => { + await server.services.fs.remove(owner.user.id, { + entry: file, + }); + }, + ); + + const told = audiences + .flat() + .filter((id) => id === recipient.user.id); + expect(told).toHaveLength(1); + }); + + it('tells a folder recipient when a file is moved out of it', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + const before = file.path; + const after = `/${owner.user.username}/Trash/${file.name}`; + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // What Delete does; `item.moved` would name a path they can't see. + const audiences = await captureAudiences( + 'outer.gui.item.removed', + file.uuid, + async () => { + await server.clients.event.emitAndWait( + 'fs.move.node', + { + node: { ...file, path: after }, + fromPath: before, + toPath: after, + }, + {}, + ); + }, + ); + + expect(audiences.flat()).toContain(recipient.user.id); + }); + + it('names a moved-out file by where the recipient last saw it', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + const before = file.path; + const after = `/${owner.user.username}/Trash/${file.name}`; + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const payload = await capturePayload( + 'outer.gui.item.removed', + recipient.user.id, + async () => { + await server.clients.event.emitAndWait( + 'fs.move.node', + { + node: { ...file, path: after }, + fromPath: before, + toPath: after, + }, + {}, + ); + }, + ); + + // Masked through the folder they hold, not the owner's Trash. + expect(payload?.path).toBe( + `/${owner.user.username}/${dir.uuid}/${dir.name}/${file.name}`, + ); + }); + + it('tells a folder recipient when a file is moved into it', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + const loose = await makeFile(owner.user); + const after = `${dir.path}/${loose.name}`; + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // They never had it, so there is nothing for `item.moved` to move. + const audiences = await captureAudiences( + 'outer.gui.item.added', + loose.uuid, + async () => { + await server.clients.event.emitAndWait( + 'fs.move.node', + { + node: { ...loose, path: after }, + fromPath: loose.path, + toPath: after, + }, + {}, + ); + }, + ); + + expect(audiences.flat()).toContain(recipient.user.id); + }); + + it('still reports a move within the shared folder as a move', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + const before = file.path; + const after = `${dir.path}/renamed-${file.name}`; + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const payload = await capturePayload( + 'outer.gui.item.moved', + recipient.user.id, + async () => { + await server.clients.event.emitAndWait( + 'fs.move.node', + { + node: { ...file, path: after }, + fromPath: before, + toPath: after, + }, + {}, + ); + }, + ); + + const masked = `/${owner.user.username}/${dir.uuid}/${dir.name}`; + expect(payload?.from_path).toBe(`${masked}/${file.name}`); + expect(payload?.path).toBe(`${masked}/renamed-${file.name}`); + }); + + it('tells a recipient when the shared item itself is trashed', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + const before = file.path; + const trashName = uuidv4(); + const trashed = `/${owner.user.username}/Trash/${trashName}`; + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // The grant follows it into Trash, so both ends would resolve. + const payload = await capturePayload( + 'outer.gui.item.removed', + recipient.user.id, + async () => { + await server.clients.event.emitAndWait( + 'fs.move.node', + { + node: { + ...file, + path: trashed, + name: trashName, + }, + fromPath: before, + toPath: trashed, + }, + {}, + ); + }, + ); + + // Named as they knew it, never the GUID that Trash gave it. + expect(payload?.path).toBe( + `/${owner.user.username}/${file.uuid}/${file.name}`, + ); + }); + + it('stays quiet when a move leaves the recipient address alone', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + const elsewhere = `/${owner.user.username}/Documents/${file.name}`; + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const seen: unknown[] = []; + const listener = (_key: string, data: unknown) => { + const payload = data as { user_id_list?: number[] }; + if (!payload.user_id_list?.includes(recipient.user.id)) return; + seen.push(payload); + }; + server.clients.event.on('outer.gui.item.moved', listener); + + try { + // Masked at its own root, so their path holds wherever it goes. + await server.clients.event.emitAndWait( + 'fs.move.node', + { + node: { ...file, path: elsewhere }, + fromPath: file.path, + toPath: elsewhere, + }, + {}, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + } finally { + server.clients.event.off('outer.gui.item.moved', listener); + } + + expect(seen).toEqual([]); + }); + it('tells a folder recipient when a file inside it changes', async () => { const owner = await makeUser(); const recipient = await makeUser(); @@ -1538,6 +1820,423 @@ describe('ShareService', () => { expect(audiences.flat()).toContain(recipient.user.id); }); + it('tells a folder recipient when a file appears inside it', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // The real create path, not the event the fan-out listens for. + let created: { uuid: string } | undefined; + const audiences = await captureAudiences( + 'outer.gui.item.added', + null, + async () => { + created = await server.services.fs.touch(owner.user.id, { + path: `${dir.path}/appeared.txt`, + }); + }, + ); + expect(created?.uuid).toEqual(expect.any(String)); + + expect(audiences.flat()).toContain(recipient.user.id); + }); + + it('addresses a new file by the path the recipient listed', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const paths: string[] = []; + server.clients.event.on('outer.gui.item.added', (_key, data) => { + const payload = data as { + user_id_list?: number[]; + response?: { path?: string }; + }; + if (!payload.user_id_list?.includes(recipient.user.id)) return; + paths.push(String(payload.response?.path)); + }); + + await server.services.fs.touch(owner.user.id, { + path: `${dir.path}/inside.txt`, + }); + for (let i = 0; i < 50 && paths.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + // The parent has to be the folder as the recipient addresses it. + expect(paths).toEqual([ + `/${owner.user.username}/${dir.uuid}/${dir.name}/inside.txt`, + ]); + // And never the owner's real path. + expect(paths[0]).not.toContain(dir.path); + }); + + it('tells a recipient when a shared file is renamed', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const audiences = await captureAudiences( + 'outer.gui.item.renamed', + file.uuid, + async () => { + await server.services.fs.rename( + owner.user.id, + file, + 'renamed.txt', + ); + }, + ); + + expect(audiences.flat()).toContain(recipient.user.id); + }); + + /** The one payload sent to `holder` for `event`, or undefined. */ + const capturePayload = async ( + event: + | 'outer.gui.item.moved' + | 'outer.gui.item.removed' + | 'outer.gui.item.renamed', + holderId: number, + fn: () => Promise, + ) => { + const seen: Record[] = []; + server.clients.event.on(event, (_key, data) => { + const payload = data as { + user_id_list?: number[]; + response?: Record; + }; + if (!payload.user_id_list?.includes(holderId)) return; + seen.push(payload.response ?? {}); + }); + await fn(); + for (let i = 0; i < 50 && seen.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return seen[0]; + }; + + it('asks who the audience is once per folder, not once per file', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const seen: number[][] = []; + const listener = (_key: string, data: unknown) => { + const payload = data as { user_id_list?: number[] }; + if (!payload.user_id_list?.includes(recipient.user.id)) return; + seen.push(payload.user_id_list); + }; + server.clients.event.on('outer.gui.item.added', listener); + const reaching = vi.spyOn(server.stores.share, 'listReaching'); + let lookups = -1; + + try { + // What an upload looks like: siblings landing together. + await Promise.all( + Array.from({ length: 8 }, (_, i) => + server.services.fs.touch(owner.user.id, { + path: `${dir.path}/bulk-${i}.txt`, + }), + ), + ); + for (let i = 0; i < 50 && seen.length < 8; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + // Read before restoring: `mockRestore` clears the history. + lookups = reaching.mock.calls.length; + } finally { + server.clients.event.off('outer.gui.item.added', listener); + reaching.mockRestore(); + } + + // Every file is announced, but they share one lookup. + expect(seen).toHaveLength(8); + expect(lookups).toBe(1); + }); + + it('asks once per folder when a whole subtree is deleted', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + await Promise.all( + Array.from({ length: 8 }, (_, i) => + server.services.fs.touch(owner.user.id, { + path: `${dir.path}/doomed-${i}.txt`, + }), + ), + ); + const files = await Promise.all( + Array.from({ length: 8 }, (_, i) => + server.stores.fsEntry.getEntryByPath( + `${dir.path}/doomed-${i}.txt`, + ), + ), + ); + + const seen: number[][] = []; + const listener = (_key: string, data: unknown) => { + const payload = data as { user_id_list?: number[] }; + if (!payload.user_id_list?.includes(recipient.user.id)) return; + seen.push(payload.user_id_list); + }; + server.clients.event.on('outer.gui.item.removed', listener); + const reaching = vi.spyOn(server.stores.share, 'listReaching'); + let lookups = -1; + + try { + await Promise.all( + files.map((entry) => + server.services.fs.remove(owner.user.id, { entry }), + ), + ); + for (let i = 0; i < 50 && seen.length < 8; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + lookups = reaching.mock.calls.length; + } finally { + server.clients.event.off('outer.gui.item.removed', listener); + reaching.mockRestore(); + } + + // Siblings share a parent, so a burst settles in a flush or two. + expect(seen).toHaveLength(8); + expect(lookups).toBeLessThanOrEqual(2); + }); + + it('stays quiet for a recipient whose grant was revoked', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // As `/auth/revoke-user-user` does; the share row survives. + await server.services.permission.revokeUserUserPermission( + owner.actor, + recipient.user.username!, + `fs:${dir.uuid}:read`, + ); + + const seen: unknown[] = []; + const listener = (_key: string, data: unknown) => { + const payload = data as { user_id_list?: number[] }; + if (!payload.user_id_list?.includes(recipient.user.id)) return; + seen.push(payload); + }; + server.clients.event.on('outer.gui.item.added', listener); + server.clients.event.on('outer.gui.item.updated', listener); + + try { + await server.services.fs.touch(owner.user.id, { + path: `${dir.path}/after-revoke.txt`, + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + } finally { + server.clients.event.off('outer.gui.item.added', listener); + server.clients.event.off('outer.gui.item.updated', listener); + } + + expect(seen).toEqual([]); + }); + + it('checks no grants when nothing is shared', async () => { + const owner = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + // This guards every write on the server, shared or not. + const linked = vi.spyOn( + server.stores.permission, + 'readLinkedUserUserPerms', + ); + let reads = -1; + try { + await server.services.fs.touch(owner.user.id, { + path: `${dir.path}/unshared.txt`, + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + reads = linked.mock.calls.length; + } finally { + linked.mockRestore(); + } + + expect(reads).toBe(0); + }); + + it('stays quiet for an event another node already handled', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const seen: unknown[] = []; + const listener = (_key: string, data: unknown) => seen.push(data); + server.clients.event.on('outer.gui.item.added', listener); + server.clients.event.on('outer.gui.item.updated', listener); + try { + // What replication looks like: the writing node already told + // this audience, so a second fan-out would only duplicate it. + await server.clients.event.emitAndWait( + 'fs.create.file', + { node: file, entry: file, uid: file.uuid }, + { from_outside: true }, + ); + await server.clients.event.emitAndWait( + 'fs.write.file', + { node: file, entry: file, target: file }, + { from_outside: true }, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + } finally { + server.clients.event.off('outer.gui.item.added', listener); + server.clients.event.off('outer.gui.item.updated', listener); + } + + expect(seen).toEqual([]); + }); + + it('says where a directly shared file moved from', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + const before = file.path; + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const payload = await capturePayload( + 'outer.gui.item.moved', + recipient.user.id, + async () => { + await server.clients.event.emitAndWait( + 'fs.move.node', + { + node: { + ...file, + path: `${before}-moved`, + name: `${file.name}-moved`, + }, + fromPath: before, + toPath: `${before}-moved`, + }, + {}, + ); + }, + ); + + // Shared at the file itself, so the old path resolves by uuid. + expect(payload?.from_path).toBe( + `/${owner.user.username}/${file.uuid}/${file.name}`, + ); + }); + + it('says what a renamed file was called', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + const before = file.path; + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const payload = await capturePayload( + 'outer.gui.item.renamed', + recipient.user.id, + async () => { + await server.services.fs.rename( + owner.user.id, + file, + 'after.txt', + ); + }, + ); + + expect(payload?.old_path).toBe( + `/${owner.user.username}/${file.uuid}/${file.name}`, + ); + expect(String(payload?.old_path)).not.toContain(before); + }); + + it('carries the container the desktop renders into', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const seen: Record[] = []; + server.clients.event.on('outer.gui.item.added', (_key, data) => { + const payload = data as { + user_id_list?: number[]; + response?: Record; + }; + if (!payload.user_id_list?.includes(recipient.user.id)) return; + seen.push(payload.response ?? {}); + }); + + await server.services.fs.touch(owner.user.id, { + path: `${dir.path}/rendered.txt`, + }); + for (let i = 0; i < 50 && seen.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + // The desktop finds the open window by `dirpath`. + expect(seen[0]?.dirpath).toBe( + `/${owner.user.username}/${dir.uuid}/${dir.name}`, + ); + }); + it('tells a recipient when a shared file moves', async () => { const owner = await makeUser(); const recipient = await makeUser(); @@ -1556,7 +2255,11 @@ describe('ShareService', () => { await server.clients.event.emitAndWait( 'fs.move.node', { - node: file, + node: { + ...file, + path: `${file.path}-moved`, + name: `${file.name}-moved`, + }, fromPath: file.path, toPath: `${file.path}-moved`, }, diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index c91c38e89..39c6535b3 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -198,28 +198,81 @@ const blocksAllShares = (user: Pick | null): boolean => * * Curated rather than the row: the row carries the owner's real path, their * numeric id, storage internals and the capability tokens — none of which are a - * recipient's to see. The path is the entry masked against itself, which always - * resolves; running outside a request, there is no per-request masker to - * consult for a deeper root. + * recipient's to see. The path is masked at `root`, the share they reach it + * through, so it matches what their own reads returned. */ -const holderPayload = (entry: FSEntry): Record => ({ - uid: entry.uuid, - uuid: entry.uuid, - name: entry.name, - path: maskedSelfPath(entry, entry.path), - is_dir: Boolean(entry.isDir), - size: entry.size ?? null, - modified: entry.modified, - from_new_service: true, -}); +const holderPayload = ( + entry: FSEntry, + /** Defaults to the entry itself, for the paths with no root to mask by. */ + root: FSEntry = entry, + /** Where the entry was; differs from `entry.path` once it has moved. */ + realPath: string = entry.path, +): Record => { + const path = + maskedPathVia(root, realPath) ?? maskedSelfPath(entry, realPath); + return { + uid: entry.uuid, + uuid: entry.uuid, + name: entry.name, + path, + // The desktop finds the container to render into by `dirpath`. + dirpath: pathPosix.dirname(path), + is_dir: Boolean(entry.isDir), + type: entry.isDir ? 'folder' : contentTypeFromMime(entry.name) || null, + immutable: Boolean(entry.immutable), + size: entry.size ?? null, + modified: entry.modified, + from_new_service: true, + }; +}; + +/** + * An `fs.*` event replayed onto this bus by replication rather than raised by a + * write here. Broadcast only carries `outer.*` and `pubsub.*`, so today nothing + * reaches these handlers that way — but the node that did the write has already + * told the audience, and a second fan-out would only duplicate it. + */ +const fromAnotherNode = (meta?: { from_outside?: boolean }): boolean => + Boolean(meta?.from_outside); + +/** The GUI events a share recipient is an audience for. */ +type HolderGuiEvent = + | 'outer.gui.item.added' + | 'outer.gui.item.moved' + | 'outer.gui.item.removed' + | 'outer.gui.item.renamed' + | 'outer.gui.item.updated'; /** `///` for a path in the owner's tree. */ +/** Inside some owner's top-level Trash, which is where Delete puts things. */ +const isTrashedPath = (path: string): boolean => + /^\/[^/]+\/Trash(\/|$)/u.test(path); + const maskedSelfPath = (entry: FSEntry, realPath: string): string => { const owner = realPath.split('/')[1]; const name = realPath.split('/').pop(); return owner && name ? `/${owner}/${entry.uuid}/${name}` : realPath; }; +/** Where `entry` was, as this holder knew it; `root` carries the new path. */ +const maskedFormerPath = ( + root: FSEntry, + entry: FSEntry, + realPath: string, +): string | null => + maskedPathVia(root, realPath) ?? + (root.uuid === entry.uuid ? maskedSelfPath(entry, realPath) : null); + +/** `realPath` as a holder of `root` addresses it; null when outside that share. */ +const maskedPathVia = (root: FSEntry, realPath: string): string | null => { + const owner = root.path.split('/')[1]; + if (!owner || !root.name) return null; + const base = `/${owner}/${root.uuid}/${root.name}`; + if (realPath === root.path) return base; + if (!realPath.startsWith(`${root.path}/`)) return null; + return base + realPath.slice(root.path.length); +}; + // -- ShareService ----------------------------------------------------- /** @@ -239,6 +292,9 @@ export class ShareService extends PuterService { #pendingRetire = new Map(); /** The in-flight flush, shared by everything buffered for it. */ #retireFlush: Promise | null = null; + /** New entries awaiting the next fan-out, by parent path. */ + #pendingCreates = new Map(); + #createFlush: Promise | null = null; /** * FS mutations only notify the owner, leaving a recipient's open window @@ -247,7 +303,8 @@ export class ShareService extends PuterService { * first and cannot depend on this service. */ override onServerStart(): void { - this.clients.event.on('fs.remove.node', (_key, data) => { + this.clients.event.on('fs.remove.node', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; const entry = (data as { node?: FSEntry })?.node; if (!entry?.uuid) return; // Returned so an `emitAndWait` caller can observe the cleanup; the @@ -261,19 +318,15 @@ export class ShareService extends PuterService { }); }); - this.clients.event.on('fs.move.node', (_key, data) => { + this.clients.event.on('fs.move.node', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; const { node, fromPath, fromUserId } = (data ?? {}) as { node?: FSEntry; fromPath?: string; fromUserId?: number; }; if (!node?.uuid) return; - const notify = this.#fanOutToHolders(node, 'outer.gui.item.moved', { - ...holderPayload(node), - from_path: fromPath - ? maskedSelfPath(node, fromPath) - : undefined, - }).catch(() => { + const notify = this.#fanOutMove(node, fromPath).catch(() => { // A stale window is better than a failed move. }); @@ -334,16 +387,43 @@ export class ShareService extends PuterService { return claimFor(user_id, new_email); }); - this.clients.event.on('fs.write.file', (_key, data) => { + this.clients.event.on('fs.write.file', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; const entry = (data as { node?: FSEntry })?.node; if (!entry?.uuid) return; + return this.#fanOutToHolders(entry, 'outer.gui.item.updated').catch( + () => { + // Same — never fail a write over its notification. + }, + ); + }); + + // A create is `fs.create.`, not `fs.write.file`. + this.clients.event.on('fs.create.*', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; + const entry = (data as { node?: FSEntry })?.node; + if (!entry?.uuid) return; + return this.#scheduleCreateFanOut(entry).catch(() => {}); + }); + + this.clients.event.on('fs.rename', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; + const { node: entry, old_path: oldPath } = (data ?? {}) as { + node?: FSEntry; + old_path?: string; + }; + if (!entry?.uuid) return; return this.#fanOutToHolders( entry, - 'outer.gui.item.updated', - holderPayload(entry), - ).catch(() => { - // Same — never fail a write over its notification. - }); + 'outer.gui.item.renamed', + (root) => { + // The GUI rewrites descendants and open windows by it. + const from = oldPath + ? maskedFormerPath(root, entry, oldPath) + : null; + return from ? { old_path: from } : {}; + }, + ).catch(() => {}); }); } @@ -371,6 +451,42 @@ export class ShareService extends PuterService { return this.#retireFlush; } + /** Same buffering for creates, by parent: siblings share one lookup. */ + #scheduleCreateFanOut(entry: FSEntry): Promise { + const parent = pathPosix.dirname(entry.path); + this.#pendingCreates.set(parent, [ + ...(this.#pendingCreates.get(parent) ?? []), + entry, + ]); + this.#createFlush ??= new Promise((resolve, reject) => { + setImmediate(() => { + const batch = this.#pendingCreates; + this.#pendingCreates = new Map(); + this.#createFlush = null; + this.#flushCreates(batch).then(resolve, reject); + }); + }); + return this.#createFlush; + } + + async #flushCreates(batch: Map): Promise { + for (const entries of batch.values()) { + const first = entries[0]; + if (!first) continue; + // Safe from one sibling: nothing holds a share on an entry this new. + const groups = await this.#reachingRoots(first); + for (const { root, holders } of groups) { + for (const entry of entries) { + await this.#emitGui( + 'outer.gui.item.added', + holders, + holderPayload(entry, root), + ); + } + } + } + } + /** * Retire the grants, then tell the recipients. The revoke reports exactly * who lost access, which the index can no longer answer — its rows cascade @@ -379,6 +495,7 @@ export class ShareService extends PuterService { */ async #flushRetire(entries: FSEntry[]): Promise { const ownerOf = new Map(entries.map((e) => [e.uuid, e])); + const notified = new Map>(); for (let i = 0; i < entries.length; i += RETIRE_CHUNK_SIZE) { const chunk = entries.slice(i, i + RETIRE_CHUNK_SIZE); @@ -406,28 +523,201 @@ export class ShareService extends PuterService { [...holders], holderPayload(entry), ); + notified.set(uuid, holders); } } + + await this.#fanOutRetiredToAncestors(entries, notified); + } + + /** + * Tell whoever reached these through a folder above them — their grant is + * on that folder, so the revoke reports no holder for them. Coalesced by + * parent like creates, so a subtree stays a few queries. + */ + async #fanOutRetiredToAncestors( + entries: FSEntry[], + notified: Map>, + ): Promise { + const byParent = new Map(); + for (const entry of entries) { + const parent = pathPosix.dirname(entry.path); + byParent.set(parent, [...(byParent.get(parent) ?? []), entry]); + } + + for (const siblings of byParent.values()) { + const first = siblings[0]; + if (!first) continue; + // Ancestors only; a share on the entry is the revoke's to report. + const groups = (await this.#reachingRoots(first)).filter( + ({ root }) => root.uuid !== first.uuid, + ); + for (const { root, holders } of groups) { + for (const entry of siblings) { + const unheard = holders.filter( + (holder) => !notified.get(entry.uuid)?.has(holder), + ); + if (!unheard.length) continue; + await this.#emitGui( + 'outer.gui.item.removed', + unheard, + holderPayload(entry, root), + ); + } + } + } + } + + /** + * A move seen from both ends, since it can take an item out of a share as + * easily as into it: both ends is a move, only the destination an arrival, + * only the origin a removal. + */ + async #fanOutMove(entry: FSEntry, fromPath?: string): Promise { + const destination = await this.#reachingRoots(entry); + const origin = + fromPath && fromPath !== entry.path + ? await this.#reachingRoots(entry, fromPath) + : destination; + + const rootsBySide = ( + groups: Array<{ root: FSEntry; holders: number[] }>, + ) => + new Map( + groups.flatMap(({ root, holders }) => + holders.map((holder) => [holder, root] as const), + ), + ); + const originRootOf = rootsBySide(origin); + const destinationRootOf = rootsBySide(destination); + + // Keyed on both: a holder can see each end through a different share. + const batches = new Map< + string, + { + event: HolderGuiEvent; + from?: FSEntry; + to?: FSEntry; + holders: number[]; + } + >(); + const place = ( + holder: number, + event: HolderGuiEvent, + from?: FSEntry, + to?: FSEntry, + ) => { + const key = `${event}|${from?.id ?? ''}|${to?.id ?? ''}`; + const batch = batches.get(key) ?? { event, from, to, holders: [] }; + batch.holders.push(holder); + batches.set(key, batch); + }; + + // A grant follows its entry into Trash, so Delete would read as a move + // and rename their item to the GUID Trash gave it. Listings omit it. + const nowTrashed = isTrashedPath(entry.path); + const wasTrashed = fromPath ? isTrashedPath(fromPath) : false; + + for (const [holder, to] of destinationRootOf) { + if (nowTrashed) continue; + const from = originRootOf.get(holder); + place( + holder, + from && !wasTrashed + ? 'outer.gui.item.moved' + : 'outer.gui.item.added', + from, + to, + ); + } + for (const [holder, from] of originRootOf) { + if (nowTrashed) { + // Already gone from their view if it was trashed before. + if (!wasTrashed) place(holder, 'outer.gui.item.removed', from); + continue; + } + if (destinationRootOf.has(holder)) continue; + place(holder, 'outer.gui.item.removed', from); + } + + for (const { event, from, to, holders } of batches.values()) { + if (event === 'outer.gui.item.removed') { + // Named by where they last saw it, which is all they have. + await this.#emitGui( + event, + holders, + holderPayload(entry, from as FSEntry, fromPath), + ); + continue; + } + const payload = holderPayload(entry, to as FSEntry); + const formerPath = + from && fromPath + ? maskedFormerPath(from, entry, fromPath) + : null; + // A share masks its own root, so this move is invisible to them. + if (formerPath && formerPath === payload.path) continue; + await this.#emitGui(event, holders, { + ...payload, + // The GUI rewrites the item it already has by this. + ...(formerPath ? { from_path: formerPath } : {}), + }); + } } async #fanOutToHolders( entry: FSEntry, - event: 'outer.gui.item.moved' | 'outer.gui.item.updated', - response: Record, + event: HolderGuiEvent, + extrasFor: (root: FSEntry) => Record = () => ({}), ): Promise { // Ancestors too: someone given a folder sees what happens inside it, // and the changed file itself carries no share of its own. - const rows = await this.#sharesReaching(entry); - const holders = [ - ...new Set( - rows.map((row: { holder_user_id: number }) => - Number(row.holder_user_id), - ), - ), - ].filter((id) => Number.isFinite(id) && id !== entry.userId); - if (holders.length === 0) return; + const groups = await this.#reachingRoots(entry); + // One event per root: the path depends on the share they came through. + for (const { root, holders } of groups) { + await this.#emitGui(event, holders, { + ...holderPayload(entry, root), + ...extrasFor(root), + }); + } + } - await this.#emitGui(event, holders, response); + /** Holders who can reach `entry`, grouped by the share they came through. */ + async #reachingRoots( + entry: FSEntry, + /** Where to look from; the former path once the entry has moved. */ + realPath: string = entry.path, + ): Promise> { + const { rows, nodesById } = await this.#sharesReaching(entry, realPath); + // An index row outlives the grant it records, so a recipient revoked + // through the ACL alone would keep receiving pushes. Free when nothing + // is shared, which is the path every write takes. + const live = await this.#reachingHolders(rows, nodesById); + + // Deepest root wins, so a holder with nested shares is told once. + const rootByHolder = new Map(); + for (const row of rows) { + const holderId = Number(row.holder_user_id); + const root = nodesById.get(Number(row.fsentry_id)); + if (!root || !Number.isFinite(holderId)) continue; + if (holderId === entry.userId) continue; + if (!live.has(`${holderId}:${root.id}`)) continue; + const current = rootByHolder.get(holderId); + if (!current || root.path.length > current.path.length) { + rootByHolder.set(holderId, root); + } + } + + const holdersByRoot = new Map< + number, + { root: FSEntry; holders: number[] } + >(); + for (const [holderId, root] of rootByHolder) { + const group = holdersByRoot.get(root.id) ?? { root, holders: [] }; + group.holders.push(holderId); + holdersByRoot.set(root.id, group); + } + return [...holdersByRoot.values()]; } /** @@ -442,10 +732,14 @@ export class ShareService extends PuterService { */ async #sharesReaching( entry: FSEntry, - ): Promise> { + realPath: string = entry.path, + ): Promise<{ + rows: ShareIndexRow[]; + nodesById: Map; + }> { const ancestorPaths: string[] = []; for ( - let cursor = pathPosix.dirname(entry.path); + let cursor = pathPosix.dirname(realPath); cursor !== '/' && cursor !== '.'; cursor = pathPosix.dirname(cursor) ) { @@ -455,18 +749,19 @@ export class ShareService extends PuterService { ancestorPaths.length > 0 ? await this.stores.fsEntry.getEntriesByPaths(ancestorPaths) : new Map(); - const ids = [ - entry.id, - ...[...ancestors.values()].map((node) => node.id), - ].filter((id): id is number => typeof id === 'number'); - return this.stores.share.listReaching(ids); + const nodesById = new Map( + [entry, ...ancestors.values()] + .filter((node) => typeof node.id === 'number') + .map((node) => [node.id, node]), + ); + return { + rows: await this.stores.share.listReaching([...nodesById.keys()]), + nodesById, + }; } async #emitGui( - event: - | 'outer.gui.item.removed' - | 'outer.gui.item.moved' - | 'outer.gui.item.updated', + event: HolderGuiEvent, userIds: number[], response: Record, ): Promise { @@ -1754,7 +2049,7 @@ export class ShareService extends PuterService { } #isTrashed(entry: FSEntry): boolean { - return /^\/[^/]+\/Trash(\/|$)/u.test(entry.path); + return isTrashedPath(entry.path); } /**