mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 23:17:23 +00:00
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:
@@ -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&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(`<a href="${link}"`);
|
||||
expect(html).toContain(`>notes.md</a>`);
|
||||
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</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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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')],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user