Просмотр исходного кода

Merge branch 'feature/web-convert' of bajdev/ReKoPub into main

bajdev 10 часов назад
Родитель
Сommit
afb4c3bcaf

+ 1 - 0
.gitignore

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

+ 32 - 6
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/
 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
 ## Features
@@ -85,7 +87,7 @@ To try it on real articles, run `test-examples.cmd` (`.\test-examples.cmd` in Po
 ## Usage
 ## Usage
 
 
 ```
 ```
-rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB]
+rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-LOCAL]
 rekopub -VERSION
 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. |
 | `-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. |
 | `-FORCE` | no | Overwrite the output file if it already exists. |
 | `-KEPUB` | no | Write a Kobo KEPUB (`.kepub.epub`) instead of a plain EPUB. |
 | `-KEPUB` | no | Write a Kobo KEPUB (`.kepub.epub`) instead of a plain EPUB. |
+| `-LOCAL` | no | Allow `-URL` to be on your own computer or a private network, such as a wiki at home. Without it, local and private addresses are refused. |
 | `-VERSION` | no | Print the version and exit. Include it when reporting a problem. |
 | `-VERSION` | no | Print the version and exit. Include it when reporting a problem. |
 
 
 Flag names are case-sensitive: `-URL` works, `-url` does not.
 Flag names are case-sensitive: `-URL` works, `-url` does not.
@@ -184,7 +187,7 @@ Use plain EPUB, without `-KEPUB`.
 |---|---|
 |---|---|
 | `0` | Success |
 | `0` | Success |
 | `1` | The page couldn't be fetched (network error, non-200 response, not an HTML page) |
 | `1` | The page couldn't be fetched (network error, non-200 response, not an HTML page) |
-| `2` | Invalid arguments |
+| `2` | Invalid arguments, including a local or private `-URL` without `-LOCAL` |
 | `3` | No readable article content was found on the page |
 | `3` | No readable article content was found on the page |
 | `4` | The book couldn't be built or written (including folder creation) |
 | `4` | The book couldn't be built or written (including folder creation) |
 | `5` | The output file already exists and `-FORCE` was not given |
 | `5` | The output file already exists and `-FORCE` was not given |
@@ -212,7 +215,8 @@ done < urls.txt
 | `no readable article content found` | The page has no article text ReKoPub can find. Usually the page builds its content with JavaScript, or it's a paywall or cookie wall. |
 | `no readable article content found` | The page has no article text ReKoPub can find. Usually the page builds its content with JavaScript, or it's a paywall or cookie wall. |
 | `warning: image … skipped: …` | One image couldn't be downloaded or read. The book is still made, with the image's description in its place. |
 | `warning: image … skipped: …` | One image couldn't be downloaded or read. The book is still made, with the image's description in its place. |
 | `warning: cover image … skipped, using a plain cover` | The page's preview image couldn't be used, so the book gets a typographic cover instead. |
 | `warning: cover image … skipped, using a plain cover` | The page's preview image couldn't be used, so the book gets a typographic cover instead. |
-| `… is a local or private address …` | The page tried to send ReKoPub to your own computer or network, through a redirect or an image. That's refused for pages on the internet (see [Privacy and security](#privacy-and-security)). To convert a page on your own network, give its local address as `-URL`. |
+| `-URL … is on this computer or a private network; add -LOCAL to convert it` | Local and private addresses are refused unless you add `-LOCAL` (see [Privacy and security](#privacy-and-security)). |
+| `… is a local or private address …` | The page tried to send ReKoPub to your own computer or network, through a redirect or an image. That's refused for pages on the internet, with or without `-LOCAL`. To convert a page on your own network, give its local address as `-URL` and add `-LOCAL`. |
 
 
 To report a problem with a particular page, include the output of `rekopub -VERSION`, the full command, everything it printed, and a saved copy of the page:
 To report a problem with a particular page, include the output of `rekopub -VERSION`, the full command, everything it printed, and a saved copy of the page:
 
 
@@ -227,7 +231,7 @@ curl -L -A "Mozilla/5.0" -o page.html "https://example.com/the-article"
 ## Privacy and security
 ## 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.
 - **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.
+- **Your own network is off limits by default.** 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. To convert a page there, such as a wiki on your network, add `-LOCAL`; that page's local images are then allowed too. A web page chooses where its images come from, and can redirect, so pages on the internet can never make ReKoPub connect to those addresses, with or without `-LOCAL`. With a proxy set in `HTTP_PROXY` or `HTTPS_PROXY`, the proxy makes the connections, so ReKoPub can't check them. The website converter never uses `-LOCAL`.
 - **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.
 - **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.
 - **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 +266,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/        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/pipeline/  Runs the stages below in order
 internal/fetch/     HTTP download, local-network guard, character-set decoding, friendly network errors
 internal/fetch/     HTTP download, local-network guard, character-set decoding, friendly network errors
 internal/extract/   Article and metadata extraction (go-trafilatura, page metadata)
 internal/extract/   Article and metadata extraction (go-trafilatura, page metadata)
@@ -275,12 +281,13 @@ internal/save/      Output path rules, folder creation, -FORCE, atomic writes
 internal/epubtest/  Test helpers for checking generated books
 internal/epubtest/  Test helpers for checking generated books
 testdata/           Saved HTML pages and images used by the tests
 testdata/           Saved HTML pages and images used by the tests
 site/               rekopub.monoexp.com (published on every merge into main)
 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
 VERSION             The version release builds report
 ```
 ```
 
 
 ### Releasing
 ### 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:
 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 +298,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.
 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`, never with `-LOCAL` (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.
 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
+}

+ 10 - 2
cmd/rekopub/main.go

@@ -1,6 +1,6 @@
 // Command rekopub converts a web article into an EPUB for e-ink readers.
 // 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] [-LOCAL]
 package main
 package main
 
 
 import (
 import (
@@ -31,7 +31,7 @@ const (
 	exitInterrupted = 130
 	exitInterrupted = 130
 )
 )
 
 
-const usage = `Usage: rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB]
+const usage = `Usage: rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-LOCAL]
 
 
 Convert a web article into an EPUB for e-ink readers.
 Convert a web article into an EPUB for e-ink readers.
 
 
@@ -48,6 +48,10 @@ Convert a web article into an EPUB for e-ink readers.
         Overwrite the output file if it already exists.
         Overwrite the output file if it already exists.
   -KEPUB
   -KEPUB
         Write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB.
         Write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB.
+  -LOCAL
+        Allow -URL to be on this computer or a private network, such as
+        a wiki at home. Without it, local and private addresses are
+        refused.
   -VERSION
   -VERSION
         Print the version and exit.
         Print the version and exit.
 `
 `
@@ -71,6 +75,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)")
 	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")
 	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")
 	kepub := fs.Bool("KEPUB", false, "write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB")
+	local := fs.Bool("LOCAL", false, "allow -URL to be a local or private address")
 	showVersion := fs.Bool("VERSION", false, "print the version and exit")
 	showVersion := fs.Bool("VERSION", false, "print the version and exit")
 
 
 	if err := fs.Parse(args); err != nil {
 	if err := fs.Parse(args); err != nil {
@@ -125,6 +130,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert c
 	opts := pipeline.Options{
 	opts := pipeline.Options{
 		Warn:  func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
 		Warn:  func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
 		KEPUB: *kepub,
 		KEPUB: *kepub,
+		Local: *local,
 	}
 	}
 	if !target.Known() && !*force {
 	if !target.Known() && !*force {
 		// For folder targets the filename comes from the title; check it
 		// For folder targets the filename comes from the title; check it
@@ -137,6 +143,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert c
 		return fail(exitInterrupted, "interrupted")
 		return fail(exitInterrupted, "interrupted")
 	case errors.Is(err, save.ErrExists):
 	case errors.Is(err, save.ErrExists):
 		return saveFailure(fail, err)
 		return saveFailure(fail, err)
+	case errors.Is(err, pipeline.ErrLocal):
+		return fail(exitUsage, "-URL %s is on this computer or a private network; add -LOCAL to convert it", pageURL.Hostname())
 	case errors.Is(err, pipeline.ErrFetch):
 	case errors.Is(err, pipeline.ErrFetch):
 		return fail(exitFetch, "%v", err)
 		return fail(exitFetch, "%v", err)
 	case errors.Is(err, pipeline.ErrNoContent):
 	case errors.Is(err, pipeline.ErrNoContent):

+ 55 - 2
cmd/rekopub/main_test.go

@@ -28,11 +28,13 @@ type fakeConverter struct {
 	err   error
 	err   error
 	calls int
 	calls int
 	kepub bool // the KEPUB option of the last call
 	kepub bool // the KEPUB option of the last call
+	local bool // the Local option of the last call
 }
 }
 
 
 func (f *fakeConverter) convert(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error) {
 func (f *fakeConverter) convert(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error) {
 	f.calls++
 	f.calls++
 	f.kepub = opts.KEPUB
 	f.kepub = opts.KEPUB
+	f.local = opts.Local
 	if f.err != nil {
 	if f.err != nil {
 		return nil, f.err
 		return nil, f.err
 	}
 	}
@@ -278,6 +280,56 @@ func TestKEPUBOption(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestLocalOption(t *testing.T) {
+	for _, local := range []bool{false, true} {
+		conv := &fakeConverter{title: "Title"}
+		args := []string{"-URL", testURL, "-SAVE", t.TempDir()}
+		if local {
+			args = append(args, "-LOCAL")
+		}
+		if r := runCLI(t, conv, args...); r.code != exitOK {
+			t.Fatalf("local=%v: exit code = %d, stderr = %q", local, r.code, r.stderr)
+		}
+		if conv.local != local {
+			t.Errorf("local=%v: pipeline got Local=%v", local, conv.local)
+		}
+	}
+}
+
+// TestLocalPageNeedsFlag runs the real pipeline: a page on localhost is
+// refused without -LOCAL, before anything is fetched, and converted with it.
+func TestLocalPageNeedsFlag(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}, &stdout, &stderr, pipeline.Run)
+	want := "error: -URL 127.0.0.1 is on this computer or a private network; add -LOCAL to convert it\n"
+	if code != exitUsage || stderr.String() != want {
+		t.Errorf("exit code = %d, stderr = %q; want %d and %q", code, stderr.String(), exitUsage, want)
+	}
+	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, "-LOCAL"}, &stdout, &stderr, pipeline.Run); code != exitOK {
+		t.Errorf("with -LOCAL: exit code = %d, stderr = %q", code, stderr.String())
+	}
+}
+
 // assertCover checks the book has a 1404x1872 grayscale cover, listed
 // assertCover checks the book has a 1404x1872 grayscale cover, listed
 // first in the spine. With photo, the cover's upper half must show the
 // 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
 // photo (mid-grays) rather than the plain cover's black band and white
@@ -416,7 +468,8 @@ func TestEndToEnd(t *testing.T) {
 
 
 	realRun := func(args ...string) result {
 	realRun := func(args ...string) result {
 		var stdout, stderr bytes.Buffer
 		var stdout, stderr bytes.Buffer
-		code := run(context.Background(), args, &stdout, &stderr, pipeline.Run)
+		// The test server is on localhost.
+		code := run(context.Background(), append(args, "-LOCAL"), &stdout, &stderr, pipeline.Run)
 		return result{code, stdout.String(), stderr.String()}
 		return result{code, stdout.String(), stderr.String()}
 	}
 	}
 
 
@@ -774,7 +827,7 @@ func TestLocalFilesNeverRead(t *testing.T) {
 
 
 	out := filepath.Join(t.TempDir(), "book.epub")
 	out := filepath.Join(t.TempDir(), "book.epub")
 	var stdout, stderr bytes.Buffer
 	var stdout, stderr bytes.Buffer
-	code := run(context.Background(), []string{"-URL", srv.URL + "/posts/eink", "-SAVE", out}, &stdout, &stderr, pipeline.Run)
+	code := run(context.Background(), []string{"-URL", srv.URL + "/posts/eink", "-SAVE", out, "-LOCAL"}, &stdout, &stderr, pipeline.Run)
 	if code != exitOK {
 	if code != exitOK {
 		t.Fatalf("exit %d: %s", code, stderr.String())
 		t.Fatalf("exit %d: %s", code, stderr.String())
 	}
 	}

+ 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;
+    }
+}

+ 1 - 1
internal/fetch/errors.go

@@ -32,7 +32,7 @@ func Explain(err error) error {
 	var invalidCert x509.CertificateInvalidError
 	var invalidCert x509.CertificateInvalidError
 	switch {
 	switch {
 	case errors.As(err, &blocked):
 	case errors.As(err, &blocked):
-		return &explained{fmt.Sprintf("%s is a local or private address (%s); rekopub connects to those only when -URL is a local address too", orServer(host), blocked.Addr), err}
+		return &explained{fmt.Sprintf("%s is a local or private address (%s); rekopub connects to those only with -LOCAL, when -URL is a local address too", orServer(host), blocked.Addr), err}
 	case errors.As(err, &dnsErr):
 	case errors.As(err, &dnsErr):
 		return &explained{fmt.Sprintf("can't find the server %s; check the address and your internet connection", dnsErr.Name), err}
 		return &explained{fmt.Sprintf("can't find the server %s; check the address and your internet connection", dnsErr.Name), err}
 	case isRefused(err):
 	case isRefused(err):

+ 15 - 3
internal/pipeline/pipeline.go

@@ -27,6 +27,8 @@ import (
 var (
 var (
 	ErrFetch     = errors.New("couldn't fetch the page")
 	ErrFetch     = errors.New("couldn't fetch the page")
 	ErrNoContent = extract.ErrNoContent
 	ErrNoContent = extract.ErrNoContent
+	// ErrLocal reports a local or private -URL without Options.Local.
+	ErrLocal = errors.New("local or private address")
 )
 )
 
 
 // Book is a finished book, ready to be saved.
 // Book is a finished book, ready to be saved.
@@ -49,13 +51,23 @@ type Options struct {
 	Warn func(msg string)
 	Warn func(msg string)
 	// KEPUB converts the finished book to a Kobo KEPUB.
 	// KEPUB converts the finished book to a Kobo KEPUB.
 	KEPUB bool
 	KEPUB bool
+	// Local allows u to be a local or private address, such as a wiki on
+	// the reader's network; its local images and redirects are allowed too.
+	// Without it, local and private addresses are always refused, so a
+	// page can never make rekopub connect to the computer it runs on or
+	// its network.
+	Local bool
 }
 }
 
 
 // Run converts the page at u into a book using the default settings.
 // 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.
+// Local and private addresses are allowed only with opts.Local and when u
+// itself is local; a local u without opts.Local returns ErrLocal.
 func Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
 func Run(ctx context.Context, u *url.URL, opts Options) (*Book, error) {
-	return New(fetch.IsLocal(ctx, u.Hostname())).Run(ctx, u, opts)
+	local := fetch.IsLocal(ctx, u.Hostname())
+	if local && !opts.Local {
+		return nil, fmt.Errorf("%w: %s", ErrLocal, u.Hostname())
+	}
+	return New(local).Run(ctx, u, opts)
 }
 }
 
 
 // Pipeline holds the settings for conversions.
 // 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 strings.Contains(res.Err, "local or private") || strings.Contains(res.Err, "-LOCAL"):
+		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."
+	}
+}

+ 553 - 0
internal/web/server.go

@@ -0,0 +1,553 @@
+// 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 (it's run without
+	// -LOCAL); 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.
+	// Never -LOCAL: visitors mustn't reach the server's own network.
+	args := []string{"-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 len(args) != 2 || args[0] != "-URL=https://example.com/posts/hello" ||
+		!strings.HasPrefix(args[1], "-SAVE="+ts.cfg.Dir+string(filepath.Separator)) || !strings.HasSuffix(args[1], string(filepath.Separator)) {
+		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: 2, Err: "-URL 10.0.0.1 is on this computer or a private network; add -LOCAL to convert it"}, "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 "$1" 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 "$2" | 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{"-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{"-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>

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

@@ -0,0 +1,66 @@
+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).
+- **Local addresses now need `-LOCAL`** (CLI change): local and private
+  addresses are refused by default. A local `-URL` without the flag stops
+  with "-URL … is on this computer or a private network; add -LOCAL to
+  convert it" (exit code 2), before anything is downloaded. Before this,
+  `rekopub -URL http://localtest.me:3000/` turned the server's own Gogs page
+  into a book. The website never passes `-LOCAL`, and also checks the
+  address itself before running anything. People who convert pages on
+  their own network need to add `-LOCAL` from this version on.
+- **"Download / Use ReKoPub"** section title above Download and Usage, which
+  become its subsections. The example command moved from the summary into
+  Usage, and the usage table lists `-LOCAL`.
+- `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: `-LOCAL` (with the real pipeline: a localhost page is refused
+  without it, before any request, and converted with it),
+  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/
 pull-releases/
   NNN-branch-name/      NNN = next number in sequence, branch name without feature/
   NNN-branch-name/      NNN = next number in sequence, branch name without feature/
     README.md           title (first line), what changed, notes, testing
     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
 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.
 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
 ## Index
 
 
@@ -25,3 +21,4 @@ alone have no screenshots.
 |---|---|---|
 |---|---|---|
 | 001 | [Import the project as ReKoPub](001-rekopub-import/README.md) | `feature/rekopub-import` |
 | 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` |
 | 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);
+      });
+  });
+})();

+ 129 - 89
site/index.html

@@ -38,7 +38,38 @@
         It is built for the <strong>reMarkable 2</strong> and the
         It is built for the <strong>reMarkable 2</strong> and the
         <strong>Kobo Clara HD</strong>.
         <strong>Kobo Clara HD</strong>.
       </p>
       </p>
-      <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>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>
 
 
     <section id="features" aria-labelledby="features-title">
     <section id="features" aria-labelledby="features-title">
@@ -71,106 +102,114 @@
       </ul>
       </ul>
     </section>
     </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>
         </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>
+        <p>Give it an article's address and a folder for the book:</p>
+        <pre><code>rekopub -URL https://example.com/some-article -SAVE ~/Books/</code></pre>
+        <p>All the options:</p>
+        <pre><code>rekopub -URL &lt;url&gt; -SAVE &lt;path&gt; [-FORCE] [-KEPUB] [-LOCAL]
 rekopub -VERSION</code></pre>
 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>-LOCAL</code></td><td>Allow a <code>-URL</code> on your own computer or network, such as a wiki at home. Without it, those addresses are refused.</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
 rekopub -URL https://example.com/article -SAVE /media/$USER/KOBOeReader/Articles/ -KEPUB
 # macOS
 # macOS
 rekopub -URL https://example.com/article -SAVE /Volumes/KOBOeReader/Articles/ -KEPUB
 rekopub -URL https://example.com/article -SAVE /Volumes/KOBOeReader/Articles/ -KEPUB
 # Windows (the drive letter may differ)
 # Windows (the drive letter may differ)
 rekopub.exe -URL https://example.com/article -SAVE E:\Articles\ -KEPUB</code></pre>
 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 it doesn't connect to your own computer or network unless you add <code>-LOCAL</code>. Even then, pages on the internet can't make it connect there.</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>
 
 
     <section id="license" aria-labelledby="license-title">
     <section id="license" aria-labelledby="license-title">
@@ -206,5 +245,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>
       <p>A <a href="https://monoexp.com/">Mono Experiments</a> project &middot; <a href="https://git.monoexp.com/bajdev/ReKoPub">Source</a></p>
     </div>
     </div>
   </footer>
   </footer>
+  <script src="/convert.js" defer></script>
 </body>
 </body>
 </html>
 </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 {
 :root {
   --bg: #fafaf9;
   --bg: #fafaf9;
@@ -92,14 +92,27 @@ nav a:hover { color: var(--text); }
 
 
 /* Sections */
 /* Sections */
 main section:not(.about) { padding-bottom: 48px; scroll-margin-top: 16px; }
 main section:not(.about) { padding-bottom: 48px; scroll-margin-top: 16px; }
-main h2 {
+main h2,
+main .label {
   margin: 0 0 16px;
   margin: 0 0 16px;
   font: 600 13px/1 var(--mono);
   font: 600 13px/1 var(--mono);
   text-transform: uppercase;
   text-transform: uppercase;
   letter-spacing: 0.08em;
   letter-spacing: 0.08em;
   color: var(--muted);
   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 p, main section li { max-width: 68ch; }
 main section ul:not(.cards) { padding-left: 22px; }
 main section ul:not(.cards) { padding-left: 22px; }
 main section li { margin-bottom: 6px; }
 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); }
   .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 */
 /* Footer */
 .site-footer { border-top: 1px solid var(--border); }
 .site-footer { border-top: 1px solid var(--border); }
 .site-footer p { margin: 0; padding: 24px 0 32px; font-size: 14px; color: var(--muted); }
 .site-footer p { margin: 0; padding: 24px 0 32px; font-size: 14px; color: var(--muted); }