From 4b873a47923253aa71adf812360b7b8311292c91 Mon Sep 17 00:00:00 2001
From: video-prize-ranch <cb.8a3w5@simplelogin.co>
Date: Mon, 19 Dec 2022 15:50:03 -0500
Subject: [PATCH] Initial _redirects implementation

---
 cmd/main.go                             |  4 +-
 server/handler/handler.go               |  8 ++--
 server/handler/handler_custom_domain.go |  4 +-
 server/handler/handler_raw_domain.go    |  6 +--
 server/handler/handler_sub_domain.go    | 10 ++---
 server/handler/handler_test.go          |  1 +
 server/handler/try.go                   | 36 +++++++++++++++++
 server/upstream/redirects.go            | 52 +++++++++++++++++++++++++
 8 files changed, 106 insertions(+), 15 deletions(-)
 create mode 100644 server/upstream/redirects.go

diff --git a/cmd/main.go b/cmd/main.go
index b72013a..d511021 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -80,6 +80,8 @@ func Serve(ctx *cli.Context) error {
 	canonicalDomainCache := cache.NewKeyValueCache()
 	// dnsLookupCache stores DNS lookups for custom domains
 	dnsLookupCache := cache.NewKeyValueCache()
+	// redirectsCache stores redirects in _redirects files
+	redirectsCache := cache.NewKeyValueCache()
 	// clientResponseCache stores responses from the Gitea server
 	clientResponseCache := cache.NewKeyValueCache()
 
@@ -93,7 +95,7 @@ func Serve(ctx *cli.Context) error {
 		giteaClient,
 		rawInfoPage,
 		BlacklistedPaths, allowedCorsDomains,
-		dnsLookupCache, canonicalDomainCache)
+		dnsLookupCache, canonicalDomainCache, redirectsCache)
 
 	httpHandler := server.SetupHTTPACMEChallengeServer(challengeCache)
 
diff --git a/server/handler/handler.go b/server/handler/handler.go
index 78301e9..3462e01 100644
--- a/server/handler/handler.go
+++ b/server/handler/handler.go
@@ -25,7 +25,7 @@ func Handler(mainDomainSuffix, rawDomain string,
 	giteaClient *gitea.Client,
 	rawInfoPage string,
 	blacklistedPaths, allowedCorsDomains []string,
-	dnsLookupCache, canonicalDomainCache cache.SetGetKey,
+	dnsLookupCache, canonicalDomainCache, redirectsCache cache.SetGetKey,
 ) http.HandlerFunc {
 	return func(w http.ResponseWriter, req *http.Request) {
 		log := log.With().Strs("Handler", []string{req.Host, req.RequestURI}).Logger()
@@ -93,21 +93,21 @@ func Handler(mainDomainSuffix, rawDomain string,
 				mainDomainSuffix, rawInfoPage,
 				trimmedHost,
 				pathElements,
-				canonicalDomainCache)
+				canonicalDomainCache, redirectsCache)
 		} else if strings.HasSuffix(trimmedHost, mainDomainSuffix) {
 			log.Debug().Msg("subdomain request detecded")
 			handleSubDomain(log, ctx, giteaClient,
 				mainDomainSuffix,
 				trimmedHost,
 				pathElements,
-				canonicalDomainCache)
+				canonicalDomainCache, redirectsCache)
 		} else {
 			log.Debug().Msg("custom domain request detecded")
 			handleCustomDomain(log, ctx, giteaClient,
 				mainDomainSuffix,
 				trimmedHost,
 				pathElements,
-				dnsLookupCache, canonicalDomainCache)
+				dnsLookupCache, canonicalDomainCache, redirectsCache)
 		}
 	}
 }
diff --git a/server/handler/handler_custom_domain.go b/server/handler/handler_custom_domain.go
index 2f98085..f9a081e 100644
--- a/server/handler/handler_custom_domain.go
+++ b/server/handler/handler_custom_domain.go
@@ -18,7 +18,7 @@ func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *g
 	mainDomainSuffix string,
 	trimmedHost string,
 	pathElements []string,
-	dnsLookupCache, canonicalDomainCache cache.SetGetKey,
+	dnsLookupCache, canonicalDomainCache, redirectsCache cache.SetGetKey,
 ) {
 	// Serve pages from custom domains
 	targetOwner, targetRepo, targetBranch := dns.GetTargetFromDNS(trimmedHost, mainDomainSuffix, dnsLookupCache)
@@ -63,7 +63,7 @@ func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *g
 		}
 
 		log.Debug().Msg("tryBranch, now trying upstream 7")
-		tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
+		tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
 		return
 	}
 
diff --git a/server/handler/handler_raw_domain.go b/server/handler/handler_raw_domain.go
index 5e974da..aa41c52 100644
--- a/server/handler/handler_raw_domain.go
+++ b/server/handler/handler_raw_domain.go
@@ -19,7 +19,7 @@ func handleRaw(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Clie
 	mainDomainSuffix, rawInfoPage string,
 	trimmedHost string,
 	pathElements []string,
-	canonicalDomainCache cache.SetGetKey,
+	canonicalDomainCache, redirectsCache cache.SetGetKey,
 ) {
 	// Serve raw content from RawDomain
 	log.Debug().Msg("raw domain")
@@ -41,7 +41,7 @@ func handleRaw(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Clie
 			TargetPath:   path.Join(pathElements[3:]...),
 		}, true); works {
 			log.Trace().Msg("tryUpstream: serve raw domain with specified branch")
-			tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
+			tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
 			return
 		}
 		log.Debug().Msg("missing branch info")
@@ -58,7 +58,7 @@ func handleRaw(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Clie
 		TargetPath:    path.Join(pathElements[2:]...),
 	}, true); works {
 		log.Trace().Msg("tryUpstream: serve raw domain with default branch")
-		tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
+		tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
 	} else {
 		html.ReturnErrorPage(ctx,
 			fmt.Sprintf("raw domain could not find repo '%s/%s' or repo is empty", targetOpt.TargetOwner, targetOpt.TargetRepo),
diff --git a/server/handler/handler_sub_domain.go b/server/handler/handler_sub_domain.go
index 2a75e9f..bda6a11 100644
--- a/server/handler/handler_sub_domain.go
+++ b/server/handler/handler_sub_domain.go
@@ -19,7 +19,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
 	mainDomainSuffix string,
 	trimmedHost string,
 	pathElements []string,
-	canonicalDomainCache cache.SetGetKey,
+	canonicalDomainCache, redirectsCache cache.SetGetKey,
 ) {
 	// Serve pages from subdomains of MainDomainSuffix
 	log.Debug().Msg("main domain suffix")
@@ -51,7 +51,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
 			TargetPath:    path.Join(pathElements[2:]...),
 		}, true); works {
 			log.Trace().Msg("tryUpstream: serve with specified repo and branch")
-			tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
+			tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
 		} else {
 			html.ReturnErrorPage(ctx,
 				fmt.Sprintf("explizite set branch %q do not exist at '%s/%s'", targetOpt.TargetBranch, targetOpt.TargetOwner, targetOpt.TargetRepo),
@@ -72,7 +72,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
 			TargetPath:    path.Join(pathElements[1:]...),
 		}, true); works {
 			log.Trace().Msg("tryUpstream: serve default pages repo with specified branch")
-			tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
+			tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
 		} else {
 			html.ReturnErrorPage(ctx,
 				fmt.Sprintf("explizite set branch %q do not exist at '%s/%s'", targetOpt.TargetBranch, targetOpt.TargetOwner, targetOpt.TargetRepo),
@@ -94,7 +94,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
 			TargetPath:    path.Join(pathElements[1:]...),
 		}, false); works {
 			log.Debug().Msg("tryBranch, now trying upstream 5")
-			tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
+			tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
 			return
 		}
 	}
@@ -109,7 +109,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
 		TargetPath:    path.Join(pathElements...),
 	}, false); works {
 		log.Debug().Msg("tryBranch, now trying upstream 6")
-		tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache)
+		tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
 		return
 	}
 
diff --git a/server/handler/handler_test.go b/server/handler/handler_test.go
index 626564a..8564bd1 100644
--- a/server/handler/handler_test.go
+++ b/server/handler/handler_test.go
@@ -20,6 +20,7 @@ func TestHandlerPerformance(t *testing.T) {
 		[]string{"raw.codeberg.org", "fonts.codeberg.org", "design.codeberg.org"},
 		cache.NewKeyValueCache(),
 		cache.NewKeyValueCache(),
+		cache.NewKeyValueCache(),
 	)
 
 	testCase := func(uri string, status int) {
diff --git a/server/handler/try.go b/server/handler/try.go
index 5a09b91..4c4ae19 100644
--- a/server/handler/try.go
+++ b/server/handler/try.go
@@ -18,6 +18,7 @@ func tryUpstream(ctx *context.Context, giteaClient *gitea.Client,
 	mainDomainSuffix, trimmedHost string,
 	options *upstream.Options,
 	canonicalDomainCache cache.SetGetKey,
+	redirectsCache cache.SetGetKey,
 ) {
 	// check if a canonical domain exists on a request on MainDomain
 	if strings.HasSuffix(trimmedHost, mainDomainSuffix) {
@@ -38,6 +39,41 @@ func tryUpstream(ctx *context.Context, giteaClient *gitea.Client,
 	// Add host for debugging.
 	options.Host = trimmedHost
 
+	// Check for redirects
+	redirects := options.GetRedirects(giteaClient, redirectsCache)
+	if len(redirects) > 0 {
+		for _, redirect := range redirects {
+			reqUrl := ctx.Req.RequestURI
+			trimmedFromUrl := strings.TrimSuffix(redirect.From, "/*")
+			if strings.TrimSuffix(redirect.From, "/") == strings.TrimSuffix(reqUrl, "/") {
+				if redirect.StatusCode == 200 {
+					options.TargetPath = redirect.To
+				} else {
+					ctx.Redirect(redirect.To, redirect.StatusCode)
+					return
+				}
+			}
+			if strings.HasSuffix(redirect.From, "/*") && strings.HasPrefix(reqUrl, trimmedFromUrl) {
+				if strings.Contains(redirect.To, ":splat") {
+					splatUrl := strings.ReplaceAll(redirect.To, ":splat", strings.TrimPrefix(reqUrl, trimmedFromUrl))
+					if redirect.StatusCode == 200 {
+						options.TargetPath = splatUrl
+					} else {
+						ctx.Redirect(splatUrl, redirect.StatusCode)
+						return
+					}
+				} else {
+					if redirect.StatusCode == 200 {
+						options.TargetPath = redirect.To
+					} else {
+						ctx.Redirect(redirect.To, redirect.StatusCode)
+						return
+					}
+				}
+			}
+		}
+	}
+
 	// Try to request the file from the Gitea API
 	if !options.Upstream(ctx, giteaClient) {
 		html.ReturnErrorPage(ctx, "", ctx.StatusCode)
diff --git a/server/upstream/redirects.go b/server/upstream/redirects.go
new file mode 100644
index 0000000..f27aed4
--- /dev/null
+++ b/server/upstream/redirects.go
@@ -0,0 +1,52 @@
+package upstream
+
+import (
+	"strconv"
+	"strings"
+	"time"
+
+	"codeberg.org/codeberg/pages/server/cache"
+	"codeberg.org/codeberg/pages/server/gitea"
+	"github.com/rs/zerolog/log"
+)
+
+type Redirect struct {
+	From       string
+	To         string
+	StatusCode int
+}
+
+// redirectsCacheTimeout specifies the timeout for the redirects cache.
+var redirectsCacheTimeout = 10 * time.Minute
+
+const redirectsConfig = "_redirects"
+
+// GetRedirects returns redirects specified in the _redirects file.
+func (o *Options) GetRedirects(giteaClient *gitea.Client, redirectsCache cache.SetGetKey) []Redirect {
+	var redirects []Redirect
+
+	if cachedValue, ok := redirectsCache.Get(o.TargetOwner + "/" + o.TargetRepo + "/" + o.TargetBranch); ok {
+		redirects = cachedValue.([]Redirect)
+	} else {
+		body, err := giteaClient.GiteaRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, redirectsConfig)
+		if err == nil {
+			for _, line := range strings.Split(string(body), "\n") {
+				redirectArr := strings.Fields(line)
+				if len(redirectArr) == 3 {
+					statusCode, err := strconv.Atoi(redirectArr[2])
+					if err != nil {
+						log.Info().Err(err).Msgf("could not read %s of %s/%s", redirectsConfig, o.TargetOwner, o.TargetRepo)
+					}
+
+					redirects = append(redirects, Redirect{
+						From:       redirectArr[0],
+						To:         redirectArr[1],
+						StatusCode: statusCode,
+					})
+				}
+			}
+		}
+		_ = redirectsCache.Set(o.TargetOwner+"/"+o.TargetRepo+"/"+o.TargetBranch, redirects, redirectsCacheTimeout)
+	}
+	return redirects
+}