Skip to content

Repository files navigation

docdrop

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.

What this is

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.

Two planes

                    ┌─────────────────────┐
  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.

Security posture

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.

Installation

CLI from PyPI

The CLI is published as docdrop. Install it with whichever tool you prefer:

pip install docdrop
# or
pipx install docdrop
# or
uv tool install docdrop

The 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).

Configuring the CLI

The CLI reads a TOML config file, resolved in this order:

  1. $DOCDROP_CONFIG (an exact path), if set
  2. else $XDG_CONFIG_HOME/docdrop/config.toml
  3. 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 list

To see what's on every profile at once, without naming one:

docdrop list --all

Each 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 servers

CLI from source

No install step, no PyPI required — just uv:

just cli list
just cli publish notes.md

which 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 Python

Hosting on anvil.works

Get 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 main

If the IDE shows a schema conflict banner, resolve in the direction of the pushed anvil.yaml.

Serving more than one app from this repo

Two files genuinely differ between two Anvil apps built from the same code:

  • the name: line of anvil.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-all

Each 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:

  1. In the IDE: Settings → Uplink → enable the server uplink, copy the key.
  2. cp environments.example.toml environments.toml and paste the key into its [production] section — just admin defaults to --env production, so that section name matters.
  3. just admin mint-key you@example.com — put the key it prints into your CLI config (see above).

Self-hosting your own anvil-app-server

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 Landing form is authored as an HTML form template (form_template.html), which the OSS anvil-app-server 1.17.0 can't read yet — the staging step swaps it for a minimal form_template.yaml copy.
  • 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 :3030

The 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.

Local development

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 clean

To 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.com

and point the CLI's url at http://localhost:3030 to publish against the container.

Using the CLI

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.md

Development

just 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-references

License

MIT — see LICENSE.

About

Share HTML and Markdown documents by unguessable link — a tiny Anvil app with a publisher CLI.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages