mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-18 05:02:15 +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,
|
invalidHttpStatusCode: &invalidHttpStatusCode,
|
||||||
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
|
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
|
||||||
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
|
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
|
||||||
|
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
|
||||||
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
||||||
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -108,6 +108,20 @@ type Options struct {
|
|||||||
// status code from at least one resource matches with one if its entries.
|
// status code from at least one resource matches with one if its entries.
|
||||||
FailOnResourceHttpStatusCodes []int64
|
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
|
// FailOnResourceLoadingFailed sets if the conversion should fail like the
|
||||||
// main page if Chromium fails to load at least one resource.
|
// main page if Chromium fails to load at least one resource.
|
||||||
FailOnResourceLoadingFailed bool
|
FailOnResourceLoadingFailed bool
|
||||||
@@ -150,19 +164,20 @@ type Options struct {
|
|||||||
// DefaultOptions returns the default values for Options.
|
// DefaultOptions returns the default values for Options.
|
||||||
func DefaultOptions() Options {
|
func DefaultOptions() Options {
|
||||||
return Options{
|
return Options{
|
||||||
SkipNetworkIdleEvent: true,
|
SkipNetworkIdleEvent: true,
|
||||||
FailOnHttpStatusCodes: []int64{499, 599},
|
FailOnHttpStatusCodes: []int64{499, 599},
|
||||||
FailOnResourceHttpStatusCodes: nil,
|
FailOnResourceHttpStatusCodes: nil,
|
||||||
FailOnResourceLoadingFailed: false,
|
IgnoreResourceHttpStatusDomains: nil,
|
||||||
FailOnConsoleExceptions: false,
|
FailOnResourceLoadingFailed: false,
|
||||||
WaitDelay: 0,
|
FailOnConsoleExceptions: false,
|
||||||
WaitWindowStatus: "",
|
WaitDelay: 0,
|
||||||
WaitForExpression: "",
|
WaitWindowStatus: "",
|
||||||
Cookies: nil,
|
WaitForExpression: "",
|
||||||
UserAgent: "",
|
Cookies: nil,
|
||||||
ExtraHttpHeaders: nil,
|
UserAgent: "",
|
||||||
EmulatedMediaType: "",
|
ExtraHttpHeaders: nil,
|
||||||
OmitBackground: false,
|
EmulatedMediaType: "",
|
||||||
|
OmitBackground: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
@@ -143,6 +145,7 @@ type eventResponseReceivedOptions struct {
|
|||||||
invalidHttpStatusCode *error
|
invalidHttpStatusCode *error
|
||||||
invalidHttpStatusCodeMu *sync.RWMutex
|
invalidHttpStatusCodeMu *sync.RWMutex
|
||||||
failOnResourceOnHttpStatusCode []int64
|
failOnResourceOnHttpStatusCode []int64
|
||||||
|
ignoreResourceHttpStatusDomains []string
|
||||||
invalidResourceHttpStatusCode *error
|
invalidResourceHttpStatusCode *error
|
||||||
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -157,6 +160,8 @@ func listenForEventResponseReceived(
|
|||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
options eventResponseReceivedOptions,
|
options eventResponseReceivedOptions,
|
||||||
) {
|
) {
|
||||||
|
normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains)
|
||||||
|
|
||||||
for _, code := range []int64{199, 299, 399, 499, 599} {
|
for _, code := range []int64{199, 299, 399, 499, 599} {
|
||||||
if slices.Contains(options.failOnHttpStatusCodes, code) {
|
if slices.Contains(options.failOnHttpStatusCodes, code) {
|
||||||
for i := code - 99; i <= code; i++ {
|
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))
|
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
|
||||||
|
|
||||||
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
|
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()
|
options.invalidResourceHttpStatusCodeMu.Lock()
|
||||||
defer options.invalidResourceHttpStatusCodeMu.Unlock()
|
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 {
|
type eventLoadingFailedOptions struct {
|
||||||
loadingFailed *error
|
loadingFailed *error
|
||||||
loadingFailedMu *sync.RWMutex
|
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,
|
regexp2.None,
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
|
// FormDataChromiumOptions creates [Options] from the form data.
|
||||||
// the default value if the considered key is not present.
|
//
|
||||||
|
// 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) {
|
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||||
defaultOptions := DefaultOptions()
|
defaultOptions := DefaultOptions()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
skipNetworkIdleEvent bool
|
skipNetworkIdleEvent bool
|
||||||
failOnHttpStatusCodes []int64
|
failOnHttpStatusCodes []int64
|
||||||
failOnResourceHttpStatusCodes []int64
|
failOnResourceHttpStatusCodes []int64
|
||||||
failOnResourceLoadingFailed bool
|
ignoreResourceHttpStatusDomains []string
|
||||||
failOnConsoleExceptions bool
|
failOnResourceLoadingFailed bool
|
||||||
waitDelay time.Duration
|
failOnConsoleExceptions bool
|
||||||
waitWindowStatus string
|
waitDelay time.Duration
|
||||||
waitForExpression string
|
waitWindowStatus string
|
||||||
cookies []Cookie
|
waitForExpression string
|
||||||
userAgent string
|
cookies []Cookie
|
||||||
extraHttpHeaders []ExtraHttpHeader
|
userAgent string
|
||||||
emulatedMediaType string
|
extraHttpHeaders []ExtraHttpHeader
|
||||||
omitBackground bool
|
emulatedMediaType string
|
||||||
|
omitBackground bool
|
||||||
)
|
)
|
||||||
|
|
||||||
form := ctx.FormData().
|
form := ctx.FormData().
|
||||||
@@ -78,6 +90,19 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
|
|
||||||
return nil
|
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("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
|
||||||
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
|
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
|
||||||
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
||||||
@@ -203,19 +228,20 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
|
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
|
||||||
|
|
||||||
options := Options{
|
options := Options{
|
||||||
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
||||||
FailOnHttpStatusCodes: failOnHttpStatusCodes,
|
FailOnHttpStatusCodes: failOnHttpStatusCodes,
|
||||||
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
|
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
|
||||||
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
|
IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains,
|
||||||
FailOnConsoleExceptions: failOnConsoleExceptions,
|
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
|
||||||
WaitDelay: waitDelay,
|
FailOnConsoleExceptions: failOnConsoleExceptions,
|
||||||
WaitWindowStatus: waitWindowStatus,
|
WaitDelay: waitDelay,
|
||||||
WaitForExpression: waitForExpression,
|
WaitWindowStatus: waitWindowStatus,
|
||||||
Cookies: cookies,
|
WaitForExpression: waitForExpression,
|
||||||
UserAgent: userAgent,
|
Cookies: cookies,
|
||||||
ExtraHttpHeaders: extraHttpHeaders,
|
UserAgent: userAgent,
|
||||||
EmulatedMediaType: emulatedMediaType,
|
ExtraHttpHeaders: extraHttpHeaders,
|
||||||
OmitBackground: omitBackground,
|
EmulatedMediaType: emulatedMediaType,
|
||||||
|
OmitBackground: omitBackground,
|
||||||
}
|
}
|
||||||
|
|
||||||
return form, options
|
return form, options
|
||||||
|
|||||||
@@ -343,6 +343,16 @@ Feature: /forms/chromium/convert/html
|
|||||||
https://gethttpstatus.com/400 - 400: Bad Request
|
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)
|
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
|
||||||
Given I have a default Gotenberg container
|
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):
|
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