New endpoint GET /<path>/foo.md?convert=docx|html|pdf renders a markdown source on demand. Surfaced as the Download buttons in browse's markdown editor (separate commit). Execution model — two upstream container images, lazy-pulled: • docker.io/pandoc/latex:latest — MD→DOCX, MD→HTML (entrypoint pandoc) • docker.io/zenika/alpine-chrome — HTML→PDF (entrypoint chromium-browser) No custom image build. The runner passes --pull=missing on every podman/ docker invocation so the operator only needs the runtime installed — first request pulls the image, subsequent requests use the local cache. Overrides: --convert-pandoc-image / --convert-chromium-image (and the matching ZDDC_CONVERT_* env vars). Engine: --convert-engine (podman preferred, docker fallback). Resource caps: --convert-mem-mib (512), --convert-cpus (2), --convert-pids (100), --convert-timeout (30s). PDF flow is two-stage: pandoc renders the markdown through the embedded viewer-template.html to standalone HTML, then chromium prints that HTML via --print-to-pdf. Preserves the print-media CSS already authored in viewer-template.html rather than going through pandoc's LaTeX template. Each conversion runs in a throw-away container with --rm --network=none --read-only --tmpfs=/tmp --cap-drop=ALL --security-opt=no-new-privileges --env=HOME=/tmp plus a bind-mounted scratch dir for I/O. Pandoc reads markdown from stdin / writes to stdout; the viewer template lives at /tpl (ro). Chromium reads HTML from a read-write bind mount at /pdf and writes the PDF to the same mount; the host reads it back. No shell wrappers, no shell quoting — argv flows straight into each image's entrypoint. On-disk cache at <dir>/.converted/<base>.<ext> with mtime synced to the source. Fast path is a stat-and-serve with no exec; slow path singleflights concurrent requests for the same target. PUT/DELETE/MOVE on the source .md purges the .converted/ sidecars. Per-project template variables (client/project/contractor/project_number) come from a new .zddc `convert:` cascade block, walked leaf→root with per-key latest-wins. Filename-derived variables (title, tracking_number, revision, status, is_draft) come from a new zddc.ParseFilename helper. If neither podman nor docker is on PATH, the endpoint serves 503 with a clear Retry-After. The rest of the server keeps working. This is the first os/exec site in the codebase. The hardening in internal/convert/runner.go — context.CancelFunc → process kill, cmd.WaitDelay, platform-specific SysProcAttr (Setpgid + Pdeathsig on Linux), minimal env, stdout cap via limitWriter, stderr ring buffer — sets the pattern for any future shell-outs. Public surface: convert.ToDocx(ctx, source, meta) / .ToHTML / .ToPDF convert.Probe(ctx, engineOverride) → install Runner if engine present convert.SetImages(pandoc, chromium) convert.ConfigureLimits(memMiB, cpus, pids, timeout) convert.Available() Container handler at internal/handler/converthandler.go; dispatcher branch in cmd/zddc-server/main.go inserts the convert lookup after the existing ACL gate, reusing the source file's read policy verbatim.
95 lines
3.3 KiB
Go
95 lines
3.3 KiB
Go
package zddc
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// transmittalFolderRE matches the canonical ZDDC transmittal-folder shape:
|
|
//
|
|
// YYYY-MM-DD_<tracking> (<status>) - <title>
|
|
//
|
|
// where <tracking> has no spaces or underscores, <status> is anything inside
|
|
// parentheses, and <title> is anything after the dash. Capture groups:
|
|
//
|
|
// 1: date (YYYY-MM-DD)
|
|
// 2: tracking number (e.g. proj-EM-TRN-0042)
|
|
// 3: status (e.g. IFR, IFA, RSA, DFT)
|
|
// 4: title
|
|
var transmittalFolderRE = regexp.MustCompile(
|
|
`^(\d{4}-\d{2}-\d{2})_([^_\s]+(?:-[^_\s]+)*)\s*\(([^)]+)\)\s*-\s*(.+)$`,
|
|
)
|
|
|
|
// ParseTransmittalFolder splits a folder basename into its ZDDC transmittal
|
|
// components. The fourth return is true iff name is a well-formed transmittal
|
|
// folder. Trailing slashes on name are tolerated.
|
|
//
|
|
// Used by handlers that need to recognise a staging-folder mkdir as a
|
|
// transmittal envelope (to mirror it under working/), and by the archive
|
|
// indexer that scans for transmittal folders on disk.
|
|
func ParseTransmittalFolder(name string) (date, tracking, status, title string, ok bool) {
|
|
name = strings.TrimRight(name, "/")
|
|
m := transmittalFolderRE.FindStringSubmatch(name)
|
|
if m == nil {
|
|
return "", "", "", "", false
|
|
}
|
|
return m[1], m[2], m[3], m[4], true
|
|
}
|
|
|
|
// IsTrnOrSubTracking reports whether tracking contains a "-TRN-" or "-SUB-"
|
|
// segment (case-insensitive). These two tracking types are the ones whose
|
|
// staging folders get a paired drafting folder under working/.
|
|
//
|
|
// "-MDL-" and other tracking types do NOT match — MDL deliverables are
|
|
// tracked via per-party mdl/ rows, not via the working↔staging mirror.
|
|
func IsTrnOrSubTracking(tracking string) bool {
|
|
upper := strings.ToUpper(tracking)
|
|
return strings.Contains(upper, "-TRN-") || strings.Contains(upper, "-SUB-")
|
|
}
|
|
|
|
// documentFilenameRE matches the canonical ZDDC document-filename shape:
|
|
//
|
|
// <tracking>_<revision> (<status>) - <title>.<ext>
|
|
//
|
|
// where <tracking> has no spaces or underscores in the tracking part,
|
|
// <revision> is anything without a space, <status> is anything inside
|
|
// parentheses, and <title> is anything after the dash up to the
|
|
// last "." before the extension.
|
|
//
|
|
// Mirror of the JS parser in shared/zddc.js — kept here for the
|
|
// conversion handler which needs to feed title/tracking/revision/
|
|
// status to pandoc as template variables.
|
|
var documentFilenameRE = regexp.MustCompile(
|
|
`^([^_]+)_(\S+)\s*\(([^)]+)\)\s*-\s*(.+)\.([^.]+)$`,
|
|
)
|
|
|
|
// ParsedFilename is the result of ParseFilename: tracking number,
|
|
// revision, status, title (everything before the extension), and the
|
|
// lowercased extension. Valid is true iff the filename matched the
|
|
// canonical pattern.
|
|
type ParsedFilename struct {
|
|
TrackingNumber string
|
|
Revision string
|
|
Status string
|
|
Title string
|
|
Extension string
|
|
Valid bool
|
|
}
|
|
|
|
// ParseFilename splits a document filename into its ZDDC components.
|
|
// Returns Valid=false if the filename doesn't match the canonical shape;
|
|
// callers can fall back to stem-based metadata in that case.
|
|
func ParseFilename(name string) ParsedFilename {
|
|
m := documentFilenameRE.FindStringSubmatch(name)
|
|
if m == nil {
|
|
return ParsedFilename{}
|
|
}
|
|
return ParsedFilename{
|
|
TrackingNumber: m[1],
|
|
Revision: m[2],
|
|
Status: m[3],
|
|
Title: m[4],
|
|
Extension: strings.ToLower(m[5]),
|
|
Valid: true,
|
|
}
|
|
}
|