Merge pull request #3645 from HeyPuter/juancastro/put-1589-shared-file-fs-events-seem-to-be-lost

🐛 PUT-1589: Shared file fs events seem to be lost
This commit is contained in:
Juan Fernando Castro
2026-08-26 16:54:09 -04:00
committed by GitHub
3 changed files with 1075 additions and 61 deletions
+16
View File
@@ -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 }>;
+713 -10
View File
@@ -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 () => {
@@ -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<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('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<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();
@@ -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`,
},
+346 -51
View File
@@ -198,28 +198,81 @@ 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,
/** Where the entry was; differs from `entry.path` once it has moved. */
realPath: string = entry.path,
): Record<string, unknown> => {
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';
/** `/<owner>/<uuid>/<name>` 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<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
@@ -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.<flavor>`, 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<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
@@ -379,6 +495,7 @@ export class ShareService extends PuterService {
*/
async #flushRetire(entries: FSEntry[]): Promise<void> {
const ownerOf = new Map(entries.map((e) => [e.uuid, e]));
const notified = new Map<string, Set<number>>();
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<string, Set<number>>,
): Promise<void> {
const byParent = new Map<string, FSEntry[]>();
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<void> {
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<number, FSEntry>(
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<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,
/** Where to look from; the former path once the entry has moved. */
realPath: string = entry.path,
): Promise<Array<{ root: FSEntry; holders: number[] }>> {
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<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;
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<Array<{ holder_user_id: number }>> {
realPath: string = entry.path,
): Promise<{
rows: ShareIndexRow[];
nodesById: Map<number, FSEntry>;
}> {
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<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> {
@@ -1754,7 +2049,7 @@ export class ShareService extends PuterService {
}
#isTrashed(entry: FSEntry): boolean {
return /^\/[^/]+\/Trash(\/|$)/u.test(entry.path);
return isTrashedPath(entry.path);
}
/**