Help us shape the future of Go! Take the 2026 Go Developer Survey

redirect

package
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Index

Constants

View Source
const (
	PriorityDBRules        = 100
	PriorityWPPatterns     = 200
	PriorityBareSlugToBlog = 500
)

Resolution priorities. Lower runs first.

These are deliberately spaced so a provider can be inserted between two existing ones without renumbering. See plans/redirect-consolidation-plan.md.

Variables

View Source
var (
	ErrNotFoundEntryNotFound = errors.New("404 entry not found")
	ErrRedirectNotFound      = errors.New("redirect not found")
	ErrDuplicateSourcePath   = errors.New("redirect with this source path already exists")
	ErrInvalidStatusCode     = errors.New("invalid redirect status code")
	// ErrExternalTarget is returned in the public demo, which only redirects within the site.
	ErrExternalTarget = errors.New("redirects can only point to a path on this site in the public demo")
)
View Source
var ErrInvalidPattern = errors.New("invalid wildcard source_path: use whole-segment * or a trailing **")

ErrInvalidPattern is returned for a malformed wildcard source_path.

View Source
var ErrUnsafePattern = errors.New("unsafe wildcard rule: needs a literal first segment and a target that does not match its own source")

ErrUnsafePattern is returned for a wildcard rule that would redirect the whole site or its own target.

Functions

func ExpandTarget

func ExpandTarget(target string, caps []string) string

ExpandTarget replaces $1..$9 with path-escaped captures (see escapeCapture). An out-of-range reference expands to the empty string.

func IsPatternSource

func IsPatternSource(source string) bool

IsPatternSource reports whether a source_path contains a wildcard.

func MatchPattern

func MatchPattern(source, path string) ([]string, bool)

MatchPattern reports whether path matches source and returns the captures.

func Normalise

func Normalise(path string) string

Normalise canonicalises a path before matching.

Collapse duplicate slashes, then strip one trailing slash, ahead of the lookup (the rule the retired Next.js middleware applied).

Case is deliberately NOT folded. The existing lookup is case-sensitive, and folding here would make some paths that 404 today start redirecting, which is a behaviour change this stage does not make. Providers that want case folding ask for it explicitly.

func SafeLocation

func SafeLocation(target string, absoluteOK bool) bool

SafeLocation reports whether a computed redirect target may be sent as a Location header. A target is safe when it is

  • a single-slash relative path ("/x", never "//x" or "/\x"), or
  • an absolute http(s) URL, allowed only when the owner configured an absolute target (absoluteOK), never one a request could have steered.

Browsers treat "\" as "/" and drop tabs and newlines, so a backslash, a control character or a Unicode slash look-alike right after the leading "/" turns "/x" into a protocol-relative URL. Every check runs on the raw and the percent-decoded form, so an encoded payload is rejected as well.

func ToggleSlash

func ToggleSlash(path string) string

ToggleSlash returns the other trailing-slash form of a path.

The existing lookup retries with the slash toggled, so the chain does too. See backend/internal/server/redirect_handlers.go.

func ValidStatusCode

func ValidStatusCode(code int) bool

ValidStatusCode returns true if the code is an allowed redirect status.

func ValidatePatternRule

func ValidatePatternRule(source, target string) error

ValidatePatternRule checks a source/target pair. Beyond ValidatePatternSource, a wildcard rule must start with a literal segment (no catch-alls) unless its target is an absolute external URL, and a relative target must not match its own source, which would loop.

func ValidatePatternSource

func ValidatePatternSource(source string) error

ValidatePatternSource checks a wildcard source_path. Sources without a wildcard are always valid.

Types

type Answer

type Answer struct {
	Path       string  `json:"path"`
	Normalised string  `json:"normalised"`
	Redirect   *Match  `json:"redirect,omitempty"`
	MatchedBy  string  `json:"matched_by,omitempty"`
	Stages     []Stage `json:"stages"`
}

Answer is the full resolution of one path.

func (*Answer) Found

func (a *Answer) Found() bool

Found reports whether any provider matched.

type BareSlugToBlogProvider

type BareSlugToBlogProvider struct {
	// contains filtered or unexported fields
}

BareSlugToBlogProvider resolves a bare legacy slug to its blog URL.

Runs last in the chain. Legacy WordPress URLs and Google's index reference bare slugs like /the-ultimate-twitter-image-size-guide; without this they 404 when the content lives under /blog.

func NewBareSlugToBlogProvider

func NewBareSlugToBlogProvider(l BlogSlugLookup) *BareSlugToBlogProvider

func (*BareSlugToBlogProvider) Name

func (p *BareSlugToBlogProvider) Name() string

func (*BareSlugToBlogProvider) Priority

func (p *BareSlugToBlogProvider) Priority() int

func (*BareSlugToBlogProvider) Resolve

func (p *BareSlugToBlogProvider) Resolve(ctx context.Context, path string) (*Match, string)

type BlogSlugLookup

type BlogSlugLookup func(ctx context.Context, slug string) string

BlogSlugLookup reports the canonical path for a bare slug, or "".

Mirrors handlePublicSlugResolve: a bare slug resolves to /blog/<slug> only when it names a published entity whose schema is blog flavoured. Everything else lives at /<slug> and needs no hint.

type Chain

type Chain struct {
	// contains filtered or unexported fields
}

Chain walks providers in priority order and returns the first match.

func NewChain

func NewChain(providers ...Provider) *Chain

NewChain builds a chain, sorted by priority ascending.

func (*Chain) Providers

func (c *Chain) Providers() []Provider

Providers returns the chain in execution order.

func (*Chain) Resolve

func (c *Chain) Resolve(ctx context.Context, path string) *Answer

Resolve walks the chain and returns the first match, recording every stage including the ones that declined.

type CreateRedirectInput

type CreateRedirectInput struct {
	SourcePath    string     `json:"source_path"`
	TargetURL     string     `json:"target_url"`
	StatusCode    int        `json:"status_code"`
	NotFoundLogID *uuid.UUID `json:"not_found_log_id,omitempty"`
}

CreateRedirectInput is the input for creating a redirect.

type DBRulesProvider

type DBRulesProvider struct {
	// contains filtered or unexported fields
}

DBRulesProvider serves the admin-managed rules in hatamoto_redirects.

Matching is exact, then retried with the trailing slash toggled, then against wildcard rules (see pattern.go) when WithPatterns is set.

func NewDBRulesProvider

func NewDBRulesProvider(lookup LookupFunc) *DBRulesProvider

NewDBRulesProvider wraps a lookup function as a chain provider.

func (*DBRulesProvider) Name

func (p *DBRulesProvider) Name() string

func (*DBRulesProvider) Priority

func (p *DBRulesProvider) Priority() int

func (*DBRulesProvider) Resolve

func (p *DBRulesProvider) Resolve(ctx context.Context, path string) (*Match, string)

Resolve returns the matching rule and which path form found it.

func (*DBRulesProvider) WithPatterns

func (p *DBRulesProvider) WithPatterns(lookup LookupFunc) *DBRulesProvider

WithPatterns adds wildcard-rule matching, tried after the exact forms.

type LookupFunc

type LookupFunc func(ctx context.Context, path string) *Redirect

LookupFunc finds a rule by exact source_path, or nil.

type Match

type Match struct {
	// Target is where the request should go. Absolute path or full URL.
	Target string
	// Status is the HTTP status to send. 301, 302, 307 or 308.
	Status int
	// Rule names what matched, for explain output. Not user facing.
	Rule string
}

Match is a provider's answer for one path.

type NotFoundEntry

type NotFoundEntry struct {
	ID            uuid.UUID  `json:"id"`
	Path          string     `json:"path"`
	HitCount      int        `json:"hit_count"`
	LastReferrer  *string    `json:"last_referrer,omitempty"`
	LastUserAgent *string    `json:"last_user_agent,omitempty"`
	FirstSeenAt   time.Time  `json:"first_seen_at"`
	LastSeenAt    time.Time  `json:"last_seen_at"`
	Ignored       bool       `json:"ignored"`
	RedirectID    *uuid.UUID `json:"redirect_id,omitempty"`
}

NotFoundEntry represents a logged 404 hit.

type NotFoundListParams

type NotFoundListParams struct {
	Search  string `json:"search"`
	Page    int    `json:"page"`
	PerPage int    `json:"per_page"`
}

NotFoundListParams controls 404 log listing.

type PathMove

type PathMove struct{ From, To string }

PathMove is a public path an entity left and the one it lives at now.

type PostTargets

type PostTargets struct {
	Archive string
	Post    func(slug string) string
}

PostTargets are where legacy WordPress URLs land: the post archive path and a function giving a post's canonical path.

type Provider

type Provider interface {
	Name() string
	Priority() int
	Resolve(ctx context.Context, path string) (*Match, string)
}

Provider resolves one class of redirect.

A provider returns nil to decline. It must not panic and must respect ctx cancellation; the chain runs on the request hot path.

type Redirect

type Redirect struct {
	ID         uuid.UUID `json:"id"`
	SourcePath string    `json:"source_path"`
	TargetURL  string    `json:"target_url"`
	StatusCode int       `json:"status_code"`
	HitCount   int       `json:"hit_count"`
	IsActive   bool      `json:"is_active"`
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`

	// Expanded marks a wildcard match whose target was computed from the
	// request path; the public handlers keep the query string on it.
	Expanded bool `json:"-"`
}

Redirect represents a URL redirect rule.

func FindPattern

func FindPattern(rules []patternRule, path string) *Redirect

FindPattern returns the first pattern rule matching path, with the target expanded, or nil. A match whose expanded target is not SafeLocation (for example "/xx/%5Cevil.com") is dropped: the lookup answers nil, never an open redirect.

type RedirectListParams

type RedirectListParams struct {
	Search  string `json:"search"`
	Page    int    `json:"page"`
	PerPage int    `json:"per_page"`
}

RedirectListParams controls redirect listing.

type Repository

type Repository struct {
	// contains filtered or unexported fields
}

Repository handles database operations for redirects and 404 log.

Path B PR 2b/7: tenant scoping has been dropped from every query. The hatamoto_redirects and hatamoto_404_log tables retain their nullable tenant_id column for PR 3 to drop; this code simply stops writing or filtering on it. Methods that previously took a tenantID now ignore it for API compatibility — call sites will be cleaned up wholesale in PR 3.

func NewRepository

func NewRepository(db *database.DB) *Repository

NewRepository creates a new redirect repository.

func (*Repository) AddRedirectHits

func (r *Repository) AddRedirectHits(ctx context.Context, id uuid.UUID, n int) error

AddRedirectHits adds n to the hit count of a redirect.

func (*Repository) CreateRedirect

func (r *Repository) CreateRedirect(ctx context.Context, input *CreateRedirectInput) (*Redirect, error)

CreateRedirect inserts a new redirect rule. tenantID is accepted for API compatibility but no longer persisted — the column is nullable post-A.

func (*Repository) Delete404Entry

func (r *Repository) Delete404Entry(ctx context.Context, id uuid.UUID) error

Delete404Entry deletes a 404 entry.

func (*Repository) DeleteRedirect

func (r *Repository) DeleteRedirect(ctx context.Context, id uuid.UUID) error

DeleteRedirect deletes a redirect.

func (*Repository) GetActiveRedirects

func (r *Repository) GetActiveRedirects(ctx context.Context) ([]*Redirect, error)

GetActiveRedirects returns all active redirects (used to populate the in-memory cache). The tenantID parameter is accepted for compatibility but ignored — there's a single tenant now and the cache spans all rows.

func (*Repository) GetRedirectByID

func (r *Repository) GetRedirectByID(ctx context.Context, id uuid.UUID) (*Redirect, error)

GetRedirectByID returns a redirect by ID.

func (*Repository) Ignore404Entry

func (r *Repository) Ignore404Entry(ctx context.Context, id uuid.UUID) error

Ignore404Entry marks a 404 entry as ignored.

func (*Repository) Link404ToRedirect

func (r *Repository) Link404ToRedirect(ctx context.Context, entryID, redirectID uuid.UUID) error

Link404ToRedirect sets the redirect_id on a 404 entry.

func (*Repository) List404Entries

func (r *Repository) List404Entries(ctx context.Context, params NotFoundListParams) ([]*NotFoundEntry, int, error)

List404Entries returns paginated 404 entries sorted by hit_count DESC.

func (*Repository) ListRecent404Entries

func (r *Repository) ListRecent404Entries(ctx context.Context, since time.Time, limit int) ([]*NotFoundEntry, error)

ListRecent404Entries returns up to limit unresolved entries (not ignored, not redirected) last seen at or after since, most hits first (lifetime), then most recently seen. It is read-only and bounded, for summaries that must not miss new 404s behind old high-hit rows.

func (*Repository) ListRedirects

func (r *Repository) ListRedirects(ctx context.Context, params RedirectListParams) ([]*Redirect, int, error)

ListRedirects returns paginated redirect rules.

func (*Repository) UpdateRedirect

func (r *Repository) UpdateRedirect(ctx context.Context, id uuid.UUID, input *UpdateRedirectInput) (*Redirect, error)

UpdateRedirect updates a redirect rule.

func (*Repository) Upsert404Entry

func (r *Repository) Upsert404Entry(ctx context.Context, path, referrer, userAgent string) error

Upsert404Entry inserts or increments a 404 entry. The tenantID parameter is accepted for compatibility but no longer scopes the upsert — the (path) column carries a unique index post-Migration A.

type Service

type Service struct {
	// SameHostOnly refuses redirect targets that leave this site (the public
	// demo would otherwise be an open redirector on the project domain).
	SameHostOnly bool
	// contains filtered or unexported fields
}

Service provides redirect and 404 log operations.

func NewService

func NewService(db *database.DB, logger *slog.Logger) *Service

NewService creates a new redirect service and starts the background logger.

func NewServiceWithStore

func NewServiceWithStore(store Store, logger *slog.Logger) *Service

NewServiceWithStore creates a service over any Store and starts the background logger.

func (*Service) CheckRedirect

func (s *Service) CheckRedirect(ctx context.Context, path string) *Redirect

CheckRedirect looks up a redirect for the given path. Returns nil if no match. Reads from an in-memory cache with TTL.

func (*Service) CheckRedirectPattern

func (s *Service) CheckRedirectPattern(ctx context.Context, path string) *Redirect

CheckRedirectPattern matches path against the wildcard rules and returns the winner with its target expanded, or nil. Callers try exact rules first.

func (*Service) Close

func (s *Service) Close()

Close stops the background logger goroutine.

func (*Service) CreateRedirect

func (s *Service) CreateRedirect(ctx context.Context, input *CreateRedirectInput) (*Redirect, error)

CreateRedirect creates a redirect and invalidates the cache.

func (*Service) Delete404Entry

func (s *Service) Delete404Entry(ctx context.Context, id uuid.UUID) error

Delete404Entry deletes a 404 entry.

func (*Service) DeleteRedirect

func (s *Service) DeleteRedirect(ctx context.Context, id uuid.UUID) error

DeleteRedirect deletes a redirect and invalidates the cache.

func (*Service) GetRedirect

func (s *Service) GetRedirect(ctx context.Context, id uuid.UUID) (*Redirect, error)

GetRedirect returns a redirect by ID.

func (*Service) Ignore404Entry

func (s *Service) Ignore404Entry(ctx context.Context, id uuid.UUID) error

Ignore404Entry marks an entry as ignored.

func (*Service) InvalidateCache

func (s *Service) InvalidateCache()

InvalidateCache makes the next lookup reload the redirects, for writers that bypass this Service (the WordPress importer runs its own).

func (*Service) KeepEntityPaths

func (s *Service) KeepEntityPaths(ctx context.Context, moves []PathMove) (int, error)

KeepEntityPaths is MoveEntityPaths for a bulk move such as a schema's URL pattern changing: it only adds. A path that already has a redirect row (of any owner, active or not) is skipped, nothing is deleted or repointed, and a move whose new path still redirects back to the old one (an owner's rule, directly or through a chain) is skipped, because it would loop. Automatic redirects that leave the new path are retired first: the page lives there. It returns how many redirects it wrote. Moves with equal or non-local paths are skipped.

func (*Service) List404Entries

func (s *Service) List404Entries(ctx context.Context, params NotFoundListParams) ([]*NotFoundEntry, int, error)

List404Entries returns paginated 404 entries.

func (*Service) ListRecent404Entries

func (s *Service) ListRecent404Entries(ctx context.Context, since time.Time, limit int) ([]*NotFoundEntry, error)

ListRecent404Entries returns up to limit open (not ignored, not redirected) entries last seen since the given time.

func (*Service) ListRedirects

func (s *Service) ListRedirects(ctx context.Context, params RedirectListParams) ([]*Redirect, int, error)

ListRedirects returns paginated redirects.

func (*Service) Log404Async

func (s *Service) Log404Async(path, referrer, userAgent string)

Log404Async enqueues a 404 hit for background persistence. Never blocks.

func (*Service) MoveEntityPaths

func (s *Service) MoveEntityPaths(ctx context.Context, moves []PathMove) error

MoveEntityPaths makes every old path of moved entities answer 301 with its new path. It also drops a rule whose source is a new path (a page moving back must not be redirected away from itself) and repoints rules that targeted an old path, so a visitor never walks a chain. Moves with equal or non-local paths are skipped.

func (*Service) Peek

func (s *Service) Peek(ctx context.Context, path string) *Redirect

Peek finds the redirect that would apply to path without counting a hit: exact match, then the trailing-slash toggle, then wildcard rules (the same order as the public redirect lookup endpoint).

func (*Service) RetireEntityMoves

func (s *Service) RetireEntityMoves(ctx context.Context, paths []string) error

RetireEntityMoves deactivates the automatic redirects (written by MoveEntityPaths) whose source is one of paths. A page that now answers at a path must win over the redirect an earlier rename left there: rename /about to /about-us, publish a new /about, and /about serves the new page. Redirects an owner created keep their precedence; they are never touched here. The row stays, inactive, as a record of the rename.

func (*Service) UpdateRedirect

func (s *Service) UpdateRedirect(ctx context.Context, id uuid.UUID, input *UpdateRedirectInput) (*Redirect, error)

UpdateRedirect updates a redirect and invalidates the cache.

type Stage

type Stage struct {
	Provider string `json:"provider"`
	Priority int    `json:"priority"`
	Matched  bool   `json:"matched"`
	Target   string `json:"target,omitempty"`
	Status   int    `json:"status,omitempty"`
	Rule     string `json:"rule,omitempty"`
	// Variant names which form of the path matched: "exact" or
	// "slash-toggled". Empty when the provider did not match.
	Variant string `json:"variant,omitempty"`
}

Stage records one provider's turn, whether it matched or not.

Every stage is recorded, including misses. That is the whole point: "what happens to URL X" has to be answerable with the providers that declined as well as the one that fired.

type Store

type Store interface {
	Upsert404Entry(ctx context.Context, path, referrer, userAgent string) error
	List404Entries(ctx context.Context, params NotFoundListParams) ([]*NotFoundEntry, int, error)
	ListRecent404Entries(ctx context.Context, since time.Time, limit int) ([]*NotFoundEntry, error)
	Ignore404Entry(ctx context.Context, id uuid.UUID) error
	Delete404Entry(ctx context.Context, id uuid.UUID) error
	Link404ToRedirect(ctx context.Context, entryID, redirectID uuid.UUID) error
	CreateRedirect(ctx context.Context, input *CreateRedirectInput) (*Redirect, error)
	GetRedirectByID(ctx context.Context, id uuid.UUID) (*Redirect, error)
	UpdateRedirect(ctx context.Context, id uuid.UUID, input *UpdateRedirectInput) (*Redirect, error)
	DeleteRedirect(ctx context.Context, id uuid.UUID) error
	ListRedirects(ctx context.Context, params RedirectListParams) ([]*Redirect, int, error)
	GetActiveRedirects(ctx context.Context) ([]*Redirect, error)
	AddRedirectHits(ctx context.Context, id uuid.UUID, n int) error
}

Store is the persistence the service needs; *Repository implements it.

type UpdateRedirectInput

type UpdateRedirectInput struct {
	SourcePath *string `json:"source_path,omitempty"`
	TargetURL  *string `json:"target_url,omitempty"`
	StatusCode *int    `json:"status_code,omitempty"`
	IsActive   *bool   `json:"is_active,omitempty"`
}

UpdateRedirectInput is the input for updating a redirect.

type WPPatternsProvider

type WPPatternsProvider struct {
	// contains filtered or unexported fields
}

WPPatternsProvider serves the generic WordPress URL families that are shapes rather than literal paths: dated permalinks, taxonomy archives, feeds and stray wp-* directories.

The provider only answers while the site setting legacy_wp_redirects is on (see docs/REDIRECTS.md). Site-specific renames and retired sections are DB redirect rules, not code.

Taxonomy archives (/author, /category, /tag) and /blog/page/N are native routes, so they are not handled here. Targets point at the site's post archive and post URL (see WithTargets); without it they are /blog and /blog/{slug}.

func NewWPPatternsProvider

func NewWPPatternsProvider(enabled func(ctx context.Context) bool) *WPPatternsProvider

NewWPPatternsProvider builds the provider. enabled reports the legacy_wp_redirects setting; nil means always on.

func (*WPPatternsProvider) Name

func (p *WPPatternsProvider) Name() string

func (*WPPatternsProvider) Priority

func (p *WPPatternsProvider) Priority() int

func (*WPPatternsProvider) Resolve

func (p *WPPatternsProvider) Resolve(ctx context.Context, path string) (*Match, string)

func (*WPPatternsProvider) WithTargets

func (p *WPPatternsProvider) WithTargets(t func(ctx context.Context) PostTargets) *WPPatternsProvider

WithTargets sets the lookup of the site's post archive and post URLs.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL