1
0

run.go 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  1. package web
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "os/exec"
  7. "strings"
  8. "time"
  9. )
  10. // Exit codes of the rekopub command, as documented in its README.
  11. const (
  12. exitFetch = 1
  13. exitNoContent = 3
  14. )
  15. // runResult is the outcome of one run of the rekopub command.
  16. type runResult struct {
  17. Path string // the saved book, from stdout
  18. Code int // exit code; -1 if the command didn't finish
  19. Err string // the command's last "error: " line, without the prefix
  20. }
  21. // runFunc runs the rekopub command with args. Tests replace it.
  22. type runFunc func(ctx context.Context, args []string) runResult
  23. // execRunner returns a runFunc that starts the command at cli. Each run is
  24. // its own process, so runs can't affect each other and a stuck one can be
  25. // killed. The command gets an empty environment: no proxy settings or
  26. // anything else from the server leak into it.
  27. func execRunner(cli string) runFunc {
  28. return func(ctx context.Context, args []string) runResult {
  29. cmd := exec.CommandContext(ctx, cli, args...)
  30. cmd.Env = []string{}
  31. cmd.WaitDelay = 5 * time.Second
  32. var stdout, stderr bytes.Buffer
  33. cmd.Stdout = &stdout
  34. cmd.Stderr = &stderr
  35. err := cmd.Run()
  36. res := runResult{Path: strings.TrimSpace(stdout.String()), Err: lastError(stderr.String())}
  37. var exit *exec.ExitError
  38. switch {
  39. case err == nil:
  40. case errors.As(err, &exit) && exit.Exited():
  41. res.Code = exit.ExitCode()
  42. default:
  43. res.Code = -1
  44. if res.Err == "" {
  45. res.Err = err.Error()
  46. }
  47. }
  48. return res
  49. }
  50. }
  51. // lastError returns the text of the last "error: " line in stderr.
  52. func lastError(stderr string) string {
  53. var msg string
  54. for line := range strings.Lines(stderr) {
  55. if rest, ok := strings.CutPrefix(line, "error: "); ok {
  56. msg = strings.TrimSpace(rest)
  57. }
  58. }
  59. return msg
  60. }
  61. // explain turns a failed run into a message for the visitor. Only fetch
  62. // errors pass on the command's own text, which describes the page (not
  63. // found, too large, not HTML and so on); the rest are fixed messages.
  64. func explain(ctx context.Context, res runResult) string {
  65. switch {
  66. case errors.Is(ctx.Err(), context.DeadlineExceeded):
  67. return "That page took too long to convert."
  68. case strings.Contains(res.Err, "local or private") || strings.Contains(res.Err, "-LOCAL"):
  69. return "That page leads to a local or private network address, which this site doesn't connect to."
  70. case res.Code == exitFetch && res.Err != "":
  71. msg := strings.TrimPrefix(res.Err, "couldn't fetch the page: ")
  72. if r := []rune(msg); len(r) > 300 {
  73. msg = string(r[:300]) + "…"
  74. }
  75. return "Couldn't download the page: " + msg
  76. case res.Code == exitFetch:
  77. return "Couldn't download the page."
  78. case res.Code == exitNoContent:
  79. return "No readable article was found on that page. Pages that build their content with JavaScript, paywalls and cookie walls can't be converted."
  80. default:
  81. return "Something went wrong while converting that page."
  82. }
  83. }