diff --git a/src/pages.go b/src/pages.go index e243ddf..4965798 100644 --- a/src/pages.go +++ b/src/pages.go @@ -10,6 +10,7 @@ import ( "log" "mime" "net/http" + "net/url" "os" "path" "strings" @@ -69,7 +70,7 @@ func getPage(w http.ResponseWriter, r *http.Request) error { return err } - sitePath, _ = strings.CutPrefix(r.URL.Path, "/") + sitePath = strings.TrimPrefix(r.URL.Path, "/") if projectName, projectPath, found := strings.Cut(sitePath, "/"); found { projectManifest, err := backend.GetManifest(makeWebRoot(host, projectName)) if err == nil { @@ -122,7 +123,8 @@ func getPage(w http.ResponseWriter, r *http.Request) error { entryPath := sitePath entry := (*Entry)(nil) - is404 := false + appliedRedirect := false + status := 200 reader := io.ReadSeeker(nil) mtime := time.Time{} for { @@ -135,12 +137,28 @@ func getPage(w http.ResponseWriter, r *http.Request) error { } entry = manifest.Contents[entryPath] if entry == nil || entry.GetType() == Type_Invalid { - is404 = true - if entryPath == notFoundPage { + if !appliedRedirect { + originalURL := (&url.URL{Host: r.Host}).ResolveReference(r.URL) + redirectURL, redirectStatus := ApplyRedirects(manifest, originalURL) + if Is3xxHTTPStatus(redirectStatus) { + w.Header().Set("Location", redirectURL.String()) + w.WriteHeader(int(redirectStatus)) + fmt.Fprintf(w, "see %s\n", redirectURL.String()) + return nil + } else if redirectURL != nil { + entryPath = strings.TrimPrefix(redirectURL.Path, "/") + status = int(redirectStatus) + appliedRedirect = true + continue + } + } + status = 404 + if entryPath != notFoundPage { + entryPath = notFoundPage + continue + } else { break } - entryPath = notFoundPage - continue } else if entry.GetType() == Type_InlineFile { reader = bytes.NewReader(entry.Data) } else if entry.GetType() == Type_ExternalFile { @@ -181,11 +199,9 @@ func getPage(w http.ResponseWriter, r *http.Request) error { } // decide on the HTTP status - if is404 { - w.WriteHeader(http.StatusNotFound) - if entry == nil { - fmt.Fprintf(w, "not found\n") - } else { + if status != 200 { + w.WriteHeader(status) + if reader != nil { io.Copy(w, reader) } } else { diff --git a/src/redirects.go b/src/redirects.go index 7ff26b3..d5314aa 100644 --- a/src/redirects.go +++ b/src/redirects.go @@ -3,6 +3,7 @@ package main import ( "fmt" "net/http" + "net/url" "slices" "strings" @@ -30,7 +31,7 @@ func unparseRule(rule redirects.Rule) string { return strings.Join(parts, " ") } -var validRedirectHTTPCodes []uint = []uint{ +var validRedirectHTTPStatuses []uint = []uint{ http.StatusOK, http.StatusMovedPermanently, http.StatusFound, @@ -44,20 +45,44 @@ var validRedirectHTTPCodes []uint = []uint{ http.StatusUnavailableForLegalReasons, } +func Is3xxHTTPStatus(status uint) bool { + return status >= 300 && status <= 399 +} + func validateRule(rule redirects.Rule) error { if len(rule.Params) > 0 { return fmt.Errorf("rules with parameters are not supported") } - if rule.IsProxy() { - return fmt.Errorf("proxy rules are not supported") - } - if !slices.Contains(validRedirectHTTPCodes, uint(rule.Status)) { + if !slices.Contains(validRedirectHTTPStatuses, uint(rule.Status)) { return fmt.Errorf("rule cannot use status %d: must be %v", - rule.Status, validRedirectHTTPCodes) + rule.Status, validRedirectHTTPStatuses) } - if strings.Contains(rule.From, "*") && !strings.HasSuffix(rule.From, "/*") { + fromURL, err := url.Parse(rule.From) + if err != nil { + return fmt.Errorf("malformed 'from' URL") + } + if fromURL.Scheme != "" { + return fmt.Errorf("'from' URL path must not contain a scheme") + } + if !strings.HasPrefix(fromURL.Path, "/") { + return fmt.Errorf("'from' URL path must start with a /") + } + if strings.Contains(fromURL.Path, "*") && !strings.HasSuffix(fromURL.Path, "/*") { return fmt.Errorf("splat * must be its own final segment of the path") } + toURL, err := url.Parse(rule.To) + if err != nil { + return fmt.Errorf("malformed 'to' URL") + } + if !strings.HasPrefix(toURL.Path, "/") { + return fmt.Errorf("'to' URL path must start with a /") + } + if toURL.Host != "" && !Is3xxHTTPStatus(uint(rule.Status)) { + return fmt.Errorf("'to' URL may only include a hostname for 3xx status rules") + } + if rule.Force { + return fmt.Errorf("force redirects are not supported") + } return nil } @@ -92,3 +117,69 @@ func ProcessRedirects(manifest *Manifest) error { } return nil } + +func pathSegments(path string) []string { + return strings.Split(strings.TrimPrefix(path, "/"), "/") +} + +func toOrFromComponent(to, from string) string { + if to == "" { + return from + } else { + return to + } +} + +func ApplyRedirects(manifest *Manifest, fromURL *url.URL) (toURL *url.URL, status uint) { + fromSegments := pathSegments(fromURL.Path) +next: + for _, rule := range manifest.Redirects { + // check if the rule matches fromURL + ruleFromURL, _ := url.Parse(*rule.From) // pre-validated in `validateRule` + if ruleFromURL.Scheme != "" && fromURL.Scheme != ruleFromURL.Scheme { + continue + } + if ruleFromURL.Host != "" && fromURL.Hostname() != ruleFromURL.Host { + continue + } + ruleFromSegments := pathSegments(ruleFromURL.Path) + splatSegments := []string{} + if ruleFromSegments[len(ruleFromSegments)-1] != "*" { + if len(ruleFromSegments) < len(fromSegments) { + continue + } + } + for index, ruleFromSegment := range ruleFromSegments { + if ruleFromSegment == "*" { + splatSegments = fromSegments[index:] + break + } + if len(fromSegments) <= index { + continue next + } + if fromSegments[index] != ruleFromSegment { + continue next + } + } + // the rule has matched fromURL, figure out where to redirect + ruleToURL, _ := url.Parse(*rule.To) // pre-validated in `validateRule` + toSegments := []string{} + for _, ruleToSegment := range pathSegments(ruleToURL.Path) { + if ruleToSegment == ":splat" { + toSegments = append(toSegments, splatSegments...) + } else { + toSegments = append(toSegments, ruleToSegment) + } + } + toURL = &url.URL{ + Scheme: toOrFromComponent(ruleToURL.Scheme, fromURL.Scheme), + Host: toOrFromComponent(ruleToURL.Host, fromURL.Host), + Path: "/" + strings.Join(toSegments, "/"), + RawQuery: fromURL.RawQuery, + } + status = uint(*rule.Status) + break + } + // no redirect found + return +} diff --git a/src/schema.pb.go b/src/schema.pb.go index f40655a..c93714c 100644 --- a/src/schema.pb.go +++ b/src/schema.pb.go @@ -82,10 +82,17 @@ func (Type) EnumDescriptor() ([]byte, []int) { } type Entry struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type *Type `protobuf:"varint,1,opt,name=type,enum=Type" json:"type,omitempty"` - Size *uint32 `protobuf:"varint,2,opt,name=size" json:"size,omitempty"` - Data []byte `protobuf:"bytes,3,opt,name=data" json:"data,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Type *Type `protobuf:"varint,1,opt,name=type,enum=Type" json:"type,omitempty"` + // Only present for `type == InlineFile` and `type == ExternalFile` + Size *uint32 `protobuf:"varint,2,opt,name=size" json:"size,omitempty"` + // Meaning depends on `type`: + // - If `type == InlineFile`, contains file data. + // - If `type == ExternalFile`, contains blob name (an otherwise unspecified + // cryptographically secure content hash). + // - If `type == Symlink`, contains link target. + // - Otherwise not present. + Data []byte `protobuf:"bytes,3,opt,name=data" json:"data,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -141,6 +148,8 @@ func (x *Entry) GetData() []byte { return nil } +// See https://docs.netlify.com/manage/routing/redirects/overview/ for details. +// Only a subset of the Netlify specification is representable here. type Redirect struct { state protoimpl.MessageState `protogen:"open.v1"` From *string `protobuf:"bytes,1,opt,name=from" json:"from,omitempty"`