Realer Testfund auf 192.168.1.131: ein Kindprozess, der seinerseits (ohne exec) einen Enkelprozess startet, haelt nach SIGKILL des direkten Kindes dessen Ausgabe-Pipe weiter offen - cmd.Wait() blockiert dann trotz abgelaufenem Kontext unbegrenzt. cmd.WaitDelay (Go 1.20+) begrenzt das. Testfixture (hanging-soffice.sh) ebenfalls auf "exec sleep" korrigiert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
6.7 KiB
Go
189 lines
6.7 KiB
Go
package pdfconvert
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
|
|
)
|
|
|
|
const defaultTimeout = 60 * time.Second
|
|
|
|
// Generator konvertiert Office-Formate zu PDF ueber einen headless
|
|
// LibreOffice-Prozess. SofficePath und Timeout sind injizierbar (Tests,
|
|
// abweichende Installationspfade). Jeder Aufruf bekommt ein eigenes,
|
|
// frisches Profilverzeichnis (-env:UserInstallation) — ohne das teilen sich
|
|
// gleichzeitig laufende soffice-Prozesse eine Profil-Sperre und blockieren
|
|
// sich gegenseitig, was Pflichtpruefung 3 (Absturz eines Jobs darf andere
|
|
// nicht beeintraechtigen) verletzen wuerde.
|
|
type Generator struct {
|
|
Pool *pgxpool.Pool
|
|
Storage *storage.Service
|
|
Crypto *dmscrypto.Service
|
|
TenantSlug string
|
|
SofficePath string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
func (g *Generator) sofficeBinary() string {
|
|
if g.SofficePath != "" {
|
|
return g.SofficePath
|
|
}
|
|
return "soffice"
|
|
}
|
|
|
|
func (g *Generator) timeout() time.Duration {
|
|
if g.Timeout > 0 {
|
|
return g.Timeout
|
|
}
|
|
return defaultTimeout
|
|
}
|
|
|
|
// Handler ist der jobqueue.Handler fuer JobType — folgt demselben Muster
|
|
// wie thumbnail.Generator.Handler (DOC-04): dekodieren, "processing"
|
|
// markieren, konvertieren, Ergebnis oder Fehlerzustand speichern. Ein
|
|
// zurueckgegebener Fehler laesst FDN-04 die Job-Queue-Wiederholungslogik
|
|
// greifen; andere Jobs bleiben durch SKIP LOCKED in Queue.Dequeue
|
|
// unberuehrt (Pflichtpruefung 3).
|
|
func (g *Generator) Handler(ctx context.Context, job *jobqueue.Job) error {
|
|
var payload jobPayload
|
|
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
|
return fmt.Errorf("pdfconvert: auftrag dekodieren: %w", err)
|
|
}
|
|
|
|
if err := markProcessing(ctx, g.Pool, payload.RevisionID); err != nil {
|
|
return fmt.Errorf("pdfconvert: verarbeitung markieren: %w", err)
|
|
}
|
|
|
|
if err := g.process(ctx, payload); err != nil {
|
|
if markErr := markFailed(ctx, g.Pool, payload.RevisionID, err); markErr != nil {
|
|
return fmt.Errorf("pdfconvert: fehlschlag erfassen (ursprünglicher fehler: %v): %w", err, markErr)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *Generator) process(ctx context.Context, payload jobPayload) error {
|
|
var storageKey, mimeType string
|
|
var wrappedDEK []byte
|
|
if err := g.Pool.QueryRow(ctx, `
|
|
SELECT storage_key, mime_type, wrapped_dek FROM file_revisions WHERE id = $1
|
|
`, payload.RevisionID).Scan(&storageKey, &mimeType, &wrappedDEK); err != nil {
|
|
return fmt.Errorf("pdfconvert: revision lesen: %w", err)
|
|
}
|
|
|
|
format, ok := lookupFormat(mimeType)
|
|
if !ok {
|
|
return fmt.Errorf("pdfconvert: nicht unterstuetztes quellformat %q", mimeType)
|
|
}
|
|
|
|
cipherReader, err := g.Storage.Get(ctx, storageKey)
|
|
if err != nil {
|
|
return fmt.Errorf("pdfconvert: original aus storage laden: %w", err)
|
|
}
|
|
defer func() { _ = cipherReader.Close() }()
|
|
|
|
plainReader, err := g.Crypto.Open(ctx, g.TenantSlug, wrappedDEK, cipherReader)
|
|
if err != nil {
|
|
return fmt.Errorf("pdfconvert: original entschluesseln: %w", err)
|
|
}
|
|
plaintext, err := io.ReadAll(plainReader)
|
|
if err != nil {
|
|
return fmt.Errorf("pdfconvert: original lesen: %w", err)
|
|
}
|
|
|
|
pdfBytes, err := g.convertToPDF(ctx, plaintext, format)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
env, err := g.Crypto.Seal(ctx, g.TenantSlug, bytes.NewReader(pdfBytes))
|
|
if err != nil {
|
|
return fmt.Errorf("pdfconvert: verschluesseln: %w", err)
|
|
}
|
|
ciphertext, err := io.ReadAll(env.Ciphertext)
|
|
if err != nil {
|
|
return fmt.Errorf("pdfconvert: chiffretext lesen: %w", err)
|
|
}
|
|
|
|
pdfKey := storage.ObjectKey(payload.DocumentID, payload.RevisionID) + "/office.pdf"
|
|
if _, err := g.Storage.Put(ctx, pdfKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream"); err != nil {
|
|
return fmt.Errorf("pdfconvert: im storage ablegen: %w", err)
|
|
}
|
|
|
|
if err := markReady(ctx, g.Pool, payload.RevisionID, format.label, pdfKey, "application/pdf", env.WrappedDEK); err != nil {
|
|
return fmt.Errorf("pdfconvert: bereit-zustand speichern: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// convertToPDF fuehrt die eigentliche Konvertierung isoliert und mit
|
|
// Zeitlimit aus (Akzeptanzkriterium 2). Isolation: eigenes Temp- und
|
|
// Profilverzeichnis je Aufruf, kein geteilter Zustand mit anderen
|
|
// gleichzeitig laufenden Konvertierungen. Zeitlimit: context.WithTimeout
|
|
// plus exec.CommandContext — ueberschreitet der Prozess die Frist, killt
|
|
// Go den Prozess automatisch und liefert einen Fehler statt den Job/Worker
|
|
// zu blockieren.
|
|
func (g *Generator) convertToPDF(ctx context.Context, input []byte, format officeFormat) ([]byte, error) {
|
|
tmpDir, err := os.MkdirTemp("", "dms-pdfconvert-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pdfconvert: temporaeres verzeichnis anlegen: %w", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
inputPath := filepath.Join(tmpDir, "input."+format.extension)
|
|
if err := os.WriteFile(inputPath, input, 0o600); err != nil {
|
|
return nil, fmt.Errorf("pdfconvert: eingabedatei schreiben: %w", err)
|
|
}
|
|
outDir := filepath.Join(tmpDir, "out")
|
|
if err := os.Mkdir(outDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("pdfconvert: ausgabeverzeichnis anlegen: %w", err)
|
|
}
|
|
profileDir := filepath.Join(tmpDir, "profile")
|
|
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, g.timeout())
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(timeoutCtx, g.sofficeBinary(),
|
|
"--headless", "--norestore",
|
|
"-env:UserInstallation=file://"+profileDir,
|
|
"--convert-to", "pdf", "--outdir", outDir, inputPath,
|
|
)
|
|
// WaitDelay begrenzt eine bekannte exec.Cmd-Falle: stirbt der direkte
|
|
// Kindprozess, aber ein von ihm (ohne exec) gestarteter Enkelprozess
|
|
// haelt die Ausgabe-Pipe weiter offen, wartet Wait() sonst unbegrenzt
|
|
// auf deren Schliessung — trotz abgelaufenem Kontext. 5s zusaetzlich
|
|
// zum eigentlichen Zeitlimit reichen, um reguläre Prozessbeendigung
|
|
// abzuwarten, ohne das Zeitlimit selbst zu verwaessern.
|
|
cmd.WaitDelay = 5 * time.Second
|
|
output, runErr := cmd.CombinedOutput()
|
|
if timeoutCtx.Err() != nil {
|
|
return nil, fmt.Errorf("pdfconvert: zeitlimit (%s) ueberschritten: %w", g.timeout(), timeoutCtx.Err())
|
|
}
|
|
if runErr != nil {
|
|
return nil, fmt.Errorf("pdfconvert: soffice fehlgeschlagen: %w (ausgabe: %s)", runErr, string(output))
|
|
}
|
|
|
|
matches, err := filepath.Glob(filepath.Join(outDir, "*.pdf"))
|
|
if err != nil || len(matches) == 0 {
|
|
return nil, fmt.Errorf("pdfconvert: soffice lieferte keine pdf-ausgabedatei (matches=%v, err=%v, ausgabe: %s)", matches, err, string(output))
|
|
}
|
|
pdfBytes, err := os.ReadFile(matches[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pdfconvert: erzeugte pdf-datei lesen: %w", err)
|
|
}
|
|
return pdfBytes, nil
|
|
}
|