mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
485 lines
14 KiB
Go
485 lines
14 KiB
Go
package chromium
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/chromedp/cdproto/fetch"
|
|
"github.com/chromedp/cdproto/network"
|
|
"github.com/chromedp/cdproto/page"
|
|
"github.com/chromedp/chromedp"
|
|
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
|
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
|
flag "github.com/spf13/pflag"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func init() {
|
|
gotenberg.MustRegisterModule(Chromium{})
|
|
}
|
|
|
|
var (
|
|
// ErrURLNotAuthorized happens if a URL is not acceptable according to the
|
|
// allowed/denied lists.
|
|
ErrURLNotAuthorized = errors.New("URL not authorized")
|
|
|
|
// 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")
|
|
)
|
|
|
|
// Chromium is a module which provides both an API and routes for converting
|
|
// HTML document to PDF.
|
|
type Chromium struct {
|
|
binPath string
|
|
engine gotenberg.PDFEngine
|
|
userAgent string
|
|
incognito bool
|
|
ignoreCertificateErrors bool
|
|
allowList *regexp.Regexp
|
|
denyList *regexp.Regexp
|
|
disableRoutes bool
|
|
}
|
|
|
|
// Options are the available options for converting HTML document to PDF.
|
|
type Options struct {
|
|
// WaitDelay is the duration to wait when loading an HTML document before
|
|
// converting it to PDF.
|
|
// Optional.
|
|
WaitDelay time.Duration
|
|
|
|
// WaitWindowStatus is the window.status value to wait for before
|
|
// converting an HTML document to PDF.
|
|
// Optional.
|
|
WaitWindowStatus string
|
|
|
|
// ExtraHTTPHeaders are the HTTP headers to send by Chromium while loading
|
|
// the HTML document.
|
|
// Optional.
|
|
ExtraHTTPHeaders map[string]string
|
|
|
|
// Landscape sets the paper orientation.
|
|
// Optional.
|
|
Landscape bool
|
|
|
|
// PrintBackground prints the background graphics.
|
|
// Optional.
|
|
PrintBackground bool
|
|
|
|
// Scale is the scale of the page rendering.
|
|
// Optional.
|
|
Scale float64
|
|
|
|
// PaperWidth is the paper width, in inches.
|
|
// Optional.
|
|
PaperWidth float64
|
|
|
|
// PaperHeight is the paper height, in inches.
|
|
// Optional.
|
|
PaperHeight float64
|
|
|
|
// MarginTop is the top margin, in inches.
|
|
// Optional.
|
|
MarginTop float64
|
|
|
|
// MarginBottom is the bottom margin, in inches.
|
|
// Optional.
|
|
MarginBottom float64
|
|
|
|
// MarginLeft is the left margin, in inches.
|
|
// Optional.
|
|
MarginLeft float64
|
|
|
|
// MarginRight is the right margin, in inches.
|
|
// Optional.
|
|
MarginRight float64
|
|
|
|
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
|
|
// Optional.
|
|
PageRanges string
|
|
|
|
// HeaderTemplate is the HTML template of the header. It should be valid
|
|
// HTML markup with following classes used to inject printing values into
|
|
// them:
|
|
// - date: formatted print date
|
|
// - title: document title
|
|
// - url: document location
|
|
// - pageNumber: current page number
|
|
// - totalPages: total pages in the document
|
|
// For example, <span class=title></span> would generate span containing
|
|
// the title.
|
|
// Optional.
|
|
HeaderTemplate string
|
|
|
|
// FooterTemplate is the HTML template of the footer. It should use the
|
|
// same format as the HeaderTemplate.
|
|
// Optional.
|
|
FooterTemplate string
|
|
|
|
// PreferCSSPageSize defines whether to prefer page size as defined by CSS.
|
|
// If false, the content will be scaled to fit the paper size.
|
|
// Optional.
|
|
PreferCSSPageSize bool
|
|
}
|
|
|
|
// DefaultOptions returns the default values for Options.
|
|
func DefaultOptions() Options {
|
|
return Options{
|
|
WaitDelay: 0,
|
|
WaitWindowStatus: "",
|
|
ExtraHTTPHeaders: nil,
|
|
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,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Provider is a module interface which exposes a method for creating an API
|
|
// for other modules.
|
|
//
|
|
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
|
// provider, _ := ctx.Module(new(chromium.Provider))
|
|
// chromium, _ := provider.(chromium.Provider).Chromium()
|
|
// }
|
|
type Provider interface {
|
|
Chromium() (API, error)
|
|
}
|
|
|
|
// Descriptor returns a Chromium's module descriptor.
|
|
func (mod Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|
return gotenberg.ModuleDescriptor{
|
|
ID: "chromium",
|
|
FlagSet: func() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
|
|
fs.String("chromium-user-agent", "", "Override the default User-Agent header")
|
|
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
|
|
fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors")
|
|
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
|
|
fs.String("chromium-deny-list", "^file:///[^tmp].*", "Set the denied URLs for Chromium using a regular expression")
|
|
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
|
|
|
return fs
|
|
}(),
|
|
New: func() gotenberg.Module { return new(Chromium) },
|
|
}
|
|
}
|
|
|
|
// Provision sets the module properties.
|
|
func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|
flags := ctx.ParsedFlags()
|
|
mod.ignoreCertificateErrors = flags.MustBool("chromium-ignore-certificate-errors")
|
|
mod.allowList = flags.MustRegexp("chromium-allow-list")
|
|
mod.denyList = flags.MustRegexp("chromium-deny-list")
|
|
mod.disableRoutes = flags.MustBool("chromium-disable-routes")
|
|
|
|
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
|
if !ok {
|
|
return errors.New("CHROMIUM_BIN_PATH environment variable is not set")
|
|
}
|
|
|
|
mod.binPath = binPath
|
|
|
|
provider, err := ctx.Module(new(gotenberg.PDFEngineProvider))
|
|
if err != nil {
|
|
return fmt.Errorf("get PDF engine provider: %w", err)
|
|
}
|
|
|
|
engine, err := provider.(gotenberg.PDFEngineProvider).PDFEngine()
|
|
if err != nil {
|
|
return fmt.Errorf("get PDF engine: %w", err)
|
|
}
|
|
|
|
mod.engine = engine
|
|
|
|
return nil
|
|
}
|
|
|
|
// Validate validates the module properties.
|
|
func (mod Chromium) Validate() error {
|
|
_, err := os.Stat(mod.binPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("chromium binary path does not exist: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Chromium returns an API for interacting with Chromium for converting HTML
|
|
// documents to PDF.
|
|
func (mod Chromium) Chromium() (API, error) {
|
|
return mod, nil
|
|
}
|
|
|
|
// Routes returns the API routes.
|
|
func (mod Chromium) Routes() ([]api.MultipartFormDataRoute, error) {
|
|
if mod.disableRoutes {
|
|
return nil, nil
|
|
}
|
|
|
|
return []api.MultipartFormDataRoute{
|
|
convertURLRoute(mod, mod.engine),
|
|
convertHTMLRoute(mod, mod.engine),
|
|
convertMarkdownRoute(mod, mod.engine),
|
|
}, nil
|
|
}
|
|
|
|
// PDF converts a URL to PDF. It creates a dedicated Chromium instance.
|
|
// Substantial calls to this method may increase CPU and memory usage
|
|
// drastically. In such a scenario, the given context may also be done before
|
|
// the end of the conversion.
|
|
func (mod Chromium) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error {
|
|
userProfileDirPath := gotenberg.NewDirPath()
|
|
|
|
args := append(chromedp.DefaultExecAllocatorOptions[:],
|
|
chromedp.ExecPath(mod.binPath),
|
|
chromedp.NoSandbox,
|
|
// See:
|
|
// https://github.com/puppeteer/puppeteer/issues/661
|
|
// https://github.com/puppeteer/puppeteer/issues/2410
|
|
chromedp.Flag("font-render-hinting", "none"),
|
|
chromedp.UserDataDir(userProfileDirPath),
|
|
)
|
|
|
|
if mod.userAgent != "" {
|
|
args = append(args, chromedp.UserAgent(mod.userAgent))
|
|
}
|
|
|
|
if mod.incognito {
|
|
args = append(args, chromedp.Flag("incognito", mod.incognito))
|
|
}
|
|
|
|
if mod.ignoreCertificateErrors {
|
|
args = append(args, chromedp.IgnoreCertErrors)
|
|
}
|
|
|
|
allocatorCtx, cancel := chromedp.NewExecAllocator(ctx, args...)
|
|
defer cancel()
|
|
|
|
taskCtx, cancel := chromedp.NewContext(allocatorCtx)
|
|
defer cancel()
|
|
|
|
// We validate the "main" URL against our allow / deny lists.
|
|
if !mod.allowList.MatchString(URL) {
|
|
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", URL, ErrURLNotAuthorized)
|
|
}
|
|
|
|
if mod.denyList.String() != "" && mod.denyList.MatchString(URL) {
|
|
return fmt.Errorf("'%s' matches the expression from the denied list: %w", URL, ErrURLNotAuthorized)
|
|
}
|
|
|
|
printToPDF := func(URL string, options Options, result *[]byte) chromedp.Tasks {
|
|
// We validate the underlying requests against our allow / deny lists.
|
|
// If a request does not pass the validation, we make it fail.
|
|
listenForEventRequestPaused(taskCtx, logger, mod.allowList, mod.denyList)
|
|
|
|
return chromedp.Tasks{
|
|
network.Enable(),
|
|
fetch.Enable(),
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
if len(options.ExtraHTTPHeaders) == 0 {
|
|
logger.Debug("no extra HTTP headers")
|
|
|
|
return nil
|
|
}
|
|
|
|
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.ExtraHTTPHeaders))
|
|
|
|
headers := make(network.Headers, len(options.ExtraHTTPHeaders))
|
|
for key, value := range options.ExtraHTTPHeaders {
|
|
headers[key] = value
|
|
}
|
|
|
|
err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("set extra HTTP headers: %w", err)
|
|
}),
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
logger.Debug(fmt.Sprintf("navigate to '%s'", URL))
|
|
|
|
_, _, _, err := page.Navigate(URL).Do(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("navigate to '%s': %w", URL, err)
|
|
}
|
|
|
|
err = runBatch(
|
|
ctx,
|
|
waitForEventDomContentEventFired(ctx, logger),
|
|
waitForEventLoadEventFired(ctx, logger),
|
|
waitForEventNetworkIdle(ctx, logger),
|
|
waitForEventLoadingFinished(ctx, logger),
|
|
)
|
|
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("wait for events: %w", err)
|
|
}),
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
if options.WaitDelay > 0 {
|
|
// We wait for a given amount of time so that JavaScript
|
|
// scripts have a chance to finish before printing the page
|
|
// to PDF.
|
|
logger.Debug(fmt.Sprintf("wait '%s' before print", options.WaitDelay))
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("wait delay: %w", ctx.Err())
|
|
case <-time.After(options.WaitDelay):
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}),
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
if options.WaitWindowStatus == "" {
|
|
return nil
|
|
}
|
|
|
|
// We wait until the evaluation of
|
|
// "window.status === options.WaitWindowStatus" is true or
|
|
// until the context is done.
|
|
logger.Debug(fmt.Sprintf("wait for window.status === '%s' before print", options.WaitWindowStatus))
|
|
|
|
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
ticker.Stop()
|
|
|
|
return fmt.Errorf("wait for window.status === '%s': %w", options.WaitWindowStatus, ctx.Err())
|
|
case <-ticker.C:
|
|
var ok bool
|
|
|
|
evaluate := chromedp.Evaluate(fmt.Sprintf("window.status === '%s'", options.WaitWindowStatus), &ok)
|
|
err := evaluate.Do(ctx)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("evaluate: %w", err)
|
|
}
|
|
|
|
if ok {
|
|
ticker.Stop()
|
|
|
|
return nil
|
|
}
|
|
|
|
continue
|
|
}
|
|
}
|
|
}),
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
printToPDF := page.PrintToPDF().
|
|
WithLandscape(options.Landscape).
|
|
WithPrintBackground(options.PrintBackground).
|
|
WithScale(options.Scale).
|
|
WithPaperWidth(options.PaperWidth).
|
|
WithPaperHeight(options.PaperHeight).
|
|
WithMarginTop(options.MarginTop).
|
|
WithMarginBottom(options.MarginBottom).
|
|
WithMarginLeft(options.MarginLeft).
|
|
WithMarginRight(options.MarginRight).
|
|
WithIgnoreInvalidPageRanges(false).
|
|
WithPageRanges(options.PageRanges).
|
|
WithDisplayHeaderFooter(true).
|
|
WithHeaderTemplate(options.HeaderTemplate).
|
|
WithFooterTemplate(options.FooterTemplate).
|
|
WithPreferCSSPageSize(options.PreferCSSPageSize)
|
|
|
|
logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPDF))
|
|
|
|
data, _, err := printToPDF.Do(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("print to PDF: %w", err)
|
|
}
|
|
|
|
*result = data
|
|
|
|
return nil
|
|
}),
|
|
}
|
|
}
|
|
|
|
var buffer []byte
|
|
err := chromedp.Run(taskCtx, printToPDF(URL, options, &buffer))
|
|
|
|
// Always remove the user profile directory created by Chromium.
|
|
go func() {
|
|
logger.Debug(fmt.Sprintf("remove user profile directory '%s'", userProfileDirPath))
|
|
|
|
err := os.RemoveAll(userProfileDirPath)
|
|
if err != nil {
|
|
logger.Error(fmt.Sprintf("remove user profile directory: %s", err))
|
|
}
|
|
}()
|
|
|
|
if err != nil {
|
|
errMessage := err.Error()
|
|
|
|
if strings.Contains(errMessage, "Show invalid printer settings error (-32000)") {
|
|
return ErrInvalidPrinterSettings
|
|
}
|
|
|
|
if strings.Contains(errMessage, "Page range syntax error") {
|
|
return ErrPageRangesSyntaxError
|
|
}
|
|
|
|
if strings.Contains(errMessage, "rpcc: message too large") {
|
|
return ErrRpccMessageTooLarge
|
|
}
|
|
|
|
return fmt.Errorf("chromium PDF: %w", err)
|
|
}
|
|
|
|
err = ioutil.WriteFile(outputPath, buffer, 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("write result to output path: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Interface guards.
|
|
var (
|
|
_ gotenberg.Module = (*Chromium)(nil)
|
|
_ gotenberg.Provisioner = (*Chromium)(nil)
|
|
_ gotenberg.Validator = (*Chromium)(nil)
|
|
_ api.MultipartFormDataRouter = (*Chromium)(nil)
|
|
_ API = (*Chromium)(nil)
|
|
_ Provider = (*Chromium)(nil)
|
|
)
|