mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 07:57:10 +00:00
🔧 PUT-1524: Sanity check file sharing api (#3603)
* fix: sharing answers "who can reach this" from the grants, not the index Two ways a share listing could name access that was no longer there. A recipient's listing paired the flat permission read with the list of permissions it asked for by array index. That read drops misses and dedupes its keys, so the two are not positional: one entry's live grant vouched for another entry that had none, and listShared() kept publishing a withdrawn item's name, size and signed thumbnail URL to someone who could no longer open it. Read the permission off the value instead. getShares() had the same gap from the owner's side, with no liveness check at all — a grant withdrawn through /auth/revoke-user-user or an ACL mode change left the index row behind, and the owner was told someone could reach a file they could not. Checked against the grants now, one batched read per distinct holder. Pending invites are not subject to it: they have no grant yet, which is the point of them. Also covers the access-token actor, which reaches the same reach bound as an app through a different arm of the ACL check. No behavior change there — it was correct and untested. * test: pin that a revoke crosses regions A revoke reaches a peer region as a replicated SQL delete plus three invalidation events, one per cache the region owns: the u2u row cache, the flat view and the scan generation. The delete alone changes nothing there, and each cache has a different consequence if its event is lost — 20s for the generation, 5 minutes for the row cache, and forever for the flat view, whose grant-path entries carry no expiry. Nothing covered the whole path end to end. The store tests prove each event is emitted and applied; this proves the result, which is that the recipient stops being able to read. It asserts the read still succeeds after the SQL delete alone, so the test also records why replication is not sufficient on its own. * test: pin that a leaked uuid buys no access A masked share path hides which folder an item sits in; it was never the thing deciding who may open it. Nothing checked that at the route level, so the guarantee rested on unit tests of the resolver alone. Reads one shared file through its masked path, then tries the sibling four ways: the shared uuid with the sibling's name, the sibling's own uuid, a `..` back out of the root, and the owner's real path. Worth knowing about this one: it's mutation-checked. Removing the head !== root.name guard in sharePathMask.ts fails it with reachable: /testuser/55dd54c0…/share-http-1df8933e.txt. I verified that specifically because two tests I wrote earlier in this chunk passed with their guards broken — both were vacuous, and I deleted them rather than commit false assurance. * docs: say what listShared's total actually counts `total` counts the shares recorded for you; items are filtered after the page is read, so a withdrawn grant leaves the count higher than anything paging will yield. The page description already explained the short-page behaviour, but the field read as an exact count and the example printed it as one. The test pins the gap it describes: two shares, one withdrawn outside the index, one item listed and a total of two. * fix: keep an undelivered broadcast event instead of dropping it The outbound queue was cleared before the send, so a peer that timed out took its events with it. Most were survivable — a lost cache invalidation heals when the entry's TTL lapses. A revoke's flat-perm invalidation is not: grant-path entries carry no expiry, so a peer went on serving a withdrawn grant until something else wrote that key. Failed sends now go back on the queue, which the existing flush timer retries. Anything queued since wins over the retry, and the queue is bounded at 10,000 with the oldest dropped first, so a peer that stays down cannot grow it without limit. Each retry is signed at send time, so it is not rejected against the replay window. * fix: stop plus-addressing from deciding who a share reaches
This commit is contained in:
@@ -67,6 +67,48 @@ describe('share endpoints over HTTP', () => {
|
||||
return { uid, path, name };
|
||||
};
|
||||
|
||||
// Masking hides where an item sits, not who may open it.
|
||||
it('will not let a masked path reach an unshared sibling', async () => {
|
||||
const owner = env.users.user;
|
||||
const recipient = env.users.other;
|
||||
const shared = await makeFile(owner);
|
||||
const secret = await makeFile(owner);
|
||||
|
||||
await post('/share', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: shared.uid }],
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const read = (path: string) =>
|
||||
fetch(
|
||||
`${env.apiOrigin}/read?${new URLSearchParams({ file: path })}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${recipient.token}`,
|
||||
origin: env.apiOrigin,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const masked = `/${owner.username}/${shared.uid}/${shared.name}`;
|
||||
expect((await read(masked)).status).toBe(200);
|
||||
|
||||
for (const attempt of [
|
||||
// The shared item's root, renamed to the sibling.
|
||||
`/${owner.username}/${shared.uid}/${secret.name}`,
|
||||
// The sibling's own uuid, as if it had leaked.
|
||||
`/${owner.username}/${secret.uid}/${secret.name}`,
|
||||
// Back out of the root the uuid vouched for.
|
||||
`/${owner.username}/${shared.uid}/${shared.name}/../${secret.name}`,
|
||||
// The owner's real path, named outright.
|
||||
secret.path,
|
||||
]) {
|
||||
const res = await read(attempt);
|
||||
expect(res.status, `reachable: ${attempt}`).not.toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('shares an item, lists it for the recipient, then revokes it', async () => {
|
||||
const owner = env.users.user;
|
||||
const recipient = env.users.other;
|
||||
|
||||
@@ -603,6 +603,33 @@ describe('BroadcastService outbound flush', () => {
|
||||
);
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
// A dropped invalidation can outlive every TTL meant to heal it.
|
||||
it('sends a failed event again instead of dropping it', async () => {
|
||||
const cacheKey = 'requeue-test';
|
||||
const carries = (call: unknown) => {
|
||||
const body = (call as [{ data?: string }])[0]?.data;
|
||||
return typeof body === 'string' && body.includes(cacheKey);
|
||||
};
|
||||
|
||||
axiosRequestMock.mockRejectedValue(new Error('peer unreachable'));
|
||||
server.clients.event.emit(
|
||||
'outer.cacheUpdate' as never,
|
||||
{ cacheKey: [cacheKey] } as never,
|
||||
{},
|
||||
);
|
||||
await waitForFlush(() => axiosRequestMock.mock.calls.some(carries));
|
||||
|
||||
axiosRequestMock.mockReset();
|
||||
axiosRequestMock.mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'ok',
|
||||
});
|
||||
|
||||
// Nothing new is emitted — only the retry can satisfy this.
|
||||
await waitForFlush(() => axiosRequestMock.mock.calls.some(carries));
|
||||
});
|
||||
});
|
||||
|
||||
// -- Header validation ------------------------------------------------
|
||||
|
||||
@@ -98,6 +98,8 @@ export class BroadcastService extends PuterService {
|
||||
|
||||
#webhookReplayWindowSeconds = 300;
|
||||
#outboundFlushMs = 2000;
|
||||
/** Bound on re-queued events, so a peer that stays down can't grow it. */
|
||||
#outboundMaxQueued = 10_000;
|
||||
#webhookProtocol: 'http' | 'https' = 'https';
|
||||
#webhookHostHeader: string | null = null;
|
||||
/** Self-signed certs are common between Puter nodes — accept them. */
|
||||
@@ -350,17 +352,21 @@ export class BroadcastService extends PuterService {
|
||||
const events = [...this.#outboundEventsByDedupKey.values()];
|
||||
this.#outboundEventsByDedupKey.clear();
|
||||
|
||||
let undelivered = false;
|
||||
for (const peer of this.#webhookPeers) {
|
||||
try {
|
||||
await this.#sendWebhookToPeer(peer, events);
|
||||
} catch (err) {
|
||||
const peerId = peer.peerId ?? 'unknown';
|
||||
undelivered = true;
|
||||
console.warn(
|
||||
`[broadcast] webhook send to peer ${peerId} failed`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
// A lost flat-perm invalidation has no TTL to heal it.
|
||||
if (undelivered) this.#requeueOutbound(events);
|
||||
} finally {
|
||||
this.#outboundIsFlushing = false;
|
||||
// Anything that arrived during flush gets the next tick.
|
||||
@@ -370,6 +376,26 @@ export class BroadcastService extends PuterService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Put undelivered events back for the next flush, oldest dropped first. */
|
||||
#requeueOutbound(events: BroadcastEvent[]): void {
|
||||
for (const event of events) {
|
||||
const dedupKey = this.#createDedupKey(event);
|
||||
// Whatever arrived since is fresher — never overwrite it.
|
||||
if (this.#outboundEventsByDedupKey.has(dedupKey)) continue;
|
||||
this.#outboundEventsByDedupKey.set(dedupKey, event);
|
||||
}
|
||||
let overflow =
|
||||
this.#outboundEventsByDedupKey.size - this.#outboundMaxQueued;
|
||||
if (overflow <= 0) return;
|
||||
console.warn(
|
||||
`[broadcast] outbound queue over ${this.#outboundMaxQueued}; dropping ${overflow} oldest`,
|
||||
);
|
||||
for (const key of this.#outboundEventsByDedupKey.keys()) {
|
||||
if (overflow-- <= 0) break;
|
||||
this.#outboundEventsByDedupKey.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #sendWebhookToPeer(
|
||||
peer: IBroadcastPeerConfig,
|
||||
events: BroadcastEvent[],
|
||||
|
||||
@@ -640,6 +640,53 @@ describe('share consistency across KV, SQL and Redis', () => {
|
||||
// answering "allowed" until its generation moves.
|
||||
expect(await canRead(B.actor, entry.path)).toBe(false);
|
||||
});
|
||||
|
||||
// Arrives as a replicated SQL delete plus the invalidation events.
|
||||
it('applies a revoke that happened in another region', async () => {
|
||||
const A = await makeParty('A');
|
||||
const B = await makeParty('B');
|
||||
const entry = await makeEntry(A);
|
||||
|
||||
await share(A.actor, {
|
||||
uid: entry.uuid,
|
||||
recipient: { email: B.email },
|
||||
mode: 'read',
|
||||
});
|
||||
// Warm this region's scan cache and flat entry.
|
||||
expect(await canRead(B.actor, entry.path)).toBe(true);
|
||||
|
||||
const permission = `fs:${entry.uuid}:read`;
|
||||
// The peer's write, as SQL replication delivers it: rows only.
|
||||
await server.clients.db.write(
|
||||
'DELETE FROM `user_to_user_permissions` WHERE `holder_user_id` = ? AND `permission` = ?',
|
||||
[B.id, permission],
|
||||
);
|
||||
|
||||
// Still allowed — the caches this region owns were never told.
|
||||
expect(await canRead(B.actor, entry.path)).toBe(true);
|
||||
|
||||
for (const [event, data] of [
|
||||
// Row cache, flat view and generation are each per-cluster.
|
||||
[
|
||||
'outer.cacheUpdate',
|
||||
{ cacheKey: [`perms:u2u:holder:${B.id}`] },
|
||||
],
|
||||
[
|
||||
'outer.permission.flatInvalidated',
|
||||
{ entries: [{ holderUserId: B.id, permission }] },
|
||||
],
|
||||
[
|
||||
'outer.permission.generationBumped',
|
||||
{ actorUids: [`user:${B.uuid}`] },
|
||||
],
|
||||
] as const) {
|
||||
await server.clients.event.emitAndWait(event, data, {
|
||||
from_outside: true,
|
||||
});
|
||||
}
|
||||
|
||||
expect(await canRead(B.actor, entry.path)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a caller who cannot see the item', () => {
|
||||
|
||||
@@ -237,6 +237,59 @@ describe('ShareService', () => {
|
||||
expect(await canRead(squatter.actor, file.path)).toBe(true);
|
||||
});
|
||||
|
||||
// `+` is only an alias separator where the domain says so.
|
||||
it('does not hand a plus-addressed share to the base account', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
const base = `finance-${Math.random().toString(36).slice(2, 8)}@example.test`;
|
||||
const holder = await makeUser();
|
||||
await server.stores.user.update(holder.user.id, {
|
||||
email: base,
|
||||
clean_email: base,
|
||||
email_confirmed: true,
|
||||
});
|
||||
const [local, domain] = base.split('@');
|
||||
const distinct = `${local}+board@${domain}`;
|
||||
|
||||
const result = await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: distinct },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
expect(result.holder.username).not.toBe(holder.user.username);
|
||||
expect(await canRead(holder.actor, file.path)).toBe(false);
|
||||
});
|
||||
|
||||
// The invite variant: no account need exist when the share is made.
|
||||
it('does not let the base address claim a plus-addressed invite', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
const stem = `payroll-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const base = `${stem}@example.test`;
|
||||
const distinct = `${stem}+contractors@example.test`;
|
||||
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: distinct },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const claimer = await makeUser();
|
||||
await server.stores.user.update(claimer.user.id, {
|
||||
email: base,
|
||||
clean_email: base,
|
||||
email_confirmed: true,
|
||||
});
|
||||
const claimed = await server.services.share.claimPendingShares(
|
||||
claimer.user.id,
|
||||
base,
|
||||
);
|
||||
|
||||
expect(claimed).toEqual([]);
|
||||
expect(await canRead(claimer.actor, file.path)).toBe(false);
|
||||
});
|
||||
|
||||
it('hides a file from a stranger trying to share it', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
@@ -1251,6 +1304,97 @@ describe('ShareService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The other derived actor: same reach bound, a different arm of the check.
|
||||
describe('a token is bounded by what it was minted for', () => {
|
||||
/** Mint a token and resolve it the way an authenticated request does. */
|
||||
const asToken = async (
|
||||
owner: { actor: Actor },
|
||||
permissions: Array<[string]>,
|
||||
) => {
|
||||
const token = await runWithContext({ actor: owner.actor }, () =>
|
||||
server.services.auth.createAccessToken(
|
||||
owner.actor,
|
||||
permissions,
|
||||
{ label: 'share-test' },
|
||||
),
|
||||
);
|
||||
const actor =
|
||||
await server.services.auth.authenticateFromToken(token);
|
||||
if (!actor) throw new Error('token did not resolve to an actor');
|
||||
return actor;
|
||||
};
|
||||
|
||||
it('shares a file the token carries', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
const actor = await asToken(owner, [[`fs:${file.uuid}:read`]]);
|
||||
|
||||
const result = await share(actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { username: recipient.user.username },
|
||||
mode: 'read',
|
||||
});
|
||||
expect(result.mode).toBe('read');
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a file of its issuer’s that the token does not carry', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const carried = await makeFile(owner.user);
|
||||
const other = await makeFile(owner.user);
|
||||
const actor = await asToken(owner, [[`fs:${carried.uuid}:read`]]);
|
||||
|
||||
await expect(
|
||||
share(actor, {
|
||||
uid: other.uuid,
|
||||
recipient: { username: recipient.user.username },
|
||||
mode: 'read',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
expect(await canRead(recipient.actor, other.path)).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot hand out more than it holds', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
const actor = await asToken(owner, [[`fs:${file.uuid}:read`]]);
|
||||
|
||||
await expect(
|
||||
share(actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { username: recipient.user.username },
|
||||
mode: 'write',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot withdraw a share on a file it does not carry', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const carried = await makeFile(owner.user);
|
||||
const other = await makeFile(owner.user);
|
||||
await share(owner.actor, {
|
||||
uid: other.uuid,
|
||||
recipient: { username: recipient.user.username },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const actor = await asToken(owner, [[`fs:${carried.uuid}:read`]]);
|
||||
await expect(
|
||||
unshare(actor, {
|
||||
uid: other.uuid,
|
||||
recipient: { username: recipient.user.username },
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
// The share it could not reach is still standing.
|
||||
expect(await canRead(recipient.actor, other.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('retires grants when the entry is deleted', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
@@ -1489,6 +1633,93 @@ describe('ShareService', () => {
|
||||
expect(after.items.map((i) => i.entryUid)).not.toContain(file.uuid);
|
||||
});
|
||||
|
||||
// One entry's answer must not vouch for another's in the batched read.
|
||||
it('drops a withdrawn listing even when another share survives', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const withdrawn = await makeFile(owner.user);
|
||||
const kept = await makeFile(owner.user);
|
||||
|
||||
for (const file of [withdrawn, kept]) {
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
}
|
||||
|
||||
await server.services.permission.revokeUserUserPermission(
|
||||
owner.actor,
|
||||
recipient.user.username!,
|
||||
`fs:${withdrawn.uuid}:read`,
|
||||
);
|
||||
expect(await canRead(recipient.actor, withdrawn.path)).toBe(false);
|
||||
expect(await canRead(recipient.actor, kept.path)).toBe(true);
|
||||
|
||||
const after = await server.services.share.listSharedWithMe(
|
||||
recipient.actor,
|
||||
);
|
||||
const listed = after.items.map((i) => i.entryUid);
|
||||
expect(listed).toContain(kept.uuid);
|
||||
expect(listed).not.toContain(withdrawn.uuid);
|
||||
});
|
||||
|
||||
// `total` counts rows; items are filtered after the page is read.
|
||||
it('reports a total that can exceed what paging yields', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const withdrawn = await makeFile(owner.user);
|
||||
const kept = await makeFile(owner.user);
|
||||
|
||||
for (const file of [withdrawn, kept]) {
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
}
|
||||
await server.services.permission.revokeUserUserPermission(
|
||||
owner.actor,
|
||||
recipient.user.username!,
|
||||
`fs:${withdrawn.uuid}:read`,
|
||||
);
|
||||
|
||||
const page = await server.services.share.listSharedWithMe(
|
||||
recipient.actor,
|
||||
{ includeTotal: true },
|
||||
);
|
||||
expect(page.items.map((i) => i.entryUid)).toEqual([kept.uuid]);
|
||||
expect(page.total).toBe(2);
|
||||
});
|
||||
|
||||
// The owner's view of the same withdrawal.
|
||||
it('stops naming a holder whose grant was withdrawn outside the index', 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',
|
||||
});
|
||||
await server.services.permission.revokeUserUserPermission(
|
||||
owner.actor,
|
||||
recipient.user.username!,
|
||||
`fs:${file.uuid}:read`,
|
||||
);
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(false);
|
||||
|
||||
const shares = await runWithContext({ actor: owner.actor }, () =>
|
||||
server.services.share.listSharesOf(owner.actor, {
|
||||
uid: file.uuid,
|
||||
}),
|
||||
);
|
||||
expect(shares.map((s) => s.holder.username)).not.toContain(
|
||||
recipient.user.username,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a recipient leave a share that was never indexed', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
@@ -2164,14 +2395,13 @@ describe('ShareService', () => {
|
||||
});
|
||||
|
||||
describe('email variants resolve to the inbox, not the string', () => {
|
||||
it('shares to the account behind a case or alias variant', async () => {
|
||||
it('shares to the account behind a case variant', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
// `Bob@…` and `bob+x@…` are the recipient's inbox; treating them
|
||||
// as strangers minted an unclaimable invite instead of a grant.
|
||||
const variant = `${recipient.email.split('@')[0].toUpperCase()}+tag@test.local`;
|
||||
// Treating `Bob@…` as a stranger minted an unclaimable invite.
|
||||
const variant = recipient.email.toUpperCase();
|
||||
const result = await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: variant },
|
||||
|
||||
@@ -22,7 +22,11 @@ import { posix as pathPosix } from 'node:path';
|
||||
import { userRelatedActor, type Actor } from '../../core/actor';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
import { cleanEmail } from '../../util/email.js';
|
||||
import {
|
||||
abuseKey,
|
||||
cleanEmail,
|
||||
isProviderCanonicalized,
|
||||
} from '../../util/email.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry';
|
||||
import type { UserRow } from '../../stores/user/UserStore';
|
||||
import type { AclMode } from '../acl/ACLService';
|
||||
@@ -47,6 +51,17 @@ export interface ShareTarget {
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
/** A `share` row, as much of it as this service reads back. */
|
||||
interface ShareIndexRow {
|
||||
uid: string;
|
||||
mode: string;
|
||||
holder_user_id: number;
|
||||
issuer_user_id: number;
|
||||
fsentry_id: number;
|
||||
created_at?: unknown;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface ShareInput extends ShareTarget {
|
||||
recipient: ShareRecipient;
|
||||
mode: AclMode;
|
||||
@@ -618,10 +633,10 @@ export class ShareService extends PuterService {
|
||||
const uuid = uuidFromEntryPermission(row.permission);
|
||||
if (uuid) live.add(uuid);
|
||||
}
|
||||
for (let i = 0; i < wanted.length; i++) {
|
||||
const value = flat[i];
|
||||
if (!value || value.deleted) continue;
|
||||
const uuid = uuidFromEntryPermission(wanted[i]);
|
||||
// Not positional against `wanted`: misses are dropped, keys deduped.
|
||||
for (const value of flat) {
|
||||
if (!value?.permission || value.deleted) continue;
|
||||
const uuid = uuidFromEntryPermission(value.permission);
|
||||
if (uuid) live.add(uuid);
|
||||
}
|
||||
// An owner listing something shared *to* them can't happen, but a
|
||||
@@ -633,6 +648,37 @@ export class ShareService extends PuterService {
|
||||
return live;
|
||||
}
|
||||
|
||||
/** The `<holderId>:<fsentryId>` pairs whose grant is still standing. */
|
||||
async #reachingHolders(
|
||||
rows: ShareIndexRow[],
|
||||
nodeById: Map<number, FSEntry>,
|
||||
): Promise<Set<string>> {
|
||||
const nodesByHolder = new Map<number, Map<number, FSEntry>>();
|
||||
for (const row of rows) {
|
||||
const holderId = Number(row.holder_user_id);
|
||||
const node = nodeById.get(Number(row.fsentry_id));
|
||||
if (!node || !Number.isFinite(holderId)) continue;
|
||||
const nodes = nodesByHolder.get(holderId) ?? new Map();
|
||||
nodes.set(node.id as number, node);
|
||||
nodesByHolder.set(holderId, nodes);
|
||||
}
|
||||
|
||||
const live = new Set<string>();
|
||||
await Promise.all(
|
||||
[...nodesByHolder].map(async ([holderId, nodes]) => {
|
||||
const uuids = await this.#liveGrants(holderId, [
|
||||
...nodes.values(),
|
||||
]);
|
||||
for (const node of nodes.values()) {
|
||||
if (uuids.has(node.uuid)) {
|
||||
live.add(`${holderId}:${node.id}`);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
return live;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw a recipient's access. An owner may clear any issuer's share of
|
||||
* their node; anyone else may only clear the ones they issued.
|
||||
@@ -986,13 +1032,15 @@ export class ShareService extends PuterService {
|
||||
maskEntryPath(node),
|
||||
]),
|
||||
);
|
||||
const inherited: Array<{ row: Record<string, unknown>; via: string }> =
|
||||
(await this.stores.share.listByFsentries([...viaById.keys()])).map(
|
||||
(row: { fsentry_id: number }) => ({
|
||||
row,
|
||||
via: viaById.get(Number(row.fsentry_id)) as string,
|
||||
}),
|
||||
);
|
||||
const nodeById = new Map(
|
||||
[entry, ...ancestorNodes.values()].map((node) => [node.id, node]),
|
||||
);
|
||||
const inherited: Array<{ row: ShareIndexRow; via: string }> = (
|
||||
await this.stores.share.listByFsentries([...viaById.keys()])
|
||||
).map((row: ShareIndexRow) => ({
|
||||
row,
|
||||
via: viaById.get(Number(row.fsentry_id)) as string,
|
||||
}));
|
||||
|
||||
const rows = await this.stores.share.listByFsentry(entry.id);
|
||||
const pendingRows = await this.stores.share.listPendingOnFsentry(
|
||||
@@ -1012,8 +1060,19 @@ export class ShareService extends PuterService {
|
||||
const users = await this.stores.user.getByIds(userIds);
|
||||
const maskedPath = maskEntryPath(entry);
|
||||
|
||||
const inheritedShares: ResolvedShare[] = inherited.map(
|
||||
({ row, via }) => ({
|
||||
// As in `#liveGrants`: an index row outlives the grant it records.
|
||||
const stillReaches = await this.#reachingHolders(
|
||||
[...rows, ...inherited.map((i) => i.row)],
|
||||
nodeById,
|
||||
);
|
||||
const isLive = (row: ShareIndexRow): boolean =>
|
||||
stillReaches.has(
|
||||
`${Number(row.holder_user_id)}:${Number(row.fsentry_id)}`,
|
||||
);
|
||||
|
||||
const inheritedShares: ResolvedShare[] = inherited
|
||||
.filter(({ row }) => isLive(row))
|
||||
.map(({ row, via }) => ({
|
||||
uid: String(row.uid),
|
||||
mode: String(row.mode),
|
||||
path: maskedPath,
|
||||
@@ -1032,38 +1091,41 @@ export class ShareService extends PuterService {
|
||||
inheritedFrom: via,
|
||||
modified: entry.modified,
|
||||
size: entry.size,
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
const own: ResolvedShare[] = rows.map(
|
||||
(row: {
|
||||
uid: string;
|
||||
mode: string;
|
||||
issuer_user_id: number;
|
||||
holder_user_id: number;
|
||||
created_at: unknown;
|
||||
data?: unknown;
|
||||
}): ResolvedShare => ({
|
||||
uid: row.uid,
|
||||
mode: row.mode,
|
||||
path: maskedPath,
|
||||
entryUid: entry.uuid,
|
||||
isDir: Boolean(entry.isDir),
|
||||
issuer: {
|
||||
username:
|
||||
users.get(Number(row.issuer_user_id))?.username ?? null,
|
||||
},
|
||||
holder: {
|
||||
username:
|
||||
users.get(Number(row.holder_user_id))?.username ?? null,
|
||||
},
|
||||
createdAt: row.created_at,
|
||||
issuedByApp: issuedByApp(row),
|
||||
inheritedFrom: null,
|
||||
modified: entry.modified,
|
||||
size: entry.size,
|
||||
}),
|
||||
);
|
||||
const own: ResolvedShare[] = rows
|
||||
.filter(isLive)
|
||||
.map(
|
||||
(row: {
|
||||
uid: string;
|
||||
mode: string;
|
||||
issuer_user_id: number;
|
||||
holder_user_id: number;
|
||||
created_at: unknown;
|
||||
data?: unknown;
|
||||
}): ResolvedShare => ({
|
||||
uid: row.uid,
|
||||
mode: row.mode,
|
||||
path: maskedPath,
|
||||
entryUid: entry.uuid,
|
||||
isDir: Boolean(entry.isDir),
|
||||
issuer: {
|
||||
username:
|
||||
users.get(Number(row.issuer_user_id))?.username ??
|
||||
null,
|
||||
},
|
||||
holder: {
|
||||
username:
|
||||
users.get(Number(row.holder_user_id))?.username ??
|
||||
null,
|
||||
},
|
||||
createdAt: row.created_at,
|
||||
issuedByApp: issuedByApp(row),
|
||||
inheritedFrom: null,
|
||||
modified: entry.modified,
|
||||
size: entry.size,
|
||||
}),
|
||||
);
|
||||
// Nobody holds an invite yet, but whoever manages the node needs to
|
||||
// see who was asked, and be able to take it back.
|
||||
const pending: ResolvedShare[] = pendingRows.map(
|
||||
@@ -1405,6 +1467,12 @@ export class ShareService extends PuterService {
|
||||
});
|
||||
}
|
||||
|
||||
// An alias we won't grant on can still land in the blocker's inbox.
|
||||
const mayReach =
|
||||
(await this.stores.user.findEmailOwner(email)) ??
|
||||
(await this.stores.user.getByCleanEmail(abuseKey(email)));
|
||||
if (mayReach) await this.#assertNotBlocked(issuerId, mayReach);
|
||||
|
||||
// Stored canonicalized, because claiming matches on it: the confirmed
|
||||
// address arrives in whatever form the signup normalized to, and an
|
||||
// exact match against what the sharer happened to type loses the
|
||||
@@ -1526,13 +1594,9 @@ export class ShareService extends PuterService {
|
||||
> {
|
||||
const email = recipient?.email?.trim();
|
||||
const username = recipient?.username?.trim();
|
||||
// `findEmailOwner`, not an exact match: `Bob@…` and `bob+x@…` are the
|
||||
// same inbox, and resolving them to the account is what routes an
|
||||
// alias through the same self/owner/blocked checks as the address
|
||||
// itself — an exact match here turned any variant into an invite that
|
||||
// skipped all three.
|
||||
// Case and provider aliases resolve to the account; see #addressOwner.
|
||||
const user = email
|
||||
? await this.stores.user.findEmailOwner(email)
|
||||
? await this.#addressOwner(email)
|
||||
: username
|
||||
? await this.stores.user.getByUsername(username)
|
||||
: null;
|
||||
@@ -1550,6 +1614,16 @@ export class ShareService extends PuterService {
|
||||
return { kind: 'user', user };
|
||||
}
|
||||
|
||||
/** Who holds this address. A rewritten local part needs a known domain. */
|
||||
async #addressOwner(email: string): Promise<UserRow | null> {
|
||||
const owner = await this.stores.user.findEmailOwner(email);
|
||||
if (!owner?.email) return owner ?? null;
|
||||
const sameAddress =
|
||||
owner.email.trim().toLowerCase() === email.trim().toLowerCase();
|
||||
if (sameAddress || isProviderCanonicalized(email)) return owner;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate every share operation on the same question the permission layer
|
||||
* already answers. Reported as the ACL's own safe error so a caller who
|
||||
|
||||
@@ -25,9 +25,16 @@ describe('cleanEmail', () => {
|
||||
expect(cleanEmail('Foo.Bar@Example.COM')).toBe('foo.bar@example.com');
|
||||
});
|
||||
|
||||
it('strips subaddressing for every provider by default', () => {
|
||||
// `+` is a convention the receiving domain defines, not a rule of SMTP.
|
||||
it('keeps subaddressing on a domain whose semantics we do not know', () => {
|
||||
expect(cleanEmail('foo+newsletter@example.com')).toBe(
|
||||
'foo@example.com',
|
||||
'foo+newsletter@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips subaddressing for a provider that defines it', () => {
|
||||
expect(cleanEmail('foo+newsletter@outlook.com')).toBe(
|
||||
'foo@outlook.com',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+39
-20
@@ -41,28 +41,39 @@ const RULES: Record<RuleName, (p: Parts) => void> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Providers whose addresses should be canonicalized before comparison. `rules`
|
||||
* are added on top of the default `remove_subaddressing`; `rmrules` are
|
||||
* subtracted (Yahoo permits `+` in local parts).
|
||||
*/
|
||||
const PROVIDERS: Record<string, { rules?: RuleName[]; rmrules?: RuleName[] }> =
|
||||
{
|
||||
gmail: { rules: ['dots_dont_matter'] },
|
||||
icloud: { rules: ['dots_dont_matter'] },
|
||||
yahoo: { rmrules: ['remove_subaddressing'] },
|
||||
};
|
||||
/** Rules each provider's semantics allow. Unlisted domains get none. */
|
||||
const PROVIDERS: Record<string, { rules: RuleName[] }> = {
|
||||
gmail: { rules: ['dots_dont_matter', 'remove_subaddressing'] },
|
||||
icloud: { rules: ['dots_dont_matter', 'remove_subaddressing'] },
|
||||
outlook: { rules: ['remove_subaddressing'] },
|
||||
proton: { rules: ['remove_subaddressing'] },
|
||||
fastmail: { rules: ['remove_subaddressing'] },
|
||||
zoho: { rules: ['remove_subaddressing'] },
|
||||
// Listed to record the finding: yahoo makes `+` significant, using `-`.
|
||||
yahoo: { rules: [] },
|
||||
};
|
||||
|
||||
const DOMAIN_TO_PROVIDER: Record<string, string> = {
|
||||
'gmail.com': 'gmail',
|
||||
'googlemail.com': 'gmail',
|
||||
'icloud.com': 'icloud',
|
||||
'me.com': 'icloud',
|
||||
'mac.com': 'icloud',
|
||||
'outlook.com': 'outlook',
|
||||
'hotmail.com': 'outlook',
|
||||
'live.com': 'outlook',
|
||||
'msn.com': 'outlook',
|
||||
'proton.me': 'proton',
|
||||
'protonmail.com': 'proton',
|
||||
'pm.me': 'proton',
|
||||
'fastmail.com': 'fastmail',
|
||||
'fastmail.fm': 'fastmail',
|
||||
'zoho.com': 'zoho',
|
||||
'zohomail.com': 'zoho',
|
||||
'yahoo.com': 'yahoo',
|
||||
'yahoo.co.uk': 'yahoo',
|
||||
'yahoo.ca': 'yahoo',
|
||||
'yahoo.com.au': 'yahoo',
|
||||
'icloud.com': 'icloud',
|
||||
'me.com': 'icloud',
|
||||
'mac.com': 'icloud',
|
||||
};
|
||||
|
||||
/** Aliases that resolve to the same inbox on the provider side. */
|
||||
@@ -85,17 +96,25 @@ export function cleanEmail(email: string): string {
|
||||
domain: DOMAIN_NONDISTINCT[domainRaw] ?? domainRaw,
|
||||
};
|
||||
|
||||
const applied = new Set<RuleName>(['remove_subaddressing']);
|
||||
// Nothing is assumed about a domain we don't know: lowercasing only.
|
||||
const provider = PROVIDERS[DOMAIN_TO_PROVIDER[parts.domain] ?? ''];
|
||||
if (provider) {
|
||||
for (const r of provider.rules ?? []) applied.add(r);
|
||||
for (const r of provider.rmrules ?? []) applied.delete(r);
|
||||
}
|
||||
for (const rule of applied) RULES[rule](parts);
|
||||
for (const rule of provider?.rules ?? []) RULES[rule](parts);
|
||||
|
||||
return `${parts.local}@${parts.domain}`;
|
||||
}
|
||||
|
||||
/** Strips `+` on any domain. For abuse decisions only, never for identity. */
|
||||
export function abuseKey(email: string): string {
|
||||
const [local, domain] = cleanEmail(email).split('@');
|
||||
return domain ? `${local.split('+')[0]}@${domain}` : local;
|
||||
}
|
||||
|
||||
/** Whether we have asserted how this domain treats its own local parts. */
|
||||
export function isProviderCanonicalized(email: string): boolean {
|
||||
const domain = email.toLowerCase().split('@')[1] ?? '';
|
||||
return Boolean(DOMAIN_TO_PROVIDER[DOMAIN_NONDISTINCT[domain] ?? domain]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the (cleaned) email matches any of the blocked domain
|
||||
* suffixes. Suffix-match so `mailinator.com` blocks `foo@bar.mailinator.com`.
|
||||
|
||||
@@ -37,7 +37,7 @@ A `Promise` that resolves to an object with:
|
||||
|
||||
- `items` (Array) - The shares on this page. Each has `uid`, `mode`, `path`, `entryUid`, `isDir`, `name`, `type`, `thumbnail`, `owner`, `issuer`, `holder`, `modified` and `size`. A share row has no directory listing behind it, so `name`, `type` and `thumbnail` are carried on the row itself for rendering.
|
||||
- `cursor` (String) - Pass to the next call to get the following page. **Present only while more pages remain.**
|
||||
- `total` (Number) - Present only when `includeTotal` was set.
|
||||
- `total` (Number) - Present only when `includeTotal` was set. An approximation: it counts the shares recorded for you, before the filtering described below, so it can be higher than the number of items paging actually yields. Treat it as a headline figure, not a count to reconcile against.
|
||||
|
||||
Iterate until `cursor` is absent rather than comparing `items.length` to `limit`. A page can come back short — items you can no longer see are filtered out after the page is read — while more pages still remain.
|
||||
|
||||
@@ -54,7 +54,7 @@ Items shared with you appear at a **masked path**, `/<owner>/<uid>/<name>`, wher
|
||||
<script>
|
||||
(async () => {
|
||||
const page = await puter.fs.listShared({ includeTotal: true });
|
||||
puter.print(`${page.total} item(s) shared with you<br>`);
|
||||
puter.print(`About ${page.total} item(s) shared with you<br>`);
|
||||
for (const share of page.items) {
|
||||
puter.print(`${share.path} — ${share.mode} from ${share.issuer}<br>`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user