From 12737c5b7f4272f42bf5c9e8ddac32306394a990 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Wed, 19 Aug 2020 22:40:17 +0300 Subject: [PATCH] implement a rewrite middleware --- HISTORY.md | 15 +- _examples/README.md | 1 + _examples/routing/rewrite/main.go | 62 +++++++ _examples/routing/rewrite/redirects.yml | 8 + core/host/supervisor.go | 2 +- core/router/router_subdomain_redirect.go | 19 +- middleware/README.md | 3 +- middleware/rewrite/rewrite.go | 219 +++++++++++++++++++++++ middleware/rewrite/rewrite_test.go | 89 +++++++++ 9 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 _examples/routing/rewrite/main.go create mode 100644 _examples/routing/rewrite/redirects.yml create mode 100644 middleware/rewrite/rewrite.go create mode 100644 middleware/rewrite/rewrite_test.go diff --git a/HISTORY.md b/HISTORY.md index ae9f3a40..f4c3c6b1 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -362,7 +362,20 @@ Response: Other Improvements: -- New `TraceRoute bool` on [request logger](https://github.com/kataras/iris/tree/master/middleware/logger) middleware. Displays information about the executed route. Also marks the handlers executed. Screenshot: +- New [Rewrite Engine Middleware](https://github.com/kataras/iris/tree/master/middleware/rewrite). Set up redirection rules for path patterns using the syntax we all know. [Example Code](https://github.com/kataras/iris/tree/master/_examples/routing/rewrite). + +```yml +# REDIRECT_CODE PATH_PATTERN TARGET_PATH_REPL +RedirectMatch: + # redirects /seo/* to /* + - 301 /seo/(.*) /$1 + # redirects /docs/v12* to /docs + - 301 /docs/v12(.*) /docs + # redirects /old(.*) to / + - 301 /old(.*) / +``` + +- New `TraceRoute bool` on [middleware/logger](https://github.com/kataras/iris/tree/master/middleware/logger) middleware. Displays information about the executed route. Also marks the handlers executed. Screenshot: ![logger middleware: TraceRoute screenshot](https://iris-go.com/images/github/logger-trace-route.png) diff --git a/_examples/README.md b/_examples/README.md index ffbd3783..507491cf 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -51,6 +51,7 @@ * [From func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc)](convert-handlers/negroni-like/main.go) * [From http.Handler or http.HandlerFunc](convert-handlers/nethttp/main.go) * [From func(http.HandlerFunc) http.HandlerFunc](convert-handlers/real-usecase-raven/writing-middleware/main.go) + * [Rewrite Middleware](routing/rewrite/main.go) * [Route State](routing/route-state/main.go) * [Reverse Routing](routing/reverse/main.go) * [Router Wrapper](routing/custom-wrapper/main.go) diff --git a/_examples/routing/rewrite/main.go b/_examples/routing/rewrite/main.go new file mode 100644 index 00000000..aeb12502 --- /dev/null +++ b/_examples/routing/rewrite/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/rewrite" +) + +func main() { + app := iris.New() + + /* + rewriteOptions := rewrite.Options{ + RedirectMatch: []string{ + "301 /seo/(.*) /$1", + "301 /docs/v12(.*) /docs", + "301 /old(.*) /", + }} + OR Load from file: + */ + rewriteOptions := rewrite.LoadOptions("redirects.yml") + rewriteEngine, err := rewrite.New(rewriteOptions) + if err != nil { // reports any line parse errors. + app.Logger().Fatal(err) + } + + app.Get("/", index) + app.Get("/about", about) + app.Get("/docs", docs) + + /* + // To use it per-party, even if not route match: + app.UseRouter(rewriteEngine.Handler) + // To use it per-party when route match: + app.Use(rewriteEngine.Handler) + // + // To use it on a single route just pass it to the Get/Post method. + // To make the entire application respect the rewrite rules + // you have to wrap the Iris Router and pass the Wrapper method instead, + // (recommended way to use this middleware, right before Listen/Run): + */ + app.WrapRouter(rewriteEngine.Wrapper) + + // http://localhost:8080/seo + // http://localhost:8080/about + // http://localhost:8080/docs/v12/hello + // http://localhost:8080/docs/v12some + // http://localhost:8080/oldsome + // http://localhost:8080/oldindex/random + app.Listen(":8080") +} + +func index(ctx iris.Context) { + ctx.WriteString("Index") +} + +func about(ctx iris.Context) { + ctx.WriteString("About") +} + +func docs(ctx iris.Context) { + ctx.WriteString("Docs") +} diff --git a/_examples/routing/rewrite/redirects.yml b/_examples/routing/rewrite/redirects.yml new file mode 100644 index 00000000..f7cb1e5c --- /dev/null +++ b/_examples/routing/rewrite/redirects.yml @@ -0,0 +1,8 @@ +# REDIRECT_CODE PATH_PATTERN TARGET_PATH_REPL +RedirectMatch: + # redirects /seo/* to /* + - 301 /seo/(.*) /$1 + # redirects /docs/v12* to /docs + - 301 /docs/v12(.*) /docs + # redirects /old(.*) to / + - 301 /old(.*) / diff --git a/core/host/supervisor.go b/core/host/supervisor.go index 1551cdb5..8057fd06 100644 --- a/core/host/supervisor.go +++ b/core/host/supervisor.go @@ -145,7 +145,7 @@ func (su *Supervisor) newListener() (net.Listener, error) { // restarts we may want for the server. // // User still be able to call .Serve instead. - // l, err := netutil.TCPKeepAlive(su.Server.Addr, su.SocketReuse) + // l, err := netutil.TCPKeepAlive(su.Server.Addr, su.SocketSharding) l, err := netutil.TCP(su.Server.Addr, su.SocketSharding) if err != nil { return nil, err diff --git a/core/router/router_subdomain_redirect.go b/core/router/router_subdomain_redirect.go index ed3fe86e..90e292a8 100644 --- a/core/router/router_subdomain_redirect.go +++ b/core/router/router_subdomain_redirect.go @@ -132,11 +132,11 @@ func (s *subdomainRedirectWrapper) Wrapper(w http.ResponseWriter, r *http.Reques resturi := r.URL.RequestURI() if s.isToRoot { // from a specific subdomain or any subdomain to the root domain. - redirectAbsolute(w, r, context.GetScheme(r)+root+resturi, http.StatusMovedPermanently) + RedirectAbsolute(w, r, context.GetScheme(r)+root+resturi, http.StatusMovedPermanently) return } // from a specific subdomain or any subdomain to a specific subdomain. - redirectAbsolute(w, r, context.GetScheme(r)+s.to+root+resturi, http.StatusMovedPermanently) + RedirectAbsolute(w, r, context.GetScheme(r)+s.to+root+resturi, http.StatusMovedPermanently) return } @@ -162,7 +162,7 @@ func (s *subdomainRedirectWrapper) Wrapper(w http.ResponseWriter, r *http.Reques resturi := r.URL.RequestURI() // we are not inside a subdomain, so we are in the root domain // and the redirect is configured to be used from root domain to a subdomain. - redirectAbsolute(w, r, context.GetScheme(r)+s.to+root+resturi, http.StatusMovedPermanently) + RedirectAbsolute(w, r, context.GetScheme(r)+s.to+root+resturi, http.StatusMovedPermanently) return } @@ -196,11 +196,20 @@ func NewSubdomainRedirectHandler(toSubdomain string) context.Handler { r.Host = targetHost r.URL.Host = targetHost urlToRedirect := r.URL.String() - redirectAbsolute(ctx.ResponseWriter(), r, urlToRedirect, http.StatusMovedPermanently) + RedirectAbsolute(ctx.ResponseWriter(), r, urlToRedirect, http.StatusMovedPermanently) } } -func redirectAbsolute(w http.ResponseWriter, r *http.Request, url string, code int) { +// RedirectAbsolute replies to the request with a redirect to an absolute URL. +// +// The provided code should be in the 3xx range and is usually +// StatusMovedPermanently, StatusFound or StatusSeeOther. +// +// If the Content-Type header has not been set, Redirect sets it +// to "text/html; charset=utf-8" and writes a small HTML body. +// Setting the Content-Type header to any value, including nil, +// disables that behavior. +func RedirectAbsolute(w http.ResponseWriter, r *http.Request, url string, code int) { h := w.Header() // RFC 7231 notes that a short HTML body is usually included in diff --git a/middleware/README.md b/middleware/README.md index a46ff66f..3c046932 100644 --- a/middleware/README.md +++ b/middleware/README.md @@ -3,6 +3,7 @@ Builtin Handlers | Middleware | Example | | -----------|-------------| +| [rewrite](rewrite) | [iris/_examples/routing/rewrite](https://github.com/kataras/iris/tree/master/_examples/routing/rewrite) | | [basic authentication](basicauth) | [iris/_examples/auth/basicauth](https://github.com/kataras/iris/tree/master/_examples/auth/basicauth) | | [request logger](logger) | [iris/_examples/logging/request-logger](https://github.com/kataras/iris/tree/master/_examples/logging/request-logger) | | [HTTP method override](methodoverride) | [iris/middleware/methodoverride/methodoverride_test.go](https://github.com/kataras/iris/blob/master/middleware/methodoverride/methodoverride_test.go) | @@ -29,7 +30,7 @@ Most of the experimental handlers are ported to work with _iris_'s handler form, | [new relic](https://github.com/iris-contrib/middleware/tree/master/newrelic) | Official [New Relic Go Agent](https://github.com/newrelic/go-agent) | [iris-contrib/middleware/newrelic/_example](https://github.com/iris-contrib/middleware/tree/master/newrelic/_example) | | [prometheus](https://github.com/iris-contrib/middleware/tree/master/prometheus)| Easily create metrics endpoint for the [prometheus](http://prometheus.io) instrumentation tool | [iris-contrib/middleware/prometheus/_example](https://github.com/iris-contrib/middleware/tree/master/prometheus/_example) | | [casbin](https://github.com/iris-contrib/middleware/tree/master/casbin)| An authorization library that supports access control models like ACL, RBAC, ABAC | [iris-contrib/middleware/casbin/_examples](https://github.com/iris-contrib/middleware/tree/master/casbin/_examples) | -| [raven](https://github.com/iris-contrib/middleware/tree/master/raven)| Sentry client in Go | [iris-contrib/middleware/raven/_example](https://github.com/iris-contrib/middleware/blob/master/raven/_example/main.go) | +| [sentry-go (ex. raven)](https://github.com/getsentry/sentry-go/tree/master/iris)| Sentry client in Go | [sentry-go/example/iris](https://github.com/getsentry/sentry-go/blob/master/example/iris/main.go) | | [csrf](https://github.com/iris-contrib/middleware/tree/master/csrf)| Cross-Site Request Forgery Protection | [iris-contrib/middleware/csrf/_example](https://github.com/iris-contrib/middleware/blob/master/csrf/_example/main.go) | | [go-i18n](https://github.com/iris-contrib/middleware/tree/master/go-i18n)| i18n Iris Loader for nicksnyder/go-i18n | [iris-contrib/middleware/go-i18n/_example](https://github.com/iris-contrib/middleware/blob/master/go-i18n/_example/main.go) | | [throttler](https://github.com/iris-contrib/middleware/tree/master/throttler)| Rate limiting access to HTTP endpoints | [iris-contrib/middleware/throttler/_example](https://github.com/iris-contrib/middleware/blob/master/throttler/_example/main.go) | diff --git a/middleware/rewrite/rewrite.go b/middleware/rewrite/rewrite.go new file mode 100644 index 00000000..524aa724 --- /dev/null +++ b/middleware/rewrite/rewrite.go @@ -0,0 +1,219 @@ +package rewrite + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "regexp" + "strconv" + "strings" + + "github.com/kataras/iris/v12/context" + + "gopkg.in/yaml.v3" +) + +// Options holds the developer input to customize +// the redirects for the Rewrite Engine. +// Look the `New` package-level function. +type Options struct { + // RedirectMatch accepts a slice of lines + // of form: + // REDIRECT_CODE PATH_PATTERN TARGET_PATH + // Example: []{"301 /seo/(.*) /$1"}. + RedirectMatch []string `json:"redirectMatch" yaml:"RedirectMatch"` +} + +// Engine is the rewrite engine master structure. +// Navigate through _examples/routing/rewrite for more. +type Engine struct { + redirects []*redirectMatch +} + +// New returns a new Rewrite Engine based on "opts". +// It reports any parser error. +// See its `Handler` or `Wrapper` methods. Depending +// on the needs, select one. +func New(opts Options) (*Engine, error) { + redirects := make([]*redirectMatch, 0, len(opts.RedirectMatch)) + + for _, line := range opts.RedirectMatch { + r, err := parseRedirectMatchLine(line) + if err != nil { + return nil, err + } + redirects = append(redirects, r) + } + + e := &Engine{ + redirects: redirects, + } + return e, nil +} + +// Handler returns a new rewrite Iris Handler. +// It panics on any error. +// Same as engine, _ := New(opts); engine.Handler. +// Usage: +// app.UseRouter(Handler(opts)). +func Handler(opts Options) context.Handler { + engine, err := New(opts) + if err != nil { + panic(err) + } + return engine.Handler +} + +// Handler is an Iris Handler that can be used as a router or party or route middleware. +// For a global alternative, if you want to wrap the entire Iris Application +// use the `Wrapper` instead. +// Usage: +// app.UseRouter(engine.Handler) +func (e *Engine) Handler(ctx *context.Context) { + // We could also do that: + // but we don't. + // e.WrapRouter(ctx.ResponseWriter(), ctx.Request(), func(http.ResponseWriter, *http.Request) { + // ctx.Next() + // }) + for _, rd := range e.redirects { + src := ctx.Path() + if !rd.isRelativePattern { + src = ctx.Request().URL.String() + } + + if target, ok := rd.matchAndReplace(src); ok { + if target == src { + // this should never happen: StatusTooManyRequests. + // keep the router flow. + ctx.Next() + return + } + + ctx.Redirect(target, rd.code) + return + } + } + + ctx.Next() +} + +// Wrapper wraps the entire Iris Router. +// Wrapper is a bit faster than Handler because it's executed +// even before any route matched and it stops on redirect pattern match. +// Use it to wrap the entire Iris Application, otherwise look `Handler` instead. +// +// Usage: +// app.WrapRouter(engine.Wrapper). +func (e *Engine) Wrapper(w http.ResponseWriter, r *http.Request, routeHandler http.HandlerFunc) { + for _, rd := range e.redirects { + src := r.URL.Path + if !rd.isRelativePattern { + src = r.URL.String() + } + + if target, ok := rd.matchAndReplace(src); ok { + if target == src { + routeHandler(w, r) + return + } + + http.Redirect(w, r, target, rd.code) + return + } + } + + routeHandler(w, r) +} + +type redirectMatch struct { + code int + pattern *regexp.Regexp + target string + + isRelativePattern bool +} + +func (r *redirectMatch) matchAndReplace(src string) (string, bool) { + if r.pattern.MatchString(src) { + if match := r.pattern.ReplaceAllString(src, r.target); match != "" { + return match, true + } + } + + return "", false +} + +func parseRedirectMatchLine(s string) (*redirectMatch, error) { + parts := strings.Split(strings.TrimSpace(s), " ") + if len(parts) != 3 { + return nil, fmt.Errorf("redirect match: invalid line: %s", s) + } + + codeStr, pattern, target := parts[0], parts[1], parts[2] + + for i, ch := range codeStr { + if !isDigit(ch) { + return nil, fmt.Errorf("redirect match: status code digits: %s [%d:%c]", codeStr, i, ch) + } + } + + code, err := strconv.Atoi(codeStr) + if err != nil { + // this should not happen, we check abt digit + // and correctly position the error too but handle it. + return nil, fmt.Errorf("redirect match: status code digits: %s: %v", codeStr, err) + } + + if code <= 0 { + code = http.StatusMovedPermanently + } + + regex := regexp.MustCompile(pattern) + if regex.MatchString(target) { + return nil, fmt.Errorf("redirect match: loop detected: pattern: %s vs target: %s", pattern, target) + } + + v := &redirectMatch{ + code: code, + pattern: regex, + target: target, + + isRelativePattern: pattern[0] == '/', // search by path. + } + + return v, nil +} + +func isDigit(ch rune) bool { + return '0' <= ch && ch <= '9' +} + +// LoadOptions loads rewrite Options from a system file. +func LoadOptions(filename string) (opts Options) { + ext := ".yml" + if index := strings.LastIndexByte(filename, '.'); index > 1 && len(filename)-1 > index { + ext = filename[index:] + } + + f, err := os.Open(filename) + if err != nil { + panic("iris: rewrite: " + err.Error()) + } + defer f.Close() + + switch ext { + case ".yaml", ".yml": + err = yaml.NewDecoder(f).Decode(&opts) + case ".json": + err = json.NewDecoder(f).Decode(&opts) + default: + panic("iris: rewrite: unexpected file extension: " + filename) + } + + if err != nil { + panic("iris: rewrite: decode: " + err.Error()) + } + + return +} diff --git a/middleware/rewrite/rewrite_test.go b/middleware/rewrite/rewrite_test.go new file mode 100644 index 00000000..d9bb93e5 --- /dev/null +++ b/middleware/rewrite/rewrite_test.go @@ -0,0 +1,89 @@ +package rewrite + +import "testing" + +func TestRedirectMatch(t *testing.T) { + tests := []struct { + line string + parseErr string + inputs map[string]string // input, expected. Order should not matter. + }{ + { + "301 /seo/(.*) /$1", + "", + map[string]string{ + "/seo/path": "/path", + }, + }, + { + "301 /old(.*) /deprecated$1", + "", + map[string]string{ + "/old": "/deprecated", + "/old/any": "/deprecated/any", + "/old/thing/here": "/deprecated/thing/here", + }, + }, + { + "301 /old(.*) /", + "", + map[string]string{ + "/oldblabla": "/", + "/old/any": "/", + "/old/thing/here": "/", + }, + }, + { + "301 /old/(.*) /deprecated/$1", + "", + map[string]string{ + "/old/": "/deprecated/", + "/old/any": "/deprecated/any", + "/old/thing/here": "/deprecated/thing/here", + }, + }, + { + "3d /seo/(.*) /$1", + "redirect match: status code digits: 3d [1:d]", + nil, + }, + { + "301 /$1", + "redirect match: invalid line: 301 /$1", + nil, + }, + { + "301 /* /$1", + "redirect match: loop detected: pattern: /* vs target: /$1", + nil, + }, + { + "301 /* /", + "redirect match: loop detected: pattern: /* vs target: /", + nil, + }, + } + + for i, tt := range tests { + r, err := parseRedirectMatchLine(tt.line) + if err != nil { + if tt.parseErr == "" { + t.Fatalf("[%d] unexpected parse error: %v", i, err) + } + + errStr := err.Error() + if tt.parseErr != err.Error() { + t.Fatalf("[%d] a parse error was expected but it differs: expected: %s but got: %s", i, tt.parseErr, errStr) + } + } else if tt.parseErr != "" { + t.Fatalf("[%d] expected an error of: %s but got nil", i, tt.parseErr) + } + + for input, expected := range tt.inputs { + got, _ := r.matchAndReplace(input) + if expected != got { + t.Fatalf(`[%d:%s] expected: "%s" but got: "%s"`, i, input, expected, got) + } + } + } +}