mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
feat(chromium): capture screenshot (#765)
This commit is contained in:
10
go.mod
10
go.mod
@@ -13,21 +13,21 @@ require (
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/labstack/echo/v4 v4.11.3
|
||||
github.com/labstack/gommon v0.4.1
|
||||
github.com/labstack/echo/v4 v4.11.4
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/pdfcpu/pdfcpu v0.6.0
|
||||
github.com/pierrec/lz4/v4 v4.1.19 // indirect
|
||||
github.com/prometheus/client_golang v1.17.0
|
||||
github.com/prometheus/client_golang v1.18.0
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/ulikunitz/xz v0.5.11 // indirect
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/crypto v0.16.0 // indirect
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
golang.org/x/image v0.14.0 // indirect
|
||||
golang.org/x/net v0.19.0
|
||||
golang.org/x/sync v0.5.0
|
||||
@@ -61,6 +61,6 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
22
go.sum
22
go.sum
@@ -28,7 +28,6 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
|
||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU=
|
||||
github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
@@ -63,10 +62,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM=
|
||||
github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
|
||||
github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk=
|
||||
github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM=
|
||||
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
|
||||
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
@@ -98,8 +97,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
|
||||
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
|
||||
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
||||
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
|
||||
@@ -136,8 +135,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
||||
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
@@ -153,9 +152,8 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -22,7 +22,8 @@ import (
|
||||
|
||||
type browser interface {
|
||||
gotenberg.Process
|
||||
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
|
||||
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
type browserArguments struct {
|
||||
@@ -213,9 +214,53 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
|
||||
forceExactColorsActionFunc(),
|
||||
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
|
||||
// PDF specific.
|
||||
printToPdfActionFunc(logger, outputPath, options),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
|
||||
forceExactColorsActionFunc(),
|
||||
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
|
||||
// Screenshot specific.
|
||||
captureScreenshotActionFunc(logger, outputPath, options),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error {
|
||||
if !b.isStarted.Load() {
|
||||
return errors.New("browser not started, cannot handle PDF conversion")
|
||||
return errors.New("browser not started, cannot handle tasks")
|
||||
}
|
||||
|
||||
// We validate the "main" URL against our allow / deny lists.
|
||||
@@ -265,23 +310,6 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
||||
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
|
||||
}
|
||||
|
||||
tasks := chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
|
||||
forceExactColorsActionFunc(),
|
||||
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
|
||||
printToPdfActionFunc(logger, outputPath, options),
|
||||
}
|
||||
|
||||
err := chromedp.Run(taskCtx, tasks...)
|
||||
if err != nil {
|
||||
errMessage := err.Error()
|
||||
@@ -298,7 +326,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
||||
return ErrRpccMessageTooLarge
|
||||
}
|
||||
|
||||
return fmt.Errorf("print to PDF: %w", err)
|
||||
return fmt.Errorf("handle tasks: %w", err)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/613.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,10 +24,6 @@ var (
|
||||
// allowed/denied lists.
|
||||
ErrUrlNotAuthorized = errors.New("URL not authorized")
|
||||
|
||||
// ErrOmitBackgroundWithoutPrintBackground happens if
|
||||
// Options.OmitBackground is set to true but not Options.PrintBackground.
|
||||
ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background")
|
||||
|
||||
// ErrInvalidEmulatedMediaType happens if the emulated media type is not
|
||||
// "screen" nor "print". Empty value are allowed though.
|
||||
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
|
||||
@@ -36,14 +32,6 @@ var (
|
||||
// returns an exception or undefined.
|
||||
ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression")
|
||||
|
||||
// ErrInvalidPrinterSettings happens if the Options have one or more
|
||||
// aberrant values.
|
||||
ErrInvalidPrinterSettings = errors.New("invalid printer settings")
|
||||
|
||||
// ErrPageRangesSyntaxError happens if the Options have an invalid page
|
||||
// ranges.
|
||||
ErrPageRangesSyntaxError = errors.New("page ranges syntax error")
|
||||
|
||||
// ErrRpccMessageTooLarge happens when the messages received by
|
||||
// ChromeDevTools are larger than 100 MB.
|
||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||
@@ -56,6 +44,20 @@ var (
|
||||
// console. It also happens only if the [Options.FailOnConsoleExceptions]
|
||||
// is set to true.
|
||||
ErrConsoleExceptions = errors.New("console exceptions")
|
||||
|
||||
// PDF specific.
|
||||
|
||||
// ErrOmitBackgroundWithoutPrintBackground happens if
|
||||
// PdfOptions.OmitBackground is set to true but not PdfOptions.PrintBackground.
|
||||
ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background")
|
||||
|
||||
// ErrInvalidPrinterSettings happens if the PdfOptions have one or more
|
||||
// aberrant values.
|
||||
ErrInvalidPrinterSettings = errors.New("invalid printer settings")
|
||||
|
||||
// ErrPageRangesSyntaxError happens if the PdfOptions have an invalid page
|
||||
// ranges.
|
||||
ErrPageRangesSyntaxError = errors.New("page ranges syntax error")
|
||||
)
|
||||
|
||||
// Chromium is a module which provides both an [Api] and routes for converting
|
||||
@@ -71,7 +73,7 @@ type Chromium struct {
|
||||
engine gotenberg.PdfEngine
|
||||
}
|
||||
|
||||
// Options are the available expectedOptions for converting HTML document to PDF.
|
||||
// Options are the common options for all conversions.
|
||||
type Options struct {
|
||||
// SkipNetworkIdleEvent set if the conversion should wait for the
|
||||
// "networkIdle" event, drastically improving the conversion speed. It may
|
||||
@@ -91,17 +93,17 @@ type Options struct {
|
||||
FailOnConsoleExceptions bool
|
||||
|
||||
// WaitDelay is the duration to wait when loading an HTML document before
|
||||
// converting it to PDF.
|
||||
// converting it.
|
||||
// Optional.
|
||||
WaitDelay time.Duration
|
||||
|
||||
// WaitWindowStatus is the window.status value to wait for before
|
||||
// converting an HTML document to PDF.
|
||||
// converting an HTML document.
|
||||
// Optional.
|
||||
WaitWindowStatus string
|
||||
|
||||
// WaitForExpression is the custom JavaScript expression to wait before
|
||||
// converting an HTML document to PDF until it returns true
|
||||
// converting an HTML document until it returns true
|
||||
// Optional.
|
||||
WaitForExpression string
|
||||
|
||||
@@ -115,6 +117,31 @@ type Options struct {
|
||||
// Optional.
|
||||
EmulatedMediaType string
|
||||
|
||||
// OmitBackground hides default white background and allows generating PDFs
|
||||
// with transparency.
|
||||
// Optional.
|
||||
OmitBackground bool
|
||||
}
|
||||
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
SkipNetworkIdleEvent: false,
|
||||
FailOnHttpStatusCodes: []int64{499, 599},
|
||||
FailOnConsoleExceptions: false,
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
ExtraHttpHeaders: nil,
|
||||
EmulatedMediaType: "",
|
||||
OmitBackground: false,
|
||||
}
|
||||
}
|
||||
|
||||
// PdfOptions are the available options for converting an HTML document to PDF.
|
||||
type PdfOptions struct {
|
||||
Options
|
||||
|
||||
// Landscape sets the paper orientation.
|
||||
// Optional.
|
||||
Landscape bool
|
||||
@@ -123,11 +150,6 @@ type Options struct {
|
||||
// Optional.
|
||||
PrintBackground bool
|
||||
|
||||
// OmitBackground hides default white background and allows generating PDFs
|
||||
// with transparency.
|
||||
// Optional.
|
||||
OmitBackground bool
|
||||
|
||||
// Scale is the scale of the page rendering.
|
||||
// Optional.
|
||||
Scale float64
|
||||
@@ -184,37 +206,60 @@ type Options struct {
|
||||
PreferCssPageSize bool
|
||||
}
|
||||
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
SkipNetworkIdleEvent: false,
|
||||
FailOnHttpStatusCodes: []int64{499, 599},
|
||||
FailOnConsoleExceptions: false,
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
ExtraHttpHeaders: nil,
|
||||
EmulatedMediaType: "",
|
||||
Landscape: false,
|
||||
PrintBackground: false,
|
||||
OmitBackground: false,
|
||||
Scale: 1.0,
|
||||
PaperWidth: 8.5,
|
||||
PaperHeight: 11,
|
||||
MarginTop: 0.39,
|
||||
MarginBottom: 0.39,
|
||||
MarginLeft: 0.39,
|
||||
MarginRight: 0.39,
|
||||
PageRanges: "",
|
||||
HeaderTemplate: "<html><head></head><body></body></html>",
|
||||
FooterTemplate: "<html><head></head><body></body></html>",
|
||||
PreferCssPageSize: false,
|
||||
// DefaultPdfOptions returns the default values for PdfOptions.
|
||||
func DefaultPdfOptions() PdfOptions {
|
||||
return PdfOptions{
|
||||
Options: DefaultOptions(),
|
||||
Landscape: false,
|
||||
PrintBackground: false,
|
||||
Scale: 1.0,
|
||||
PaperWidth: 8.5,
|
||||
PaperHeight: 11,
|
||||
MarginTop: 0.39,
|
||||
MarginBottom: 0.39,
|
||||
MarginLeft: 0.39,
|
||||
MarginRight: 0.39,
|
||||
PageRanges: "",
|
||||
HeaderTemplate: "<html><head></head><body></body></html>",
|
||||
FooterTemplate: "<html><head></head><body></body></html>",
|
||||
PreferCssPageSize: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ScreenshotOptions are the available options for capturing a screenshot from
|
||||
// an HTML document.
|
||||
type ScreenshotOptions struct {
|
||||
Options
|
||||
|
||||
// Format is the image compression format, either "png" or "jpeg" or
|
||||
// "webp".
|
||||
// Optional.
|
||||
Format string
|
||||
|
||||
// Quality is the compression quality from range [0..100] (jpeg only).
|
||||
// Optional.
|
||||
Quality int
|
||||
|
||||
// OptimizeForSpeed defines whether to optimize image encoding for speed,
|
||||
// not for resulting size.
|
||||
// Optional.
|
||||
OptimizeForSpeed bool
|
||||
}
|
||||
|
||||
// DefaultScreenshotOptions returns the default values for ScreenshotOptions.
|
||||
func DefaultScreenshotOptions() ScreenshotOptions {
|
||||
return ScreenshotOptions{
|
||||
Options: DefaultOptions(),
|
||||
Format: "png",
|
||||
Quality: 100,
|
||||
OptimizeForSpeed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Api helps to interact with Chromium for converting HTML documents to PDF.
|
||||
type Api interface {
|
||||
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
|
||||
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
// Provider is a module interface which exposes a method for creating an [Api]
|
||||
@@ -443,19 +488,31 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
|
||||
|
||||
return []api.Route{
|
||||
convertUrlRoute(mod, mod.engine),
|
||||
screenshotUrlRoute(mod),
|
||||
convertHtmlRoute(mod, mod.engine),
|
||||
screenshotHtmlRoute(mod),
|
||||
convertMarkdownRoute(mod, mod.engine),
|
||||
screenshotMarkdownRoute(mod),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Pdf converts a URL to PDF.
|
||||
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
||||
// Note: no error wrapping because it leaks on console exceptions output.
|
||||
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return mod.supervisor.Run(ctx, logger, func() error {
|
||||
return mod.browser.pdf(ctx, logger, url, outputPath, options)
|
||||
})
|
||||
}
|
||||
|
||||
func (mod *Chromium) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return mod.supervisor.Run(ctx, logger, func() error {
|
||||
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
|
||||
})
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*Chromium)(nil)
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDefaultOptions(t *testing.T) {
|
||||
actual := DefaultOptions()
|
||||
notExpect := Options{}
|
||||
actual := DefaultPdfOptions()
|
||||
notExpect := PdfOptions{}
|
||||
|
||||
if reflect.DeepEqual(actual, notExpect) {
|
||||
t.Errorf("expected %v and got identical %v", actual, notExpect)
|
||||
@@ -461,7 +461,7 @@ func TestChromium_Routes(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
scenario: "routes not disabled",
|
||||
expectRoutes: 3,
|
||||
expectRoutes: 6,
|
||||
disableRoutes: false,
|
||||
},
|
||||
{
|
||||
@@ -495,14 +495,14 @@ func TestChromium_Pdf(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
scenario: "PDF task success",
|
||||
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
||||
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "PDF task error",
|
||||
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
||||
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return errors.New("PDF task error")
|
||||
}},
|
||||
expectError: true,
|
||||
@@ -515,7 +515,49 @@ func TestChromium_Pdf(t *testing.T) {
|
||||
}}
|
||||
mod.browser = tc.browser
|
||||
|
||||
err := mod.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
err := mod.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Screenshot(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
browser browser
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "Screenshot task success",
|
||||
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "Screenshot task error",
|
||||
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return errors.New("screenshot task error")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
return task()
|
||||
}}
|
||||
mod.browser = tc.browser
|
||||
|
||||
err := mod.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
|
||||
@@ -10,23 +10,33 @@ import (
|
||||
|
||||
// ApiMock is a mock for the [Api] interface.
|
||||
type ApiMock struct {
|
||||
PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
|
||||
PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
ScreenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
||||
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return api.PdfMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
func (api *ApiMock) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return api.ScreenshotMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
// browserMock is a mock for the [browser] interface.
|
||||
type browserMock struct {
|
||||
gotenberg.ProcessMock
|
||||
pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
|
||||
pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
||||
func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return b.pdfMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
func (b *browserMock) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return b.screenshotMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Api = (*ApiMock)(nil)
|
||||
|
||||
@@ -9,26 +9,42 @@ import (
|
||||
|
||||
func TestApiMock(t *testing.T) {
|
||||
mock := &ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
},
|
||||
ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ApiMock.Screenshot, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserMock(t *testing.T) {
|
||||
mock := &browserMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
},
|
||||
screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from browserMock.pdf, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from browserMock.screenshot, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,26 +22,21 @@ import (
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
)
|
||||
|
||||
// FormDataChromiumPdfOptions creates [Options] from the form data. Fallback to
|
||||
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
|
||||
// default value if the considered key is not present.
|
||||
func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
defaultOptions := DefaultOptions()
|
||||
|
||||
var (
|
||||
skipNetworkIdleEvent bool
|
||||
failOnHttpStatusCodes []int64
|
||||
failOnConsoleExceptions bool
|
||||
waitDelay time.Duration
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
extraHttpHeaders map[string]string
|
||||
emulatedMediaType string
|
||||
landscape, printBackground, omitBackground bool
|
||||
scale, paperWidth, paperHeight float64
|
||||
marginTop, marginBottom, marginLeft, marginRight float64
|
||||
pageRanges string
|
||||
headerTemplate, footerTemplate string
|
||||
preferCssPageSize bool
|
||||
skipNetworkIdleEvent bool
|
||||
failOnHttpStatusCodes []int64
|
||||
failOnConsoleExceptions bool
|
||||
waitDelay time.Duration
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
extraHttpHeaders map[string]string
|
||||
emulatedMediaType string
|
||||
omitBackground bool
|
||||
)
|
||||
|
||||
form := ctx.FormData().
|
||||
@@ -89,20 +85,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
return nil
|
||||
}).
|
||||
Bool("landscape", &landscape, defaultOptions.Landscape).
|
||||
Bool("printBackground", &printBackground, defaultOptions.PrintBackground).
|
||||
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground).
|
||||
Float64("scale", &scale, defaultOptions.Scale).
|
||||
Float64("paperWidth", &paperWidth, defaultOptions.PaperWidth).
|
||||
Float64("paperHeight", &paperHeight, defaultOptions.PaperHeight).
|
||||
Float64("marginTop", &marginTop, defaultOptions.MarginTop).
|
||||
Float64("marginBottom", &marginBottom, defaultOptions.MarginBottom).
|
||||
Float64("marginLeft", &marginLeft, defaultOptions.MarginLeft).
|
||||
Float64("marginRight", &marginRight, defaultOptions.MarginRight).
|
||||
String("nativePageRanges", &pageRanges, defaultOptions.PageRanges).
|
||||
Content("header.html", &headerTemplate, defaultOptions.HeaderTemplate).
|
||||
Content("footer.html", &footerTemplate, defaultOptions.FooterTemplate).
|
||||
Bool("preferCssPageSize", &preferCssPageSize, defaultOptions.PreferCssPageSize)
|
||||
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
|
||||
|
||||
options := Options{
|
||||
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
||||
@@ -113,34 +96,132 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
WaitForExpression: waitForExpression,
|
||||
ExtraHttpHeaders: extraHttpHeaders,
|
||||
EmulatedMediaType: emulatedMediaType,
|
||||
Landscape: landscape,
|
||||
PrintBackground: printBackground,
|
||||
OmitBackground: omitBackground,
|
||||
Scale: scale,
|
||||
PaperWidth: paperWidth,
|
||||
PaperHeight: paperHeight,
|
||||
MarginTop: marginTop,
|
||||
MarginBottom: marginBottom,
|
||||
MarginLeft: marginLeft,
|
||||
MarginRight: marginRight,
|
||||
PageRanges: pageRanges,
|
||||
HeaderTemplate: headerTemplate,
|
||||
FooterTemplate: footerTemplate,
|
||||
PreferCssPageSize: preferCssPageSize,
|
||||
}
|
||||
|
||||
return form, options
|
||||
}
|
||||
|
||||
// FormDataChromiumPdfOptions creates [PdfOptions] from the form data. Fallback to
|
||||
// default value if the considered key is not present.
|
||||
func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
|
||||
form, options := FormDataChromiumOptions(ctx)
|
||||
defaultPdfOptions := DefaultPdfOptions()
|
||||
|
||||
var (
|
||||
landscape, printBackground bool
|
||||
scale, paperWidth, paperHeight float64
|
||||
marginTop, marginBottom, marginLeft, marginRight float64
|
||||
pageRanges string
|
||||
headerTemplate, footerTemplate string
|
||||
preferCssPageSize bool
|
||||
)
|
||||
|
||||
form.
|
||||
Bool("landscape", &landscape, defaultPdfOptions.Landscape).
|
||||
Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
|
||||
Float64("scale", &scale, defaultPdfOptions.Scale).
|
||||
Float64("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
|
||||
Float64("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
|
||||
Float64("marginTop", &marginTop, defaultPdfOptions.MarginTop).
|
||||
Float64("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom).
|
||||
Float64("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft).
|
||||
Float64("marginRight", &marginRight, defaultPdfOptions.MarginRight).
|
||||
String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges).
|
||||
Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate).
|
||||
Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate).
|
||||
Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize)
|
||||
|
||||
pdfOptions := PdfOptions{
|
||||
Options: options,
|
||||
Landscape: landscape,
|
||||
PrintBackground: printBackground,
|
||||
Scale: scale,
|
||||
PaperWidth: paperWidth,
|
||||
PaperHeight: paperHeight,
|
||||
MarginTop: marginTop,
|
||||
MarginBottom: marginBottom,
|
||||
MarginLeft: marginLeft,
|
||||
MarginRight: marginRight,
|
||||
PageRanges: pageRanges,
|
||||
HeaderTemplate: headerTemplate,
|
||||
FooterTemplate: footerTemplate,
|
||||
PreferCssPageSize: preferCssPageSize,
|
||||
}
|
||||
|
||||
return form, pdfOptions
|
||||
}
|
||||
|
||||
// FormDataChromiumScreenshotOptions creates [ScreenshotOptions] from the form
|
||||
// data. Fallback to default value if the considered key is not present.
|
||||
func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, ScreenshotOptions) {
|
||||
form, options := FormDataChromiumOptions(ctx)
|
||||
defaultScreenshotOptions := DefaultScreenshotOptions()
|
||||
|
||||
var (
|
||||
format string
|
||||
quality int
|
||||
optimizeForSpeed bool
|
||||
)
|
||||
|
||||
form.
|
||||
Custom("format", func(value string) error {
|
||||
if value == "" {
|
||||
format = defaultScreenshotOptions.Format
|
||||
return nil
|
||||
}
|
||||
|
||||
if value != "png" && value != "jpeg" && value != "" {
|
||||
return fmt.Errorf("wrong value, expected either 'png', 'jpeg' or 'webp'")
|
||||
}
|
||||
|
||||
format = value
|
||||
|
||||
return nil
|
||||
}).
|
||||
Custom("quality", func(value string) error {
|
||||
if value == "" {
|
||||
quality = defaultScreenshotOptions.Quality
|
||||
return nil
|
||||
}
|
||||
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if intValue < 0 {
|
||||
return errors.New("value is negative")
|
||||
}
|
||||
|
||||
if intValue > 100 {
|
||||
return errors.New("value is superior to 100")
|
||||
}
|
||||
|
||||
quality = intValue
|
||||
return nil
|
||||
}).
|
||||
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
|
||||
|
||||
screenshotOptions := ScreenshotOptions{
|
||||
Options: options,
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
OptimizeForSpeed: optimizeForSpeed,
|
||||
}
|
||||
|
||||
return form, screenshotOptions
|
||||
}
|
||||
|
||||
// FormDataChromiumPdfFormats creates [gotenberg.PdfFormats] from the form
|
||||
// data. Fallback to default value if the considered key is not present.
|
||||
func FormDataChromiumPdfFormats(ctx *api.Context) gotenberg.PdfFormats {
|
||||
func FormDataChromiumPdfFormats(form *api.FormData) gotenberg.PdfFormats {
|
||||
var (
|
||||
pdfa string
|
||||
pdfua bool
|
||||
)
|
||||
|
||||
ctx.FormData().
|
||||
form.
|
||||
String("pdfa", &pdfa, "").
|
||||
Bool("pdfua", &pdfua, false)
|
||||
|
||||
@@ -159,7 +240,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
@@ -179,6 +260,35 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
// screenshotUrlRoute returns an [api.Route] which can take a screenshot from a
|
||||
// URL.
|
||||
func screenshotUrlRoute(chromium Api) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/screenshot/url",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumScreenshotOptions(ctx)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
MandatoryString("url", &url).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = screenshotUrl(ctx, chromium, url, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("URL screenshot: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// convertHtmlRoute returns an [api.Route] which can convert an HTML file to
|
||||
// PDF.
|
||||
func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
@@ -189,7 +299,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
@@ -210,6 +320,36 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
// screenshotHtmlRoute returns an [api.Route] which can take a screenshot from
|
||||
// an HTML file.
|
||||
func screenshotHtmlRoute(chromium Api) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/screenshot/html",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumScreenshotOptions(ctx)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
MandatoryPath("index.html", &inputPath).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
err = screenshotUrl(ctx, chromium, url, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTML screenshot: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// convertMarkdownRoute returns an [api.Route] which can convert markdown files
|
||||
// to PDF.
|
||||
func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
@@ -220,7 +360,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
@@ -235,79 +375,11 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
// We have to convert each markdown file referenced in the HTML
|
||||
// file to... HTML. Thanks to the "html/template" package, we are
|
||||
// able to provide the "toHTML" function which the user may call
|
||||
// directly inside the HTML file.
|
||||
|
||||
var markdownFilesNotFoundErr error
|
||||
|
||||
tmpl, err := template.
|
||||
New(filepath.Base(inputPath)).
|
||||
Funcs(template.FuncMap{
|
||||
"toHTML": func(filename string) (template.HTML, error) {
|
||||
var path string
|
||||
|
||||
for _, markdownPath := range markdownPaths {
|
||||
markdownFilename := filepath.Base(markdownPath)
|
||||
|
||||
if filename == markdownFilename {
|
||||
path = markdownPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
markdownFilesNotFoundErr = multierr.Append(
|
||||
markdownFilesNotFoundErr,
|
||||
fmt.Errorf("'%s'", filename),
|
||||
)
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
|
||||
}
|
||||
|
||||
unsafe := blackfriday.Run(b)
|
||||
sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
|
||||
|
||||
// #nosec
|
||||
return template.HTML(sanitized), nil
|
||||
},
|
||||
}).ParseFiles(inputPath)
|
||||
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse template file: %w", err)
|
||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||
}
|
||||
|
||||
var buffer bytes.Buffer
|
||||
|
||||
err = tmpl.Execute(&buffer, &struct{}{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
if markdownFilesNotFoundErr != nil {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
inputPath = ctx.GeneratePath(".html")
|
||||
|
||||
err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write template result: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
@@ -318,26 +390,129 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
// convertUrl is a stub which is called by the other methods of this file.
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options Options) error {
|
||||
// screenshotMarkdownRoute returns an [api.Route] which can take a screenshot
|
||||
// from markdown files.
|
||||
func screenshotMarkdownRoute(chromium Api) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/screenshot/markdown",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumScreenshotOptions(ctx)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
markdownPaths []string
|
||||
)
|
||||
|
||||
err := form.
|
||||
MandatoryPath("index.html", &inputPath).
|
||||
MandatoryPaths([]string{".md"}, &markdownPaths).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||
}
|
||||
|
||||
err = screenshotUrl(ctx, chromium, url, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("markdown screenshot: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) (string, error) {
|
||||
// We have to convert each markdown file referenced in the HTML
|
||||
// file to... HTML. Thanks to the "html/template" package, we are
|
||||
// able to provide the "toHTML" function which the user may call
|
||||
// directly inside the HTML file.
|
||||
|
||||
var markdownFilesNotFoundErr error
|
||||
|
||||
tmpl, err := template.
|
||||
New(filepath.Base(inputPath)).
|
||||
Funcs(template.FuncMap{
|
||||
"toHTML": func(filename string) (template.HTML, error) {
|
||||
var path string
|
||||
|
||||
for _, markdownPath := range markdownPaths {
|
||||
markdownFilename := filepath.Base(markdownPath)
|
||||
|
||||
if filename == markdownFilename {
|
||||
path = markdownPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
markdownFilesNotFoundErr = multierr.Append(
|
||||
markdownFilesNotFoundErr,
|
||||
fmt.Errorf("'%s'", filename),
|
||||
)
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
|
||||
}
|
||||
|
||||
unsafe := blackfriday.Run(b)
|
||||
sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
|
||||
|
||||
// #nosec
|
||||
return template.HTML(sanitized), nil
|
||||
},
|
||||
}).ParseFiles(inputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse template file: %w", err)
|
||||
}
|
||||
|
||||
var buffer bytes.Buffer
|
||||
|
||||
err = tmpl.Execute(&buffer, &struct{}{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
if markdownFilesNotFoundErr != nil {
|
||||
return "", api.WrapError(
|
||||
fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
inputPath = ctx.GeneratePath(".html")
|
||||
|
||||
err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write template result: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("file://%s", inputPath), nil
|
||||
}
|
||||
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options PdfOptions) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
|
||||
err = handleChromiumError(err, url, options.Options)
|
||||
if err != nil {
|
||||
|
||||
if errors.Is(err, ErrUrlNotAuthorized) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusForbidden,
|
||||
fmt.Sprintf("'%s' does not match the authorized URLs", url),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
"omitBackground requires printBackground set to true",
|
||||
@@ -345,23 +520,6 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
||||
if options.WaitForExpression == "" {
|
||||
// We do not expect the 'waitWindowStatus' form field to return
|
||||
// an ErrInvalidEvaluationExpression error. In such a scenario,
|
||||
// we return a 500.
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidPrinterSettings) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
@@ -382,26 +540,6 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidHttpStatusCode) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusConflict,
|
||||
fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrConsoleExceptions) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusConflict,
|
||||
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
@@ -440,3 +578,76 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func screenshotUrl(ctx *api.Context, chromium Api, url string, options ScreenshotOptions) error {
|
||||
ext := fmt.Sprintf(".%s", options.Format)
|
||||
outputPath := ctx.GeneratePath(ext)
|
||||
|
||||
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
||||
err = handleChromiumError(err, url, options.Options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("screenshot: %w", err)
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleChromiumError(err error, url string, options Options) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrUrlNotAuthorized) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusForbidden,
|
||||
fmt.Sprintf("'%s' does not match the authorized URLs", url),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
||||
if options.WaitForExpression == "" {
|
||||
// We do not expect the 'waitWindowStatus' form field to return
|
||||
// an ErrInvalidEvaluationExpression error. In such a scenario,
|
||||
// we return a 500.
|
||||
return err
|
||||
}
|
||||
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidHttpStatusCode) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusConflict,
|
||||
fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrConsoleExceptions) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusConflict,
|
||||
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options) chromedp.ActionFunc {
|
||||
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
printToPdf := page.PrintToPDF().
|
||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||
@@ -31,8 +31,8 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
|
||||
WithPageRanges(options.PageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize)
|
||||
|
||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultOptions().HeaderTemplate ||
|
||||
options.FooterTemplate != DefaultOptions().FooterTemplate
|
||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||
|
||||
if !hasCustomHeaderFooter {
|
||||
logger.Debug("no custom header nor footer")
|
||||
@@ -63,7 +63,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := reader.Close()
|
||||
err = reader.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := file.Close()
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
@@ -92,6 +92,47 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
|
||||
}
|
||||
}
|
||||
|
||||
func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
captureScreenshot := page.CaptureScreenshot().
|
||||
WithCaptureBeyondViewport(true).
|
||||
WithFromSurface(true).
|
||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
||||
|
||||
if options.Format == "jpeg" {
|
||||
captureScreenshot = captureScreenshot.
|
||||
WithQuality(int64(options.Quality))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
|
||||
|
||||
buffer, err := captureScreenshot.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("capture screenshot: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open output path: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = file.Write(buffer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write result to output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/753.
|
||||
@@ -211,7 +252,6 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
|
||||
// See https://github.com/gotenberg/gotenberg/issues/226.
|
||||
if !omitBackground {
|
||||
logger.Debug("default white background not hidden")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -270,7 +310,6 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.A
|
||||
return func(ctx context.Context) error {
|
||||
if mediaType == "" {
|
||||
logger.Debug("no emulated media type")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -294,13 +333,11 @@ func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool,
|
||||
return func(ctx context.Context) error {
|
||||
if disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait delay")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if delay <= 0 {
|
||||
logger.Debug("no wait delay")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -321,13 +358,11 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
|
||||
return func(ctx context.Context) error {
|
||||
if disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait expression")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if expression == "" {
|
||||
logger.Debug("no wait expression")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user