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#
| Address | What it does |
|---|---|
/ | The website's home page |
/downloads | The latest release's files, with the visitor's own system first |
/assets/… | The website's stylesheet, icons and screenshots |
POST /images | Upload 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 |
/health | Answers ok, for health checks |
/privacy | What the server keeps about the people who use it, and for how long |
/api/v1/info | What 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: nosniffand asandboxcontent security policy. - Uploads send the raw image bytes with
Authorization: Bearer <token>. The answer is201with{"url": "…", "markdown": ""}. - 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>, andPOST /uploadis the same asPOST /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, orDOWNLOAD_URL). - It explains that Revue may not be installed, or not set to open
revue://links (Revue → Settings → Apps). - Only
http://andhttps://addresses up to 2048 characters are taken. Anything else shows "Nothing to open" with status400. - 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, andhttp://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).
-
Get the source code:
git clone https://forgejo.cleangame.dev/thatrichan/revue.git cd revue -
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. -
Make an upload token, a long random secret. For example:
openssl rand -hex 32 -
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 -
Open
http://localhost:8080/health. It should sayok.
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-bookwormand runs ondebian:bookworm-slim. The web version's stages aredeploy/web/Dockerfile's: therevuebinary goes to/usr/local/bin/revue, besiderevue-server, and the browser bundle to/usr/local/share/revue/web. The runtime image carries the few shared librariesrevuelinks (xcb, xkbcommon) and the CA certificates it uses for the services; revue-server itself needs neither. - It listens on port
8080and 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 userrevue(uid 1000) and runs the server. A bind-mounted folder therefore works without a manualchown. 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
HEALTHCHECKrunsrevue-server --healthcheckevery 30 seconds. It askshttp://127.0.0.1:$PORT/healthand 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.
-
Install Rust (see Building from source and contributing).
-
Build it:
cd server cargo build --release --locked -
Run
target/release/revue-serverwith the environment variables below. SetDATA_DIR, because the default/datararely 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.
| Variable | Default | What 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 |
PORT | 8080 | The port the server listens on |
DATA_DIR | /data | Where uploaded images are kept |
MAX_UPLOAD_MB | 10 | The largest image accepted, in MB |
UPLOADS_PER_HOUR | 120 | Uploads allowed per client IP address per hour |
TRUST_PROXY | false | true behind a reverse proxy, so limits count the real client (see below) |
DOWNLOAD_URL | PUBLIC_URL/downloads | Where to get Revue, shown as Get Revue on link pages |
RELEASES_API | Revue's own releases on Forgejo | A Forgejo or Gitea releases/latest API address, for the downloads page |
RELEASES_PAGE | Revue's own releases page | The 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_URLis missing or doesn't start withhttp://orhttps://, whenUPLOAD_TOKENSholds no token, or when a number orTRUST_PROXYcan't be read.TRUST_PROXYtakestrueorfalse. If it can't listen on the port, it exits with code 1. - A trailing
/onREVUE_PUBLIC_URLis 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_URLmust be anhttp://orhttps://address; anything else is ignored.- The server always listens on all interfaces (
0.0.0.0). RELEASES_APIandRELEASES_PAGEdefault tohttps://forgejo.cleangame.dev/api/v1/repos/thatrichan/revue/releases/latestandhttps://forgejo.cleangame.dev/thatrichan/revue/releases. Point them at a fork's releases if you publish your own builds. Onlyhttp(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.
- Bind the container to localhost only, e.g.
-p 127.0.0.1:8080:8080. - Point your proxy at
http://127.0.0.1:8080, with TLS for your domain. - 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. - Set
REVUE_PUBLIC_URLto the HTTPS address, e.g.https://revue.example.com. - Set
TRUST_PROXY=true, and make sure the proxy adds the client's address toX-Forwarded-For.
Advanced: which headers matter
- The server builds every address it hands out from
REVUE_PUBLIC_URL. It doesn't readHost,X-Forwarded-HostorX-Forwarded-Protofor itself. With the web version on, it tells that one the host the browser asked for: keep theHostheader (or, withTRUST_PROXY=true, setX-Forwarded-Host). - With
TRUST_PROXY=true, the client is the last address inX-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
revueexecutable beside its own (or in/usr/local/bin) and the browser bundle inshare/revue/webnext to thatbin(or in/usr/local/share/revue/web), where the image puts them. It startsrevue serveas a process of its own, on a free loopback port, and passes every request for/appand/app/…to it. A build without them (from source, say) logsno web version at /app: …at start, and/appis 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. WithWEB_OAUTH=off, or no app set, the web version takes pasted tokens only. Jira: any*.atlassian.netsite. - The session key from
SERVER_SECRET. The web version's sessions are encrypted with a key derived fromSERVER_SECRET, so they survive restarts and updates (changing the secret signs everyone out of the web version). A server withoutSERVER_SECRETmakes 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_URLplus/app), the callbacks (REVUE_CALLBACK_URL), its data folder (<DATA_DIR>/web), the bundle, the loopback port, withACCOUNTS=onthe 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), andPATH, the locale (LANG,LC_ALL,LC_CTYPE),TZ,RUST_LOG,RUST_BACKTRACEandHOME(<DATA_DIR>/web/home). Nothing else of the server's environment: notSERVER_SECRET, the upload tokens or the mail settings. - A callback is the account pages' when its
stateis 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 isSameSite=LaxatPath=/, so the service's redirect brings it), and only once. - If
REVUE_PUBLIC_URLhas a path, there's no web version (it's served at/appof 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 versionSIGTERMand 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 withTRUST_PROXY=truethe last one your proxy appended) and which host was asked for inX-Forwarded-Host. What a client sends in those headers itself is dropped. Hop-by-hop headers (Connectionand 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),
/appanswers502./healthis the server's own and saysokeither way;/app/healthis 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:
| Variable | What it's for |
|---|---|
REVUE_SERVER_URL | Your server's address, e.g. https://revue.example.com |
REVUE_IMAGES_TOKEN | One 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.envfile builds inREVUE_SERVER_URL=https://revue.cleangame.dev. A variable of the same name set while building wins, and building withREVUE_SERVER_URL=(empty) makes a Revue without a server. REVUE_IMAGES_TOKENisn't inglobal.env. Put it in.envfortask dev, or in your build environment.- Without a server or without a token, uploading is off: the editor's image button inserts
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,.gifor.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=devandDATA_DIR=.dev-data(that is,server/.dev-data, which git ignores).- Revue accounts are on, with
MAIL_MODE=dir: mails are written toserver/.dev-data/mail/and each code is printed in the log.task server:invitemakes an invitation code for it. - Any of these set in your environment wins, e.g.
PORT=9000 task server:dev. - Arguments after
--go tocargo 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.
| Variable | Default | What it does |
|---|---|---|
ACCOUNTS | off | on to host Revue accounts |
SERVER_SECRET | (required) | At least 32 random bytes as hex |
REGISTRATION | open | open (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_UNVERIFIED | 500 | At most this many accounts waiting for their emailed code (also 3 per IP address) |
UNVERIFIED_DAYS | 7 | Accounts whose code was never entered are deleted after this |
DEVICE_IDLE_DAYS | 90 | A computer unused this long is signed out |
MAX_ITEM_MB | 8 | The largest vault item (a Slack attachment is a few MB) |
QUOTA_MB | 64 | All of one account's items together |
MAX_ITEMS | 1024 | Items per account |
BACKUP_KEEP | 7 | Daily database snapshots kept in <DATA_DIR>/backups (0: none) |
MAIL_MODE | smtp | smtp; 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_PORT | 465 (587 with starttls) | Its port |
SMTP_TLS | implicit | implicit: 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_HOUR | 100 | The server's own ceiling on mails sent, set below your provider's limit |
PRIVACY_URL | /privacy | The 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 overaccounts.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 withrevue-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 withcargo build --no-default-features, there is no database and no mail,/api/v1/infoanswers{"accounts": false}, and every other/api/v1/address is not found. - Behind a proxy, allow request bodies of at least
MAX_ITEM_MBplus 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=Strictcookie (Secureon 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:
- the page asks your server to start, and opens the service's page in a window;
- 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; - 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>
| Variable | Default | What 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 (orhttp://localhost), soREVUE_PUBLIC_URLmust be your server'shttps://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 unlessPASSKEY_RP_IDnames a domain it's under (example.comforrevue.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:
- 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. - DKIM: turn on DKIM signing for the domain in your provider's administration, then publish the TXT record it shows (
<selector>._domainkey.<domain>). - DMARC:
_dmarc.<domain> TXT "v=DMARC1; p=none; rua=mailto:<a mailbox you read>". After a few weeks of clean reports, move top=quarantine. - Test: create an account with addresses at a few big providers; in the received mail's headers, check
spf=pass,dkim=passanddmarc=pass. - Read the sending mailbox now and then: bounces land there, and many of them mean spam sign-ups.
More#
- The maintainers' deployment notes
- Slack, for the ↗ Revue links in Slack messages
- Links and opening from anywhere, for
revue://links