mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-17 17:53:17 +01:00
Compare commits
6 Commits
b3c06fb8ae
...
v8.36.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0f487e333 | ||
|
|
3de6932279 | ||
|
|
e9a67132ec | ||
|
|
41b33fd6ad | ||
|
|
2521485bf8 | ||
|
|
65e5699b71 |
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
files: @file(../../test/integration/testdata/page_2.pdf)
|
files: @file(../../test/integration/testdata/page_2.pdf)
|
||||||
~flatten: false
|
~flatten: false
|
||||||
~autoIndexBookmarks: false
|
~autoIndexBookmarks: false
|
||||||
|
~titleBookmarks: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
~optimizeImages: false
|
~optimizeImages: false
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -28,6 +28,10 @@ API_CORRELATION_ID_HEADER=Gotenberg-Trace
|
|||||||
API_ENABLE_BASIC_AUTH=false
|
API_ENABLE_BASIC_AUTH=false
|
||||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||||
|
API_ENABLE_OIDC_AUTH=false
|
||||||
|
API_OIDC_ISSUER=
|
||||||
|
API_OIDC_AUDIENCE=
|
||||||
|
API_OIDC_JWKS_URL=
|
||||||
API_DOWNLOAD_FROM_ALLOW_LIST=
|
API_DOWNLOAD_FROM_ALLOW_LIST=
|
||||||
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
||||||
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
|
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
|
||||||
|
|||||||
8
go.mod
8
go.mod
@@ -37,6 +37,11 @@ require (
|
|||||||
golang.org/x/text v0.41.0
|
golang.org/x/text v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.20.0
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
@@ -66,6 +71,7 @@ require (
|
|||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||||
github.com/ebitengine/purego v0.10.2 // indirect
|
github.com/ebitengine/purego v0.10.2 // indirect
|
||||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||||
github.com/go-logr/logr v1.4.4 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
@@ -118,7 +124,6 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 // indirect
|
||||||
@@ -133,6 +138,7 @@ require (
|
|||||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||||
golang.org/x/crypto v0.55.0 // indirect
|
golang.org/x/crypto v0.55.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -42,6 +42,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
|||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
@@ -72,6 +74,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
|||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
@@ -305,6 +309,8 @@ golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
|||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ type Api struct {
|
|||||||
correlationIdHeader string
|
correlationIdHeader string
|
||||||
basicAuthUsername string
|
basicAuthUsername string
|
||||||
basicAuthPassword string
|
basicAuthPassword string
|
||||||
|
oidcEnabled bool
|
||||||
|
oidcIssuer string
|
||||||
|
oidcAudience string
|
||||||
|
oidcJwksUrl string
|
||||||
downloadFromCfg downloadFromConfig
|
downloadFromCfg downloadFromConfig
|
||||||
disableHealthCheckRouteTelemetry bool
|
disableHealthCheckRouteTelemetry bool
|
||||||
disableRootRouteTelemetry bool
|
disableRootRouteTelemetry bool
|
||||||
@@ -198,6 +202,10 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
||||||
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||||
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
||||||
|
fs.Bool("api-enable-oidc-auth", false, "Enable OIDC bearer token authentication - mutually exclusive with basic authentication")
|
||||||
|
fs.String("api-oidc-issuer", "", "Set the OIDC issuer URL, e.g. https://tenant.example.com/ - the token 'iss' claim must match")
|
||||||
|
fs.String("api-oidc-audience", "", "Set the expected OIDC audience - the token 'aud' claim must contain it")
|
||||||
|
fs.String("api-oidc-jwks-url", "", "Set the OIDC JWKS URL - discovered from the issuer's well-known configuration when empty")
|
||||||
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
|
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
|
||||||
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
|
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
|
||||||
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
|
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
|
||||||
@@ -280,6 +288,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
a.basicAuthPassword = basicAuthPassword
|
a.basicAuthPassword = basicAuthPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable OIDC auth? The flags are populated from their API_OIDC_* env vars
|
||||||
|
// by the CLI, so no manual environment lookup is needed here.
|
||||||
|
a.oidcEnabled = flags.MustBool("api-enable-oidc-auth")
|
||||||
|
if a.oidcEnabled {
|
||||||
|
a.oidcIssuer = flags.MustString("api-oidc-issuer")
|
||||||
|
a.oidcAudience = flags.MustString("api-oidc-audience")
|
||||||
|
a.oidcJwksUrl = flags.MustString("api-oidc-jwks-url")
|
||||||
|
}
|
||||||
|
|
||||||
// Get routes from modules.
|
// Get routes from modules.
|
||||||
mods, err := ctx.Modules(new(Router))
|
mods, err := ctx.Modules(new(Router))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -411,6 +428,25 @@ func (a *Api) Validate() error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.basicAuthUsername != "" && a.oidcEnabled {
|
||||||
|
err = errors.Join(err,
|
||||||
|
errors.New("basic authentication and OIDC authentication cannot both be enabled"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.oidcEnabled {
|
||||||
|
if a.oidcIssuer == "" {
|
||||||
|
err = errors.Join(err,
|
||||||
|
errors.New("OIDC issuer must not be empty when OIDC auth is enabled; set --api-oidc-issuer"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if a.oidcAudience == "" {
|
||||||
|
err = errors.Join(err,
|
||||||
|
errors.New("OIDC audience must not be empty when OIDC auth is enabled; set --api-oidc-audience"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -517,11 +553,18 @@ func (a *Api) Start() error {
|
|||||||
|
|
||||||
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
|
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
|
||||||
|
|
||||||
// Basic auth?
|
// Authentication?
|
||||||
var securityMiddleware echo.MiddlewareFunc
|
var securityMiddleware echo.MiddlewareFunc
|
||||||
if a.basicAuthUsername != "" {
|
switch {
|
||||||
|
case a.basicAuthUsername != "":
|
||||||
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
|
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
|
||||||
} else {
|
case a.oidcEnabled:
|
||||||
|
verifier, err := a.buildOidcVerifier()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build OIDC verifier: %w", err)
|
||||||
|
}
|
||||||
|
securityMiddleware = oidcAuthMiddleware(verifier)
|
||||||
|
default:
|
||||||
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
|
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
return next(c)
|
return next(c)
|
||||||
|
|||||||
67
pkg/modules/api/api_test.go
Normal file
67
pkg/modules/api/api_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApi_Validate_Auth(t *testing.T) {
|
||||||
|
base := func() *Api {
|
||||||
|
return &Api{port: 3000, rootPath: "/", correlationIdHeader: "Gotenberg-Trace"}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
mutate func(*Api)
|
||||||
|
wantErr string // substring expected in the error, "" means no error
|
||||||
|
}{
|
||||||
|
{"no auth", func(*Api) {}, ""},
|
||||||
|
{"basic auth only", func(a *Api) { a.basicAuthUsername = "foo" }, ""},
|
||||||
|
{
|
||||||
|
"oidc auth valid",
|
||||||
|
func(a *Api) {
|
||||||
|
a.oidcEnabled = true
|
||||||
|
a.oidcIssuer = "https://tenant.example.com/"
|
||||||
|
a.oidcAudience = "gotenberg"
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"basic and oidc are mutually exclusive",
|
||||||
|
func(a *Api) {
|
||||||
|
a.basicAuthUsername = "foo"
|
||||||
|
a.oidcEnabled = true
|
||||||
|
a.oidcIssuer = "https://tenant.example.com/"
|
||||||
|
a.oidcAudience = "gotenberg"
|
||||||
|
},
|
||||||
|
"cannot both be enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oidc missing issuer",
|
||||||
|
func(a *Api) { a.oidcEnabled = true; a.oidcAudience = "gotenberg" },
|
||||||
|
"issuer must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oidc missing audience",
|
||||||
|
func(a *Api) { a.oidcEnabled = true; a.oidcIssuer = "https://tenant.example.com/" },
|
||||||
|
"audience must not be empty",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
a := base()
|
||||||
|
tc.mutate(a)
|
||||||
|
|
||||||
|
err := a.Validate()
|
||||||
|
|
||||||
|
if tc.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("error = %v, want a substring %q", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -479,6 +479,51 @@ func (form *FormData) Stamp(target *string) *FormData {
|
|||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stamps binds the absolute paths of every file uploaded with the "stamp"
|
||||||
|
// field name, in submission order. Unlike [FormData.Stamp], it keeps all of
|
||||||
|
// them so a route can apply several stamps in a single request.
|
||||||
|
func (form *FormData) Stamps(target *[]string) *FormData {
|
||||||
|
if form.errors != nil {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
if paths, ok := form.filesByField[StampFormField]; ok {
|
||||||
|
*target = paths
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
func (form *FormData) Strings(key string, target *[]string) *FormData {
|
||||||
|
if form.errors != nil {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
if values, ok := form.values[key]; ok {
|
||||||
|
*target = values
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
|
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
|
||||||
// XML. Only a file uploaded with the "facturxXml" field name is included.
|
// XML. Only a file uploaded with the "facturxXml" field name is included.
|
||||||
func (form *FormData) FacturXXml(target *string) *FormData {
|
func (form *FormData) FacturXXml(target *string) *FormData {
|
||||||
|
|||||||
@@ -1837,3 +1837,61 @@ func TestFormData_paths_excludesFacturXXml(t *testing.T) {
|
|||||||
t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths)
|
t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormData_Strings(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {"a", "b", "c"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
form.Strings("foo", &got)
|
||||||
|
|
||||||
|
if want := []string{"a", "b", "c"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("expected %+v, got %+v", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
form.Strings("bar", &missing)
|
||||||
|
if missing != nil {
|
||||||
|
t.Errorf("expected nil for a missing key, got %+v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormData_Stamps(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
filesByField: map[string][]string{
|
||||||
|
StampFormField: {"/tmp/abc/a.png", "/tmp/abc/b.pdf"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
form.Stamps(&got)
|
||||||
|
|
||||||
|
if want := []string{"/tmp/abc/a.png", "/tmp/abc/b.pdf"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("expected %+v, got %+v", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := &FormData{}
|
||||||
|
var none []string
|
||||||
|
empty.Stamps(&none)
|
||||||
|
if none != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/propagation"
|
"go.opentelemetry.io/otel/propagation"
|
||||||
@@ -368,6 +370,63 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildOidcVerifier constructs an OIDC ID token verifier. When oidcJwksUrl is
|
||||||
|
// set, the keys are fetched from that URL lazily, so there is no network call at
|
||||||
|
// startup; otherwise the provider is discovered from its issuer, which does one.
|
||||||
|
// Both paths use an OTEL-instrumented HTTP client, so the JWKS and discovery
|
||||||
|
// fetches produce client spans.
|
||||||
|
func (a *Api) buildOidcVerifier() (*oidc.IDTokenVerifier, error) {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||||
|
}
|
||||||
|
ctx := oidc.ClientContext(context.Background(), httpClient)
|
||||||
|
|
||||||
|
cfg := &oidc.Config{
|
||||||
|
ClientID: a.oidcAudience,
|
||||||
|
SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256},
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.oidcJwksUrl != "" {
|
||||||
|
keySet := oidc.NewRemoteKeySet(ctx, a.oidcJwksUrl)
|
||||||
|
return oidc.NewVerifier(a.oidcIssuer, keySet, cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := oidc.NewProvider(ctx, a.oidcIssuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("discover OIDC provider '%s': %w", a.oidcIssuer, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.Verifier(cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidcAuthMiddleware validates the Bearer token in the Authorization header with
|
||||||
|
// the OIDC verifier, which checks the signature against the provider's rotating
|
||||||
|
// JWKS and the issuer, audience and expiry claims. It answers 401 for a missing
|
||||||
|
// or invalid token, logging the underlying reason at debug level without leaking
|
||||||
|
// it to the client.
|
||||||
|
func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
rawToken, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !ok || rawToken == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "a Bearer token is required in the Authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := verifier.Verify(c.Request().Context(), rawToken)
|
||||||
|
if err != nil {
|
||||||
|
if logger, ok := c.Get("logger").(*slog.Logger); ok && logger != nil {
|
||||||
|
logger.DebugContext(c.Request().Context(), "OIDC token verification failed", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "the Bearer token is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// contextMiddleware, middleware for "multipart/form-data" requests, sets the
|
// contextMiddleware, middleware for "multipart/form-data" requests, sets the
|
||||||
// [Context] and related context.CancelFunc in the [echo.Context] under
|
// [Context] and related context.CancelFunc in the [echo.Context] under
|
||||||
// "context" and "cancel". If the process is synchronous, it also handles the
|
// "context" and "cancel". If the process is synchronous, it also handles the
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -11,6 +13,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc/oidctest"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,3 +156,90 @@ func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *te
|
|||||||
t.Fatalf("error = %q, want a message mentioning logger", err)
|
t.Fatalf("error = %q, want a message mentioning logger", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOidcAuthMiddleware(t *testing.T) {
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyID = "test-key"
|
||||||
|
audience = "gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
oidcServer := &oidctest.Server{
|
||||||
|
PublicKeys: []oidctest.PublicKey{
|
||||||
|
{PublicKey: privateKey.Public(), KeyID: keyID, Algorithm: oidc.RS256},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(oidcServer)
|
||||||
|
defer srv.Close()
|
||||||
|
oidcServer.SetIssuer(srv.URL)
|
||||||
|
|
||||||
|
// Building through the module's own helper exercises the discovery path too.
|
||||||
|
a := &Api{oidcIssuer: srv.URL, oidcAudience: audience}
|
||||||
|
verifier, err := a.buildOidcVerifier()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build verifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := func(issuer, aud string, expiresIn time.Duration) string {
|
||||||
|
now := time.Now()
|
||||||
|
return fmt.Sprintf(`{"iss":%q,"aud":%q,"sub":"user","exp":%d,"iat":%d}`,
|
||||||
|
issuer, aud, now.Add(expiresIn).Unix(), now.Unix())
|
||||||
|
}
|
||||||
|
sign := func(claims string) string {
|
||||||
|
return oidctest.SignIDToken(privateKey, keyID, oidc.RS256, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate other key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
authHeader string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"valid token", "Bearer " + sign(claims(srv.URL, audience, time.Hour)), http.StatusOK},
|
||||||
|
{"missing header", "", http.StatusUnauthorized},
|
||||||
|
{"wrong scheme", "Basic Zm9vOmJhcg==", http.StatusUnauthorized},
|
||||||
|
{"empty bearer", "Bearer ", http.StatusUnauthorized},
|
||||||
|
{"malformed token", "Bearer not-a-jwt", http.StatusUnauthorized},
|
||||||
|
{"wrong issuer", "Bearer " + sign(claims("https://evil.example/", audience, time.Hour)), http.StatusUnauthorized},
|
||||||
|
{"wrong audience", "Bearer " + sign(claims(srv.URL, "someone-else", time.Hour)), http.StatusUnauthorized},
|
||||||
|
{"expired token", "Bearer " + sign(claims(srv.URL, audience, -time.Hour)), http.StatusUnauthorized},
|
||||||
|
{"unknown signing key", "Bearer " + oidctest.SignIDToken(otherKey, "unknown", oidc.RS256, claims(srv.URL, audience, time.Hour)), http.StatusUnauthorized},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if tc.authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", tc.authHeader)
|
||||||
|
}
|
||||||
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
|
||||||
|
handler := oidcAuthMiddleware(verifier)(func(c echo.Context) error {
|
||||||
|
return c.NoContent(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := handler(c)
|
||||||
|
|
||||||
|
if tc.wantStatus == http.StatusOK {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected the request to pass, got error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpErr *echo.HTTPError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("expected an *echo.HTTPError, got %T (%v)", err, err)
|
||||||
|
}
|
||||||
|
if httpErr.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d", httpErr.Code, tc.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -474,10 +474,16 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
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)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(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)
|
return fmt.Errorf("reject URL scheme: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
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 {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
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)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
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)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(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)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("file://%s", inputPath)
|
url := fmt.Sprintf("file://%s", inputPath)
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
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)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
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)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(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)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
||||||
@@ -685,7 +703,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
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
|
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")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
filename := ctx.OutputFilename(outputPath)
|
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)
|
return fmt.Errorf("split PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
|
err = pdfengines.WatermarkStub(ctx, engine, watermarks, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
|
err = pdfengines.StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,16 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
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)
|
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(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)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
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 {
|
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)
|
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.
|
// requested. The conversion runs as a post-processing step below.
|
||||||
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
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 != ""
|
len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != ""
|
||||||
|
|
||||||
outputPaths := make([]string, len(inputPaths))
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
|
err = pdfengines.StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,6 +442,27 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// documentTitle returns the input PDF's Title metadata entry, falling back to
|
||||||
|
// the original filename without its extension when the entry is absent, blank,
|
||||||
|
// or cannot be read. It labels the per-document entries the merge route's
|
||||||
|
// titleBookmarks feature generates.
|
||||||
|
func documentTitle(ctx *api.Context, engine gotenberg.PdfEngine, inputPath, filename string) string {
|
||||||
|
fallback := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
|
||||||
|
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Log().WarnContext(ctx, fmt.Sprintf("read metadata of '%s' for title bookmark, using filename: %s", filename, err))
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
title, ok := metadata["Title"].(string)
|
||||||
|
if !ok || strings.TrimSpace(title) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
func shiftBookmarks(bookmarks []gotenberg.Bookmark, offset int) []gotenberg.Bookmark {
|
func shiftBookmarks(bookmarks []gotenberg.Bookmark, offset int) []gotenberg.Bookmark {
|
||||||
if offset == 0 {
|
if offset == 0 {
|
||||||
return bookmarks
|
return bookmarks
|
||||||
@@ -808,160 +829,149 @@ func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataPdfWatermark creates a [gotenberg.Stamp] for watermarking from the
|
// FormDataPdfStamps builds the ordered list of stamps from the repeated stamp
|
||||||
// form data.
|
// fields. See [formDataPdfStampsOrWatermarks].
|
||||||
func FormDataPdfWatermark(form *api.FormData, mandatory bool) gotenberg.Stamp {
|
func FormDataPdfStamps(form *api.FormData) ([]gotenberg.Stamp, error) {
|
||||||
return formDataPdfStampOrWatermark(form, "watermark", mandatory)
|
return formDataPdfStampsOrWatermarks(form, "stamp")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataPdfStamp creates a [gotenberg.Stamp] for stamping from the form data.
|
// FormDataPdfWatermarks builds the ordered list of watermarks from the repeated
|
||||||
func FormDataPdfStamp(form *api.FormData, mandatory bool) gotenberg.Stamp {
|
// watermark fields. See [formDataPdfStampsOrWatermarks].
|
||||||
return formDataPdfStampOrWatermark(form, "stamp", mandatory)
|
func FormDataPdfWatermarks(form *api.FormData) ([]gotenberg.Stamp, error) {
|
||||||
|
return formDataPdfStampsOrWatermarks(form, "watermark")
|
||||||
}
|
}
|
||||||
|
|
||||||
func formDataPdfStampOrWatermark(form *api.FormData, prefix string, mandatory bool) gotenberg.Stamp {
|
// formDataPdfStampsOrWatermarks builds the ordered list of stamps or watermarks
|
||||||
var (
|
// from the repeated {prefix}Source, {prefix}Expression, {prefix}Pages and
|
||||||
source string
|
// {prefix}Options fields. The number of entries equals the number of
|
||||||
expression string
|
// {prefix}Source values, so a single occurrence of each field yields one entry,
|
||||||
pages string
|
// preserving the single-stamp/watermark behavior. Fields are aligned by
|
||||||
options map[string]string
|
// 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(prefix+"Source", &sources).
|
||||||
|
Strings(prefix+"Expression", &expressions).
|
||||||
|
Strings(prefix+"Pages", &pages).
|
||||||
|
Strings(prefix+"Options", &options)
|
||||||
|
|
||||||
sourceFunc := func(value string) error {
|
at := func(values []string, i int) string {
|
||||||
if value != "" && value != gotenberg.StampSourceText && value != gotenberg.StampSourceImage && value != gotenberg.StampSourcePDF {
|
if i < len(values) {
|
||||||
return fmt.Errorf("wrong value, expected either '%s', '%s' or '%s'", gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF)
|
return values[i]
|
||||||
}
|
}
|
||||||
source = value
|
return ""
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
optionsFunc := func(value string) error {
|
stamps := make([]gotenberg.Stamp, 0, len(sources))
|
||||||
if value == "" {
|
for i, source := range sources {
|
||||||
return nil
|
if source != gotenberg.StampSourceText && source != gotenberg.StampSourceImage && source != gotenberg.StampSourcePDF {
|
||||||
|
return nil, api.WrapError(
|
||||||
|
fmt.Errorf("wrong %sSource value '%s'", prefix, source),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
err := json.Unmarshal([]byte(value), &options)
|
|
||||||
if err != nil {
|
var opts map[string]string
|
||||||
return fmt.Errorf("unmarshal %s options: %w", prefix, err)
|
if raw := at(options, i); raw != "" {
|
||||||
|
err := json.Unmarshal([]byte(raw), &opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, api.WrapError(
|
||||||
|
fmt.Errorf("unmarshal %sOptions: %w", prefix, err),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("Invalid form data: form field '%sOptions' is invalid", prefix),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
stamps = append(stamps, gotenberg.Stamp{
|
||||||
|
Source: source,
|
||||||
|
Expression: at(expressions, i),
|
||||||
|
Pages: at(pages, i),
|
||||||
|
Options: opts,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if mandatory {
|
return stamps, nil
|
||||||
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.
|
// BindStampFiles assigns each image or pdf stamp its uploaded stamp file. See
|
||||||
func FormDataPdfWatermarkFile(form *api.FormData) string {
|
// [bindStampOrWatermarkFiles].
|
||||||
var path string
|
func BindStampFiles(stamps []gotenberg.Stamp, stampFiles []string) error {
|
||||||
form.Watermark(&path)
|
return bindStampOrWatermarkFiles(stamps, stampFiles, "stamp")
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataPdfStampFile extracts the stamp file path from form data.
|
// BindWatermarkFiles assigns each image or pdf watermark its uploaded watermark
|
||||||
func FormDataPdfStampFile(form *api.FormData) string {
|
// file. See [bindStampOrWatermarkFiles].
|
||||||
var path string
|
func BindWatermarkFiles(watermarks []gotenberg.Stamp, watermarkFiles []string) error {
|
||||||
form.Stamp(&path)
|
return bindStampOrWatermarkFiles(watermarks, watermarkFiles, "watermark")
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureStampFile validates that, when stamp.Source is image or pdf, an
|
// bindStampOrWatermarkFiles assigns each image or pdf entry its uploaded file,
|
||||||
// uploaded stamp file was supplied, and replaces stamp.Expression with
|
// consuming files in order. Text entries take no file. It returns an [api] HTTP
|
||||||
// uploadedFile in that case. Returning an [api] HTTP 400 error prevents
|
// 400 error when an image or pdf entry has no file left to consume, which also
|
||||||
// an anonymous caller from passing an arbitrary filesystem path via
|
// prevents an anonymous caller from passing an arbitrary filesystem path via
|
||||||
// stampExpression and having pdfcpu read it. Source values of text or
|
// the expression field. kind is "stamp" or "watermark" and shapes the error.
|
||||||
// empty are passed through unchanged.
|
func bindStampOrWatermarkFiles(stamps []gotenberg.Stamp, files []string, kind string) error {
|
||||||
func EnsureStampFile(stamp *gotenberg.Stamp, uploadedFile string) error {
|
fileIndex := 0
|
||||||
if stamp.Source != gotenberg.StampSourceImage && stamp.Source != gotenberg.StampSourcePDF {
|
for i := range stamps {
|
||||||
return nil
|
if stamps[i].Source != gotenberg.StampSourceImage && stamps[i].Source != gotenberg.StampSourcePDF {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fileIndex >= len(files) {
|
||||||
|
return api.WrapError(
|
||||||
|
fmt.Errorf("not enough %s files for the image or pdf entries", kind),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("Invalid form data: a %s file is required for image or pdf source", kind),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stamps[i].Expression = files[fileIndex]
|
||||||
|
fileIndex++
|
||||||
}
|
}
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureWatermarkFile mirrors [EnsureStampFile] for a watermark. The
|
// WatermarkStub applies each watermark to a list of PDF files, in order.
|
||||||
// shape is identical: image or pdf sources must be accompanied by an
|
// Entries with no source are skipped, so an empty list does nothing.
|
||||||
// uploaded file, and the file path replaces watermark.Expression to
|
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, watermarks []gotenberg.Stamp, inputPaths []string) error {
|
||||||
// prevent pdfcpu from reading an attacker-controlled path.
|
for _, watermark := range watermarks {
|
||||||
func EnsureWatermarkFile(watermark *gotenberg.Stamp, uploadedFile string) error {
|
if watermark.Source == "" {
|
||||||
if watermark.Source != gotenberg.StampSourceImage && watermark.Source != gotenberg.StampSourcePDF {
|
continue
|
||||||
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 a watermark to a list of PDF files. If the stamp has
|
for _, inputPath := range inputPaths {
|
||||||
// no source, it does nothing.
|
err := engine.Watermark(ctx, ctx.Log(), inputPath, watermark)
|
||||||
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
|
if err != nil {
|
||||||
if stamp.Source == "" {
|
return fmt.Errorf("watermark '%s': %w", inputPath, err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StampStub applies a stamp to a list of PDF files. If the stamp has
|
// StampStub applies each stamp to a list of PDF files, in order. Entries with
|
||||||
// no source, it does nothing.
|
// no source are skipped, so an empty list does nothing.
|
||||||
func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
|
func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamps []gotenberg.Stamp, inputPaths []string) error {
|
||||||
if stamp.Source == "" {
|
for _, stamp := range stamps {
|
||||||
return nil
|
if stamp.Source == "" {
|
||||||
}
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for _, inputPath := range inputPaths {
|
for _, inputPath := range inputPaths {
|
||||||
err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp)
|
err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp '%s': %w", inputPath, err)
|
return fmt.Errorf("stamp '%s': %w", inputPath, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,10 +993,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
bookmarks := FormDataPdfBookmarks(form, false)
|
bookmarks := FormDataPdfBookmarks(form, false)
|
||||||
encrypt := FormDataPdfEncrypt(form)
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
watermark := FormDataPdfWatermark(form, false)
|
watermarks, wErr := FormDataPdfWatermarks(form)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := FormDataPdfStampFile(form)
|
}
|
||||||
|
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)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
@@ -995,22 +1011,24 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
var autoIndexBookmarks bool
|
var autoIndexBookmarks bool
|
||||||
|
var titleBookmarks bool
|
||||||
err := form.
|
err := form.
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
Bool("flatten", &flatten, false).
|
Bool("flatten", &flatten, false).
|
||||||
Bool("autoIndexBookmarks", &autoIndexBookmarks, false).
|
Bool("autoIndexBookmarks", &autoIndexBookmarks, false).
|
||||||
|
Bool("titleBookmarks", &titleBookmarks, false).
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EnsureWatermarkFile(&watermark, watermarkFile)
|
err = BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
@@ -1036,12 +1054,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
|
|
||||||
outputPaths := []string{outputPath}
|
outputPaths := []string{outputPath}
|
||||||
|
|
||||||
err = WatermarkStub(ctx, engine, watermark, outputPaths)
|
err = WatermarkStub(ctx, engine, watermarks, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = StampStub(ctx, engine, stamp, outputPaths)
|
err = StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1078,7 +1096,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
finalBookmarks = b
|
finalBookmarks = b
|
||||||
} else {
|
} else {
|
||||||
bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark)
|
bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark)
|
||||||
if bMap != nil || autoIndexBookmarks {
|
if bMap != nil || autoIndexBookmarks || titleBookmarks {
|
||||||
offset := 0
|
offset := 0
|
||||||
for _, inputPath := range inputPaths {
|
for _, inputPath := range inputPaths {
|
||||||
filename := ctx.OriginalFilename(inputPath)
|
filename := ctx.OriginalFilename(inputPath)
|
||||||
@@ -1088,7 +1106,9 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
fileBookmarks = bMap[filename]
|
fileBookmarks = bMap[filename]
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(fileBookmarks) == 0 && autoIndexBookmarks {
|
// titleBookmarks nests each input's own outline under
|
||||||
|
// its title entry, so its outline is read here too.
|
||||||
|
if len(fileBookmarks) == 0 && (autoIndexBookmarks || titleBookmarks) {
|
||||||
fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
|
fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read bookmarks of '%s': %w", filename, err)
|
return fmt.Errorf("read bookmarks of '%s': %w", filename, err)
|
||||||
@@ -1096,8 +1116,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
fileBookmarks = fb
|
fileBookmarks = fb
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(fileBookmarks) > 0 {
|
fileBookmarks = shiftBookmarks(fileBookmarks, offset)
|
||||||
finalBookmarks = append(finalBookmarks, shiftBookmarks(fileBookmarks, offset)...)
|
|
||||||
|
if titleBookmarks {
|
||||||
|
finalBookmarks = append(finalBookmarks, gotenberg.Bookmark{
|
||||||
|
Title: documentTitle(ctx, engine, inputPath, filename),
|
||||||
|
Page: offset + 1,
|
||||||
|
Children: fileBookmarks,
|
||||||
|
})
|
||||||
|
} else if len(fileBookmarks) > 0 {
|
||||||
|
finalBookmarks = append(finalBookmarks, fileBookmarks...)
|
||||||
}
|
}
|
||||||
|
|
||||||
pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath)
|
pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath)
|
||||||
@@ -1166,10 +1194,16 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
metadata := FormDataPdfMetadata(form, false)
|
metadata := FormDataPdfMetadata(form, false)
|
||||||
encrypt := FormDataPdfEncrypt(form)
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
watermark := FormDataPdfWatermark(form, false)
|
watermarks, wErr := FormDataPdfWatermarks(form)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := FormDataPdfStampFile(form)
|
}
|
||||||
|
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)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
@@ -1185,13 +1219,13 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EnsureWatermarkFile(&watermark, watermarkFile)
|
err = BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
@@ -1214,12 +1248,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("split PDFs: %w", err)
|
return fmt.Errorf("split PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = WatermarkStub(ctx, engine, watermark, outputPaths)
|
err = WatermarkStub(ctx, engine, watermarks, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = StampStub(ctx, engine, stamp, outputPaths)
|
err = StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1697,23 +1731,37 @@ func watermarkRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
form := ctx.FormData()
|
form := ctx.FormData()
|
||||||
stamp := FormDataPdfWatermark(form, true)
|
watermarks, err := FormDataPdfWatermarks(form)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
if err != nil {
|
||||||
|
return fmt.Errorf("form data watermarks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
err := form.
|
var watermarkFiles []string
|
||||||
|
err = form.
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
|
Watermarks(&watermarkFiles).
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EnsureWatermarkFile(&stamp, watermarkFile)
|
if len(watermarks) == 0 {
|
||||||
if err != nil {
|
return api.WrapError(
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1740,23 +1788,41 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
form := ctx.FormData()
|
form := ctx.FormData()
|
||||||
stamp := FormDataPdfStamp(form, true)
|
|
||||||
stampFile := FormDataPdfStampFile(form)
|
// Reading the stamp fields as parallel arrays applies several
|
||||||
|
// stamps in one request. A single occurrence of each field is the
|
||||||
|
// existing single-stamp behavior.
|
||||||
|
stamps, err := FormDataPdfStamps(form)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
err := form.
|
var stampFiles []string
|
||||||
|
err = form.
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
|
Stamps(&stampFiles).
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EnsureStampFile(&stamp, stampFile)
|
if len(stamps) == 0 {
|
||||||
if err != nil {
|
return api.WrapError(
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
errors.New("no stamp provided"),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
"Invalid form data: form field 'stampSource' is required",
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = StampStub(ctx, engine, stamp, inputPaths)
|
err = BindStampFiles(stamps, stampFiles)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = StampStub(ctx, engine, stamps, inputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
181
pkg/modules/pdfengines/stamp_test.go
Normal file
181
pkg/modules/pdfengines/stamp_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package pdfengines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormDataPdfStamps(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
values map[string][]string
|
||||||
|
expect []gotenberg.Stamp
|
||||||
|
expectErr bool
|
||||||
|
expectCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "single text stamp (backward compatible)",
|
||||||
|
values: map[string][]string{
|
||||||
|
"stampSource": {"text"},
|
||||||
|
"stampExpression": {"CONFIDENTIAL"},
|
||||||
|
"stampOptions": {`{"rot":"45"}`},
|
||||||
|
},
|
||||||
|
expect: []gotenberg.Stamp{
|
||||||
|
{Source: "text", Expression: "CONFIDENTIAL", Options: map[string]string{"rot": "45"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "multiple stamps aligned by position",
|
||||||
|
values: map[string][]string{
|
||||||
|
"stampSource": {"text", "image"},
|
||||||
|
"stampExpression": {"ONE"},
|
||||||
|
"stampPages": {"1-2", "3"},
|
||||||
|
"stampOptions": {`{"pos":"tl"}`, `{"pos":"br"}`},
|
||||||
|
},
|
||||||
|
expect: []gotenberg.Stamp{
|
||||||
|
{Source: "text", Expression: "ONE", Pages: "1-2", Options: map[string]string{"pos": "tl"}},
|
||||||
|
{Source: "image", Expression: "", Pages: "3", Options: map[string]string{"pos": "br"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "no stamp fields",
|
||||||
|
values: map[string][]string{},
|
||||||
|
expect: []gotenberg.Stamp{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "invalid source",
|
||||||
|
values: map[string][]string{"stampSource": {"text", "foo"}},
|
||||||
|
expectErr: true,
|
||||||
|
expectCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "invalid options JSON",
|
||||||
|
values: map[string][]string{
|
||||||
|
"stampSource": {"text"},
|
||||||
|
"stampOptions": {"{"},
|
||||||
|
},
|
||||||
|
expectErr: true,
|
||||||
|
expectCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
ctx := &api.ContextMock{Context: &api.Context{}}
|
||||||
|
ctx.SetValues(tc.values)
|
||||||
|
form := ctx.FormData()
|
||||||
|
|
||||||
|
got, err := FormDataPdfStamps(form)
|
||||||
|
|
||||||
|
if tc.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got nil")
|
||||||
|
}
|
||||||
|
var httpErr api.HttpError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("expected an api.HttpError, got %T", err)
|
||||||
|
}
|
||||||
|
if status, _ := httpErr.HttpError(); status != tc.expectCode {
|
||||||
|
t.Fatalf("status = %d, want %d", status, tc.expectCode)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.expect) {
|
||||||
|
t.Fatalf("stamps = %#v, want %#v", got, tc.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindStampFiles(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
stamps []gotenberg.Stamp
|
||||||
|
files []string
|
||||||
|
expect []gotenberg.Stamp
|
||||||
|
expectErr bool
|
||||||
|
expectCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "text stamps consume no files",
|
||||||
|
stamps: []gotenberg.Stamp{{Source: "text", Expression: "FOO"}},
|
||||||
|
expect: []gotenberg.Stamp{{Source: "text", Expression: "FOO"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "image and pdf stamps consume files in order, overwriting expression",
|
||||||
|
stamps: []gotenberg.Stamp{
|
||||||
|
{Source: "image", Expression: "ignored"},
|
||||||
|
{Source: "text", Expression: "MIDDLE"},
|
||||||
|
{Source: "pdf"},
|
||||||
|
},
|
||||||
|
files: []string{"/a.png", "/b.pdf"},
|
||||||
|
expect: []gotenberg.Stamp{
|
||||||
|
{Source: "image", Expression: "/a.png"},
|
||||||
|
{Source: "text", Expression: "MIDDLE"},
|
||||||
|
{Source: "pdf", Expression: "/b.pdf"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "not enough files for the image or pdf stamps",
|
||||||
|
stamps: []gotenberg.Stamp{{Source: "image"}, {Source: "image"}},
|
||||||
|
files: []string{"/a.png"},
|
||||||
|
expectErr: true,
|
||||||
|
expectCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
err := BindStampFiles(tc.stamps, tc.files)
|
||||||
|
|
||||||
|
if tc.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got nil")
|
||||||
|
}
|
||||||
|
var httpErr api.HttpError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("expected an api.HttpError, got %T", err)
|
||||||
|
}
|
||||||
|
if status, _ := httpErr.HttpError(); status != tc.expectCode {
|
||||||
|
t.Fatalf("status = %d, want %d", status, tc.expectCode)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(tc.stamps, tc.expect) {
|
||||||
|
t.Fatalf("stamps = %#v, want %#v", tc.stamps, tc.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,7 +238,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
Invalid form data: no form file found for extensions: [.123 .602 .abw .bib .bmp .cdr .cgm .cmx .csv .cwk .dbf .dif .doc .docm .docx .dot .dotm .dotx .dxf .emf .eps .epub .fodg .fodp .fods .fodt .fopd .gif .htm .html .hwp .jpeg .jpg .key .ltx .lwp .mcw .met .mml .mw .numbers .odd .odg .odm .odp .ods .odt .otg .oth .otp .ots .ott .pages .pbm .pcd .pct .pcx .pdb .pdf .pgm .png .pot .potm .potx .ppm .pps .ppt .pptm .pptx .psd .psw .pub .pwp .pxl .ras .rtf .sda .sdc .sdd .sdp .sdw .sgl .slk .smf .stc .std .sti .stw .svg .svm .swf .sxc .sxd .sxg .sxi .sxm .sxw .tga .tif .tiff .txt .uof .uop .uos .uot .vdx .vor .vsd .vsdm .vsdx .wb2 .wk1 .wks .wmf .wpd .wpg .wps .xbm .xhtml .xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xpm .zabw]
|
Invalid form data: no form file found for extensions: [.123 .602 .abw .bib .bmp .cdr .cgm .cmx .csv .cwk .dbf .dif .doc .docm .docx .dot .dotm .dotx .dxf .emf .eps .epub .fodg .fodp .fods .fodt .fopd .gif .htm .html .hwp .jpeg .jpg .key .ltx .lwp .mcw .met .mml .mw .numbers .odd .odg .odm .odp .ods .odt .otg .oth .otp .ots .ott .pages .pbm .pcd .pct .pcx .pdb .pdf .pgm .png .pot .potm .potx .ppm .pps .ppsm .ppsx .ppt .pptm .pptx .psd .psw .pub .pwp .pxl .ras .rtf .sda .sdc .sdd .sdp .sdw .sgl .slk .smf .stc .std .sti .stw .svg .svm .swf .sxc .sxd .sxg .sxi .sxm .sxw .tga .tif .tiff .txt .uof .uop .uos .uot .vdx .vor .vsd .vsdm .vsdx .wb2 .wk1 .wks .wmf .wpd .wpg .wps .xbm .xhtml .xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xpm .zabw]
|
||||||
form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||||
form field 'exportFormFields' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
form field 'exportFormFields' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||||
form field 'allowDuplicateFieldNames' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
form field 'allowDuplicateFieldNames' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||||
|
|||||||
@@ -291,6 +291,49 @@ Feature: /forms/pdfengines/merge
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# titleBookmarks adds a top-level bookmark per merged document, labeled by its
|
||||||
|
# Title metadata (falling back to the filename) and pointing to its first page,
|
||||||
|
# with the document's own outline nested underneath.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/867.
|
||||||
|
@bookmarks
|
||||||
|
Scenario: POST /forms/pdfengines/merge (Title Bookmarks)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/titled_alpha.pdf | file |
|
||||||
|
| files | testdata/titled_bravo.pdf | file |
|
||||||
|
| files | testdata/untitled_gamma.pdf | file |
|
||||||
|
| titleBookmarks | true | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
|
||||||
|
| files | teststore/foo.pdf | file |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/json"
|
||||||
|
Then the response body should match JSON:
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"foo.pdf": [
|
||||||
|
{
|
||||||
|
"title": "Alpha",
|
||||||
|
"page": 1,
|
||||||
|
"children": [
|
||||||
|
{ "title": "A1", "page": 1 },
|
||||||
|
{ "title": "A2", "page": 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Bravo",
|
||||||
|
"page": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "untitled_gamma",
|
||||||
|
"page": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
@bookmarks
|
@bookmarks
|
||||||
Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks)
|
Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
|
|||||||
@@ -51,6 +51,39 @@ Feature: /forms/pdfengines/stamp
|
|||||||
Then the response header "Content-Type" should be "application/pdf"
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
|
|
||||||
|
# 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. 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 | 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)
|
Scenario: POST /forms/pdfengines/stamp (PDF - pdfcpu)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| PDFENGINES_STAMP_ENGINES | pdfcpu |
|
| PDFENGINES_STAMP_ENGINES | pdfcpu |
|
||||||
|
|||||||
@@ -51,6 +51,39 @@ Feature: /forms/pdfengines/watermark
|
|||||||
Then the response header "Content-Type" should be "application/pdf"
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
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)
|
Scenario: POST /forms/pdfengines/watermark (PDF - pdfcpu)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
|
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ Feature: /
|
|||||||
When I make a "GET" request to Gotenberg at the "/" endpoint
|
When I make a "GET" request to Gotenberg at the "/" endpoint
|
||||||
Then the response status code should be 401
|
Then the response status code should be 401
|
||||||
|
|
||||||
|
# A request without a Bearer token is rejected. The JWKS URL is never fetched
|
||||||
|
# here, so no OIDC provider needs to be reachable.
|
||||||
|
Scenario: GET / (OIDC Auth)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| API_ENABLE_OIDC_AUTH | true |
|
||||||
|
| API_OIDC_ISSUER | https://gotenberg.test/ |
|
||||||
|
| API_OIDC_AUDIENCE | gotenberg |
|
||||||
|
| API_OIDC_JWKS_URL | https://gotenberg.test/jwks.json |
|
||||||
|
When I make a "GET" request to Gotenberg at the "/" endpoint
|
||||||
|
Then the response status code should be 401
|
||||||
|
|
||||||
Scenario: GET /foo/ (Root Path)
|
Scenario: GET /foo/ (Root Path)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| API_ROOT_PATH | /foo/ |
|
| API_ROOT_PATH | /foo/ |
|
||||||
|
|||||||
@@ -29,14 +29,16 @@ func doRequest(method, url string, headers map[string]string, body io.Reader) (*
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doFormDataRequest(method, url string, fields map[string]string, files map[string][]string, headers map[string]string) (*http.Response, error) {
|
func doFormDataRequest(method, url string, fields map[string][]string, files map[string][]string, headers map[string]string) (*http.Response, error) {
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
writer := multipart.NewWriter(&b)
|
writer := multipart.NewWriter(&b)
|
||||||
|
|
||||||
for name, value := range fields {
|
for name, values := range fields {
|
||||||
err := writer.WriteField(name, value)
|
for _, value := range values {
|
||||||
if err != nil {
|
err := writer.WriteField(name, value)
|
||||||
return nil, fmt.Errorf("write field %q: %w", name, err)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("write field %q: %w", name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
|
|||||||
return errors.New("no Gotenberg container")
|
return errors.New("no Gotenberg container")
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := make(map[string]string)
|
fields := make(map[string][]string)
|
||||||
files := make(map[string][]string)
|
files := make(map[string][]string)
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
@@ -220,10 +220,10 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
|
|||||||
switch kind {
|
switch kind {
|
||||||
case "field":
|
case "field":
|
||||||
if name == "downloadFrom" || name == "url" || name == "cookies" {
|
if name == "downloadFrom" || name == "url" || name == "cookies" {
|
||||||
fields[name] = strings.ReplaceAll(value, "%d", fmt.Sprintf("%d", s.hostPort))
|
fields[name] = append(fields[name], strings.ReplaceAll(value, "%d", fmt.Sprintf("%d", s.hostPort)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fields[name] = value
|
fields[name] = append(fields[name], value)
|
||||||
case "file":
|
case "file":
|
||||||
if strings.Contains(value, "teststore") {
|
if strings.Contains(value, "teststore") {
|
||||||
if s.teststoreDir == "" {
|
if s.teststoreDir == "" {
|
||||||
@@ -363,7 +363,7 @@ func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count
|
|||||||
return errors.New("no Gotenberg container")
|
return errors.New("no Gotenberg container")
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := make(map[string]string)
|
fields := make(map[string][]string)
|
||||||
files := make(map[string][]string)
|
files := make(map[string][]string)
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count
|
|||||||
|
|
||||||
switch kind {
|
switch kind {
|
||||||
case "field":
|
case "field":
|
||||||
fields[name] = value
|
fields[name] = append(fields[name], value)
|
||||||
case "file":
|
case "file":
|
||||||
wd, err := os.Getwd()
|
wd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
BIN
test/integration/testdata/titled_alpha.pdf
vendored
Normal file
BIN
test/integration/testdata/titled_alpha.pdf
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/titled_bravo.pdf
vendored
Normal file
BIN
test/integration/testdata/titled_bravo.pdf
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/untitled_gamma.pdf
vendored
Normal file
BIN
test/integration/testdata/untitled_gamma.pdf
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user