Zum Inhalt

Files

apps/server stores folders and files and every version of a file. It is a Hono app on Bun (src/app.ts) over SQLite (bun:sqlite with Drizzle, src/db/schema.ts) and a folder of blobs; both live in the data folder, so copying that folder backs up everything.

SQLite rather than PostgreSQL: one process and one folder to run, nothing else to install, the same database the desktop app can use locally, and the same choice as firsttris/sekretaer, which Schreibzeug is meant to join. While several people edit, their changes go through the relay in memory; the database sees a save every few seconds, which one writer at a time handles easily. bun:sqlite is synchronous, so the repository functions are too: a function that does not await is not interleaved with other requests, and a transaction must not await (src/db/database.ts).

Data

Table What it holds
nodes Folders and files in one tree (parent_id, null is the root of the owner's files, owner_id whose they are; see accounts). A file has mime, size and its newest version.
versions The saved states of a file, thinned out with age: number, size, the SHA-256 of its bytes, and kept when someone chose to keep it.
media Which image belongs to which file: the file and the SHA-256 of an image put into it while it was edited.
shares Who else may open a node, with edit or view; see accounts.
invites Links that share a node: a random token, the role, when it expires.
pending_shares Shares with an address that has no account yet, taken over at its first sign-in; see accounts.
access_tokens Personal access tokens for MCP clients: only their SHA-256, name, scope, expiry, last use; see the MCP server.

The bytes are in the blob store (src/blobs.ts), under data/blobs/ab/abcdef… by their SHA-256: the same bytes are stored once, and a blob is written to a temporary name and renamed, so a crash never leaves half a blob under a real key. Images put into a document while it is edited go there too, named after their hash (src/files/media.ts), and media notes the file each one was put into; once saved, the Office file holds them itself.

Names are unique per folder (at the top level per owner), ignoring case, as on macOS and Windows (a partial unique index on name_key among nodes outside the trash; the server keeps name_key as the lowercased name, because SQLite's own lower() only knows ASCII and would let "Übersicht" and "übersicht" stand side by side). Times are stored as milliseconds. Names must not contain / \ : * ? " < > | or control characters, so every file can be downloaded under its name on every system (packages/files-api/src/schemas.ts).

Saving adds a version. Concurrent saves of one file lock its row and get consecutive numbers.

Versions

The editor saves a few seconds after each change (AUTOSAVE_DELAY in packages/editor-core/src/session-progress.ts), so a day's work makes hundreds of versions. After every save and restore the server thins out the file's older ones (src/files/thinning.ts), as Google Docs and Nextcloud do:

Age What stays
up to a day every version
up to 30 days the newest per hour
up to a year the newest per day
older the newest per week

The newest version always stays, and so does every version someone chose to keep ("Version behalten" in the versions dialog, PATCH /files/:id/versions/:number with { "kept": true }, for those who may edit): the sent invoice, the signed contract. With SCHREIBZEUG_VERSIONS=all every version stays (installation).

A thinned-out version's row goes at once; its bytes stay in the blob store until the collection (src/blob-collection.ts), which runs every six hours and removes the blobs that no version and no image refers to and that were stored more than an hour ago. A save stores its bytes before it writes their row, and storing bytes that are already there renews their time, so the collection never takes the blob of a save under way.

Trash

Trashing a node sets trashed_at on it and on everything below it with the same time, and marks it as trash_root. Everywhere else "not in the trash" is then a single column. The trash lists the roots; restoring one clears what was trashed with it, while what had been trashed before stays in the trash. A restore fails when the old folder is itself in the trash or the name has been taken again.

API

All under /api; bodies are JSON unless noted. Every route but /health, /info, Better Auth's /auth/*, GET /invites/:token and DELETE /sessions (only with a token from Kontext, Kontext) needs a session or a personal access token and acts for its user (accounts, MCP server). Errors are { "error": { "kind", "message" } }, with kind one of not_found (404), invalid (400), unauthorized (401), forbidden (403: the role is too weak), not_a_folder (422), name_taken and conflict (409), too_large (413), internal (500).

Only the uploads (POST /files, PUT /files/:id/content, POST /files/:id/media) take bodies up to SCHREIBZEUG_MAX_UPLOAD_MB. Better Auth's /auth/* answers a body over 64 KB with 413 before it reads it, and every other route one over 16 MB (src/limits.ts): Bun's own limit is sized for the uploads, and Better Auth reads a JSON body whole before it checks who is asking.

Every node in an answer carries role, what the user may do with it: owner, edit or view.

Route What it does
GET /nodes?parent=root\|<id> A folder: the folder, its path from the root (for a shared folder, from the folder that was shared), its children (folders first, by name)
GET /nodes/:id One node
PATCH /nodes/:id Rename (name; needs edit and the folder it lies in, sharing) and/or move (parentId, owner only)
POST /nodes/:id/trash Move into the trash
POST /nodes/:id/restore Bring back from the trash
GET /trash, GET /recent What is in the trash; files by last change (both only the user's own)
GET /storage How much room there is, below
GET /search?q= Nodes whose name contains q, ignoring case: the user's own and what lies in or below what is shared with them, files first, at most 50
GET /nodes/:id/shares Who the node is shared with (owner only)
PUT /nodes/:id/shares Share with the account behind email, or change its role (edit, view)
DELETE /nodes/:id/shares/:userId Take a share back
GET /shared What others shared with the user, with ownerName
GET /nodes/:id/invites The node's links that still hold (owner only)
POST /nodes/:id/invites A new link (role, days: 1, 7 or 30)
DELETE /nodes/:id/invites/:token Delete a link
GET /invites/:token What a link shares (nodeName, kind, ownerName, role, expiresAt); no session needed
POST /invites/:token/accept Accept a link: a share with its role, never weaker than one the user has
POST /folders New folder (parentId, name)
POST /files Upload (multipart: file, optional parentId)
POST /files/blank New file (parentId, name, format: docx, xlsx, pptx, or md and txt, which are empty)
GET /files/:id/content[?version=n] The bytes of the newest or a given version, as a download (Content-Disposition: attachment, X-Content-Type-Options: nosniff)
PUT /files/:id/content Save: the body becomes a new version; from a shared session with x-schreibzeug-session-save (409 when that session is gone; the edit role is checked first, so someone without it gets 404/403 whatever the session's state), see collaboration. With If-Match: "<sha256>" only while the newest version's content has that SHA-256, else 409 (the text editor, Markdown and text files); a malformed one is 400
GET /files/:id/versions All versions, newest first
POST /files/:id/media Store an image put into the file while editing (PNG, JPEG, GIF, BMP, by its first bytes); answers its name, <sha256>.<ext>. Needs edit
GET /files/:id/media/:name That image, for everyone who may see the file; only images put into this file, never another blob by its hash; with X-Content-Type-Options: nosniff
POST /files/:id/copy A copy of the newest version, named "… (Kopie).docx", next to the original when the user may add there, otherwise in their own files
POST /files/:id/versions/:number/restore Make an older version the newest again (as a new version; the ones in between stay)
PATCH /files/:id/versions/:number { "kept": true } keeps a version from being thinned out, false lets it go again (edit)
GET /health { "ok": true }
GET /info { "mode": "server" }, with oidc: { name } when a sign-in provider is set and embedOrigin when a site may frame the app (Kontext); the desktop app's local server answers { "mode": "local", "rootName" } (desktop app). No session needed

More routes, described where they belong:

Route Where
GET /tokens, POST /tokens, DELETE /tokens/:id Personal access tokens, only with a session (MCP server)
POST /files/:id/agent, GET /files/:id/outline A job for the open editor (MCP server)
GET /collab/:fileId (WebSocket) The editing session of a file (collaboration)
GET /agent/:fileId (WebSocket) An open editor taking jobs (MCP server)
DELETE /sessions Kontext signs someone out everywhere (Kontext)
/mcp (outside /api) The MCP endpoint (MCP server)

Storage

GET /api/storage answers what the user's own files take and how much room is left (src/files/storage.ts, src/disk.ts):

  • usage: the sizes of their versions, split into files (the newest version of every file outside the trash), versions (the older ones that thinning left) and trash (every version of what lies there). These are the files' sizes: the blob store keeps the same bytes once, so the disk may hold less. A shared file counts for its owner.
  • disk: free and total of the drive that holds the data folder (statfs; free is what an unprivileged process may still write, without the blocks reserved for root), asked anew each time; null when the system does not say. Every signed-in account sees it: they share the drive.
  • uploadLimit: SCHREIBZEUG_MAX_UPLOAD_MB in bytes.

The app shows it at the foot of the navigation (a bar that turns yellow from 80 % and red from 95 %, as Kontext's mailbox ring, and what the user's files take) and in the settings, split as above (apps/web/src/storage, apps/web/src/settings/storage-section.tsx). With less than 1 GB free, or less than the upload limit when that is larger, a notice above every file page says that saves may soon fail (apps/web/src/lib/storage.ts); a share of the drive would not do, as 5 % of a large one is still plenty. The desktop app's local server answers the same route with the drive of its folder and no usage, and the app shows only that notice (desktop app).

Templates

"Neu → Aus Vorlage …" (apps/web/src/files/template-dialog.tsx) starts a file from a template:

  • Built in (packages/ooxml/src/templates.ts): a letter after DIN 5008, an offer, an invoice with what § 14 UStG asks of one, a calculation (formulas for the totals and the tax, euro formats), a project plan and a presentation. They are written as Office XML there; square brackets mark what to fill in.
  • Own templates: every document, spreadsheet and presentation in a folder named "Vorlagen" at the top of the user's files. Nothing else needs setting up, on a server as on the computer.

The new file is the template's bytes uploaded under the name given (POST /files), so the API needs nothing of its own for it and an own template stays as it is.

Running it

Variable Default
SCHREIBZEUG_DATA_DIR data: the database schreibzeug.db, the blobs in blobs/, the generated auth-secret
PORT 3000
SCHREIBZEUG_MAX_UPLOAD_MB 512
SCHREIBZEUG_OPEN_SIGNUP, SCHREIBZEUG_AUTH_SECRET, SCHREIBZEUG_TRUSTED_ORIGINS, SCHREIBZEUG_TRUSTED_PROXIES see accounts
SCHREIBZEUG_URL, SCHREIBZEUG_EMBED_ORIGIN, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_NAME see Schreibzeug in Kontext
SCHREIBZEUG_WEB_DIR unset: the API only. The built web app (apps/web/dist, after bun run build) to serve beside it, so one address serves the app and the API (every file with X-Content-Type-Options: nosniff, so a browser never takes it for another type); the desktop app opens that address

The image tristanteu/schreibzeug (docker/Dockerfile) has the server and the built web app with its engine, with the data in /data (SCHREIBZEUG_WEB_DIR=/app/web, port 3000). Actions → Release publishes it on Docker Hub and as ghcr.io/firsttris/schreibzeug when a v* tag is pushed; to build it yourself, docker build -f docker/Dockerfile . (installation).

bun run server starts it with reload; it applies the migrations in apps/server/drizzle on start. After a change to the schema, bun run --filter @schreibzeug/server db:generate writes the next migration. drizzle-kit 0.31 quotes the expressions of a SQLite index as column names; check generated indexes on expressions (as nodes_name_per_parent in 0000_init.sql, fixed by hand).

Tests

apps/server/test/files.test.ts signs up an account and runs the app with app.request() and Hono's typed test client against a real SQLite file: each test file gets a temporary data folder of its own (test/harness.ts), removed afterwards. Vitest runs on Bun (bun --bun vitest), which bun:sqlite needs. The E2E server starts on an empty e2e/.data; the screenshots pin their dates in its database through a small Bun script (e2e/sqlite.ts), since Playwright runs on Node. apps/server/test/media.test.ts covers images stored while editing, storage.test.ts what /storage counts, units.test.ts the settings, names and small helpers. Templates: apps/web/src/files/templates.test.ts (which files count, the names) and e2e/templates.spec.ts (a file from a built-in and from an own template, opened in its editor). e2e/files.spec.ts goes through the file list as a person does.