From 26b29ec4be1b057f1c2d396e30f30b47b95915e8 Mon Sep 17 00:00:00 2001 From: Catherine Date: Tue, 11 Nov 2025 15:13:46 +0000 Subject: [PATCH] Add Netlify `_headers` support. --- conf/config.example.toml | 1 + flake.nix | 2 +- go.mod | 7 ++ go.sum | 2 + src/backend.go | 2 +- src/backend_fs.go | 4 +- src/backend_s3.go | 4 +- src/config.go | 4 + src/headers.go | 161 +++++++++++++++++++++++++++++ src/manifest.go | 10 +- src/pages.go | 29 +++++- src/redirects.go | 24 ++--- src/schema.pb.go | 216 +++++++++++++++++++++++++++++++-------- src/schema.proto | 18 +++- 14 files changed, 414 insertions(+), 70 deletions(-) create mode 100644 src/headers.go diff --git a/conf/config.example.toml b/conf/config.example.toml index 379e1d9..fbb65ff 100644 --- a/conf/config.example.toml +++ b/conf/config.example.toml @@ -47,6 +47,7 @@ update-timeout = "60s" max-heap-size-ratio = 0.5 # * RAM_size forbidden-domains = [] # allowed-repository-url-prefixes = +allowed-custom-headers = [] [observability] slow-response-threshold = "500ms" diff --git a/flake.nix b/flake.nix index 869b8b6..5b1a5d8 100644 --- a/flake.nix +++ b/flake.nix @@ -43,7 +43,7 @@ "-s -w" ]; - vendorHash = "sha256-qxqIz8hubOBpvJ60h44kY/GbFcZg15WA1JxyskNKtKk="; + vendorHash = "sha256-UQl8AeijqJd2qpVZBDuHT/+Dtd3+Uwrf4w4yAOaFs98="; }; in { diff --git a/go.mod b/go.mod index b58e8fa..e1a099e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module codeberg.org/git-pages/git-pages go 1.25.0 require ( + codeberg.org/git-pages/go-headers v1.0.0 github.com/KimMachineGun/automemlimit v0.7.5 github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 github.com/creasty/defaults v1.8.0 @@ -10,6 +11,7 @@ require ( github.com/getsentry/sentry-go/slog v0.36.2 github.com/go-git/go-billy/v6 v6.0.0-20251026101908-623011986e70 github.com/go-git/go-git/v6 v6.0.0-20251029213217-0bbfc0875edd + github.com/honeycombio/otel-config-go v1.17.0 github.com/klauspost/compress v1.18.1 github.com/maypok86/otter/v2 v2.2.1 github.com/minio/minio-go/v7 v7.0.95 @@ -19,6 +21,11 @@ require ( github.com/samber/slog-multi v1.5.0 github.com/tj/go-redirects v0.0.0-20200911105812-fd1ba1020b37 github.com/valyala/fasttemplate v1.2.2 + go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 + go.opentelemetry.io/otel/log v0.14.0 + go.opentelemetry.io/otel/sdk/log v0.14.0 google.golang.org/protobuf v1.36.10 ) diff --git a/go.sum b/go.sum index c0da93e..85b247e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +codeberg.org/git-pages/go-headers v1.0.0 h1:hvGU97hQdXaT5HwCpZJWQdg7akvtOBCSUNL4u2a5uTs= +codeberg.org/git-pages/go-headers v1.0.0/go.mod h1:N4gwH0U3YPwmuyxqH7xBA8j44fTPX+vOEP7ejJVBPts= github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk= github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= diff --git a/src/backend.go b/src/backend.go index b82b7e3..a9635be 100644 --- a/src/backend.go +++ b/src/backend.go @@ -10,7 +10,7 @@ import ( "time" ) -var ObjectNotFoundError = errors.New("not found") +var ErrObjectNotFound = errors.New("not found") func splitBlobName(name string) []string { algo, hash, found := strings.Cut(name, "-") diff --git a/src/backend_fs.go b/src/backend_fs.go index eacba8e..c821642 100644 --- a/src/backend_fs.go +++ b/src/backend_fs.go @@ -100,7 +100,7 @@ func (fs *FSBackend) GetBlob( blobPath := filepath.Join(splitBlobName(name)...) stat, err := fs.blobRoot.Stat(blobPath) if errors.Is(err, os.ErrNotExist) { - err = fmt.Errorf("%w: %s", ObjectNotFoundError, err.(*os.PathError).Path) + err = fmt.Errorf("%w: %s", ErrObjectNotFound, err.(*os.PathError).Path) return } else if err != nil { err = fmt.Errorf("stat: %w", err) @@ -171,7 +171,7 @@ func (b *FSBackend) ListManifests(ctx context.Context) (manifests []string, err func (fs *FSBackend) GetManifest(ctx context.Context, name string, opts GetManifestOptions) (*Manifest, error) { data, err := fs.siteRoot.ReadFile(name) if errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("%w: %s", ObjectNotFoundError, err.(*os.PathError).Path) + return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, err.(*os.PathError).Path) } else if err != nil { return nil, err } diff --git a/src/backend_s3.go b/src/backend_s3.go index 687f3ee..e922236 100644 --- a/src/backend_s3.go +++ b/src/backend_s3.go @@ -299,7 +299,7 @@ func (s3 *S3Backend) GetBlob( cached, err = s3.blobCache.Get(ctx, name, otter.LoaderFunc[string, *CachedBlob](loader)) if err != nil { if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { - err = fmt.Errorf("%w: %s", ObjectNotFoundError, errResp.Key) + err = fmt.Errorf("%w: %s", ErrObjectNotFound, errResp.Key) } } else { reader = bytes.NewReader(cached.blob) @@ -433,7 +433,7 @@ func (l s3ManifestLoader) load(ctx context.Context, name string, oldManifest *Ca if err != nil { if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { - err = fmt.Errorf("%w: %s", ObjectNotFoundError, errResp.Key) + err = fmt.Errorf("%w: %s", ErrObjectNotFound, errResp.Key) return &CachedManifest{nil, 1, etag, err}, nil } else if errResp.StatusCode == http.StatusNotModified && oldManifest != nil { return oldManifest, nil diff --git a/src/config.go b/src/config.go index 8eedd9e..6b19e67 100644 --- a/src/config.go +++ b/src/config.go @@ -109,6 +109,10 @@ type LimitsConfig struct { ForbiddenDomains []string `toml:"forbidden-domains" default:"[]"` // List of allowed repository URL prefixes. Setting this option prohibits uploading archives. AllowedRepositoryURLPrefixes []string `toml:"allowed-repository-url-prefixes"` + // List of allowed custom headers. Header name must be in the MIME canonical form, + // e.g. `Foo-Bar`. Setting this option permits including this custom header in `_headers`, + // unless it is fundamentally unsafe. + AllowedCustomHeaders []string `toml:"allowed-custom-headers" default:"[]"` } type ObservabilityConfig struct { diff --git a/src/headers.go b/src/headers.go new file mode 100644 index 0000000..23ef4b0 --- /dev/null +++ b/src/headers.go @@ -0,0 +1,161 @@ +package git_pages + +import ( + "errors" + "fmt" + "net/http" + "net/textproto" + "net/url" + "slices" + "strings" + + "codeberg.org/git-pages/go-headers" + "google.golang.org/protobuf/proto" +) + +var ErrHeaderNotAllowed = errors.New("custom header not allowed") + +const headersFileName string = "_headers" + +// Lifted from https://docs.netlify.com/manage/routing/headers/, except for `Set-Cookie` +// the rationale for which does not apply in our environment. +var unsafeHeaders []string = []string{ + "Accept-Ranges", + "Age", + "Allow", + "Alt-Svc", + "Connection", + "Content-Encoding", + "Content-Length", + "Content-Range", + "Date", + "Location", // use `_redirects` instead + "Server", + "Trailer", + "Transfer-Encoding", + "Upgrade", +} + +func IsAllowedCustomHeader(header string) bool { + header = textproto.CanonicalMIMEHeaderKey(header) + switch { + case slices.Contains(unsafeHeaders, header): + return false // explicitly unsafe + case slices.Contains(config.Limits.AllowedCustomHeaders, header): + return true // explicitly allowlisted + default: + return false // deny by default; we don't know what the future holds + } +} + +func validateHeaderRule(rule headers.Rule) error { + url, err := url.Parse(rule.Path) + if err != nil { + return fmt.Errorf("malformed path") + } + if url.Scheme != "" { + return fmt.Errorf("path must not contain a scheme") + } + if !strings.HasPrefix(url.Path, "/") { + return fmt.Errorf("path must start with a /") + } + // Per Netlify documentation: + // > Wildcards (*) can be used at any place inside of a path segment to match any character. + // However, we currently do not implement this, for simplicity. Instead we implement a strict + // subset of the syntactically allowed wildcards. + if strings.Contains(url.Path, "*") && !strings.HasSuffix(url.Path, "/*") { + return fmt.Errorf("splat * must be its own final segment of the path") + } + // Note that this isn't our only line of defense against forbidden headers; + // the purpose of this check is just to inform the uploader of a problem. + // If the validation rules change after a manifest is uploaded, we could + // still end up attempting to serve a forbidden header. + for header := range rule.Headers { + if slices.Contains(unsafeHeaders, header) { + return fmt.Errorf("rule sets header %q (fundamentally unsafe)", header) + } + if !slices.Contains(config.Limits.AllowedCustomHeaders, header) { + return fmt.Errorf("rule sets header %q (not allowlisted)", header) + } + if !IsAllowedCustomHeader(header) { // make sure we don't desync + panic(errors.New("header check inconsistency")) + } + } + return nil +} + +// Parses redirects file and injects rules into the manifest. +func ProcessHeadersFile(manifest *Manifest) error { + headersEntry := manifest.Contents[headersFileName] + delete(manifest.Contents, headersFileName) + if headersEntry == nil { + return nil + } else if headersEntry.GetType() != Type_InlineFile { + return AddProblem(manifest, headersFileName, + "not a regular file") + } + + rules, err := headers.ParseString(string(headersEntry.GetData())) + if err != nil { + return AddProblem(manifest, headersFileName, + "syntax error: %s", err) + } + + for index, rule := range rules { + if err := validateHeaderRule(rule); err != nil { + return AddProblem(manifest, headersFileName, + "rule #%d %q: %s", index+1, rule.Path, err) + } + headerMap := []*Header{} + for header, values := range rule.Headers { + headerMap = append(headerMap, &Header{ + Name: proto.String(header), + Values: values, + }) + } + manifest.Headers = append(manifest.Headers, &HeaderRule{ + Path: proto.String(rule.Path), + HeaderMap: headerMap, + }) + } + return nil +} + +func ApplyHeaderRules(manifest *Manifest, url *url.URL) (headers http.Header, err error) { + headers = http.Header{} + fromSegments := pathSegments(url.Path) +next: + for _, rule := range manifest.Headers { + // check if the rule matches url + ruleURL, _ := url.Parse(*rule.Path) // pre-validated in `validateHeaderRule` + ruleSegments := pathSegments(ruleURL.Path) + if ruleSegments[len(ruleSegments)-1] != "*" { + if len(ruleSegments) < len(fromSegments) { + continue + } + } + for index, ruleFromSegment := range ruleSegments { + if ruleFromSegment == "*" { + break + } + if len(fromSegments) <= index { + continue next + } + if fromSegments[index] != ruleFromSegment { + continue next + } + } + // the rule has matched url, validate headers against up-to-date policy + for _, header := range rule.HeaderMap { + name := header.GetName() + if !IsAllowedCustomHeader(name) { + return nil, fmt.Errorf("%w: %s", ErrHeaderNotAllowed, name) + } + for _, value := range header.GetValues() { + headers.Add(name, value) + } + } + break + } + return +} diff --git a/src/manifest.go b/src/manifest.go index 713c920..b50e311 100644 --- a/src/manifest.go +++ b/src/manifest.go @@ -200,12 +200,19 @@ func CompressFiles(ctx context.Context, manifest *Manifest) { // (Perhaps in the future they could be exposed at `.git-pages/status.txt`?) func PrepareManifest(ctx context.Context, manifest *Manifest) error { // Parse Netlify-style `_redirects` - if err := ProcessRedirects(manifest); err != nil { + if err := ProcessRedirectsFile(manifest); err != nil { log.Printf("redirects err: %s\n", err) } else if len(manifest.Redirects) > 0 { log.Printf("redirects ok: %d rules\n", len(manifest.Redirects)) } + // Parse Netlify-style `_headers` + if err := ProcessHeadersFile(manifest); err != nil { + log.Printf("headers err: %s\n", err) + } else if len(manifest.Headers) > 0 { + log.Printf("headers ok: %d rules\n", len(manifest.Headers)) + } + DetectContentType(manifest) if config.Feature("compress") { @@ -230,6 +237,7 @@ func StoreManifest(ctx context.Context, name string, manifest *Manifest) (*Manif Commit: manifest.Commit, Contents: make(map[string]*Entry), Redirects: manifest.Redirects, + Headers: manifest.Headers, Problems: manifest.Problems, TotalSize: proto.Int64(0), StoredSize: proto.Int64(0), diff --git a/src/pages.go b/src/pages.go index c823a81..1c02140 100644 --- a/src/pages.go +++ b/src/pages.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log" + "maps" "net/http" "net/url" "os" @@ -109,10 +110,10 @@ func getPage(w http.ResponseWriter, r *http.Request) error { sitePath, manifest = projectPath, projectManifest } } - if manifest == nil && (err == nil || errors.Is(err, ObjectNotFoundError)) { + if manifest == nil && (err == nil || errors.Is(err, ErrObjectNotFound)) { result := <-indexManifestCh manifest, err = result.manifest, result.err - if manifest == nil && errors.Is(err, ObjectNotFoundError) { + if manifest == nil && errors.Is(err, ErrObjectNotFound) { if found, fallbackErr := HandleWildcardFallback(w, r); found { return fallbackErr } else { @@ -181,7 +182,7 @@ func getPage(w http.ResponseWriter, r *http.Request) error { redirectKind = RedirectForce } originalURL := (&url.URL{Host: r.Host}).ResolveReference(r.URL) - redirectURL, redirectStatus := ApplyRedirects(manifest, originalURL, redirectKind) + redirectURL, redirectStatus := ApplyRedirectRules(manifest, originalURL, redirectKind) if Is3xxHTTPStatus(redirectStatus) { w.Header().Set("Location", redirectURL.String()) w.WriteHeader(int(redirectStatus)) @@ -284,6 +285,7 @@ func getPage(w http.ResponseWriter, r *http.Request) error { if entry != nil && entry.ContentType != nil { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Type", *entry.ContentType) + // mediaType := getMediaType(*entry.ContentType) if slices.Contains( []string{"", "text/html", "application/xhtml+xml", "text/javascript"}, @@ -294,6 +296,27 @@ func getPage(w http.ResponseWriter, r *http.Request) error { w.Header().Set("Cross-Origin-Embedder-Policy", "credentialless") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") } + // + } + + customHeaders, err := ApplyHeaderRules(manifest, &url.URL{Path: entryPath}) + if err != nil { + // This is an "internal server error" from an HTTP point of view, but also + // either an issue with the site or a misconfiguration from our point of view. + // Since it's not a problem with the server we don't observe the error. + // + // Note that this behavior is different from a site upload with a malformed + // `_headers` file (where it is semantically ignored); this is because a broken + // upload is something the uploader can notice and fix, but a change in server + // configuration is something they are unaware of and won't be notified of. + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "%s\n", err) + return err + } else { + // If the header has passed all of our stringent, deny-by-default checks, it means + // it's good enough to overwrite whatever was our builtin option (if any). + maps.Copy(w.Header(), customHeaders) } // decide on the HTTP status diff --git a/src/redirects.go b/src/redirects.go index 29205d0..b88a12b 100644 --- a/src/redirects.go +++ b/src/redirects.go @@ -11,7 +11,7 @@ import ( "google.golang.org/protobuf/proto" ) -const redirectsFile string = "_redirects" +const redirectsFileName string = "_redirects" func unparseRule(rule redirects.Rule) string { var statusPart string @@ -49,7 +49,7 @@ func Is3xxHTTPStatus(status uint) bool { return status >= 300 && status <= 399 } -func validateRule(rule redirects.Rule) error { +func validateRedirectRule(rule redirects.Rule) error { if len(rule.Params) > 0 { return fmt.Errorf("rules with parameters are not supported") } @@ -84,28 +84,28 @@ func validateRule(rule redirects.Rule) error { } // Parses redirects file and injects rules into the manifest. -func ProcessRedirects(manifest *Manifest) error { - redirectsEntry := manifest.Contents[redirectsFile] - delete(manifest.Contents, redirectsFile) +func ProcessRedirectsFile(manifest *Manifest) error { + redirectsEntry := manifest.Contents[redirectsFileName] + delete(manifest.Contents, redirectsFileName) if redirectsEntry == nil { return nil } else if redirectsEntry.GetType() != Type_InlineFile { - return AddProblem(manifest, redirectsFile, + return AddProblem(manifest, redirectsFileName, "not a regular file") } rules, err := redirects.ParseString(string(redirectsEntry.GetData())) if err != nil { - return AddProblem(manifest, redirectsFile, + return AddProblem(manifest, redirectsFileName, "syntax error: %s", err) } for index, rule := range rules { - if err := validateRule(rule); err != nil { - return AddProblem(manifest, redirectsFile, + if err := validateRedirectRule(rule); err != nil { + return AddProblem(manifest, redirectsFileName, "rule #%d %q: %s", index+1, unparseRule(rule), err) } - manifest.Redirects = append(manifest.Redirects, &Redirect{ + manifest.Redirects = append(manifest.Redirects, &RedirectRule{ From: proto.String(rule.From), To: proto.String(rule.To), Status: proto.Uint32(uint32(rule.Status)), @@ -134,7 +134,7 @@ const ( RedirectForce ) -func ApplyRedirects( +func ApplyRedirectRules( manifest *Manifest, fromURL *url.URL, kind RedirectKind, ) ( toURL *url.URL, status uint, @@ -146,7 +146,7 @@ next: continue } // check if the rule matches fromURL - ruleFromURL, _ := url.Parse(*rule.From) // pre-validated in `validateRule` + ruleFromURL, _ := url.Parse(*rule.From) // pre-validated in `validateRedirectRule` if ruleFromURL.Scheme != "" && fromURL.Scheme != ruleFromURL.Scheme { continue } diff --git a/src/schema.pb.go b/src/schema.pb.go index 6f99c4b..226bfd1 100644 --- a/src/schema.pb.go +++ b/src/schema.pb.go @@ -220,7 +220,7 @@ func (x *Entry) GetContentType() string { // See https://docs.netlify.com/manage/routing/redirects/overview/ for details. // Only a subset of the Netlify specification is representable here. -type Redirect struct { +type RedirectRule struct { state protoimpl.MessageState `protogen:"open.v1"` From *string `protobuf:"bytes,1,opt,name=from" json:"from,omitempty"` To *string `protobuf:"bytes,2,opt,name=to" json:"to,omitempty"` @@ -230,20 +230,20 @@ type Redirect struct { sizeCache protoimpl.SizeCache } -func (x *Redirect) Reset() { - *x = Redirect{} +func (x *RedirectRule) Reset() { + *x = RedirectRule{} mi := &file_schema_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *Redirect) String() string { +func (x *RedirectRule) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Redirect) ProtoMessage() {} +func (*RedirectRule) ProtoMessage() {} -func (x *Redirect) ProtoReflect() protoreflect.Message { +func (x *RedirectRule) ProtoReflect() protoreflect.Message { mi := &file_schema_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -255,39 +255,144 @@ func (x *Redirect) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Redirect.ProtoReflect.Descriptor instead. -func (*Redirect) Descriptor() ([]byte, []int) { +// Deprecated: Use RedirectRule.ProtoReflect.Descriptor instead. +func (*RedirectRule) Descriptor() ([]byte, []int) { return file_schema_proto_rawDescGZIP(), []int{1} } -func (x *Redirect) GetFrom() string { +func (x *RedirectRule) GetFrom() string { if x != nil && x.From != nil { return *x.From } return "" } -func (x *Redirect) GetTo() string { +func (x *RedirectRule) GetTo() string { if x != nil && x.To != nil { return *x.To } return "" } -func (x *Redirect) GetStatus() uint32 { +func (x *RedirectRule) GetStatus() uint32 { if x != nil && x.Status != nil { return *x.Status } return 0 } -func (x *Redirect) GetForce() bool { +func (x *RedirectRule) GetForce() bool { if x != nil && x.Force != nil { return *x.Force } return false } +// See https://docs.netlify.com/manage/routing/headers/ for details. +type Header struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name *string `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"` + Values []string `protobuf:"bytes,2,rep,name=values" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Header) Reset() { + *x = Header{} + mi := &file_schema_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Header) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Header) ProtoMessage() {} + +func (x *Header) ProtoReflect() protoreflect.Message { + mi := &file_schema_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Header.ProtoReflect.Descriptor instead. +func (*Header) Descriptor() ([]byte, []int) { + return file_schema_proto_rawDescGZIP(), []int{2} +} + +func (x *Header) GetName() string { + if x != nil && x.Name != nil { + return *x.Name + } + return "" +} + +func (x *Header) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + +type HeaderRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path *string `protobuf:"bytes,1,opt,name=path" json:"path,omitempty"` + HeaderMap []*Header `protobuf:"bytes,2,rep,name=header_map,json=headerMap" json:"header_map,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeaderRule) Reset() { + *x = HeaderRule{} + mi := &file_schema_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeaderRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeaderRule) ProtoMessage() {} + +func (x *HeaderRule) ProtoReflect() protoreflect.Message { + mi := &file_schema_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeaderRule.ProtoReflect.Descriptor instead. +func (*HeaderRule) Descriptor() ([]byte, []int) { + return file_schema_proto_rawDescGZIP(), []int{3} +} + +func (x *HeaderRule) GetPath() string { + if x != nil && x.Path != nil { + return *x.Path + } + return "" +} + +func (x *HeaderRule) GetHeaderMap() []*Header { + if x != nil { + return x.HeaderMap + } + return nil +} + type Problem struct { state protoimpl.MessageState `protogen:"open.v1"` Path *string `protobuf:"bytes,1,opt,name=path" json:"path,omitempty"` @@ -298,7 +403,7 @@ type Problem struct { func (x *Problem) Reset() { *x = Problem{} - mi := &file_schema_proto_msgTypes[2] + mi := &file_schema_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -310,7 +415,7 @@ func (x *Problem) String() string { func (*Problem) ProtoMessage() {} func (x *Problem) ProtoReflect() protoreflect.Message { - mi := &file_schema_proto_msgTypes[2] + mi := &file_schema_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -323,7 +428,7 @@ func (x *Problem) ProtoReflect() protoreflect.Message { // Deprecated: Use Problem.ProtoReflect.Descriptor instead. func (*Problem) Descriptor() ([]byte, []int) { - return file_schema_proto_rawDescGZIP(), []int{2} + return file_schema_proto_rawDescGZIP(), []int{4} } func (x *Problem) GetPath() string { @@ -350,8 +455,9 @@ type Manifest struct { Contents map[string]*Entry `protobuf:"bytes,4,rep,name=contents" json:"contents,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` TotalSize *int64 `protobuf:"varint,5,opt,name=total_size,json=totalSize" json:"total_size,omitempty"` // simple sum of each `entry.size` StoredSize *int64 `protobuf:"varint,8,opt,name=stored_size,json=storedSize" json:"stored_size,omitempty"` // external objects, after deduplication - // Netlify-style `_redirects` - Redirects []*Redirect `protobuf:"bytes,6,rep,name=redirects" json:"redirects,omitempty"` + // Netlify-style `_redirects` and `_headers` + Redirects []*RedirectRule `protobuf:"bytes,6,rep,name=redirects" json:"redirects,omitempty"` + Headers []*HeaderRule `protobuf:"bytes,9,rep,name=headers" json:"headers,omitempty"` // Diagnostics for non-fatal errors Problems []*Problem `protobuf:"bytes,7,rep,name=problems" json:"problems,omitempty"` unknownFields protoimpl.UnknownFields @@ -360,7 +466,7 @@ type Manifest struct { func (x *Manifest) Reset() { *x = Manifest{} - mi := &file_schema_proto_msgTypes[3] + mi := &file_schema_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -372,7 +478,7 @@ func (x *Manifest) String() string { func (*Manifest) ProtoMessage() {} func (x *Manifest) ProtoReflect() protoreflect.Message { - mi := &file_schema_proto_msgTypes[3] + mi := &file_schema_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -385,7 +491,7 @@ func (x *Manifest) ProtoReflect() protoreflect.Message { // Deprecated: Use Manifest.ProtoReflect.Descriptor instead. func (*Manifest) Descriptor() ([]byte, []int) { - return file_schema_proto_rawDescGZIP(), []int{3} + return file_schema_proto_rawDescGZIP(), []int{5} } func (x *Manifest) GetRepoUrl() string { @@ -430,13 +536,20 @@ func (x *Manifest) GetStoredSize() int64 { return 0 } -func (x *Manifest) GetRedirects() []*Redirect { +func (x *Manifest) GetRedirects() []*RedirectRule { if x != nil { return x.Redirects } return nil } +func (x *Manifest) GetHeaders() []*HeaderRule { + if x != nil { + return x.Headers + } + return nil +} + func (x *Manifest) GetProblems() []*Problem { if x != nil { return x.Problems @@ -455,15 +568,23 @@ const file_schema_proto_rawDesc = "" + "\x04data\x18\x03 \x01(\fR\x04data\x12(\n" + "\ttransform\x18\x04 \x01(\x0e2\n" + ".TransformR\ttransform\x12!\n" + - "\fcontent_type\x18\x05 \x01(\tR\vcontentType\"\\\n" + - "\bRedirect\x12\x12\n" + + "\fcontent_type\x18\x05 \x01(\tR\vcontentType\"`\n" + + "\fRedirectRule\x12\x12\n" + "\x04from\x18\x01 \x01(\tR\x04from\x12\x0e\n" + "\x02to\x18\x02 \x01(\tR\x02to\x12\x16\n" + "\x06status\x18\x03 \x01(\rR\x06status\x12\x14\n" + - "\x05force\x18\x04 \x01(\bR\x05force\"3\n" + + "\x05force\x18\x04 \x01(\bR\x05force\"4\n" + + "\x06Header\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" + + "\x06values\x18\x02 \x03(\tR\x06values\"H\n" + + "\n" + + "HeaderRule\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12&\n" + + "\n" + + "header_map\x18\x02 \x03(\v2\a.HeaderR\theaderMap\"3\n" + "\aProblem\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n" + - "\x05cause\x18\x02 \x01(\tR\x05cause\"\xde\x02\n" + + "\x05cause\x18\x02 \x01(\tR\x05cause\"\x89\x03\n" + "\bManifest\x12\x19\n" + "\brepo_url\x18\x01 \x01(\tR\arepoUrl\x12\x16\n" + "\x06branch\x18\x02 \x01(\tR\x06branch\x12\x16\n" + @@ -472,8 +593,9 @@ const file_schema_proto_rawDesc = "" + "\n" + "total_size\x18\x05 \x01(\x03R\ttotalSize\x12\x1f\n" + "\vstored_size\x18\b \x01(\x03R\n" + - "storedSize\x12'\n" + - "\tredirects\x18\x06 \x03(\v2\t.RedirectR\tredirects\x12$\n" + + "storedSize\x12+\n" + + "\tredirects\x18\x06 \x03(\v2\r.RedirectRuleR\tredirects\x12%\n" + + "\aheaders\x18\t \x03(\v2\v.HeaderRuleR\aheaders\x12$\n" + "\bproblems\x18\a \x03(\v2\b.ProblemR\bproblems\x1aC\n" + "\rContentsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x1c\n" + @@ -502,28 +624,32 @@ func file_schema_proto_rawDescGZIP() []byte { } var file_schema_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_schema_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_schema_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_schema_proto_goTypes = []any{ - (Type)(0), // 0: Type - (Transform)(0), // 1: Transform - (*Entry)(nil), // 2: Entry - (*Redirect)(nil), // 3: Redirect - (*Problem)(nil), // 4: Problem - (*Manifest)(nil), // 5: Manifest - nil, // 6: Manifest.ContentsEntry + (Type)(0), // 0: Type + (Transform)(0), // 1: Transform + (*Entry)(nil), // 2: Entry + (*RedirectRule)(nil), // 3: RedirectRule + (*Header)(nil), // 4: Header + (*HeaderRule)(nil), // 5: HeaderRule + (*Problem)(nil), // 6: Problem + (*Manifest)(nil), // 7: Manifest + nil, // 8: Manifest.ContentsEntry } var file_schema_proto_depIdxs = []int32{ 0, // 0: Entry.type:type_name -> Type 1, // 1: Entry.transform:type_name -> Transform - 6, // 2: Manifest.contents:type_name -> Manifest.ContentsEntry - 3, // 3: Manifest.redirects:type_name -> Redirect - 4, // 4: Manifest.problems:type_name -> Problem - 2, // 5: Manifest.ContentsEntry.value:type_name -> Entry - 6, // [6:6] is the sub-list for method output_type - 6, // [6:6] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 4, // 2: HeaderRule.header_map:type_name -> Header + 8, // 3: Manifest.contents:type_name -> Manifest.ContentsEntry + 3, // 4: Manifest.redirects:type_name -> RedirectRule + 5, // 5: Manifest.headers:type_name -> HeaderRule + 6, // 6: Manifest.problems:type_name -> Problem + 2, // 7: Manifest.ContentsEntry.value:type_name -> Entry + 8, // [8:8] is the sub-list for method output_type + 8, // [8:8] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_schema_proto_init() } @@ -537,7 +663,7 @@ func file_schema_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_schema_proto_rawDesc), len(file_schema_proto_rawDesc)), NumEnums: 2, - NumMessages: 5, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/src/schema.proto b/src/schema.proto index fbd4257..ba34389 100644 --- a/src/schema.proto +++ b/src/schema.proto @@ -44,13 +44,24 @@ message Entry { // See https://docs.netlify.com/manage/routing/redirects/overview/ for details. // Only a subset of the Netlify specification is representable here. -message Redirect { +message RedirectRule { string from = 1; string to = 2; uint32 status = 3; bool force = 4; } +// See https://docs.netlify.com/manage/routing/headers/ for details. +message Header { + string name = 1; + repeated string values = 2; +} + +message HeaderRule { + string path = 1; + repeated Header header_map = 2; +} + message Problem { string path = 1; string cause = 2; @@ -67,8 +78,9 @@ message Manifest { int64 total_size = 5; // simple sum of each `entry.size` int64 stored_size = 8; // external objects, after deduplication - // Netlify-style `_redirects` - repeated Redirect redirects = 6; + // Netlify-style `_redirects` and `_headers` + repeated RedirectRule redirects = 6; + repeated HeaderRule headers = 9; // Diagnostics for non-fatal errors repeated Problem problems = 7;