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>
52 lines
2.0 KiB
TypeScript
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';
|
|
}
|