diff --git a/src/backend/clients/email/EmailClient.test.ts b/src/backend/clients/email/EmailClient.test.ts
index 0550d51a2..c4534dec7 100644
--- a/src/backend/clients/email/EmailClient.test.ts
+++ b/src/backend/clients/email/EmailClient.test.ts
@@ -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');
- expect(text).toContain('Open Puter: https://puter.test');
+ expect(text).toContain(`Open Puter: ${link}`);
+ expect(html).not.toContain('=');
+ expect(html).not.toContain('&shared');
});
it('separates senders without a rule above the first', async () => {
diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts
index e423ed138..cef7ae557 100644
--- a/src/backend/clients/email/templates.ts
+++ b/src/backend/clients/email/templates.ts
@@ -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 => `
@@ -208,7 +211,7 @@ const buttonRow = (label: string): string => `
@@ -370,7 +373,9 @@ immediately
/**
* 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
'24px 0 0',
),
footer: `You're receiving this because someone shared with your Puter account.{{#if unsubscribe_uuid}}
-
Unsubscribe from notification emails{{/if}}`,
+
Unsubscribe from notification emails{{/if}}`,
}),
text: `
Hi{{#if recipient}} {{recipient}}{{/if}},
@@ -404,7 +409,7 @@ immediately
--
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.
diff --git a/src/backend/services/share/ShareNotificationService.ts b/src/backend/services/share/ShareNotificationService.ts
index 7146ce4cf..d31f1ad52 100644
--- a/src/backend/services/share/ShareNotificationService.ts
+++ b/src/backend/services/share/ShareNotificationService.ts
@@ -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 `=`.
+ // "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 `=`.
+ origin: this.#appLink(),
unsubscribe_uuid: first.recipientUuid ?? null,
},
);
diff --git a/src/backend/services/share/shareDeepLink.test.ts b/src/backend/services/share/shareDeepLink.test.ts
index 3c59f2c36..fe590143a 100644
--- a/src/backend/services/share/shareDeepLink.test.ts
+++ b/src/backend/services/share/shareDeepLink.test.ts
@@ -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(
diff --git a/src/backend/services/share/shareDeepLink.ts b/src/backend/services/share/shareDeepLink.ts
index 250cccdb5..193cb3be4 100644
--- a/src/backend/services/share/shareDeepLink.ts
+++ b/src/backend/services/share/shareDeepLink.ts
@@ -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,
diff --git a/src/backend/services/share/shareEmail.test.ts b/src/backend/services/share/shareEmail.test.ts
index 4da6d9479..a367590b2 100644
--- a/src/backend/services/share/shareEmail.test.ts
+++ b/src/backend/services/share/shareEmail.test.ts
@@ -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
diff --git a/src/backend/services/share/shareNotifyTitle.test.ts b/src/backend/services/share/shareNotifyTitle.test.ts
index 96e71e0d5..eee45ec2c 100644
--- a/src/backend/services/share/shareNotifyTitle.test.ts
+++ b/src/backend/services/share/shareNotifyTitle.test.ts
@@ -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')] }],
diff --git a/src/backend/services/share/shareNotifyTitle.ts b/src/backend/services/share/shareNotifyTitle.ts
index 5c6772e2d..047fdbbb2 100644
--- a/src/backend/services/share/shareNotifyTitle.ts
+++ b/src/backend/services/share/shareNotifyTitle.ts
@@ -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.
diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js
index bb7a734db..020064ce5 100644
--- a/src/gui/src/UI/Dashboard/TabFiles.js
+++ b/src/gui/src/UI/Dashboard/TabFiles.js
@@ -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 `///`
+ * 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' });
},
/**
diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js
index 456e68ebe..6e596ff4b 100644
--- a/src/gui/src/UI/Dashboard/UIDashboard.js
+++ b/src/gui/src/UI/Dashboard/UIDashboard.js
@@ -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
diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js
index d627d3211..b0754230e 100644
--- a/src/gui/src/UI/UIDesktop.js
+++ b/src/gui/src/UI/UIDesktop.js
@@ -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%2F%2F
+ // Opening an item someone shared, on the desktop
+ // i.e. https://puter.com/desktop?shared=%2F%2F%2F
+ // (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));
diff --git a/src/gui/src/helpers/parse_shared_path.js b/src/gui/src/helpers/parse_shared_path.js
index 9b94eb323..13a1c23fb 100644
--- a/src/gui/src/helpers/parse_shared_path.js
+++ b/src/gui/src/helpers/parse_shared_path.js
@@ -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;
diff --git a/src/gui/src/helpers/share_paths.js b/src/gui/src/helpers/share_paths.js
index 1d056f634..140483650 100644
--- a/src/gui/src/helpers/share_paths.js
+++ b/src/gui/src/helpers/share_paths.js
@@ -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;
diff --git a/src/gui/src/helpers/share_paths.test.js b/src/gui/src/helpers/share_paths.test.js
index 53550af58..c065e8f00 100644
--- a/src/gui/src/helpers/share_paths.test.js
+++ b/src/gui/src/helpers/share_paths.test.js
@@ -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);
diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js
index 53cb0364b..691effce4 100644
--- a/src/gui/src/initgui.js
+++ b/src/gui/src/initgui.js
@@ -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/`, incl. `/desktop/app/`) 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=` serves this GUI locally while pointing
// `gui_origin` at a remote Puter. There is nothing here to log into: