Land share-email links in the Dashboard's Shared view (#3634)

* Land share-email links in the Dashboard's Shared view

A share email's links opened the item on the desktop, and "Open Puter"
went to the bare origin — the recipient arrived on Home with nothing to
say what had just been shared. Every link now lands in the Dashboard's
Files tab, on Shared, with the shared items selected.

Each named file still links to itself. "Open Puter" carries every item
in the mail (?shared=a&shared=b), so the whole batch lands highlighted;
the digest records now keep each item's masked path for that. The
button's href is rendered raw like the item links, so `=` and `&` read
the same in the html and text parts, and the unsubscribe link is built
from a separate origin value.

On the GUI side `/?shared=` boots the dashboard rather than the desktop;
`/desktop?shared=` keeps opening the item in place. The Files tab
selects the rows by uid, which survives a rename. A share only reaches a
real account, so a share link never mints a temp user and an existing
temp session is asked to sign in first, as the desktop already did. The
address-bar cleanup both shells need moves into a helper.

* Keep the share email's button link within what mail clients tolerate

The "Open Puter" link names every item in the mail, capped at twenty. But
a single item's parameter is ~150 characters once the owner, uuid and an
encoded name are in it, so twenty of them run to several kilobytes — past
the ~2000 characters where older mail clients cut a URL off or stop making
it clickable — and the count alone couldn't do what its comment promised
for the mail's primary button.

Add a length budget beside the count: items go in, in digest order, only
while the whole link stays under it, so the button always works and the
first items are the ones highlighted. A single item always fits.

* Keep a share link's own item however long its name runs

`shareDeepLink` now builds through `sharedViewLink`, whose length budget
applied to the first item too: a parameter that alone overran it was
dropped, and the link came out as a bare `?shared=`. A name of a few
hundred characters — the GUI allows five hundred, and encoding triples
every non-ASCII one — was enough, so that item's own link in the mail
landed on Shared with nothing picked out, where it used to open the item.

The first item now always goes in; the budget only decides how many more
join it. One long link is still the item the mail is about, and it is no
worse than a bare origin for the clients that truncate it.
This commit is contained in:
Nariman Jelveh
2026-08-24 17:01:55 -07:00
committed by GitHub
parent 34af8d18d0
commit 2c9a5c4f7e
15 changed files with 387 additions and 63 deletions
+15 -8
View File
@@ -280,7 +280,8 @@ describe('EmailClient — share notification templates', () => {
items: [{ name: 'a.txt' }, { name: 'b.txt' }],
},
]),
link: 'https://puter.test',
link: 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md',
origin: 'https://puter.test',
unsubscribe_uuid: null,
};
@@ -364,15 +365,21 @@ describe('EmailClient — share notification templates', () => {
expect(preheader).toBeLessThan(html.indexOf('Hi alice,'));
});
it('points the call to action at the app and nowhere else', async () => {
const { html, text } = await renderShare(
'file_shared_with_you',
HOLDER,
);
// The button carries every item in the mail, so it is a query string with
// `&` and `=` in it — which must reach the client as written, in both parts.
it('points the call to action at Shared with every item picked out', async () => {
const link =
'https://puter.test/?shared=%2Fbob%2Fu1%2Fa.txt&shared=%2Fbob%2Fu2%2Fb.txt';
const { html, text } = await renderShare('file_shared_with_you', {
...HOLDER,
link,
});
expect(html).toContain('href="https://puter.test"');
expect(html).toContain(`href="${link}"`);
expect(html).toContain('>Open Puter</a>');
expect(text).toContain('Open Puter: https://puter.test');
expect(text).toContain(`Open Puter: ${link}`);
expect(html).not.toContain('&#x3D;');
expect(html).not.toContain('&amp;shared');
});
it('separates senders without a rule above the first', async () => {
+9 -4
View File
@@ -201,6 +201,9 @@ const textRow = (text: string, padding = '18px 0 0'): string => `
* The call to action. Padding sits on the cell and the color on `bgcolor` so
* Outlook still draws a real button (square-cornered, which is fine); the table
* goes full width under 600px so the tap target spans the card.
*
* `link` is triple-braced for the same reason as an item's: we build it, and a
* deep link's `=` and `&` should read the same in the html as in the text.
*/
const buttonRow = (label: string): string => `
<tr>
@@ -208,7 +211,7 @@ const buttonRow = (label: string): string => `
<table role="presentation" class="btn" cellpadding="0" cellspacing="0" border="0" style="border-collapse: separate;">
<tr>
<td align="center" bgcolor="${ACCENT}" style="background-color: ${ACCENT}; border-radius: 10px; padding: 14px 26px;">
<a href="{{link}}" style="display: inline-block; font-family: ${FONT}; font-size: 16px; line-height: 20px; font-weight: 600; color: #ffffff; text-decoration: none;">${label}</a>
<a href="{{{link}}}" style="display: inline-block; font-family: ${FONT}; font-size: 16px; line-height: 20px; font-weight: 600; color: #ffffff; text-decoration: none;">${label}</a>
</td>
</tr>
</table>
@@ -370,7 +373,9 @@ immediately</p>
/**
* A digest: shares to one recipient are held briefly and merged, so
* `shares` may carry several senders. The subject is composed by the
* service (see `digestSubject`), which owns the grouped wording.
* service (see `digestSubject`), which owns the grouped wording. `link`
* opens Shared with every item highlighted; `origin` is the bare site, for
* the unsubscribe link.
*/
file_shared_with_you: {
subject: '{{subject_line}}',
@@ -386,7 +391,7 @@ immediately</p>
'24px 0 0',
),
footer: `You're receiving this because someone shared with your Puter account.{{#if unsubscribe_uuid}}
<br /><a href="{{link}}/unsubscribe?user_uuid={{unsubscribe_uuid}}" style="color: inherit; text-decoration: underline;">Unsubscribe from notification emails</a>{{/if}}`,
<br /><a href="{{origin}}/unsubscribe?user_uuid={{unsubscribe_uuid}}" style="color: inherit; text-decoration: underline;">Unsubscribe from notification emails</a>{{/if}}`,
}),
text: `
Hi{{#if recipient}} {{recipient}}{{/if}},
@@ -404,7 +409,7 @@ immediately</p>
--
You're receiving this because someone shared with your Puter account.
{{#if unsubscribe_uuid}}Unsubscribe from notification emails:
{{link}}/unsubscribe?user_uuid={{unsubscribe_uuid}}{{/if}}
{{origin}}/unsubscribe?user_uuid={{unsubscribe_uuid}}{{/if}}
`,
},
// The only way to reach someone with no account. Same digest shape.
@@ -22,6 +22,7 @@ import type { Actor } from '../../core/actor';
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import { PuterService } from '../types';
import {
digestItemPaths,
digestLines,
digestSubject,
mergeDigestEntry,
@@ -37,6 +38,7 @@ import {
maskedSharePath,
ownerFromSharePath,
shareDeepLink,
sharedViewLink,
} from './shareDeepLink';
import type { ResolvedShare } from './ShareService';
@@ -591,7 +593,11 @@ export class ShareNotificationService extends PuterService {
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) };
return {
name: share.name,
link: shareDeepLink(this.#appLink(), path),
path,
};
}
/** The masked path for a share, or `null` when it isn't addressable. */
@@ -798,9 +804,16 @@ export class ShareNotificationService extends PuterService {
recipient: first.recipient,
subject_line: digestSubject(entries),
shares: digestLines(entries),
link: this.#appLink(),
// The template composes the URL, so `?` and `=`
// stay literal instead of escaping to `&#x3D;`.
// "Open Puter" lands on Shared with everything
// in this mail picked out, not just one item.
link: sharedViewLink(
this.#appLink(),
digestItemPaths(entries),
),
// The template composes the unsubscribe URL from
// the origin, so `?` and `=` stay literal instead
// of escaping to `&#x3D;`.
origin: this.#appLink(),
unsubscribe_uuid: first.recipientUuid ?? null,
},
);
@@ -21,7 +21,10 @@ import { describe, expect, it } from 'vitest';
import {
maskedSharePath,
ownerFromSharePath,
SHARE_DEEP_LINK_ITEMS_LIMIT,
SHARE_DEEP_LINK_MAX_LENGTH,
shareDeepLink,
sharedViewLink,
shareTargetLink,
} from './shareDeepLink.js';
@@ -94,6 +97,91 @@ describe('shareDeepLink', () => {
});
});
describe('sharedViewLink', () => {
it('repeats the parameter once per item, in order', () => {
const link = sharedViewLink('https://puter.com', [
`/alice/${UID}/a.txt`,
`/bob/${UID}/b.txt`,
]);
expect(link).toBe(
`https://puter.com/?shared=%2Falice%2F${UID}%2Fa.txt&shared=%2Fbob%2F${UID}%2Fb.txt`,
);
// Round-trips: the GUI reads back every path, as it was.
expect(new URL(link).searchParams.getAll('shared')).toEqual([
`/alice/${UID}/a.txt`,
`/bob/${UID}/b.txt`,
]);
});
// Nothing addressable still deserves a way in: the parameter alone opens
// Shared, the same place a link with items lands.
it('still opens Shared when there is nothing to pick out', () => {
expect(sharedViewLink('https://puter.com', [])).toBe(
'https://puter.com/?shared=',
);
});
// A name can run to hundreds of characters, and encoding multiplies
// non-ASCII ones; the link exists to name the item, so it always does,
// however long — the length cap only limits how many more join it.
it('keeps the first item even when it alone outgrows the length cap', () => {
const long = `/alice/${UID}/${'\u5831\u544a'.repeat(120)}.pdf`;
const short = `/alice/${UID}/a.txt`;
expect(shareDeepLink('https://puter.com', long).length).toBeGreaterThan(
SHARE_DEEP_LINK_MAX_LENGTH,
);
expect(
new URL(
shareDeepLink('https://puter.com', long),
).searchParams.getAll('shared'),
).toEqual([long]);
// Nothing fits after it, and nothing later is taken instead.
expect(
new URL(
sharedViewLink('https://puter.com', [long, short]),
).searchParams.getAll('shared'),
).toEqual([long]);
});
it('names an item once however often it was queued', () => {
const path = `/alice/${UID}/a.txt`;
expect(sharedViewLink('https://puter.com', [path, path])).toBe(
shareDeepLink('https://puter.com', path),
);
});
it('stops adding items where mail clients stop tolerating the length', () => {
const paths = Array.from(
{ length: SHARE_DEEP_LINK_ITEMS_LIMIT + 5 },
(_, i) => `/alice/${UID}/file-${i}.txt`,
);
const shared = new URL(
sharedViewLink('https://puter.com', paths),
).searchParams.getAll('shared');
expect(shared).toEqual(paths.slice(0, SHARE_DEEP_LINK_ITEMS_LIMIT));
});
// Twenty ordinary names already run to several kilobytes once encoded, so
// the count alone is no guard; the link itself has to stay short enough.
it('stops adding items before the link outgrows what mail clients tolerate', () => {
const paths = Array.from(
{ length: SHARE_DEEP_LINK_ITEMS_LIMIT },
(_, i) => `/alice/${UID}/${'quarterly report '.repeat(8)}${i}.pdf`,
);
const link = sharedViewLink('https://puter.com', paths);
expect(link.length).toBeLessThanOrEqual(SHARE_DEEP_LINK_MAX_LENGTH);
const shared = new URL(link).searchParams.getAll('shared');
// Only a leading run made it — the first items, none skipped.
expect(shared.length).toBeGreaterThan(1);
expect(shared.length).toBeLessThan(paths.length);
expect(shared).toEqual(paths.slice(0, shared.length));
// The next item would not have fit.
expect(
link.length + `&shared=${encodeURIComponent(paths[shared.length])}`.length,
).toBeGreaterThan(SHARE_DEEP_LINK_MAX_LENGTH);
});
});
describe('shareTargetLink', () => {
it('links an addressable target and nothing else', () => {
expect(
+47 -9
View File
@@ -18,9 +18,10 @@
*/
/**
* 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.
* Links that open a shared item: the dashboard's Files tab, on Shared, with the
* item highlighted. 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. */
@@ -53,15 +54,52 @@ export const maskedSharePath = (target: ShareTarget): string | null => {
};
/**
* 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.
* Items one link will highlight, at most. Past this the link still opens
* Shared, just without picking the rest out.
*/
export const shareDeepLink = (origin: string, path: string): string => {
const base = origin.replace(/\/+$/, '');
return `${base}/?${SHARE_DEEP_LINK_PARAM}=${encodeURIComponent(path)}`;
export const SHARE_DEEP_LINK_ITEMS_LIMIT = 20;
/**
* How long a link may run, in characters. Somewhere past two thousand, older
* mail clients cut a URL off or stop making it clickable — and this is the
* button — so items are added only while the whole link stays within this. The
* first item goes in regardless: a link that names nothing is no better than
* the origin, and one long name (hundreds of characters, tripled by encoding
* when non-ASCII) is still the item the mail is about.
*/
export const SHARE_DEEP_LINK_MAX_LENGTH = 2000;
/**
* A link that opens the recipient's Shared view with `paths` highlighted, once
* they are signed in. Only masked paths travel — each one's second segment is
* the uuid, so a rename is recoverable and there is no second copy to disagree
* with the first. With no paths the link still lands on Shared.
*/
export const sharedViewLink = (origin: string, paths: string[]): string => {
const base = `${origin.replace(/\/+$/, '')}/?`;
// The first items that fit, in order — never a later one over an
// earlier, so what is highlighted reads as the top of the list.
const params: string[] = [];
let length = base.length;
for (const path of new Set(paths)) {
if (params.length === SHARE_DEEP_LINK_ITEMS_LIMIT) break;
const param = `${SHARE_DEEP_LINK_PARAM}=${encodeURIComponent(path)}`;
const added = param.length + (params.length === 0 ? 0 : '&'.length);
const overLength = length + added > SHARE_DEEP_LINK_MAX_LENGTH;
if (params.length > 0 && overLength) break;
params.push(param);
length += added;
}
return (
base +
(params.length === 0 ? `${SHARE_DEEP_LINK_PARAM}=` : params.join('&'))
);
};
/** A link that opens `path`: the Shared view with that one item highlighted. */
export const shareDeepLink = (origin: string, path: string): string =>
sharedViewLink(origin, [path]);
/** The link for a target, or `null` when it isn't addressable. */
export const shareTargetLink = (
origin: string,
+24 -7
View File
@@ -139,6 +139,10 @@ describe('share email', () => {
vi.restoreAllMocks();
});
/** Where the "Open Puter" button points, as distinct from the item links. */
const openPuterHref = (html: string): string | undefined =>
html.match(/href="([^"]+)"[^>]*>Open Puter<\/a>/)?.[1];
const post = (path: string, token: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
@@ -351,8 +355,13 @@ describe('share email', () => {
`${owner.username} shared ${first.name} with you`,
);
expect(mail.html).toContain(first.name);
expect(mail.html).toContain('Open Puter');
expect(mail.html).toContain(`href="${env.origin}"`);
// The button opens Shared with the item picked out — on the full
// origin, port included, like every other link in the mail.
expect(openPuterHref(mail.html)).toBe(
`${env.origin}/?shared=${encodeURIComponent(
`/${owner.username}/${first.uid}/${first.name}`,
)}`,
);
expect(mail.html).toContain(recipient.username);
// A second share to the same pair inside the window is one more thing to
@@ -509,12 +518,20 @@ describe('share email', () => {
);
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)}`,
);
const masked = files.map(
(file) => `/${sender.username}/${file.uid}/${file.name}`,
);
for (const path of masked) {
expect(mail.html).toContain(`?shared=${encodeURIComponent(path)}`);
}
// "Open Puter" is one link for the whole mail: Shared, with every item
// in it picked out — not the origin, which would land them on Home.
const href = openPuterHref(mail.html);
expect(href).toBeDefined();
expect(new URL(href!).searchParams.getAll('shared').sort()).toEqual(
[...masked].sort(),
);
expect(mail.text).toContain(`Open Puter: ${href}`);
});
// Nothing to route to yet, so the names stay plain and the call to action
@@ -19,6 +19,7 @@
import { describe, expect, it } from 'vitest';
import {
digestItemPaths,
digestLines,
digestSubject,
mergeDigestEntry,
@@ -210,6 +211,26 @@ describe('email digests', () => {
}
});
it('lists every addressable item once, across senders, for one link', () => {
const paths = digestItemPaths([
{
username: 'alice',
count: 3,
items: [
{ name: 'a.txt', link: 'l1', path: '/alice/u1/a.txt' },
{ name: 'plain.txt' },
{ name: 'a.txt', link: 'l1', path: '/alice/u1/a.txt' },
],
},
{
username: 'bob',
count: 1,
items: [{ name: 'b.txt', link: 'l2', path: '/bob/u2/b.txt' }],
},
]);
expect(paths).toEqual(['/alice/u1/a.txt', '/bob/u2/b.txt']);
});
it('merges a sender back into their own digest entry', () => {
const merged = mergeDigestEntry(
[{ username: 'alice', count: 1, items: [item('a.txt', 'l1')] }],
+17 -1
View File
@@ -119,10 +119,15 @@ 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. */
/**
* One named item. No `link` or `path` when it isn't addressable; wording is
* unchanged. `path` is the masked form the link was built from, kept so the
* digest's own link can point at every item at once.
*/
export interface DigestItem {
name: string;
link?: string;
path?: string;
}
/** One sender's contribution to a digest email. */
@@ -158,6 +163,17 @@ export const mergeDigestEntry = (
return merged;
};
/** Every addressable item's masked path, across senders, in digest order. */
export const digestItemPaths = (entries: DigestEntry[]): string[] => {
const paths: string[] = [];
for (const entry of entries) {
for (const item of entry.items) {
if (item.path && !paths.includes(item.path)) paths.push(item.path);
}
}
return paths;
};
/**
* The digest's subject: "alice shared report.txt with you" when there is
* exactly one named item, counts otherwise.
+39 -8
View File
@@ -40,7 +40,7 @@ import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js'
import { icons } from '../../helpers/actionIcons.js';
import list_all_shared from '../../helpers/list_all_shared.js';
import { can_share, remember_shared_roots } from '../../helpers/shared_access.js';
import { parent_path_for, shared_crumbs_for } from '../../helpers/share_paths.js';
import { parent_path_for, shared_crumbs_for, shared_uids_from_paths } from '../../helpers/share_paths.js';
const { html_encode, SelectionArea } = window;
@@ -563,9 +563,14 @@ const TabFiles = {
// Check for initial file path from URL routing
if ( window.dashboard_initial_file_path ) {
const initialPath = window.dashboard_initial_file_path;
const sharedPaths = window.dashboard_initial_shared_paths;
delete window.dashboard_initial_file_path; // Clear so it only runs once
delete window.dashboard_initial_shared_paths;
this.pushNavHistory(initialPath);
this.renderDirectory(initialPath, { skipUrlUpdate: true });
const rendered = this.renderDirectory(initialPath, { skipUrlUpdate: true });
// A share link names what was just shared; pick it out once the
// listing is up, the way an upload lands highlighted.
if ( sharedPaths ) rendered.then(() => this.selectSharedRows(sharedPaths));
} else {
// Auto-select Home folder on initialization
const homeFolder = $el_window.find('[data-folder="Home"]');
@@ -4695,16 +4700,42 @@ const TabFiles = {
*/
selectUploadedRows (paths) {
const wanted = new Set(paths.map(p => String(p).toLowerCase()));
const matches = this.$el_window.find('.files-tab .files .row').filter(function () {
const rowPath = String($(this).attr('data-path') ?? '').toLowerCase();
return wanted.has(rowPath);
this.selectRowsWhere((row) => wanted.has(String(row.getAttribute('data-path') ?? '').toLowerCase()));
},
/**
* Selects the rows for the items a share link names, replacing the
* current selection. Matched by uid — the link's `/<owner>/<uid>/<name>`
* form carries it, and unlike the name it survives a rename. Values that
* aren't shared paths, or items no longer shared, match nothing.
*
* @param {string[]} sharedPaths - The link's `?shared=` values
* @returns {void}
*/
selectSharedRows (sharedPaths) {
const wanted = new Set(shared_uids_from_paths(sharedPaths));
if ( wanted.size === 0 ) return;
this.selectRowsWhere((row) => wanted.has(String(row.getAttribute('data-uid') ?? '').toLowerCase()));
},
/**
* Selects the rendered rows `matches` accepts, replacing the current
* selection and scrolling the first into view. No match leaves the
* selection untouched.
*
* @param {(row: HTMLElement) => boolean} matches
* @returns {void}
*/
selectRowsWhere (matches) {
const rows = this.$el_window.find('.files-tab .files .row').filter(function () {
return matches(this);
});
if ( matches.length === 0 ) return;
if ( rows.length === 0 ) return;
this.$el_window.find('.files-tab .files .row.selected').removeClass('selected');
matches.addClass('selected');
rows.addClass('selected');
this.updateFooterStats();
matches[0].scrollIntoView({ block: 'nearest' });
rows[0].scrollIntoView({ block: 'nearest' });
},
/**
+29
View File
@@ -26,6 +26,7 @@ import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
import UIWindowLogin from '../UIWindowLogin.js';
import UIWindowFeedback from '../UIWindowFeedback.js';
import apply_item_added_to_containers from '../../helpers/apply_item_added_to_containers.js';
import { clear_shared_param } from '../../helpers/parse_shared_path.js';
/**
* Creates and displays the Dashboard window.
*
@@ -83,6 +84,17 @@ async function UIDashboard (options) {
// dashboard's event handlers unbound.
const isKnownTabId = tab => tabs.some(t => t !== '-' && t.id === tab);
// A share link names items only a real account can hold, so a temporary
// session is never its recipient. It routes as a plain visit and is asked
// to sign in below; 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 see what it points at.
const sharedLinkPaths = window.dashboard_initial_route?.shared ?? null;
const sharedLinkNeedsLogin = Boolean(sharedLinkPaths && window.user?.is_temp);
if ( sharedLinkNeedsLogin ) {
window.dashboard_initial_route = { tab: 'apps', path: null };
}
// Tab to render active on open. Apps is the default (root URL / no hash);
// Home is reached via #home. Fall back to Apps for an unknown/absent route.
const routeTab = window.dashboard_initial_route?.tab;
@@ -187,6 +199,12 @@ async function UIDashboard (options) {
// Set initial file path BEFORE tabs are initialized (so TabFiles.init() can use it)
if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.path ) {
window.dashboard_initial_file_path = window.dashboard_initial_route.path;
} else if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.shared ) {
// A share link: open Shared, and pick out the items it names once
// the listing is up. Consumed here, so a reload shows Files plainly.
window.dashboard_initial_file_path = window.shared_path;
window.dashboard_initial_shared_paths = window.dashboard_initial_route.shared;
clear_shared_param('#files');
}
// Initialize all tabs
@@ -400,6 +418,17 @@ async function UIDashboard (options) {
}
}
if ( sharedLinkNeedsLogin ) {
// Not awaited: the dashboard is already up underneath the cover page.
UIWindowLogin({
reload_on_success: true,
window_options: { cover_page: true, has_head: false },
}).then(() => {
// Dismissed without signing in: drop the link rather than loop.
if ( window.user?.is_temp ) clear_shared_param();
});
}
// Handle browser back/forward navigation
// This handler is called for both hashchange (manual hash changes) and popstate (back/forward)
// A single back/forward fires BOTH popstate and hashchange; track the last
+4 -15
View File
@@ -41,7 +41,7 @@ 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 { SHARED_PATH_PARAM, clear_shared_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';
@@ -1846,18 +1846,6 @@ async function UIDesktop (options) {
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
@@ -1881,8 +1869,9 @@ async function UIDesktop (options) {
}
//--------------------------------------------------------------------------------------
// 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>
// Opening an item someone shared, on the desktop
// i.e. https://puter.com/desktop?shared=%2F<owner>%2F<uuid>%2F<name>
// (the same link at the root lands in the dashboard's Shared view instead)
//--------------------------------------------------------------------------------------
if ( window.url_query_params.has(SHARED_PATH_PARAM) ) {
await handle_shared_link(window.url_query_params.get(SHARED_PATH_PARAM));
+16
View File
@@ -20,6 +20,22 @@
/** The query parameter a share link arrives on. */
export const SHARED_PATH_PARAM = 'shared';
/**
* Take `?shared=` off the address bar so a reload doesn't act on it again.
*
* @param {string} [hash] - What to leave after the `#`; the current hash if omitted
*/
export function clear_shared_param (hash = window.location.hash) {
const params = new URLSearchParams(window.location.search);
params.delete(SHARED_PATH_PARAM);
const rest = params.toString();
window.history.replaceState(
null,
document.title,
`${window.location.pathname || '/'}${rest ? `?${rest}` : ''}${hash || ''}`,
);
}
// 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;
+17
View File
@@ -44,6 +44,23 @@ export const parse_shared_path = (abs_path) => {
return { owner, uid, segments };
};
/**
* The uids a list of shared paths names, in order and without repeats. Anything
* that isn't a shared path falls away: a link's values are user-visible text, so
* a hand-edited one must be ignored rather than become a lookup.
*
* @param {string[]} paths
* @returns {string[]}
*/
export const shared_uids_from_paths = (paths) => {
const uids = [];
for ( const path of Array.isArray(paths) ? paths : [] ) {
const uid = parse_shared_path(path)?.uid.toLowerCase();
if ( uid && ! uids.includes(uid) ) uids.push(uid);
}
return uids;
};
/** The shared item itself, as opposed to something inside it. */
export const is_share_root = (abs_path) =>
parse_shared_path(abs_path)?.segments.length === 1;
+24
View File
@@ -23,6 +23,7 @@ import {
parent_path_for,
parse_shared_path,
shared_crumbs_for,
shared_uids_from_paths,
} from './share_paths.js';
const UID = '11111111-2222-3333-4444-555555555555';
@@ -48,6 +49,29 @@ describe('parse_shared_path', () => {
});
});
describe('shared_uids_from_paths', () => {
const OTHER = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
it('reads each link value down to the uid it names, once', () => {
expect(shared_uids_from_paths([
`/jfcastro/${UID}/Contents`,
`/jfcastro/${UID.toUpperCase()}/Contents`,
`/other/${OTHER}/f.txt`,
])).toEqual([UID, OTHER]);
});
it('drops anything that is not a shared path', () => {
expect(shared_uids_from_paths([
'',
'/jfcastro/Documents/f.txt',
`/jfcastro/${UID}`,
'not a path',
`/jfcastro/${UID}/f.txt`,
])).toEqual([UID]);
expect(shared_uids_from_paths(undefined)).toEqual([]);
});
});
describe('is_share_root', () => {
it('is true only for the shared item itself', () => {
expect(is_share_root(`/jfcastro/${UID}/Contents`)).toBe(true);
+20 -7
View File
@@ -909,9 +909,11 @@ 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=`),
// 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.
// fullpage mode (`?puter.fullpage=`), and iframe embeds. A share link (`?shared=`,
// from an email) lands in the dashboard: Files, on Shared, with the items it names
// picked out; `/desktop?shared=` opens the item on the desktop instead. 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.
{
const pathname = window.location.pathname;
const search_params = new URLSearchParams(window.location.search);
@@ -925,14 +927,22 @@ if (jQuery) {
in_iframe ||
search_params.has('puter.fullpage') ||
search_params.has('app') ||
search_params.has('download') ||
search_params.has('shared');
search_params.has('download');
const is_dashboard_alias =
pathname === '/dashboard' || pathname === '/dashboard/';
const is_app_landing = /^\/app\/[^/]+\/?$/.test(pathname);
if (is_dashboard_alias || ((pathname === '/' || is_app_landing) && !needs_desktop_at_root)) {
window.is_dashboard_mode = true;
window.dashboard_initial_route = parseDashboardRoute();
// A share link outranks the hash: Files, on Shared, with what it names
// picked out. The values go through raw — the Files tab validates them.
if (search_params.has('shared')) {
window.dashboard_initial_route = {
tab: 'files',
path: null,
shared: search_params.getAll('shared'),
};
}
}
}
@@ -2029,11 +2039,14 @@ window.initgui = async function (options) {
// Un-authed but not first visit -> try to log in/sign up
// -------------------------------------------------------------------------------------
// App landing pages (`/app/<name>`, incl. `/desktop/app/<name>`) require a
// real account even on a first visit — never a temp user.
// real account even on a first visit — never a temp user. So does a share
// link: a share only ever reaches a real account, so a temporary one could
// never see what it points at.
const is_app_landing_page = window.url_paths[0] === 'app' && !!window.url_paths[1];
const is_share_link = window.url_query_params.has('shared');
if (
!window.is_auth() &&
(!window.first_visit_ever || window.disable_temp_users || is_app_landing_page)
(!window.first_visit_ever || window.disable_temp_users || is_app_landing_page || is_share_link)
) {
// `npm start --server=<remote>` serves this GUI locally while pointing
// `gui_origin` at a remote Puter. There is nothing here to log into: