contentbridge

package
v0.51.6 Latest Latest
Warning

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

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

Documentation

Overview

Package contentbridge is the node side of the kombify Content Bridge (HOMELAB-CONTENT-BRIDGE-STANDARD, ADR-0047). It reads a closed set of counts from the self-hosted apps of one server, strictly read-only, and answers them as a `homelab-content/v1` document.

The bridge is a separate component. It is not kombify Guard and never travels in Guard's channel. It answers only the consent tier its caller is granted, caches for at most one minute, stores nothing and never logs a payload.

Index

Constants

View Source
const (
	EnvListen        = "CONTENT_BRIDGE_LISTEN"
	EnvHomelabID     = "CONTENT_BRIDGE_HOMELAB_ID"
	EnvImmichURL     = "CONTENT_BRIDGE_IMMICH_URL"
	EnvImmichKeyFile = "CONTENT_BRIDGE_IMMICH_API_KEY_FILE"
	EnvCacheTTL      = "CONTENT_BRIDGE_CACHE_TTL"

	DefaultListen        = ":8083"
	DefaultImmichURL     = "http://immich-server:2283"
	DefaultImmichKeyFile = "/run/secrets/immich-api-key"
)

Environment of the bridge container. The credential arrives as a file, never as an environment value that container inspection would expose.

View Source
const (
	WorkloadRef   = "photos-content-bridge"
	ImmichKeySlot = "immich-api-key"
	// ImmichKeyName names the key inside Immich, where the owner sees and
	// can revoke it. One key per bridge: issuing again replaces it.
	ImmichKeyName = "stackkits-content-bridge"
)

Identity of the bridge's own custody and app credentials. The workload reference follows the Photos add-on convention (photos-kiosk, photos-tools), so the custody reference reads secret://workloads/photos-content-bridge/ immich-api-key like every governed workload secret.

View Source
const (
	SummaryPath = "/content/v1/summary"
	HealthPath  = "/healthz"
)

Paths of the node bridge (standard section 4).

View Source
const MaxCacheTTL = 60 * time.Second

MaxCacheTTL is the longest the bridge keeps an app answer in memory (standard section 3).

View Source
const Version = "homelab-content/v1"

Version is the `version` of every document the bridge answers.

Variables

View Source
var (
	ErrAppUnreachable    = errors.New("content bridge: app unreachable")
	ErrCredentialInvalid = errors.New("content bridge: app credential invalid")
	ErrNotConfigured     = errors.New("content bridge: app not configured")
	// ErrRequestRefused is returned for anything outside the read-only
	// allowlist. It signals a defect, never a state of the app.
	ErrRequestRefused = errors.New("content bridge: request refused by the read-only allowlist")
)

Outcomes of reading an app. They map one to one onto the statuses of the contract, so a failed read can never become an empty answer.

Functions

func ImmichKeySecretRef

func ImmichKeySecretRef() string

ImmichKeySecretRef is the local custody reference of the bridge's Immich key. The key never leaves node custody (standard section 1).

func ImmichPermissions

func ImmichPermissions() []string

ImmichPermissions are the API key permissions the bridge needs for the summary tier, and nothing more. The Immich 2.7 permission enum offers a statistics permission for each of the three counts, so the key can neither list nor open an asset, an album or a memory, and cannot write.

func NewHandler

func NewHandler(service *Service, authority TierAuthority) http.Handler

NewHandler serves the summary and the loopback health endpoint.

func NewReadOnlyTransport

func NewReadOnlyTransport(next http.RoundTripper, origin *url.URL, endpoints []Endpoint) http.RoundTripper

NewReadOnlyTransport wraps next so that only GET requests to the listed endpoints of one origin can leave the bridge. The guard sits in the transport, below every caller: no code path that holds the client, now or later, can issue a mutating call by choosing another method, path or host.

Types

type Adapter

type Adapter interface {
	UseCase() UseCase
	// Ceiling is the highest tier this adapter can answer today.
	Ceiling() Tier
	// Counts returns the summary tier counts, or one of the outcome errors.
	Counts(ctx context.Context) (any, error)
}

Adapter answers one use case from one app.

type Config

type Config struct {
	Listen        string
	HomelabID     string
	ImmichURL     string
	ImmichKeyFile string
	CacheTTL      time.Duration
}

Config is the validated environment of the bridge.

func ConfigFromEnv

func ConfigFromEnv(getenv func(string) string) (Config, error)

ConfigFromEnv reads the bridge configuration through getenv.

type Document

type Document struct {
	Version   string          `json:"version"`
	HomelabID string          `json:"homelab_id"`
	AsOf      string          `json:"as_of"`
	UseCases  []UseCaseResult `json:"use_cases"`
}

Document is the `homelab-content/v1` response.

type Endpoint

type Endpoint struct {
	Path  string
	Query []string
}

Endpoint is one allowlisted GET endpoint of an app and the query parameters it may carry.

func ImmichEndpoints

func ImmichEndpoints() []Endpoint

ImmichEndpoints is the complete GET allowlist toward Immich.

type ImmichAdapter

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

ImmichAdapter answers the Photos use case from Immich.

func NewImmichAdapter

func NewImmichAdapter(client *ReadOnlyClient, now func() time.Time) *ImmichAdapter

NewImmichAdapter wires the adapter to its read-only client. now may be nil.

func (*ImmichAdapter) Ceiling

func (*ImmichAdapter) Ceiling() Tier

Ceiling is the highest tier the node can answer for Photos. Previews need thumbnails and titles, which arrive with the node-signed thumbnail slice. Until then a previews request is answered at the summary tier.

func (*ImmichAdapter) Counts

func (a *ImmichAdapter) Counts(ctx context.Context) (any, error)

Counts reads the Photos counts. Assets are required: without them the answer is an error and never an empty set. Albums and on-this-day are reported when Immich answers them and omitted otherwise.

func (*ImmichAdapter) UseCase

func (*ImmichAdapter) UseCase() UseCase

UseCase is the contract use case this adapter answers.

type KeyFunc

type KeyFunc func() (string, error)

KeyFunc returns the app credential. It reads custody on every call, so a rotated key takes effect without a restart. It returns ErrNotConfigured when no credential has been issued yet.

func FileKey

func FileKey(path string) KeyFunc

FileKey returns a KeyFunc that reads the credential file on each call. A missing or empty file means no credential was issued yet.

type LoopbackQueryTier

type LoopbackQueryTier struct{}

LoopbackQueryTier is the authority used until envelope verification exists. It accepts the `tier` query parameter only from a caller on the loopback interface of the bridge's own network namespace, which is the owner on the node (or a test). Behind the router every request comes from the router's address, so the public route answers 403 until the envelope authority replaces this one. It never reads a forwarded-for header: a remote client controls those.

func (LoopbackQueryTier) GrantedTier

func (LoopbackQueryTier) GrantedTier(r *http.Request) (Tier, error)

GrantedTier implements TierAuthority.

type PhotosCounts

type PhotosCounts struct {
	Assets    *int64 `json:"assets,omitempty"`
	Images    *int64 `json:"images,omitempty"`
	Videos    *int64 `json:"videos,omitempty"`
	Albums    *int64 `json:"albums,omitempty"`
	OnThisDay *int64 `json:"on_this_day,omitempty"`
}

PhotosCounts are the Photos counts of the summary tier. A pointer field is absent when the app did not answer it; zero is a real answer.

type ReadOnlyClient

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

ReadOnlyClient reads JSON from one app. It has no method for anything but GET, and its transport refuses everything outside the allowlist.

func NewReadOnlyClient

func NewReadOnlyClient(origin string, endpoints []Endpoint, key KeyFunc, base http.RoundTripper) (*ReadOnlyClient, error)

NewReadOnlyClient builds the client for one app origin. base is the network transport below the guard; nil selects a direct transport that ignores proxy environment variables, because the app is on the node's own internal network and the bridge has no egress.

func (*ReadOnlyClient) GetJSON

func (c *ReadOnlyClient) GetJSON(ctx context.Context, path string, query url.Values, out any) error

GetJSON reads one allowlisted endpoint and decodes the answer into out. The error is one of the outcome errors above; it never contains a URL, a credential or a response body.

type Service

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

Service assembles homelab-content/v1 documents.

func NewService

func NewService(cfg ServiceConfig) *Service

NewService builds a Service.

func NewServiceFromConfig

func NewServiceFromConfig(cfg Config, logger *slog.Logger) (*Service, error)

NewServiceFromConfig builds the service with the Immich adapter.

func (*Service) HomelabID

func (s *Service) HomelabID() string

HomelabID is the identifier answered when the caller supplies none.

func (*Service) Summarize

func (s *Service) Summarize(ctx context.Context, useCases []UseCase, granted Tier, homelabID string) Document

Summarize answers the requested use cases at the granted tier. The effective tier of each use case is the minimum of the granted tier and the adapter's ceiling. At the off tier nothing is read.

type ServiceConfig

type ServiceConfig struct {
	// HomelabID is answered when the caller supplies none.
	HomelabID string
	Adapters  []Adapter
	// CacheTTL is clamped to MaxCacheTTL; zero selects MaxCacheTTL.
	CacheTTL time.Duration
	Now      func() time.Time
	Logger   *slog.Logger
}

ServiceConfig configures a Service.

type Status

type Status string

Status is the per use case outcome. An app that cannot be read is never reported as empty.

const (
	StatusOK                Status = "ok"
	StatusAppUnreachable    Status = "app_unreachable"
	StatusCredentialInvalid Status = "credential_invalid"
	StatusNotConfigured     Status = "not_configured"
	StatusConsentRequired   Status = "consent_required"
)

The statuses of homelab-content/v1.

type Tier

type Tier string

Tier is the consent tier a response may carry.

const (
	TierOff      Tier = "off"
	TierSummary  Tier = "summary"
	TierPreviews Tier = "previews"
)

The three consent tiers of the standard, in ascending order of content.

func MinTier

func MinTier(a, b Tier) Tier

MinTier returns the lower of two tiers. The effective tier of an answer is the minimum of what the caller is granted and what the node can answer.

func ParseTier

func ParseTier(value string) (Tier, bool)

ParseTier accepts exactly the three tier names.

type TierAuthority

type TierAuthority interface {
	GrantedTier(r *http.Request) (Tier, error)
}

TierAuthority decides which tier a request is granted. The bridge never reads a tier from anywhere else, and a request without a grant is refused before any app is called (consent default off).

The grant belongs to the Gateway: it travels as a signed `tier` claim of the installation envelope; the authority that verifies that envelope implements this interface.

type TierError

type TierError struct {
	HTTPStatus int
	Code       string
}

TierError refuses a request before anything is read from an app.

func (*TierError) Error

func (e *TierError) Error() string

type UseCase

type UseCase string

UseCase names one content use case of the contract.

const (
	UseCasePhotos    UseCase = "photos"
	UseCaseMedia     UseCase = "media"
	UseCaseDocuments UseCase = "documents"
	UseCaseFiles     UseCase = "files"
	UseCaseSmartHome UseCase = "smart_home"
)

The use cases of homelab-content/v1.

func ParseUseCase

func ParseUseCase(value string) (UseCase, bool)

ParseUseCase accepts exactly the use cases of the contract.

type UseCaseResult

type UseCaseResult struct {
	UseCase UseCase `json:"use_case"`
	App     string  `json:"app"`
	Status  Status  `json:"status"`
	Tier    Tier    `json:"tier"`
	AsOf    string  `json:"as_of,omitempty"`
	Counts  any     `json:"counts,omitempty"`
}

UseCaseResult is one use case of a Document. Counts is present only for status ok. The bridge carries no items yet: titles and thumbnails belong to the previews tier, which the bridge cannot answer yet (see Ceiling).

Jump to

Keyboard shortcuts

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