mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
feat(chromium): add IgnoreResourceHttpStatusDomains option to filter out resources based on their hostnames (#1434)
* Introduced `IgnoreResourceHttpStatusDomains` option to filter out resources based on their hostnames - Introduced `IgnoreResourceHttpStatusDomains` option to filter out resources based on their hostnames during HTTP status code checks. - Updated relevant functions to handle domain normalization and matching. - Enhanced the form data handling to include the new option. - Added integration test scenario to verify the functionality of ignoring specified domains. * Updated the `normalizeDomains` function to initialize the `normalized` slice with a predefined capacity based on the input `domains` slice length, improving memory allocation efficiency.
This commit is contained in:
@@ -376,6 +376,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
invalidHttpStatusCode: &invalidHttpStatusCode,
|
||||
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
|
||||
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
|
||||
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
|
||||
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
||||
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
||||
})
|
||||
|
||||
@@ -108,6 +108,20 @@ type Options struct {
|
||||
// status code from at least one resource matches with one if its entries.
|
||||
FailOnResourceHttpStatusCodes []int64
|
||||
|
||||
// IgnoreResourceHttpStatusDomains excludes resources whose hostname matches
|
||||
// one of these domains from the application of
|
||||
// [Options.FailOnResourceHttpStatusCodes].
|
||||
//
|
||||
// A match happens if the hostname equals the domain or is a subdomain of it
|
||||
// (e.g., "browser.sentry-cdn.com" matches "sentry-cdn.com").
|
||||
//
|
||||
// Values are normalized (trimmed, lowercased) and may be provided as:
|
||||
// - "example.com"
|
||||
// - "*.example.com" or ".example.com"
|
||||
// - "example.com:443" (port is ignored)
|
||||
// - "https://example.com/path" (scheme/path are ignored)
|
||||
IgnoreResourceHttpStatusDomains []string
|
||||
|
||||
// FailOnResourceLoadingFailed sets if the conversion should fail like the
|
||||
// main page if Chromium fails to load at least one resource.
|
||||
FailOnResourceLoadingFailed bool
|
||||
@@ -150,19 +164,20 @@ type Options struct {
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
SkipNetworkIdleEvent: true,
|
||||
FailOnHttpStatusCodes: []int64{499, 599},
|
||||
FailOnResourceHttpStatusCodes: nil,
|
||||
FailOnResourceLoadingFailed: false,
|
||||
FailOnConsoleExceptions: false,
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
Cookies: nil,
|
||||
UserAgent: "",
|
||||
ExtraHttpHeaders: nil,
|
||||
EmulatedMediaType: "",
|
||||
OmitBackground: false,
|
||||
SkipNetworkIdleEvent: true,
|
||||
FailOnHttpStatusCodes: []int64{499, 599},
|
||||
FailOnResourceHttpStatusCodes: nil,
|
||||
IgnoreResourceHttpStatusDomains: nil,
|
||||
FailOnResourceLoadingFailed: false,
|
||||
FailOnConsoleExceptions: false,
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
Cookies: nil,
|
||||
UserAgent: "",
|
||||
ExtraHttpHeaders: nil,
|
||||
EmulatedMediaType: "",
|
||||
OmitBackground: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
@@ -143,6 +145,7 @@ type eventResponseReceivedOptions struct {
|
||||
invalidHttpStatusCode *error
|
||||
invalidHttpStatusCodeMu *sync.RWMutex
|
||||
failOnResourceOnHttpStatusCode []int64
|
||||
ignoreResourceHttpStatusDomains []string
|
||||
invalidResourceHttpStatusCode *error
|
||||
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
||||
}
|
||||
@@ -157,6 +160,8 @@ func listenForEventResponseReceived(
|
||||
logger *zap.Logger,
|
||||
options eventResponseReceivedOptions,
|
||||
) {
|
||||
normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains)
|
||||
|
||||
for _, code := range []int64{199, 299, 399, 499, 599} {
|
||||
if slices.Contains(options.failOnHttpStatusCodes, code) {
|
||||
for i := code - 99; i <= code; i++ {
|
||||
@@ -190,6 +195,11 @@ func listenForEventResponseReceived(
|
||||
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
|
||||
|
||||
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
|
||||
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) {
|
||||
logger.Debug(fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL))
|
||||
return
|
||||
}
|
||||
|
||||
options.invalidResourceHttpStatusCodeMu.Lock()
|
||||
defer options.invalidResourceHttpStatusCodeMu.Unlock()
|
||||
|
||||
@@ -202,6 +212,79 @@ func listenForEventResponseReceived(
|
||||
})
|
||||
}
|
||||
|
||||
func shouldCheckResourceHttpStatusCode(rawURL string, ignoreDomains []string) bool {
|
||||
host := hostnameFromURL(rawURL)
|
||||
|
||||
if len(ignoreDomains) > 0 && matchesAnyDomain(host, ignoreDomains) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func hostnameFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
|
||||
func normalizeDomains(domains []string) []string {
|
||||
normalized := make([]string, 0, len(domains))
|
||||
|
||||
for _, domain := range domains {
|
||||
d := normalizeDomain(domain)
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, d)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeDomain(domain string) string {
|
||||
d := strings.ToLower(strings.TrimSpace(domain))
|
||||
if d == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Accept "example.com", "*.example.com", ".example.com", "https://example.com/path",
|
||||
// or "example.com:443".
|
||||
if strings.Contains(d, "://") || strings.HasPrefix(d, "//") {
|
||||
u, err := url.Parse(d)
|
||||
if err == nil && u.Hostname() != "" {
|
||||
d = strings.ToLower(u.Hostname())
|
||||
}
|
||||
} else {
|
||||
// Make it parseable as a URL to extract the hostname and drop any port/path.
|
||||
u, err := url.Parse("https://" + d)
|
||||
if err == nil && u.Hostname() != "" {
|
||||
d = strings.ToLower(u.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
d = strings.TrimPrefix(d, "*.")
|
||||
d = strings.TrimPrefix(d, ".")
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func matchesAnyDomain(host string, domains []string) bool {
|
||||
if host == "" || len(domains) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, domain := range domains {
|
||||
if host == domain || strings.HasSuffix(host, "."+domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type eventLoadingFailedOptions struct {
|
||||
loadingFailed *error
|
||||
loadingFailedMu *sync.RWMutex
|
||||
|
||||
63
pkg/modules/chromium/events_test.go
Normal file
63
pkg/modules/chromium/events_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package chromium
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", in: "", want: ""},
|
||||
{name: "spaces", in: " ", want: ""},
|
||||
{name: "simple", in: "example.com", want: "example.com"},
|
||||
{name: "mixed case", in: "ExAmPlE.Com", want: "example.com"},
|
||||
{name: "leading wildcard", in: "*.example.com", want: "example.com"},
|
||||
{name: "leading dot", in: ".example.com", want: "example.com"},
|
||||
{name: "with scheme and path", in: "https://example.com/foo/bar", want: "example.com"},
|
||||
{name: "with port", in: "example.com:443", want: "example.com"},
|
||||
{name: "with scheme and port", in: "https://example.com:443/foo", want: "example.com"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := normalizeDomain(tt.in); got != tt.want {
|
||||
t.Fatalf("normalizeDomain(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesAnyDomain(t *testing.T) {
|
||||
host := "browser.sentry-cdn.com"
|
||||
|
||||
if !matchesAnyDomain(host, []string{"sentry-cdn.com"}) {
|
||||
t.Fatalf("expected %q to match %q", host, "sentry-cdn.com")
|
||||
}
|
||||
|
||||
if matchesAnyDomain("not-sentry-cdn.com", []string{"sentry-cdn.com"}) {
|
||||
t.Fatalf("expected %q to not match %q", "not-sentry-cdn.com", "sentry-cdn.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCheckResourceHttpStatusCode_IgnoreDomains(t *testing.T) {
|
||||
ignore := normalizeDomains([]string{"sentry.io"})
|
||||
|
||||
if shouldCheckResourceHttpStatusCode("https://sentry.io/api/123", ignore) {
|
||||
t.Fatalf("expected ignored domain to be skipped")
|
||||
}
|
||||
|
||||
if shouldCheckResourceHttpStatusCode("https://sub.sentry.io/api/123", ignore) {
|
||||
t.Fatalf("expected ignored subdomain to be skipped")
|
||||
}
|
||||
|
||||
if !shouldCheckResourceHttpStatusCode("https://other.com/api/123", ignore) {
|
||||
t.Fatalf("expected non-ignored domain to be checked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
|
||||
if !shouldCheckResourceHttpStatusCode("data:text/plain,hello", nil) {
|
||||
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
|
||||
}
|
||||
}
|
||||
@@ -29,25 +29,37 @@ var sameSiteRegexp = regexp2.MustCompile(
|
||||
regexp2.None,
|
||||
)
|
||||
|
||||
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
|
||||
// the default value if the considered key is not present.
|
||||
// FormDataChromiumOptions creates [Options] from the form data.
|
||||
//
|
||||
// It falls back to the default value if the considered key is not present.
|
||||
//
|
||||
// JSON-encoded fields:
|
||||
// - failOnHttpStatusCodes: []int
|
||||
// - failOnResourceHttpStatusCodes: []int
|
||||
// - ignoreResourceHttpStatusDomains: []string
|
||||
// - cookies: []Cookie
|
||||
// - extraHttpHeaders: map[string]string
|
||||
//
|
||||
// Domain filtering only applies to resource checks triggered by
|
||||
// "failOnResourceHttpStatusCodes".
|
||||
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
defaultOptions := DefaultOptions()
|
||||
|
||||
var (
|
||||
skipNetworkIdleEvent bool
|
||||
failOnHttpStatusCodes []int64
|
||||
failOnResourceHttpStatusCodes []int64
|
||||
failOnResourceLoadingFailed bool
|
||||
failOnConsoleExceptions bool
|
||||
waitDelay time.Duration
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
cookies []Cookie
|
||||
userAgent string
|
||||
extraHttpHeaders []ExtraHttpHeader
|
||||
emulatedMediaType string
|
||||
omitBackground bool
|
||||
skipNetworkIdleEvent bool
|
||||
failOnHttpStatusCodes []int64
|
||||
failOnResourceHttpStatusCodes []int64
|
||||
ignoreResourceHttpStatusDomains []string
|
||||
failOnResourceLoadingFailed bool
|
||||
failOnConsoleExceptions bool
|
||||
waitDelay time.Duration
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
cookies []Cookie
|
||||
userAgent string
|
||||
extraHttpHeaders []ExtraHttpHeader
|
||||
emulatedMediaType string
|
||||
omitBackground bool
|
||||
)
|
||||
|
||||
form := ctx.FormData().
|
||||
@@ -78,6 +90,19 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
return nil
|
||||
}).
|
||||
Custom("ignoreResourceHttpStatusDomains", func(value string) error {
|
||||
if value == "" {
|
||||
ignoreResourceHttpStatusDomains = defaultOptions.IgnoreResourceHttpStatusDomains
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &ignoreResourceHttpStatusDomains)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal ignoreResourceHttpStatusDomains: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}).
|
||||
Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
|
||||
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
|
||||
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
||||
@@ -203,19 +228,20 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
|
||||
|
||||
options := Options{
|
||||
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
||||
FailOnHttpStatusCodes: failOnHttpStatusCodes,
|
||||
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
|
||||
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
|
||||
FailOnConsoleExceptions: failOnConsoleExceptions,
|
||||
WaitDelay: waitDelay,
|
||||
WaitWindowStatus: waitWindowStatus,
|
||||
WaitForExpression: waitForExpression,
|
||||
Cookies: cookies,
|
||||
UserAgent: userAgent,
|
||||
ExtraHttpHeaders: extraHttpHeaders,
|
||||
EmulatedMediaType: emulatedMediaType,
|
||||
OmitBackground: omitBackground,
|
||||
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
||||
FailOnHttpStatusCodes: failOnHttpStatusCodes,
|
||||
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
|
||||
IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains,
|
||||
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
|
||||
FailOnConsoleExceptions: failOnConsoleExceptions,
|
||||
WaitDelay: waitDelay,
|
||||
WaitWindowStatus: waitWindowStatus,
|
||||
WaitForExpression: waitForExpression,
|
||||
Cookies: cookies,
|
||||
UserAgent: userAgent,
|
||||
ExtraHttpHeaders: extraHttpHeaders,
|
||||
EmulatedMediaType: emulatedMediaType,
|
||||
OmitBackground: omitBackground,
|
||||
}
|
||||
|
||||
return form, options
|
||||
|
||||
@@ -343,6 +343,16 @@ Feature: /forms/chromium/convert/html
|
||||
https://gethttpstatus.com/400 - 400: Bad Request
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Fail On Resource HTTP Status Codes - Ignore Domains)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/feature-rich-html/index.html | file |
|
||||
| failOnResourceHttpStatusCodes | [499,599] | field |
|
||||
| ignoreResourceHttpStatusDomains | ["gethttpstatus.com"] | field |
|
||||
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
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
|
||||
Reference in New Issue
Block a user