Kaynağa Gözat

Convert articles on the website (v1.2.0)

Add a converter form to rekopub.monoexp.com, served by a new rekopub-web
server that runs the rekopub command for each conversion, keeps the books
for 24 hours and limits use (50 per 15 minutes in total, 10 per visitor,
2 at once, 2 GB kept). Add the -NOLOCAL flag so a server can never be made
to connect to its own network, and group Download and Usage under
"Download / Use ReKoPub".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 8 saat önce
ebeveyn
işleme
1d05bfe0e7

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 # Built binaries
 /rekopub
 /rekopub.exe
+/rekopub-web
 
 # Test output
 *.test

+ 29 - 4
README.md

@@ -8,6 +8,8 @@ Turn any web article into a clean EPUB for your e-ink reader.
 rekopub -URL https://example.com/some-article -SAVE ~/Books/
 ```
 
+You can also paste an address into the form on [rekopub.monoexp.com](https://rekopub.monoexp.com/), which runs ReKoPub on the server and gives you download links.
+
 ---
 
 ## Features
@@ -85,7 +87,7 @@ To try it on real articles, run `test-examples.cmd` (`.\test-examples.cmd` in Po
 ## Usage
 
 ```
-rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB]
+rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
 rekopub -VERSION
 ```
 
@@ -95,6 +97,7 @@ rekopub -VERSION
 | `-SAVE` | yes | Where to write the book: a file or a folder (see below). Missing folders are created. |
 | `-FORCE` | no | Overwrite the output file if it already exists. |
 | `-KEPUB` | no | Write a Kobo KEPUB (`.kepub.epub`) instead of a plain EPUB. |
+| `-NOLOCAL` | no | Never connect to local or private addresses, even when `-URL` is one. For servers that run ReKoPub on behalf of other people. |
 | `-VERSION` | no | Print the version and exit. Include it when reporting a problem. |
 
 Flag names are case-sensitive: `-URL` works, `-url` does not.
@@ -227,7 +230,7 @@ curl -L -A "Mozilla/5.0" -o page.html "https://example.com/the-article"
 ## Privacy and security
 
 - **Only web pages are read.** ReKoPub downloads the page given as `-URL`, its images and its preview image, over `http://` or `https://` only. It never reads files on your computer: `file:` links and local paths in a page are ignored, and the only file it writes is the book.
-- **Pages on the internet can't reach your network.** A web page chooses where its images come from, and can redirect. So when `-URL` is on the internet, ReKoPub refuses to connect to your own computer (`localhost`), your home or office network (such as `192.168.x.x` or `10.x.x.x`) and similar private addresses. If `-URL` itself is a local address, such as a wiki on your network, its local images are allowed. With a proxy set in `HTTP_PROXY` or `HTTPS_PROXY`, the proxy makes the connections, so ReKoPub can't check them.
+- **Pages on the internet can't reach your network.** A web page chooses where its images come from, and can redirect. So when `-URL` is on the internet, ReKoPub refuses to connect to your own computer (`localhost`), your home or office network (such as `192.168.x.x` or `10.x.x.x`) and similar private addresses. If `-URL` itself is a local address, such as a wiki on your network, its local images are allowed. With a proxy set in `HTTP_PROXY` or `HTTPS_PROXY`, the proxy makes the connections, so ReKoPub can't check them. `-NOLOCAL` refuses local addresses even when `-URL` is one; the website converter always uses it.
 - **What sites see.** Requests look like a desktop browser and include "ReKoPub" in the user agent. Image requests send the article's address as the referrer, as browsers do. ReKoPub sends no cookies, never logs in and runs no JavaScript.
 - **Your files are safe.** Existing files are only replaced with `-FORCE`. Filenames made from article titles contain only letters, digits and dashes, so a title can't place the book outside the folder you chose.
 
@@ -262,6 +265,8 @@ On Windows, `build.cmd` builds `rekopub.exe` and `test-examples.cmd` converts a
 
 ```
 cmd/rekopub/        CLI entry point (flags, exit codes, -VERSION)
+cmd/rekopub-web/    The website's converter server (settings from the environment)
+internal/web/       Converter: form checks, limits, running rekopub, serving and deleting books
 internal/pipeline/  Runs the stages below in order
 internal/fetch/     HTTP download, local-network guard, character-set decoding, friendly network errors
 internal/extract/   Article and metadata extraction (go-trafilatura, page metadata)
@@ -275,12 +280,13 @@ internal/save/      Output path rules, folder creation, -FORCE, atomic writes
 internal/epubtest/  Test helpers for checking generated books
 testdata/           Saved HTML pages and images used by the tests
 site/               rekopub.monoexp.com (published on every merge into main)
+deploy/             nginx site file and example settings for the server
 VERSION             The version release builds report
 ```
 
 ### Releasing
 
-Every merge into `main` builds and publishes [rekopub.monoexp.com](https://rekopub.monoexp.com/): the server's deploy pipeline ([bajdev/devops](https://git.monoexp.com/bajdev/devops), kind `cli`) builds `./cmd/rekopub` for Windows, macOS and Linux on amd64 and arm64, archives each binary with `README.md` and `LICENSE`, writes `checksums.txt`, and publishes them in `download/` alongside the website in `site/`. The page shows the version, the build date and the commit, which the pipeline fills in where `site/index.html` has `@VERSION@`, `@DATE@`, `@COMMIT@` and `@SHORT@`.
+Every merge into `main` builds and publishes [rekopub.monoexp.com](https://rekopub.monoexp.com/): the server's deploy pipeline ([bajdev/devops](https://git.monoexp.com/bajdev/devops), kind `cli`) builds `./cmd/rekopub` for Windows, macOS and Linux on amd64 and arm64, archives each binary with `README.md` and `LICENSE`, writes `checksums.txt`, and publishes them in `download/` alongside the website in `site/`. It also builds the converter (`./cmd/rekopub-web`) and a `rekopub` for the server into `/opt/apps/rekopub-web/`, and restarts it before publishing the site. The page shows the version, the build date and the commit, which the pipeline fills in where `site/index.html` has `@VERSION@`, `@DATE@`, `@COMMIT@` and `@SHORT@`.
 
 The version comes from `VERSION` (`1.2.3`, without the `v`). To release a new version, change it in the pull request. After the merge, tag the merge commit:
 
@@ -291,7 +297,26 @@ git push origin v1.2.3
 
 Merges that don't change `VERSION` still republish the site and rebuild the binaries, with the same version and the new commit. Release binaries include the Go standard library, so run [govulncheck](https://go.dev/doc/security/vuln/) (`go run golang.org/x/vuln/cmd/govulncheck@latest ./...`) now and then; when it finds something, update Go or the dependency and merge.
 
-To preview the site, open `site/index.html` in a browser; the placeholders show as they are.
+To preview the site, open `site/index.html` in a browser; the placeholders show as they are. The converter form needs the server below.
+
+### Website converter
+
+The form on the site sends the address and format to `rekopub-web`, which runs as `goapp@rekopub-web` on `127.0.0.1:8004` behind nginx ([`deploy/rekopub.monoexp.com.conf`](deploy/rekopub.monoexp.com.conf) passes it `/api/` and `/books/`). For each conversion it:
+
+1. checks the address: `http://` or `https://`, standard ports, no user name or password, not a local or private address;
+2. checks the limits: by default 50 conversions in 15 minutes for everyone together and 10 per visitor (an IPv4 address, or an IPv6 /64), 2 running at once with up to 10 waiting, and 2 GB of kept books. When a limit is reached the form is paused and says when to try again;
+3. runs `rekopub -NOLOCAL` (with `-KEPUB` for KEPUB, both for "Both") as a separate process with a 2-minute time limit and an empty environment, saving into a new folder with a random 128-bit name in `data/books/`;
+4. answers with the download links, `/books/<folder>/<file>`. Books are deleted 24 hours later.
+
+Settings come from `/etc/goapps/rekopub-web.env` (see [`deploy/rekopub-web.env.example`](deploy/rekopub-web.env.example) and `cmd/rekopub-web/main.go`). To run it locally, next to a built `rekopub`:
+
+```bash
+go build -o /tmp/rk/ ./cmd/rekopub ./cmd/rekopub-web
+cd /tmp/rk && PORT=18014 ./rekopub-web
+curl -H 'Accept: application/json' -d url=https://example.com/ -d format=epub http://127.0.0.1:18014/api/convert
+```
+
+Without `Accept: application/json` it answers with an HTML page, which is what browsers without JavaScript get.
 
 See [`PLAN.md`](PLAN.md) for the full design.
 

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.1.0
+1.2.0

+ 112 - 0
cmd/rekopub-web/main.go

@@ -0,0 +1,112 @@
+// Command rekopub-web runs the converter behind the form on
+// rekopub.monoexp.com. It listens on 127.0.0.1 only, behind nginx, and runs
+// the rekopub command for each conversion.
+//
+// Settings come from the environment:
+//
+//	PORT               port to listen on (required)
+//	REKOPUB            the rekopub command (default: rekopub next to this program)
+//	DATA_DIR           writable folder; books are kept in DATA_DIR/books (default: data)
+//	KEEP_HOURS         hours a book is kept and its link works (default: 24)
+//	LIMIT              conversions for all visitors together... (default: 50)
+//	LIMIT_PER_VISITOR  ...and per visitor... (default: 10)
+//	LIMIT_MINUTES      ...within this many minutes (default: 15)
+//	MAX_RUNNING        conversions running at once (default: 2)
+//	MAX_STORAGE_MB     disk used by kept books before conversions pause (default: 2048)
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"strconv"
+	"syscall"
+	"time"
+
+	"git.monoexp.com/bajdev/rekopub/internal/web"
+)
+
+func main() {
+	log.SetFlags(0) // the journal adds timestamps
+	port, cfg, err := config()
+	if err != nil {
+		log.Fatal(err)
+	}
+	srv, err := web.New(cfg)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	go srv.CleanEvery(ctx, 10*time.Minute)
+
+	hs := &http.Server{
+		Addr:              "127.0.0.1:" + port,
+		Handler:           srv.Handler(),
+		ReadHeaderTimeout: 10 * time.Second,
+		ReadTimeout:       30 * time.Second,
+		IdleTimeout:       60 * time.Second,
+		// No WriteTimeout: a conversion can take minutes. Each run of the
+		// command has its own time limit instead.
+	}
+	go func() {
+		<-ctx.Done()
+		shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		hs.Shutdown(shutdown)
+	}()
+	log.Printf("rekopub-web listening on %s, running %s", hs.Addr, cfg.CLI)
+	if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+		log.Fatal(err)
+	}
+}
+
+func config() (string, web.Config, error) {
+	var cfg web.Config
+	port := os.Getenv("PORT")
+	if _, err := strconv.ParseUint(port, 10, 16); err != nil {
+		return "", cfg, fmt.Errorf("PORT %q: must be a port number", port)
+	}
+	cfg.CLI = os.Getenv("REKOPUB")
+	if cfg.CLI == "" {
+		exe, err := os.Executable()
+		if err != nil {
+			return "", cfg, err
+		}
+		cfg.CLI = filepath.Join(filepath.Dir(exe), "rekopub")
+	}
+	if _, err := os.Stat(cfg.CLI); err != nil {
+		return "", cfg, fmt.Errorf("REKOPUB: %w", err)
+	}
+	data := os.Getenv("DATA_DIR")
+	if data == "" {
+		data = "data"
+	}
+	cfg.Dir = filepath.Join(data, "books")
+
+	var err error
+	num := func(name string) int {
+		v := os.Getenv(name)
+		if v == "" || err != nil {
+			return 0 // the default
+		}
+		n, perr := strconv.Atoi(v)
+		if perr != nil || n <= 0 {
+			err = fmt.Errorf("%s %q: must be a whole number above 0", name, v)
+		}
+		return n
+	}
+	cfg.Keep = time.Duration(num("KEEP_HOURS")) * time.Hour
+	cfg.Limit = num("LIMIT")
+	cfg.PerVisitor = num("LIMIT_PER_VISITOR")
+	cfg.Window = time.Duration(num("LIMIT_MINUTES")) * time.Minute
+	cfg.MaxRunning = num("MAX_RUNNING")
+	cfg.MaxStorage = int64(num("MAX_STORAGE_MB")) << 20
+	return port, cfg, err
+}

+ 9 - 4
cmd/rekopub/main.go

@@ -1,6 +1,6 @@
 // Command rekopub converts a web article into an EPUB for e-ink readers.
 //
-//	rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB]
+//	rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
 package main
 
 import (
@@ -31,7 +31,7 @@ const (
 	exitInterrupted = 130
 )
 
-const usage = `Usage: rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB]
+const usage = `Usage: rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
 
 Convert a web article into an EPUB for e-ink readers.
 
@@ -48,6 +48,9 @@ Convert a web article into an EPUB for e-ink readers.
         Overwrite the output file if it already exists.
   -KEPUB
         Write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB.
+  -NOLOCAL
+        Never connect to local or private addresses, even when -URL is
+        one. For servers that run rekopub on behalf of other people.
   -VERSION
         Print the version and exit.
 `
@@ -71,6 +74,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert c
 	saveArg := fs.String("SAVE", "", "file or folder to write the book to (required)")
 	force := fs.Bool("FORCE", false, "overwrite the output file if it already exists")
 	kepub := fs.Bool("KEPUB", false, "write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB")
+	noLocal := fs.Bool("NOLOCAL", false, "never connect to local or private addresses")
 	showVersion := fs.Bool("VERSION", false, "print the version and exit")
 
 	if err := fs.Parse(args); err != nil {
@@ -123,8 +127,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert c
 	}
 
 	opts := pipeline.Options{
-		Warn:  func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
-		KEPUB: *kepub,
+		Warn:    func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
+		KEPUB:   *kepub,
+		NoLocal: *noLocal,
 	}
 	if !target.Known() && !*force {
 		// For folder targets the filename comes from the title; check it

+ 55 - 4
cmd/rekopub/main_test.go

@@ -24,15 +24,17 @@ import (
 // fakeConverter returns a fixed book and counts how often it was called, so
 // tests can check that nothing is fetched when arguments are rejected.
 type fakeConverter struct {
-	title string
-	err   error
-	calls int
-	kepub bool // the KEPUB option of the last call
+	title   string
+	err     error
+	calls   int
+	kepub   bool // the KEPUB option of the last call
+	noLocal bool // the NoLocal option of the last call
 }
 
 func (f *fakeConverter) convert(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error) {
 	f.calls++
 	f.kepub = opts.KEPUB
+	f.noLocal = opts.NoLocal
 	if f.err != nil {
 		return nil, f.err
 	}
@@ -278,6 +280,55 @@ func TestKEPUBOption(t *testing.T) {
 	}
 }
 
+func TestNoLocalOption(t *testing.T) {
+	for _, noLocal := range []bool{false, true} {
+		conv := &fakeConverter{title: "Title"}
+		args := []string{"-URL", testURL, "-SAVE", t.TempDir()}
+		if noLocal {
+			args = append(args, "-NOLOCAL")
+		}
+		if r := runCLI(t, conv, args...); r.code != exitOK {
+			t.Fatalf("noLocal=%v: exit code = %d, stderr = %q", noLocal, r.code, r.stderr)
+		}
+		if conv.noLocal != noLocal {
+			t.Errorf("noLocal=%v: pipeline got NoLocal=%v", noLocal, conv.noLocal)
+		}
+	}
+}
+
+// TestNoLocalRefusesLocalPage runs the real pipeline: without -NOLOCAL a
+// page on localhost may be converted, with it the page isn't even fetched.
+func TestNoLocalRefusesLocalPage(t *testing.T) {
+	blog, err := os.ReadFile("../../testdata/pages/blog.html")
+	if err != nil {
+		t.Fatal(err)
+	}
+	var requests atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		requests.Add(1)
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		w.Write(blog)
+	}))
+	defer srv.Close()
+
+	dir := t.TempDir()
+	var stdout, stderr bytes.Buffer
+	code := run(context.Background(), []string{"-URL", srv.URL + "/post", "-SAVE", dir, "-NOLOCAL"}, &stdout, &stderr, pipeline.Run)
+	if code != exitFetch || !strings.Contains(stderr.String(), "local or private address") {
+		t.Errorf("exit code = %d, stderr = %q; want %d and a refused local address", code, stderr.String(), exitFetch)
+	}
+	if n := requests.Load(); n != 0 {
+		t.Errorf("server got %d requests, want 0", n)
+	}
+	assertEmptyDir(t, dir)
+
+	stdout.Reset()
+	stderr.Reset()
+	if code := run(context.Background(), []string{"-URL", srv.URL + "/post", "-SAVE", dir}, &stdout, &stderr, pipeline.Run); code != exitOK {
+		t.Errorf("without -NOLOCAL: exit code = %d, stderr = %q", code, stderr.String())
+	}
+}
+
 // assertCover checks the book has a 1404x1872 grayscale cover, listed
 // first in the spine. With photo, the cover's upper half must show the
 // photo (mid-grays) rather than the plain cover's black band and white

+ 9 - 0
deploy/rekopub-web.env.example

@@ -0,0 +1,9 @@
+# /etc/goapps/rekopub-web.env (group apps, not in git): settings for
+# goapp@rekopub-web. Only PORT is required; the rest show the defaults.
+PORT=8004
+# KEEP_HOURS=24
+# LIMIT=50
+# LIMIT_PER_VISITOR=10
+# LIMIT_MINUTES=15
+# MAX_RUNNING=2
+# MAX_STORAGE_MB=2048

+ 62 - 0
deploy/rekopub.monoexp.com.conf

@@ -0,0 +1,62 @@
+# /etc/nginx/vhosts.d/rekopub.monoexp.com.conf
+#
+# ReKoPub's website and downloads, published by the deploy pipeline
+# (bajdev/devops, kind "cli") on every merge into bajdev/ReKoPub main.
+# The converter form's requests (/api/) and the books it makes (/books/) go
+# to rekopub-web (goapp@rekopub-web, 127.0.0.1:8004), which runs the
+# rekopub command. Port 80 (ACME challenges and the HTTP->HTTPS redirect)
+# is handled by the `.monoexp.com` block in 00-default.conf.
+
+# A first line of defence for the converter: per client address, at most
+# 6 conversion requests a minute, with a burst of 5. rekopub-web has the
+# real limits (50 for everyone and 10 per visitor in 15 minutes).
+limit_req_zone $binary_remote_addr zone=rekopub_convert:1m rate=6r/m;
+limit_req_zone $binary_remote_addr zone=rekopub_api:1m rate=60r/m;
+
+server {
+    listen 443 ssl; listen [::]:443 ssl;
+    server_name rekopub.monoexp.com;
+    ssl_certificate     /etc/letsencrypt/live/rekopub.monoexp.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/rekopub.monoexp.com/privkey.pem;
+    include snippets/tls-common.conf;
+
+    root /srv/www/vhosts/rekopub.monoexp.com;
+    index index.html;
+
+    location / { try_files $uri $uri/ =404; }
+
+    # The download names stay the same from one version to the next, so
+    # browsers must check for a newer file every time.
+    location /download/ { expires -1; }
+
+    # Short cache for the stylesheet, script and icon; HTML keeps nginx's
+    # defaults.
+    location ~* \.(css|js|svg)$ { expires 1h; }
+
+    # The converter. The form is a few hundred bytes; a conversion can take
+    # a few minutes (two runs of up to 2 minutes each for "Both", plus up to
+    # a minute waiting for a free slot).
+    location = /api/convert {
+        limit_req zone=rekopub_convert burst=5 nodelay;
+        limit_req_status 429;
+        client_max_body_size 8k;
+        proxy_read_timeout 330s;
+        include snippets/proxy.conf;
+        proxy_pass http://127.0.0.1:8004;
+    }
+    location = /api/status {
+        limit_req zone=rekopub_api burst=20 nodelay;
+        limit_req_status 429;
+        include snippets/proxy.conf;
+        proxy_pass http://127.0.0.1:8004;
+    }
+    location /api/ { return 404; }
+
+    # Finished books: random 128-bit folder names, deleted after 24 hours.
+    location /books/ {
+        limit_req zone=rekopub_api burst=20 nodelay;
+        limit_req_status 429;
+        include snippets/proxy.conf;
+        proxy_pass http://127.0.0.1:8004;
+    }
+}

+ 6 - 2
internal/pipeline/pipeline.go

@@ -49,13 +49,17 @@ type Options struct {
 	Warn func(msg string)
 	// KEPUB converts the finished book to a Kobo KEPUB.
 	KEPUB bool
+	// NoLocal refuses local and private addresses even when u itself is
+	// one. A server that converts pages for other people sets it, so a
+	// visitor can't make it connect to services on the server's network.
+	NoLocal bool
 }
 
 // Run converts the page at u into a book using the default settings.
 // Images and redirects may lead to local and private addresses only when
-// u itself is local.
+// u itself is local and opts.NoLocal isn't set.
 func Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
-	return New(fetch.IsLocal(ctx, u.Hostname())).Run(ctx, u, opts)
+	return New(!opts.NoLocal && fetch.IsLocal(ctx, u.Hostname())).Run(ctx, u, opts)
 }
 
 // Pipeline holds the settings for conversions.

+ 46 - 0
internal/web/limit.go

@@ -0,0 +1,46 @@
+package web
+
+import (
+	"fmt"
+	"math"
+	"slices"
+	"time"
+)
+
+// window remembers when recent conversions started, so it can tell whether
+// another one would go over a limit of n conversions per period.
+type window []time.Time
+
+// prune drops entries older than period.
+func (w *window) prune(now time.Time, period time.Duration) {
+	cut := 0
+	for cut < len(*w) && now.Sub((*w)[cut]) >= period {
+		cut++
+	}
+	*w = (*w)[cut:]
+}
+
+// full reports whether n conversions already started within period, and if
+// so when the oldest of them stops counting.
+func (w window) full(now time.Time, n int, period time.Duration) (bool, time.Time) {
+	if len(w) < n {
+		return false, time.Time{}
+	}
+	return true, w[len(w)-n].Add(period)
+}
+
+// remove takes back one entry made at t.
+func (w *window) remove(t time.Time) {
+	if i := slices.Index(*w, t); i >= 0 {
+		*w = slices.Delete(*w, i, i+1)
+	}
+}
+
+// minutes describes d in whole minutes, rounded up.
+func minutes(d time.Duration) string {
+	m := max(1, int(math.Ceil(d.Minutes())))
+	if m == 1 {
+		return "1 minute"
+	}
+	return fmt.Sprintf("%d minutes", m)
+}

+ 90 - 0
internal/web/run.go

@@ -0,0 +1,90 @@
+package web
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"os/exec"
+	"strings"
+	"time"
+)
+
+// Exit codes of the rekopub command, as documented in its README.
+const (
+	exitFetch     = 1
+	exitNoContent = 3
+)
+
+// runResult is the outcome of one run of the rekopub command.
+type runResult struct {
+	Path string // the saved book, from stdout
+	Code int    // exit code; -1 if the command didn't finish
+	Err  string // the command's last "error: " line, without the prefix
+}
+
+// runFunc runs the rekopub command with args. Tests replace it.
+type runFunc func(ctx context.Context, args []string) runResult
+
+// execRunner returns a runFunc that starts the command at cli. Each run is
+// its own process, so runs can't affect each other and a stuck one can be
+// killed. The command gets an empty environment: no proxy settings or
+// anything else from the server leak into it.
+func execRunner(cli string) runFunc {
+	return func(ctx context.Context, args []string) runResult {
+		cmd := exec.CommandContext(ctx, cli, args...)
+		cmd.Env = []string{}
+		cmd.WaitDelay = 5 * time.Second
+		var stdout, stderr bytes.Buffer
+		cmd.Stdout = &stdout
+		cmd.Stderr = &stderr
+		err := cmd.Run()
+		res := runResult{Path: strings.TrimSpace(stdout.String()), Err: lastError(stderr.String())}
+		var exit *exec.ExitError
+		switch {
+		case err == nil:
+		case errors.As(err, &exit) && exit.Exited():
+			res.Code = exit.ExitCode()
+		default:
+			res.Code = -1
+			if res.Err == "" {
+				res.Err = err.Error()
+			}
+		}
+		return res
+	}
+}
+
+// lastError returns the text of the last "error: " line in stderr.
+func lastError(stderr string) string {
+	var msg string
+	for line := range strings.Lines(stderr) {
+		if rest, ok := strings.CutPrefix(line, "error: "); ok {
+			msg = strings.TrimSpace(rest)
+		}
+	}
+	return msg
+}
+
+// explain turns a failed run into a message for the visitor. Only fetch
+// errors pass on the command's own text, which describes the page (not
+// found, too large, not HTML and so on); the rest are fixed messages.
+func explain(ctx context.Context, res runResult) string {
+	switch {
+	case errors.Is(ctx.Err(), context.DeadlineExceeded):
+		return "That page took too long to convert."
+	case res.Code == exitFetch && strings.Contains(res.Err, "local or private address"):
+		return "That page leads to a local or private network address, which this site doesn't connect to."
+	case res.Code == exitFetch && res.Err != "":
+		msg := strings.TrimPrefix(res.Err, "couldn't fetch the page: ")
+		if r := []rune(msg); len(r) > 300 {
+			msg = string(r[:300]) + "…"
+		}
+		return "Couldn't download the page: " + msg
+	case res.Code == exitFetch:
+		return "Couldn't download the page."
+	case res.Code == exitNoContent:
+		return "No readable article was found on that page. Pages that build their content with JavaScript, paywalls and cookie walls can't be converted."
+	default:
+		return "Something went wrong while converting that page."
+	}
+}

+ 551 - 0
internal/web/server.go

@@ -0,0 +1,551 @@
+// Package web is the server behind the converter form on
+// rekopub.monoexp.com. It checks the address a visitor enters, runs the
+// rekopub command on it and serves the finished books for a limited time.
+//
+// The site is open to everyone, so conversions are limited: in total and
+// per visitor within a time window, by how many run at once, and by how much
+// disk the kept books may use. nginx serves the rest of the site and passes
+// /api/ and /books/ to this server.
+package web
+
+import (
+	"context"
+	"crypto/rand"
+	"embed"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"html/template"
+	"io/fs"
+	"log"
+	"net"
+	"net/http"
+	"net/netip"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+	"unicode"
+
+	"git.monoexp.com/bajdev/rekopub/internal/fetch"
+)
+
+// Config holds the server's settings. New fills in defaults for zero values
+// where noted.
+type Config struct {
+	CLI        string        // path of the rekopub command (required)
+	Dir        string        // books are kept here, one folder per conversion (required)
+	Keep       time.Duration // how long books are kept (24h)
+	Limit      int           // conversions allowed for all visitors together... (50)
+	Window     time.Duration // ...and per visitor, within this period (15m)
+	PerVisitor int           // conversions allowed per visitor (10)
+	MaxRunning int           // conversions running at the same time (2)
+	MaxWaiting int           // conversions waiting for a free slot (10)
+	Wait       time.Duration // how long a conversion may wait for a slot (60s)
+	RunTimeout time.Duration // how long one run of the command may take (2m)
+	MaxStorage int64         // bytes of kept books before conversions pause (2 GB)
+}
+
+// Formats a visitor can choose, and the command's flags for each book.
+var formats = map[string][]struct {
+	name  string
+	kepub bool
+}{
+	"epub":  {{"EPUB", false}},
+	"kepub": {{"KEPUB", true}},
+	"both":  {{"EPUB", false}, {"KEPUB", true}},
+}
+
+const (
+	maxURLLen  = 2000
+	maxBodyLen = 8 << 10
+)
+
+var (
+	idPattern   = regexp.MustCompile(`^[0-9a-f]{32}$`)
+	bookPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*(\.kepub)?\.epub$`)
+)
+
+// Server handles the converter's requests.
+type Server struct {
+	cfg     Config
+	root    *os.Root
+	run     runFunc
+	now     func() time.Time
+	isLocal func(ctx context.Context, host string) bool
+
+	mu      sync.Mutex
+	all     window
+	visitor map[string]*window
+
+	slots   chan struct{}
+	waiting atomic.Int32
+	stored  atomic.Int64 // bytes of kept books
+}
+
+// New returns a server for cfg. It creates cfg.Dir if needed.
+func New(cfg Config) (*Server, error) {
+	if cfg.CLI == "" || cfg.Dir == "" {
+		return nil, errors.New("web: CLI and Dir are required")
+	}
+	def := func(v *time.Duration, d time.Duration) {
+		if *v <= 0 {
+			*v = d
+		}
+	}
+	defInt := func(v *int, d int) {
+		if *v <= 0 {
+			*v = d
+		}
+	}
+	def(&cfg.Keep, 24*time.Hour)
+	def(&cfg.Window, 15*time.Minute)
+	def(&cfg.Wait, time.Minute)
+	def(&cfg.RunTimeout, 2*time.Minute)
+	defInt(&cfg.Limit, 50)
+	defInt(&cfg.PerVisitor, 10)
+	defInt(&cfg.MaxRunning, 2)
+	defInt(&cfg.MaxWaiting, 10)
+	if cfg.MaxStorage <= 0 {
+		cfg.MaxStorage = 2 << 30
+	}
+	// The command prints the absolute path of each book it saves.
+	dir, err := filepath.Abs(cfg.Dir)
+	if err != nil {
+		return nil, err
+	}
+	cfg.Dir = dir
+	if err := os.MkdirAll(cfg.Dir, 0o750); err != nil {
+		return nil, err
+	}
+	root, err := os.OpenRoot(cfg.Dir)
+	if err != nil {
+		return nil, err
+	}
+	return &Server{
+		cfg:     cfg,
+		root:    root,
+		run:     execRunner(cfg.CLI),
+		now:     time.Now,
+		isLocal: fetch.IsLocal,
+		visitor: map[string]*window{},
+		slots:   make(chan struct{}, cfg.MaxRunning),
+	}, nil
+}
+
+// Handler returns the server's routes. Cross-origin form posts are refused,
+// so other sites can't use the converter through their visitors' browsers.
+func (s *Server) Handler() http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /api/status", s.status)
+	mux.HandleFunc("POST /api/convert", s.convert)
+	mux.HandleFunc("GET /books/{id}/{name}", s.book)
+	return http.NewCrossOriginProtection().Handler(headers(mux))
+}
+
+// headers sets security headers on every response.
+func headers(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		h := w.Header()
+		h.Set("X-Content-Type-Options", "nosniff")
+		h.Set("Referrer-Policy", "no-referrer")
+		h.Set("X-Robots-Tag", "noindex")
+		h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
+		next.ServeHTTP(w, r)
+	})
+}
+
+// file is a finished book in a reply.
+type file struct {
+	Format string `json:"format"`
+	Name   string `json:"name"`
+	URL    string `json:"url"`
+	Size   int64  `json:"size"`
+}
+
+// reply is the answer to a status or convert request.
+type reply struct {
+	Title     string `json:"-"` // heading of the HTML page, if not the default
+	Paused    bool   `json:"paused,omitempty"`
+	Error     string `json:"error,omitempty"`
+	Warning   string `json:"warning,omitempty"`
+	Files     []file `json:"files,omitempty"`
+	KeepHours int    `json:"keepHours"`
+}
+
+func (s *Server) keepHours() int { return int(s.cfg.Keep.Hours()) }
+
+// status tells the page whether the form can be used right now.
+func (s *Server) status(w http.ResponseWriter, r *http.Request) {
+	rep := reply{KeepHours: s.keepHours()}
+	if msg := s.pausedFor(visitorKey(r)); msg != "" {
+		rep.Paused, rep.Error = true, msg
+	}
+	w.Header().Set("Cache-Control", "no-store")
+	writeJSON(w, http.StatusOK, rep)
+}
+
+// convert checks the form, waits for a free slot and runs the command once
+// per chosen format.
+func (s *Server) convert(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxBodyLen)
+	if err := r.ParseForm(); err != nil {
+		s.respond(w, r, http.StatusBadRequest, reply{Error: "The form couldn't be read."})
+		return
+	}
+	format := r.PostForm.Get("format")
+	if format == "" {
+		format = "epub"
+	}
+	books, ok := formats[format]
+	if !ok {
+		s.respond(w, r, http.StatusBadRequest, reply{Error: "Choose EPUB, KEPUB or both."})
+		return
+	}
+	u, msg := checkURL(r.PostForm.Get("url"))
+	if msg != "" {
+		s.respond(w, r, http.StatusBadRequest, reply{Error: msg})
+		return
+	}
+	// The command refuses local addresses anyway (-NOLOCAL); checking here
+	// too gives a clear message and doesn't use up a conversion.
+	if s.isLocal(r.Context(), u.Hostname()) {
+		s.respond(w, r, http.StatusBadRequest, reply{Error: "That address is on a local or private network, which this site doesn't connect to."})
+		return
+	}
+
+	key := visitorKey(r)
+	undo, msg := s.reserve(key)
+	if msg != "" {
+		log.Printf("limited %s: %s", key, msg)
+		s.respond(w, r, http.StatusTooManyRequests, reply{Paused: true, Error: msg})
+		return
+	}
+	release, msg := s.acquire(r.Context())
+	if msg != "" {
+		undo()
+		s.respond(w, r, http.StatusServiceUnavailable, reply{Error: msg})
+		return
+	}
+	defer release()
+
+	start := s.now()
+	rep, code := s.convertAll(r.Context(), u, books)
+	if rep.Error != "" {
+		log.Printf("convert %s %s: failed in %s: %s", u.Hostname(), format, s.now().Sub(start).Round(time.Millisecond), rep.Error)
+	} else {
+		log.Printf("convert %s %s: ok in %s", u.Hostname(), format, s.now().Sub(start).Round(time.Millisecond))
+	}
+	s.respond(w, r, code, rep)
+}
+
+// convertAll converts u into each of books, in a new folder.
+func (s *Server) convertAll(ctx context.Context, u *url.URL, books []struct {
+	name  string
+	kepub bool
+}) (reply, int) {
+	rep := reply{KeepHours: s.keepHours()}
+	id := newID()
+	dir := filepath.Join(s.cfg.Dir, id)
+	if err := s.root.Mkdir(id, 0o750); err != nil {
+		log.Printf("creating %s: %v", dir, err)
+		rep.Error = "Something went wrong while converting that page."
+		return rep, http.StatusInternalServerError
+	}
+	for _, b := range books {
+		f, msg := s.runOnce(ctx, u, dir, b.kepub)
+		if msg != "" {
+			if len(rep.Files) > 0 {
+				// The first book is fine; offer it and say what's missing.
+				rep.Warning = fmt.Sprintf("The %s couldn't be made: %s", b.name, msg)
+				break
+			}
+			s.root.RemoveAll(id)
+			rep.Error = msg
+			return rep, http.StatusUnprocessableEntity
+		}
+		f.Format = b.name
+		f.URL = "/books/" + id + "/" + f.Name
+		rep.Files = append(rep.Files, f)
+		s.stored.Add(f.Size)
+	}
+	return rep, http.StatusOK
+}
+
+// runOnce runs the command for one book and checks what it wrote.
+func (s *Server) runOnce(ctx context.Context, u *url.URL, dir string, kepub bool) (file, string) {
+	ctx, cancel := context.WithTimeout(ctx, s.cfg.RunTimeout)
+	defer cancel()
+	// "-FLAG=value" keeps a value from ever being read as a flag.
+	args := []string{"-NOLOCAL", "-URL=" + u.String(), "-SAVE=" + dir + string(filepath.Separator)}
+	if kepub {
+		args = append(args, "-KEPUB")
+	}
+	res := s.run(ctx, args)
+	if res.Code != 0 {
+		return file{}, explain(ctx, res)
+	}
+	name := filepath.Base(res.Path)
+	if filepath.Dir(res.Path) != dir || !bookPattern.MatchString(name) {
+		log.Printf("unexpected output path %q", res.Path)
+		return file{}, "Something went wrong while converting that page."
+	}
+	info, err := os.Stat(res.Path)
+	if err != nil {
+		log.Printf("checking output: %v", err)
+		return file{}, "Something went wrong while converting that page."
+	}
+	return file{Name: name, Size: info.Size()}, ""
+}
+
+// reserve counts a conversion against the limits, or says why it can't
+// start. undo takes the conversion back if it never runs.
+func (s *Server) reserve(key string) (undo func(), msg string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	now := s.now()
+	if msg := s.pausedLocked(key, now); msg != "" {
+		return nil, msg
+	}
+	v := s.visitor[key]
+	if v == nil {
+		v = &window{}
+		s.visitor[key] = v
+	}
+	s.all = append(s.all, now)
+	*v = append(*v, now)
+	return func() {
+		s.mu.Lock()
+		defer s.mu.Unlock()
+		s.all.remove(now)
+		v.remove(now)
+	}, ""
+}
+
+// pausedFor returns why a visitor can't convert right now, or "".
+func (s *Server) pausedFor(key string) string {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.pausedLocked(key, s.now())
+}
+
+func (s *Server) pausedLocked(key string, now time.Time) string {
+	if s.stored.Load() >= s.cfg.MaxStorage {
+		return "Conversions are temporarily paused. Please try again in an hour."
+	}
+	s.all.prune(now, s.cfg.Window)
+	if full, until := s.all.full(now, s.cfg.Limit, s.cfg.Window); full {
+		return "Conversions are temporarily paused. Please try again in " + minutes(until.Sub(now)) + "."
+	}
+	if v := s.visitor[key]; v != nil {
+		v.prune(now, s.cfg.Window)
+		if full, until := v.full(now, s.cfg.PerVisitor, s.cfg.Window); full {
+			return fmt.Sprintf("You've made %d conversions in the last %s. Please try again in %s.",
+				s.cfg.PerVisitor, minutes(s.cfg.Window), minutes(until.Sub(now)))
+		}
+	}
+	return ""
+}
+
+// acquire waits for a free slot. It gives up when too many conversions are
+// already waiting, after cfg.Wait, or when the visitor leaves.
+func (s *Server) acquire(ctx context.Context) (release func(), msg string) {
+	const busy = "The converter is busy. Please try again in a minute."
+	if s.waiting.Add(1) > int32(s.cfg.MaxWaiting) {
+		s.waiting.Add(-1)
+		return nil, busy
+	}
+	defer s.waiting.Add(-1)
+	timer := time.NewTimer(s.cfg.Wait)
+	defer timer.Stop()
+	select {
+	case s.slots <- struct{}{}:
+		return func() { <-s.slots }, ""
+	case <-timer.C:
+		return nil, busy
+	case <-ctx.Done():
+		return nil, busy
+	}
+}
+
+// book serves a finished book as a download.
+func (s *Server) book(w http.ResponseWriter, r *http.Request) {
+	id, name := r.PathValue("id"), r.PathValue("name")
+	gone := func() {
+		s.respond(w, r, http.StatusNotFound, reply{Title: "Link expired", Error: fmt.Sprintf(
+			"This download link has expired or doesn't exist. Links work for %d hours after a conversion.", s.keepHours())})
+	}
+	if !idPattern.MatchString(id) || !bookPattern.MatchString(name) {
+		gone()
+		return
+	}
+	// os.Root keeps the open inside cfg.Dir, whatever the path holds.
+	f, err := s.root.Open(id + "/" + name)
+	if err != nil {
+		gone()
+		return
+	}
+	defer f.Close()
+	info, err := f.Stat()
+	if err != nil || !info.Mode().IsRegular() || s.now().Sub(info.ModTime()) >= s.cfg.Keep {
+		gone()
+		return
+	}
+	h := w.Header()
+	h.Set("Content-Type", "application/epub+zip")
+	h.Set("Content-Disposition", `attachment; filename="`+name+`"`)
+	h.Set("Cache-Control", "private, max-age=3600")
+	http.ServeContent(w, r, name, info.ModTime(), f)
+}
+
+// Clean deletes books older than cfg.Keep, adds up what's left and forgets
+// visitors with no recent conversions. Run it at start-up and then
+// regularly.
+func (s *Server) Clean() {
+	now := s.now()
+	entries, err := fs.ReadDir(s.root.FS(), ".")
+	if err != nil {
+		log.Printf("clean: %v", err)
+		return
+	}
+	var total int64
+	removed := 0
+	for _, e := range entries {
+		info, err := e.Info()
+		if err != nil || !e.IsDir() || !idPattern.MatchString(e.Name()) {
+			continue
+		}
+		if now.Sub(info.ModTime()) >= s.cfg.Keep {
+			if err := s.root.RemoveAll(e.Name()); err != nil {
+				log.Printf("clean: %v", err)
+			} else {
+				removed++
+			}
+			continue
+		}
+		fs.WalkDir(s.root.FS(), e.Name(), func(_ string, d fs.DirEntry, err error) error {
+			if err == nil && d.Type().IsRegular() {
+				if fi, err := d.Info(); err == nil {
+					total += fi.Size()
+				}
+			}
+			return nil
+		})
+	}
+	s.stored.Store(total)
+	if removed > 0 {
+		log.Printf("clean: removed %d expired conversions; %d MB kept", removed, total>>20)
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	for key, v := range s.visitor {
+		if v.prune(now, s.cfg.Window); len(*v) == 0 {
+			delete(s.visitor, key)
+		}
+	}
+}
+
+// CleanEvery runs Clean every interval until ctx ends.
+func (s *Server) CleanEvery(ctx context.Context, interval time.Duration) {
+	s.Clean()
+	t := time.NewTicker(interval)
+	defer t.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-t.C:
+			s.Clean()
+		}
+	}
+}
+
+// checkURL checks a visitor's address, returning it or a message saying
+// what's wrong.
+func checkURL(raw string) (*url.URL, string) {
+	raw = strings.TrimSpace(raw)
+	switch {
+	case raw == "":
+		return nil, "Enter the address of a web page."
+	case len(raw) > maxURLLen:
+		return nil, "That address is too long."
+	case strings.ContainsFunc(raw, unicode.IsControl):
+		return nil, "That isn't a valid web address."
+	}
+	u, err := url.Parse(raw)
+	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Opaque != "" {
+		return nil, "Enter a full web address starting with http:// or https://."
+	}
+	if u.User != nil {
+		return nil, "Addresses with a user name or password can't be converted."
+	}
+	// Other ports would let visitors use the server to probe other
+	// people's machines.
+	if p := u.Port(); p != "" && p != "80" && p != "443" {
+		return nil, "Only addresses on the standard web ports can be converted."
+	}
+	u.Fragment, u.RawFragment = "", ""
+	return u, ""
+}
+
+// visitorKey identifies a visitor for the per-visitor limit: their IPv4
+// address, or the /64 network of an IPv6 address, since one IPv6
+// connection usually has a whole /64 to choose from. The server listens on
+// 127.0.0.1 behind nginx, which sets X-Real-IP; any value the visitor sent
+// is replaced.
+func visitorKey(r *http.Request) string {
+	raw := r.Header.Get("X-Real-IP")
+	if raw == "" {
+		raw, _, _ = net.SplitHostPort(r.RemoteAddr)
+	}
+	ip, err := netip.ParseAddr(raw)
+	if err != nil {
+		return raw
+	}
+	ip = ip.Unmap()
+	if ip.Is6() {
+		p, _ := ip.Prefix(64)
+		return p.String()
+	}
+	return ip.String()
+}
+
+func newID() string {
+	b := make([]byte, 16)
+	rand.Read(b)
+	return hex.EncodeToString(b)
+}
+
+// respond answers in JSON for the page's script and as a page for a
+// browser without JavaScript.
+func (s *Server) respond(w http.ResponseWriter, r *http.Request, code int, rep reply) {
+	rep.KeepHours = s.keepHours()
+	if strings.Contains(r.Header.Get("Accept"), "application/json") {
+		writeJSON(w, code, rep)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.WriteHeader(code)
+	if err := pages.ExecuteTemplate(w, "result.html", rep); err != nil {
+		log.Printf("result page: %v", err)
+	}
+}
+
+func writeJSON(w http.ResponseWriter, code int, v any) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(code)
+	json.NewEncoder(w).Encode(v)
+}
+
+//go:embed templates
+var templateFS embed.FS
+
+var pages = template.Must(template.New("").Funcs(template.FuncMap{
+	"kb": func(n int64) string { return fmt.Sprintf("%d KB", (n+1023)/1024) },
+}).ParseFS(templateFS, "templates/*.html"))

+ 542 - 0
internal/web/server_test.go

@@ -0,0 +1,542 @@
+package web
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path"
+	"path/filepath"
+	"runtime"
+	"slices"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+)
+
+// fakeCLI stands in for the rekopub command. It writes a book named after
+// the page's last path segment into the -SAVE folder, as the command does.
+type fakeCLI struct {
+	mu    sync.Mutex
+	calls [][]string
+	fail  runResult     // returned instead of a book when Code != 0
+	block chan struct{} // if set, runs wait for it to close
+}
+
+func (f *fakeCLI) run(ctx context.Context, args []string) runResult {
+	f.mu.Lock()
+	f.calls = append(f.calls, args)
+	f.mu.Unlock()
+	if f.block != nil {
+		select {
+		case <-f.block:
+		case <-ctx.Done():
+			return runResult{Code: -1}
+		}
+	}
+	if f.fail.Code != 0 {
+		return f.fail
+	}
+	var page, dir string
+	kepub := false
+	for _, a := range args {
+		switch {
+		case strings.HasPrefix(a, "-URL="):
+			u, _ := url.Parse(strings.TrimPrefix(a, "-URL="))
+			page = filepath.Base(u.Path)
+		case strings.HasPrefix(a, "-SAVE="):
+			dir = strings.TrimPrefix(a, "-SAVE=")
+		case a == "-KEPUB":
+			kepub = true
+		}
+	}
+	path := filepath.Join(dir, page+".epub")
+	if kepub {
+		path = filepath.Join(dir, page+".kepub.epub")
+	}
+	if err := os.WriteFile(path, []byte("book:"+page), 0o640); err != nil {
+		return runResult{Code: 4, Err: err.Error()}
+	}
+	abs, _ := filepath.Abs(path) // the command prints absolute paths
+	return runResult{Path: abs}
+}
+
+func (f *fakeCLI) count() int {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return len(f.calls)
+}
+
+type testServer struct {
+	*Server
+	cli  *fakeCLI
+	now  time.Time
+	http http.Handler
+}
+
+func newTestServer(t *testing.T, cfg Config) *testServer {
+	t.Helper()
+	cfg.CLI = "rekopub"
+	// Relative, as on the server (DATA_DIR=data).
+	t.Chdir(t.TempDir())
+	cfg.Dir = filepath.Join("data", "books")
+	s, err := New(cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ts := &testServer{Server: s, cli: &fakeCLI{}, now: time.Now()}
+	s.run = ts.cli.run
+	s.now = func() time.Time { return ts.now }
+	s.isLocal = func(_ context.Context, host string) bool { return host == "localhost" || host == "10.0.0.1" }
+	ts.http = s.Handler()
+	return ts
+}
+
+type response struct {
+	code int
+	body string
+	rep  reply
+	hdr  http.Header
+}
+
+func (ts *testServer) do(t *testing.T, req *http.Request) response {
+	t.Helper()
+	w := httptest.NewRecorder()
+	ts.http.ServeHTTP(w, req)
+	res := response{code: w.Code, body: w.Body.String(), hdr: w.Header()}
+	if strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
+		if err := json.Unmarshal(w.Body.Bytes(), &res.rep); err != nil {
+			t.Fatalf("bad JSON %q: %v", res.body, err)
+		}
+	}
+	return res
+}
+
+// post submits the form as the page's script does, from visitor ip.
+func (ts *testServer) post(t *testing.T, ip, page, format string) response {
+	t.Helper()
+	form := url.Values{"url": {page}}
+	if format != "" {
+		form.Set("format", format)
+	}
+	req := httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("X-Real-IP", ip)
+	return ts.do(t, req)
+}
+
+func (ts *testServer) get(t *testing.T, ip, path string) response {
+	t.Helper()
+	req := httptest.NewRequest("GET", path, nil)
+	req.Header.Set("X-Real-IP", ip)
+	return ts.do(t, req)
+}
+
+func TestConvertEPUB(t *testing.T) {
+	ts := newTestServer(t, Config{})
+	res := ts.post(t, "203.0.113.5", "https://example.com/posts/hello#top", "")
+	if res.code != http.StatusOK || len(res.rep.Files) != 1 {
+		t.Fatalf("code = %d, body = %s", res.code, res.body)
+	}
+	if res.rep.KeepHours != 24 {
+		t.Errorf("keepHours = %d, want 24", res.rep.KeepHours)
+	}
+	f := res.rep.Files[0]
+	if f.Format != "EPUB" || f.Name != "hello.epub" || f.Size != int64(len("book:hello")) {
+		t.Errorf("file = %+v", f)
+	}
+	args := ts.cli.calls[0]
+	if args[0] != "-NOLOCAL" || args[1] != "-URL=https://example.com/posts/hello" ||
+		!strings.HasPrefix(args[2], "-SAVE="+ts.cfg.Dir+string(filepath.Separator)) || !strings.HasSuffix(args[2], string(filepath.Separator)) ||
+		slices.Contains(args, "-KEPUB") {
+		t.Errorf("command args = %q", args)
+	}
+
+	dl := ts.get(t, "198.51.100.7", f.URL)
+	if dl.code != http.StatusOK || dl.body != "book:hello" {
+		t.Fatalf("download: code = %d, body = %q", dl.code, dl.body)
+	}
+	if got := dl.hdr.Get("Content-Disposition"); got != `attachment; filename="hello.epub"` {
+		t.Errorf("Content-Disposition = %q", got)
+	}
+	if got := dl.hdr.Get("Content-Type"); got != "application/epub+zip" {
+		t.Errorf("Content-Type = %q", got)
+	}
+	if got := dl.hdr.Get("X-Content-Type-Options"); got != "nosniff" {
+		t.Errorf("X-Content-Type-Options = %q", got)
+	}
+}
+
+func TestConvertBoth(t *testing.T) {
+	ts := newTestServer(t, Config{})
+	res := ts.post(t, "203.0.113.5", "https://example.com/a", "both")
+	if res.code != http.StatusOK || len(res.rep.Files) != 2 {
+		t.Fatalf("code = %d, body = %s", res.code, res.body)
+	}
+	if res.rep.Files[0].Name != "a.epub" || res.rep.Files[1].Name != "a.kepub.epub" || res.rep.Files[1].Format != "KEPUB" {
+		t.Errorf("files = %+v", res.rep.Files)
+	}
+	if path.Dir(res.rep.Files[0].URL) != path.Dir(res.rep.Files[1].URL) {
+		t.Errorf("books of one conversion are in different folders: %+v", res.rep.Files)
+	}
+	if !slices.Contains(ts.cli.calls[1], "-KEPUB") {
+		t.Errorf("second run args = %q, want -KEPUB", ts.cli.calls[1])
+	}
+	// Both books count as one conversion.
+	ts.Server.mu.Lock()
+	n := len(ts.all)
+	ts.Server.mu.Unlock()
+	if n != 1 {
+		t.Errorf("counted %d conversions, want 1", n)
+	}
+}
+
+func TestConvertKEPUB(t *testing.T) {
+	ts := newTestServer(t, Config{})
+	res := ts.post(t, "203.0.113.5", "https://example.com/a", "kepub")
+	if res.code != http.StatusOK || len(res.rep.Files) != 1 || res.rep.Files[0].Name != "a.kepub.epub" {
+		t.Fatalf("code = %d, body = %s", res.code, res.body)
+	}
+}
+
+func TestBadRequests(t *testing.T) {
+	tests := []struct {
+		name, page, format, want string
+	}{
+		{"empty", "", "", "Enter the address"},
+		{"no scheme", "example.com/a", "", "starting with http"},
+		{"ftp", "ftp://example.com/a", "", "starting with http"},
+		{"javascript", "javascript:alert(1)", "", "starting with http"},
+		{"file", "file:///etc/passwd", "", "starting with http"},
+		{"no host", "https:///a", "", "starting with http"},
+		{"credentials", "https://user:pw@example.com/a", "", "user name or password"},
+		{"port", "https://example.com:3000/a", "", "standard web ports"},
+		{"control", "https://example.com/a\nb", "", "valid web address"},
+		{"too long", "https://example.com/" + strings.Repeat("a", maxURLLen), "", "too long"},
+		{"localhost", "http://localhost/a", "", "local or private"},
+		{"private", "http://10.0.0.1/a", "", "local or private"},
+		{"format", "https://example.com/a", "pdf", "Choose EPUB"},
+	}
+	ts := newTestServer(t, Config{Limit: 3})
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			res := ts.post(t, "203.0.113.5", tt.page, tt.format)
+			if res.code != http.StatusBadRequest || !strings.Contains(res.rep.Error, tt.want) {
+				t.Errorf("code = %d, error = %q; want 400 and %q", res.code, res.rep.Error, tt.want)
+			}
+		})
+	}
+	if n := ts.cli.count(); n != 0 {
+		t.Errorf("command ran %d times, want 0", n)
+	}
+	// Rejected requests don't use up conversions.
+	if res := ts.get(t, "203.0.113.5", "/api/status"); res.rep.Paused {
+		t.Errorf("paused after rejected requests: %+v", res.rep)
+	}
+}
+
+func TestOversizedForm(t *testing.T) {
+	ts := newTestServer(t, Config{})
+	res := ts.post(t, "203.0.113.5", "https://example.com/"+strings.Repeat("a", maxBodyLen), "")
+	if res.code != http.StatusBadRequest {
+		t.Errorf("code = %d, want 400", res.code)
+	}
+}
+
+func TestLimitForEveryone(t *testing.T) {
+	ts := newTestServer(t, Config{Limit: 3, PerVisitor: 10, Window: 15 * time.Minute})
+	for i, ip := range []string{"203.0.113.1", "203.0.113.2", "203.0.113.3"} {
+		if res := ts.post(t, ip, "https://example.com/a", ""); res.code != http.StatusOK {
+			t.Fatalf("conversion %d: code = %d, body = %s", i+1, res.code, res.body)
+		}
+		ts.now = ts.now.Add(time.Minute)
+	}
+	res := ts.post(t, "203.0.113.4", "https://example.com/a", "")
+	want := "Conversions are temporarily paused. Please try again in 12 minutes."
+	if res.code != http.StatusTooManyRequests || !res.rep.Paused || res.rep.Error != want {
+		t.Errorf("code = %d, reply = %+v; want 429 and %q", res.code, res.rep, want)
+	}
+	if st := ts.get(t, "203.0.113.9", "/api/status"); !st.rep.Paused || st.rep.Error != want {
+		t.Errorf("status = %+v, want paused", st.rep)
+	}
+	if n := ts.cli.count(); n != 3 {
+		t.Errorf("command ran %d times, want 3", n)
+	}
+
+	// The first conversion stops counting 15 minutes after it started.
+	ts.now = ts.now.Add(12 * time.Minute)
+	if st := ts.get(t, "203.0.113.9", "/api/status"); st.rep.Paused {
+		t.Errorf("still paused after the window: %+v", st.rep)
+	}
+	if res := ts.post(t, "203.0.113.4", "https://example.com/a", ""); res.code != http.StatusOK {
+		t.Errorf("after the window: code = %d, body = %s", res.code, res.body)
+	}
+}
+
+func TestLimitPerVisitor(t *testing.T) {
+	ts := newTestServer(t, Config{Limit: 50, PerVisitor: 2})
+	for range 2 {
+		if res := ts.post(t, "2001:db8:1:2::1", "https://example.com/a", ""); res.code != http.StatusOK {
+			t.Fatalf("code = %d, body = %s", res.code, res.body)
+		}
+	}
+	// Another address in the same IPv6 /64 is the same visitor.
+	res := ts.post(t, "2001:db8:1:2::ffff", "https://example.com/a", "")
+	if res.code != http.StatusTooManyRequests || !strings.Contains(res.rep.Error, "You've made 2 conversions in the last 15 minutes") {
+		t.Errorf("code = %d, reply = %+v", res.code, res.rep)
+	}
+	if st := ts.get(t, "2001:db8:1:2::1", "/api/status"); !st.rep.Paused {
+		t.Errorf("status for the visitor = %+v, want paused", st.rep)
+	}
+	// Everyone else can still convert.
+	if st := ts.get(t, "203.0.113.1", "/api/status"); st.rep.Paused {
+		t.Errorf("status for others = %+v, want not paused", st.rep)
+	}
+	if res := ts.post(t, "2001:db8:1:3::1", "https://example.com/a", ""); res.code != http.StatusOK {
+		t.Errorf("other visitor: code = %d, body = %s", res.code, res.body)
+	}
+}
+
+func TestStorageLimit(t *testing.T) {
+	ts := newTestServer(t, Config{MaxStorage: 10})
+	if res := ts.post(t, "203.0.113.1", "https://example.com/abcdefgh", ""); res.code != http.StatusOK {
+		t.Fatalf("code = %d, body = %s", res.code, res.body)
+	}
+	if res := ts.post(t, "203.0.113.2", "https://example.com/a", ""); res.code != http.StatusTooManyRequests || !res.rep.Paused {
+		t.Errorf("over storage: code = %d, reply = %+v", res.code, res.rep)
+	}
+	// Once the book expires, conversions resume.
+	ts.now = ts.now.Add(25 * time.Hour)
+	ageAll(t, ts.cfg.Dir, 25*time.Hour)
+	ts.Clean()
+	if res := ts.post(t, "203.0.113.2", "https://example.com/a", ""); res.code != http.StatusOK {
+		t.Errorf("after clean: code = %d, body = %s", res.code, res.body)
+	}
+}
+
+func TestBusy(t *testing.T) {
+	ts := newTestServer(t, Config{MaxRunning: 1, MaxWaiting: 1, Wait: 50 * time.Millisecond})
+	ts.cli.block = make(chan struct{})
+	first := make(chan response)
+	go func() { first <- ts.post(t, "203.0.113.1", "https://example.com/a", "") }()
+	for ts.cli.count() == 0 {
+		time.Sleep(time.Millisecond)
+	}
+	res := ts.post(t, "203.0.113.2", "https://example.com/b", "")
+	if res.code != http.StatusServiceUnavailable || !strings.Contains(res.rep.Error, "busy") {
+		t.Errorf("code = %d, reply = %+v; want 503 busy", res.code, res.rep)
+	}
+	close(ts.cli.block)
+	if res := <-first; res.code != http.StatusOK {
+		t.Errorf("first: code = %d, body = %s", res.code, res.body)
+	}
+	// The conversion that never ran was taken back.
+	ts.Server.mu.Lock()
+	n := len(*ts.visitor["203.0.113.2"])
+	ts.Server.mu.Unlock()
+	if n != 0 {
+		t.Errorf("busy visitor has %d conversions counted, want 0", n)
+	}
+}
+
+func TestFailedConversion(t *testing.T) {
+	ts := newTestServer(t, Config{})
+	ts.cli.fail = runResult{Code: exitNoContent, Err: "no readable content found"}
+	res := ts.post(t, "203.0.113.1", "https://example.com/a", "both")
+	if res.code != http.StatusUnprocessableEntity || !strings.Contains(res.rep.Error, "No readable article") {
+		t.Errorf("code = %d, reply = %+v", res.code, res.rep)
+	}
+	if n := ts.cli.count(); n != 1 {
+		t.Errorf("command ran %d times, want 1 (no KEPUB after the EPUB failed)", n)
+	}
+	if entries, _ := os.ReadDir(ts.cfg.Dir); len(entries) != 0 {
+		t.Errorf("folder left behind: %v", entries)
+	}
+}
+
+func TestUnexpectedOutputPath(t *testing.T) {
+	ts := newTestServer(t, Config{})
+	ts.Server.run = func(context.Context, []string) runResult { return runResult{Path: "/etc/passwd"} }
+	res := ts.post(t, "203.0.113.1", "https://example.com/a", "")
+	if res.code != http.StatusUnprocessableEntity || res.rep.Files != nil {
+		t.Errorf("code = %d, reply = %+v", res.code, res.rep)
+	}
+}
+
+func TestExplain(t *testing.T) {
+	ctx := context.Background()
+	tests := []struct {
+		res  runResult
+		want string
+	}{
+		{runResult{Code: 1, Err: "couldn't fetch the page: example.com returned 404 Not Found"}, "Couldn't download the page: example.com returned 404 Not Found"},
+		{runResult{Code: 1, Err: "couldn't fetch the page: example.com is a local or private address (10.0.0.1); …"}, "local or private network address"},
+		{runResult{Code: 3}, "No readable article"},
+		{runResult{Code: 4, Err: "saving: /opt/apps/x: permission denied"}, "Something went wrong"},
+		{runResult{Code: -1}, "Something went wrong"},
+	}
+	for _, tt := range tests {
+		if got := explain(ctx, tt.res); !strings.Contains(got, tt.want) {
+			t.Errorf("explain(%+v) = %q, want %q", tt.res, got, tt.want)
+		}
+	}
+	expired, cancel := context.WithDeadline(ctx, time.Now().Add(-time.Second))
+	defer cancel()
+	if got := explain(expired, runResult{Code: -1}); !strings.Contains(got, "too long") {
+		t.Errorf("timeout: %q", got)
+	}
+}
+
+func TestBookLinks(t *testing.T) {
+	ts := newTestServer(t, Config{})
+	res := ts.post(t, "203.0.113.1", "https://example.com/a", "")
+	link := res.rep.Files[0].URL
+	if err := os.WriteFile(filepath.Join(filepath.Dir(ts.cfg.Dir), "secret.epub"), []byte("TOP-SECRET-CONTENT"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	for _, p := range []string{
+		"/books/0123456789abcdef0123456789abcdef/a.epub", // unknown id
+		strings.TrimSuffix(link, "a.epub") + "b.epub",    // unknown name
+		strings.TrimSuffix(link, ".epub") + ".html",      // not a book name
+		"/books/../secret.epub",
+		"/books/%2e%2e/secret.epub",
+		"/books/x/..%2fsecret.epub",
+	} {
+		if res := ts.get(t, "203.0.113.1", p); res.code == http.StatusOK || strings.Contains(res.body, "TOP-SECRET") {
+			t.Errorf("GET %s: code = %d, body = %q", p, res.code, res.body)
+		}
+	}
+	ts.now = ts.now.Add(25 * time.Hour)
+	if res := ts.get(t, "203.0.113.1", link); res.code != http.StatusNotFound || !strings.Contains(res.body, "<h1>Link expired</h1>") {
+		t.Errorf("expired link: code = %d, body = %q", res.code, res.body)
+	}
+}
+
+func TestClean(t *testing.T) {
+	ts := newTestServer(t, Config{Keep: 2 * time.Hour})
+	old := ts.post(t, "203.0.113.1", "https://example.com/old", "").rep.Files[0]
+	ageAll(t, ts.cfg.Dir, 3*time.Hour)
+	fresh := ts.post(t, "203.0.113.2", "https://example.com/fresh", "").rep.Files[0]
+	stray := filepath.Join(ts.cfg.Dir, "not-a-conversion")
+	if err := os.Mkdir(stray, 0o750); err != nil {
+		t.Fatal(err)
+	}
+	ts.Clean()
+	if _, err := os.Stat(filepath.Join(ts.cfg.Dir, strings.Split(old.URL, "/")[2])); !os.IsNotExist(err) {
+		t.Errorf("old conversion kept: %v", err)
+	}
+	if res := ts.get(t, "203.0.113.1", fresh.URL); res.code != http.StatusOK {
+		t.Errorf("fresh book: code = %d", res.code)
+	}
+	if _, err := os.Stat(stray); err != nil {
+		t.Errorf("unrelated folder removed: %v", err)
+	}
+	if got := ts.stored.Load(); got != fresh.Size {
+		t.Errorf("stored = %d, want %d", got, fresh.Size)
+	}
+	if res := ts.post(t, "203.0.113.1", "https://example.com/x", ""); res.rep.KeepHours != 2 {
+		t.Errorf("keepHours = %d, want 2", res.rep.KeepHours)
+	}
+}
+
+func TestHTMLReply(t *testing.T) {
+	ts := newTestServer(t, Config{})
+	form := url.Values{"url": {"example.com"}}
+	req := httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	res := ts.do(t, req)
+	if res.code != http.StatusBadRequest || !strings.Contains(res.hdr.Get("Content-Type"), "text/html") {
+		t.Fatalf("code = %d, Content-Type = %q", res.code, res.hdr.Get("Content-Type"))
+	}
+
+	ts.cli.fail = runResult{Code: 1, Err: `couldn't fetch the page: <script>alert(1)</script>`}
+	form = url.Values{"url": {"https://example.com/a"}}
+	req = httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	res = ts.do(t, req)
+	if strings.Contains(res.body, "<script>") || !strings.Contains(res.body, "&lt;script&gt;") {
+		t.Errorf("error not escaped:\n%s", res.body)
+	}
+
+	ts.cli.fail = runResult{}
+	req = httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	res = ts.do(t, req)
+	if res.code != http.StatusOK || !strings.Contains(res.body, "Links work for 24 hours") || !strings.Contains(res.body, `href="/books/`) {
+		t.Errorf("code = %d, body:\n%s", res.code, res.body)
+	}
+}
+
+func TestCrossOriginRefused(t *testing.T) {
+	ts := newTestServer(t, Config{})
+	form := url.Values{"url": {"https://example.com/a"}}
+	req := httptest.NewRequest("POST", "/api/convert", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Sec-Fetch-Site", "cross-site")
+	if res := ts.do(t, req); res.code != http.StatusForbidden {
+		t.Errorf("code = %d, want 403", res.code)
+	}
+	if n := ts.cli.count(); n != 0 {
+		t.Errorf("command ran %d times, want 0", n)
+	}
+}
+
+func TestVisitorKey(t *testing.T) {
+	tests := []struct{ realIP, remote, want string }{
+		{"203.0.113.5", "127.0.0.1:5000", "203.0.113.5"},
+		{"", "203.0.113.6:5000", "203.0.113.6"},
+		{"::ffff:203.0.113.7", "127.0.0.1:5000", "203.0.113.7"},
+		{"2001:db8:1:2:3:4:5:6", "127.0.0.1:5000", "2001:db8:1:2::/64"},
+	}
+	for _, tt := range tests {
+		r := httptest.NewRequest("GET", "/", nil)
+		r.RemoteAddr = tt.remote
+		if tt.realIP != "" {
+			r.Header.Set("X-Real-IP", tt.realIP)
+		}
+		if got := visitorKey(r); got != tt.want {
+			t.Errorf("visitorKey(%q, %q) = %q, want %q", tt.realIP, tt.remote, got, tt.want)
+		}
+	}
+}
+
+// TestExecRunner runs a stand-in script through the real runner.
+func TestExecRunner(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("uses a shell script")
+	}
+	t.Setenv("REKOPUB_TEST_SECRET", "x")
+	dir := t.TempDir()
+	script := filepath.Join(dir, "rekopub")
+	body := "#!/bin/sh\n" +
+		`[ -z "$REKOPUB_TEST_SECRET$HOME" ] || { echo "error: environment not empty" >&2; exit 4; }` + "\n" +
+		`case "$2" in *fail*) echo "warning: one image" >&2; echo "error: couldn't fetch the page: example.com returned 404 Not Found" >&2; exit 1;; esac` + "\n" +
+		`echo "$3" | cut -c7-` + "\n"
+	if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	run := execRunner(script)
+	if res := run(context.Background(), []string{"-NOLOCAL", "-URL=https://example.com/ok", "-SAVE=/x/a.epub"}); res.Code != 0 || res.Path != "/x/a.epub" {
+		t.Errorf("ok run = %+v", res)
+	}
+	res := run(context.Background(), []string{"-NOLOCAL", "-URL=https://example.com/fail", "-SAVE=/x/"})
+	if res.Code != 1 || res.Err != "couldn't fetch the page: example.com returned 404 Not Found" {
+		t.Errorf("failed run = %+v", res)
+	}
+}
+
+// ageAll makes every file and folder under dir look d older.
+func ageAll(t *testing.T, dir string, d time.Duration) {
+	t.Helper()
+	filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		old := info.ModTime().Add(-d)
+		return os.Chtimes(p, old, old)
+	})
+}

+ 40 - 0
internal/web/templates/result.html

@@ -0,0 +1,40 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="robots" content="noindex">
+  <title>{{if .Title}}{{.Title}}{{else if .Error}}Couldn't convert{{else if gt (len .Files) 1}}Your books are ready{{else}}Your book is ready{{end}} · ReKoPub</title>
+  <meta name="color-scheme" content="light dark">
+  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
+  <link rel="stylesheet" href="/style.css">
+</head>
+<body>
+  <!-- Shown when the converter form is used without JavaScript. -->
+  <header class="site-header">
+    <div class="wrap header-inner">
+      <a class="wordmark" href="/">reko<span>pub</span></a>
+    </div>
+  </header>
+  <main class="wrap">
+    <section class="about">
+      {{- if .Error}}
+      <h1>{{if .Title}}{{.Title}}{{else if .Paused}}Temporarily paused{{else}}Couldn't convert{{end}}</h1>
+      <p class="convert-error">{{.Error}}</p>
+      {{- else}}
+      <h1>{{if gt (len .Files) 1}}Your books are ready{{else}}Your book is ready{{end}}</h1>
+      <p class="small">Links work for {{.KeepHours}} hours.</p>
+      <ul class="convert-files">
+        {{- range .Files}}
+        <li><a href="{{.URL}}" download>{{.Name}}</a> <span class="small">{{.Format}}, {{kb .Size}}</span></li>
+        {{- end}}
+      </ul>
+      {{- if .Warning}}
+      <p class="convert-error">{{.Warning}}</p>
+      {{- end}}
+      {{- end}}
+      <p><a href="/#convert">Convert another article</a></p>
+    </section>
+  </main>
+</body>
+</html>

+ 60 - 0
pull-releases/003-web-convert/README.md

@@ -0,0 +1,60 @@
+Convert articles on the website (v1.2.0)
+
+## What changed
+
+- **Converter form on rekopub.monoexp.com**, under the summary: paste an
+  article address, choose EPUB (the default), KEPUB or Both, and get
+  download links. "Links work for 24 hours." is shown above them.
+- **`rekopub-web`** (`cmd/rekopub-web`, `internal/web`): a small server on
+  127.0.0.1:8004, run as `goapp@rekopub-web` behind nginx. For each
+  conversion it runs the `rekopub` command as a separate process, saves the
+  books in a new folder with a random name and serves them from
+  `/books/<folder>/<file>`. Books are deleted after 24 hours.
+- **Limits**, all settable in `/etc/goapps/rekopub-web.env`:
+  - 50 conversions in 15 minutes for everyone together. Over that, the form
+    is disabled and says "Conversions are temporarily paused. Please try
+    again in N minutes."
+  - 10 per visitor in 15 minutes, so one visitor can't use up everyone's.
+  - 2 conversions running at once, up to 10 waiting (a minute at most).
+  - 2 GB of kept books; over that, conversions pause until old books are
+    deleted.
+  - nginx adds a per-address rate limit in front (6 conversion requests a
+    minute, burst 5).
+- **New CLI flag `-NOLOCAL`**: refuses local and private addresses even when
+  `-URL` is one. Without it, `rekopub -URL http://localtest.me:3000/` turns
+  the server's own Gogs page into a book. The website always uses it, and
+  also checks the address itself before running anything.
+- **"Download / Use ReKoPub"** section title above Download and Usage, which
+  become its subsections.
+- `deploy/`: the nginx site file and an example settings file.
+- `VERSION` 1.2.0.
+
+## Notes
+
+- Needs bajdev/devops PR "cli kind: also deploy a server" merged and
+  installed first: it builds `./cmd/rekopub-web` and a server `rekopub`
+  into `/opt/apps/rekopub-web/` and restarts `goapp@rekopub-web` before
+  publishing the site.
+- Server setup before the merge (see the PR description for who does what):
+  `/opt/apps/rekopub-web/data/`, `/etc/goapps/rekopub-web.env` with
+  `PORT=8004`, the new nginx site file.
+- The form works without JavaScript too: the server then answers with a
+  page of its own.
+- Cross-site form posts are refused, so other sites can't use the converter
+  through their visitors' browsers.
+
+## Testing
+
+- `go vet ./...`, `go test ./...` and `go test -race ./internal/web`.
+- New tests: `-NOLOCAL` (with the real pipeline against a local server),
+  and the server: form checks, local addresses, both limits, busy, storage
+  limit, failed conversions, unexpected output paths, link expiry and
+  path tricks on `/books/`, deleting old books, HTML answers (escaped),
+  cross-site posts, visitor keys for IPv4/IPv6, and the real process
+  runner with a stand-in script.
+- Ran the whole stack locally: the built `rekopub` and `rekopub-web`
+  behind nginx with this PR's server block (without TLS). Converted real
+  pages to EPUB, KEPUB and both, downloaded them, and checked: local
+  addresses refused (`localtest.me`, `127.0.0.1`, `[::1]`), the per-visitor
+  limit, nginx's 429, five conversions sent at once (two at a time, each in
+  its own folder), and the page in its normal, finished and paused states.

+ 3 - 6
pull-releases/README.md

@@ -7,17 +7,13 @@ added on the PR's own branch, so it is reviewed and merged with the change.
 pull-releases/
   NNN-branch-name/      NNN = next number in sequence, branch name without feature/
     README.md           title (first line), what changed, notes, testing
-    desktop.png         site changes only: the page at 1280×900
-    mobile.png          site changes only: the page at 390×844 (2× pixel density)
 ```
 
 To open the PR on Gogs, use the compare link for the branch, paste the first
 line of `README.md` as the title and the rest as the description.
 
-Records for changes to the website in `site/` include `desktop.png` (1280×900)
-and `mobile.png` (390×844 at 2×), taken with `screenshot-site` from the site as
-the deploy pipeline builds it. Records for changes to the command-line tool
-alone have no screenshots.
+Records have no screenshots unless bajdev asks for them (since 003). Records
+001–002 include `desktop.png` (1280×900) and `mobile.png` (390×844 at 2×).
 
 ## Index
 
@@ -25,3 +21,4 @@ alone have no screenshots.
 |---|---|---|
 | 001 | [Import the project as ReKoPub](001-rekopub-import/README.md) | `feature/rekopub-import` |
 | 002 | [Add the rekopub.monoexp.com website and VERSION](002-site/README.md) | `feature/site` |
+| 003 | [Convert articles on the website (v1.2.0)](003-web-convert/README.md) | `feature/web-convert` |

+ 97 - 0
site/convert.js

@@ -0,0 +1,97 @@
+// The converter form: sends the address to /api/convert (rekopub-web) and
+// shows the download links on this page. Without this script the form still
+// works, and the server answers with a page of its own.
+(function () {
+  "use strict";
+  var form = document.getElementById("convert-form");
+  var fields = document.getElementById("convert-fields");
+  var result = document.getElementById("convert-result");
+  var keepHours = document.getElementById("keep-hours");
+  var button = form.querySelector("button");
+  if (!window.fetch || !window.URLSearchParams) return;
+
+  function el(tag, attrs, text) {
+    var node = document.createElement(tag);
+    for (var k in attrs) node.setAttribute(k, attrs[k]);
+    if (text) node.textContent = text;
+    return node;
+  }
+
+  function show(nodes) {
+    result.replaceChildren.apply(result, nodes);
+  }
+
+  function message(text, isError) {
+    show([el("p", isError ? { "class": "convert-error" } : {}, text)]);
+  }
+
+  function hours(n) {
+    return n === 1 ? "1 hour" : n + " hours";
+  }
+
+  // pause disables the form while conversions are paused.
+  function pause(text) {
+    fields.disabled = true;
+    message(text, true);
+  }
+
+  function size(bytes) {
+    return bytes < 1024 * 1024
+      ? Math.ceil(bytes / 1024) + " KB"
+      : (bytes / 1024 / 1024).toFixed(1) + " MB";
+  }
+
+  // The server's reply, or a stand-in when nginx answered instead (a
+  // limit, a timeout or the converter being down).
+  function readReply(res) {
+    return res.json().catch(function () {
+      if (res.status === 429) return { error: "Too many requests at once. Please wait a minute and try again." };
+      if (res.status === 503) return { error: "The converter is busy. Please try again in a minute." };
+      if (res.status === 504) return { error: "That page took too long to convert." };
+      return { error: "The converter isn't available right now. Please try again later." };
+    });
+  }
+
+  fetch("/api/status", { headers: { Accept: "application/json" } })
+    .then(readReply)
+    .then(function (st) {
+      if (st.keepHours) keepHours.textContent = st.keepHours;
+      if (st.paused) pause(st.error);
+    })
+    .catch(function () {});
+
+  form.addEventListener("submit", function (event) {
+    event.preventDefault();
+    var body = new URLSearchParams(new FormData(form));
+    fields.disabled = true;
+    button.textContent = "Converting…";
+    message("Converting… this can take up to a minute.");
+    fetch(form.action, {
+      method: "POST",
+      headers: { Accept: "application/json" },
+      body: body
+    })
+      .then(readReply)
+      .then(function (rep) {
+        fields.disabled = false;
+        button.textContent = "Convert";
+        if (rep.paused) return pause(rep.error);
+        if (rep.error) return message(rep.error, true);
+        var list = el("ul", { "class": "convert-files" });
+        rep.files.forEach(function (f) {
+          var li = el("li");
+          li.append(el("a", { href: f.url, download: "" }, f.name), " ",
+            el("span", { "class": "small" }, f.format + ", " + size(f.size)));
+          list.append(li);
+        });
+        var nodes = [el("p", { "class": "small" }, "Links work for " + hours(rep.keepHours) + "."), list];
+        if (rep.warning) nodes.push(el("p", { "class": "convert-error" }, rep.warning));
+        show(nodes);
+      })
+      .catch(function () {
+        fields.disabled = false;
+        button.textContent = "Convert";
+        message("Couldn't reach the converter. Check your connection and try again.", true);
+      });
+  });
+})();

+ 125 - 88
site/index.html

@@ -41,6 +41,38 @@
       <pre><code>rekopub -URL https://example.com/some-article -SAVE ~/Books/</code></pre>
     </section>
 
+    <section id="convert" aria-labelledby="convert-title">
+      <h2 id="convert-title">Convert an article</h2>
+      <div class="convert">
+        <!-- Sent to rekopub-web (cmd/rekopub-web), which runs the rekopub
+             command on the server. convert.js shows the result on this page;
+             without JavaScript the server answers with a page of its own. -->
+        <form id="convert-form" action="/api/convert" method="post">
+          <fieldset id="convert-fields">
+            <label for="convert-url">Article address</label>
+            <div class="convert-row">
+              <input id="convert-url" name="url" type="url" required maxlength="2000"
+                     placeholder="https://example.com/some-article" autocomplete="url" spellcheck="false">
+              <button type="submit">Convert</button>
+            </div>
+            <fieldset class="formats">
+              <legend>Format</legend>
+              <label><input type="radio" name="format" value="epub" checked> EPUB <span>reMarkable and most readers</span></label>
+              <label><input type="radio" name="format" value="kepub"> KEPUB <span>Kobo</span></label>
+              <label><input type="radio" name="format" value="both"> Both</label>
+            </fieldset>
+          </fieldset>
+        </form>
+        <div id="convert-result" class="convert-result" role="status" aria-live="polite"></div>
+        <p class="small">
+          This server downloads the page and makes the book for you, with the
+          same rules as the command-line tool. Books are deleted after
+          <span id="keep-hours">24</span> hours. It's shared by everyone, so
+          conversions are limited and may be paused for a while when it's busy.
+        </p>
+      </div>
+    </section>
+
     <section id="features" aria-labelledby="features-title">
       <h2 id="features-title">Features</h2>
       <ul class="cards">
@@ -71,106 +103,110 @@
       </ul>
     </section>
 
-    <section id="download" aria-labelledby="download-title">
-      <h2 id="download-title">Download</h2>
-      <div class="release">
-        <p class="release-version">
-          <strong>@VERSION@</strong>
-          <span>built @DATE@ from commit
-          <a href="https://git.monoexp.com/bajdev/ReKoPub/commit/@COMMIT@"><code>@SHORT@</code></a></span>
+    <section id="get" class="group" aria-labelledby="get-title">
+      <h2 id="get-title" class="section-title">Download / Use ReKoPub</h2>
+
+      <section id="download" aria-labelledby="download-title">
+        <h3 id="download-title" class="label">Download</h3>
+        <div class="release">
+          <p class="release-version">
+            <strong>@VERSION@</strong>
+            <span>built @DATE@ from commit
+            <a href="https://git.monoexp.com/bajdev/ReKoPub/commit/@COMMIT@"><code>@SHORT@</code></a></span>
+          </p>
+          <table class="downloads">
+            <thead>
+              <tr><th scope="col">Computer</th><th scope="col">File</th></tr>
+            </thead>
+            <tbody>
+              <tr><td>Windows (most PCs)</td><td><a href="/download/rekopub_windows_amd64.zip">rekopub_windows_amd64.zip</a></td></tr>
+              <tr><td>Windows on Arm</td><td><a href="/download/rekopub_windows_arm64.zip">rekopub_windows_arm64.zip</a></td></tr>
+              <tr><td>Mac with Apple silicon</td><td><a href="/download/rekopub_darwin_arm64.tar.gz">rekopub_darwin_arm64.tar.gz</a></td></tr>
+              <tr><td>Mac with Intel</td><td><a href="/download/rekopub_darwin_amd64.tar.gz">rekopub_darwin_amd64.tar.gz</a></td></tr>
+              <tr><td>Linux (x86-64)</td><td><a href="/download/rekopub_linux_amd64.tar.gz">rekopub_linux_amd64.tar.gz</a></td></tr>
+              <tr><td>Linux (Arm)</td><td><a href="/download/rekopub_linux_arm64.tar.gz">rekopub_linux_arm64.tar.gz</a></td></tr>
+            </tbody>
+          </table>
+          <p class="small"><a href="/download/checksums.txt">checksums.txt</a> lists the SHA-256 of each file.</p>
+        </div>
+        <p>
+          Unpack the archive and put <code>rekopub</code> (<code>rekopub.exe</code>
+          on Windows) somewhere convenient. It's a command-line program, so run it
+          from Terminal, Command Prompt or PowerShell rather than by double-clicking.
+          The binaries aren't code-signed, so the first run may show a warning:
         </p>
-        <table class="downloads">
-          <thead>
-            <tr><th scope="col">Computer</th><th scope="col">File</th></tr>
-          </thead>
-          <tbody>
-            <tr><td>Windows (most PCs)</td><td><a href="/download/rekopub_windows_amd64.zip">rekopub_windows_amd64.zip</a></td></tr>
-            <tr><td>Windows on Arm</td><td><a href="/download/rekopub_windows_arm64.zip">rekopub_windows_arm64.zip</a></td></tr>
-            <tr><td>Mac with Apple silicon</td><td><a href="/download/rekopub_darwin_arm64.tar.gz">rekopub_darwin_arm64.tar.gz</a></td></tr>
-            <tr><td>Mac with Intel</td><td><a href="/download/rekopub_darwin_amd64.tar.gz">rekopub_darwin_amd64.tar.gz</a></td></tr>
-            <tr><td>Linux (x86-64)</td><td><a href="/download/rekopub_linux_amd64.tar.gz">rekopub_linux_amd64.tar.gz</a></td></tr>
-            <tr><td>Linux (Arm)</td><td><a href="/download/rekopub_linux_arm64.tar.gz">rekopub_linux_arm64.tar.gz</a></td></tr>
-          </tbody>
-        </table>
-        <p class="small"><a href="/download/checksums.txt">checksums.txt</a> lists the SHA-256 of each file.</p>
-      </div>
-      <p>
-        Unpack the archive and put <code>rekopub</code> (<code>rekopub.exe</code>
-        on Windows) somewhere convenient. It's a command-line program, so run it
-        from Terminal, Command Prompt or PowerShell rather than by double-clicking.
-        The binaries aren't code-signed, so the first run may show a warning:
-      </p>
-      <ul>
-        <li><strong>Windows:</strong> SmartScreen may say "Windows protected your PC". Choose <em>More info &rarr; Run anyway</em>.</li>
-        <li><strong>macOS:</strong> Gatekeeper may refuse to open it. Run this once, in the folder where you unpacked it:
-          <pre><code>xattr -d com.apple.quarantine rekopub</code></pre></li>
-      </ul>
-      <p>With Go 1.26 or newer you can also build it yourself:</p>
-      <pre><code>go install git.monoexp.com/bajdev/rekopub/cmd/rekopub@latest</code></pre>
-    </section>
+        <ul>
+          <li><strong>Windows:</strong> SmartScreen may say "Windows protected your PC". Choose <em>More info &rarr; Run anyway</em>.</li>
+          <li><strong>macOS:</strong> Gatekeeper may refuse to open it. Run this once, in the folder where you unpacked it:
+            <pre><code>xattr -d com.apple.quarantine rekopub</code></pre></li>
+        </ul>
+        <p>With Go 1.26 or newer you can also build it yourself:</p>
+        <pre><code>go install git.monoexp.com/bajdev/rekopub/cmd/rekopub@latest</code></pre>
+      </section>
 
-    <section id="usage" aria-labelledby="usage-title">
-      <h2 id="usage-title">Usage</h2>
-      <pre><code>rekopub -URL &lt;url&gt; -SAVE &lt;path&gt; [-FORCE] [-KEPUB]
+      <section id="usage" aria-labelledby="usage-title">
+        <h3 id="usage-title" class="label">Usage</h3>
+        <pre><code>rekopub -URL &lt;url&gt; -SAVE &lt;path&gt; [-FORCE] [-KEPUB]
 rekopub -VERSION</code></pre>
-      <div class="table-scroll">
-        <table>
-          <thead>
-            <tr><th scope="col">Flag</th><th scope="col">What it does</th></tr>
-          </thead>
-          <tbody>
-            <tr><td><code>-URL</code></td><td>The web page to convert. Must start with <code>http://</code> or <code>https://</code>. Required.</td></tr>
-            <tr><td><code>-SAVE</code></td><td>Where to write the book: a file or a folder. Missing folders are created. Required.</td></tr>
-            <tr><td><code>-FORCE</code></td><td>Overwrite the output file if it already exists.</td></tr>
-            <tr><td><code>-KEPUB</code></td><td>Write a Kobo KEPUB (<code>.kepub.epub</code>) instead of a plain EPUB.</td></tr>
-            <tr><td><code>-VERSION</code></td><td>Print the version and exit.</td></tr>
-          </tbody>
-        </table>
-      </div>
-      <p>Flag names are case-sensitive: <code>-URL</code> works, <code>-url</code> does not.</p>
+        <div class="table-scroll">
+          <table>
+            <thead>
+              <tr><th scope="col">Flag</th><th scope="col">What it does</th></tr>
+            </thead>
+            <tbody>
+              <tr><td><code>-URL</code></td><td>The web page to convert. Must start with <code>http://</code> or <code>https://</code>. Required.</td></tr>
+              <tr><td><code>-SAVE</code></td><td>Where to write the book: a file or a folder. Missing folders are created. Required.</td></tr>
+              <tr><td><code>-FORCE</code></td><td>Overwrite the output file if it already exists.</td></tr>
+              <tr><td><code>-KEPUB</code></td><td>Write a Kobo KEPUB (<code>.kepub.epub</code>) instead of a plain EPUB.</td></tr>
+              <tr><td><code>-VERSION</code></td><td>Print the version and exit.</td></tr>
+            </tbody>
+          </table>
+        </div>
+        <p>Flag names are case-sensitive: <code>-URL</code> works, <code>-url</code> does not.</p>
 
-      <h3>Where the book is saved</h3>
-      <ul>
-        <li><strong>A folder</strong>, such as <code>~/Books/</code> or an existing <code>~/Books</code>: the file name comes from the article title.</li>
-        <li><strong>A path ending in <code>.epub</code></strong>: written to exactly that file.</li>
-        <li><strong>Anything else</strong>, such as <code>~/Books/a</code>: treated as a file name, with <code>.epub</code> added.</li>
-      </ul>
-      <p>
-        Watch the trailing slash when the folder doesn't exist yet:
-        <code>-SAVE ~/Books/new/</code> creates a folder called <code>new</code>,
-        while <code>-SAVE ~/Books/new</code> writes <code>new.epub</code>.
-        On success, the saved file's full path is printed, so it's easy to use in scripts.
-      </p>
+        <h4>Where the book is saved</h4>
+        <ul>
+          <li><strong>A folder</strong>, such as <code>~/Books/</code> or an existing <code>~/Books</code>: the file name comes from the article title.</li>
+          <li><strong>A path ending in <code>.epub</code></strong>: written to exactly that file.</li>
+          <li><strong>Anything else</strong>, such as <code>~/Books/a</code>: treated as a file name, with <code>.epub</code> added.</li>
+        </ul>
+        <p>
+          Watch the trailing slash when the folder doesn't exist yet:
+          <code>-SAVE ~/Books/new/</code> creates a folder called <code>new</code>,
+          while <code>-SAVE ~/Books/new</code> writes <code>new.epub</code>.
+          On success, the saved file's full path is printed, so it's easy to use in scripts.
+        </p>
 
-      <h3>Kobo Clara HD</h3>
-      <p>Use <code>-KEPUB</code> for faster page turns and reading stats. Connect the Kobo by USB, tap <em>Connect</em>, and save straight to it:</p>
-      <pre><code># Linux
+        <h4>Kobo Clara HD</h4>
+        <p>Use <code>-KEPUB</code> for faster page turns and reading stats. Connect the Kobo by USB, tap <em>Connect</em>, and save straight to it:</p>
+        <pre><code># Linux
 rekopub -URL https://example.com/article -SAVE /media/$USER/KOBOeReader/Articles/ -KEPUB
 # macOS
 rekopub -URL https://example.com/article -SAVE /Volumes/KOBOeReader/Articles/ -KEPUB
 # Windows (the drive letter may differ)
 rekopub.exe -URL https://example.com/article -SAVE E:\Articles\ -KEPUB</code></pre>
-      <p>Eject the Kobo and the article appears in your library.</p>
+        <p>Eject the Kobo and the article appears in your library.</p>
 
-      <h3>reMarkable 2</h3>
-      <p>Use a plain EPUB, without <code>-KEPUB</code>. Save it on your computer:</p>
-      <pre><code>rekopub -URL https://example.com/article -SAVE ~/reMarkable/</code></pre>
-      <p>
-        Then drag the file into the reMarkable desktop or mobile app, or upload it
-        through the USB web interface (<em>Settings &rarr; Storage &rarr; USB web
-        interface</em>, then open <code>http://10.11.99.1</code>).
-      </p>
+        <h4>reMarkable 2</h4>
+        <p>Use a plain EPUB, without <code>-KEPUB</code>. Save it on your computer:</p>
+        <pre><code>rekopub -URL https://example.com/article -SAVE ~/reMarkable/</code></pre>
+        <p>
+          Then drag the file into the reMarkable desktop or mobile app, or upload it
+          through the USB web interface (<em>Settings &rarr; Storage &rarr; USB web
+          interface</em>, then open <code>http://10.11.99.1</code>).
+        </p>
 
-      <h3>Good to know</h3>
-      <ul>
-        <li>Pages that build their content with JavaScript, paywalls and cookie walls can't be converted.</li>
-        <li>ReKoPub only reads web pages: it never reads files on your computer, and pages on the internet can't make it connect to your own network.</li>
-        <li>It sends no cookies, never logs in and runs no JavaScript. Every book records its source URL.</li>
-      </ul>
-      <p>
-        The <a href="https://git.monoexp.com/bajdev/ReKoPub#readme">README</a> has
-        the full details: exit codes, troubleshooting and privacy.
-      </p>
+        <h4>Good to know</h4>
+        <ul>
+          <li>Pages that build their content with JavaScript, paywalls and cookie walls can't be converted.</li>
+          <li>ReKoPub only reads web pages: it never reads files on your computer, and pages on the internet can't make it connect to your own network.</li>
+          <li>It sends no cookies, never logs in and runs no JavaScript. Every book records its source URL.</li>
+        </ul>
+        <p>
+          The <a href="https://git.monoexp.com/bajdev/ReKoPub#readme">README</a> has
+          the full details: exit codes, troubleshooting and privacy.
+        </p>
+      </section>
     </section>
 
     <section id="license" aria-labelledby="license-title">
@@ -206,5 +242,6 @@ rekopub.exe -URL https://example.com/article -SAVE E:\Articles\ -KEPUB</code></p
       <p>A <a href="https://monoexp.com/">Mono Experiments</a> project &middot; <a href="https://git.monoexp.com/bajdev/ReKoPub">Source</a></p>
     </div>
   </footer>
+  <script src="/convert.js" defer></script>
 </body>
 </html>

+ 69 - 3
site/style.css

@@ -1,4 +1,4 @@
-/* rekopub.monoexp.com: the monoexp.com stylesheet, plus code, tables and downloads */
+/* rekopub.monoexp.com: the monoexp.com stylesheet, plus code, tables, downloads and the converter form */
 
 :root {
   --bg: #fafaf9;
@@ -92,14 +92,27 @@ nav a:hover { color: var(--text); }
 
 /* Sections */
 main section:not(.about) { padding-bottom: 48px; scroll-margin-top: 16px; }
-main h2 {
+main h2,
+main .label {
   margin: 0 0 16px;
   font: 600 13px/1 var(--mono);
   text-transform: uppercase;
   letter-spacing: 0.08em;
   color: var(--muted);
 }
-main h3 { margin: 28px 0 8px; font-size: 19px; letter-spacing: -0.01em; }
+main h3,
+main h4 { margin: 28px 0 8px; font-size: 19px; letter-spacing: -0.01em; }
+
+/* A section that groups others, such as Download / Use ReKoPub */
+main .group { padding-top: 40px; border-top: 1px solid var(--border); }
+main .group > section:last-child { padding-bottom: 0; }
+main h2.section-title {
+  margin: 0 0 28px;
+  font: 650 clamp(24px, 4.5vw, 30px)/1.2 var(--sans);
+  text-transform: none;
+  letter-spacing: -0.02em;
+  color: var(--text);
+}
 main section p, main section li { max-width: 68ch; }
 main section ul:not(.cards) { padding-left: 22px; }
 main section li { margin-bottom: 6px; }
@@ -180,6 +193,59 @@ td:first-child { white-space: nowrap; }
   .downloads td:first-child { font-size: 14px; color: var(--muted); }
 }
 
+/* Converter form */
+.convert {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 20px;
+}
+.convert fieldset { margin: 0; padding: 0; border: 0; min-width: 0; }
+.convert label[for] { display: block; font-weight: 600; margin-bottom: 8px; }
+.convert-row { display: flex; gap: 10px; }
+.convert input[type="url"] {
+  flex: 1;
+  min-width: 0;
+  padding: 10px 12px;
+  font: 16px/1.4 var(--sans);
+  color: var(--text);
+  background: var(--bg);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+}
+.convert button {
+  padding: 10px 18px;
+  font: 600 16px/1.4 var(--sans);
+  color: var(--bg);
+  background: var(--accent);
+  border: 1px solid var(--accent);
+  border-radius: 8px;
+  cursor: pointer;
+}
+.convert input:focus-visible,
+.convert button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
+.convert fieldset:disabled { opacity: 0.55; }
+.convert fieldset:disabled button { cursor: not-allowed; }
+.convert .formats { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 14px; }
+.formats legend { float: left; margin-right: 6px; font-weight: 600; }
+.formats label { display: inline-flex; align-items: baseline; gap: 6px; cursor: pointer; }
+.formats label span { color: var(--muted); font-size: 14.5px; }
+.convert-result:empty { display: none; }
+.convert-result {
+  margin-top: 16px;
+  padding-top: 14px;
+  border-top: 1px solid var(--border);
+}
+.convert-result p { margin: 0 0 8px; }
+.convert > .small { margin: 16px 0 0; }
+main section ul.convert-files { list-style: none; padding: 0; margin: 0 0 8px; }
+.convert-files li { margin-bottom: 6px; }
+.convert-files a { font: 15px/1.5 var(--mono); overflow-wrap: anywhere; }
+.convert-error { font-weight: 600; }
+@media (max-width: 520px) {
+  .convert-row { flex-direction: column; }
+}
+
 /* Footer */
 .site-footer { border-top: 1px solid var(--border); }
 .site-footer p { margin: 0; padding: 24px 0 32px; font-size: 14px; color: var(--muted); }