Revue
DocumentationSelf-hosting the server

Self-hosting revue-server

Run your own copy of Revue's small web server, so pasted images and "open in Revue" links go through a server you control.

Revue works without any server. revue-server adds a few web things the desktop app can't do itself:

  • Image hosting. Images you paste, drop or pick in a comment are uploaded here, and the comment gets a normal Markdown image. This works everywhere, including GitHub, whose API has no image upload of its own.
  • Links that open Revue. A web page that opens a pull request in Revue, for places that only accept web links (Slack's ↗ Revue links, and Copy Revue link in the app).
  • The website. A home page and a downloads page with the latest release.
  • Revue in the browser: the web version at /app, run by the same container (see The web version at /app).

By default Revue uses the public server at https://revue.cleangame.dev. You only need your own if you want images and links on your own domain, or the official server can't be reached from where you work.

What the server offers#

AddressWhat it does
/The website's home page
/downloadsThe latest release's files, with the visitor's own system first
/assets/…The website's stylesheet, icons and screenshots
POST /imagesUpload an image (needs an upload token)
/images/<hash>.<ext>A stored image, public
/link?url=<pull request address>A page that opens that pull request in Revue
/<host>/<path>The same page, short form: /github.com/acme/web/pull/12 opens https://github.com/acme/web/pull/12
/healthAnswers ok, for health checks
/privacyWhat the server keeps about the people who use it, and for how long
/api/v1/infoWhat the server offers: whether it hosts Revue accounts
/api/v1/…Revue accounts, when turned on (see Revue accounts)
/app, /app/…Revue's web version (see The web version at /app)
Advanced: how each part behaves

Images

  • Only PNG, JPEG, GIF and WebP are accepted. The server looks at the file's bytes, not its name or the type the client sends. SVG is refused on purpose, because an SVG can carry script.
  • Each image is stored under the SHA-256 of its bytes. The same screenshot uploaded twice is one file with one address, and an address never changes meaning. Images are served with Cache-Control: public, max-age=31536000, immutable, X-Content-Type-Options: nosniff and a sandbox content security policy.
  • Uploads send the raw image bytes with Authorization: Bearer <token>. The answer is 201 with {"url": "…", "markdown": "![image](…)"}.
  • Refusals are JSON {"error": "…"}: 401 (missing or unknown upload token), 429 (too many uploads, try again later), 413 (image is larger than the limit), 415 (not one of the accepted formats), 500 (could not store the image).
  • Older addresses still work: /i/<name> redirects permanently to /images/<name>, and POST /upload is the same as POST /images, for older Revue versions.

Link pages

  • The page tries to open revue://open?url=… at once. If Revue doesn't open, it shows Open in Revue, Open on the web and Get Revue (this server's own downloads page, or DOWNLOAD_URL).
  • It explains that Revue may not be installed, or not set to open revue:// links (Revue → Settings → Apps).
  • Only http:// and https:// addresses up to 2048 characters are taken. Anything else shows "Nothing to open" with status 400.
  • In the short form, the first part of the path must look like a host: a name with a dot (github.com) or with a port (git.example:3000). A whole address pasted after the server's name (/https://github.com/…) is taken too, and http:// addresses keep their scheme.
  • Link pages are never cached, send no referrer, and can't be framed.

Downloads page

  • It reads the latest release from a Forgejo (or Gitea) API and keeps it for 5 minutes. After a failed fetch it asks again after 1 minute, and waits at most 5 seconds for an answer.
  • If the API can't be reached, the last release fetched is still shown. The very first time, the page links the releases page instead.
  • Files are sorted into Linux, Windows and macOS by their names (-linux-, -windows-, -macos-), as the release workflow names them.

Website

  • The pages are Revue's own, built into the server. They load nothing from other sites. The only JavaScript is the home page's own small file for the screenshot viewer (keys, swipe, focus), which works without it too.

Run it with Docker#

The image isn't published for general use, so you build it yourself from the repository (its root is the build's context; server/Dockerfile.dockerignore says what of it the build gets).

  1. Get the source code:

    git clone https://forgejo.cleangame.dev/thatrichan/revue.git
    cd revue
    
  2. Build the image:

    docker build -f server/Dockerfile -t revue-server .
    

    Besides the server (server/), the build takes the documentation the website shows (docs/) and the app's encryption code (crates/): it compiles that to the WebAssembly the account pages run, and tests that module and the passkey page against the encryption's test vectors. No image is made if a test fails. It also builds Revue itself and its browser version, for the web version, which the server then runs at /app.

  3. Make an upload token, a long random secret. For example:

    openssl rand -hex 32
    
  4. Start it, with a folder for the images:

    docker run -d --name revue-server --restart unless-stopped \
      -p 8080:8080 -v ./data:/data \
      -e REVUE_PUBLIC_URL=https://revue.example.com \
      -e UPLOAD_TOKENS=<your token> \
      revue-server
    
  5. Open http://localhost:8080/health. It should say ok.

The build also runs the server's tests and compiles Revue twice (natively and to WebAssembly), so the first one takes a while: several minutes on a fast machine, much longer on a small server, and several GB of memory.

With Docker Compose, the same looks like this:

services:
  revue-server:
    image: revue-server
    restart: unless-stopped
    env_file: .env          # REVUE_PUBLIC_URL, UPLOAD_TOKENS, …
    volumes:
      - ./data:/data
    ports:
      - 127.0.0.1:8080:8080  # only the reverse proxy reaches it
Advanced: what's inside the image
  • The server is built in rust:1-bookworm and runs on debian:bookworm-slim. The web version's stages are deploy/web/Dockerfile's: the revue binary goes to /usr/local/bin/revue, beside revue-server, and the browser bundle to /usr/local/share/revue/web. The runtime image carries the few shared libraries revue links (xcb, xkbcommon) and the CA certificates it uses for the services; revue-server itself needs neither.
  • It listens on port 8080 and keeps everything in the volume /data.
  • It starts as root only to take ownership of the data folder (DATA_DIR, by default /data), then drops to the user revue (uid 1000) and runs the server. A bind-mounted folder therefore works without a manual chown. If you start the container as a non-root user (--user / user:), it skips that step, and the folder must already be writable for that user.
  • The server stays the main process and stops cleanly on SIGTERM (docker stop) or Ctrl+C, stopping the web version's process first when it runs one.
  • A built-in HEALTHCHECK runs revue-server --healthcheck every 30 seconds. It asks http://127.0.0.1:$PORT/health and needs no curl in the image.
  • The image holds nothing specific to a deployment: everything comes from the environment.
Advanced: running it without Docker

revue-server is an ordinary Rust program in the server folder, with its own Cargo.toml and lockfile.

  1. Install Rust (see Building from source and contributing).

  2. Build it:

    cd server
    cargo build --release --locked
    
  3. Run target/release/revue-server with the environment variables below. Set DATA_DIR, because the default /data rarely exists outside a container.

It needs no other system libraries: it brings its own TLS root certificates for fetching releases.

Settings#

Everything is set with environment variables. Only two are required.

VariableDefaultWhat it's for
REVUE_PUBLIC_URL(required)The address people reach the server at, e.g. https://revue.example.com. Image addresses are built from it. The same name as the web version's setting.
UPLOAD_TOKENS(required)One or more upload tokens, separated by commas
PORT8080The port the server listens on
DATA_DIR/dataWhere uploaded images are kept
MAX_UPLOAD_MB10The largest image accepted, in MB
UPLOADS_PER_HOUR120Uploads allowed per client IP address per hour
TRUST_PROXYfalsetrue behind a reverse proxy, so limits count the real client (see below)
DOWNLOAD_URLPUBLIC_URL/downloadsWhere to get Revue, shown as Get Revue on link pages
RELEASES_APIRevue's own releases on ForgejoA Forgejo or Gitea releases/latest API address, for the downloads page
RELEASES_PAGERevue's own releases pageThe page listing all releases, linked from the downloads page
Advanced: details of each setting
  • Values are trimmed, and an empty value counts as unset.
  • The server won't start (exit code 2, with a message) when REVUE_PUBLIC_URL is missing or doesn't start with http:// or https://, when UPLOAD_TOKENS holds no token, or when a number or TRUST_PROXY can't be read. TRUST_PROXY takes true or false. If it can't listen on the port, it exits with code 1.
  • A trailing / on REVUE_PUBLIC_URL is ignored.
  • Upload tokens are compared by their SHA-256 hash, all of them every time, so the comparison takes the same time whatever is sent. To revoke a token, remove it from the list and restart. To rotate one, add the new token, update the app, then remove the old one.
  • There is no open mode: without a token the server refuses to start, so it can never become an open image host.
  • DOWNLOAD_URL must be an http:// or https:// address; anything else is ignored.
  • The server always listens on all interfaces (0.0.0.0).
  • RELEASES_API and RELEASES_PAGE default to https://forgejo.cleangame.dev/api/v1/repos/thatrichan/revue/releases/latest and https://forgejo.cleangame.dev/thatrichan/revue/releases. Point them at a fork's releases if you publish your own builds. Only http(s) download links from the API are shown.

Images: storage and limits#

Images are plain files in the data folder, one per image:

<DATA_DIR>/images/<first two characters of the hash>/<sha256>.<ext>
  • Back up the data folder like any folder: copy, tar or rsync it. Nothing else holds state, and the server can be restarted at any time.
  • Nothing is deleted by the server. Images stay until you remove them, and comments that embed a removed image show it as broken.
  • Anyone with the address can see an image. Addresses are long hashes and can't be guessed, but they aren't secret once posted in a comment.
Advanced: limits
  • Size: MAX_UPLOAD_MB (10 MB by default) per image. A larger one is refused with "image is larger than 10 MB".
  • Rate: UPLOADS_PER_HOUR (120 by default) per client IP address, in a one-hour window that starts at that address's first upload.
  • Images are written to a temporary file and then renamed, so a reader never sees half an image.
  • Revue itself waits up to 2 minutes for an upload.

Behind a reverse proxy with TLS#

Put any reverse proxy in front (Caddy, nginx, Traefik, HAProxy, a cloud load balancer) so the server is reached over HTTPS. Images in comments and link pages should be HTTPS: forges and browsers may refuse plain HTTP images.

  1. Bind the container to localhost only, e.g. -p 127.0.0.1:8080:8080.
  2. Point your proxy at http://127.0.0.1:8080, with TLS for your domain.
  3. Allow request bodies at least as large as MAX_UPLOAD_MB (10 MB by default). Many proxies allow only 1 MB by default, and uploads then fail.
  4. Set REVUE_PUBLIC_URL to the HTTPS address, e.g. https://revue.example.com.
  5. Set TRUST_PROXY=true, and make sure the proxy adds the client's address to X-Forwarded-For.
Advanced: which headers matter
  • The server builds every address it hands out from REVUE_PUBLIC_URL. It doesn't read Host, X-Forwarded-Host or X-Forwarded-Proto for itself. With the web version on, it tells that one the host the browser asked for: keep the Host header (or, with TRUST_PROXY=true, set X-Forwarded-Host).
  • With TRUST_PROXY=true, the client is the last address in X-Forwarded-For (the one your proxy appended). Without the header, it falls back to the connecting address.
  • The server speaks HTTP/1.1. It sets its own caching and security headers; you don't need to add any.

The web version at /app#

The image also carries Revue's web version, Revue in the browser, and the server runs it at /app of its address: with REVUE_PUBLIC_URL=https://revue.example.com, people open https://revue.example.com/app, and the home page offers it (Open in your browser). There's nothing to set: it's on whenever the server finds it, and shares the server's settings.

  • On when the image has it. revue-server looks for the revue executable beside its own (or in /usr/local/bin) and the browser bundle in share/revue/web next to that bin (or in /usr/local/share/revue/web), where the image puts them. It starts revue serve as a process of its own, on a free loopback port, and passes every request for /app and /app/… to it. A build without them (from source, say) logs no web version at /app: … at start, and /app is a page saying so.
  • The same OAuth apps and callbacks. It signs in with the apps of Signing in to services from the web (GitHub, Bitbucket, Jira, and the Forgejo servers of REVUE_FORGEJO_CLIENT_IDS; not Slack), and the services send people back to the callbacks those apps already have, <REVUE_PUBLIC_URL>/account/connect/<service>/callback: the server keeps the callbacks of its account pages' sign-ins and passes every other one to the web version. No app to register, no callback to add. With WEB_OAUTH=off, or no app set, the web version takes pasted tokens only. Jira: any *.atlassian.net site.
  • The session key from SERVER_SECRET. The web version's sessions are encrypted with a key derived from SERVER_SECRET, so they survive restarts and updates (changing the secret signs everyone out of the web version). A server without SERVER_SECRET makes a random key once and keeps it in <DATA_DIR>/web/session-key.

To check it's on: <REVUE_PUBLIC_URL>/app/health says ok, and the log says web version on at …/app at start. To run the web version on its own instead, without revue-server, see Hosting the web version.

Advanced: how the server runs it
  • It gives the web version a clean environment: the OAuth apps above (none with WEB_OAUTH=off), its session key, its address (REVUE_PUBLIC_URL plus /app), the callbacks (REVUE_CALLBACK_URL), its data folder (<DATA_DIR>/web), the bundle, the loopback port, with ACCOUNTS=on the account server (REVUE_ACCOUNT_SERVER, the server's own address: the web version's welcome screen signs in with the browser's Revue account, see The web version at /app), and PATH, the locale (LANG, LC_ALL, LC_CTYPE), TZ, RUST_LOG, RUST_BACKTRACE and HOME (<DATA_DIR>/web/home). Nothing else of the server's environment: not SERVER_SECRET, the upload tokens or the mail settings.
  • A callback is the account pages' when its state is one of their sign-ins under way; any other, of a service the web version signs in to, goes to its /app/auth/<service>/callback, which takes it only from the browser session that started that sign-in (its cookie is SameSite=Lax at Path=/, so the service's redirect brings it), and only once.
  • If REVUE_PUBLIC_URL has a path, there's no web version (it's served at /app of the server's own address).
  • Everything it writes is in the log, each line starting with revue-server: web:. If it stops, it's started again after 1 second, then 2, 4, … up to a minute between tries while it keeps stopping at once. When the server stops, it sends the web version SIGTERM and waits up to 5 seconds before killing it.
  • It tells the web version who the client is in X-Forwarded-For (the address it would count itself, so with TRUST_PROXY=true the last one your proxy appended) and which host was asked for in X-Forwarded-Host. What a client sends in those headers itself is dropped. Hop-by-hop headers (Connection and those it names, Keep-Alive, Transfer-Encoding, Upgrade, …) aren't passed on, either way.
  • A request to the web version may be up to 16 MB (the web version takes 8 MB at most, and only for its API proxy and sign-in): let your proxy pass at least 8 MB to /app/. Answers are streamed back with no limit of the server's, so turn off your proxy's buffering for /app/ if it delays them (nginx: proxy_buffering off).
  • While it isn't answering (starting, or restarting), /app answers 502. /health is the server's own and says ok either way; /app/health is the web version's.
  • Its sessions are kept in <DATA_DIR>/web: back it up with the rest of the data folder, or don't (losing it only signs everyone out of the web version).
  • It shares the server's origin. Its session cookie has a name of its own (__Host-revue_session_app; the account pages' is __Host-revue_session), but a script on revue-server's pages could call /app/api/… with the visitor's session. revue-server's pages escape every value and run only their own scripts, and it serves no uploaded HTML or SVG, which is what keeps that out. The other way round, the account pages' cookie (and the sign-in binding cookie) is taken out of every request passed to the web version: it never holds the account's session.

Point Revue at your server#

Revue reads two settings, from the environment when it starts, or built into the app when it's compiled:

VariableWhat it's for
REVUE_SERVER_URLYour server's address, e.g. https://revue.example.com
REVUE_IMAGES_TOKENOne of the server's UPLOAD_TOKENS

To try it on one computer, start Revue with both set:

REVUE_SERVER_URL=https://revue.example.com REVUE_IMAGES_TOKEN=<your token> revue

To give everyone a build that uses your server, set the same variables when you build Revue (see Building from source and contributing).

Advanced: how Revue uses these settings
  • A value set when Revue starts wins over the one built in. An empty value counts as unset, so at run time it falls back to the built-in one.
  • The project's global.env file builds in REVUE_SERVER_URL=https://revue.cleangame.dev. A variable of the same name set while building wins, and building with REVUE_SERVER_URL= (empty) makes a Revue without a server.
  • REVUE_IMAGES_TOKEN isn't in global.env. Put it in .env for task dev, or in your build environment.
  • Without a server or without a token, uploading is off: the editor's image button inserts ![image](https://) for you to fill in.
  • Without a server, Copy Revue link isn't offered, and Slack messages get no ↗ Revue link unless you set a page of your own in Revue's Slack settings.
  • Dropped or picked files are uploaded only when they end in .png, .jpg, .jpeg, .gif or .webp.
  • A token built into an app can be read out of it. Anyone holding the app can upload images to your server, within the size and rate limits. That's why the limits exist.

Try it on your computer#

From a checkout of the source code, with Task and Rust installed:

task server:dev

It builds and runs the server at http://localhost:7377, with the upload token dev. Stop it with Ctrl+C.

To try uploads from Revue against it, start the app with REVUE_SERVER_URL=http://localhost:7377 and REVUE_IMAGES_TOKEN=dev.

Advanced: server:dev defaults
  • PORT=7377, REVUE_PUBLIC_URL=http://localhost:$PORT, UPLOAD_TOKENS=dev and DATA_DIR=.dev-data (that is, server/.dev-data, which git ignores).
  • Revue accounts are on, with MAIL_MODE=dir: mails are written to server/.dev-data/mail/ and each code is printed in the log. task server:invite makes an invitation code for it.
  • Any of these set in your environment wins, e.g. PORT=9000 task server:dev.
  • Arguments after -- go to cargo run, e.g. task server:dev -- --release.

Revue accounts#

A server can also keep Revue accounts: an end-to-end encrypted vault of people's sign-ins, settings and favourites, so signing in once on a computer brings every account with it. It's off unless you turn it on, and you can build the server without it.

The vault is encrypted on people's computers, with keys made from their password that never leave them. Your server stores ciphertext it can't read, salts and wrapped keys. It never sees a password, a token, or which services an item is for. What it does hold (email addresses, device names, an activity log with IP addresses) is personal data: as its operator you are its controller under the GDPR, and the server's /privacy page says what it keeps.

To turn it on, add to the server's environment:

ACCOUNTS=on
# 32 random bytes as hex: openssl rand -hex 32. Keep it only in the server's
# environment. Losing it turns off two-step sign-in for everyone (they turn
# it on again); no vault is lost.
SERVER_SECRET=<64 hex characters>
# Mail: codes and security notices. TLS only.
SMTP_HOST=smtp.example.com
[email protected]
SMTP_PASSWORD=<the mailbox's password>
MAIL_FROM=Revue <[email protected]>
# Who runs the server, shown on /privacy.
PRIVACY_CONTACT=Your Name, [email protected]

The server refuses to start, with a message, when something required is missing or wrong, rather than failing at the first sign-up.

VariableDefaultWhat it does
ACCOUNTSoffon to host Revue accounts
SERVER_SECRET(required)At least 32 random bytes as hex
REGISTRATIONopenopen (anyone), invite (with a code from revue-server invite), closed (no new accounts; existing ones keep working)
MAX_USERS(none)At most this many verified accounts
MAX_UNVERIFIED500At most this many accounts waiting for their emailed code (also 3 per IP address)
UNVERIFIED_DAYS7Accounts whose code was never entered are deleted after this
DEVICE_IDLE_DAYS90A computer unused this long is signed out
MAX_ITEM_MB8The largest vault item (a Slack attachment is a few MB)
QUOTA_MB64All of one account's items together
MAX_ITEMS1024Items per account
BACKUP_KEEP7Daily database snapshots kept in <DATA_DIR>/backups (0: none)
MAIL_MODEsmtpsmtp; dir writes each mail as an .eml file to <DATA_DIR>/mail; log only prints a line. Both of these print codes in the log: for trying it out, never for real users
SMTP_HOST(required for smtp)The mail server
SMTP_PORT465 (587 with starttls)Its port
SMTP_TLSimplicitimplicit: TLS from the start (port 465). starttls: upgraded, and required (port 587). There's no plain-text mode
SMTP_USERNAME(required for smtp)The mailbox's full address
SMTP_PASSWORD or SMTP_PASSWORD_FILE(required for smtp)Its password, or a file holding it (a Docker secret)
MAIL_FROM(required for smtp)The sender, e.g. Revue <[email protected]>. Must be SMTP_USERNAME's address, since many providers refuse anything else; MAIL_FROM_UNCHECKED=1 if yours allows aliases
MAIL_REPLY_TO(none)Where replies go
MAIL_PER_HOUR100The server's own ceiling on mails sent, set below your provider's limit
PRIVACY_URL/privacyThe privacy page linked from every mail
PRIVACY_CONTACT(none)Who runs the server, for the privacy page
Advanced: how accounts behave
  • Storage. One SQLite file, <DATA_DIR>/accounts.db, next to the images. Its schema is upgraded at start; a server older than the file refuses to start rather than damage it. Once a day the server writes a consistent copy to <DATA_DIR>/backups/accounts-<date>.db (copying the live file could catch it mid-write), so backing up the data folder backs up accounts too. To restore: stop the server, copy a snapshot over accounts.db, start it. Restoring an old snapshot rolls vault items back; people's computers notice and push their newer copies.
  • Mail. Mails are queued in the database and sent by the server in the background, so a request never waits for the mail server. A failure is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, then every 6 hours, and given up after a day; a mail refused for good isn't retried. A mail carrying a code is dropped once the code has expired (15 minutes). A mail's text is deleted from the database once it's sent. Mails are plain text with a simple HTML version, in English or Czech, the app's language when the account was made.
  • Open registration, without abuse. Until someone enters the 6-digit code mailed to them, their account holds nothing. Sign-ups are limited to 3 an hour and 10 a day per IP address and 20 a day per /24 (IPv4) or /48 (IPv6) network; codes to 1 a minute, 5 an hour and 10 a day per address. Signing up with an address that already has an account answers exactly like a new one, and that address gets a mail saying so, so nobody can find out who has an account. There's no CAPTCHA.
  • Sign-in. 20 attempts per 10 minutes per IP address. After 5 wrong passwords for one address, sign-ins to it wait 1 minute, then 2, 4 … up to an hour, and its owner gets one mail. The answer is the same for an unknown address, a wrong password and a locked-out address. Two-step sign-in (an authenticator app, with 10 backup codes) is optional per account.
  • Invitations. With REGISTRATION=invite, make a single-use code with revue-server invite (in Docker: docker compose exec revue-server revue-server invite) and give it to the person signing up.
  • Without accounts. With ACCOUNTS=off (the default), or a server built with cargo build --no-default-features, there is no database and no mail, /api/v1/info answers {"accounts": false}, and every other /api/v1/ address is not found.
  • Behind a proxy, allow request bodies of at least MAX_ITEM_MB plus a little; everything else under /api/v1/ is at most 256 KiB.

The account in a browser#

With accounts on, the server also has pages to create a Revue account and manage it from any browser, at /account (see Revue account in a browser). The site's header and footer link them. They're the same accounts as in the app; nothing extra to turn on, and REGISTRATION applies to them too.

  • Same end-to-end encryption. The browser derives the keys itself, running the app's own cryptography compiled to WebAssembly (/assets/revue-vault.wasm). Your server gets what the app would send, never a password. The pages manage the account (email, password, recovery key, two-step sign-in, computers, passkeys, activity, export, deletion) and its connected services: the browser opens the vault's sign-ins with the person's password, in a background worker, and adds a pasted token encrypted, as the app would. Your server still sees only ciphertext. The vault's routes open to a browser only for 15 minutes after its password is typed again, and only to list, create and remove sign-ins (never a key rotation or a lease).
  • Sessions. A browser signs in as a device of its own ("Web browser" in the app's computer list), whose token the server keeps in an HttpOnly, SameSite=Strict cookie (Secure on https). It's signed out after 15 minutes unused or 8 hours in all, or after 30 days when the person ticks Keep me signed in on this browser (the vault's 15 minutes stay). Every request also carries a CSRF token and must come from the server's own origin, REVUE_PUBLIC_URL: serve the pages at exactly that address.
  • What the pages may do. Their Content-Security-Policy allows only the server's own scripts, plus 'wasm-unsafe-eval' to run the module (every other page of the site runs no WebAssembly); no frames, inline scripts or other origins. A proxy must not add or rewrite these headers.
  • Trust. Like the passkey page, the account pages run code your server serves. People who use them trust you, the operator, at that moment; the app's own sign-ins don't depend on any page of the server.
  • Checking a pasted token. The page's worker asks the service whose a pasted token is, straight from the browser (its policy allows connect-src https: for that worker only). Services that don't answer web pages (Jira; Forgejo servers without CORS) are added unchecked, and the app checks them at its next start.

Signing in to services from the web (WEB_OAUTH)#

On for every service whose OAuth app you set below (WEB_OAUTH=off turns it off): the Connected services card then offers Sign in with the service first, as the app does, and Paste a token beside it. A web page can't finish such a sign-in by itself (GitHub's token endpoint doesn't answer browsers, and the others want the OAuth app's secret), so your server does the middle of it:

  1. the page asks your server to start, and opens the service's page in a window;
  2. the service sends that window back to <REVUE_PUBLIC_URL>/account/connect/<service>/callback, where your server swaps the code for the tokens (with the app's secret, or PKCE) and asks the service whose they are;
  3. the page's worker takes the result from your server, once, then builds the sign-in, encrypts it and uploads it like a pasted token.

The trade-off: for that moment your server sees the person's token. It keeps it in memory only, until the page takes it (10 minutes at most), and never writes it, the code or a service's answer to disk or to the log. The /privacy page says so when it's on. Pasting a token stays end-to-end either way. Only the browser that started a sign-in can finish it: it's bound to that browser's session and to a second cookie, revue_connect (SameSite=Lax, so it comes along on the service's redirect back; __Host- on https), and each sign-in's state works once.

Use the same OAuth apps as the Revue builds your users run: the app renews GitHub, Bitbucket, Jira and Slack tokens with its own built-in app, so a token from another app won't renew there. For GitHub, the builds need the app's secret too (REVUE_GITHUB_CLIENT_SECRET): a token from the web flow renews only with it. Each service is offered only when its app is set:

REVUE_GITHUB_CLIENT_ID=...
REVUE_GITHUB_CLIENT_SECRET=...     # the web flow needs it; the app's device flow doesn't
REVUE_BITBUCKET_CLIENT_ID=...
REVUE_BITBUCKET_CLIENT_SECRET=...
REVUE_JIRA_CLIENT_ID=...
REVUE_JIRA_CLIENT_SECRET=...
REVUE_SLACK_CLIENT_ID=...
REVUE_SLACK_CLIENT_SECRET=...      # only for a Slack app without PKCE
REVUE_FORGEJO_CLIENT_IDS=https://codeberg.org=<id>,https://git.example.com=<id>
VariableDefaultWhat it does
WEB_OAUTH(unset)Unset: Sign in with the service for each service whose app is set below (a half-set one is left out). off turns it off; on insists, so a half-set app stops the server at start, and with no app set it signs in to people's own Forgejo servers only. Needs ACCOUNTS=on
REVUE_GITHUB_CLIENT_ID, REVUE_GITHUB_CLIENT_SECRET(none)GitHub's OAuth app; both or neither
REVUE_BITBUCKET_CLIENT_ID, REVUE_BITBUCKET_CLIENT_SECRET(none)Bitbucket's OAuth consumer; both or neither
REVUE_JIRA_CLIENT_ID, REVUE_JIRA_CLIENT_SECRET(none)Atlassian's OAuth 2.0 (3LO) app; both or neither
REVUE_SLACK_CLIENT_ID, REVUE_SLACK_CLIENT_SECRET(none)Slack's app; the secret only if it doesn't use PKCE
REVUE_FORGEJO_CLIENT_IDS(none)https://<server>=<client id> pairs, comma-separated: the Forgejo servers offered at once (public clients, PKCE). Any other Forgejo server works with the person's own OAuth app there: your server then reaches that server's address, which must be public https (no private, loopback or link-local address, checked on every lookup, no redirects, short timeouts)

Add <REVUE_PUBLIC_URL>/account/connect/<service>/callback to each app's redirect addresses, <service> being github, bitbucket, jira, slack or forgejo. A GitHub OAuth app takes one callback address, which the app's device flow doesn't use, so set it to this one. A Bitbucket consumer also takes one, the app's own loopback: check that Bitbucket accepts the web one as a redirect_uri before turning Bitbucket on. Flows under way live in memory: a restart only cancels them. The web version at /app signs in with the same apps and callbacks (all but Slack).

Passkeys#

With accounts on, the server can also take passkeys, a separate switch that's off by default:

PASSKEYS=on
# Optional: the passkeys' relying party, by default REVUE_PUBLIC_URL's host.
# PASSKEY_RP_ID=revue.example.com

A passkey is added from a computer already signed in, and then signs in on a new one through the system browser: Revue opens a page on your server (/passkey/…), the browser asks for the passkey, and the result goes back to Revue on the computer. A passkey whose authenticator supports the PRF extension (iCloud Keychain, Google Password Manager, recent Windows Hello, 1Password and most FIDO2 security keys) also unlocks the account's vault, with no password; it also keeps the vault when the password is forgotten. Any other passkey confirms it's you: in place of the authenticator app's code after the password, and before security changes.

What turning it on needs:

  • HTTPS. Browsers take passkeys only on https:// addresses (or http://localhost), so REVUE_PUBLIC_URL must be your server's https:// address, exactly as people's browsers reach it: the browser reports that origin and the server checks it.
  • A domain that stays. Passkeys belong to their relying party, REVUE_PUBLIC_URL's host unless PASSKEY_RP_ID names a domain it's under (example.com for revue.example.com, so passkeys keep working if the server moves to another name under it). Change it, or move the server to another domain, and every passkey stops working: people sign in with their password or recovery key and add their passkeys again.
  • Your users' trust. The passkey page runs on your server. For passkeys that unlock the vault, the page sees the passkey's result for a moment while it seals it for the person's computer: a server run by someone hostile could keep it. Revue says so next to passkeys, and asks before using one with a server other than its own. Password sign-ins don't depend on any page of the server.

/api/v1/info says "passkeys": true once they're on, with passkey_rp_id and passkey_origin. Passkey ceremonies are limited to 60 per 10 minutes per IP address, and each one lasts 5 minutes. With PASSKEYS=off every /passkey/ and passkey /api/v1/ address is not found, and passkeys people added earlier aren't asked for (they come back when it's on again).

Mail that arrives#

Mail from your domain lands in spam unless the domain vouches for it. With your mail provider's help pages and administration:

  1. SPF: one TXT record on the domain that includes your provider's senders, e.g. v=spf1 include:<your provider's SPF domain> ~all. Only one SPF record per name: merge it with any you have.
  2. DKIM: turn on DKIM signing for the domain in your provider's administration, then publish the TXT record it shows (<selector>._domainkey.<domain>).
  3. DMARC: _dmarc.<domain> TXT "v=DMARC1; p=none; rua=mailto:<a mailbox you read>". After a few weeks of clean reports, move to p=quarantine.
  4. Test: create an account with addresses at a few big providers; in the received mail's headers, check spf=pass, dkim=pass and dmarc=pass.
  5. Read the sending mailbox now and then: bounces land there, and many of them mean spam sign-ups.

More#