// 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 }