// SPDX-License-Identifier: MIT package pdfexport import ( "bytes" gocontext "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "time" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" ) const ( defaultWorkerTimeout = 60 * time.Second maxWorkerStderrBytes = 256 * 1024 ) func runWorker(input workerInput) ([]byte, userError) { if !setting.PDF.Enabled { return nil, errBadRequest("ERR_PDF_DISABLED", "PDF export is disabled.") } jobDir, err := os.MkdirTemp("", "forgejo-pdfexport-job-*") if err != nil { return nil, errInternal("ERR_PDF_INTERNAL", "Failed to export PDF.") } defer func() { if err := os.RemoveAll(jobDir); err != nil { log.Error("pdfexport: remove job dir: %v", err) } }() inPath := filepath.Join(jobDir, "input.json") outPath := filepath.Join(jobDir, "output.pdf") inBytes, err := json.Marshal(input) if err != nil { return nil, errInternal("ERR_PDF_INTERNAL", "Failed to export PDF.") } if err := os.WriteFile(inPath, inBytes, 0o600); err != nil { return nil, errInternal("ERR_PDF_INTERNAL", "Failed to export PDF.") } runtime := setting.PDF.ContainerRuntime if runtime == "" { runtime = "podman" } image := setting.PDF.WorkerImage if image == "" { image = "localhost/forgejo/pdf-worker:v0.1" } ctx, cancel := gocontext.WithTimeout(gocontext.Background(), defaultWorkerTimeout) defer cancel() args := []string{ "run", "--rm", "--network=none", "--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--tmpfs", "/tmp:rw,noexec,nosuid,size=1024m", "--volume", fmt.Sprintf("%s:/job:rw", jobDir), "--memory", "2g", "--cpus", "2", image, "node", "src/index.js", "--in", "/job/input.json", "--out", "/job/output.pdf", } if strings.Contains(runtime, "podman") { // Proxmox/LXC deployments often confine AppArmor and can block the default container profile load. args = append(args[:6], append([]string{"--security-opt=apparmor=unconfined"}, args[6:]...)...) } cmd := exec.CommandContext(ctx, runtime, args...) cmd.Stdout = io.Discard var stderr bytes.Buffer cmd.Stderr = &limitedWriter{W: &stderr, N: maxWorkerStderrBytes} if err := cmd.Run(); err != nil { if errors.Is(ctx.Err(), gocontext.DeadlineExceeded) { return nil, errInternal("ERR_PDF_TIMEOUT", "PDF export timed out.") } // Best-effort parse of worker error envelope from stderr JSONL. if ue, ok := parseWorkerError(stderr.String()); ok { return nil, ue } log.Error("pdfexport: worker failed: %v", err) return nil, errInternal("ERR_PDF_WORKER_FAILED", "Failed to export PDF.") } pdf, err := os.ReadFile(outPath) if err != nil || len(pdf) == 0 { return nil, errInternal("ERR_PDF_WORKER_NO_OUTPUT", "Failed to export PDF.") } return pdf, userError{} } type limitedWriter struct { W io.Writer N int } func (w *limitedWriter) Write(p []byte) (int, error) { if w.N <= 0 { return len(p), nil } if len(p) > w.N { p = p[:w.N] } n, err := w.W.Write(p) w.N -= n return n, err } func parseWorkerError(stderr string) (userError, bool) { type workerErr struct { ErrorID string `json:"error_id"` Message string `json:"message"` } lines := strings.Split(stderr, "\n") for i := len(lines) - 1; i >= 0; i-- { line := strings.TrimSpace(lines[i]) if line == "" { continue } var we workerErr if err := json.Unmarshal([]byte(line), &we); err != nil { continue } if we.ErrorID == "" || we.Message == "" { continue } return errInternal(we.ErrorID, we.Message), true } return userError{}, false }