1
0

server.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. // Package web is the server behind the converter form on
  2. // rekopub.monoexp.com. It checks the address a visitor enters, runs the
  3. // rekopub command on it and serves the finished books for a limited time.
  4. //
  5. // The site is open to everyone, so conversions are limited: in total and
  6. // per visitor within a time window, by how many run at once, and by how much
  7. // disk the kept books may use. nginx serves the rest of the site and passes
  8. // /api/ and /books/ to this server.
  9. package web
  10. import (
  11. "context"
  12. "crypto/rand"
  13. "embed"
  14. "encoding/hex"
  15. "encoding/json"
  16. "errors"
  17. "fmt"
  18. "html/template"
  19. "io/fs"
  20. "log"
  21. "net"
  22. "net/http"
  23. "net/netip"
  24. "net/url"
  25. "os"
  26. "path/filepath"
  27. "regexp"
  28. "strings"
  29. "sync"
  30. "sync/atomic"
  31. "time"
  32. "unicode"
  33. "git.monoexp.com/bajdev/rekopub/internal/fetch"
  34. )
  35. // Config holds the server's settings. New fills in defaults for zero values
  36. // where noted.
  37. type Config struct {
  38. CLI string // path of the rekopub command (required)
  39. Dir string // books are kept here, one folder per conversion (required)
  40. Keep time.Duration // how long books are kept (24h)
  41. Limit int // conversions allowed for all visitors together... (50)
  42. Window time.Duration // ...and per visitor, within this period (15m)
  43. PerVisitor int // conversions allowed per visitor (10)
  44. MaxRunning int // conversions running at the same time (2)
  45. MaxWaiting int // conversions waiting for a free slot (10)
  46. Wait time.Duration // how long a conversion may wait for a slot (60s)
  47. RunTimeout time.Duration // how long one run of the command may take (2m)
  48. MaxStorage int64 // bytes of kept books before conversions pause (2 GB)
  49. }
  50. // Formats a visitor can choose, and the command's flags for each book.
  51. var formats = map[string][]struct {
  52. name string
  53. kepub bool
  54. }{
  55. "epub": {{"EPUB", false}},
  56. "kepub": {{"KEPUB", true}},
  57. "both": {{"EPUB", false}, {"KEPUB", true}},
  58. }
  59. const (
  60. maxURLLen = 2000
  61. maxBodyLen = 8 << 10
  62. )
  63. var (
  64. idPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
  65. bookPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*(\.kepub)?\.epub$`)
  66. )
  67. // Server handles the converter's requests.
  68. type Server struct {
  69. cfg Config
  70. root *os.Root
  71. run runFunc
  72. now func() time.Time
  73. isLocal func(ctx context.Context, host string) bool
  74. mu sync.Mutex
  75. all window
  76. visitor map[string]*window
  77. slots chan struct{}
  78. waiting atomic.Int32
  79. stored atomic.Int64 // bytes of kept books
  80. }
  81. // New returns a server for cfg. It creates cfg.Dir if needed.
  82. func New(cfg Config) (*Server, error) {
  83. if cfg.CLI == "" || cfg.Dir == "" {
  84. return nil, errors.New("web: CLI and Dir are required")
  85. }
  86. def := func(v *time.Duration, d time.Duration) {
  87. if *v <= 0 {
  88. *v = d
  89. }
  90. }
  91. defInt := func(v *int, d int) {
  92. if *v <= 0 {
  93. *v = d
  94. }
  95. }
  96. def(&cfg.Keep, 24*time.Hour)
  97. def(&cfg.Window, 15*time.Minute)
  98. def(&cfg.Wait, time.Minute)
  99. def(&cfg.RunTimeout, 2*time.Minute)
  100. defInt(&cfg.Limit, 50)
  101. defInt(&cfg.PerVisitor, 10)
  102. defInt(&cfg.MaxRunning, 2)
  103. defInt(&cfg.MaxWaiting, 10)
  104. if cfg.MaxStorage <= 0 {
  105. cfg.MaxStorage = 2 << 30
  106. }
  107. // The command prints the absolute path of each book it saves.
  108. dir, err := filepath.Abs(cfg.Dir)
  109. if err != nil {
  110. return nil, err
  111. }
  112. cfg.Dir = dir
  113. if err := os.MkdirAll(cfg.Dir, 0o750); err != nil {
  114. return nil, err
  115. }
  116. root, err := os.OpenRoot(cfg.Dir)
  117. if err != nil {
  118. return nil, err
  119. }
  120. return &Server{
  121. cfg: cfg,
  122. root: root,
  123. run: execRunner(cfg.CLI),
  124. now: time.Now,
  125. isLocal: fetch.IsLocal,
  126. visitor: map[string]*window{},
  127. slots: make(chan struct{}, cfg.MaxRunning),
  128. }, nil
  129. }
  130. // Handler returns the server's routes. Cross-origin form posts are refused,
  131. // so other sites can't use the converter through their visitors' browsers.
  132. func (s *Server) Handler() http.Handler {
  133. mux := http.NewServeMux()
  134. mux.HandleFunc("GET /api/status", s.status)
  135. mux.HandleFunc("POST /api/convert", s.convert)
  136. mux.HandleFunc("GET /books/{id}/{name}", s.book)
  137. return http.NewCrossOriginProtection().Handler(headers(mux))
  138. }
  139. // headers sets security headers on every response.
  140. func headers(next http.Handler) http.Handler {
  141. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  142. h := w.Header()
  143. h.Set("X-Content-Type-Options", "nosniff")
  144. h.Set("Referrer-Policy", "no-referrer")
  145. h.Set("X-Robots-Tag", "noindex")
  146. h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
  147. next.ServeHTTP(w, r)
  148. })
  149. }
  150. // file is a finished book in a reply.
  151. type file struct {
  152. Format string `json:"format"`
  153. Name string `json:"name"`
  154. URL string `json:"url"`
  155. Size int64 `json:"size"`
  156. }
  157. // reply is the answer to a status or convert request.
  158. type reply struct {
  159. Title string `json:"-"` // heading of the HTML page, if not the default
  160. Paused bool `json:"paused,omitempty"`
  161. Error string `json:"error,omitempty"`
  162. Warning string `json:"warning,omitempty"`
  163. Files []file `json:"files,omitempty"`
  164. KeepHours int `json:"keepHours"`
  165. }
  166. func (s *Server) keepHours() int { return int(s.cfg.Keep.Hours()) }
  167. // status tells the page whether the form can be used right now.
  168. func (s *Server) status(w http.ResponseWriter, r *http.Request) {
  169. rep := reply{KeepHours: s.keepHours()}
  170. if msg := s.pausedFor(visitorKey(r)); msg != "" {
  171. rep.Paused, rep.Error = true, msg
  172. }
  173. w.Header().Set("Cache-Control", "no-store")
  174. writeJSON(w, http.StatusOK, rep)
  175. }
  176. // convert checks the form, waits for a free slot and runs the command once
  177. // per chosen format.
  178. func (s *Server) convert(w http.ResponseWriter, r *http.Request) {
  179. r.Body = http.MaxBytesReader(w, r.Body, maxBodyLen)
  180. if err := r.ParseForm(); err != nil {
  181. s.respond(w, r, http.StatusBadRequest, reply{Error: "The form couldn't be read."})
  182. return
  183. }
  184. format := r.PostForm.Get("format")
  185. if format == "" {
  186. format = "epub"
  187. }
  188. books, ok := formats[format]
  189. if !ok {
  190. s.respond(w, r, http.StatusBadRequest, reply{Error: "Choose EPUB, KEPUB or both."})
  191. return
  192. }
  193. u, msg := checkURL(r.PostForm.Get("url"))
  194. if msg != "" {
  195. s.respond(w, r, http.StatusBadRequest, reply{Error: msg})
  196. return
  197. }
  198. // The command refuses local addresses anyway (it's run without
  199. // -LOCAL); checking here too gives a clear message and doesn't use up
  200. // a conversion.
  201. if s.isLocal(r.Context(), u.Hostname()) {
  202. s.respond(w, r, http.StatusBadRequest, reply{Error: "That address is on a local or private network, which this site doesn't connect to."})
  203. return
  204. }
  205. key := visitorKey(r)
  206. undo, msg := s.reserve(key)
  207. if msg != "" {
  208. log.Printf("limited %s: %s", key, msg)
  209. s.respond(w, r, http.StatusTooManyRequests, reply{Paused: true, Error: msg})
  210. return
  211. }
  212. release, msg := s.acquire(r.Context())
  213. if msg != "" {
  214. undo()
  215. s.respond(w, r, http.StatusServiceUnavailable, reply{Error: msg})
  216. return
  217. }
  218. defer release()
  219. start := s.now()
  220. rep, code := s.convertAll(r.Context(), u, books)
  221. if rep.Error != "" {
  222. log.Printf("convert %s %s: failed in %s: %s", u.Hostname(), format, s.now().Sub(start).Round(time.Millisecond), rep.Error)
  223. } else {
  224. log.Printf("convert %s %s: ok in %s", u.Hostname(), format, s.now().Sub(start).Round(time.Millisecond))
  225. }
  226. s.respond(w, r, code, rep)
  227. }
  228. // convertAll converts u into each of books, in a new folder.
  229. func (s *Server) convertAll(ctx context.Context, u *url.URL, books []struct {
  230. name string
  231. kepub bool
  232. }) (reply, int) {
  233. rep := reply{KeepHours: s.keepHours()}
  234. id := newID()
  235. dir := filepath.Join(s.cfg.Dir, id)
  236. if err := s.root.Mkdir(id, 0o750); err != nil {
  237. log.Printf("creating %s: %v", dir, err)
  238. rep.Error = "Something went wrong while converting that page."
  239. return rep, http.StatusInternalServerError
  240. }
  241. for _, b := range books {
  242. f, msg := s.runOnce(ctx, u, dir, b.kepub)
  243. if msg != "" {
  244. if len(rep.Files) > 0 {
  245. // The first book is fine; offer it and say what's missing.
  246. rep.Warning = fmt.Sprintf("The %s couldn't be made: %s", b.name, msg)
  247. break
  248. }
  249. s.root.RemoveAll(id)
  250. rep.Error = msg
  251. return rep, http.StatusUnprocessableEntity
  252. }
  253. f.Format = b.name
  254. f.URL = "/books/" + id + "/" + f.Name
  255. rep.Files = append(rep.Files, f)
  256. s.stored.Add(f.Size)
  257. }
  258. return rep, http.StatusOK
  259. }
  260. // runOnce runs the command for one book and checks what it wrote.
  261. func (s *Server) runOnce(ctx context.Context, u *url.URL, dir string, kepub bool) (file, string) {
  262. ctx, cancel := context.WithTimeout(ctx, s.cfg.RunTimeout)
  263. defer cancel()
  264. // "-FLAG=value" keeps a value from ever being read as a flag.
  265. // Never -LOCAL: visitors mustn't reach the server's own network.
  266. args := []string{"-URL=" + u.String(), "-SAVE=" + dir + string(filepath.Separator)}
  267. if kepub {
  268. args = append(args, "-KEPUB")
  269. }
  270. res := s.run(ctx, args)
  271. if res.Code != 0 {
  272. return file{}, explain(ctx, res)
  273. }
  274. name := filepath.Base(res.Path)
  275. if filepath.Dir(res.Path) != dir || !bookPattern.MatchString(name) {
  276. log.Printf("unexpected output path %q", res.Path)
  277. return file{}, "Something went wrong while converting that page."
  278. }
  279. info, err := os.Stat(res.Path)
  280. if err != nil {
  281. log.Printf("checking output: %v", err)
  282. return file{}, "Something went wrong while converting that page."
  283. }
  284. return file{Name: name, Size: info.Size()}, ""
  285. }
  286. // reserve counts a conversion against the limits, or says why it can't
  287. // start. undo takes the conversion back if it never runs.
  288. func (s *Server) reserve(key string) (undo func(), msg string) {
  289. s.mu.Lock()
  290. defer s.mu.Unlock()
  291. now := s.now()
  292. if msg := s.pausedLocked(key, now); msg != "" {
  293. return nil, msg
  294. }
  295. v := s.visitor[key]
  296. if v == nil {
  297. v = &window{}
  298. s.visitor[key] = v
  299. }
  300. s.all = append(s.all, now)
  301. *v = append(*v, now)
  302. return func() {
  303. s.mu.Lock()
  304. defer s.mu.Unlock()
  305. s.all.remove(now)
  306. v.remove(now)
  307. }, ""
  308. }
  309. // pausedFor returns why a visitor can't convert right now, or "".
  310. func (s *Server) pausedFor(key string) string {
  311. s.mu.Lock()
  312. defer s.mu.Unlock()
  313. return s.pausedLocked(key, s.now())
  314. }
  315. func (s *Server) pausedLocked(key string, now time.Time) string {
  316. if s.stored.Load() >= s.cfg.MaxStorage {
  317. return "Conversions are temporarily paused. Please try again in an hour."
  318. }
  319. s.all.prune(now, s.cfg.Window)
  320. if full, until := s.all.full(now, s.cfg.Limit, s.cfg.Window); full {
  321. return "Conversions are temporarily paused. Please try again in " + minutes(until.Sub(now)) + "."
  322. }
  323. if v := s.visitor[key]; v != nil {
  324. v.prune(now, s.cfg.Window)
  325. if full, until := v.full(now, s.cfg.PerVisitor, s.cfg.Window); full {
  326. return fmt.Sprintf("You've made %d conversions in the last %s. Please try again in %s.",
  327. s.cfg.PerVisitor, minutes(s.cfg.Window), minutes(until.Sub(now)))
  328. }
  329. }
  330. return ""
  331. }
  332. // acquire waits for a free slot. It gives up when too many conversions are
  333. // already waiting, after cfg.Wait, or when the visitor leaves.
  334. func (s *Server) acquire(ctx context.Context) (release func(), msg string) {
  335. const busy = "The converter is busy. Please try again in a minute."
  336. if s.waiting.Add(1) > int32(s.cfg.MaxWaiting) {
  337. s.waiting.Add(-1)
  338. return nil, busy
  339. }
  340. defer s.waiting.Add(-1)
  341. timer := time.NewTimer(s.cfg.Wait)
  342. defer timer.Stop()
  343. select {
  344. case s.slots <- struct{}{}:
  345. return func() { <-s.slots }, ""
  346. case <-timer.C:
  347. return nil, busy
  348. case <-ctx.Done():
  349. return nil, busy
  350. }
  351. }
  352. // book serves a finished book as a download.
  353. func (s *Server) book(w http.ResponseWriter, r *http.Request) {
  354. id, name := r.PathValue("id"), r.PathValue("name")
  355. gone := func() {
  356. s.respond(w, r, http.StatusNotFound, reply{Title: "Link expired", Error: fmt.Sprintf(
  357. "This download link has expired or doesn't exist. Links work for %d hours after a conversion.", s.keepHours())})
  358. }
  359. if !idPattern.MatchString(id) || !bookPattern.MatchString(name) {
  360. gone()
  361. return
  362. }
  363. // os.Root keeps the open inside cfg.Dir, whatever the path holds.
  364. f, err := s.root.Open(id + "/" + name)
  365. if err != nil {
  366. gone()
  367. return
  368. }
  369. defer f.Close()
  370. info, err := f.Stat()
  371. if err != nil || !info.Mode().IsRegular() || s.now().Sub(info.ModTime()) >= s.cfg.Keep {
  372. gone()
  373. return
  374. }
  375. h := w.Header()
  376. h.Set("Content-Type", "application/epub+zip")
  377. h.Set("Content-Disposition", `attachment; filename="`+name+`"`)
  378. h.Set("Cache-Control", "private, max-age=3600")
  379. http.ServeContent(w, r, name, info.ModTime(), f)
  380. }
  381. // Clean deletes books older than cfg.Keep, adds up what's left and forgets
  382. // visitors with no recent conversions. Run it at start-up and then
  383. // regularly.
  384. func (s *Server) Clean() {
  385. now := s.now()
  386. entries, err := fs.ReadDir(s.root.FS(), ".")
  387. if err != nil {
  388. log.Printf("clean: %v", err)
  389. return
  390. }
  391. var total int64
  392. removed := 0
  393. for _, e := range entries {
  394. info, err := e.Info()
  395. if err != nil || !e.IsDir() || !idPattern.MatchString(e.Name()) {
  396. continue
  397. }
  398. if now.Sub(info.ModTime()) >= s.cfg.Keep {
  399. if err := s.root.RemoveAll(e.Name()); err != nil {
  400. log.Printf("clean: %v", err)
  401. } else {
  402. removed++
  403. }
  404. continue
  405. }
  406. fs.WalkDir(s.root.FS(), e.Name(), func(_ string, d fs.DirEntry, err error) error {
  407. if err == nil && d.Type().IsRegular() {
  408. if fi, err := d.Info(); err == nil {
  409. total += fi.Size()
  410. }
  411. }
  412. return nil
  413. })
  414. }
  415. s.stored.Store(total)
  416. if removed > 0 {
  417. log.Printf("clean: removed %d expired conversions; %d MB kept", removed, total>>20)
  418. }
  419. s.mu.Lock()
  420. defer s.mu.Unlock()
  421. for key, v := range s.visitor {
  422. if v.prune(now, s.cfg.Window); len(*v) == 0 {
  423. delete(s.visitor, key)
  424. }
  425. }
  426. }
  427. // CleanEvery runs Clean every interval until ctx ends.
  428. func (s *Server) CleanEvery(ctx context.Context, interval time.Duration) {
  429. s.Clean()
  430. t := time.NewTicker(interval)
  431. defer t.Stop()
  432. for {
  433. select {
  434. case <-ctx.Done():
  435. return
  436. case <-t.C:
  437. s.Clean()
  438. }
  439. }
  440. }
  441. // checkURL checks a visitor's address, returning it or a message saying
  442. // what's wrong.
  443. func checkURL(raw string) (*url.URL, string) {
  444. raw = strings.TrimSpace(raw)
  445. switch {
  446. case raw == "":
  447. return nil, "Enter the address of a web page."
  448. case len(raw) > maxURLLen:
  449. return nil, "That address is too long."
  450. case strings.ContainsFunc(raw, unicode.IsControl):
  451. return nil, "That isn't a valid web address."
  452. }
  453. u, err := url.Parse(raw)
  454. if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Opaque != "" {
  455. return nil, "Enter a full web address starting with http:// or https://."
  456. }
  457. if u.User != nil {
  458. return nil, "Addresses with a user name or password can't be converted."
  459. }
  460. // Other ports would let visitors use the server to probe other
  461. // people's machines.
  462. if p := u.Port(); p != "" && p != "80" && p != "443" {
  463. return nil, "Only addresses on the standard web ports can be converted."
  464. }
  465. u.Fragment, u.RawFragment = "", ""
  466. return u, ""
  467. }
  468. // visitorKey identifies a visitor for the per-visitor limit: their IPv4
  469. // address, or the /64 network of an IPv6 address, since one IPv6
  470. // connection usually has a whole /64 to choose from. The server listens on
  471. // 127.0.0.1 behind nginx, which sets X-Real-IP; any value the visitor sent
  472. // is replaced.
  473. func visitorKey(r *http.Request) string {
  474. raw := r.Header.Get("X-Real-IP")
  475. if raw == "" {
  476. raw, _, _ = net.SplitHostPort(r.RemoteAddr)
  477. }
  478. ip, err := netip.ParseAddr(raw)
  479. if err != nil {
  480. return raw
  481. }
  482. ip = ip.Unmap()
  483. if ip.Is6() {
  484. p, _ := ip.Prefix(64)
  485. return p.String()
  486. }
  487. return ip.String()
  488. }
  489. func newID() string {
  490. b := make([]byte, 16)
  491. rand.Read(b)
  492. return hex.EncodeToString(b)
  493. }
  494. // respond answers in JSON for the page's script and as a page for a
  495. // browser without JavaScript.
  496. func (s *Server) respond(w http.ResponseWriter, r *http.Request, code int, rep reply) {
  497. rep.KeepHours = s.keepHours()
  498. if strings.Contains(r.Header.Get("Accept"), "application/json") {
  499. writeJSON(w, code, rep)
  500. return
  501. }
  502. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  503. w.WriteHeader(code)
  504. if err := pages.ExecuteTemplate(w, "result.html", rep); err != nil {
  505. log.Printf("result page: %v", err)
  506. }
  507. }
  508. func writeJSON(w http.ResponseWriter, code int, v any) {
  509. w.Header().Set("Content-Type", "application/json")
  510. w.WriteHeader(code)
  511. json.NewEncoder(w).Encode(v)
  512. }
  513. //go:embed templates
  514. var templateFS embed.FS
  515. var pages = template.Must(template.New("").Funcs(template.FuncMap{
  516. "kb": func(n int64) string { return fmt.Sprintf("%d KB", (n+1023)/1024) },
  517. }).ParseFS(templateFS, "templates/*.html"))