mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-27 16:37:18 +00:00
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:
@@ -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 () => {
|
||||
@@ -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`,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user