Media Gateway

Architecture

Purpose

Media Gateway is a small, fail-closed HTTP publication boundary between a private media provider and public consumers.

Immich is the supported provider. Immich remains private and indexes a read-only external archive. Media Gateway is the only component allowed to turn a provider asset into an Internet-deliverable response, and only after re-evaluating publication policy from current provider metadata.

The service is intentionally smaller than the applications consuming it. It has no media-management UI, no publication database, no direct NAS access and no generic proxy behavior.

Private archive indexed by Immich, publication rules checked by Media Gateway, and eligible media served to applications and clients

Reference deployment

                                  PRIVATE

                     NAS curated photo archive
                                |
                                v
                              Immich
                         127.0.0.1:2283
                                |
                         private API key
                                |
                                v
                      +-------------------+
                      |   Media Gateway   |
                      | 127.0.0.1:2290   |
                      +---------+---------+
                                |
                       public routes only
                                |
                                v
                              nginx
                                |
                       trusted edge / HTTPS
                                |
=============================== | ===============================
                              Internet
                                |
                                v
                        media.example.com

nginx is the reference public boundary. HTTPS may terminate at nginx or an operator-controlled external edge; any external edge or tunnel is optional and deployment-specific. The architectural requirements are:

Trust boundaries

Media provider

Immich owns indexing, metadata extraction, asset identity and media retrieval. It may see both private and publication-eligible assets.

Media Gateway must therefore treat provider reachability as capability to inspect, not permission to publish.

Publication policy

Publication uses provider-indexed path metadata as the single publication switch.

Configured root:

/library/photos

Eligible exact directory segments:

public          -> images and videos eligible
public-images   -> images eligible
public-videos   -> videos eligible

Anything else is private.

internal/publication.Evaluate(policy, originalPath, media) implements the pure policy decision using the config.Policy returned by config.Load. It accepts only canonical absolute POSIX asset paths and normalized image or video media values. It does not contact a provider, access storage, log metadata or deliver bytes. Callers must not mutate the policy concurrently with evaluation.

Only exact directory components strictly beneath an allowed root and above the asset basename can grant eligibility. Any matching rule that permits the media type within its global or named-root scope is sufficient (OR semantics, no precedence). Named roots must have unique names and canonical non-overlapping paths; / is invalid. The evaluator defensively denies zero or multiple matching roots. It returns only Match{RootName, CollectionPath} plus eligibility, with zero context on denial. The collection is the root-relative parent path without leading/trailing slashes. The public catalog projects this context only after successful evaluation.

This is a policy input, not a public filesystem mapping. Media Gateway never converts the provider path into an nginx alias or public URL.

Both image and video preview/original delivery are supported, subject to current policy and representation validation.

Consumer applications

Consumers such as WordPress may store a provider asset reference, alt text, captions or presentation metadata. Consumer state never grants publication permission.

A request for a consumer-referenced asset must still pass the gateway’s current provider-root, path-segment and media-type checks. A compromised consumer must not be able to use a private asset ID to bypass policy.

Current media request flow

The implemented public image/video request flow is:

GET or HEAD /media/<asset-id>/preview
GET or HEAD /media/<asset-id>/original
          |
          v
validate route/method/identifier/variant
          | original with source metadata disabled -> fixed 404, no provider I/O
          |
          v
query Immich for current asset metadata
          |
          v
explicit isTrashed=false and isOffline=false?
          | unavailable -> 404; missing/invalid fields -> 502
          v active
asset path under configured allowed root?
          | no -> 404
          v yes
contains an exact eligible directory segment?
          | no -> 404
          v yes
provider media type permitted by that rule and implemented by gateway?
          | no -> 404
          v yes
parse Range only for authorized video original (malformed -> 400)
          |
          v
request the fixed authorized preview or original
          |
          v
validate bounded upstream response
          |
          v
stream public response

Every enabled delivery request checks current provider lifecycle availability before evaluating policy. Immich external-library moves can retain an old eligible path on a trashed/offline record; that record now denies delivery. An active record at a new ineligible path also denies. There is no second publication database to synchronize.

Public URLs

Media Gateway does not treat provider asset identifiers as secrets. Knowledge of an identifier must never be sufficient for publication; the gateway always rechecks policy.

The implemented stable image/video routes are:

/media/<asset-id>/preview
/media/<asset-id>/original

without a publication database or reversible token scheme.

Signed or opaque URLs may be added later only if they solve a demonstrated abuse, privacy or integration requirement. They must remain an additional control rather than replacing policy evaluation.

Provider paths and NAS paths must never appear in public URLs.

Public HTTP surface

The public surface is intentionally narrow:

Private/missing/invalid/unauthorized assets should normally be indistinguishable through a 404 response.

Public catalog surface

Public clients browse paginated image/video collections and exact collection assets, with independently reauthorized detail. nginx exposes the GET-only /catalog/ routes; the process remains numeric-loopback-bound. Catalog responses grant Access-Control-Allow-Origin: * without credentials or OPTIONS support. See the catalog contract for the full allowlist.

Collection identities come from publication policy and are deduplicated within each page, with at-least-once discovery across pages. Stateless gateway HMAC cursors bind the query/policy and invalidate on restart. Each request has at most eight provider calls, a 30-second deadline and 512 KiB buffered JSON output. Provider page sizes never exceed remaining output slots. No catalog database or folder-view API is used.

Collection discovery accepts optional Unicode multi-term q over the authorized root/collection_path, an exact logical root, and media=image|video. Root syntax is validated without configuration lookup; every valid root selector traverses the same streams and filters only authorized results, so unknown and nonpublic roots share empty-page/continuation behavior. Media narrows provider types and skips incompatible streams, with final Gateway checks still authoritative. Empty filtered pages retain continuation until stream exhaustion; no page-size or global sorting guarantee exists. Cursors bind all exact decoded filters.

Each collection advertises a representative preview path and media type from its already eligible candidate, without extra provider I/O. It is presentation metadata that may change across occurrences or later fail delivery, never collection identity or authority. Exact counts are omitted because provider statistics do not prove policy-equivalent public totals. See the catalog contract for the rationale.

Assets expose logical root/relative collection, path basename, image/video type, nullable dimensions and integer duration_ms. Capture/local timestamps, coordinates and original paths are present but null by default; immutable privacy.expose_source_metadata=true enables their validated projection and exact original delivery. Preview paths remain non-null without provider representation probes. Consumer references do not authorize subsequent detail or public delivery. Raw EXIF remains private.

Provider boundary

internal/immich provides a concrete metadata/preview/original/candidate client so provider-specific HTTP/JSON stays outside the pure publication evaluator. Its Asset method returns only ID, unchanged original path and normalized media type. It does not grant publication itself. The adapter requires explicit boolean isTrashed=false and isOffline=false before returning metadata. Either true returns ErrMissing; missing, null or wrongly typed lifecycle fields return ErrMetadata. Lifecycle state stays within the provider boundary; publication.Evaluate remains pure. The public handler calls Asset, requires supported image/video media, evaluates publication.Evaluate, and only then calls the fixed Preview, Original or VideoOriginal operation. Startup constructs one client from validated configuration and the separately loaded key. See the reviewed API contract.

Client.SearchCandidates performs one bounded structured metadata search page for gateway-owned discovery, collection assets or exact UUIDv4 detail. Discovery uses withExif=false and decodes only lifecycle/path/media facts. Asset projection uses withExif from the immutable privacy setting and always validates dimensions and duration. Only when enabled does it validate capture/local times and the nullable coordinate pair; hidden sensitive fields are ignored. Unavailable candidates are omitted; malformed consumed metadata fails the request. Provider path/media/lifecycle filters are optimizers only: case/accent overmatches still pass exact publication.Evaluate. The handler fills pages within its finite budget and requires exact root/parent membership for collection assets. Optional asset-list media=image|video narrows provider types and filters authorized exact members before filename matching and projection; detail accepts no query. Filename search remains Gateway-only because Immich’s stored original filename can differ from the authorized path basename. See the reviewed search contract.

The provider boundary supplies:

Do not build provider discovery, dynamic plugins or a generic provider SDK before a second provider establishes real requirements.

Exact Immich API endpoints must be verified against the supported Immich version at implementation time.

Preview delivery

Images and videos use the same provider-generated still preview streaming. Immich owns generation; Media Gateway does no transcoding, original/fullsize fallback or image buffering. The fixed preview upstream representation is /api/assets/{id}/thumbnail?size=preview. All redirects are rejected. Before public headers, require HTTP 200, exactly image/jpeg or image/webp, and a known positive Content-Length no larger than 16 MiB. Encoded, chunked and partial representations are rejected.

GET streams the validated representation. HEAD performs the same fresh metadata, policy and selected representation-header checks, then closes the provider body without draining it. Both construct only content type/length, X-Content-Type-Options: nosniff and Cache-Control: no-store (plus standard HTTP framing/date). Provider headers are never forwarded. Queries and caller headers do not select upstream behavior. Range and conditional headers are ignored; no 206, ETag or 304 support exists.

Every request revalidates authorization; no-store prevents a gateway-supported cache from bypassing revocation once Immich reports an ineligible path or trashed/offline state. It cannot recall bytes already received or stop an already-authorized in-flight response. A short/failed stream aborts the public connection or HTTP stream; headers already sent cannot be replaced with a 502. No error text is appended to image bytes.

Representative preview privacy, visual quality and live lifecycle revocation were validated against Immich 3.2.0. Consumer-specific derivative and caching strategy belongs to consumers; additional gateway representations require a fresh bounded issue grounded in a measured need.

A preview derivative may be qualified for production public delivery only after tests prove:

If provider previews do not meet those requirements, an explicit image-transformation slice may add safe re-encoding/metadata stripping. Do not add ImageMagick/libvips/transcoding dependencies preemptively.

Original images

The default-off source-metadata guard denies image/video originals with the normal fixed 404 before provider I/O or video Range parsing. The following original contracts apply only with privacy.expose_source_metadata=true; publication eligibility itself is unchanged. No original adapter or source bytes are rewritten.

Client.Original retrieves only GET /api/assets/<UUIDv4>/original, with x-api-key and no query. asset.download joins the metadata/preview permissions. Current active lifecycle, publication policy and image type are mandatory before opening it. Originals preserve source bytes and embedded EXIF/GPS, including RAW/HEIC, with no conversion or fallback.

Validate direct 200, exactly one valid parameter-free image/* Content-Type and one explicit positive int64 Content-Length. Reject transfer/content encoding and Content-Range. There is no preview-size ceiling. GET streams a shared length-enforcing body; HEAD validates the provider GET then closes it. Both construct the same four public headers described above.

Originals use a separate HTTP/1 transport with no environment proxy, redirects, compression or connection reuse. A bounded 16 KiB wire-header check rejects duplicate Content-Length and transfer encoding before Go normalizes them; net/http continues to own HTTP parsing and body framing. Dial/TLS/header acquisition remain bounded. Established image/video body streams use 60-second per-I/O upstream read and downstream write inactivity deadlines. Explicit writes/flushes refresh the server deadline; client cancellation propagates upstream. Ordinary responses retain finite server defaults. Shutdown drains for 10 seconds then force-closes remaining streams.

Video delivery

Video preview uses the existing thumbnail size=preview operation and JPEG/WebP validation. Video originals use only GET /api/assets/<id>/original without query, never /video/playback. Accept parameter-free video/* or the explicit Immich application/mxf exception; preserve exact bytes and source metadata.

After fresh authorization, ParseRange accepts at most one 128-byte value with unsigned signed-int64 decimals: explicit, open-ended or positive suffix bytes. Reject whitespace, signs, other units and multiple ranges with fixed 400 before original open. Reserialize parsed numbers before forwarding. No Range requires direct 200; a valid Range normally returns direct 206. A direct valid unsatisfiable 416 is also accepted. Provider 200 for Range is 502 without fallback.

For 206, validate one bytes start-end/total against the exact requested interval, including EOF clipping and suffix calculation, and require length end-start+1. Total is positive signed-int64. Validate optional provider Accept-Ranges as a single bytes. Raw duplicate lengths and transfer framing are rejected before HTTP normalization for both 200 and 206. For 416, validate one bytes */total and unsatisfiability, discard the provider body and construct a zero-length response. HEAD uses provider GET with the same checks and closes the body.

Immich 3.2.0 maps pre-header file-send errors, including unsatisfiable ranges, to 404. Only a 404 for an authorized valid video Range triggers exactly one no-Range GET to the same fixed original endpoint, with no query or caller headers. Validate the normal video-original 200 contract and resolve the requested range against its positive length. Unsatisfiable ranges close the body and produce public zero-body 416. An oversized/equal suffix (suffix_length >= total) reuses the validated length-enforced original body as full-representation 206 with Content-Range: bytes 0-(total-1)/total and Content-Length: total. GET retains existing inactivity/cancellation semantics; HEAD closes without reading. Other satisfiable ranges close the body and produce sanitized 502. There is no third provider request. A second 404 preserves public 404; probe auth, transport, unexpected status or malformed framing produces sanitized 502. Normal 206 performs no probe. This bounded disambiguation never loops or opens playback.

Public video originals add Accept-Ranges: bytes and validated Content-Range for 206/416. Other provider headers are discarded. Image original Range remains ignored/full 200, and all previews ignore Range. Conditional/validator semantics remain unsupported. Provider validation on Immich 3.2.0 covered original ranges, poster privacy and long streams. Repeat relevant checks after provider or deployment changes; synthetic tests alone do not qualify a deployed provider.

Configuration

Media Gateway uses TOML. Configuration describes policy and connectivity, not publication state.

Representative shape:

[server]
listen = "127.0.0.1:2290"

[provider]
type = "immich"
base_url = "http://127.0.0.1:2283"
api_key_file = "/etc/media-gateway/immich.key"
request_timeout = "15s"

[[policy.roots]]
name = "photos"
path = "/library/photos"

[[policy.rules]]
segment = "public"
media = ["image", "video"]

[[policy.rules]]
segment = "public-images"
media = ["image"]

[[policy.rules]]
segment = "public-videos"
media = ["video"]

The committed example and configuration contract describe the validated schema, including the required provider request timeout. Preview/original are fixed image/video routes; exact originals require privacy.expose_source_metadata=true. Unsupported configuration fails strict decoding. Clients combine relative capability paths with their single Gateway origin; the server does not construct absolute URLs.

Invalid policy must fail startup rather than silently widen access. Rules use exact normalized directory-segment equality; arbitrary regex/glob policy is not supported.

State

Media Gateway has no application database. The catalog keeps only a random 32-byte HMAC key and deterministic discovery streams in process memory; no persistent cursor state or seen-set exists.

Allowed state is limited to normal process/runtime state and, if later justified, disposable derived-media cache. A cache must never become publication authority; policy must still be checked before serving cached content unless an explicitly designed cache contract proves equivalent revocation semantics.

Dependencies

Prefer Go’s standard library. The product scope is small enough that a web framework, dependency injection container, ORM, message broker, scheduler or container runtime is unnecessary.

A small TOML parser is an acceptable dependency if chosen deliberately. Additional dependencies require concrete value and should stay auditable.

Failure behavior

Future extensions

Potential later capabilities include:

These optional directions require concrete requirements before expanding the product.

Back to top