diff --git a/src/backend/controllers/share/ShareController.http.test.ts b/src/backend/controllers/share/ShareController.http.test.ts index 39526349b..94b3c6be1 100644 --- a/src/backend/controllers/share/ShareController.http.test.ts +++ b/src/backend/controllers/share/ShareController.http.test.ts @@ -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; diff --git a/src/backend/services/broadcast/BroadcastService.test.ts b/src/backend/services/broadcast/BroadcastService.test.ts index 54f69a1d6..2af9f2acf 100644 --- a/src/backend/services/broadcast/BroadcastService.test.ts +++ b/src/backend/services/broadcast/BroadcastService.test.ts @@ -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 ------------------------------------------------ diff --git a/src/backend/services/broadcast/BroadcastService.ts b/src/backend/services/broadcast/BroadcastService.ts index b19e7edc7..30b060dcd 100644 --- a/src/backend/services/broadcast/BroadcastService.ts +++ b/src/backend/services/broadcast/BroadcastService.ts @@ -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[], diff --git a/src/backend/services/share/ShareConsistency.test.ts b/src/backend/services/share/ShareConsistency.test.ts index f12702138..268e3bbff 100644 --- a/src/backend/services/share/ShareConsistency.test.ts +++ b/src/backend/services/share/ShareConsistency.test.ts @@ -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', () => { diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index bb137538e..23080436b 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -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 }, diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index db4c1bb10..d190a966c 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -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 `:` pairs whose grant is still standing. */ + async #reachingHolders( + rows: ShareIndexRow[], + nodeById: Map, + ): Promise> { + const nodesByHolder = new Map>(); + 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(); + 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; 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 { + 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 diff --git a/src/backend/util/email.test.ts b/src/backend/util/email.test.ts index ba177ca6d..c397cc48d 100644 --- a/src/backend/util/email.test.ts +++ b/src/backend/util/email.test.ts @@ -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', ); }); diff --git a/src/backend/util/email.ts b/src/backend/util/email.ts index 99841786a..201fd9af7 100644 --- a/src/backend/util/email.ts +++ b/src/backend/util/email.ts @@ -41,28 +41,39 @@ const RULES: Record 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 = - { - 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 = { + 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 = { '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(['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`. diff --git a/src/docs/src/FS/listShared.md b/src/docs/src/FS/listShared.md index 85d12740b..4e52783d5 100644 --- a/src/docs/src/FS/listShared.md +++ b/src/docs/src/FS/listShared.md @@ -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**, `///`, wher