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: