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
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:
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 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.
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.
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.
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.
The public surface is intentionally narrow:
GET and HEAD only for implemented media routes;Private/missing/invalid/unauthorized assets should normally be indistinguishable through a 404 response.
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.
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.
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.
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 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.
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.
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.
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.
502 with
media unavailable body; no direct-storage fallback. Mid-stream errors abort.404 with
not found body. HEAD has matching error headers but no body. All use no-store.Potential later capabilities include:
These optional directions require concrete requirements before expanding the product.