internal/redirects/redirects.go (118 lines of code) (raw):
// Package redirects provides functions for parsing and rewriting URLs
// according to Netlify style _redirects syntax
package redirects
import (
"context"
"errors"
"fmt"
"net/url"
"regexp"
"strings"
netlifyRedirects "github.com/tj/go-redirects"
"gitlab.com/gitlab-org/labkit/log"
"gitlab.com/gitlab-org/gitlab-pages/internal/acme"
"gitlab.com/gitlab-org/gitlab-pages/internal/config"
"gitlab.com/gitlab-org/gitlab-pages/internal/vfs"
)
const (
// ConfigFile is the default name of the file containing the redirect rules.
// It follows Netlify's syntax but we don't support all of the special options yet
// - https://docs.netlify.com/routing/redirects/
// - https://docs.netlify.com/routing/redirects/redirect-options/
ConfigFile = "_redirects"
// Check https://gitlab.com/gitlab-org/gitlab-pages/-/issues/472 before increasing this value
defaultMaxConfigSize = 64 * 1024
// maxPathSegments is used to limit the number of path segments allowed in rules URLs
defaultMaxPathSegments = 25
// maxRuleCount is used to limit the total number of rules allowed in _redirects
defaultMaxRuleCount = 1000
)
var (
cfg = config.Redirects{
MaxConfigSize: defaultMaxConfigSize,
MaxPathSegments: defaultMaxPathSegments,
MaxRuleCount: defaultMaxRuleCount,
}
// ErrNoRedirect is the error thrown when a no redirect rule matches while trying to Rewrite URL.
// This means that no redirect applies to the URL and you can fallback to serving actual content instead.
ErrNoRedirect = errors.New("no redirect found")
errConfigNotFound = errors.New("_redirects file not found")
errNeedRegularFile = errors.New("_redirects needs to be a regular file (not a directory)")
errFileTooLarge = errors.New("_redirects file too large")
errFailedToOpenConfig = errors.New("unable to open _redirects file")
errFailedToParseConfig = errors.New("failed to parse _redirects file")
errFailedToParseURL = errors.New("unable to parse URL")
errNoDomainLevelRewrite = errors.New("no domain-level rewrite to outside sites")
errNoValidStartingInURLPath = errors.New("url path must start with forward slash / or http:// or https://")
errNoSplats = errors.New("splats are not enabled. See https://docs.gitlab.com/ee/user/project/pages/redirects.html#feature-flag-for-rewrites")
errMoreThanOneSplats = errors.New("rule cannot contain more than 1 asterisk (*) in its from path")
errNoPlaceholders = errors.New("placeholders are not enabled. See https://docs.gitlab.com/ee/user/project/pages/redirects.html#feature-flag-for-rewrites")
errNoParams = errors.New("params not supported")
errUnsupportedStatus = errors.New("status not supported")
errNoForce = errors.New("force! not supported")
regexpPlaceholder = regexp.MustCompile(`(?i)/:[a-z]+`)
)
func SetConfig(redirectsConfig config.Redirects) {
cfg = redirectsConfig
}
type Redirects struct {
rules []netlifyRedirects.Rule
error error
}
// Status maps over each redirect rule and returns any error message
func (r *Redirects) Status() string {
if r.error != nil {
return fmt.Sprintf("parse error: %s", r.error.Error())
}
messages := make([]string, 0, len(r.rules)+1)
messages = append(messages, fmt.Sprintf("%d rules", len(r.rules)))
for i, rule := range r.rules {
if i >= cfg.MaxRuleCount {
messages = append([]string{
fmt.Sprintf(
"The _redirects file contains (%d) rules, more than the maximum of %d rules. Only the first %d rules will be processed.",
len(r.rules),
cfg.MaxRuleCount,
cfg.MaxRuleCount,
)},
messages...,
)
break
}
if err := validateRule(rule); err != nil {
messages = append(messages, fmt.Sprintf("rule %d: error: %s", i+1, err.Error()))
} else {
messages = append(messages, fmt.Sprintf("rule %d: valid", i+1))
}
}
return strings.Join(messages, "\n")
}
// Rewrite takes in a URL and uses the parsed Netlify rules to rewrite
// the URL to the new location if it matches any rule
func (r *Redirects) Rewrite(originalURL *url.URL) (*url.URL, int, error) {
if acme.IsAcmeChallenge(originalURL.Path) {
return nil, 0, ErrNoRedirect
}
rule, newPath := r.match(originalURL)
if rule == nil {
return nil, 0, ErrNoRedirect
}
newURL, err := url.Parse(newPath)
log.WithFields(log.Fields{
"url": originalURL,
"newURL": newURL,
"err": err,
"rule.From": rule.From,
"rule.To": rule.To,
"rule.Status": rule.Status,
}).Debug("Rewrite")
return newURL, rule.Status, err
}
// ParseRedirects decodes Netlify style redirects from the projects `.../public/_redirects`
// https://docs.netlify.com/routing/redirects/#syntax-for-the-redirects-file
func ParseRedirects(ctx context.Context, root vfs.Root) *Redirects {
fi, err := root.Lstat(ctx, ConfigFile)
if err != nil {
return &Redirects{error: errConfigNotFound}
}
if !fi.Mode().IsRegular() {
return &Redirects{error: errNeedRegularFile}
}
if fi.Size() > int64(cfg.MaxConfigSize) {
return &Redirects{error: errFileTooLarge}
}
reader, err := root.Open(ctx, ConfigFile)
if err != nil {
return &Redirects{error: errFailedToOpenConfig}
}
defer reader.Close()
redirectRules, err := netlifyRedirects.Parse(reader)
if err != nil {
return &Redirects{error: errFailedToParseConfig}
}
return &Redirects{rules: redirectRules}
}