Implement partial Netlify _redirects support.

This is roughly to match the Codeberg subset:
https://docs.codeberg.org/codeberg-pages/redirects/
This commit is contained in:
Catherine
2025-09-24 19:11:25 +00:00
parent 59eb65ff66
commit 714e37cce8
3 changed files with 138 additions and 22 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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"`