Documentation
¶
Index ¶
- Constants
- Variables
- func ExpandTarget(target string, caps []string) string
- func IsPatternSource(source string) bool
- func MatchPattern(source, path string) ([]string, bool)
- func Normalise(path string) string
- func SafeLocation(target string, absoluteOK bool) bool
- func ToggleSlash(path string) string
- func ValidStatusCode(code int) bool
- func ValidatePatternRule(source, target string) error
- func ValidatePatternSource(source string) error
- type Answer
- type BareSlugToBlogProvider
- type BlogSlugLookup
- type Chain
- type CreateRedirectInput
- type DBRulesProvider
- type LookupFunc
- type Match
- type NotFoundEntry
- type NotFoundListParams
- type PathMove
- type PostTargets
- type Provider
- type Redirect
- type RedirectListParams
- type Repository
- func (r *Repository) AddRedirectHits(ctx context.Context, id uuid.UUID, n int) error
- func (r *Repository) CreateRedirect(ctx context.Context, input *CreateRedirectInput) (*Redirect, error)
- func (r *Repository) Delete404Entry(ctx context.Context, id uuid.UUID) error
- func (r *Repository) DeleteRedirect(ctx context.Context, id uuid.UUID) error
- func (r *Repository) GetActiveRedirects(ctx context.Context) ([]*Redirect, error)
- func (r *Repository) GetRedirectByID(ctx context.Context, id uuid.UUID) (*Redirect, error)
- func (r *Repository) Ignore404Entry(ctx context.Context, id uuid.UUID) error
- func (r *Repository) Link404ToRedirect(ctx context.Context, entryID, redirectID uuid.UUID) error
- func (r *Repository) List404Entries(ctx context.Context, params NotFoundListParams) ([]*NotFoundEntry, int, error)
- func (r *Repository) ListRecent404Entries(ctx context.Context, since time.Time, limit int) ([]*NotFoundEntry, error)
- func (r *Repository) ListRedirects(ctx context.Context, params RedirectListParams) ([]*Redirect, int, error)
- func (r *Repository) UpdateRedirect(ctx context.Context, id uuid.UUID, input *UpdateRedirectInput) (*Redirect, error)
- func (r *Repository) Upsert404Entry(ctx context.Context, path, referrer, userAgent string) error
- type Service
- func (s *Service) CheckRedirect(ctx context.Context, path string) *Redirect
- func (s *Service) CheckRedirectPattern(ctx context.Context, path string) *Redirect
- func (s *Service) Close()
- func (s *Service) CreateRedirect(ctx context.Context, input *CreateRedirectInput) (*Redirect, error)
- func (s *Service) Delete404Entry(ctx context.Context, id uuid.UUID) error
- func (s *Service) DeleteRedirect(ctx context.Context, id uuid.UUID) error
- func (s *Service) GetRedirect(ctx context.Context, id uuid.UUID) (*Redirect, error)
- func (s *Service) Ignore404Entry(ctx context.Context, id uuid.UUID) error
- func (s *Service) InvalidateCache()
- func (s *Service) KeepEntityPaths(ctx context.Context, moves []PathMove) (int, error)
- func (s *Service) List404Entries(ctx context.Context, params NotFoundListParams) ([]*NotFoundEntry, int, error)
- func (s *Service) ListRecent404Entries(ctx context.Context, since time.Time, limit int) ([]*NotFoundEntry, error)
- func (s *Service) ListRedirects(ctx context.Context, params RedirectListParams) ([]*Redirect, int, error)
- func (s *Service) Log404Async(path, referrer, userAgent string)
- func (s *Service) MoveEntityPaths(ctx context.Context, moves []PathMove) error
- func (s *Service) Peek(ctx context.Context, path string) *Redirect
- func (s *Service) RetireEntityMoves(ctx context.Context, paths []string) error
- func (s *Service) UpdateRedirect(ctx context.Context, id uuid.UUID, input *UpdateRedirectInput) (*Redirect, error)
- type Stage
- type Store
- type UpdateRedirectInput
- type WPPatternsProvider
Constants ¶
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 ¶
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") )
var ErrInvalidPattern = errors.New("invalid wildcard source_path: use whole-segment * or a trailing **")
ErrInvalidPattern is returned for a malformed wildcard source_path.
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 ¶
ExpandTarget replaces $1..$9 with path-escaped captures (see escapeCapture). An out-of-range reference expands to the empty string.
func IsPatternSource ¶
IsPatternSource reports whether a source_path contains a wildcard.
func MatchPattern ¶
MatchPattern reports whether path matches source and returns the captures.
func Normalise ¶
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 ¶
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 ¶
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 ¶
ValidStatusCode returns true if the code is an allowed redirect status.
func ValidatePatternRule ¶
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 ¶
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.
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
type BlogSlugLookup ¶
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.
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) WithPatterns ¶
func (p *DBRulesProvider) WithPatterns(lookup LookupFunc) *DBRulesProvider
WithPatterns adds wildcard-rule matching, tried after the exact forms.
type LookupFunc ¶
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 ¶
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 ¶
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 ¶
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 ¶
Delete404Entry deletes a 404 entry.
func (*Repository) DeleteRedirect ¶
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 ¶
GetRedirectByID returns a redirect by ID.
func (*Repository) Ignore404Entry ¶
Ignore404Entry marks a 404 entry as ignored.
func (*Repository) Link404ToRedirect ¶
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 ¶
NewService creates a new redirect service and starts the background logger.
func NewServiceWithStore ¶
NewServiceWithStore creates a service over any Store and starts the background logger.
func (*Service) CheckRedirect ¶
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 ¶
CheckRedirectPattern matches path against the wildcard rules and returns the winner with its target expanded, or nil. Callers try exact rules first.
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 ¶
Delete404Entry deletes a 404 entry.
func (*Service) DeleteRedirect ¶
DeleteRedirect deletes a redirect and invalidates the cache.
func (*Service) GetRedirect ¶
GetRedirect returns a redirect by ID.
func (*Service) Ignore404Entry ¶
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 ¶
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 ¶
Log404Async enqueues a 404 hit for background persistence. Never blocks.
func (*Service) MoveEntityPaths ¶
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 ¶
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 ¶
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) 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.