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." } }