Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a8fe1c2c8 | ||
|
|
2137d06498 | ||
|
|
ca7d3c657b | ||
|
|
b22e9d86c0 | ||
|
|
2095b6a717 | ||
|
|
4ea25d76c8 | ||
|
|
b94a5e90b8 |
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **19 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# SendOff — mobile's answer
|
||||
|
||||
**From:** mobile · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/platform.md`
|
||||
|
||||
All three deviations adopted, none contested. Shipped in monorepo-mobile (`sendoff.ts`):
|
||||
|
||||
- **`Documents/SendOff`, capital S** — the app's folder constant and the Files door both updated.
|
||||
Good catch flagging the case explicitly; the app did build that path itself and would have opened
|
||||
an empty sibling folder.
|
||||
- **Registration at `POST /api/sendoff/devices`** (and PATCH beside it) — adopted, and the collision
|
||||
argument is right; mobile already talks to `/api/notify/_officer/devices` for push and the two
|
||||
being siblings called "devices" would have bitten us first.
|
||||
- **`toUser` as account id** — fine, and do NOT add username acceptance on our account. Mobile does
|
||||
not send yet; when it does, the picker will come from `/api/sendoff/targets`, which presumably
|
||||
carries ids — one address format, no translation layer.
|
||||
|
||||
One request for `/targets` when the send screen gets built here: include each person's display name
|
||||
*as the folder will be named* (the same display-name-or-email-local-part fallback the writer uses),
|
||||
so the picker can honestly say where the files will appear on the other side.
|
||||
|
||||
The push needs nothing from us, as you said — `officer://files?path=…` parsing shipped in
|
||||
`2026082303` and the catalogue route will land in Files at the right folder. Building a new Officer
|
||||
iOS now so the corrected paths are on the device.
|
||||
@@ -0,0 +1,40 @@
|
||||
# SendOff — `/targets` now serves the folder name
|
||||
|
||||
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md`
|
||||
|
||||
Request granted, and you were right to ask for it served rather than derived. Two fields:
|
||||
|
||||
```
|
||||
GET /api/sendoff/targets
|
||||
→ {
|
||||
devices: [{ id, name, platform, lastSeenAt }],
|
||||
people: [{ id, email, name, displayName }], ← displayName is new
|
||||
myFolder: "Pastilhas" ← this is new
|
||||
}
|
||||
```
|
||||
|
||||
**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the
|
||||
local part of their email, else `user-<id>`. Computed by `displayNameFor` in
|
||||
`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory
|
||||
cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever
|
||||
needs to change, it changes in one place and both sides move together.
|
||||
|
||||
**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the
|
||||
RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row.
|
||||
The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected.
|
||||
|
||||
Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you
|
||||
who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker
|
||||
that showed `displayName` as the destination folder would be naming the wrong directory.
|
||||
|
||||
Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so
|
||||
what you see is what appears on disk.
|
||||
|
||||
## Also since the last note
|
||||
|
||||
- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a
|
||||
machine or a person, send. No received view, deliberately: the file browser already is the inbox.
|
||||
- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner.
|
||||
|
||||
Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen
|
||||
asked for, and it is there now rather than when you get to it.
|
||||
@@ -0,0 +1,141 @@
|
||||
# SendOff — the backend is in. What mobile needs to change.
|
||||
|
||||
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design
|
||||
draft; this file is what actually shipped, and it differs in two places.
|
||||
|
||||
The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine
|
||||
folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all
|
||||
exercised against the live server before this note was written. The web send form is **not** built yet;
|
||||
that is on this side and does not block anything below.
|
||||
|
||||
## The convention on disk
|
||||
|
||||
```
|
||||
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
|
||||
<home>/Documents/SendOff/<sender>/ — files another account sent you
|
||||
```
|
||||
|
||||
**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is
|
||||
`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital.
|
||||
|
||||
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
|
||||
under `DATA_PATH/<email>/home`. The app never needs to know which — every path it receives is already
|
||||
relative to the right one.
|
||||
|
||||
There is no `from-` prefix. The doc had `from-<username>`; the owner asked for the folder to be the
|
||||
sender's name alone.
|
||||
|
||||
## Two changes from the draft spec
|
||||
|
||||
**1. Device registration moved under the feature.**
|
||||
|
||||
```
|
||||
was: POST /api/devices/register { id, name, platform }
|
||||
now: POST /api/sendoff/devices { id, name, platform } → { ok: true }
|
||||
```
|
||||
|
||||
Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also
|
||||
`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`,
|
||||
`DELETE /api/sendoff/devices/:id`.
|
||||
|
||||
Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a
|
||||
top-level noun collides with the **push token** registry that already exists at
|
||||
`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with
|
||||
no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may
|
||||
never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody
|
||||
an afternoon.
|
||||
|
||||
**2. `toUser` is an account id, not a username.**
|
||||
|
||||
```
|
||||
was: meta { "toUser": "<username>" }
|
||||
now: form field toUser=<account id, integer>
|
||||
```
|
||||
|
||||
Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still
|
||||
gets a name — the sender's display name, falling back to the local part of their email — so nothing about
|
||||
the on-disk convention changes. If the app would rather send a username, say so and this side will accept
|
||||
either; it is a small change here and a smaller one than guessing.
|
||||
|
||||
## The send endpoint
|
||||
|
||||
```
|
||||
POST /api/sendoff/send
|
||||
multipart/form-data:
|
||||
file — repeatable, any number of parts
|
||||
machine — a name of your own } exactly one of these
|
||||
toUser — an account id }
|
||||
→ { path: "Documents/SendOff/<folder>", files: [{ name, size }] }
|
||||
```
|
||||
|
||||
Notes that matter for a client:
|
||||
|
||||
- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`.
|
||||
A file over the limit is a `400` naming the file.
|
||||
- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is
|
||||
ever overwritten, because the sender cannot see the recipient's directory and would have no way to know
|
||||
they were about to destroy something. Show the returned name, not the local one.
|
||||
- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash.
|
||||
Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target.
|
||||
- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.**
|
||||
That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that
|
||||
one refused you. Do not present the two differently, because they are not distinguishable.
|
||||
|
||||
## Who may send to whom
|
||||
|
||||
Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept
|
||||
handshake: I add you, and only then do you see me as a target.
|
||||
|
||||
```
|
||||
GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows
|
||||
GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] }
|
||||
POST /api/sendoff/senders { userId }
|
||||
DELETE /api/sendoff/senders/:userId
|
||||
```
|
||||
|
||||
`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single
|
||||
request. For everyone except the owner, `people` contains only accounts that have named them. **The owner
|
||||
may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs
|
||||
their say-so, so nobody lands files in the owner's tree uninvited.
|
||||
|
||||
Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by
|
||||
then, and deleting somebody's files as a side effect of a permission change would be its own surprise.
|
||||
|
||||
## The push
|
||||
|
||||
```
|
||||
code: officer/core:sendoff.landed
|
||||
id: the folder name — a username, or one of your own machine names
|
||||
n: how many files landed
|
||||
```
|
||||
|
||||
The catalogue entry renders `{n} file(s) from {id}` and its route is:
|
||||
|
||||
```
|
||||
officer://files?path=Documents/SendOff/{id}
|
||||
```
|
||||
|
||||
**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive
|
||||
from the server — which is the whole point of that design. What the app does need is to keep parsing
|
||||
`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does.
|
||||
|
||||
A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate
|
||||
relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file
|
||||
itself 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. If a future notification wants more than that, it is a
|
||||
conversation rather than a patch.
|
||||
|
||||
## What is NOT built
|
||||
|
||||
- **The web send form.** This side, in progress. It changes no API.
|
||||
- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here
|
||||
blocks it — `POST /sendoff/send` takes multipart from any client.
|
||||
- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came
|
||||
with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a
|
||||
month.
|
||||
|
||||
## If something here is wrong for mobile
|
||||
|
||||
Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and
|
||||
expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client
|
||||
ends up carrying a translation layer nobody meant to write.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 2026.08.23.02
|
||||
|
||||
SendOff — send a file to one of your own machines, or to somebody who has allowed you, and it lands in a
|
||||
real directory in their tree. Plus the deploy procedure's own corrections from the last rollout.
|
||||
|
||||
**SCHEMA CHANGE.** `bun db:push` is a step. `sendoff_devices` and `sendoff_senders` are new. Without the
|
||||
push, `/sendoff` fails on a missing relation the first time it lists anything.
|
||||
|
||||
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.23.01..HEAD` and `package.json`
|
||||
moved only its version. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **SendOff.** `Documents/SendOff/<machine>/` for something you sent yourself,
|
||||
`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 those are different directories
|
||||
and writing to the wrong one makes a delivery invisible rather than wrong.
|
||||
- **Consent belongs to the recipient**, one row per sender, absence denies. Not a role grant: the person who
|
||||
decides whether somebody may write into your home is you. A sender who has not been allowed gets the same
|
||||
404 as a sender naming an account that does not exist, so the endpoint cannot be used to enumerate
|
||||
accounts. The owner bypasses this as a SENDER only.
|
||||
- **`/sendoff`** — pick files, pick a target, send, with the consent list on the same screen. No received
|
||||
view: the file browser already is the inbox. A dock tile, gated by the new `sendoff` permission.
|
||||
- **Push on arrival** — `officer/core:sendoff.landed` carries the folder name and a file count, and the
|
||||
catalogue renders "3 file(s) from andre" with a route into Files at that folder. No app build needed;
|
||||
the catalogue syncs.
|
||||
- **The deploy procedure deploys the TAG**, not `master`. `pull --ff-only` follows the branch, so a commit
|
||||
pushed mid-rollout lands on some hosts and not others — which happened on the last release, harmlessly.
|
||||
- **`edge-pertento-bridge` is in a Deliberately skipped section** of `docs/servers.md`. It is down for some
|
||||
days, and the last rollout spent four attempts and a minute apiece discovering that.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.02`
|
||||
2. **`bun db:push`** — new this release
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||
the report rather than being omitted.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## Hosts to expect trouble from
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, see `docs/servers.md`. Name it and move on.
|
||||
- **`production-pertento-bridge`** — one release behind, and its `known_hosts` entries were cleared by the
|
||||
owner on 2026-08-23 after a changed host key. The first connection will pin whatever key it offers. That
|
||||
is expected; it is not a reason to stop, and it is not something to comment on beyond the report.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
The rollout runs from here and restarting `officer` ends the agent performing it. Name it as skipped.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.23.01",
|
||||
"version": "2026.08.23.02",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -107,6 +107,7 @@ export function App() {
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
|
||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ import {
|
||||
Store,
|
||||
Puzzle,
|
||||
Radar,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -180,6 +181,10 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
// Core because it is the door into another account's filesystem, and the permission that gates it is
|
||||
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
|
||||
// every other grantable tile here gets.
|
||||
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
|
||||
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
|
||||
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
|
||||
// while the dock and the settings list went silent about them, which is exactly the shape of failure
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /sendoff — pick files, pick a machine of your own or a person who has allowed you, send.
|
||||
//
|
||||
// There is no receiving half. Files land in `Documents/SendOff/` inside the recipient's own tree, so the
|
||||
// file browser already IS the inbox — and a second view of the same directory would be a worse copy of
|
||||
// one that already works, kept in sync by hand.
|
||||
|
||||
export const SendOffScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/sendoff', defaultLayout);
|
||||
|
||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['sendoff'], fallback: 'sendoff' }} />;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// One panel. The form is a column and there is nothing to put beside it — receiving already has a UI,
|
||||
// which is the file browser.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'sendoff',
|
||||
appType: 'sendoff',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SendOffScreen';
|
||||
@@ -23,3 +23,4 @@ export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './Minesweeper';
|
||||
export * from './SendOff';
|
||||
|
||||
@@ -40,6 +40,7 @@ export * from './ai-provider-usage';
|
||||
export * from './anthropic-accounts';
|
||||
export * from './theme-favorites';
|
||||
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
||||
export * from './sendoff';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -37,6 +37,7 @@ export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage
|
||||
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
|
||||
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||
export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export { sendoffDevices, sendoffSenders } from './schema';
|
||||
export {
|
||||
upsertSendoffDevice,
|
||||
listSendoffDevices,
|
||||
renameSendoffDevice,
|
||||
deleteSendoffDevice,
|
||||
listAllowedSenders,
|
||||
listMyRecipients,
|
||||
listOtherAccounts,
|
||||
allowSender,
|
||||
revokeSender,
|
||||
maySendTo,
|
||||
accountsByIds,
|
||||
type SendoffDevice,
|
||||
type SendoffPerson,
|
||||
} from './queries';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users } from '../auth/schema';
|
||||
import { sendoffDevices, sendoffSenders } from './schema';
|
||||
|
||||
export type SendoffDevice = { id: string; name: string; platform: string | null; lastSeenAt: Date };
|
||||
|
||||
/**
|
||||
* Register or refresh a device. Called on every app start, so it must be idempotent on the client's own id.
|
||||
*
|
||||
* `lastSeenAt` is refreshed on conflict and the name is overwritten, because the client is the authority on
|
||||
* what it calls itself — renaming on the device should show up in the picker without a second endpoint.
|
||||
*/
|
||||
export async function upsertSendoffDevice(params: {
|
||||
id: string;
|
||||
userId: number;
|
||||
name: string;
|
||||
platform?: string | null;
|
||||
}): Promise<void> {
|
||||
await db
|
||||
.insert(sendoffDevices)
|
||||
.values({
|
||||
id: params.id,
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
platform: params.platform ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sendoffDevices.id,
|
||||
set: { name: params.name, platform: params.platform ?? null, lastSeenAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSendoffDevices(userId: number): Promise<SendoffDevice[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: sendoffDevices.id,
|
||||
name: sendoffDevices.name,
|
||||
platform: sendoffDevices.platform,
|
||||
lastSeenAt: sendoffDevices.lastSeenAt,
|
||||
})
|
||||
.from(sendoffDevices)
|
||||
.where(eq(sendoffDevices.userId, userId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Scoped to the owner of the row: a device id is a client-chosen uuid and must not be a way to reach another account's. */
|
||||
export async function renameSendoffDevice(userId: number, id: string, name: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(sendoffDevices)
|
||||
.set({ name })
|
||||
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
|
||||
.returning({ id: sendoffDevices.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function deleteSendoffDevice(userId: number, id: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(sendoffDevices)
|
||||
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
|
||||
.returning({ id: sendoffDevices.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export type SendoffPerson = { id: number; email: string; name: string | null };
|
||||
|
||||
/** The accounts I have allowed to send to me. */
|
||||
export async function listAllowedSenders(recipientUserId: number): Promise<SendoffPerson[]> {
|
||||
const rows = await db
|
||||
.select({ id: users.id, email: users.email, name: users.name })
|
||||
.from(sendoffSenders)
|
||||
.innerJoin(users, eq(users.id, sendoffSenders.senderUserId))
|
||||
.where(eq(sendoffSenders.recipientUserId, recipientUserId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts I may send to — the inverse question, and the one the picker asks.
|
||||
*
|
||||
* Absence denies, so this returns only recipients who have named me. A sender never learns that an account
|
||||
* exists and refused them; it simply is not in the list.
|
||||
*/
|
||||
export async function listMyRecipients(senderUserId: number): Promise<SendoffPerson[]> {
|
||||
const rows = await db
|
||||
.select({ id: users.id, email: users.email, name: users.name })
|
||||
.from(sendoffSenders)
|
||||
.innerJoin(users, eq(users.id, sendoffSenders.recipientUserId))
|
||||
.where(eq(sendoffSenders.senderUserId, senderUserId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Every account except me — what the owner's picker shows, since the owner may send to anyone. */
|
||||
export async function listOtherAccounts(exceptUserId: number): Promise<SendoffPerson[]> {
|
||||
const rows = await db.select({ id: users.id, email: users.email, name: users.name }).from(users);
|
||||
return rows.filter((r) => r.id !== exceptUserId);
|
||||
}
|
||||
|
||||
export async function allowSender(recipientUserId: number, senderUserId: number): Promise<void> {
|
||||
await db.insert(sendoffSenders).values({ recipientUserId, senderUserId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function revokeSender(recipientUserId: number, senderUserId: number): Promise<void> {
|
||||
await db
|
||||
.delete(sendoffSenders)
|
||||
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)));
|
||||
}
|
||||
|
||||
/** May this sender write into this recipient's tree? The owner check is the CALLER's job — see the schema. */
|
||||
export async function maySendTo(senderUserId: number, recipientUserId: number): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: sendoffSenders.id })
|
||||
.from(sendoffSenders)
|
||||
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Resolve accounts by id, for naming a recipient in a response without a second round trip. */
|
||||
export async function accountsByIds(ids: number[]): Promise<SendoffPerson[]> {
|
||||
if (!ids.length) return [];
|
||||
return db.select({ id: users.id, email: users.email, name: users.name }).from(users).where(inArray(users.id, ids));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
* A named machine, so "send it to my laptop" has something to pick from.
|
||||
*
|
||||
* Separate from `push_devices` deliberately, and the two are not the same population. A push device is a
|
||||
* token: iOS or Android only, no human name, and it exists to be pushed AT. This is a name a person chose,
|
||||
* for a machine that may never receive a push at all — a desktop, a browser, a box with no app on it. The
|
||||
* name's only job is to become a folder.
|
||||
*
|
||||
* The id is the client's own uuid rather than a serial, because registration happens on every app start and
|
||||
* has to be idempotent without the client remembering a number we gave it.
|
||||
*/
|
||||
export const sendoffDevices = pgTable('sendoff_devices', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** What the owner calls it. Becomes a directory name, so it is sanitised before it touches the disk. */
|
||||
name: text('name').notNull(),
|
||||
platform: text('platform'),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Who may send files to whom — held by the RECIPIENT, one row per sender they have allowed.
|
||||
*
|
||||
* This is the whole cross-user permission model and it is deliberately not a role grant. A grant answers
|
||||
* "may this role send files", which is the wrong question: the person who should decide whether Fedra can
|
||||
* put a file in your home directory is you, not whoever administers roles. So consent is data, it is
|
||||
* per-pair, and absence denies — a sender who has not been allowed does not see the recipient as a target
|
||||
* at all, rather than seeing one and being refused.
|
||||
*
|
||||
* The owner is exempt as a SENDER and only as a sender: they bypass every other check on this platform and
|
||||
* there is no version of this where they cannot reach a home directory they already own. Receiving still
|
||||
* needs their say-so, so nobody lands files in the owner's tree uninvited.
|
||||
*/
|
||||
export const sendoffSenders = pgTable(
|
||||
'sendoff_senders',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** The account granting permission — the one whose tree gets written to. */
|
||||
recipientUserId: integer('recipient_user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The account allowed to send to them. */
|
||||
senderUserId: integer('sender_user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||
(table) => [uniqueIndex('uq_sendoff_senders').on(table.recipientUserId, table.senderUserId)],
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* The name an account appears as, and therefore the name of the folder its files land in.
|
||||
*
|
||||
* One definition, used by the writer AND by `/targets`, because the two must agree: a picker that says
|
||||
* "Fedra" while the writer creates `fedra.almeida` has told somebody the wrong place to look. Mobile asked
|
||||
* for this to be served rather than derived, which is right — three clients reimplementing a fallback is
|
||||
* how three clients disagree.
|
||||
*
|
||||
* `users.name` is nullable, so the fallback chain ends at the account id, which is always there.
|
||||
*/
|
||||
export function displayNameFor(account: { id: number; name: string | null; email: string }): string {
|
||||
const chosen = account.name?.trim();
|
||||
if (chosen) return chosen;
|
||||
const local = account.email.split('@')[0]?.trim();
|
||||
return local || `user-${account.id}`;
|
||||
}
|
||||
|
||||
/** What that name becomes on disk — the same value, through the segment sanitiser. */
|
||||
export const folderNameFor = (account: { id: number; name: string | null; email: string }): string =>
|
||||
safeSegment(displayNameFor(account)) || `user-${account.id}`;
|
||||
@@ -0,0 +1,248 @@
|
||||
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 { displayNameFor, folderNameFor, 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 = folderNameFor({ id: me.id, name: me.name ?? null, email: me.email });
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
/*
|
||||
`displayName` is served rather than derived, at mobile's request and for the right reason: the writer
|
||||
already computes this exact string to name a folder, and a picker that computes its own would sooner or
|
||||
later disagree with it — which means telling somebody the wrong place to look.
|
||||
|
||||
`myFolder` answers the question a picker actually wants: files I send land in the RECIPIENT's tree under
|
||||
MY name, so it is one value for the whole list, not a field on each row.
|
||||
*/
|
||||
return ctx.json({
|
||||
devices,
|
||||
people: people.map((p) => ({ ...p, displayName: displayNameFor(p) })),
|
||||
myFolder: folderNameFor({ id: me.id, name: me.name ?? null, email: me.email }),
|
||||
});
|
||||
});
|
||||
|
||||
/** 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 });
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-p
|
||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { minesweeperRouter } from './api/minesweeper/router';
|
||||
import { sendoffRouter } from './api/sendoff/router';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
// import { memosRouter } from './api/memos/router';
|
||||
import { appStoreRouter } from './api/app-store/router';
|
||||
@@ -141,6 +142,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/file-browser', fileBrowserRouter],
|
||||
['/terminal', terminalRouter],
|
||||
['/minesweeper', minesweeperRouter],
|
||||
['/sendoff', sendoffRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
['/app-store', appStoreRouter],
|
||||
['/plugins', pluginsRouter],
|
||||
|
||||
@@ -99,6 +99,22 @@ const CORE_ENTRIES: Record<string, NotificationEntry> = {
|
||||
route: 'officer://jobs/{id}',
|
||||
thread: 'officer/core:job',
|
||||
},
|
||||
/*
|
||||
SendOff. `id` is the FOLDER — a username, or one of your own machine names — and `n` is how many
|
||||
files landed, so the device renders "3 file(s) from andre" and the route opens that directory.
|
||||
|
||||
A folder name and a count travel, and nothing else: no filename, no path, no size. That is a
|
||||
deliberate relaxation of "content does not travel" rather than an oversight — the owner's call, on
|
||||
the grounds that the file itself 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.
|
||||
*/
|
||||
'officer/core:sendoff.landed': {
|
||||
title: 'SendOff',
|
||||
body: '{n} file(s) from {id}',
|
||||
target: 'mobile',
|
||||
route: 'officer://files?path=Documents/SendOff/{id}',
|
||||
thread: 'sendoff:{id}',
|
||||
},
|
||||
'officer/core:job-failed': {
|
||||
title: 'Job failed',
|
||||
target: 'mobile',
|
||||
|
||||
@@ -347,6 +347,27 @@ const CORE_REGISTRY: Permission[] = [
|
||||
ws: ['minesweeper'],
|
||||
routes: ['/minesweeper'],
|
||||
},
|
||||
/*
|
||||
SendOff is `app`, not `confined`, and the distinction is the point.
|
||||
|
||||
It writes into a home directory, which normally means `confined` — but it writes exactly one shape of
|
||||
path, `Documents/SendOff/<sender>/`, chosen by the server from the caller's token. There is no path in
|
||||
the request to contain, nothing to traverse out of, and no way to read anything back. So the Linux-user
|
||||
requirement that `confined` exists to enforce buys nothing here, and requiring it would mean an account
|
||||
with no OS user could not be sent a file — which is most of the point of the feature.
|
||||
|
||||
WHO may send to whom is not decided here. It is a row per pair held by the recipient, because the
|
||||
person who should decide whether somebody may put a file in your home is you, not whoever administers
|
||||
roles. This grant only says the feature is reachable at all.
|
||||
*/
|
||||
{
|
||||
key: 'sendoff',
|
||||
label: 'SendOff',
|
||||
description: 'Send files to your own machines, and to accounts that have allowed you',
|
||||
kind: 'app',
|
||||
api: ['/sendoff'],
|
||||
routes: ['/sendoff'],
|
||||
},
|
||||
{
|
||||
key: 'items',
|
||||
label: 'Permission authoring',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
|
||||
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
||||
import { appRegistryMetas as davMetas } from '../apps/Dav';
|
||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
@@ -30,6 +31,7 @@ export const apps = [
|
||||
...monitorMetas,
|
||||
...qrTransferMetas,
|
||||
...minesweeperMetas,
|
||||
...sendOffMetas,
|
||||
...davMetas,
|
||||
...commandHqMetas,
|
||||
...appStoreMetas,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Laptop, Send, Upload, User, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
|
||||
|
||||
const label = (p: { name: string | null; email: string }): string => p.name || p.email.split('@')[0] || p.email;
|
||||
const kb = (n: number): string =>
|
||||
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; name: string };
|
||||
|
||||
/**
|
||||
* SendOff — pick files, pick where, send.
|
||||
*
|
||||
* Deliberately has no "received" view. Files land in `Documents/SendOff/` in the recipient's own tree, so
|
||||
* the file browser is already the receiving UI and a second one here would be a worse copy of it that
|
||||
* needed keeping in sync.
|
||||
*/
|
||||
export const SendOffPanel = () => {
|
||||
const { devices, people, myFolder, isLoading } = useSendOffTargets();
|
||||
const send = useSendOff();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [target, setTarget] = useState<Target | null>(null);
|
||||
const picker = useRef<HTMLInputElement>(null);
|
||||
|
||||
const total = files.reduce((n, f) => n + f.size, 0);
|
||||
const ready = files.length > 0 && target !== null;
|
||||
|
||||
const doSend = async () => {
|
||||
if (!target) return;
|
||||
await send
|
||||
.mutateAsync({ files, target: target.kind === 'machine' ? { machine: target.name } : { toUser: target.id } })
|
||||
// Clear only the files. The target stays selected because sending several batches to the same place
|
||||
// is the common second action, and re-picking it every time is the kind of small tax that stops
|
||||
// somebody using a thing.
|
||||
.then(() => setFiles([]))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-5">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-5">
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-foreground">Files</h3>
|
||||
<input
|
||||
ref={picker}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
setFiles((prev) => [...prev, ...Array.from(ev.target.files ?? [])]);
|
||||
// Reset the input, or picking the same file twice in a row does nothing: `change` does not
|
||||
// fire when the value is unchanged.
|
||||
ev.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" className="mt-2 w-full justify-start" onClick={() => picker.current?.click()}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{files.length ? 'Add more files…' : 'Choose files…'}
|
||||
</Button>
|
||||
|
||||
{files.length > 0 && (
|
||||
<ul className="mt-2 flex flex-col gap-1">
|
||||
{files.map((f, i) => (
|
||||
<li
|
||||
key={`${f.name}-${i}`}
|
||||
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1 text-sm"
|
||||
>
|
||||
<span className="truncate text-foreground">{f.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{kb(f.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${f.name}`}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setFiles((prev) => prev.filter((_, k) => k !== i))}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="px-2 pt-1 text-xs text-muted-foreground">
|
||||
{files.length} file{files.length === 1 ? '' : 's'} · {kb(total)}
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-foreground">Where</h3>
|
||||
{isLoading ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
<TargetGroup
|
||||
icon={Laptop}
|
||||
heading="My machines"
|
||||
empty="No machines registered yet — an Officer app registers itself on start."
|
||||
options={devices.map((d) => ({
|
||||
key: `m:${d.id}`,
|
||||
name: d.name,
|
||||
target: { kind: 'machine' as const, name: d.name },
|
||||
}))}
|
||||
selected={target}
|
||||
onSelect={setTarget}
|
||||
/>
|
||||
<TargetGroup
|
||||
icon={User}
|
||||
heading="People"
|
||||
empty="Nobody has allowed you to send to them yet."
|
||||
// `displayName` from the server, never computed here: it is the folder the writer will
|
||||
// create, and a picker that guessed it would sooner or later name the wrong directory.
|
||||
options={people.map((p) => ({
|
||||
key: `u:${p.id}`,
|
||||
name: p.displayName,
|
||||
target: { kind: 'user' as const, id: p.id, name: p.displayName },
|
||||
}))}
|
||||
selected={target}
|
||||
onSelect={setTarget}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<Button onClick={doSend} disabled={!ready || send.isPending} size="lg">
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{send.isPending ? 'Sending…' : target ? `Send to ${target.name}` : 'Send'}
|
||||
</Button>
|
||||
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
|
||||
revoked you are different problems with different answers. */}
|
||||
{target?.kind === 'user' && myFolder && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lands in their <span className="text-foreground">Documents/SendOff/{myFolder}</span>.
|
||||
</p>
|
||||
)}
|
||||
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
|
||||
{send.data && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Landed in <span className="text-foreground">{send.data.path}</span> —{' '}
|
||||
{send.data.files.map((f) => f.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<WhoMaySend />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TargetGroupProps = {
|
||||
icon: typeof Laptop;
|
||||
heading: string;
|
||||
empty: string;
|
||||
options: { key: string; name: string; target: Target }[];
|
||||
selected: Target | null;
|
||||
onSelect: (t: Target) => void;
|
||||
};
|
||||
|
||||
const sameTarget = (a: Target | null, b: Target): boolean =>
|
||||
a !== null &&
|
||||
a.kind === b.kind &&
|
||||
(b.kind === 'machine' ? a.kind === 'machine' && a.name === b.name : a.kind === 'user' && a.id === b.id);
|
||||
|
||||
const TargetGroup = ({ icon: Icon, heading, empty, options, selected, onSelect }: TargetGroupProps) => (
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{heading}
|
||||
</p>
|
||||
{options.length === 0 ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{empty}</p>
|
||||
) : (
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{options.map((o) => (
|
||||
<Button
|
||||
key={o.key}
|
||||
size="sm"
|
||||
variant={sameTarget(selected, o.target) ? 'default' : 'outline'}
|
||||
onClick={() => onSelect(o.target)}
|
||||
>
|
||||
{o.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* The consent list, on the same screen as sending.
|
||||
*
|
||||
* It belongs here rather than in settings because the two questions arrive together: the first time
|
||||
* somebody opens SendOff and finds nobody to send to, the reason is that consent runs the other way, and
|
||||
* the answer has to be visible in the same place the question was asked.
|
||||
*/
|
||||
const WhoMaySend = () => {
|
||||
const { candidates, isLoading, setAllowed, isSaving } = useSendOffSenders();
|
||||
if (isLoading || candidates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="border-t border-border pt-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Who may send to me</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Files from anyone you allow land in <span className="text-foreground">Documents/SendOff/their-name</span>.
|
||||
Turning someone off stops future sends and leaves what they already sent alone.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{candidates.map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1.5 text-sm"
|
||||
>
|
||||
<span className="truncate text-foreground">{label(p)}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={p.allowed}
|
||||
disabled={isSaving}
|
||||
onChange={(ev) => void setAllowed(p.id, ev.target.checked).catch(() => {})}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { Send } from 'lucide-react';
|
||||
import { SendOffPanel } from './SendOffPanel';
|
||||
|
||||
export { SendOffPanel } from './SendOffPanel';
|
||||
export { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'sendoff', name: 'SendOff', icon: Send, component: SendOffPanel },
|
||||
];
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export type SendOffDevice = { id: string; name: string; platform: string | null; lastSeenAt: string };
|
||||
export type SendOffPerson = { id: number; email: string; name: string | null };
|
||||
/** As served by `/targets` — `displayName` is the folder name, computed once on the server. */
|
||||
export type SendOffTargetPerson = SendOffPerson & { displayName: string };
|
||||
type Targets = { devices: SendOffDevice[]; people: SendOffTargetPerson[]; myFolder: string };
|
||||
type Senders = { allowed: SendOffPerson[]; candidates: (SendOffPerson & { allowed: boolean })[] };
|
||||
|
||||
const TARGETS = ['sendoff', 'targets'];
|
||||
const SENDERS = ['sendoff', 'senders'];
|
||||
|
||||
/** Where a file can go: my own named machines, and the accounts that have allowed me. */
|
||||
export function useSendOffTargets() {
|
||||
const client = useClient();
|
||||
const { data, isLoading, error } = useQuery<Targets>({
|
||||
queryKey: TARGETS,
|
||||
queryFn: () => client.get<Targets>('/sendoff/targets'),
|
||||
// A machine registered from a phone a moment ago should appear without a reload, and this is a
|
||||
// two-row query — the panel is the only reader and it is open for seconds at a time.
|
||||
staleTime: 0,
|
||||
});
|
||||
return { devices: data?.devices ?? [], people: data?.people ?? [], myFolder: data?.myFolder ?? '', isLoading, error };
|
||||
}
|
||||
|
||||
/** Who I have allowed to send to me — the consent list, which only I can change. */
|
||||
export function useSendOffSenders() {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery<Senders>({
|
||||
queryKey: SENDERS,
|
||||
queryFn: () => client.get<Senders>('/sendoff/senders'),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const allow = useMutation({
|
||||
mutationFn: (userId: number) => client.post('/sendoff/senders', { userId }),
|
||||
// Both: granting changes who may send to ME, and on somebody else's screen it changes who they may
|
||||
// send TO. Only this browser's cache is reachable from here, so it invalidates its own of each.
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: SENDERS });
|
||||
void qc.invalidateQueries({ queryKey: TARGETS });
|
||||
},
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (userId: number) => client.delete(`/sendoff/senders/${userId}`),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: SENDERS });
|
||||
void qc.invalidateQueries({ queryKey: TARGETS });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
candidates: data?.candidates ?? [],
|
||||
isLoading,
|
||||
setAllowed: (userId: number, allowed: boolean) =>
|
||||
allowed ? allow.mutateAsync(userId) : revoke.mutateAsync(userId),
|
||||
isSaving: allow.isPending || revoke.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export type SendResult = { path: string; files: { name: string; size: number }[] };
|
||||
|
||||
/**
|
||||
* The send itself — multipart, through the ordinary client.
|
||||
*
|
||||
* `client.post` already recognises a `FormData` and drops the JSON content-type when it sees one, so the
|
||||
* browser picks the boundary. That is the only thing that made this look like it needed a raw `fetch`.
|
||||
*/
|
||||
export function useSendOff() {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
return useMutation<SendResult, Error, { files: File[]; target: { machine: string } | { toUser: number } }>({
|
||||
mutationFn: ({ files, target }) => {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append('file', f);
|
||||
if ('machine' in target) form.append('machine', target.machine);
|
||||
else form.append('toUser', String(target.toUser));
|
||||
return client.post<SendResult>('/sendoff/send', form);
|
||||
},
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: TARGETS }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
theme: sendoff
|
||||
heading: 'SendOff'
|
||||
kind: theme
|
||||
---
|
||||
|
||||
Sending files to your own machines, and to accounts that have allowed you. Landed 2026-08-23 as a folder
|
||||
convention plus one endpoint — `docs/sendoff.md` is the design, `src/servers/api/sendoff/` the code.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: sendoff/per-user-size-limit
|
||||
title: 'The SendOff file size limit is one constant for everybody'
|
||||
theme: sendoff
|
||||
status: open
|
||||
created: 2026-08-23
|
||||
date_source: stated
|
||||
order: 1
|
||||
---
|
||||
|
||||
**`MAX_FILE_SIZE` in `src/servers/api/sendoff/router.ts` is a hard-coded 512 MB and applies to every
|
||||
account equally.** The owner asked for it to be configurable per user, from settings, on the day the
|
||||
feature landed — 512 MB was chosen as "enough for now" rather than as a considered ceiling.
|
||||
|
||||
Why it wants to be per-user rather than one server-wide number: the accounts differ in what they are for.
|
||||
The owner sending themselves a video off a phone and a member being allowed to drop a document are not the
|
||||
same size of thing, and the honest limit for one is an insult or a hazard for the other. A single number
|
||||
has to be set for the most generous case, which is exactly the case you would want to bound.
|
||||
|
||||
Shape, when it is built:
|
||||
|
||||
- An owner-only setting, per account, defaulting to the current 512 MB when unset. Absence means default,
|
||||
not unlimited — the same posture the permission model already takes.
|
||||
- Enforced in `POST /sendoff/send` where the constant is read today, so there is one place it can be wrong.
|
||||
- The refusal has to say the limit it applied. A 400 reading "too big" against an invisible per-account
|
||||
number is the kind of error somebody retries three times before asking.
|
||||
|
||||
Worth deciding at the same time, because it is the same question one layer out: whether a **disk quota**
|
||||
belongs here too. A size limit bounds one send; nothing currently bounds how much a sender may accumulate
|
||||
in somebody's tree over a month, and the recipient's only tool is revoking them afterwards.
|
||||
Reference in New Issue
Block a user