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>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import {
|
||||
accountsByIds,
|
||||
allowSender,
|
||||
deleteSendoffDevice,
|
||||
listAllowedSenders,
|
||||
listMyRecipients,
|
||||
listOtherAccounts,
|
||||
listSendoffDevices,
|
||||
maySendTo,
|
||||
renameSendoffDevice,
|
||||
revokeSender,
|
||||
upsertSendoffDevice,
|
||||
} from 'officerdb';
|
||||
import { getNotifyServerUrl } from '../notify/router';
|
||||
import { safeFilename, safeSegment, sendoffDirFor } from './paths';
|
||||
|
||||
/*
|
||||
SendOff — files land in a real directory in the recipient's own tree.
|
||||
|
||||
There is no staging area, no transfer record and 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: visible in
|
||||
every UI that exists, and managed like anything else its owner owns.
|
||||
|
||||
Two things are worth knowing before changing anything here.
|
||||
|
||||
**This is the only sanctioned door into another account's filesystem.** `/file-browser` and `/upload`
|
||||
write exclusively into the caller's own tree and that must stay true. So this route writes ONLY under
|
||||
`Documents/SendOff/<sender>/`, and `<sender>` comes from the token — never from the payload — so a folder
|
||||
name cannot lie about who put a file there.
|
||||
|
||||
**Consent belongs to the recipient.** A role grant would answer "may this role send files", which is the
|
||||
wrong question; the person who decides whether somebody may write into your home is you. So it is a row,
|
||||
per pair, held by the recipient, and absence denies. A sender who has not been allowed does not see the
|
||||
recipient in their picker at all rather than seeing one and being refused — which also means this endpoint
|
||||
never confirms that an account exists.
|
||||
*/
|
||||
|
||||
export const sendoffRouter = createRouter();
|
||||
|
||||
const MAX_FILE_SIZE = 512 * 1024 * 1024; // 512 MB — this is a file transfer, not an avatar upload
|
||||
const isOwnerRole = (role: string | null | undefined): boolean => role === 'Super Admin';
|
||||
|
||||
/**
|
||||
* A path that does not exist yet, suffixing ` (1)`, ` (2)`, … before the extension.
|
||||
*
|
||||
* Never overwrite. A second send of the same name is a second file: the sender cannot see the recipient's
|
||||
* directory, so they have no way to know they are about to destroy something, and silently replacing a file
|
||||
* somebody was sent is the one outcome with no recovery.
|
||||
*/
|
||||
function freePath(dir: string, filename: string): string {
|
||||
const first = join(dir, filename);
|
||||
if (!existsSync(first)) return first;
|
||||
const ext = extname(filename);
|
||||
const stem = filename.slice(0, filename.length - ext.length);
|
||||
for (let n = 1; n < 1000; n++) {
|
||||
const candidate = join(dir, `${stem} (${n})${ext}`);
|
||||
if (!existsSync(candidate)) return candidate;
|
||||
}
|
||||
return join(dir, `${stem} (${Date.now()})${ext}`);
|
||||
}
|
||||
|
||||
/*
|
||||
The push, and why it carries what it does.
|
||||
|
||||
`id` is the FOLDER NAME — a username or a machine name — and `n` is a file count. Both are interpolated by
|
||||
the catalogue on the device ("3 file(s) from andre") and both are what the route needs to open the right
|
||||
directory. No filename and no path travels: the folder name plus the count is what the owner accepted as
|
||||
the line, and it is enough for the tap to land somewhere useful.
|
||||
|
||||
Never fatal. A delivery that succeeded must not be reported as a failure because a notification did not
|
||||
go out — the file is already in their tree, and the file browser shows it either way.
|
||||
*/
|
||||
async function pushLanded(recipientUserId: number, folder: string, count: number): Promise<void> {
|
||||
try {
|
||||
const base = getNotifyServerUrl();
|
||||
if (!base) return;
|
||||
await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId) },
|
||||
body: JSON.stringify({ code: 'officer/core:sendoff.landed', userId: recipientUserId, id: folder, n: count }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[sendoff] could not push:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /sendoff/send — multipart: any number of `file` parts, plus `toUser` or `machine`.
|
||||
*
|
||||
* `toUser` is an account id and writes into THEIR tree under `SendOff/<my-username>/`. `machine` is a name
|
||||
* of my own and writes into MY tree under `SendOff/<machine>/`. Exactly one of the two.
|
||||
*/
|
||||
sendoffRouter.post('/send', async (ctx) => {
|
||||
const me = ctx.get('user');
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
|
||||
// `parseBody({ all: true })` gives an array when a part repeats and a single value when it does not, so
|
||||
// both shapes have to be accepted — a one-file send is the common case and must not need a special part.
|
||||
const raw = body.file;
|
||||
const files = (Array.isArray(raw) ? raw : [raw]).filter((f): f is File => f instanceof File);
|
||||
if (!files.length) throw errors.BAD_REQUEST('at least one file is required');
|
||||
const tooBig = files.find((f) => f.size > MAX_FILE_SIZE);
|
||||
if (tooBig) throw errors.BAD_REQUEST(`${tooBig.name} exceeds the ${MAX_FILE_SIZE / 1024 / 1024} MB limit`);
|
||||
|
||||
const toUser = body.toUser === undefined || body.toUser === '' ? null : Number(body.toUser);
|
||||
const machine = typeof body.machine === 'string' && body.machine.trim() ? body.machine.trim() : null;
|
||||
if ((toUser === null) === (machine === null)) {
|
||||
throw errors.BAD_REQUEST('send to exactly one of toUser or machine');
|
||||
}
|
||||
|
||||
let targetUserId: number;
|
||||
let targetAccount: { email: string; isOwner: boolean };
|
||||
let folder: string;
|
||||
|
||||
if (machine) {
|
||||
// My own machine, my own tree. The name is mine to choose and is not checked against the device
|
||||
// registry: the registry exists to POPULATE a picker, not to be the authority on what I may call a
|
||||
// folder in my own home.
|
||||
targetUserId = me.id;
|
||||
targetAccount = { email: me.email, isOwner: isOwnerRole(me.role) };
|
||||
folder = safeSegment(machine);
|
||||
} else {
|
||||
if (toUser === me.id) throw errors.BAD_REQUEST('use machine to send to yourself');
|
||||
if (!Number.isInteger(toUser) || toUser === null || toUser <= 0)
|
||||
throw errors.BAD_REQUEST('toUser must be an account id');
|
||||
const [recipient] = await accountsByIds([toUser]);
|
||||
// 404, never 403, and the same answer for "no such account" as for "did not allow you". A sender must
|
||||
// not be able to enumerate accounts, nor learn that one refused them.
|
||||
if (!recipient) throw errors.NOT_FOUND('no such recipient');
|
||||
const allowed = isOwnerRole(me.role) || (await maySendTo(me.id, toUser));
|
||||
if (!allowed) throw errors.NOT_FOUND('no such recipient');
|
||||
|
||||
const [full] = await accountsByIds([toUser]);
|
||||
targetUserId = toUser;
|
||||
targetAccount = { email: recipient.email, isOwner: full?.id === 1 };
|
||||
// From the TOKEN. This is the whole reason the folder name can be trusted.
|
||||
folder = safeSegment(me.name || me.email.split('@')[0] || `user-${me.id}`);
|
||||
}
|
||||
|
||||
if (!folder) throw errors.BAD_REQUEST('that name has no usable characters');
|
||||
|
||||
const dir = join(sendoffDirFor(targetAccount), folder);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const written: { name: string; size: number }[] = [];
|
||||
for (const file of files) {
|
||||
const path = freePath(dir, safeFilename(file.name));
|
||||
await Bun.write(path, await file.arrayBuffer());
|
||||
written.push({ name: path.split('/').pop() ?? file.name, size: file.size });
|
||||
}
|
||||
|
||||
void pushLanded(targetUserId, folder, written.length);
|
||||
|
||||
return ctx.json({ path: join('Documents', 'SendOff', folder), files: written });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /sendoff/targets — everything the send form needs: my machines, and who I may send to.
|
||||
*
|
||||
* The owner sees every account because they may send to anyone; everybody else sees only the accounts that
|
||||
* have named them. One request rather than two, because a picker with half its options is not a picker.
|
||||
*/
|
||||
sendoffRouter.get('/targets', async (ctx) => {
|
||||
const me = ctx.get('user');
|
||||
const [devices, people] = await Promise.all([
|
||||
listSendoffDevices(me.id),
|
||||
isOwnerRole(me.role) ? listOtherAccounts(me.id) : listMyRecipients(me.id),
|
||||
]);
|
||||
return ctx.json({ devices, people });
|
||||
});
|
||||
|
||||
/** GET /sendoff/senders — who I have allowed to send to me. */
|
||||
sendoffRouter.get('/senders', async (ctx) => {
|
||||
const me = ctx.get('user');
|
||||
const [allowed, everyone] = await Promise.all([listAllowedSenders(me.id), listOtherAccounts(me.id)]);
|
||||
const allowedIds = new Set(allowed.map((a) => a.id));
|
||||
// Both lists, so the UI can render a set of toggles rather than an "add" flow over an account picker.
|
||||
return ctx.json({ allowed, candidates: everyone.map((p) => ({ ...p, allowed: allowedIds.has(p.id) })) });
|
||||
});
|
||||
|
||||
sendoffRouter.post('/senders', async (ctx) => {
|
||||
const me = ctx.get('user');
|
||||
const body = ctx.get('body') as { userId?: number };
|
||||
const id = Number(body?.userId);
|
||||
if (!Number.isInteger(id) || id <= 0) throw errors.BAD_REQUEST('userId is required');
|
||||
if (id === me.id) throw errors.BAD_REQUEST('you can always send to yourself');
|
||||
const [account] = await accountsByIds([id]);
|
||||
if (!account) throw errors.NOT_FOUND('no such account');
|
||||
await allowSender(me.id, id);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
sendoffRouter.delete('/senders/:userId', async (ctx) => {
|
||||
const me = ctx.get('user');
|
||||
const id = Number(ctx.req.param('userId'));
|
||||
if (!Number.isInteger(id) || id <= 0) throw errors.BAD_REQUEST('userId is required');
|
||||
// Revoking stops future sends. Files already delivered are the recipient's own files now, and deleting
|
||||
// somebody's files as a side effect of a permission change would be its own kind of surprise.
|
||||
await revokeSender(me.id, id);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/** POST /sendoff/devices — register or refresh. Idempotent on the client's own uuid; called every launch. */
|
||||
sendoffRouter.post('/devices', async (ctx) => {
|
||||
const me = ctx.get('user');
|
||||
const body = ctx.get('body') as { id?: string; name?: string; platform?: string };
|
||||
const id = typeof body?.id === 'string' ? body.id.trim() : '';
|
||||
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
||||
if (!id || !name) throw errors.BAD_REQUEST('id and name are required');
|
||||
if (!safeSegment(name)) throw errors.BAD_REQUEST('that name has no usable characters');
|
||||
await upsertSendoffDevice({ id, userId: me.id, name, platform: body?.platform ?? null });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
sendoffRouter.get('/devices', async (ctx) => ctx.json({ devices: await listSendoffDevices(ctx.get('user').id) }));
|
||||
|
||||
sendoffRouter.patch('/devices/:id', async (ctx) => {
|
||||
const me = ctx.get('user');
|
||||
const body = ctx.get('body') as { name?: string };
|
||||
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
||||
if (!name || !safeSegment(name)) throw errors.BAD_REQUEST('name is required');
|
||||
if (!(await renameSendoffDevice(me.id, ctx.req.param('id'), name))) throw errors.NOT_FOUND('no such device');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
sendoffRouter.delete('/devices/:id', async (ctx) => {
|
||||
const me = ctx.get('user');
|
||||
if (!(await deleteSendoffDevice(me.id, ctx.req.param('id')))) throw errors.NOT_FOUND('no such device');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
Reference in New Issue
Block a user