docdrop is a tiny Anvil app for sharing HTML and Markdown documents — guides,
notes, exports from an AI session, whatever — with anyone, by sending them
one link. There's one data table, one route, and no viewer accounts: publish
a file and you get back https://<app-domain>/docs/<token>, where <token>
is a secrets.token_urlsafe(16) value minted the first time the slug is
published. Republishing the same slug replaces the content but keeps the
token, so a link you've already shared keeps working.
docdrop is two pieces: the docdrop command-line tool you install, and the
Anvil app it publishes to. You can run that app yourself, or — if someone else
runs a docdrop server — publish to theirs with a key they give you.
HTML is already a whole page, so it's served untouched. Markdown is rendered
on the way out and wrapped in a page shell (server_code/render.py), so
restyling every Markdown document is a change to that stylesheet plus a
redeploy — nothing needs re-publishing.
Slugs are global. A document records its publisher_email, so a slug that
belongs to someone else is refused rather than overwritten, and docdrop list shows a publisher only their own documents.
Beyond the default link, a document can carry named, revocable share links
(docdrop grant / revoke / grants) and a full publish history
(docdrop history / diff / rollback) — every publish that actually
changes the content cuts a new revision; a title-only or identical-content
republish doesn't.
Published sources are also kept as committed copies under documents/, so a
document can be recovered or re-published without hunting for the original
file.
┌─────────────────────┐
Publisher │ docdrop CLI │ Authorization: Bearer <api_key>
machine ──▶ │ (cli/, PyPI pkg) │ ───────────────┐
└─────────────────────┘ │
▼
┌───────────────────────┐
Operator ┌─────────────────────┐ │ Anvil app │
machine ──▶ │ tools/admin.py │──────▶│ /api/* (publisher) │
(uplink key, │ (just admin ...) │uplink │ /docs/:token (public) │
environments.toml)└─────────────────────┘ │ admin callables │
│ (uplink-only) │
└───────────┬───────────┘
│
▼
document / grant / revision
/ users (all client: none)
Publisher plane: the docdrop CLI (cli/) talks to /api/* over
HTTPS, authenticating with Authorization: Bearer <api_key> against a row
in the users table — no session, cookie, or OAuth; the bearer key is the
entire publisher credential.
Admin plane: uplink-only. tools/admin.py, driven via just admin <command>, connects to the app over a privileged server uplink. Every
admin callable in server_code/admin.py is gated by _require_uplink(),
which checks that the caller's connection type is "uplink" — this cannot
be satisfied from a browser or from a publisher's api_key, since only a
privileged server-uplink connection reports that caller type.
mint_api_key(email) is the only way a users row is created — it
creates or rotates a key and prints the raw key exactly once. There is no
signup or login flow anywhere in the codebase.
Every table docdrop uses — document, grant, revision, and users —
declares client: none in anvil.yaml, so browser code can read none of
them; every read and write goes through a server callable or an /api/*
endpoint.
The uplink key is the admin credential, and it is never what a publisher
holds. The two credentials are entirely separate and never intersect in
code: a leaked publisher api_key only grants publish/list access to that
one account's own documents, while only the uplink key grants full
read/write access to every table and callable.
Every page served from /docs/:token carries the header
X-Robots-Tag: noindex, nofollow. Documents are unlisted, not private —
anyone who has the link can read the document.
Publisher accounts are minted, never self-service: mint_api_key is
gated behind the uplink-only check above, and no publicly reachable route
creates or rotates an api_key. There is no signup form to abuse.
The CLI is published as docdrop. Install it with whichever tool you
prefer:
pip install docdrop
# or
pipx install docdrop
# or
uv tool install docdropThe maintainer publishes a build before sharing this repo, so this should
already work by the time you're reading it — but if pip install docdrop
can't find a matching distribution, install from source instead (below).
The CLI reads a TOML config file, resolved in this order:
$DOCDROP_CONFIG(an exact path), if set- else
$XDG_CONFIG_HOME/docdrop/config.toml - else
~/.config/docdrop/config.toml
There are three ways to fill it in.
Flat mode — no [servers.*] tables at all. DOCDROP_API_KEY /
DOCDROP_URL, if set, override the file's api_key / url:
api_key = "..."
url = "https://docdrop.anvil.app"Single profile — one [servers.NAME] table auto-defaults, no
default = true needed:
[servers.production]
url = "https://docdrop.anvil.app"
api_key = "..."Multiple profiles — exactly one must be marked default = true:
[servers.production]
url = "https://docdrop.anvil.app"
api_key = "..."
default = true
[servers.local]
url = "http://localhost:3030"
api_key = "..."--server NAME may go either side of the command name — these are the same:
docdrop list --server local
docdrop --server local listTo see what's on every profile at once, without naming one:
docdrop list --allEach profile is listed under its own heading, using its own key against its
own URL. A profile that's unreachable or missing its api_key is reported
in place and the sweep carries on to the rest, so one bad profile never
costs you the others — but the exit status is still non-zero.
Precedence is exact and never blends modes. Passing --server NAME looks
up [servers.NAME] and never falls back to env vars or flat keys, even if
they're set. Otherwise, if any [servers.*] tables exist at all, the
default profile is used and env vars / flat keys are ignored entirely.
Only when zero [servers.*] tables exist does flat mode apply, and only
there do DOCDROP_API_KEY / DOCDROP_URL take effect. url is always
mandatory — there is no default URL to fall back to.
docdrop servers lists your configured profiles, marking the active one,
without ever printing an api_key:
docdrop serversNo install step, no PyPI required — just uv:
just cli list
just cli publish notes.mdwhich runs uv run --project cli docdrop "$@" against the working tree.
Also:
just cli-test # the CLI's own test suite
just cli-lint # ruff, targeting the CLI's own PythonGet the app onto Anvil one of two ways, then do the admin-plane setup below.
Easiest — clone this repo into a new app. On the Anvil IDE Start Page,
under "Blank App", choose Clone from GitHub, enter the repo URL
https://github.com/Empiria/docdrop, and pick an authentication method —
Anonymous for a read-only copy of this public repo, or GitHub account if
you want to edit and push changes back — then click Clone App. The IDE
creates the app with all the code and the document, grant, revision, and
users tables from anvil.yaml's db_schema.
Or push into a blank app. Create a new blank app, get its git URL (Version
History → Clone with Git), then wire this repo to it and push — the IDE creates
the same tables from anvil.yaml's db_schema via schema-diff resolution on
push:
git remote add anvil <the-git-url>
git push anvil mainIf the IDE shows a schema conflict banner, resolve in the direction of the
pushed anvil.yaml.
Two files genuinely differ between two Anvil apps built from the same code:
- the
name:line ofanvil.yaml— the app's name in the Anvil dashboard; .anvil_editor.yaml— a map of modules to that app's internal object ids.
So main is canonical and is never pushed at an app directly: doing that
renames the app and hands it the other one's editor state. Each app gets a
long-lived deploy/<app> branch holding its own copies of those two files, and
deploying merges main into it:
just deploy-personal # → docdrop.anvil.app
just deploy-empiria # → docdrop.empiria.co.uk
just deploy-allEach recipe merges the app's own main first, so a deploy never reverts
something edited in the IDE, then merges main and pushes. Everything else —
client_code/, server_code/, the schema in anvil.yaml — is shared, and
because the per-app difference is one line well away from the schema, merges do
not conflict in practice.
Anything you would otherwise write as a comment in anvil.yaml belongs in this
README or AGENTS.md instead: the IDE round-trips that file and strips comments
out of it every time.
Then, for either route, set up the admin plane:
- In the IDE: Settings → Uplink → enable the server uplink, copy the key.
cp environments.example.toml environments.tomland paste the key into its[production]section —just admindefaults to--env production, so that section name matters.just admin mint-key you@example.com— put the key it prints into your CLI config (see above).
There isn't yet a turnkey self-hosting path. What exists is the dev
Containerfile and the dev-* justfile recipes, built and commented as
"Local anvil-app-server for docdrop — dev + Phase-verification only." They
carry two dev-specific workarounds a durable self-host setup would also
need to solve:
- The
Landingform is authored as an HTML form template (form_template.html), which the OSSanvil-app-server1.17.0 can't read yet — the staging step swaps it for a minimalform_template.yamlcopy. - The Anvil-IDE-only
theme/directory isn't in this repo — the staging step scaffolds a throwaway blank one in.
To try it as-is:
just dev-build # build the local app-server image
just dev-up # stage the app, start it on :3030The same admin-plane setup as managed hosting applies from there — mint a
key, point the CLI's url at your instance. Treat this as a starting point
to adapt, not a supported self-hosting guide.
just dev-up # start (or resume) the container on :3030
just dev-reload # pick up client_code/server_code changes
just dev-fresh # wipe the DB volume and start cleanTo administer that instance, add a [local] section to your
environments.toml (see environments.example.toml):
[local]
url = "ws://localhost:3030/_/uplink"
uplink_key = "dev-uplink"dev-uplink is the Containerfile's hardcoded dev key — real deployments
never hardcode one. Then:
just admin --env local mint-key you@example.comand point the CLI's url at http://localhost:3030 to publish against
the container.
Every command takes --server NAME to target a named profile (see
Configuring the CLI, above). It goes either before or after the command
name, so docdrop list --server local and docdrop --server local list
are equivalent.
| Command | What it does |
|---|---|
docdrop publish <file> |
publish an HTML or Markdown file (--slug, --title, --alias/--clear-alias) |
docdrop list |
list your published documents (--all sweeps every configured profile) |
docdrop open <slug> |
open a document in your browser (--token forces the token URL over its alias) |
docdrop alias <slug> [name] |
add, edit, clear (--clear), or show a document's alias |
docdrop grant <slug> |
mint a new share link (--label, --expires) |
docdrop revoke <slug> <handle> |
revoke a share link by its short handle |
docdrop grants <slug> |
list a document's share links |
docdrop history <slug> |
list a document's revisions |
docdrop diff <slug> [A B] |
show what changed between two revisions (default: previous vs. head) |
docdrop rollback <slug> N |
make an old revision the new head |
docdrop servers |
list your configured server profiles |
docdrop publish notes.md --slug trip-notes --title "Trip Notes"
docdrop list
docdrop open trip-notes
docdrop alias trip-notes --clear
docdrop grant trip-notes --label review --expires 7d
docdrop grants trip-notes
docdrop revoke trip-notes ab12cd34
docdrop history trip-notes
docdrop diff trip-notes
docdrop diff trip-notes 1 2
docdrop rollback trip-notes 1
docdrop servers
docdrop list --server local
docdrop list --all
docdrop --server local publish notes.mdjust validate # anvil validate . — after any YAML/HTML template edit
just check # anvil-pyright (the app; cli/ has its own config)
just lint # ruff
just cli-test # the CLI's test suite
just cli-lint # ruff, targeting the CLI's own Python
just stubs # wire .stubs/ symlinks into the vault's anvil-agent-referencesMIT — see LICENSE.