Files
puter/src/backend/services/share/shareDeepLink.ts
T
Nariman Jelveh 2c9a5c4f7e 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.
2026-08-24 17:01:55 -07:00

111 lines
4.3 KiB
TypeScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Links that open a shared item: 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. */
export const SHARE_DEEP_LINK_PARAM = 'shared';
export interface ShareTarget {
/** The entry's own name, which the masked path's last segment must be. */
name: string;
/** The entry's uuid. */
uid: string;
/** Whose entry it is. */
ownerUsername: string;
}
/** The owner out of either form of share path; both name it first. */
export const ownerFromSharePath = (path: string): string | null => {
if (typeof path !== 'string') return null;
const owner = path.split('/')[1];
return owner ? owner : null;
};
/**
* `/<owner>/<uuid>/<name>`, built without a request context so every reader
* gets the same. See `sharePathMask.ts` for how it is read back.
*/
export const maskedSharePath = (target: ShareTarget): string | null => {
const { name, uid, ownerUsername } = target;
if (!name || !uid || !ownerUsername) return null;
return `/${ownerUsername}/${uid}/${name}`;
};
/**
* Items one link will highlight, at most. Past this the link still opens
* Shared, just without picking the rest out.
*/
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,
target: ShareTarget,
): string | null => {
const path = maskedSharePath(target);
return path === null ? null : shareDeepLink(origin, path);
};