From 3de6932279d8f2fcc57b80032edbe8a566f502e5 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Fri, 14 Aug 2026 16:36:40 +0200 Subject: [PATCH] feat(pdfengines): apply multiple stamps and watermarks on every route --- pkg/modules/api/formdata.go | 15 + pkg/modules/api/formdata_test.go | 15 + pkg/modules/chromium/routes.go | 78 +++-- pkg/modules/libreoffice/routes.go | 28 +- pkg/modules/pdfengines/routes.go | 329 +++++++----------- pkg/modules/pdfengines/stamp_test.go | 23 ++ .../features/pdfengines_stamp.feature | 29 +- .../features/pdfengines_watermark.feature | 33 ++ 8 files changed, 302 insertions(+), 248 deletions(-) diff --git a/pkg/modules/api/formdata.go b/pkg/modules/api/formdata.go index ead61d20..47906cbf 100644 --- a/pkg/modules/api/formdata.go +++ b/pkg/modules/api/formdata.go @@ -494,6 +494,21 @@ func (form *FormData) Stamps(target *[]string) *FormData { return form } +// Watermarks binds the absolute paths of every file uploaded with the +// "watermark" field name, in submission order. Unlike [FormData.Watermark], it +// keeps all of them so a route can apply several watermarks in a single request. +func (form *FormData) Watermarks(target *[]string) *FormData { + if form.errors != nil { + return form + } + + if paths, ok := form.filesByField[WatermarkFormField]; ok { + *target = paths + } + + return form +} + // Strings binds every value submitted for key, in submission order. A field // repeated in the multipart body (e.g. multiple "stampSource") contributes one // entry per occurrence, which lets a route read parallel field arrays. diff --git a/pkg/modules/api/formdata_test.go b/pkg/modules/api/formdata_test.go index c74fdcd1..de6e2a37 100644 --- a/pkg/modules/api/formdata_test.go +++ b/pkg/modules/api/formdata_test.go @@ -1880,3 +1880,18 @@ func TestFormData_Stamps(t *testing.T) { t.Errorf("expected nil when no stamp file was uploaded, got %+v", none) } } + +func TestFormData_Watermarks(t *testing.T) { + form := &FormData{ + filesByField: map[string][]string{ + WatermarkFormField: {"/tmp/abc/a.png"}, + }, + } + + var got []string + form.Watermarks(&got) + + if want := []string{"/tmp/abc/a.png"}; !reflect.DeepEqual(got, want) { + t.Errorf("expected %+v, got %+v", want, got) + } +} diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index d6cc88f9..92a21fc5 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -474,10 +474,16 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { metadata := pdfengines.FormDataPdfMetadata(form, false) encrypt := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) - watermark := pdfengines.FormDataPdfWatermark(form, false) - watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) - stamp := pdfengines.FormDataPdfStamp(form, false) - stampFile := pdfengines.FormDataPdfStampFile(form) + watermarks, wErr := pdfengines.FormDataPdfWatermarks(form) + if wErr != nil { + return fmt.Errorf("form data watermarks: %w", wErr) + } + stamps, sErr := pdfengines.FormDataPdfStamps(form) + if sErr != nil { + return fmt.Errorf("form data stamps: %w", sErr) + } + var watermarkFiles, stampFiles []string + form.Watermarks(&watermarkFiles).Stamps(&stampFiles) rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false) optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form) embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form) @@ -496,16 +502,16 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("reject URL scheme: %w", err) } - err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile) + err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles) if err != nil { - return fmt.Errorf("validate watermark: %w", err) + return fmt.Errorf("bind watermark files: %w", err) } - err = pdfengines.EnsureStampFile(&stamp, stampFile) + err = pdfengines.BindStampFiles(stamps, stampFiles) if err != nil { - return fmt.Errorf("validate stamp: %w", err) + return fmt.Errorf("bind stamp files: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality) if err != nil { return fmt.Errorf("convert URL to PDF: %w", err) } @@ -564,10 +570,16 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { metadata := pdfengines.FormDataPdfMetadata(form, false) encrypt := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) - watermark := pdfengines.FormDataPdfWatermark(form, false) - watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) - stamp := pdfengines.FormDataPdfStamp(form, false) - stampFile := pdfengines.FormDataPdfStampFile(form) + watermarks, wErr := pdfengines.FormDataPdfWatermarks(form) + if wErr != nil { + return fmt.Errorf("form data watermarks: %w", wErr) + } + stamps, sErr := pdfengines.FormDataPdfStamps(form) + if sErr != nil { + return fmt.Errorf("form data stamps: %w", sErr) + } + var watermarkFiles, stampFiles []string + form.Watermarks(&watermarkFiles).Stamps(&stampFiles) rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false) optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form) embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form) @@ -581,18 +593,18 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile) + err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles) if err != nil { - return fmt.Errorf("validate watermark: %w", err) + return fmt.Errorf("bind watermark files: %w", err) } - err = pdfengines.EnsureStampFile(&stamp, stampFile) + err = pdfengines.BindStampFiles(stamps, stampFiles) if err != nil { - return fmt.Errorf("validate stamp: %w", err) + return fmt.Errorf("bind stamp files: %w", err) } url := fmt.Sprintf("file://%s", inputPath) options.AllowedFilePrefixes = []string{ctx.DirPath()} - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) } @@ -648,10 +660,16 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { metadata := pdfengines.FormDataPdfMetadata(form, false) encrypt := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) - watermark := pdfengines.FormDataPdfWatermark(form, false) - watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) - stamp := pdfengines.FormDataPdfStamp(form, false) - stampFile := pdfengines.FormDataPdfStampFile(form) + watermarks, wErr := pdfengines.FormDataPdfWatermarks(form) + if wErr != nil { + return fmt.Errorf("form data watermarks: %w", wErr) + } + stamps, sErr := pdfengines.FormDataPdfStamps(form) + if sErr != nil { + return fmt.Errorf("form data stamps: %w", sErr) + } + var watermarkFiles, stampFiles []string + form.Watermarks(&watermarkFiles).Stamps(&stampFiles) rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false) optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form) embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form) @@ -670,13 +688,13 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile) + err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles) if err != nil { - return fmt.Errorf("validate watermark: %w", err) + return fmt.Errorf("bind watermark files: %w", err) } - err = pdfengines.EnsureStampFile(&stamp, stampFile) + err = pdfengines.BindStampFiles(stamps, stampFiles) if err != nil { - return fmt.Errorf("validate stamp: %w", err) + return fmt.Errorf("bind stamp files: %w", err) } url, err := markdownToHtml(ctx, inputPath, markdownPaths) @@ -685,7 +703,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } options.AllowedFilePrefixes = []string{ctx.DirPath()} - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) } @@ -810,7 +828,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) return fmt.Sprintf("file://%s", inputPath), nil } -func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string, optimizeImages bool, imageQuality int) error { +func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermarks, stamps []gotenberg.Stamp, rotateAngle int, rotatePages string, optimizeImages bool, imageQuality int) error { outputPath := ctx.GeneratePath(".pdf") // See https://github.com/gotenberg/gotenberg/issues/1130. filename := ctx.OutputFilename(outputPath) @@ -892,12 +910,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return fmt.Errorf("split PDF: %w", err) } - err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths) + err = pdfengines.WatermarkStub(ctx, engine, watermarks, outputPaths) if err != nil { return fmt.Errorf("watermark PDFs: %w", err) } - err = pdfengines.StampStub(ctx, engine, stamp, outputPaths) + err = pdfengines.StampStub(ctx, engine, stamps, outputPaths) if err != nil { return fmt.Errorf("stamp PDFs: %w", err) } diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index c3386f9d..0ed5f9c7 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -37,10 +37,16 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap metadata := pdfengines.FormDataPdfMetadata(form, false) encrypt := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) - watermark := pdfengines.FormDataPdfWatermark(form, false) - watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) - stamp := pdfengines.FormDataPdfStamp(form, false) - stampFile := pdfengines.FormDataPdfStampFile(form) + watermarks, wErr := pdfengines.FormDataPdfWatermarks(form) + if wErr != nil { + return fmt.Errorf("form data watermarks: %w", wErr) + } + stamps, sErr := pdfengines.FormDataPdfStamps(form) + if sErr != nil { + return fmt.Errorf("form data stamps: %w", sErr) + } + var watermarkFiles, stampFiles []string + form.Watermarks(&watermarkFiles).Stamps(&stampFiles) angle, rotatePages := pdfengines.FormDataPdfRotate(form, false) embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form) facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form) @@ -311,13 +317,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap return fmt.Errorf("validate form data: %w", err) } - err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile) + err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles) if err != nil { - return fmt.Errorf("validate watermark: %w", err) + return fmt.Errorf("bind watermark files: %w", err) } - err = pdfengines.EnsureStampFile(&stamp, stampFile) + err = pdfengines.BindStampFiles(stamps, stampFiles) if err != nil { - return fmt.Errorf("validate stamp: %w", err) + return fmt.Errorf("bind stamp files: %w", err) } err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths) @@ -339,7 +345,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap // requested. The conversion runs as a post-processing step below. pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil) - hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 || + hasPostProcessing := len(watermarks) > 0 || len(stamps) > 0 || angle != 0 || len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != "" outputPaths := make([]string, len(inputPaths)) @@ -494,12 +500,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap } } - err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths) + err = pdfengines.WatermarkStub(ctx, engine, watermarks, outputPaths) if err != nil { return fmt.Errorf("watermark PDFs: %w", err) } - err = pdfengines.StampStub(ctx, engine, stamp, outputPaths) + err = pdfengines.StampStub(ctx, engine, stamps, outputPaths) if err != nil { return fmt.Errorf("stamp PDFs: %w", err) } diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 2544ee02..f6460bf7 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -829,125 +829,33 @@ func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []s return nil } -// FormDataPdfWatermark creates a [gotenberg.Stamp] for watermarking from the -// form data. -func FormDataPdfWatermark(form *api.FormData, mandatory bool) gotenberg.Stamp { - return formDataPdfStampOrWatermark(form, "watermark", mandatory) -} - -// FormDataPdfStamp creates a [gotenberg.Stamp] for stamping from the form data. -func FormDataPdfStamp(form *api.FormData, mandatory bool) gotenberg.Stamp { - return formDataPdfStampOrWatermark(form, "stamp", mandatory) -} - -func formDataPdfStampOrWatermark(form *api.FormData, prefix string, mandatory bool) gotenberg.Stamp { - var ( - source string - expression string - pages string - options map[string]string - ) - - sourceFunc := func(value string) error { - if value != "" && value != gotenberg.StampSourceText && value != gotenberg.StampSourceImage && value != gotenberg.StampSourcePDF { - return fmt.Errorf("wrong value, expected either '%s', '%s' or '%s'", gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF) - } - source = value - return nil - } - - optionsFunc := func(value string) error { - if value == "" { - return nil - } - err := json.Unmarshal([]byte(value), &options) - if err != nil { - return fmt.Errorf("unmarshal %s options: %w", prefix, err) - } - return nil - } - - if mandatory { - form. - MandatoryCustom(prefix+"Source", func(value string) error { - return sourceFunc(value) - }). - String(prefix+"Expression", &expression, ""). - String(prefix+"Pages", &pages, ""). - Custom(prefix+"Options", func(value string) error { - return optionsFunc(value) - }) - } else { - form. - Custom(prefix+"Source", func(value string) error { - return sourceFunc(value) - }). - String(prefix+"Expression", &expression, ""). - String(prefix+"Pages", &pages, ""). - Custom(prefix+"Options", func(value string) error { - return optionsFunc(value) - }) - } - - return gotenberg.Stamp{ - Source: source, - Expression: expression, - Pages: pages, - Options: options, - } -} - -// FormDataPdfWatermarkFile extracts the watermark file path from form data. -func FormDataPdfWatermarkFile(form *api.FormData) string { - var path string - form.Watermark(&path) - return path -} - -// FormDataPdfStampFile extracts the stamp file path from form data. -func FormDataPdfStampFile(form *api.FormData) string { - var path string - form.Stamp(&path) - return path -} - -// EnsureStampFile validates that, when stamp.Source is image or pdf, an -// uploaded stamp file was supplied, and replaces stamp.Expression with -// uploadedFile in that case. Returning an [api] HTTP 400 error prevents -// an anonymous caller from passing an arbitrary filesystem path via -// stampExpression and having pdfcpu read it. Source values of text or -// empty are passed through unchanged. -func EnsureStampFile(stamp *gotenberg.Stamp, uploadedFile string) error { - if stamp.Source != gotenberg.StampSourceImage && stamp.Source != gotenberg.StampSourcePDF { - return nil - } - if uploadedFile == "" { - return api.WrapError( - errors.New("no stamp file provided for image or pdf source"), - api.NewSentinelHttpError( - http.StatusBadRequest, - "Invalid form data: a stamp file is required for image or pdf source", - ), - ) - } - stamp.Expression = uploadedFile - return nil -} - // FormDataPdfStamps builds the ordered list of stamps from the repeated stamp -// fields: stampSource, stampExpression, stampPages and stampOptions. The number -// of stamps equals the number of stampSource values, so a single occurrence of -// each field yields one stamp, preserving the single-stamp behavior. Fields are -// aligned by position; a missing expression, pages or options entry defaults to -// empty. Image and pdf stamps take their file from the uploaded stamp files, in -// order (see [BindStampFiles]). +// fields. See [formDataPdfStampsOrWatermarks]. func FormDataPdfStamps(form *api.FormData) ([]gotenberg.Stamp, error) { + return formDataPdfStampsOrWatermarks(form, "stamp") +} + +// FormDataPdfWatermarks builds the ordered list of watermarks from the repeated +// watermark fields. See [formDataPdfStampsOrWatermarks]. +func FormDataPdfWatermarks(form *api.FormData) ([]gotenberg.Stamp, error) { + return formDataPdfStampsOrWatermarks(form, "watermark") +} + +// formDataPdfStampsOrWatermarks builds the ordered list of stamps or watermarks +// from the repeated {prefix}Source, {prefix}Expression, {prefix}Pages and +// {prefix}Options fields. The number of entries equals the number of +// {prefix}Source values, so a single occurrence of each field yields one entry, +// preserving the single-stamp/watermark behavior. Fields are aligned by +// position; a missing expression, pages or options entry defaults to empty. +// Image and pdf entries take their file from the uploaded files, in order (see +// [bindStampOrWatermarkFiles]). +func formDataPdfStampsOrWatermarks(form *api.FormData, prefix string) ([]gotenberg.Stamp, error) { var sources, expressions, pages, options []string form. - Strings("stampSource", &sources). - Strings("stampExpression", &expressions). - Strings("stampPages", &pages). - Strings("stampOptions", &options) + Strings(prefix+"Source", &sources). + Strings(prefix+"Expression", &expressions). + Strings(prefix+"Pages", &pages). + Strings(prefix+"Options", &options) at := func(values []string, i int) string { if i < len(values) { @@ -960,10 +868,10 @@ func FormDataPdfStamps(form *api.FormData) ([]gotenberg.Stamp, error) { for i, source := range sources { if source != gotenberg.StampSourceText && source != gotenberg.StampSourceImage && source != gotenberg.StampSourcePDF { return nil, api.WrapError( - fmt.Errorf("wrong stampSource value '%s'", source), + fmt.Errorf("wrong %sSource value '%s'", prefix, source), api.NewSentinelHttpError( http.StatusBadRequest, - fmt.Sprintf("Invalid form data: form field 'stampSource' is invalid (got '%s', resulting to wrong value, expected either '%s', '%s' or '%s')", source, gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF), + fmt.Sprintf("Invalid form data: form field '%sSource' is invalid (got '%s', resulting to wrong value, expected either '%s', '%s' or '%s')", prefix, source, gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF), ), ) } @@ -973,10 +881,10 @@ func FormDataPdfStamps(form *api.FormData) ([]gotenberg.Stamp, error) { err := json.Unmarshal([]byte(raw), &opts) if err != nil { return nil, api.WrapError( - fmt.Errorf("unmarshal stampOptions: %w", err), + fmt.Errorf("unmarshal %sOptions: %w", prefix, err), api.NewSentinelHttpError( http.StatusBadRequest, - "Invalid form data: form field 'stampOptions' is invalid", + fmt.Sprintf("Invalid form data: form field '%sOptions' is invalid", prefix), ), ) } @@ -993,82 +901,77 @@ func FormDataPdfStamps(form *api.FormData) ([]gotenberg.Stamp, error) { return stamps, nil } -// BindStampFiles assigns each image or pdf stamp its uploaded file, consuming -// stampFiles in order. Text stamps take no file. It returns an [api] HTTP 400 -// error when an image or pdf stamp has no file left to consume, which also -// prevents an anonymous caller from passing an arbitrary filesystem path via -// stampExpression. +// BindStampFiles assigns each image or pdf stamp its uploaded stamp file. See +// [bindStampOrWatermarkFiles]. func BindStampFiles(stamps []gotenberg.Stamp, stampFiles []string) error { + return bindStampOrWatermarkFiles(stamps, stampFiles, "stamp") +} + +// BindWatermarkFiles assigns each image or pdf watermark its uploaded watermark +// file. See [bindStampOrWatermarkFiles]. +func BindWatermarkFiles(watermarks []gotenberg.Stamp, watermarkFiles []string) error { + return bindStampOrWatermarkFiles(watermarks, watermarkFiles, "watermark") +} + +// bindStampOrWatermarkFiles assigns each image or pdf entry its uploaded file, +// consuming files in order. Text entries take no file. It returns an [api] HTTP +// 400 error when an image or pdf entry has no file left to consume, which also +// prevents an anonymous caller from passing an arbitrary filesystem path via +// the expression field. kind is "stamp" or "watermark" and shapes the error. +func bindStampOrWatermarkFiles(stamps []gotenberg.Stamp, files []string, kind string) error { fileIndex := 0 for i := range stamps { if stamps[i].Source != gotenberg.StampSourceImage && stamps[i].Source != gotenberg.StampSourcePDF { continue } - if fileIndex >= len(stampFiles) { + if fileIndex >= len(files) { return api.WrapError( - errors.New("not enough stamp files for the image or pdf stamps"), + fmt.Errorf("not enough %s files for the image or pdf entries", kind), api.NewSentinelHttpError( http.StatusBadRequest, - "Invalid form data: a stamp file is required for image or pdf source", + fmt.Sprintf("Invalid form data: a %s file is required for image or pdf source", kind), ), ) } - stamps[i].Expression = stampFiles[fileIndex] + stamps[i].Expression = files[fileIndex] fileIndex++ } return nil } -// EnsureWatermarkFile mirrors [EnsureStampFile] for a watermark. The -// shape is identical: image or pdf sources must be accompanied by an -// uploaded file, and the file path replaces watermark.Expression to -// prevent pdfcpu from reading an attacker-controlled path. -func EnsureWatermarkFile(watermark *gotenberg.Stamp, uploadedFile string) error { - if watermark.Source != gotenberg.StampSourceImage && watermark.Source != gotenberg.StampSourcePDF { - return nil - } - if uploadedFile == "" { - return api.WrapError( - errors.New("no watermark file provided for image or pdf source"), - api.NewSentinelHttpError( - http.StatusBadRequest, - "Invalid form data: a watermark file is required for image or pdf source", - ), - ) - } - watermark.Expression = uploadedFile - return nil -} +// WatermarkStub applies each watermark to a list of PDF files, in order. +// Entries with no source are skipped, so an empty list does nothing. +func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, watermarks []gotenberg.Stamp, inputPaths []string) error { + for _, watermark := range watermarks { + if watermark.Source == "" { + continue + } -// WatermarkStub applies a watermark to a list of PDF files. If the stamp has -// no source, it does nothing. -func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error { - if stamp.Source == "" { - return nil - } - - for _, inputPath := range inputPaths { - err := engine.Watermark(ctx, ctx.Log(), inputPath, stamp) - if err != nil { - return fmt.Errorf("watermark '%s': %w", inputPath, err) + for _, inputPath := range inputPaths { + err := engine.Watermark(ctx, ctx.Log(), inputPath, watermark) + if err != nil { + return fmt.Errorf("watermark '%s': %w", inputPath, err) + } } } return nil } -// StampStub applies a stamp to a list of PDF files. If the stamp has -// no source, it does nothing. -func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error { - if stamp.Source == "" { - return nil - } +// StampStub applies each stamp to a list of PDF files, in order. Entries with +// no source are skipped, so an empty list does nothing. +func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamps []gotenberg.Stamp, inputPaths []string) error { + for _, stamp := range stamps { + if stamp.Source == "" { + continue + } - for _, inputPath := range inputPaths { - err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp) - if err != nil { - return fmt.Errorf("stamp '%s': %w", inputPath, err) + for _, inputPath := range inputPaths { + err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp) + if err != nil { + return fmt.Errorf("stamp '%s': %w", inputPath, err) + } } } @@ -1090,10 +993,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { bookmarks := FormDataPdfBookmarks(form, false) encrypt := FormDataPdfEncrypt(form) embedPaths := FormDataPdfEmbeds(form) - watermark := FormDataPdfWatermark(form, false) - watermarkFile := FormDataPdfWatermarkFile(form) - stamp := FormDataPdfStamp(form, false) - stampFile := FormDataPdfStampFile(form) + watermarks, wErr := FormDataPdfWatermarks(form) + if wErr != nil { + return fmt.Errorf("form data watermarks: %w", wErr) + } + stamps, sErr := FormDataPdfStamps(form) + if sErr != nil { + return fmt.Errorf("form data stamps: %w", sErr) + } + var watermarkFiles, stampFiles []string + form.Watermarks(&watermarkFiles).Stamps(&stampFiles) angle, rotatePages := FormDataPdfRotate(form, false) embedsMetadata := FormDataPdfEmbedsMetadata(form) facturX, facturxXmlPath := FormDataPdfFacturX(form) @@ -1113,13 +1022,13 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - err = EnsureWatermarkFile(&watermark, watermarkFile) + err = BindWatermarkFiles(watermarks, watermarkFiles) if err != nil { - return fmt.Errorf("validate watermark: %w", err) + return fmt.Errorf("bind watermark files: %w", err) } - err = EnsureStampFile(&stamp, stampFile) + err = BindStampFiles(stamps, stampFiles) if err != nil { - return fmt.Errorf("validate stamp: %w", err) + return fmt.Errorf("bind stamp files: %w", err) } err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths) @@ -1145,12 +1054,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { outputPaths := []string{outputPath} - err = WatermarkStub(ctx, engine, watermark, outputPaths) + err = WatermarkStub(ctx, engine, watermarks, outputPaths) if err != nil { return fmt.Errorf("watermark PDFs: %w", err) } - err = StampStub(ctx, engine, stamp, outputPaths) + err = StampStub(ctx, engine, stamps, outputPaths) if err != nil { return fmt.Errorf("stamp PDFs: %w", err) } @@ -1285,10 +1194,16 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { metadata := FormDataPdfMetadata(form, false) encrypt := FormDataPdfEncrypt(form) embedPaths := FormDataPdfEmbeds(form) - watermark := FormDataPdfWatermark(form, false) - watermarkFile := FormDataPdfWatermarkFile(form) - stamp := FormDataPdfStamp(form, false) - stampFile := FormDataPdfStampFile(form) + watermarks, wErr := FormDataPdfWatermarks(form) + if wErr != nil { + return fmt.Errorf("form data watermarks: %w", wErr) + } + stamps, sErr := FormDataPdfStamps(form) + if sErr != nil { + return fmt.Errorf("form data stamps: %w", sErr) + } + var watermarkFiles, stampFiles []string + form.Watermarks(&watermarkFiles).Stamps(&stampFiles) angle, rotatePages := FormDataPdfRotate(form, false) embedsMetadata := FormDataPdfEmbedsMetadata(form) facturX, facturxXmlPath := FormDataPdfFacturX(form) @@ -1304,13 +1219,13 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - err = EnsureWatermarkFile(&watermark, watermarkFile) + err = BindWatermarkFiles(watermarks, watermarkFiles) if err != nil { - return fmt.Errorf("validate watermark: %w", err) + return fmt.Errorf("bind watermark files: %w", err) } - err = EnsureStampFile(&stamp, stampFile) + err = BindStampFiles(stamps, stampFiles) if err != nil { - return fmt.Errorf("validate stamp: %w", err) + return fmt.Errorf("bind stamp files: %w", err) } err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths) @@ -1333,12 +1248,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("split PDFs: %w", err) } - err = WatermarkStub(ctx, engine, watermark, outputPaths) + err = WatermarkStub(ctx, engine, watermarks, outputPaths) if err != nil { return fmt.Errorf("watermark PDFs: %w", err) } - err = StampStub(ctx, engine, stamp, outputPaths) + err = StampStub(ctx, engine, stamps, outputPaths) if err != nil { return fmt.Errorf("stamp PDFs: %w", err) } @@ -1816,23 +1731,37 @@ func watermarkRoute(engine gotenberg.PdfEngine) api.Route { ctx := c.Get("context").(*api.Context) form := ctx.FormData() - stamp := FormDataPdfWatermark(form, true) - watermarkFile := FormDataPdfWatermarkFile(form) + watermarks, err := FormDataPdfWatermarks(form) + if err != nil { + return fmt.Errorf("form data watermarks: %w", err) + } var inputPaths []string - err := form. + var watermarkFiles []string + err = form. MandatoryPaths([]string{".pdf"}, &inputPaths). + Watermarks(&watermarkFiles). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) } - err = EnsureWatermarkFile(&stamp, watermarkFile) - if err != nil { - return fmt.Errorf("validate watermark: %w", err) + if len(watermarks) == 0 { + return api.WrapError( + errors.New("no watermark provided"), + api.NewSentinelHttpError( + http.StatusBadRequest, + "Invalid form data: form field 'watermarkSource' is required", + ), + ) } - err = WatermarkStub(ctx, engine, stamp, inputPaths) + err = BindWatermarkFiles(watermarks, watermarkFiles) + if err != nil { + return fmt.Errorf("bind watermark files: %w", err) + } + + err = WatermarkStub(ctx, engine, watermarks, inputPaths) if err != nil { return fmt.Errorf("watermark PDFs: %w", err) } @@ -1893,11 +1822,9 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("bind stamp files: %w", err) } - for _, stamp := range stamps { - err = StampStub(ctx, engine, stamp, inputPaths) - if err != nil { - return fmt.Errorf("stamp PDFs: %w", err) - } + err = StampStub(ctx, engine, stamps, inputPaths) + if err != nil { + return fmt.Errorf("stamp PDFs: %w", err) } err = ctx.AddOutputPaths(inputPaths...) diff --git a/pkg/modules/pdfengines/stamp_test.go b/pkg/modules/pdfengines/stamp_test.go index f9e1658a..b2be472d 100644 --- a/pkg/modules/pdfengines/stamp_test.go +++ b/pkg/modules/pdfengines/stamp_test.go @@ -156,3 +156,26 @@ func TestBindStampFiles(t *testing.T) { }) } } + +func TestFormDataPdfWatermarks(t *testing.T) { + ctx := &api.ContextMock{Context: &api.Context{}} + ctx.SetValues(map[string][]string{ + "watermarkSource": {"text", "image"}, + "watermarkExpression": {"DRAFT"}, + "watermarkOptions": {`{"opacity":"0.5"}`, ""}, + }) + form := ctx.FormData() + + got, err := FormDataPdfWatermarks(form) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + want := []gotenberg.Stamp{ + {Source: "text", Expression: "DRAFT", Options: map[string]string{"opacity": "0.5"}}, + {Source: "image"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("watermarks = %#v, want %#v", got, want) + } +} diff --git a/test/integration/features/pdfengines_stamp.feature b/test/integration/features/pdfengines_stamp.feature index 9464ceb6..64f5125d 100644 --- a/test/integration/features/pdfengines_stamp.feature +++ b/test/integration/features/pdfengines_stamp.feature @@ -53,19 +53,36 @@ Feature: /forms/pdfengines/stamp # Repeating the stamp fields applies several stamps in one request, in order. # Image and pdf stamps consume the uploaded stamp files in order; text stamps - # take none. See https://github.com/gotenberg/gotenberg/pull/1601. + # take none. Both text stamps must land, so their content is asserted (the + # options keep them unrotated and apart so pdftotext reads them cleanly). + # See https://github.com/gotenberg/gotenberg/pull/1601. Scenario: POST /forms/pdfengines/stamp (Multiple Stamps - pdfcpu) Given I have a Gotenberg container with the following environment variable(s): | PDFENGINES_STAMP_ENGINES | pdfcpu | When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | stampSource | text | field | - | stampExpression | CONFIDENTIAL | field | - | stampSource | image | field | - | stamp | testdata/watermark.png | file | + | files | testdata/page_1.pdf | file | + | stampSource | text | field | + | stampExpression | STAMPONE | field | + | stampOptions | {"rotation":"0","position":"tl","scale":"0.2 abs"} | field | + | stampSource | text | field | + | stampExpression | STAMPTWO | field | + | stampOptions | {"rotation":"0","position":"br","scale":"0.2 abs"} | field | + | stampSource | image | field | + | stamp | testdata/watermark.png | file | + | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have the following content at page 1: + """ + STAMPONE + """ + Then the "foo.pdf" PDF should have the following content at page 1: + """ + STAMPTWO + """ Scenario: POST /forms/pdfengines/stamp (PDF - pdfcpu) Given I have a Gotenberg container with the following environment variable(s): diff --git a/test/integration/features/pdfengines_watermark.feature b/test/integration/features/pdfengines_watermark.feature index 1af74719..bfbc2aab 100644 --- a/test/integration/features/pdfengines_watermark.feature +++ b/test/integration/features/pdfengines_watermark.feature @@ -51,6 +51,39 @@ Feature: /forms/pdfengines/watermark Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response + # Repeating the watermark fields applies several watermarks in one request, in + # order. Image and pdf watermarks consume the uploaded watermark files in + # order; text watermarks take none. Both text watermarks must land, so their + # content is asserted (the options keep them unrotated and apart so pdftotext + # reads them cleanly). + Scenario: POST /forms/pdfengines/watermark (Multiple Watermarks - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_WATERMARK_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | MARKONE | field | + | watermarkOptions | {"rotation":"0","position":"tl","scale":"0.2 abs"} | field | + | watermarkSource | text | field | + | watermarkExpression | MARKTWO | field | + | watermarkOptions | {"rotation":"0","position":"br","scale":"0.2 abs"} | field | + | watermarkSource | image | field | + | watermark | testdata/watermark.png | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have the following content at page 1: + """ + MARKONE + """ + Then the "foo.pdf" PDF should have the following content at page 1: + """ + MARKTWO + """ + Scenario: POST /forms/pdfengines/watermark (PDF - pdfcpu) Given I have a Gotenberg container with the following environment variable(s): | PDFENGINES_WATERMARK_ENGINES | pdfcpu |