diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts index 390e207376..29fb54b78f 100644 --- a/src/backend/controllers/oidc/OIDCController.test.ts +++ b/src/backend/controllers/oidc/OIDCController.test.ts @@ -424,9 +424,14 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => { return String(oidc().verifyState(state!)?.redirect_uri); }; - const returnToFor = (paths: string[], path = '/') => { + const returnToFor = ( + paths: string[], + path = '/', + recipientUuid?: string, + ) => { const params = new URLSearchParams(); for (const p of paths) params.append('shared', p); + if (recipientUuid) params.set('user_uuid', recipientUuid); return `${path}?${params.toString()}`; }; @@ -460,6 +465,16 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => { ]); }); + it('carries a valid recipient account hint with the share', async () => { + const recipientUuid = uuidv4(); + const uri = await redirectUriFor( + returnToFor([sharedPath('Report.pdf')], '/', recipientUuid), + ); + expect(new URL(uri).searchParams.get('user_uuid')).toBe( + recipientUuid, + ); + }); + it('carries no more items than a share link may name', async () => { const paths = Array.from({ length: 25 }, (_, i) => sharedPath(`file-${i}.txt`), @@ -481,6 +496,8 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => { // a parameter that isn't `shared`, alone or alongside one '/?x=1', `${returnToFor([sharedPath('a.txt')])}&x=1`, + `${returnToFor([sharedPath('a.txt')])}&user_uuid=not-a-uuid`, + `${returnToFor([sharedPath('a.txt')])}&user_uuid=${uuidv4()}&user_uuid=${uuidv4()}`, // the root is only a destination when it names something '/', // still no origin smuggling, share link or not @@ -830,7 +847,7 @@ describe('OIDCController login callback', () => { const state = oidc().signState({ provider: 'custom', - redirect_uri: `${TEST_ORIGIN}/?shared=${encodeURIComponent(shared)}`, + redirect_uri: `${TEST_ORIGIN}/?shared=${encodeURIComponent(shared)}&user_uuid=${encodeURIComponent(created.user!.uuid)}`, }); vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({ access_token: 'access', @@ -853,6 +870,7 @@ describe('OIDCController login callback', () => { expect(url.searchParams.get('auth_error')).toBe('1'); expect(url.searchParams.get('action')).toBe('login'); expect(url.searchParams.getAll('shared')).toEqual([shared]); + expect(url.searchParams.get('user_uuid')).toBe(created.user!.uuid); }); it('redirects back to an /app/ landing after sign-in', async () => { diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index 4162f53ef9..0188713d54 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -18,6 +18,7 @@ */ import crypto from 'node:crypto'; +import { validate as validateUuid } from 'uuid'; import type { Request, Response } from 'express'; import { HttpError } from '../../core/http/HttpError.js'; import type { PuterRouter } from '../../core/http/PuterRouter.js'; @@ -27,6 +28,7 @@ import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js'; import { SHARE_DEEP_LINK_ITEMS_LIMIT, SHARE_DEEP_LINK_PARAM, + SHARE_RECIPIENT_PARAM, } from '../../services/share/shareDeepLink.js'; const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; @@ -98,9 +100,18 @@ function isWhitelistedReturnPath(path: string): boolean { * text, so a hand-edited one is refused rather than reflected back into the * browser. */ -function sharedPathsFromReturnQuery(query: string): string[] | null { +function sharedReturnQuery(query: string): { + paths: string[]; + recipientUuid?: string; +} | null { const paths: string[] = []; + let recipientUuid: string | undefined; for (const [key, value] of new URLSearchParams(query)) { + if (key === SHARE_RECIPIENT_PARAM) { + if (recipientUuid || !validateUuid(value)) return null; + recipientUuid = value; + continue; + } if (key !== SHARE_DEEP_LINK_PARAM) return null; const parsed = parseMaskedSharePath(value); // The segment after the uuid is the shared item itself. A mask without @@ -116,7 +127,7 @@ function sharedPathsFromReturnQuery(query: string): string[] | null { paths.push(value); } } - return paths; + return { paths, recipientUuid }; } /** @@ -135,15 +146,19 @@ function sanitizeReturnTo(raw: string): string | null { const shared = separator === -1 - ? [] - : sharedPathsFromReturnQuery(raw.slice(separator + 1)); + ? { paths: [] } + : sharedReturnQuery(raw.slice(separator + 1)); if (shared === null) return null; // The root is only a destination when it names something: on its own it is // where the flow already lands. - if (shared.length === 0) return path === '/' ? null : path; + if (shared.paths.length === 0) return path === '/' ? null : path; const params = new URLSearchParams(); - for (const value of shared) params.append(SHARE_DEEP_LINK_PARAM, value); + for (const value of shared.paths) + params.append(SHARE_DEEP_LINK_PARAM, value); + if (shared.recipientUuid) { + params.set(SHARE_RECIPIENT_PARAM, shared.recipientUuid); + } return `${path}?${params.toString()}`; } @@ -175,12 +190,15 @@ function buildErrorRedirectUrl( // its success reloads it, so they have to be on it to survive. let pagePath = '/'; let sharedPaths: string[] = []; + let shareRecipientUuid: string | undefined; if (typeof stateDecoded?.redirect_uri === 'string') { try { const stateUrl = new URL(stateDecoded.redirect_uri); if (isWhitelistedReturnPath(stateUrl.pathname)) { pagePath = stateUrl.pathname; - sharedPaths = sharedPathsFromReturnQuery(stateUrl.search) ?? []; + const shared = sharedReturnQuery(stateUrl.search); + sharedPaths = shared?.paths ?? []; + shareRecipientUuid = shared?.recipientUuid; } } catch { // unparsable redirect_uri: fall back to the root page @@ -225,6 +243,9 @@ function buildErrorRedirectUrl( for (const path of sharedPaths) { params.append(SHARE_DEEP_LINK_PARAM, path); } + if (shareRecipientUuid) { + params.set(SHARE_RECIPIENT_PARAM, shareRecipientUuid); + } return `${base}${pagePath}?${params.toString()}`; } diff --git a/src/backend/services/share/ShareNotificationService.ts b/src/backend/services/share/ShareNotificationService.ts index c78cee54b5..df30edb3de 100644 --- a/src/backend/services/share/ShareNotificationService.ts +++ b/src/backend/services/share/ShareNotificationService.ts @@ -711,7 +711,20 @@ export class ShareNotificationService extends PuterService { /** A record's items, or its names alone when it predates the links. */ #recordItems(record: DigestEntryRecord): DigestItem[] { - if (record.items?.length) return record.items; + if (record.items?.length) { + return record.items.map((item) => + item.path + ? { + ...item, + link: shareDeepLink( + this.#appLink(), + item.path, + record.recipientUuid, + ), + } + : item, + ); + } return (record.names ?? []).map((name) => ({ name })); } @@ -905,6 +918,7 @@ export class ShareNotificationService extends PuterService { link: sharedViewLink( this.#appLink(), digestItemPaths(entries), + first.recipientUuid, ), // The template composes the unsubscribe URL from // the origin, so `?` and `=` stay literal instead diff --git a/src/backend/services/share/shareDeepLink.test.ts b/src/backend/services/share/shareDeepLink.test.ts index fe590143a2..6839f491b4 100644 --- a/src/backend/services/share/shareDeepLink.test.ts +++ b/src/backend/services/share/shareDeepLink.test.ts @@ -23,6 +23,7 @@ import { ownerFromSharePath, SHARE_DEEP_LINK_ITEMS_LIMIT, SHARE_DEEP_LINK_MAX_LENGTH, + SHARE_RECIPIENT_PARAM, shareDeepLink, sharedViewLink, shareTargetLink, @@ -98,6 +99,31 @@ describe('shareDeepLink', () => { }); describe('sharedViewLink', () => { + it('names the account that received the share without changing access', () => { + const recipientUuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; + const link = sharedViewLink( + 'https://puter.com', + [`/alice/${UID}/a.txt`], + recipientUuid, + ); + const params = new URL(link).searchParams; + expect(params.get(SHARE_RECIPIENT_PARAM)).toBe(recipientUuid); + expect(params.get('shared')).toBe(`/alice/${UID}/a.txt`); + }); + + it('counts the recipient hint toward the email-client length limit', () => { + 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, + 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + ); + expect(link.length).toBeLessThanOrEqual(SHARE_DEEP_LINK_MAX_LENGTH); + }); + it('repeats the parameter once per item, in order', () => { const link = sharedViewLink('https://puter.com', [ `/alice/${UID}/a.txt`, diff --git a/src/backend/services/share/shareDeepLink.ts b/src/backend/services/share/shareDeepLink.ts index 193cb3be41..dd98767a15 100644 --- a/src/backend/services/share/shareDeepLink.ts +++ b/src/backend/services/share/shareDeepLink.ts @@ -26,6 +26,7 @@ /** The query parameter the GUI routes on. */ export const SHARE_DEEP_LINK_PARAM = 'shared'; +export const SHARE_RECIPIENT_PARAM = 'user_uuid'; export interface ShareTarget { /** The entry's own name, which the masked path's last segment must be. */ @@ -75,12 +76,19 @@ export const SHARE_DEEP_LINK_MAX_LENGTH = 2000; * 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 => { +export const sharedViewLink = ( + origin: string, + paths: string[], + recipientUuid?: string, +): string => { const base = `${origin.replace(/\/+$/, '')}/?`; + const recipient = recipientUuid + ? `&${SHARE_RECIPIENT_PARAM}=${encodeURIComponent(recipientUuid)}` + : ''; // 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; + let length = base.length + recipient.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)}`; @@ -92,13 +100,17 @@ export const sharedViewLink = (origin: string, paths: string[]): string => { } return ( base + - (params.length === 0 ? `${SHARE_DEEP_LINK_PARAM}=` : params.join('&')) + (params.length === 0 ? `${SHARE_DEEP_LINK_PARAM}=` : params.join('&')) + + recipient ); }; /** A link that opens `path`: the Shared view with that one item highlighted. */ -export const shareDeepLink = (origin: string, path: string): string => - sharedViewLink(origin, [path]); +export const shareDeepLink = ( + origin: string, + path: string, + recipientUuid?: string, +): string => sharedViewLink(origin, [path], recipientUuid); /** The link for a target, or `null` when it isn't addressable. */ export const shareTargetLink = ( diff --git a/src/backend/services/share/shareEmail.test.ts b/src/backend/services/share/shareEmail.test.ts index a367590b2c..61ed1cd2e7 100644 --- a/src/backend/services/share/shareEmail.test.ts +++ b/src/backend/services/share/shareEmail.test.ts @@ -270,7 +270,8 @@ describe('share email', () => { const confirmed = await post('/confirm-email', token, { code }); expect(await confirmed.json()).toMatchObject({ email_confirmed: true }); - return { username, email, token }; + const row = await env.server.stores.user.getByUsername(username); + return { username, email, token, uuid: row!.uuid }; }; it('emails an invite to an address with no account', async () => { @@ -360,7 +361,7 @@ describe('share email', () => { expect(openPuterHref(mail.html)).toBe( `${env.origin}/?shared=${encodeURIComponent( `/${owner.username}/${first.uid}/${first.name}`, - )}`, + )}&user_uuid=${encodeURIComponent(recipient.uuid)}`, ); expect(mail.html).toContain(recipient.username); @@ -493,7 +494,7 @@ describe('share email', () => { const mail = await waitForMail({ to: recipient.email }); const masked = `/${owner.username}/${file.uid}/${file.name}`; - const link = `?shared=${encodeURIComponent(masked)}`; + const link = `?shared=${encodeURIComponent(masked)}&user_uuid=${encodeURIComponent(recipient.uuid)}`; expect(mail.html).toContain(link); // Linked, not merely mentioned. expect(mail.html).toContain(`${link}"`); diff --git a/src/gui/src/helpers/authRedirect.js b/src/gui/src/helpers/authRedirect.js index b4be196644..bec34153d9 100644 --- a/src/gui/src/helpers/authRedirect.js +++ b/src/gui/src/helpers/authRedirect.js @@ -17,7 +17,11 @@ * along with this program. If not, see . */ -import parse_shared_path, { SHARED_PATH_PARAM } from './parseSharedPath.js'; +import parse_shared_path, { + SHARED_PATH_PARAM, + SHARE_RECIPIENT_PARAM, + sharedLinkRecipientUuid, +} from './parseSharedPath.js'; /** * Where to send the user after a successful login/signup started from the @@ -99,5 +103,9 @@ export const get_oidc_return_to = () => { const params = new URLSearchParams(); for ( const value of shared ) params.append(SHARED_PATH_PARAM, value); + const recipientUuid = sharedLinkRecipientUuid( + new URLSearchParams(window.location.search ?? ''), + ); + if ( recipientUuid ) params.set(SHARE_RECIPIENT_PARAM, recipientUuid); return `${path}?${params.toString()}`; }; diff --git a/src/gui/src/helpers/authRedirect.test.js b/src/gui/src/helpers/authRedirect.test.js index 8f996977a6..51d96b6667 100644 --- a/src/gui/src/helpers/authRedirect.test.js +++ b/src/gui/src/helpers/authRedirect.test.js @@ -67,6 +67,16 @@ describe('get_oidc_return_to', () => { ).toBe(`/${share_search(shared_path('a.txt'), shared_path('b.txt'))}`); }); + it('carries the intended account through the OIDC round trip', () => { + const search = `${share_search(shared_path('Report.pdf'))}&user_uuid=aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee`; + expect(at('/', search)).toBe(`/${search}`); + }); + + it('drops an invalid intended-account hint', () => { + const shared = share_search(shared_path('Report.pdf')); + expect(at('/', `${shared}&user_uuid=recipient`)).toBe(`/${shared}`); + }); + it('leaves behind everything that is not a share link', () => { // a hand-edited value the backend would refuse anyway expect(at('/', share_search('/alice/Documents/Report.pdf'))).toBe(null); diff --git a/src/gui/src/helpers/parseSharedPath.js b/src/gui/src/helpers/parseSharedPath.js index 13a1c23fb9..6f6eeaf2c6 100644 --- a/src/gui/src/helpers/parseSharedPath.js +++ b/src/gui/src/helpers/parseSharedPath.js @@ -19,6 +19,11 @@ /** The query parameter a share link arrives on. */ export const SHARED_PATH_PARAM = 'shared'; +export const SHARE_RECIPIENT_PARAM = 'user_uuid'; + +// 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; /** * Take `?shared=` off the address bar so a reload doesn't act on it again. @@ -28,6 +33,7 @@ export const SHARED_PATH_PARAM = 'shared'; export function clear_shared_param (hash = window.location.hash) { const params = new URLSearchParams(window.location.search); params.delete(SHARED_PATH_PARAM); + params.delete(SHARE_RECIPIENT_PARAM); const rest = params.toString(); window.history.replaceState( null, @@ -36,9 +42,37 @@ export function clear_shared_param (hash = window.location.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; +/** + * The account named by a share-email link, or null for an ordinary or + * hand-edited link. + * + * @param {URLSearchParams} params + * @returns {string | null} + */ +export function sharedLinkRecipientUuid (params) { + if ( ! params.has(SHARED_PATH_PARAM) ) return null; + const recipientUuid = params.get(SHARE_RECIPIENT_PARAM); + return recipientUuid && UID_PATTERN.test(recipientUuid) + ? recipientUuid + : null; +} + +/** + * Return the locally saved account named by a share-email link, when switching + * away from the current account is necessary. + * + * @param {URLSearchParams} params + * @param {{ uuid?: string } | null} currentUser + * @param {Array<{ uuid?: string, auth_token?: string }>} loggedInUsers + * @returns {{ uuid?: string, auth_token?: string } | null} + */ +export function sharedLinkAccount (params, currentUser, loggedInUsers) { + const recipientUuid = sharedLinkRecipientUuid(params); + if ( ! recipientUuid || recipientUuid === currentUser?.uuid ) return null; + return loggedInUsers.find(user => + user.uuid === recipientUuid && Boolean(user.auth_token) + ) ?? null; +} /** * Read `///`, the form a recipient is given. `null` for diff --git a/src/gui/src/helpers/parseSharedPath.test.js b/src/gui/src/helpers/parseSharedPath.test.js index f76395f220..db02c9b6f6 100644 --- a/src/gui/src/helpers/parseSharedPath.test.js +++ b/src/gui/src/helpers/parseSharedPath.test.js @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import parse_shared_path from './parseSharedPath.js'; +import parse_shared_path, { + sharedLinkAccount, + sharedLinkRecipientUuid, +} from './parseSharedPath.js'; const UID = '11111111-2222-4333-8444-555555555555'; @@ -42,3 +45,58 @@ describe('parse_shared_path', () => { ).toBeNull(); }); }); + +describe('sharedLinkAccount', () => { + const currentUuid = '11111111-2222-4333-8444-555555555555'; + const recipientUuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; + const current = { uuid: currentUuid, auth_token: 'current-token' }; + const recipient = { uuid: recipientUuid, auth_token: 'recipient-token' }; + + it('selects a saved recipient account for a share link', () => { + const params = new URLSearchParams( + `?shared=x&user_uuid=${recipientUuid}`, + ); + expect(sharedLinkAccount(params, current, [current, recipient])).toBe( + recipient, + ); + }); + + it('does nothing for the current, missing, or tokenless account', () => { + expect(sharedLinkAccount( + new URLSearchParams(`?shared=x&user_uuid=${currentUuid}`), + current, + [current, recipient], + )).toBeNull(); + expect(sharedLinkAccount( + new URLSearchParams('?shared=x&user_uuid=not-a-uuid'), + current, + [current, recipient], + )).toBeNull(); + expect(sharedLinkAccount( + new URLSearchParams(`?user_uuid=${recipientUuid}`), + current, + [current, recipient], + )).toBeNull(); + expect(sharedLinkAccount( + new URLSearchParams(`?shared=x&user_uuid=${recipientUuid}`), + current, + [{ ...recipient, auth_token: '' }], + )).toBeNull(); + }); +}); + +describe('sharedLinkRecipientUuid', () => { + const uuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; + + it('returns a valid recipient only when it accompanies a share', () => { + expect(sharedLinkRecipientUuid( + new URLSearchParams(`?shared=x&user_uuid=${uuid}`), + )).toBe(uuid); + expect(sharedLinkRecipientUuid( + new URLSearchParams(`?user_uuid=${uuid}`), + )).toBeNull(); + expect(sharedLinkRecipientUuid( + new URLSearchParams('?shared=x&user_uuid=not-a-uuid'), + )).toBeNull(); + }); +}); diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 2a9723a720..25c68a678f 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -60,6 +60,10 @@ import { parse_url_paths } from './helpers/urlPaths.js'; import update_last_touch_coordinates from './helpers/updateLastTouchCoordinates.js'; import update_mouse_position from './helpers/updateMousePosition.js'; import update_title_based_on_uploads from './helpers/updateTitleBasedOnUploads.js'; +import { + sharedLinkAccount, + sharedLinkRecipientUuid, +} from './helpers/parseSharedPath.js'; import path from './lib/path.js'; import { AntiCSRFService } from './services/AntiCSRFService.js'; import { BroadcastService } from './services/BroadcastService.js'; @@ -80,6 +84,31 @@ import { deliversTokenToOpener, runsUserAppTokenExchange } from './util/popupAut import { verifyOidcPopupReturn } from './util/popupOidcReturn.js'; const postAuthActions = async (action) => { + const recipientUuid = sharedLinkRecipientUuid( + window.url_query_params, + ); + const savedSharedLinkAccount = sharedLinkAccount( + window.url_query_params, + window.user, + window.logged_in_users, + ); + if ( savedSharedLinkAccount ) { + await window.update_auth_data( + savedSharedLinkAccount.auth_token, + savedSharedLinkAccount, + ); + window.location.reload(); + return; + } + if ( recipientUuid && recipientUuid !== window.user?.uuid ) { + await UIWindowSessionList({ + reload_on_success: true, + cover_page: true, + has_head: false, + send_confirmation_code: true, + }); + return; + } // Set when a popup's user-app token exchange fails. The exchange is what // bootstraps the app row a permission grant is written against, so an // action that depends on it has to report failure rather than prompt.