link

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 31, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package link owns link and tag business logic.

Index

Constants

View Source
const (
	PermRead      = "links.read"
	PermCreate    = "links.create"
	PermUpdate    = "links.update"
	PermDelete    = "links.delete"
	PermTagsRead  = "tags.read"
	PermTagsWrite = "tags.write"
	// PermDomainsWrite guards settings that apply to the hostname rather than to
	// a workspace. One host serves every workspace on the instance, so this is a
	// wider grant than links.update despite touching fewer rows.
	PermDomainsWrite = "domains.write"
)

Permissions this service enforces. Named constants so a typo is a compile error rather than a silently-always-false check.

View Source
const TrashRetentionDays = 30

TrashRetentionDays is how long a soft-deleted link stays restorable.

Variables

This section is empty.

Functions

func HostOf

func HostOf(rawURL string) string

HostOf extracts the lowercase host, stored alongside the URL so the hot path and reporting never have to re-parse.

func ValidateDestination

func ValidateDestination(raw string, p DestinationPolicy) (string, error)

ValidateDestination checks a destination URL and returns its normalized form.

This is an allowlist, not a blocklist, and that is the whole design. A blocklist of dangerous schemes is a game you lose: javascript:, data:, vbscript:, file:, intent:, and whatever the next browser ships. Permitting only http and https means a new scheme is refused by default.

Known limitation, deliberately not papered over: blocking private literals does not defend against DNS rebinding, where a hostname resolves to a public address at creation and a private one when a visitor follows the link. Defending against that requires resolving at redirect time on the hot path, which cannot be afforded, or an egress policy outside this process. Recorded in docs/build-notes/SECURITY.md rather than pretended away.

Types

type Config

type Config struct {
	Policy DestinationPolicy
	// Aliases carries the operator's reserved-word additions and the profanity
	// switch. The zero value is the safe default: built-in list, filter on.
	Aliases alias.Policy
	BaseURL string
	Cache   Invalidator
	// SplitHosts mirrors config.SplitHosts. The root-redirect setting is refused
	// when false, because there the root is the dashboard.
	SplitHosts bool
	RootCache  RootInvalidator
}

type CreateInput

type CreateInput struct {
	URL         string
	Alias       string // optional; generated when empty
	Title       string
	Description string
	Tags        []string
	ExpiresAt   *time.Time
	// ForwardQuery merges the visitor's query string into the destination.
	// Off by default; the destination's own parameters always win on conflict.
	ForwardQuery bool

	// Phase 2 fields. Accepted by the parser so the API can reject them with a
	// specific message rather than ignoring them silently, which would look
	// like the feature works.
	Password  string
	MaxClicks *int64
	OneTime   bool
}

CreateInput describes a new link.

type DestinationPolicy

type DestinationPolicy struct {
	// Schemes is the allowlist. Anything outside it is refused.
	Schemes   []string
	MaxLength int
	// BlockPrivateIPs refuses literal addresses in private, loopback,
	// link-local and unique-local ranges.
	BlockPrivateIPs bool
	// BlockedHostSuffixes refuses matching hosts, matched on label boundaries.
	BlockedHostSuffixes []string
}

DestinationPolicy governs which URLs a link may point at.

func DefaultDestinationPolicy

func DefaultDestinationPolicy() DestinationPolicy

type DomainSettings

type DomainSettings struct {
	Hostname string `json:"hostname"`
	// RootRedirectURL is where https://<link host>/ sends a visitor. Empty means
	// the root answers 404, which is the default and reveals nothing.
	RootRedirectURL string `json:"root_redirect_url,omitempty"`
	// SplitHosts reports whether the setting is in effect at all. On a
	// single-host deployment the root belongs to the dashboard.
	SplitHosts bool `json:"split_hosts"`
}

DomainSettings is what an operator can configure about the hostname short links are served on.

type Invalidator

type Invalidator interface {
	InvalidateAlias(ctx context.Context, domainID uuid.UUID, alias string)
}

Invalidator clears cached snapshots when a link changes. The redirect cache implements it in M7; a nil Invalidator is valid and means "no cache".

type RootInvalidator

type RootInvalidator interface {
	InvalidateRoot()
}

RootInvalidator drops the cached root redirect when it changes. Nil is valid and means the redirect tree is not running in this process.

type Service

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

func NewService

func NewService(pool *pgxpool.Pool, cfg Config) *Service

func (*Service) Archive

func (s *Service) Archive(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*domain.Link, error)

func (*Service) Create

func (s *Service) Create(ctx context.Context, actor *auth.Identity, in CreateInput) (*domain.Link, error)

Create makes a link, generating an alias when none is supplied.

func (*Service) Delete

func (s *Service) Delete(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

Delete soft-deletes a link, keeping it restorable for TrashRetentionDays.

func (*Service) DeleteTag

func (s *Service) DeleteTag(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

func (*Service) DomainSettings

func (s *Service) DomainSettings(ctx context.Context, actor *auth.Identity) (*DomainSettings, error)

DomainSettings reads the link domain's settings.

Readable by anyone who can read links: it is one URL an operator chose, and every visitor to the bare domain sees where it points anyway.

func (*Service) Get

func (s *Service) Get(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*domain.Link, error)

func (*Service) List

List returns a keyset-paginated page of links.

func (*Service) ListTags

func (s *Service) ListTags(ctx context.Context, actor *auth.Identity) ([]domain.Tag, error)

func (*Service) LoadRootRedirect

func (s *Service) LoadRootRedirect(ctx context.Context) (string, error)

LoadRootRedirect reads the current value for the redirect path. Unexported callers only: the hot path uses it through a cache.

func (*Service) Restore

func (s *Service) Restore(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*domain.Link, error)

func (*Service) SetRootRedirect

func (s *Service) SetRootRedirect(ctx context.Context, actor *auth.Identity, rawURL string) (*DomainSettings, error)

SetRootRedirect points the link domain's root somewhere, or clears it.

Three refusals, each of which would otherwise be discovered late.

It needs domains.write rather than links.update: this is not one link, it is where every visitor who trims a short link back to its domain ends up.

It is refused outright on a single-host deployment. There "/" is the dashboard, and honouring this would take the dashboard away from the person who just set it — a failure that reads as the product breaking rather than as a setting doing what it says.

The destination goes through exactly the same validation as a link's, which matters more here than anywhere: a root redirect that skipped the private, loopback and metadata refusals would be a cleaner SSRF than the one the validator exists to prevent, because reaching it needs no link and no alias.

func (*Service) Update

func (s *Service) Update(ctx context.Context, actor *auth.Identity, id uuid.UUID, in UpdateInput) (*domain.Link, error)

type UpdateInput

type UpdateInput struct {
	URL          *string
	Alias        *string
	Title        *string
	Description  *string
	ExpiresAt    *time.Time
	ClearExpiry  bool
	Tags         *[]string
	ForwardQuery *bool
}

UpdateInput is a partial update; nil fields are left unchanged.

Jump to

Keyboard shortcuts

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