feat(share): deep-link the shared item from email and notifications

A share email named the file but had nowhere to go: the only link was "Open
Puter", and finding what someone shared meant hunting for it under Shared. Each
named file in the digest now links to itself, and a notification covering a
single item points at that item.

The link carries one parameter, the masked path a recipient is already given:

    https://puter.com/?shared=%2Falice%2F<uuid>%2Freport.txt

Its second segment is the uuid, so there is no second copy of it to disagree
with the path, and the GUI can still find the entry when a rename has left the
name segment stale - it stats the path, then falls back to the uuid.

Built from the owner, uuid and name rather than from `ResolvedShare.path`.
That path is masked for whoever made the request, and the issuer owns the
entry, so it comes back as the owner's *real* path - mailing it would tell the
recipient which folders the owner keeps things in, which is the one thing
masking exists to prevent. A test asserts the real path never reaches the mail.

`digestLines` now returns `lead`/`items`/`trail` beside `what`, so the template
can put an anchor around each name while Handlebars keeps escaping the names
themselves; the URL is machine-built from the configured origin and one encoded
path, so it stays literal. Concatenating the parts reproduces `what` exactly,
which a test pins - the linked and sentence forms must not describe different
shares.

Notifications carry the masked path rather than a URL: the recipient is already
in the GUI, which opens the item in place instead of reloading. Only a
single-item notification gets a target; folding into a group drops it rather
than picking one of five.

In the GUI, `?shared=` joins `?download=` and `?app=` as a param that keeps the
desktop booting at `/`, and the handler reuses the `/@user` public-folder flow -
extracted to `open_path_target`, which carried a TODO asking for exactly this -
so a file opens in its associated app and a folder in an explorer window. The
param is stripped from the address bar first, so a reload lands on the desktop
rather than opening the item twice.

Invites are deliberately not linked: there is no account to route to yet, and
the invite's own call to action is to create one.

Rolling-deploy safe: a digest entry queued before this has `names` and no
`items`, and still flushes - without links. New entries write both, so a node
on the previous build can flush them too.
This commit is contained in:
Juan Castro
2026-08-21 16:08:39 -04:00
parent e7be457a56
commit 202c43f46e
15 changed files with 871 additions and 65 deletions
+47 -6
View File
@@ -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" <no-reply@puter.test>';
@@ -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&amp;d &quot;notes&quot;.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(`<a href="${link}"`);
expect(html).toContain(`>notes.md</a>`);
expect(text).toContain(link);
expect(html).not.toContain('&#x3D;');
});
// 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</a>');
});
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',
});
+11 -2
View File
@@ -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}}<a href="{{{this.link}}}" style="color: ${ACCENT}; text-decoration: underline;">{{this.name}}</a>{{else}}{{this.name}}{{/if}}{{/each}}{{this.trail}}`;
const SHARE_LIST_ROW = `
<tr>
<td class="panel" style="padding: 6px 18px; background-color: ${PANEL_BG}; border: 1px solid ${PANEL_BORDER}; border-radius: 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
{{#each shares}}
<tr>
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared {{this.what}}</td>
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}</td>
</tr>
{{/each}}
</table>
@@ -386,7 +394,8 @@ immediately</p>
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}}
@@ -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();
@@ -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<string, unknown>): 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<number, number>();
const named = new Map<number, string[]>();
const named = new Map<number, DigestItem[]>();
const targets = new Map<number, ShareNotificationTarget | null>();
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<void> {
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<string, unknown> {
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<void> {
// 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<void> {
// 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<string, { count: number; names: string[] }>();
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) {
@@ -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 <https://www.gnu.org/licenses/>.
*/
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();
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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;
};
/**
* `/<owner>/<uuid>/<name>`, 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);
};
+76 -2
View File
@@ -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;
},
@@ -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')],
},
]);
});
});
+42 -18
View File
@@ -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,
};
});
+95 -4
View File
@@ -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 `/<owner>/<uuid>/<name>`. */
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<owner>%2F<uuid>%2F<name>
//--------------------------------------------------------------------------------------
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=<file_url> or https://puter.com/desktop?download=<file_url>
+46
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/** 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 `/<owner>/<uuid>/<name>`, 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 };
}
@@ -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();
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<object> }} fs
* @param {string} shared_path
* @returns {Promise<object | null>} 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;
}
}
@@ -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();
});
});
+3 -2
View File
@@ -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);