mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
Since e4e3e2b every registered SSH key could read sealed files over the
mount while the vault was open in the browser. A key is a standing
credential on another machine, so it now needs vault access, given and
taken per key with :sshkey vault <name> on|off (PATCH /api/ssh-keys/{id},
migration 0016). Existing keys start without it. The access is asked on
every read of a sealed file, so taking it away cuts off a read under way,
as locking does. That includes a write handle opened over a sealed file:
its .part holds the decrypted bytes and could be read back through it.
A mount no longer holds the vault open: it takes the key through the new
Vault::peek_key, which does not count as use. Opening sealed files over
and over from an indexer kept the idle lock from ever firing.
The rest of the audit's findings on this side:
- An unfinished upload's .part is removed when it is dropped, so a sealed
file's plaintext does not sit in uploads/ after a client goes away.
- A deleted key can no longer write, setstat or close-and-save through a
session that was already open.
- A session holds at most 256 handles and 64 MiB in memory, the server at
most 32 connections; keepalives drop a peer that stopped answering.
- A sealed download in the browser ends when the vault locks, the way a
read over the mount already did.
- Certificate inspection refuses a key or keystore that asks for more
than ten million rounds, or scrypt beyond 2^20 and r = 8, before running
the derivation: the file chooses those numbers, and the work could not
be stopped once begun.
473 lines
16 KiB
TypeScript
473 lines
16 KiB
TypeScript
/** What a node is: a top-level space, a folder inside one, a document, or a file. */
|
|
export type NodeKind = 'space' | 'folder' | 'document' | 'file'
|
|
|
|
/** Whether a node can hold other nodes — the ones `l` walks into. */
|
|
export const isContainer = (node: { kind: NodeKind }) => node.kind === 'space' || node.kind === 'folder'
|
|
|
|
/** The bytes behind a file node. */
|
|
export interface FileInfo {
|
|
media_id: string
|
|
mime: string
|
|
size: number
|
|
url: string
|
|
/** Sealed in the vault: it opens only while the vault is unlocked. */
|
|
encrypted: boolean
|
|
}
|
|
|
|
export interface NodeSummary {
|
|
id: number
|
|
parent_id: number | null
|
|
kind: NodeKind
|
|
name: string
|
|
slug: string
|
|
excerpt: string
|
|
position: number
|
|
has_board: boolean
|
|
/** Spaces the app has to tell apart: `diary`, or nothing. */
|
|
role: string | null
|
|
child_count: number
|
|
created_at: number
|
|
updated_at: number
|
|
shared: boolean
|
|
share_token: string | null
|
|
/** Folders and spaces only: whatever lands here is sealed in the vault. */
|
|
sealed: boolean
|
|
/** Files only. */
|
|
file: FileInfo | null
|
|
/** Search results only: where the match sits, `work/notes`. */
|
|
location?: string
|
|
}
|
|
|
|
/** One step of a node's path, the space first and the node itself last. */
|
|
export interface Crumb {
|
|
id: number
|
|
kind: NodeKind
|
|
name: string
|
|
slug: string
|
|
}
|
|
|
|
export interface WorkspaceNode extends NodeSummary {
|
|
body: string
|
|
path: Crumb[]
|
|
}
|
|
|
|
/** A space as the overview shows it: itself, and what was last written in it. */
|
|
export interface SpaceOverview {
|
|
space: NodeSummary
|
|
recent: NodeSummary[]
|
|
}
|
|
|
|
/** A public key allowed to mount the workspace over SFTP. */
|
|
export interface SshKey {
|
|
id: number
|
|
name: string
|
|
fingerprint: string
|
|
created_at: number
|
|
last_used_at: number | null
|
|
/** May read sealed files over the mount while the vault is open. */
|
|
vault: boolean
|
|
}
|
|
|
|
export interface SharedDocument {
|
|
name: string
|
|
body: string
|
|
created_at: number
|
|
updated_at: number
|
|
token: string
|
|
}
|
|
|
|
/** What an import did, as `POST /api/nodes/{id}/import` reports it. */
|
|
export interface ImportReport {
|
|
/** New spaces, made by an import at the root. */
|
|
spaces: number
|
|
folders: number
|
|
documents: number
|
|
files: number
|
|
links: number
|
|
cards: number
|
|
skipped: string[]
|
|
}
|
|
|
|
/** A card on a space's board. `node_id` is the document it is about, if any. */
|
|
export interface Card {
|
|
id: number
|
|
list_id: number
|
|
title: string
|
|
body: string
|
|
node_id: number | null
|
|
node_name: string | null
|
|
due_at: number | null
|
|
done_at: number | null
|
|
position: number
|
|
created_at: number
|
|
updated_at: number
|
|
}
|
|
|
|
/** An open card as the agenda lists it, with the board it lives on. */
|
|
export interface AgendaCard extends Card {
|
|
space_id: number
|
|
space_slug: string
|
|
list_name: string
|
|
role: 'inbox' | 'doing'
|
|
}
|
|
|
|
export interface Agenda {
|
|
cards: AgendaCard[]
|
|
/** Whether the diary has an entry today; null when there is no diary. */
|
|
diary_today: boolean | null
|
|
}
|
|
|
|
export interface BoardList {
|
|
id: number
|
|
name: string
|
|
position: number
|
|
cards: Card[]
|
|
}
|
|
|
|
/** One space's board. There is one per space; the diary has none. */
|
|
export interface Board {
|
|
space_id: number
|
|
space_name: string
|
|
lists: BoardList[]
|
|
}
|
|
|
|
export interface MediaFile {
|
|
id: string
|
|
filename: string
|
|
mime: string
|
|
size: number
|
|
created_at: number
|
|
url: string
|
|
encrypted: boolean
|
|
/** Every document that embeds this file — a file may be used by several. */
|
|
node_ids: number[]
|
|
/** The file node it is, when it sits in the tree; deleted from there. */
|
|
file_node: number | null
|
|
}
|
|
|
|
/** One thing a certificate, key or keystore file holds, as `/inspect` describes it. */
|
|
export type CertItem =
|
|
| {
|
|
kind: 'certificate'
|
|
subject: string
|
|
issuer: string
|
|
serial: string
|
|
not_before: number
|
|
not_after: number
|
|
/** `RSA 2048`, `EC P-256`, `Ed25519`. */
|
|
key: string
|
|
names: string[]
|
|
usage: string[]
|
|
ca: boolean
|
|
sha256: string
|
|
sha1: string
|
|
label: string | null
|
|
}
|
|
| { kind: 'request'; subject: string; key: string; names: string[] }
|
|
| {
|
|
kind: 'private_key'
|
|
/** Null while it is still encrypted. */
|
|
key: string | null
|
|
encrypted: boolean
|
|
/** The index of its certificate among the items. */
|
|
matches: number | null
|
|
label: string | null
|
|
}
|
|
| { kind: 'public_key'; key: string }
|
|
|
|
export interface Inspection {
|
|
items: CertItem[]
|
|
/** Something in the file is behind a password that was not given. */
|
|
needs_password: boolean
|
|
}
|
|
|
|
/** The vault, as `/api/vault` reports it. */
|
|
export interface VaultStatus {
|
|
configured: boolean
|
|
unlocked: boolean
|
|
/** Seconds until it locks itself if nothing uses it. */
|
|
locks_in: number | null
|
|
/** How many files are sealed: what an export needs the vault open for. */
|
|
sealed: number
|
|
}
|
|
|
|
/** The Proton Drive mirror, as `/api/backup` reports it. */
|
|
export interface BackupStatus {
|
|
configured: boolean
|
|
device: string | null
|
|
running: boolean
|
|
pending: boolean
|
|
last_run_at: number | null
|
|
last_success_at: number | null
|
|
last_error: string | null
|
|
last: { uploaded: number; skipped: number; pruned: number; bytes: number } | null
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
message: string,
|
|
) {
|
|
super(message)
|
|
}
|
|
}
|
|
|
|
/** No answer at all: offline, or the server is down. Status 0, as XHR has it. */
|
|
export const unreachable = (error: unknown) =>
|
|
error instanceof ApiError && (error.status === 0 || error.status === 502 || error.status === 503 || error.status === 504)
|
|
|
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
let response: Response
|
|
try {
|
|
response = await fetch(`/api${path}`, {
|
|
credentials: 'same-origin',
|
|
...init,
|
|
headers:
|
|
init.body instanceof FormData
|
|
? init.headers
|
|
: { 'content-type': 'application/json', ...init.headers },
|
|
})
|
|
} catch {
|
|
// fetch says "Failed to fetch" and nothing else; this says what it means.
|
|
throw new ApiError(0, 'the server cannot be reached')
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let message = response.statusText
|
|
try {
|
|
message = (await response.json()).error ?? message
|
|
} catch {
|
|
/* the body was not JSON; the status text will do */
|
|
}
|
|
throw new ApiError(response.status, message)
|
|
}
|
|
|
|
return response.status === 204 ? (undefined as T) : await response.json()
|
|
}
|
|
|
|
const body = (data: unknown) => JSON.stringify(data)
|
|
|
|
/** The size of a page of a long list, the most the server hands out at once. */
|
|
const PAGE = 2000
|
|
|
|
/** Every page of a list, read until one comes back short. */
|
|
async function pages<T>(read: (limit: number, offset: number) => Promise<T[]>): Promise<T[]> {
|
|
const all: T[] = []
|
|
for (;;) {
|
|
const page = await read(PAGE, all.length)
|
|
all.push(...page)
|
|
if (page.length < PAGE) return all
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Files up as multipart, by XHR rather than fetch, because a gigabyte going
|
|
* up needs to say how far along it is.
|
|
*/
|
|
function send<T>(url: string, files: File[], progress?: (fraction: number) => void) {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const form = new FormData()
|
|
for (const file of files) form.append('file', file)
|
|
const xhr = new XMLHttpRequest()
|
|
xhr.open('POST', url)
|
|
xhr.responseType = 'json'
|
|
xhr.upload.onprogress = (event) => {
|
|
if (event.lengthComputable) progress?.(event.loaded / event.total)
|
|
}
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as T)
|
|
else reject(new ApiError(xhr.status, xhr.response?.error ?? xhr.statusText))
|
|
}
|
|
xhr.onerror = () => reject(new ApiError(0, 'the upload did not reach the server'))
|
|
xhr.send(form)
|
|
})
|
|
}
|
|
|
|
export const api = {
|
|
me: () => request<{ user: string }>('/me'),
|
|
|
|
login: (username: string, password: string) =>
|
|
request<{ user: string }>('/login', { method: 'POST', body: body({ username, password }) }),
|
|
|
|
/** `everywhere`: every device's session ends, not only this one. */
|
|
logout: (everywhere = false) =>
|
|
request<{ ok: true }>(`/logout${everywhere ? '?everywhere=true' : ''}`, { method: 'POST' }),
|
|
|
|
listSpaces: () => request<NodeSummary[]>('/spaces'),
|
|
|
|
overview: () => request<SpaceOverview[]>('/spaces/overview'),
|
|
|
|
createSpace: (name: string, has_board = true) =>
|
|
request<WorkspaceNode>('/spaces', { method: 'POST', body: body({ name, has_board }) }),
|
|
|
|
/**
|
|
* The children of a node, or — with `q` — a search across the whole tree.
|
|
* A folder is read a page at a time until a page comes back short, so one
|
|
* that grows past a page is listed whole rather than cut off; a search
|
|
* stops at its first page, the best matches.
|
|
*/
|
|
listNodes: async (params: { parent?: number; q?: string; space?: number }) => {
|
|
const query = new URLSearchParams()
|
|
if (params.q) query.set('q', params.q)
|
|
else if (params.parent !== undefined) query.set('parent', String(params.parent))
|
|
if (params.q && params.space !== undefined) query.set('space', String(params.space))
|
|
if (params.q) return request<NodeSummary[]>(`/nodes?${query}`)
|
|
return pages((limit, offset) => {
|
|
query.set('limit', String(limit))
|
|
query.set('offset', String(offset))
|
|
return request<NodeSummary[]>(`/nodes?${query}`)
|
|
})
|
|
},
|
|
|
|
getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`),
|
|
|
|
/** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */
|
|
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
|
|
|
|
/**
|
|
* Upload a folder into a node. The relative path is passed as each part's
|
|
* file name, which is the only place a multipart body has to put it, and is
|
|
* what the server rebuilds the directory tree from.
|
|
*/
|
|
importFolder: (id: number, files: File[]) => {
|
|
const form = new FormData()
|
|
for (const file of files) form.append('file', file, file.webkitRelativePath || file.name)
|
|
return request<ImportReport>(`/nodes/${id}/import`, { method: 'POST', body: form })
|
|
},
|
|
|
|
/** An `:export!` poured back in whole: its directories become spaces. */
|
|
importRoot: (files: File[]) => {
|
|
const form = new FormData()
|
|
for (const file of files) form.append('file', file, file.webkitRelativePath || file.name)
|
|
return request<ImportReport>('/import', { method: 'POST', body: form })
|
|
},
|
|
|
|
createNode: (input: {
|
|
parent_id: number
|
|
kind?: NodeKind
|
|
name?: string
|
|
body?: string
|
|
created_at?: number
|
|
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
|
|
|
|
/** `base_updated_at`: refused with 409 if the node was saved since then. */
|
|
updateNode: (
|
|
id: number,
|
|
input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number },
|
|
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
|
|
|
|
deleteNode: (id: number) =>
|
|
request<{ ok: true; removed: number }>(`/nodes/${id}`, { method: 'DELETE' }),
|
|
|
|
moveNode: (id: number, parent_id: number, position?: number) =>
|
|
request<WorkspaceNode>(`/nodes/${id}/move`, { method: 'POST', body: body({ parent_id, position }) }),
|
|
/** All of them into `parent_id`, or none when one cannot go. */
|
|
moveNodes: (ids: number[], parent_id: number) =>
|
|
request<{ moved: number; unchanged: number }>('/nodes/move', { method: 'POST', body: body({ ids, parent_id }) }),
|
|
|
|
/** Publishes the document, or mints a fresh key for one already published. */
|
|
share: (id: number) =>
|
|
request<{ token: string; key: string; path: string; sealed: number }>(`/nodes/${id}/share`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
unshare: (id: number) => request<{ ok: true }>(`/nodes/${id}/share`, { method: 'DELETE' }),
|
|
|
|
/** Open cards across every board: due within a week, overdue, or in hand. */
|
|
agenda: () => request<Agenda>('/agenda'),
|
|
|
|
/** The board of a space. Fails for a space that has none, such as the diary. */
|
|
board: (spaceId: number) => request<Board>(`/spaces/${spaceId}/board`),
|
|
|
|
createList: (spaceId: number, name: string) =>
|
|
request<Board>(`/spaces/${spaceId}/lists`, { method: 'POST', body: body({ name }) }),
|
|
|
|
renameList: (listId: number, name: string) =>
|
|
request<Board>(`/lists/${listId}`, { method: 'PUT', body: body({ name }) }),
|
|
|
|
deleteList: (listId: number) => request<Board>(`/lists/${listId}`, { method: 'DELETE' }),
|
|
|
|
createCard: (
|
|
listId: number,
|
|
input: { title: string; body?: string; node_id?: number | null; due_at?: number | null },
|
|
) => request<Card>(`/lists/${listId}/cards`, { method: 'POST', body: body(input) }),
|
|
|
|
/*
|
|
* Only what changed is sent. `due_at: null` clears the date while leaving it
|
|
* out keeps it, which is why the server reads those fields as double options.
|
|
*/
|
|
updateCard: (
|
|
id: number,
|
|
patch: {
|
|
title?: string
|
|
body?: string
|
|
node_id?: number | null
|
|
due_at?: number | null
|
|
done?: boolean
|
|
},
|
|
) => request<Card>(`/cards/${id}`, { method: 'PUT', body: body(patch) }),
|
|
|
|
deleteCard: (id: number) => request<{ ok: true }>(`/cards/${id}`, { method: 'DELETE' }),
|
|
|
|
moveCard: (id: number, list_id: number, position?: number) =>
|
|
request<Card>(`/cards/${id}/move`, { method: 'POST', body: body({ list_id, position }) }),
|
|
|
|
listMedia: () => pages((limit, offset) => request<MediaFile[]>(`/media?limit=${limit}&offset=${offset}`)),
|
|
|
|
/**
|
|
* Upload files into a space or folder as file nodes. XHR rather than fetch,
|
|
* because a gigabyte going up needs to say how far along it is.
|
|
*/
|
|
putFiles: (parentId: number, files: File[], encrypt: boolean, progress?: (fraction: number) => void) =>
|
|
send<WorkspaceNode[]>(`/api/nodes/${parentId}/files${encrypt ? '?encrypt=true' : ''}`, files, progress),
|
|
|
|
/** Files to embed in a document, with the same progress as `putFiles`. */
|
|
upload: (files: File[], progress?: (fraction: number) => void) =>
|
|
send<MediaFile[]>('/api/media', files, progress),
|
|
|
|
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
|
|
|
|
vaultStatus: () => request<VaultStatus>('/vault'),
|
|
|
|
setupVault: (password: string) =>
|
|
request<VaultStatus>('/vault', { method: 'POST', body: body({ password }) }),
|
|
|
|
changeVaultPassword: (current: string, password: string) =>
|
|
request<VaultStatus>('/vault', { method: 'PUT', body: body({ current, password }) }),
|
|
|
|
unlockVault: (password: string) =>
|
|
request<VaultStatus>('/vault/unlock', { method: 'POST', body: body({ password }) }),
|
|
|
|
lockVault: () => request<VaultStatus>('/vault/lock', { method: 'POST' }),
|
|
|
|
/**
|
|
* Seal a file into the vault, or a folder with everything in it and all
|
|
* that lands there later — or take it back out.
|
|
*/
|
|
seal: (id: number, encrypt: boolean) =>
|
|
request<{ files: number; unchanged: number; failed: number; shared: number; sealed: boolean }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
/** What a certificate, key or keystore holds; the password opens it for this answer only. */
|
|
inspect: (id: number, password?: string) =>
|
|
request<Inspection>(`/nodes/${id}/inspect`, { method: 'POST', body: body({ password }) }),
|
|
|
|
backupStatus: () => request<BackupStatus>('/backup'),
|
|
|
|
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
|
|
|
|
readShared: (token: string, key: string) =>
|
|
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
|
|
|
|
listSshKeys: () => request<SshKey[]>('/ssh-keys'),
|
|
|
|
addSshKey: (name: string, public_key: string) =>
|
|
request<SshKey>('/ssh-keys', { method: 'POST', body: body({ name, public_key }) }),
|
|
|
|
removeSshKey: (id: number) => request<{ ok: true }>(`/ssh-keys/${id}`, { method: 'DELETE' }),
|
|
|
|
setSshKeyVault: (id: number, vault: boolean) =>
|
|
request<{ ok: true }>(`/ssh-keys/${id}`, { method: 'PATCH', body: body({ vault }) }),
|
|
}
|