mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-26 23:26:04 +00:00
Tell share recipients about creates and renames
A recipient's client keeps its cache fresh from fs events pushed over their socket, and ShareService fans those out to holders — but only for write, move and delete. A new entry emits fs.create.<flavor>, not fs.write.file, and an in-place rename emits fs.rename; neither had a listener, so a recipient watching a shared folder never learned that a file appeared in it or was renamed. Part of why: those keys and outer.gui.item.renamed were missing from the typed event map, so a listener for them did not compile. Delivering the event is only half of it. Paths were masked against the entry itself, so item.added named a parent no cached listing was keyed on, and the payload carried no dirpath, which is how the desktop finds the container to render into — the event would have arrived and changed nothing. Paths are now masked at the share the holder reached the entry through, which is the address their own reads returned, and from_path on a move and old_path on a rename travel the same way (dropped when the move started outside the share, self-masked when the share is on the entry itself, where the root already carries the new path). Creates fire per entry, so an upload would have cost one share lookup per file; they are coalesced by parent folder the way subtree deletes already are. Measured on a 25-file burst into one folder: 25 lookups before, 1 after. A holder with a share on both a folder and something inside it is told once, by the nearer of the two.
This commit is contained in:
@@ -32,6 +32,9 @@ type GuiEvent<R = Record<string, unknown>> = {
|
||||
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 }>;
|
||||
|
||||
@@ -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<void>,
|
||||
) => {
|
||||
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<void>,
|
||||
) => {
|
||||
const seen: Record<string, unknown>[] = [];
|
||||
server.clients.event.on(event, (_key, data) => {
|
||||
const payload = data as {
|
||||
user_id_list?: number[];
|
||||
response?: Record<string, unknown>;
|
||||
};
|
||||
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<string, unknown>[] = [];
|
||||
server.clients.event.on('outer.gui.item.added', (_key, data) => {
|
||||
const payload = data as {
|
||||
user_id_list?: number[];
|
||||
response?: Record<string, unknown>;
|
||||
};
|
||||
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();
|
||||
|
||||
@@ -198,20 +198,39 @@ const blocksAllShares = (user: Pick<UserRow, 'metadata'> | 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<string, unknown> => ({
|
||||
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<string, unknown> => {
|
||||
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';
|
||||
|
||||
/** `/<owner>/<uuid>/<name>` 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<string, FSEntry>();
|
||||
/** The in-flight flush, shared by everything buffered for it. */
|
||||
#retireFlush: Promise<void> | null = null;
|
||||
/** New entries awaiting the next fan-out, by parent path. */
|
||||
#pendingCreates = new Map<string, FSEntry[]>();
|
||||
#createFlush: Promise<void> | 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.<flavor>`, 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<void> {
|
||||
const parent = pathPosix.dirname(entry.path);
|
||||
this.#pendingCreates.set(parent, [
|
||||
...(this.#pendingCreates.get(parent) ?? []),
|
||||
entry,
|
||||
]);
|
||||
this.#createFlush ??= new Promise<void>((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<string, FSEntry[]>): Promise<void> {
|
||||
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<string, unknown>,
|
||||
event: HolderGuiEvent,
|
||||
extrasFor: (root: FSEntry) => Record<string, unknown> = () => ({}),
|
||||
): Promise<void> {
|
||||
// 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<Array<{ root: FSEntry; holders: number[] }>> {
|
||||
const { rows, nodesById } = await this.#sharesReaching(entry);
|
||||
|
||||
// Deepest root wins, so a holder with nested shares is told once.
|
||||
const rootByHolder = new Map<number, FSEntry>();
|
||||
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<Array<{ holder_user_id: number }>> {
|
||||
async #sharesReaching(entry: FSEntry): Promise<{
|
||||
rows: Array<{ holder_user_id: number; fsentry_id: number }>;
|
||||
nodesById: Map<number, FSEntry>;
|
||||
}> {
|
||||
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<string, FSEntry>();
|
||||
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<number, FSEntry>(
|
||||
[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<string, unknown>,
|
||||
): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user