mirror of
https://codeberg.org/git-pages/git-pages.git
synced 2026-05-21 06:31:51 +00:00
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:
38
src/pages.go
38
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 {
|
||||
|
||||
105
src/redirects.go
105
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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user