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..1bf968816 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 () => { @@ -1538,6 +1547,260 @@ 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.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('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` }, + 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(); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index c91c38e89..8fe3050d2 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -198,20 +198,39 @@ 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, +): Record => { + const path = + maskedPathVia(root, entry.path) ?? maskedSelfPath(entry, entry.path); + 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, + }; +}; + +/** 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. */ const maskedSelfPath = (entry: FSEntry, realPath: string): string => { @@ -220,6 +239,25 @@ const maskedSelfPath = (entry: FSEntry, realPath: string): string => { 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 +277,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 @@ -268,12 +309,17 @@ export class ShareService extends PuterService { 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.#fanOutToHolders( + node, + 'outer.gui.item.moved', + (root) => { + // Omitted when the move started outside this share. + const from = fromPath + ? maskedFormerPath(root, node, fromPath) + : null; + return from ? { from_path: from } : {}; + }, + ).catch(() => { // A stale window is better than a failed move. }); @@ -337,13 +383,37 @@ export class ShareService extends PuterService { this.clients.event.on('fs.write.file', (_key, data) => { 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) => { + const entry = (data as { node?: FSEntry })?.node; + if (!entry?.uuid) return; + return this.#scheduleCreateFanOut(entry).catch(() => {}); + }); + + this.clients.event.on('fs.rename', (_key, data) => { + 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 +441,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 @@ -412,22 +518,50 @@ export class ShareService extends PuterService { 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, + ): Promise> { + const { rows, nodesById } = await this.#sharesReaching(entry); + + // 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; + 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()]; } /** @@ -440,9 +574,10 @@ export class ShareService extends PuterService { * share table to join fsentries and match `path IN (...)` instead put an * un-indexable OR on the write path. */ - async #sharesReaching( - entry: FSEntry, - ): Promise> { + async #sharesReaching(entry: FSEntry): Promise<{ + rows: Array<{ holder_user_id: number; fsentry_id: number }>; + nodesById: Map; + }> { const ancestorPaths: string[] = []; for ( let cursor = pathPosix.dirname(entry.path); @@ -455,18 +590,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 {