Skip to content

Hooks

Hooks are the customization seam between an incoming IIIF request and the source backend. The internal/hook.Hook interface has three methods:

Method Purpose Wired?
Resolve(ctx, identifier) Map IIIF identifier → (backend, key) ✓ consulted by handlers before source.Open
Authorize(ctx, AuthRequest) Per-identifier auth gate ✓ consulted by auth.RuleAuthorizer.applyHook after the rule-engine verdict; hook can only make a verdict more restrictive (cannot lift a rule-driven deny)
Redirect(ctx, identifier) 303 to a different URL ✓ consulted at the top of each image / info handler, before auth and parameter parsing — a non-empty URL fires a 303 immediately

AuthRequest carries the per-request inputs to Authorize: { Identifier, Path, Header }. Header is the request's http.Header (read-only by convention), so a hook can make decisions on cookies or bearer tokens. The js engine exposes it to scripts; webhook does not forward it (yet).

The default hook.Noop returns zero values for all three, which the handler maps to "use defaults": backend = sources.default, key = identifier.

Optional capability: Describe (Presentation derivation)

A hook may additionally implement presentation.Describer:

Describe(ctx, identifier) (desc presentation.Descriptor, found bool, err error)

When a directory identifier is auto-derived into a manifest/collection (see the Presentation API), iiiris layers descriptor overrides over the filesystem conventions: a manifest.{yaml,yml,json} sidecar in the directory first, then the hook's Describe result (authoritative — it wins over both conventions and the sidecar). A descriptor can set the manifest/collection label, a metadata block, and per-child (canvas / member) label overrides. The capability is detected by type assertion, so a hook that doesn't implement it simply contributes nothing. The js engine implements it (via a describe() handler); noop / lookup / lua / webhook do not. found=false means "no opinion for this identifier".

Optional capability: Overlay (watermarking)

A hook may additionally implement hook.Overlayer:

Overlay(ctx, OverlayRequest) (*OverlaySpec, error)

It decides, per image request, whether to composite an image overlay (a watermark) onto the response and how to place it. The js engine implements it via an overlay(identifier, ctx) handler; noop / lookup / lua / webhook do not. Returning null (or not implementing the handler) means "no overlay". The capability is opt-in: a deployment with no overlay-capable hook does no overlay work and keeps the zero-touch cache hit.

The handler receives ctx = { path, headers, outputWidth, outputHeight }outputWidth/outputHeight are the final derivative dimensions, so a script can suppress the overlay on small outputs (thumbnails). It returns a spec:

Field Values Meaning
backend, key source name + key The overlay image, loaded via the same sources as resolve. key required; empty backend = default source. Any decodable image; alpha (transparent PNG) is honoured.
version string Optional cache-busting token. The cache keys on the image reference, not its bytes — bump version after swapping the file under the same key.
target output (default), source output: drawn at a fixed pixel size on the final derivative. source: sized/placed in source space so it scales with the image.
anchor topLeftbottomRight, center (default) Nine-position grid.
fit none (default), shrink, scale, tile none: native size. shrink: fit within the anchored cell, never upscaling. scale: longest side = scale × the base's longest side. tile: repeat across the whole image (requires target: output).
scale number Fraction for fit: scale (e.g. 0.25).
offsetX, offsetY px Inset from the anchor edge. Ignored for tile.
opacity 0–100 (default 100) Overlay opacity.

A target: output overlay is composited after rotation and the quality colorspace conversion, so a fixed-size watermark sits upright on the final output and a gray/bitonal request stays gray/bitonal. A target: source overlay is sized/placed in source coordinates and mapped through the request's crop + scale, so it scales with the image and rotates/mirrors with it (and is cropped away if the requested region excludes it). The overlay decision is folded into the render-cache key, so a watermarked render never collides with an un-watermarked one (or with a different watermark). A hook error or an unreadable overlay image fails closed (500) — iiiris never serves an un-watermarked image for an overlay-keyed request.

Built-in hook impls

Type When to reach for it
noop Default. Identifier is the source key; no auth or redirect logic.
lookup Static template substitution (prefix/suffix/sharding). 90% of cases.
js In-process scripted logic with branching, lookups, computed strings, header-aware auth, and descriptor overrides. The recommended scripting engine. Microseconds per call.
lua Deprecated — removed in iiiris 1.0. The original scripting engine; migrate to js.
webhook The hook needs to call your existing service / database / identity system. Network latency per call.

noop

Identity. The IIIF identifier is passed straight to the configured default source as the lookup key. This is the default when no hook is configured.

lookup

Template-driven identifier-to-key translation, optionally pinned to a specific backend. The template is Go's text/template; the data context is hook.TemplateData:

Field Meaning
{{.Identifier}} Raw IIIF identifier as the client sent it
{{.IdentifierEscaped}} URL-path-escaped identifier (url.PathEscape)

Config:

hook:
  type: lookup
  lookup:
    backend: filesystem            # or http / s3 — overrides sources.default
    template: "production/{{.Identifier}}.tif"

Examples:

Template identifier "cat" resolves to
"{{.Identifier}}" cat (identity, equivalent to noop)
"production/{{.Identifier}}.tif" production/cat.tif
"v1/{{.IdentifierEscaped}}" v1/cat (escapes spaces, slashes, etc.)
"archive/{{.Identifier}}" (with id "photos/cat.jpg") archive/photos/cat.jpg

The template parses once at startup; an unparseable template fails fast. template: is required when type: lookup.

js

Embedded JavaScript (ES5.1 + most ES6, via goja — a pure-Go interpreter, no CGO). The script is compiled once and runs against a pool of pre-initialized runtimes — per-request overhead is microseconds. This is the recommended scripting engine.

The script exports a handler object. All methods are optional; a missing method is pass-through:

module.exports = {
  resolve(id) {
    // Identifier "legacy/123" → key "archive/123" on the s3 source.
    if (id.indexOf("legacy/") === 0) {
      return { backend: "s3", key: "archive/" + id.slice(7) };
    }
    return { backend: "filesystem", key: "production/" + id + ".tif" };
  },

  authorize(id, ctx) {
    // ctx = { path, headers }. headers is a read-only object of request
    // header name -> first value.
    if (id.indexOf("private/") === 0 && !ctx.headers["Authorization"]) {
      return { allow: false, reason: "private collection" };
    }
    return { allow: true };
  },

  redirect(id) {
    // null = no redirect; a string = 303 to that URL.
    return null;
  },

  describe(id) {
    // Optional: override the derived Presentation manifest/collection.
    // null = no opinion. See "Optional capability: Describe" above.
    return { label: "Item " + id };
  },

  overlay(id, ctx) {
    // Optional: composite a watermark onto image responses.
    // ctx = { path, headers, outputWidth, outputHeight }.
    // null = no overlay. See "Optional capability: Overlay" below.
    if (ctx.outputWidth < 400) return null;        // skip thumbnails
    return { backend: "watermarks", key: "logo.png",
             anchor: "bottomRight", fit: "shrink", opacity: 70 };
  },
};

Config:

hook:
  type: js
  js:
    path: /etc/iiiris/hooks.js     # OR
    script: |
      module.exports = { resolve(id) { return { key: "production/" + id + ".tif" }; } };
    watch_interval: 5s             # hot-reload poll cadence (path mode only)
    timeout: 1s                    # per-call execution budget (default 1s)

Sandbox. A bare runtime with only the JavaScript builtins (JSON, Math, String, Array, Object, Date, RegExp, …). There is no require, no Node API, no filesystem, no network, and no timers. The one host affordance is log(level, msg, fields?) — it routes into the server log (level is debug/info/warn/error; fields is an optional object of structured attributes), tagged source=hook.js. There is no print or console. Scripts are expected to be pure functions of their inputs.

Execution budget. Each call is bounded by timeout (default 1s). A script that exceeds it (e.g. an infinite loop) is interrupted and the call fails. See Failure semantics below.

Concurrency. Runtimes are pooled and reused across requests; do not mutate module-level state from inside a handler (the change leaks into other requests).

Hot reload. Identical to lua: path-mode scripts are live-reloaded, mtime re-checked at most once per watch_interval (default 5s); a changed file is recompiled and the generation swapped atomically. A recompile error is logged at WARN and the previous version stays in service. Set watch_interval negative (e.g. -1ns) to disable. Inline scripts (js.script) are never reloaded.

Failure semantics. When a handler throws, times out, or returns a malformed value:

Handler Failure result
resolve Request fails (500). No fallback to defaults — never silently serve unintended content.
authorize Deny (fail closed).
redirect Request fails (500).
describe Fail open — the override is ignored, the server-derived descriptor is used, and a WARN is logged.
overlay Request fails (500), fail closed — a broken overlay must never serve an un-watermarked image.

TypeScript. There is no TypeScript runtime — goja runs JavaScript. To author hooks in TypeScript, compile to a single dependency-free .js file (e.g. tsc --outFile hooks.js) and point js.path at the output.

lua

Deprecated. The Lua engine is superseded by the js engine and will be removed in iiiris 1.0. It still works for now; configuring hook.type: lua logs a one-line deprecation warning at startup. New hooks should use js, and existing Lua hooks should migrate. See the js section above for the equivalent surface.

Embedded Lua 5.1 (via gopher-lua). The script is compiled once and runs against a pool of pre-initialized LStates — per-request overhead is microseconds.

Three optional globals are honored. Missing globals are treated as pass-through:

function resolve(id)
  -- Identifier "cat" → key "production/cat.tif" on the s3 source.
  if string.find(id, "^legacy/") then
    return { backend = "s3", key = "archive/" .. string.sub(id, 8) }
  end
  return { backend = "filesystem", key = "production/" .. id .. ".tif" }
end

function authorize(id, request_path)
  if string.find(id, "^private/") then
    return { allow = false, reason = "private collection" }
  end
  return { allow = true }
end

function redirect(id)
  -- nil = no redirect; string = 303 to that URL.
  return nil
end

Config:

hook:
  type: lua
  lua:
    path: /etc/iiiris/hooks.lua    # OR
    script: |
      function resolve(id) return { key = "production/" .. id .. ".tif" } end
    watch_interval: 5s             # hot-reload poll cadence (path mode only)

Sandbox. Only the base, string, table, and math standard libraries are loaded. os, io, package, debug, coroutine and the dynamic-load functions (loadstring, loadfile, dofile, load) are unavailable. The intent is that scripts are pure functions of their inputs.

Concurrency. LStates are pooled and reused across requests; do not mutate globals from inside a hook function (the change leaks into other requests).

Hot reload. Path-mode scripts are live-reloaded. The file's mtime is re-checked at most once per watch_interval (default 5s); a changed file is recompiled and the script generation is swapped atomically — in- flight requests finish on the previous version, subsequent requests use the new one. A recompile error (syntax, sandbox-disallowed call, init failure) is logged at WARN and the previous version stays in service, so a bad edit does not break live traffic. Set watch_interval to a negative value (e.g. -1ns) to disable hot reload entirely. Inline scripts (lua.script) are baked into config and never reloaded.

webhook

Out-of-process: iiirisd POSTs a JSON envelope per Hook call to a URL the operator runs. The hook can be written in any language. Adds 1–10 ms of network latency per request and creates a hard dependency: if the webhook service is down, every request fails.

Request body:

{ "method": "resolve", "identifier": "cat", "request_path": "/iiif/3/cat/info.json" }

(request_path is set for authorize; omitted otherwise.)

Response body — all fields optional, only those relevant to method need be set:

{
  "backend": "filesystem",
  "key": "production/cat.tif",
  "allow": true,
  "reason": "",
  "redirect": ""
}

For authorize, an absent allow defaults to true (no opinion = allow). Non-2xx upstream responses surface as errors; the request fails.

Config:

hook:
  type: webhook
  webhook:
    url: https://iiiris-hook.example.internal/hook
    timeout: 2s            # default 2s when zero
    auth_header: "Bearer xyz"   # sent verbatim as Authorization

Cache-key invariant

The render and info caches key on r.URL.Path (i.e. the IIIF identifier as the client sees it), not on the resolved backend key. This is the right bound: two different IIIF identifiers that happen to map to the same backend file should still have distinct cache entries (their info.json id fields differ, and a client could cache them differently). The OriginCache, by contrast, keys on <source-name>:<resolved-key> — so two identifiers that resolve to the same origin object share the cached origin bytes.

Authorize vs. Redirect ordering

Both authorization hooks now run on every gated request, but at different points and with different semantics:

  • Redirect runs first, before auth and before parsing IIIF parameters. A non-empty URL produces an immediate 303 to that location and the request stops there. Use it for content relocation — "this identifier has moved" — regardless of who is asking.
  • Authorize runs after the rule engine has rendered an initial verdict from the YAML auth.rules list. The hook can refine the verdict (add a substitute identifier, a redirect URL, or flip allow → deny) but cannot lift a rule-driven deny. Use it for per-request policy: time-of-day windows, IP allowlists richer than the kiosk one, or per-user lookups against an external service.

When both produce a redirect, Redirect wins because it fires first. The auth-flow's own Decision.Redirect (driven by external SSO flows) is a third, separate redirect path that runs only after auth denies — it never collides with Hook.Redirect's unconditional behavior.

Limitations

  • lookup is a single template — per-prefix routing logic should use the js engine (it can branch on the identifier).
  • The js (and lua) sandbox has no filesystem or network access. If you need either from the hook, use webhook.
  • webhook does not yet forward request headers (the AuthRequest.Header added for js) in its JSON envelope.