Files
platform/src/servers/api/sendoff/paths.ts
T
pastilhasandClaude Opus 5 b94a5e90b8 SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push.
No staging area, no transfer table, no ack, nothing to expire. Every account
already has a home, a file browser over it and a Files app on the phone, so a
delivered file is an ordinary file from the moment it arrives.

`Documents/SendOff/<machine>/` for something you sent yourself, and
`Documents/SendOff/<sender>/` for another account's files. The home is the one
that account actually browses — the owner's real login home, a member's managed
home — because the two are different directories and writing to the wrong one
makes a delivery invisible rather than wrong.

CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant
answers "may this role send files", and the person who should decide whether
somebody may write into your home is you. Absence denies, and a sender who was
not allowed does not see the recipient in their picker at all — the endpoint
answers 404 for "no such account" and for "did not allow you" alike, so it cannot
be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving
still needs their say-so.

The sender's folder name comes from the token and never from the payload, so it
cannot lie about who put a file there. Names are sanitised to one path segment and
filenames to their basename — verified: `../../../../etc` becomes a literal
folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside
the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the
sender cannot see the directory and would have no way to know.

The push carries the folder name and a file count, and the catalogue renders
"3 file(s) from andre" on the device. That relaxes "content does not travel" by a
folder name and an integer — the owner's call, on the grounds that the file never
leaves the tailnet and a notification nobody can act on is not worth sending. A
filename would be content; whose folder it is, is addressing.

`kind: 'app'` rather than `confined` even though it writes into a home: the path
is one fixed shape chosen by the server from the caller's token, so there is
nothing to traverse and nothing to read back. Requiring a Linux user would mean an
account without one could not be sent a file, which is most of the point.

Backend and verified end to end. The web send form is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:32:09 +00:00

52 lines
2.0 KiB
TypeScript

import { join } from 'node:path';
import { getHomeDir, getOwnerHomeDir } from '@@/data-path';
/** `Documents/SendOff` under whichever home this account actually browses. */
export const SENDOFF_SUBPATH = join('Documents', 'SendOff');
/**
* The SendOff directory for an account, in the home its own file browser shows.
*
* The two homes are not interchangeable and picking the wrong one makes a delivery invisible: the owner
* browses their real login home (`/home/pastilhas`), while another account browses the managed home under
* `DATA_PATH/<email>/home`. `getOwnerHomeDir` ignores the email it is passed and always answers the owner,
* so it can only be used once the role has been checked — which is why this takes the role rather than
* working it out from the email.
*/
export function sendoffDirFor(account: { email: string; isOwner: boolean }): string {
const home = account.isOwner ? getOwnerHomeDir(account.email) : getHomeDir(account.email);
return join(home, SENDOFF_SUBPATH);
}
/**
* A folder name safe to create, from a name a person or a device chose.
*
* Anything that is not a letter, digit, dash, underscore or dot becomes a dash, and leading dots go — so a
* name cannot produce `..`, a hidden directory, or a path with a separator in it. Empty after that is
* refused by the caller rather than silently becoming a directory called `-`.
*/
export function safeSegment(name: string): string {
return name
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^\.+/, '')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
/**
* A filename safe to write, preserving the extension.
*
* Same rules as a segment, with the basename taken first: a browser will happily send `../../etc/passwd`
* as a filename and the multipart spec does not forbid it.
*/
export function safeFilename(name: string): string {
const base = name.split(/[/\\]/).pop() ?? '';
const cleaned = base
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^\.+/, '')
.slice(0, 200);
return cleaned || 'file';
}