diff --git a/src/backend/clients/email/EmailClient.test.ts b/src/backend/clients/email/EmailClient.test.ts index 2c3ab18f5..0550d51a2 100644 --- a/src/backend/clients/email/EmailClient.test.ts +++ b/src/backend/clients/email/EmailClient.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { IConfig } from '../../types'; +import { digestLines } from '../../services/share/shareNotifyTitle'; import { EmailClient } from './EmailClient'; const FROM = '"Puter" '; @@ -262,13 +263,23 @@ describe('EmailClient — share notification templates', () => { return captured; }; + // Built by the producer rather than hand-shaped, so a change to the digest + // wording can't leave these fixtures describing a shape it no longer emits. const HOLDER = { recipient: 'alice', subject_line: 'bob shared notes.md with you', - shares: [ - { sender: 'bob', what: 'notes.md' }, - { sender: 'carol', what: '3 items — a.txt, b.txt, +1 more' }, - ], + shares: digestLines([ + { + username: 'bob', + count: 1, + items: [{ name: 'notes.md', link: 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md' }], + }, + { + username: 'carol', + count: 3, + items: [{ name: 'a.txt' }, { name: 'b.txt' }], + }, + ]), link: 'https://puter.test', unsubscribe_uuid: null, }; @@ -293,13 +304,41 @@ describe('EmailClient — share notification templates', () => { it('escapes item names in the html and leaves them raw in the text', async () => { const { html, text } = await renderShare('file_shared_with_you', { ...HOLDER, - shares: [{ sender: 'bob', what: 'r&d "notes".md' }], + shares: digestLines([ + { + username: 'bob', + count: 1, + items: [{ name: 'r&d "notes".md' }], + }, + ]), }); expect(html).toContain('r&d "notes".md'); expect(text).toContain('r&d "notes".md'); }); + // The name links to the item. The URL is ours, built from the origin and one + // encoded path, so it stays literal — an entity-escaped `=` would still + // resolve, but the text part has no parser to undo it. + it('links a named item to itself in both parts', async () => { + const link = 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md'; + const { html, text } = await renderShare('file_shared_with_you', HOLDER); + + expect(html).toContain(`notes.md`); + expect(text).toContain(link); + expect(html).not.toContain('='); + }); + + // An item with no link — an invite, with no account to route to yet — + // renders as the plain name it always did. + it('leaves an unlinked item as plain text', async () => { + const { html } = await renderShare('file_shared_with_you', HOLDER); + + expect(html).toContain('a.txt'); + expect(html).not.toContain('>a.txt'); + }); + it('renders as a responsive single column with no remote assets', async () => { const { html } = await renderShare('file_shared_with_you', HOLDER); @@ -364,7 +403,9 @@ describe('EmailClient — share notification templates', () => { const { html, text } = await renderShare('file_shared_invite', { email: 'new@example.test', subject_line: 'bob shared notes.md with you on Puter', - shares: [{ sender: 'bob', what: 'notes.md' }], + shares: digestLines([ + { username: 'bob', count: 1, items: [{ name: 'notes.md' }] }, + ]), link: 'https://puter.test', }); diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts index ac630708a..e423ed138 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -169,14 +169,22 @@ const headingRow = (text: string): string => ` * One row per sender, hairline-separated. The wording comes pre-composed from * the digest (`digestLines`), so the list stays a list however many senders and * items fold into it. + * + * Each named item links to itself where it has one; `lead`/`trail` are the + * wording around the names. An unlinked item renders as the plain name. + * + * `link` is triple-braced because we build it, so `?` and `=` stay literal; + * `name` is the owner's text and stays escaped. */ +const SHARE_ITEM_LIST = `{{this.lead}}{{#each this.items}}{{#unless @first}}, {{/unless}}{{#if this.link}}{{this.name}}{{else}}{{this.name}}{{/if}}{{/each}}{{this.trail}}`; + const SHARE_LIST_ROW = ` {{#each shares}} - + {{/each}}
{{this.sender}} shared {{this.what}}{{this.sender}} shared ${SHARE_ITEM_LIST}
@@ -386,7 +394,8 @@ immediately

Shared with you on Puter: {{#each shares}} - {{this.sender}} shared {{this.what}} - {{/each}} + {{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}} + {{/if}}{{/each}}{{/each}} Open Puter: {{link}} diff --git a/src/backend/services/share/ShareNotificationService.test.ts b/src/backend/services/share/ShareNotificationService.test.ts index ed42be6a7..dae04d5d7 100644 --- a/src/backend/services/share/ShareNotificationService.test.ts +++ b/src/backend/services/share/ShareNotificationService.test.ts @@ -148,6 +148,61 @@ describe('ShareNotificationService', () => { expect(titles).toContain(`${who} shared 2 items with you`); }); + // The masked path, so clicking the notification opens the item. Built from + // the uuid and the owner's name rather than from `path`, which is the + // owner's real one at this point and not the recipient's to see. + it('points a single-item notification at what was shared', async () => { + const sender = actorFor(await makeUser()); + const alice = await makeUser(); + const calls = captureNotifications(); + + await server.services.shareNotification.notifyShared(sender, [ + shareTo(alice, { + name: 'report.txt', + entryUid: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + path: '/owner/Documents/private/report.txt', + }), + ]); + + expect(calls).toHaveLength(1); + expect(calls[0].payload.fields).toMatchObject({ + target: { + path: '/owner/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/report.txt', + name: 'report.txt', + }, + }); + // The folder the owner keeps it in stays theirs. + expect(JSON.stringify(calls[0].payload)).not.toContain('private'); + }); + + // No single item describes several, so the click falls back to Shared + // rather than picking one of them. + it('carries no target when the notification covers more than one item', async () => { + const sender = actorFor(await makeUser()); + const alice = await makeUser(); + const calls = captureNotifications(); + + await server.services.shareNotification.notifyShared(sender, [ + shareTo(alice, { name: 'a.txt', entryUid: 'e1' }), + shareTo(alice, { name: 'b.txt', entryUid: 'e2' }), + ]); + + expect(calls).toHaveLength(1); + expect(calls[0].payload.fields).not.toHaveProperty('target'); + }); + + it('carries no target for a share whose name it never learned', async () => { + const sender = actorFor(await makeUser()); + const alice = await makeUser(); + const calls = captureNotifications(); + + await server.services.shareNotification.notifyShared(sender, [ + shareTo(alice, { name: undefined }), + ]); + + expect(calls[0].payload.fields).not.toHaveProperty('target'); + }); + it('does not notify the sender about their own share', async () => { const alice = await makeUser(); const calls = captureNotifications(); diff --git a/src/backend/services/share/ShareNotificationService.ts b/src/backend/services/share/ShareNotificationService.ts index 23f216b64..7146ce4cf 100644 --- a/src/backend/services/share/ShareNotificationService.ts +++ b/src/backend/services/share/ShareNotificationService.ts @@ -30,8 +30,14 @@ import { shareNotifyTitle, shareSendersFromFields, type DigestEntry, + type DigestItem, type ShareSender, } from './shareNotifyTitle'; +import { + maskedSharePath, + ownerFromSharePath, + shareDeepLink, +} from './shareDeepLink'; import type { ResolvedShare } from './ShareService'; /** @@ -120,6 +126,12 @@ const skipped = (reason: string, detail: Record): void => { * kept current, while whether it may _interrupt_ them — pushed to their screen, * mailed to them — is budgeted, since that is the part that can bury someone. */ +/** Where a single-item notification points; a masked path, opened in place. */ +interface ShareNotificationTarget { + path: string; + name: string; +} + /** * A queued send's durable form: persisted to KV so it survives the node that * queued it and is visible to every other node's flush. @@ -134,6 +146,8 @@ interface DigestEntryRecord { sender?: string; count: number; names: string[]; + /** As `names`, plus links. Absent on records queued before this shipped. */ + items?: DigestItem[]; /** Arrival order — KV lists by key, which is a uuid and says nothing. */ queuedAt: number; } @@ -242,19 +256,27 @@ export class ShareNotificationService extends PuterService { if (typeof issuerId !== 'number') return; const counts = new Map(); - const named = new Map(); + const named = new Map(); + const targets = new Map(); for (const share of shares) { if (share.pending) continue; if (!share.isNew || !share.holderId) continue; if (share.holderId === issuerId) continue; counts.set(share.holderId, (counts.get(share.holderId) ?? 0) + 1); - if (share.name) { - const names = named.get(share.holderId) ?? []; - if (names.length < DIGEST_NAMES_PER_SENDER) { - names.push(share.name); - } - named.set(share.holderId, names); + const item = this.#digestItem(share); + if (item) { + const items = named.get(share.holderId) ?? []; + if (items.length < DIGEST_NAMES_PER_SENDER) items.push(item); + named.set(share.holderId, items); } + // Only a lone item is worth pointing at; a second nulls it. + const path = this.#targetPath(share); + targets.set( + share.holderId, + targets.has(share.holderId) || !path + ? null + : { path, name: share.name as string }, + ); } // Each recipient fails alone: one refused send must not cost the next @@ -266,7 +288,13 @@ export class ShareNotificationService extends PuterService { issuerId, holderId, ); - await this.#announce(holderId, issuer, count, interrupt); + await this.#announce( + holderId, + issuer, + count, + interrupt, + targets.get(holderId) ?? null, + ); await this.#emailHolder( holderId, issuer, @@ -301,15 +329,19 @@ export class ShareNotificationService extends PuterService { issuer: string | undefined, count: number, interrupt: boolean, + target: ShareNotificationTarget | null, ): Promise { const silent = !interrupt; const open = await this.#openShareNotification(holderId); if (open) { + // Folding means the group now covers more than one item, so no + // single target describes it — the click goes to Shared instead. const folded = this.#payload( issuer, mergeShareSender(open.senders, issuer, count), open.groupUntil, + null, ); if ( await this.services.notification.notifyUpdate( @@ -331,6 +363,7 @@ export class ShareNotificationService extends PuterService { issuer, mergeShareSender([], issuer, count), Date.now() + this.#limits().pairWindowSeconds * 1000, + count === 1 ? target : null, ), { silent }, ); @@ -346,6 +379,7 @@ export class ShareNotificationService extends PuterService { issuer: string | undefined, senders: ShareSender[], groupUntil: number, + target: ShareNotificationTarget | null, ): Record { return { source: 'sharing', @@ -358,6 +392,8 @@ export class ShareNotificationService extends PuterService { count: shareNotifyCount(senders), senders, groupUntil, + // A masked path, not a URL: the GUI opens it in place. + ...(target ? { target } : {}), }, }; } @@ -498,7 +534,7 @@ export class ShareNotificationService extends PuterService { holderId: number, issuer: string | undefined, count: number, - itemNames: string[], + items: DigestItem[], mayOpen: boolean, ): Promise { // Explicitly false, not falsy: unset means on. @@ -541,11 +577,42 @@ export class ShareNotificationService extends PuterService { }, issuer, count, - itemNames, + items, mayOpen, ); } + /** + * One named, linked item for the digest. Built from the uuid and owner, not + * `share.path` — that is the owner's real path here, not the recipient's to + * see. Both forms name the owner first, which is where it comes from. + */ + #digestItem(share: ResolvedShare): DigestItem | null { + if (!share.name) return null; + const path = this.#targetPath(share); + if (!path) return { name: share.name }; + return { name: share.name, link: shareDeepLink(this.#appLink(), path) }; + } + + /** The masked path for a share, or `null` when it isn't addressable. */ + #targetPath(share: ResolvedShare): string | null { + if (!share.name) return null; + const ownerUsername = + share.owner?.username ?? ownerFromSharePath(share.path); + if (!ownerUsername) return null; + return maskedSharePath({ + name: share.name, + uid: share.entryUid, + ownerUsername, + }); + } + + /** A record's items, or its names alone when it predates the links. */ + #recordItems(record: DigestEntryRecord): DigestItem[] { + if (record.items?.length) return record.items; + return (record.names ?? []).map((name) => ({ name })); + } + /** * Queue a send into the recipient's digest and arm the window. The entry * goes to durable KV first, so nothing rides on this process surviving; the @@ -559,7 +626,7 @@ export class ShareNotificationService extends PuterService { >, sender: string | undefined, count: number, - names: string[], + items: DigestItem[], mayOpen: boolean, ): Promise { // A digest is one email, so the budget is spent opening one, not per @@ -573,7 +640,10 @@ export class ShareNotificationService extends PuterService { ...seed, sender, count, - names, + // `names` stays written so a node still running the previous build + // can flush this entry; `items` is what this one reads. + names: items.map((item) => item.name), + items, queuedAt: Date.now(), }; await this.stores.kv.set({ @@ -707,7 +777,7 @@ export class ShareNotificationService extends PuterService { entries, record.sender, record.count, - record.names ?? [], + this.#recordItems(record), ); } const [{ record: first }] = claimed; @@ -789,25 +859,30 @@ export class ShareNotificationService extends PuterService { const issuerId = actor.user?.id; if (typeof issuerId !== 'number') return; - const byEmail = new Map(); + const byEmail = new Map< + string, + { count: number; items: DigestItem[] } + >(); for (const share of shares) { if (!share.pending || !share.isNew || !share.recipientEmail) { continue; } const seen = byEmail.get(share.recipientEmail) ?? { count: 0, - names: [], + items: [] as DigestItem[], }; seen.count += 1; - if (share.name && seen.names.length < DIGEST_NAMES_PER_SENDER) { - seen.names.push(share.name); + if (share.name && seen.items.length < DIGEST_NAMES_PER_SENDER) { + // Named but not linked: there is no account to route yet, and + // the invite's own call to action is to create one. + seen.items.push({ name: share.name }); } byEmail.set(share.recipientEmail, seen); } if (byEmail.size === 0) return; const issuer = actor.user?.username; - for (const [to, { count, names }] of byEmail) { + for (const [to, { count, items }] of byEmail) { // Each address fails alone — one refused send must not cost the // next invitee their only channel. try { @@ -821,7 +896,7 @@ export class ShareNotificationService extends PuterService { { kind: 'invite', to }, issuer, count, - names, + items, mayOpen, ); } catch (err) { diff --git a/src/backend/services/share/shareDeepLink.test.ts b/src/backend/services/share/shareDeepLink.test.ts new file mode 100644 index 000000000..3c59f2c36 --- /dev/null +++ b/src/backend/services/share/shareDeepLink.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import { + maskedSharePath, + ownerFromSharePath, + shareDeepLink, + shareTargetLink, +} from './shareDeepLink.js'; + +const UID = '11111111-2222-4333-8444-555555555555'; + +describe('ownerFromSharePath', () => { + // Both forms name the owner first, which is the point: the masked path is + // what a recipient holds, the real one is what the issuer's request built. + it('reads the owner from either form of path', () => { + expect(ownerFromSharePath(`/alice/${UID}/report.txt`)).toBe('alice'); + expect(ownerFromSharePath('/alice/Documents/Q3/report.txt')).toBe( + 'alice', + ); + }); + + it('has no owner to give for a path that names none', () => { + expect(ownerFromSharePath('/')).toBeNull(); + expect(ownerFromSharePath('')).toBeNull(); + expect(ownerFromSharePath(undefined as unknown as string)).toBeNull(); + }); +}); + +describe('maskedSharePath', () => { + it('builds the form a recipient is given', () => { + expect( + maskedSharePath({ + name: 'report.txt', + uid: UID, + ownerUsername: 'alice', + }), + ).toBe(`/alice/${UID}/report.txt`); + }); + + it('refuses to build one with a piece missing', () => { + const target = { name: 'report.txt', uid: UID, ownerUsername: 'alice' }; + expect(maskedSharePath({ ...target, name: '' })).toBeNull(); + expect(maskedSharePath({ ...target, uid: '' })).toBeNull(); + expect(maskedSharePath({ ...target, ownerUsername: '' })).toBeNull(); + }); +}); + +describe('shareDeepLink', () => { + it('puts the whole path in one encoded parameter', () => { + expect(shareDeepLink('https://puter.com', `/alice/${UID}/a.txt`)).toBe( + `https://puter.com/?shared=%2Falice%2F${UID}%2Fa.txt`, + ); + }); + + // A self-hoster's origin may carry a port, and may or may not end in a + // slash; neither should produce `//?shared=`. + it('tolerates a trailing slash on the origin', () => { + expect(shareDeepLink('http://localhost:4100/', '/a/b/c')).toBe( + 'http://localhost:4100/?shared=%2Fa%2Fb%2Fc', + ); + }); + + // A name is the owner's text: `&` would start a second parameter and `#` + // would truncate the path, so the encoding is what keeps the link whole. + it('encodes a name that would otherwise break the query string', () => { + const link = shareDeepLink( + 'https://puter.com', + `/alice/${UID}/a&b#c d.txt`, + ); + expect(link).toContain('%26b%23c%20d.txt'); + expect(link.split('?')).toHaveLength(2); + expect(link).not.toContain('#'); + // Round-trips: what the GUI reads back is the path we meant. + const shared = new URL(link).searchParams.get('shared'); + expect(shared).toBe(`/alice/${UID}/a&b#c d.txt`); + }); +}); + +describe('shareTargetLink', () => { + it('links an addressable target and nothing else', () => { + expect( + shareTargetLink('https://puter.com', { + name: 'a.txt', + uid: UID, + ownerUsername: 'alice', + }), + ).toBe(`https://puter.com/?shared=%2Falice%2F${UID}%2Fa.txt`); + expect( + shareTargetLink('https://puter.com', { + name: 'a.txt', + uid: '', + ownerUsername: 'alice', + }), + ).toBeNull(); + }); +}); diff --git a/src/backend/services/share/shareDeepLink.ts b/src/backend/services/share/shareDeepLink.ts new file mode 100644 index 000000000..250cccdb5 --- /dev/null +++ b/src/backend/services/share/shareDeepLink.ts @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Links that open a shared item. Not derived from `ResolvedShare.path`: that is + * masked for the requester, and the issuer owns the entry, so it comes back as + * the owner's real path — which mailing would leak. + */ + +/** The query parameter the GUI routes on. */ +export const SHARE_DEEP_LINK_PARAM = 'shared'; + +export interface ShareTarget { + /** The entry's own name, which the masked path's last segment must be. */ + name: string; + /** The entry's uuid. */ + uid: string; + /** Whose entry it is. */ + ownerUsername: string; +} + +/** The owner out of either form of share path; both name it first. */ +export const ownerFromSharePath = (path: string): string | null => { + if (typeof path !== 'string') return null; + const owner = path.split('/')[1]; + return owner ? owner : null; +}; + +/** + * `///`, built without a request context so every reader + * gets the same. See `sharePathMask.ts` for how it is read back. + */ +export const maskedSharePath = (target: ShareTarget): string | null => { + const { name, uid, ownerUsername } = target; + if (!name || !uid || !ownerUsername) return null; + return `/${ownerUsername}/${uid}/${name}`; +}; + +/** + * A link that opens `path` once the recipient is signed in. Only the masked + * path travels — its second segment is the uuid, so a rename is recoverable and + * there is no second copy to disagree with the first. + */ +export const shareDeepLink = (origin: string, path: string): string => { + const base = origin.replace(/\/+$/, ''); + return `${base}/?${SHARE_DEEP_LINK_PARAM}=${encodeURIComponent(path)}`; +}; + +/** The link for a target, or `null` when it isn't addressable. */ +export const shareTargetLink = ( + origin: string, + target: ShareTarget, +): string | null => { + const path = maskedSharePath(target); + return path === null ? null : shareDeepLink(origin, path); +}; diff --git a/src/backend/services/share/shareEmail.test.ts b/src/backend/services/share/shareEmail.test.ts index 6054959f9..4da6d9479 100644 --- a/src/backend/services/share/shareEmail.test.ts +++ b/src/backend/services/share/shareEmail.test.ts @@ -45,6 +45,8 @@ interface SentEmail { to: string; subject: string; html: string; + /** The plain-text alternative, which carries its own links. */ + text: string; } const uniqueSuffix = (): string => @@ -116,11 +118,17 @@ describe('share email', () => { beforeEach(() => { sent = []; vi.spyOn(env.server.clients.email, 'sendRaw').mockImplementation( - async (options: { to: string; subject: string; html?: string }) => { + async (options: { + to: string; + subject: string; + html?: string; + text?: string; + }) => { sent.push({ to: options.to, subject: options.subject, html: options.html ?? '', + text: options.text ?? '', }); return null; }, @@ -464,6 +472,66 @@ describe('share email', () => { for (const file of files) expect(mail.html).toContain(file.name); }); + // The name in the mail links to the item. The path is the masked form the + // recipient is allowed to see, never the owner's real one. + it('links each named file to itself, by its masked path', async () => { + const owner = env.users.user; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const file = await makeFile(owner, 'deeplink'); + await shareWith(owner, recipient.email, [{ uid: file.uid }]); + + const mail = await waitForMail({ to: recipient.email }); + const masked = `/${owner.username}/${file.uid}/${file.name}`; + const link = `?shared=${encodeURIComponent(masked)}`; + expect(mail.html).toContain(link); + // Linked, not merely mentioned. + expect(mail.html).toContain(`${link}"`); + expect(mail.text).toContain(link); + // The owner's real path is theirs alone; only the mask travels. + expect(mail.html).not.toContain(`/${owner.username}/deeplink`); + }); + + it('links every file when several are shared at once', async () => { + const sender = env.users.admin; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const files = []; + for (const label of ['links-a', 'links-b']) { + files.push(await makeFile(sender, label)); + } + await shareWith( + sender, + recipient.email, + files.map((file) => ({ uid: file.uid })), + ); + + const mail = await waitForMail({ to: recipient.email }); + for (const file of files) { + const masked = `/${sender.username}/${file.uid}/${file.name}`; + expect(mail.html).toContain( + `?shared=${encodeURIComponent(masked)}`, + ); + } + }); + + // Nothing to route to yet, so the names stay plain and the call to action + // is still "create an account". + it('does not link the files in an invite', async () => { + const owner = env.users.user; + const address = uninvitedAddress(); + sent = []; + + const file = await makeFile(owner, 'invite-nolink'); + await shareWith(owner, address, [{ uid: file.uid }]); + + const mail = await waitForMail({ to: address }); + expect(mail.html).toContain(file.name); + expect(mail.html).not.toContain('?shared='); + }); + it('honors an account-wide unsubscribe, and offers the link to those who have not', async () => { const owner = env.users.user; const recipient = await signUpAndConfirm(uninvitedAddress()); @@ -544,11 +612,17 @@ describe('share email digest durability', () => { beforeEach(() => { sent = []; vi.spyOn(env.server.clients.email, 'sendRaw').mockImplementation( - async (options: { to: string; subject: string; html?: string }) => { + async (options: { + to: string; + subject: string; + html?: string; + text?: string; + }) => { sent.push({ to: options.to, subject: options.subject, html: options.html ?? '', + text: options.text ?? '', }); return null; }, diff --git a/src/backend/services/share/shareNotifyTitle.test.ts b/src/backend/services/share/shareNotifyTitle.test.ts index 92857073a..96e71e0d5 100644 --- a/src/backend/services/share/shareNotifyTitle.test.ts +++ b/src/backend/services/share/shareNotifyTitle.test.ts @@ -133,15 +133,20 @@ describe('shareSendersFromFields', () => { }); describe('email digests', () => { + const item = (name: string, link?: string) => + link === undefined ? { name } : { name, link }; + it('names the item for a single share, counts for more', () => { expect( - digestSubject([{ username: 'alice', count: 1, names: ['a.txt'] }]), + digestSubject([ + { username: 'alice', count: 1, items: [item('a.txt')] }, + ]), ).toBe('alice shared a.txt with you'); expect( digestSubject( [ - { username: 'alice', count: 1, names: ['a.txt'] }, - { username: 'bob', count: 2, names: ['b.txt'] }, + { username: 'alice', count: 1, items: [item('a.txt')] }, + { username: 'bob', count: 2, items: [item('b.txt')] }, ], { suffix: 'on Puter' }, ), @@ -151,26 +156,73 @@ describe('email digests', () => { it('renders one line per sender, naming what it can', () => { expect( digestLines([ - { username: 'alice', count: 1, names: ['a.txt'] }, - { username: 'bob', count: 5, names: ['b.txt', 'c.txt'] }, - { username: 'carol', count: 2, names: [] }, + { username: 'alice', count: 1, items: [item('a.txt')] }, + { + username: 'bob', + count: 5, + items: [item('b.txt'), item('c.txt')], + }, + { username: 'carol', count: 2, items: [] }, ]), ).toEqual([ - { sender: 'alice', what: 'a.txt' }, - { sender: 'bob', what: '5 items — b.txt, c.txt, +3 more' }, - { sender: 'carol', what: '2 items' }, + { + sender: 'alice', + what: 'a.txt', + lead: '', + items: [item('a.txt')], + trail: '', + }, + { + sender: 'bob', + what: '5 items — b.txt, c.txt, +3 more', + lead: '5 items — ', + items: [item('b.txt'), item('c.txt')], + trail: ', +3 more', + }, + { + sender: 'carol', + what: '2 items', + lead: '2 items', + items: [], + trail: '', + }, ]); }); + // The linked form has to read as the sentence form: whatever the template + // renders between `lead` and `trail`, the two must not describe different + // shares. + it('composes the same wording from the link parts as from `what`', () => { + for (const line of digestLines([ + { username: 'alice', count: 1, items: [item('a.txt', 'l1')] }, + { + username: 'bob', + count: 5, + items: [item('b.txt', 'l2'), item('c.txt')], + }, + { username: 'carol', count: 2, items: [] }, + ])) { + const rebuilt = + line.lead + + line.items.map((each) => each.name).join(', ') + + line.trail; + expect(rebuilt).toBe(line.what); + } + }); + it('merges a sender back into their own digest entry', () => { const merged = mergeDigestEntry( - [{ username: 'alice', count: 1, names: ['a.txt'] }], + [{ username: 'alice', count: 1, items: [item('a.txt', 'l1')] }], 'alice', 2, - ['b.txt'], + [item('b.txt', 'l2')], ); expect(merged).toEqual([ - { username: 'alice', count: 3, names: ['a.txt', 'b.txt'] }, + { + username: 'alice', + count: 3, + items: [item('a.txt', 'l1'), item('b.txt', 'l2')], + }, ]); }); }); diff --git a/src/backend/services/share/shareNotifyTitle.ts b/src/backend/services/share/shareNotifyTitle.ts index 166ab8df5..5c6772e2d 100644 --- a/src/backend/services/share/shareNotifyTitle.ts +++ b/src/backend/services/share/shareNotifyTitle.ts @@ -119,12 +119,18 @@ export const shareNotifyTitle = (senders: ShareSender[]): string => { // Email can't be rewritten the way a notification can, so it gets the grouped // wording by being held briefly and merged. These shapes are the accumulator. +/** One named item. No `link` when it isn't addressable; wording is unchanged. */ +export interface DigestItem { + name: string; + link?: string; +} + /** One sender's contribution to a digest email. */ export interface DigestEntry { username: string; count: number; - /** Item names, newest last. */ - names: string[]; + /** Named items, newest last. */ + items: DigestItem[]; } /** How many item names one digest line spells out before counting the rest. */ @@ -135,20 +141,20 @@ export const mergeDigestEntry = ( entries: DigestEntry[], username: string | undefined, count: number, - names: string[] = [], + items: DigestItem[] = [], ): DigestEntry[] => { const name = username || 'Someone'; const merged = entries.map((entry) => ({ ...entry, - names: [...entry.names], + items: [...entry.items], })); const existing = merged.find((entry) => entry.username === name); if (existing) { existing.count += count; - existing.names.push(...names); + existing.items.push(...items); return merged; } - merged.push({ username: name, count, names: [...names] }); + merged.push({ username: name, count, items: [...items] }); return merged; }; @@ -166,29 +172,47 @@ export const digestSubject = ( ); const what = total === 1 - ? (entries.find((entry) => entry.names.length > 0)?.names[0] ?? + ? (entries.find((entry) => entry.items.length > 0)?.items[0].name ?? 'an item') : `${total} items`; const base = `${senderList(entries)} shared ${what} with you`; return opts.suffix ? `${base} ${opts.suffix}` : base; }; +/** + * One line per sender, twice over: `what` as a sentence, and + * `lead`/`items`/`trail` split at the names so each can be linked. + * Concatenating the three reproduces `what` exactly. + */ +export interface DigestLine { + sender: string; + what: string; + lead: string; + items: DigestItem[]; + trail: string; +} + /** One rendered line per sender: who, and what they shared. */ -export const digestLines = ( - entries: DigestEntry[], -): Array<{ sender: string; what: string }> => +export const digestLines = (entries: DigestEntry[]): DigestLine[] => entries.map((entry) => { - const named = entry.names.slice(0, NAMED_ITEMS_LIMIT); - let what: string; + const named = entry.items.slice(0, NAMED_ITEMS_LIMIT); + const names = named.map((item) => item.name); + let lead: string; + let trail = ''; if (entry.count === 1 && named.length === 1) { - what = named[0]; + lead = ''; } else if (named.length === 0) { - what = `${entry.count} items`; + lead = `${entry.count} items`; } else { const rest = entry.count - named.length; - what = - `${entry.count} items — ${named.join(', ')}` + - (rest > 0 ? `, +${rest} more` : ''); + lead = `${entry.count} items — `; + if (rest > 0) trail = `, +${rest} more`; } - return { sender: entry.username, what }; + return { + sender: entry.username, + what: lead + names.join(', ') + trail, + lead, + items: named, + trail, + }; }); diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 0869f4d07..d627d3211 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -41,6 +41,8 @@ import UINotification from './UINotification.js'; import UIWindowWelcome from './UIWindowWelcome.js'; import launch_app from '../helpers/launch_app.js'; import item_icon from '../helpers/item_icon.js'; +import { SHARED_PATH_PARAM } from '../helpers/parse_shared_path.js'; +import resolve_shared_item from '../helpers/resolve_shared_item.js'; import apply_item_added_to_containers from '../helpers/apply_item_added_to_containers.js'; import UIWindowSearch from './UIWindowSearch.js'; @@ -208,6 +210,27 @@ async function UIDesktop (options) { } }); + /** Clicking a share notification opens the item, or Shared if grouped. */ + const share_notification_click = (notification) => { + if ( notification?.source !== 'sharing' ) return undefined; + const target = notification?.fields?.target; + if ( target?.path ) { + return () => { + open_shared_item(target.path); + }; + } + // Grouped: open where they all landed rather than picking one. + return () => { + UIWindow({ + path: window.shared_path, + title: i18n('shared'), + icon: window.icons['sidebar-folder-shared.svg'], + is_dir: true, + app: 'explorer', + }); + }; + }; + /** * This event is triggered if a user receives a notification during * an active session. @@ -231,6 +254,7 @@ async function UIDesktop (options) { icon: icon, value: notification, uid, + click: share_notification_click(notification), close: async () => { await fetch(`${window.api_origin}/notif/mark-ack`, { method: 'POST', @@ -266,6 +290,8 @@ async function UIDesktop (options) { title: notification.title, text: notification.text ?? notification.title, uid: notif_info.uid, + value: notification, + click: share_notification_click(notification), close: async () => { await fetch(`${window.api_origin}/notif/mark-ack`, { method: 'POST', @@ -1723,10 +1749,17 @@ async function UIDesktop (options) { return; } - // TODO: DRY everything here with open_item. Unfortunately we can't - // use open_item here because it's coupled with UI logic; - // it requires a UIItem element and cannot operate on a - // file path on its own. + await open_path_target(item_path, stat); + } + + /** + * Open a path as double-clicking would: the associated app for a file, an + * explorer window for a directory. + * + * TODO: DRY with open_item, which is coupled to a UIItem element and can't + * operate on a path alone. + */ + async function open_path_target (item_path, stat) { if ( ! stat.is_dir ) { if ( stat.associated_app ) { launch_app({ name: stat.associated_app.name }); @@ -1797,6 +1830,64 @@ async function UIDesktop (options) { }); } + /** Open an item somebody shared, addressed as `///`. */ + async function open_shared_item (shared_path) { + const stat = await resolve_shared_item(puter.fs, shared_path); + if ( ! stat ) { + UIAlert({ + message: i18n('error_user_or_path_not_found'), + type: 'error', + }); + return false; + } + + // `stat` returns the path this viewer may use, which is the one to open. + await open_path_target(stat.path ?? shared_path, stat); + return true; + } + + /** Take `?shared=` off the address bar so a reload doesn't act on it again. */ + function clear_shared_param () { + const params = new URLSearchParams(window.location.search); + params.delete(SHARED_PATH_PARAM); + const rest = params.toString(); + window.history.replaceState( + null, + document.title, + rest ? `${window.location.pathname}?${rest}` : (window.location.pathname || '/'), + ); + } + + /** + * Act on a share link. A share only ever reaches a real account, so a + * temporary session is never the recipient: signing out of the way first + * beats resolving the link as somebody who can't see it and burning it on + * a "not found". The link stays in the address bar across the prompt + * because login reloads on success, which brings it back for the account + * that can actually open it. + */ + async function handle_shared_link (shared_path) { + if ( window.user?.is_temp ) { + await UIWindowLogin({ + reload_on_success: true, + window_options: { cover_page: true, has_head: false }, + }); + // Dismissed without signing in: drop the link rather than loop. + if ( window.user?.is_temp ) clear_shared_param(); + return; + } + clear_shared_param(); + await open_shared_item(shared_path); + } + + //-------------------------------------------------------------------------------------- + // Opening an item someone shared, from the link in an email or notification + // i.e. https://puter.com/?shared=%2F%2F%2F + //-------------------------------------------------------------------------------------- + if ( window.url_query_params.has(SHARED_PATH_PARAM) ) { + await handle_shared_link(window.url_query_params.get(SHARED_PATH_PARAM)); + } + //-------------------------------------------------------------------------------------- // Direct download link // i.e. https://puter.com/?download= or https://puter.com/desktop?download= diff --git a/src/gui/src/helpers/parse_shared_path.js b/src/gui/src/helpers/parse_shared_path.js new file mode 100644 index 000000000..9b94eb323 --- /dev/null +++ b/src/gui/src/helpers/parse_shared_path.js @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** The query parameter a share link arrives on. */ +export const SHARED_PATH_PARAM = 'shared'; + +// The uuid segment of a shared item's path; see the backend's `sharePathMask`. +const UID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Read `///`, the form a recipient is given. `null` for + * anything else, so a hand-edited link is refused before it becomes a request. + * + * @param {string} shared_path + * @returns {{ owner: string, uid: string, name: string } | null} + */ +export default function parse_shared_path (shared_path) { + if ( typeof shared_path !== 'string' || ! shared_path.startsWith('/') ) { + return null; + } + const [, owner, uid, ...rest] = shared_path.split('/'); + if ( ! owner || ! uid || ! UID_PATTERN.test(uid) ) return null; + + const name = rest.join('/'); + // The uuid stands in for the parent; the segment after it is the item. + if ( ! name ) return null; + + return { owner, uid, name }; +} diff --git a/src/gui/src/helpers/parse_shared_path.test.js b/src/gui/src/helpers/parse_shared_path.test.js new file mode 100644 index 000000000..7fce2960f --- /dev/null +++ b/src/gui/src/helpers/parse_shared_path.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import parse_shared_path from './parse_shared_path.js'; + +const UID = '11111111-2222-4333-8444-555555555555'; + +describe('parse_shared_path', () => { + it('reads the owner, uuid and name a share link carries', () => { + expect(parse_shared_path(`/alice/${UID}/report.txt`)).toEqual({ + owner: 'alice', + uid: UID, + name: 'report.txt', + }); + }); + + // A shared folder stays navigable, so the name can go deeper than one + // segment once the recipient has opened into it. + it('keeps a path below the shared root', () => { + expect(parse_shared_path(`/alice/${UID}/dir/inner/file.txt`)).toEqual({ + owner: 'alice', + uid: UID, + name: 'dir/inner/file.txt', + }); + }); + + it('refuses anything that is not that shape', () => { + // Not a path at all. + expect(parse_shared_path('')).toBeNull(); + expect(parse_shared_path(undefined)).toBeNull(); + expect(parse_shared_path(42)).toBeNull(); + expect(parse_shared_path('alice/uuid/a.txt')).toBeNull(); + // A real path that merely looks similar. + expect(parse_shared_path('/alice/Documents/a.txt')).toBeNull(); + // Truncated: the uuid addresses the parent, so there is nothing to open. + expect(parse_shared_path(`/alice/${UID}`)).toBeNull(); + expect(parse_shared_path(`/alice/${UID}/`)).toBeNull(); + // Owner missing. + expect(parse_shared_path(`//${UID}/a.txt`)).toBeNull(); + // Not a uuid. + expect(parse_shared_path('/alice/not-a-uuid/a.txt')).toBeNull(); + expect( + parse_shared_path(`/alice/${UID.replace('1', 'z')}/a.txt`), + ).toBeNull(); + }); +}); diff --git a/src/gui/src/helpers/resolve_shared_item.js b/src/gui/src/helpers/resolve_shared_item.js new file mode 100644 index 000000000..8d17343c6 --- /dev/null +++ b/src/gui/src/helpers/resolve_shared_item.js @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import parse_shared_path from './parse_shared_path.js'; + +/** + * Find the item a share link addresses. The path only resolves while the name + * segment still matches, so a rename falls back to the uuid — which is what the + * item actually is. `fs` is a parameter so this needs no server to test. + * + * @param {{ stat: (opts: object) => Promise }} fs + * @param {string} shared_path + * @returns {Promise} The stat, or `null` if it can't be found. + */ +export default async function resolve_shared_item (fs, shared_path) { + const target = parse_shared_path(shared_path); + if ( ! target ) return null; + + try { + return await fs.stat({ path: shared_path, consistency: 'eventual' }); + } catch ( e ) { + // Fall through to the uuid. + } + + try { + return await fs.stat({ uid: target.uid, consistency: 'eventual' }); + } catch ( e ) { + return null; + } +} diff --git a/src/gui/src/helpers/resolve_shared_item.test.js b/src/gui/src/helpers/resolve_shared_item.test.js new file mode 100644 index 000000000..89393b426 --- /dev/null +++ b/src/gui/src/helpers/resolve_shared_item.test.js @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import resolve_shared_item from './resolve_shared_item.js'; + +const UID = '11111111-2222-4333-8444-555555555555'; +const PATH = `/alice/${UID}/report.txt`; + +/** A `stat` that answers for the calls named and throws for anything else. */ +const fakeFs = (answers) => ({ + stat: vi.fn(async (opts) => { + if ( opts.path && answers.byPath ) return answers.byPath; + if ( opts.uid && answers.byUid ) return answers.byUid; + throw new Error('not found'); + }), +}); + +describe('resolve_shared_item', () => { + it('resolves by path when the link is still accurate', async () => { + const fs = fakeFs({ byPath: { path: PATH, is_dir: false } }); + + expect(await resolve_shared_item(fs, PATH)).toEqual({ + path: PATH, + is_dir: false, + }); + // One call: no need for the fallback. + expect(fs.stat).toHaveBeenCalledTimes(1); + expect(fs.stat).toHaveBeenCalledWith({ + path: PATH, + consistency: 'eventual', + }); + }); + + // The owner renaming the item leaves the name segment stale, so the path + // resolves to nothing while the uuid still names the item. + it('falls back to the uuid when the name has gone stale', async () => { + const renamed = { path: `/alice/${UID}/renamed.txt`, is_dir: false }; + const fs = fakeFs({ byUid: renamed }); + + expect(await resolve_shared_item(fs, PATH)).toEqual(renamed); + expect(fs.stat).toHaveBeenCalledTimes(2); + expect(fs.stat).toHaveBeenLastCalledWith({ + uid: UID, + consistency: 'eventual', + }); + }); + + it('gives up when neither the path nor the uuid finds it', async () => { + const fs = fakeFs({}); + expect(await resolve_shared_item(fs, PATH)).toBeNull(); + expect(fs.stat).toHaveBeenCalledTimes(2); + }); + + // A link that isn't the shared shape is refused before any request goes + // out, so a hand-edited one can't become a lookup. + it('asks nothing for a path that is not a share link', async () => { + const fs = fakeFs({ byPath: { path: '/alice/Documents/a.txt' } }); + + expect( + await resolve_shared_item(fs, '/alice/Documents/a.txt'), + ).toBeNull(); + expect(fs.stat).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 419cda4c9..47a957d5f 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -909,7 +909,7 @@ if (jQuery) { // through to the desktop. // URLs that carry a desktop-only flow keep booting the desktop: auth popups // (`?embedded_in_popup=`), app deep links (`?app=`), direct downloads (`?download=`), -// fullpage mode (`?puter.fullpage=`), and iframe embeds. App metadata like +// shared items (`?shared=`), fullpage mode (`?puter.fullpage=`), and iframe embeds. App metadata like // fullpage_on_landing does NOT opt a landing out of the dashboard; it only affects // boots that still go through the desktop flow. { @@ -925,7 +925,8 @@ if (jQuery) { in_iframe || search_params.has('puter.fullpage') || search_params.has('app') || - search_params.has('download'); + search_params.has('download') || + search_params.has('shared'); const is_dashboard_alias = pathname === '/dashboard' || pathname === '/dashboard/'; const is_app_landing = /^\/app\/[^/]+\/?$/.test(pathname);