148 lines
3.5 KiB
Go
148 lines
3.5 KiB
Go
// 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
|
|
}
|