Initial Commit
This commit is contained in:
101
packages/media/image/caddy-imgproxy/README.md
Normal file
101
packages/media/image/caddy-imgproxy/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# @suaveplan/caddy-imgproxy - Universal TypeScript port of the Caddy imgproxy URL rewriter — compact-grammar parser, aspect / pixel dimension resolver, gravity + operation translator, HMAC-SHA256 URL signer, full handler with Zod-validated config and OTel telemetry
|
||||
|
||||
Universal TypeScript port of the Caddy imgproxy URL rewriter.
|
||||
Parses the compact `/u<op><gravity><dims><modifiers>/<path>.<format>`
|
||||
grammar, resolves aspect-ratio + hint dimensions, translates gravity +
|
||||
operations into imgproxy's URL DSL, signs the result with
|
||||
HMAC-SHA256, and exposes a high-level `ImgproxyHandler` orchestrator
|
||||
with injectable `Clock`, `LoggerLike`, and signing hash, plus
|
||||
Zod-validated configuration and OTel telemetry.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [config](./src/config/config.md) — The `config` module exposes the Zod schema and the validating `parseConfig` function for the package's public `Imgproxy…
|
||||
- [config](./src/config/config.md) — The `config` module exposes the Zod schema and the validating `parseConfig` function for the package's public `Imgproxy…
|
||||
- [dimensions](./src/dimensions/calculate-dimensions.md) — `calculateDimensions` resolves a `ParsedImgproxyUrl` plus the configured `defaultWidth` into a concrete `{ width, heigh…
|
||||
- [calculate-dimensions](./src/dimensions/calculate-dimensions.md) — `calculateDimensions` resolves a `ParsedImgproxyUrl` plus the configured `defaultWidth` into a concrete `{ width, heigh…
|
||||
- [domain](./src/domain/extract-domain.md) — `extractDomain` translates an incoming HTTP `Host` header into the S3 sub-path segment that the URL builder interpolate…
|
||||
- [extract-domain](./src/domain/extract-domain.md) — `extractDomain` translates an incoming HTTP `Host` header into the S3 sub-path segment that the URL builder interpolate…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the canonical home of the package's stable error-code registry and the typed `CaddyImgproxy…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the canonical home of the package's stable error-code registry and the typed `CaddyImgproxy…
|
||||
- [gravity](./src/gravity/gravity.md) — The `gravity` module exposes the bidirectional translation between the compact one- or two-character grammar codes (`t`…
|
||||
- [gravity](./src/gravity/gravity.md) — The `gravity` module exposes the bidirectional translation between the compact one- or two-character grammar codes (`t`…
|
||||
- [handler](./src/handler/handler.md) — `ImgproxyHandler` is the high-level orchestrator that wraps `parseImgproxyUrl`, `extractDomain`, and `buildImgproxyUrl`…
|
||||
- [handler](./src/handler/handler.md) — `ImgproxyHandler` is the high-level orchestrator that wraps `parseImgproxyUrl`, `extractDomain`, and `buildImgproxyUrl`…
|
||||
- [operation](./src/operation/operation.md) — `operationToImgproxy` converts a resolved operation + concrete pixel dimensions + gravity into the imgproxy processing-…
|
||||
- [operation](./src/operation/operation.md) — `operationToImgproxy` converts a resolved operation + concrete pixel dimensions + gravity into the imgproxy processing-…
|
||||
- [parse](./src/parse/parse-url.md) — `parseImgproxyUrl` parses the compact imgproxy URL grammar used by the Caddy module — `/[s|u]<op><gravity><dims><modifi…
|
||||
- [parse-url](./src/parse/parse-url.md) — `parseImgproxyUrl` parses the compact imgproxy URL grammar used by the Caddy module — `/[s|u]<op><gravity><dims><modifi…
|
||||
- [signing](./src/signing/sign-path.md) — `signImgproxyPath` computes the HMAC-SHA256 URL signature that imgproxy requires when it is configured with a key + sal…
|
||||
- [sign-path](./src/signing/sign-path.md) — `signImgproxyPath` computes the HMAC-SHA256 URL signature that imgproxy requires when it is configured with a key + sal…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module owns the OTel instruments emitted by every public entry point in `@suaveplan/caddy-imgproxy`. It…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module owns the OTel instruments emitted by every public entry point in `@suaveplan/caddy-imgproxy`. It…
|
||||
- [types](./src/types/types.md) — The `types` module declares every cross-feature type that flows between the package's runtime modules: `ParsedImgproxyU…
|
||||
- [types](./src/types/types.md) — The `types` module declares every cross-feature type that flows between the package's runtime modules: `ParsedImgproxyU…
|
||||
- [url-builder](./src/url-builder/url-builder.md) — `buildImgproxyUrl` composes the final imgproxy URL path from a parsed grammar representation. It is the orchestrator th…
|
||||
- [url-builder](./src/url-builder/url-builder.md) — `buildImgproxyUrl` composes the final imgproxy URL path from a parsed grammar representation. It is the orchestrator th…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/caddy-imgproxy
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ImgproxyHandler, parseConfig } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const handler = new ImgproxyHandler({
|
||||
config: parseConfig({
|
||||
upstream: "imgproxy:8080",
|
||||
s3Bucket: "media",
|
||||
defaultWidth: 1200,
|
||||
key: "aa11bbcc",
|
||||
salt: "deadbeef",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = handler.rewrite({
|
||||
path: "/uvt9:16h1080/photos/hero.webp",
|
||||
host: "images.example.com",
|
||||
});
|
||||
|
||||
if (result.rewritten) {
|
||||
forwardToImgproxy(result.targetPath);
|
||||
} else if (result.reason === "passthrough") {
|
||||
forwardToImgproxy(originalPath);
|
||||
}
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [parse](./src/parse/parse-url.md) — compact-grammar parser.
|
||||
- [dimensions](./src/dimensions/calculate-dimensions.md) — aspect /
|
||||
hint → pixel resolution.
|
||||
- [gravity](./src/gravity/gravity.md) — grammar gravity → imgproxy
|
||||
compass tokens.
|
||||
- [operation](./src/operation/operation.md) — operation +
|
||||
dimensions → imgproxy processing string.
|
||||
- [signing](./src/signing/sign-path.md) — HMAC-SHA256 URL signing.
|
||||
- [url-builder](./src/url-builder/url-builder.md) — composes the
|
||||
final imgproxy path.
|
||||
- [domain](./src/domain/extract-domain.md) — Host header → S3
|
||||
sub-path.
|
||||
- [handler](./src/handler/handler.md) — `ImgproxyHandler`
|
||||
orchestrator.
|
||||
- [config](./src/config/config.md) — Zod-validated `ImgproxyConfig`.
|
||||
- [error-codes](./src/error-codes/error-codes.md) —
|
||||
`CADDY_IMGPROXY_CODES` + `CaddyImgproxyError`.
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OTel counters +
|
||||
histogram.
|
||||
|
||||
## Tier & dependencies
|
||||
|
||||
Universal — no DOM or Node-only APIs at the package boundary.
|
||||
`sideEffects: false`. Internal deps: `@suaveplan/crypto-hash`,
|
||||
`@suaveplan/error`, `@suaveplan/types`. External deps:
|
||||
`@opentelemetry/api`, `tslib`, `zod`.
|
||||
|
||||
131
packages/media/image/caddy-imgproxy/package.json
Normal file
131
packages/media/image/caddy-imgproxy/package.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"name": "@suaveplan/caddy-imgproxy",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal TypeScript port of the Caddy imgproxy URL rewriter — compact-grammar parser, aspect / pixel dimension resolver, gravity + operation translator, HMAC-SHA256 URL signer, full handler with Zod-validated config and OTel telemetry",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./parse": {
|
||||
"import": {
|
||||
"types": "./dist/parse/index.d.ts",
|
||||
"default": "./dist/parse/index.js"
|
||||
}
|
||||
},
|
||||
"./dimensions": {
|
||||
"import": {
|
||||
"types": "./dist/dimensions/index.d.ts",
|
||||
"default": "./dist/dimensions/index.js"
|
||||
}
|
||||
},
|
||||
"./gravity": {
|
||||
"import": {
|
||||
"types": "./dist/gravity/index.d.ts",
|
||||
"default": "./dist/gravity/index.js"
|
||||
}
|
||||
},
|
||||
"./operation": {
|
||||
"import": {
|
||||
"types": "./dist/operation/index.d.ts",
|
||||
"default": "./dist/operation/index.js"
|
||||
}
|
||||
},
|
||||
"./signing": {
|
||||
"import": {
|
||||
"types": "./dist/signing/index.d.ts",
|
||||
"default": "./dist/signing/index.js"
|
||||
}
|
||||
},
|
||||
"./url-builder": {
|
||||
"import": {
|
||||
"types": "./dist/url-builder/index.d.ts",
|
||||
"default": "./dist/url-builder/index.js"
|
||||
}
|
||||
},
|
||||
"./domain": {
|
||||
"import": {
|
||||
"types": "./dist/domain/index.d.ts",
|
||||
"default": "./dist/domain/index.js"
|
||||
}
|
||||
},
|
||||
"./handler": {
|
||||
"import": {
|
||||
"types": "./dist/handler/index.d.ts",
|
||||
"default": "./dist/handler/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"import": {
|
||||
"types": "./dist/config/index.d.ts",
|
||||
"default": "./dist/config/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./telemetry": {
|
||||
"import": {
|
||||
"types": "./dist/telemetry/index.d.ts",
|
||||
"default": "./dist/telemetry/index.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/crypto-hash": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
86
packages/media/image/caddy-imgproxy/src/config/config.md
Normal file
86
packages/media/image/caddy-imgproxy/src/config/config.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# config
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `config` module exposes the Zod schema and the validating
|
||||
`parseConfig` function for the package's public `ImgproxyConfig`. It
|
||||
is the single boundary where a raw object — typically loaded from
|
||||
environment variables, JSON, or a Caddyfile shim — is converted into
|
||||
the immutable, defaulted `ImgproxyConfig` that the URL builder and
|
||||
the handler consume.
|
||||
|
||||
`parseConfig` raises stable, typed `CaddyImgproxyError` instances on
|
||||
every failure path: `INVALID_OPTIONS` for general schema failures,
|
||||
`INVALID_KEY` for a malformed `key`, and `INVALID_SALT` for a
|
||||
malformed `salt`. The schema applies the same defaults the upstream
|
||||
Go module ships with (`localhost:8080`, `images`, `800`) so a bare
|
||||
`parseConfig({})` returns a usable production configuration.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod schema is exported so callers can compose it into larger
|
||||
configs.
|
||||
- Defaults match the Go reference exactly.
|
||||
- Hex validation built into the schema — even-length, lower-case
|
||||
only.
|
||||
- Distinct error codes for key vs salt failures so consumers can
|
||||
surface targeted remediation in their UI.
|
||||
- The returned `ImgproxyConfig` is frozen for safe sharing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseConfig } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const cfg = parseConfig({});
|
||||
cfg.upstream; // "localhost:8080"
|
||||
cfg.s3Bucket; // "images"
|
||||
cfg.defaultWidth; // 800
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseConfig, ImgproxyConfigSchema } from "@suaveplan/caddy-imgproxy";
|
||||
import z from "zod";
|
||||
|
||||
// Compose into a larger application schema:
|
||||
const AppEnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production"]),
|
||||
IMGPROXY: ImgproxyConfigSchema,
|
||||
});
|
||||
|
||||
const env = AppEnvSchema.parse(process.env);
|
||||
const cfg = parseConfig(env.IMGPROXY);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ImgproxyConfigSchema`
|
||||
|
||||
A Zod object schema with the following fields:
|
||||
|
||||
- `upstream: string` — default `"localhost:8080"`, non-empty.
|
||||
- `s3Bucket: string` — default `"images"`, non-empty.
|
||||
- `defaultWidth: number` — positive integer, default `800`.
|
||||
- `key?: string` — optional even-length lower-hex string.
|
||||
- `salt?: string` — optional even-length lower-hex string.
|
||||
|
||||
### `parseConfig(raw: unknown): ImgproxyConfig`
|
||||
|
||||
- **Returns:** a frozen `ImgproxyConfig`.
|
||||
- **Throws:** `CaddyImgproxyError` with one of `INVALID_OPTIONS`,
|
||||
`INVALID_KEY`, `INVALID_SALT`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`safeParse` is used instead of `parse` so the function can branch on
|
||||
the first issue's `path` to choose the appropriate error code. The
|
||||
schema lifts the hex check into a `refine` so the Zod issue path is
|
||||
exactly `["key"]` or `["salt"]` when the hex format fails — that
|
||||
makes the `parseConfig` dispatch one-liner reliable. The returned
|
||||
config is frozen so downstream code cannot mutate it accidentally.
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
type CaddyImgproxyError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
|
||||
describe("parseConfig", () => {
|
||||
it("applies defaults when fields are omitted", { timeout: 5000 }, () => {
|
||||
const cfg = parseConfig({});
|
||||
expect(cfg.upstream).toBe("localhost:8080");
|
||||
expect(cfg.s3Bucket).toBe("images");
|
||||
expect(cfg.defaultWidth).toBe(800);
|
||||
expect(cfg.key).toBeUndefined();
|
||||
expect(cfg.salt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a fully-specified config", { timeout: 5000 }, () => {
|
||||
const cfg = parseConfig({
|
||||
upstream: "imgproxy:8080",
|
||||
s3Bucket: "media",
|
||||
defaultWidth: 1200,
|
||||
key: "aa11",
|
||||
salt: "bbcc",
|
||||
});
|
||||
expect(cfg.upstream).toBe("imgproxy:8080");
|
||||
expect(cfg.s3Bucket).toBe("media");
|
||||
expect(cfg.defaultWidth).toBe(1200);
|
||||
expect(cfg.key).toBe("aa11");
|
||||
expect(cfg.salt).toBe("bbcc");
|
||||
});
|
||||
|
||||
it("raises INVALID_OPTIONS when defaultWidth is zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseConfig({ defaultWidth: 0 });
|
||||
expect.fail("expected INVALID_OPTIONS");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_OPTIONS when upstream is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseConfig({ upstream: "" });
|
||||
expect.fail("expected INVALID_OPTIONS");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_KEY when key is malformed hex", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseConfig({ key: "xyz" });
|
||||
expect.fail("expected INVALID_KEY");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_KEY when key is odd length", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseConfig({ key: "abc" });
|
||||
expect.fail("expected INVALID_KEY");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_SALT when salt is malformed hex", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseConfig({ salt: "zz" });
|
||||
expect.fail("expected INVALID_SALT");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_SALT
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
71
packages/media/image/caddy-imgproxy/src/config/config.ts
Normal file
71
packages/media/image/caddy-imgproxy/src/config/config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
CaddyImgproxyError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { ImgproxyConfig } from "../types/types.js";
|
||||
|
||||
const HEX_RE = /^[0-9a-f]*$/u;
|
||||
|
||||
const HexStringSchema = z
|
||||
.string()
|
||||
.min(2, "must be a non-empty even-length lower-hex string")
|
||||
.refine(
|
||||
(value) => value.length % 2 === 0 && HEX_RE.test(value),
|
||||
"must be even-length lower-hex"
|
||||
);
|
||||
|
||||
/**
|
||||
* Public Zod schema for `ImgproxyConfig`. Defaults mirror the Go
|
||||
* module: upstream `localhost:8080`, bucket `images`, default width
|
||||
* 800. `key` / `salt` are validated as even-length lower-hex when
|
||||
* supplied; absence flips the rewriter into `/unsafe` mode.
|
||||
*/
|
||||
export const ImgproxyConfigSchema = z.object({
|
||||
upstream: z.string().min(1).default("localhost:8080"),
|
||||
s3Bucket: z.string().min(1).default("images"),
|
||||
defaultWidth: z
|
||||
.number()
|
||||
.int("defaultWidth must be an integer")
|
||||
.positive("defaultWidth must be > 0")
|
||||
.default(800),
|
||||
key: HexStringSchema.optional(),
|
||||
salt: HexStringSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parses and validates a raw config object into an immutable
|
||||
* `ImgproxyConfig`. Raises `CaddyImgproxyError(INVALID_KEY)` when the
|
||||
* key is malformed hex, `INVALID_SALT` for the salt, and
|
||||
* `INVALID_OPTIONS` for everything else.
|
||||
*/
|
||||
export function parseConfig(raw: unknown): ImgproxyConfig {
|
||||
const result = ImgproxyConfigSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
const firstIssue = result.error.issues[0] as {
|
||||
path: PropertyKey[];
|
||||
message: string;
|
||||
};
|
||||
const path = firstIssue.path.join(".");
|
||||
if (path === "key") {
|
||||
throw new CaddyImgproxyError(
|
||||
CADDY_IMGPROXY_CODES.INVALID_KEY,
|
||||
"key must be a non-empty even-length lower-hex string",
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
if (path === "salt") {
|
||||
throw new CaddyImgproxyError(
|
||||
CADDY_IMGPROXY_CODES.INVALID_SALT,
|
||||
"salt must be a non-empty even-length lower-hex string",
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
throw new CaddyImgproxyError(
|
||||
CADDY_IMGPROXY_CODES.INVALID_OPTIONS,
|
||||
`ImgproxyConfig validation failed: ${firstIssue.message}`,
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
return Object.freeze(result.data);
|
||||
}
|
||||
1
packages/media/image/caddy-imgproxy/src/config/index.ts
Normal file
1
packages/media/image/caddy-imgproxy/src/config/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImgproxyConfigSchema, parseConfig } from "./config.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# calculateDimensions
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`calculateDimensions` resolves a `ParsedImgproxyUrl` plus the
|
||||
configured `defaultWidth` into a concrete `{ width, height }` pair in
|
||||
integer pixels. It is the single source of truth for how the compact
|
||||
grammar's aspect-ratio + hint syntax becomes pixel values; every
|
||||
processing string emitted by `operationToImgproxy` is derived from
|
||||
this result.
|
||||
|
||||
Semantics mirror the upstream Go `calculateDimensions` byte-for-byte:
|
||||
no dimensions → `{0, 0}` (passthrough); pixel dimensions return
|
||||
verbatim; aspect-ratio with an `h` hint drives height first; otherwise
|
||||
width drives (from the `w` hint or the configured default). All
|
||||
divisions truncate toward zero via `Math.trunc` so the TypeScript and
|
||||
Go implementations agree on every fixture.
|
||||
|
||||
## Features
|
||||
|
||||
- Total function — never throws, always returns concrete numbers.
|
||||
- Honors the same precedence as the Go reference: hint axis wins over
|
||||
default width.
|
||||
- Integer truncation matches Go integer division exactly.
|
||||
- Zero runtime cost beyond two multiplications and one divide on the
|
||||
aspect path.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { calculateDimensions, parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
|
||||
const dims = calculateDimensions(parsed, 800);
|
||||
dims; // { width: 800, height: 450 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { calculateDimensions, parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
// Height hint with portrait aspect ratio
|
||||
const portrait = parseImgproxyUrl("/uv9:16h1080/photo.jpg");
|
||||
calculateDimensions(portrait, 800);
|
||||
// → { width: 607, height: 1080 }
|
||||
|
||||
// Pixel passthrough — the default-width parameter is ignored
|
||||
const pixel = parseImgproxyUrl("/uf800x600/photo.jpg");
|
||||
calculateDimensions(pixel, 9999);
|
||||
// → { width: 800, height: 600 }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `calculateDimensions(parsed: ParsedImgproxyUrl, defaultWidth: number): ImgproxyDimensions`
|
||||
|
||||
- **Parameters:**
|
||||
- `parsed` — output of `parseImgproxyUrl`.
|
||||
- `defaultWidth` — width used when the URL is in aspect-ratio mode
|
||||
without a `w`/`h` hint.
|
||||
- **Returns:** `{ width, height }`, both non-negative integers. Either
|
||||
axis may be `0` (meaning "auto / unspecified").
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function is intentionally a pure expression — it never touches
|
||||
the clock, the meter, or the logger so it composes cleanly under
|
||||
property-based tests. The default-width branch is reached when the
|
||||
parsed URL specifies an aspect ratio but neither a `w` nor an `h`
|
||||
hint, or when `hintType === "h"` but `hintValue === 0` (the
|
||||
zero-value clause is unreachable from the public regex but defended
|
||||
for callers who construct `ParsedImgproxyUrl` values directly).
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { ParsedImgproxyUrl } from "../types/types.js";
|
||||
import { calculateDimensions } from "./calculate-dimensions.js";
|
||||
|
||||
function parsed(overrides: Partial<ParsedImgproxyUrl>): ParsedImgproxyUrl {
|
||||
return {
|
||||
signed: false,
|
||||
operation: "",
|
||||
gravity: "c",
|
||||
aspectW: 0,
|
||||
aspectH: 0,
|
||||
pixelW: 0,
|
||||
pixelH: 0,
|
||||
hintType: "",
|
||||
hintValue: 0,
|
||||
quality: 0,
|
||||
path: "x",
|
||||
format: "png",
|
||||
isAspect: false,
|
||||
hasDims: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("calculateDimensions", () => {
|
||||
it("returns zero when hasDims is false", { timeout: 5000 }, () => {
|
||||
const r = calculateDimensions(parsed({}), 800);
|
||||
expect(r).toEqual({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it("returns pixel dimensions unchanged", { timeout: 5000 }, () => {
|
||||
const r = calculateDimensions(
|
||||
parsed({ pixelW: 800, pixelH: 600, hasDims: true }),
|
||||
1200
|
||||
);
|
||||
expect(r).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it("resolves 16:9 with default width", { timeout: 5000 }, () => {
|
||||
const r = calculateDimensions(
|
||||
parsed({
|
||||
aspectW: 16,
|
||||
aspectH: 9,
|
||||
isAspect: true,
|
||||
hasDims: true,
|
||||
}),
|
||||
800
|
||||
);
|
||||
expect(r).toEqual({ width: 800, height: 450 });
|
||||
});
|
||||
|
||||
it("resolves 9:16 with default width", { timeout: 5000 }, () => {
|
||||
const r = calculateDimensions(
|
||||
parsed({
|
||||
aspectW: 9,
|
||||
aspectH: 16,
|
||||
isAspect: true,
|
||||
hasDims: true,
|
||||
}),
|
||||
800
|
||||
);
|
||||
expect(r).toEqual({ width: 800, height: 1422 });
|
||||
});
|
||||
|
||||
it("resolves 16:9 with width hint", { timeout: 5000 }, () => {
|
||||
const r = calculateDimensions(
|
||||
parsed({
|
||||
aspectW: 16,
|
||||
aspectH: 9,
|
||||
isAspect: true,
|
||||
hasDims: true,
|
||||
hintType: "w",
|
||||
hintValue: 1920,
|
||||
}),
|
||||
800
|
||||
);
|
||||
expect(r).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it("resolves 9:16 with height hint", { timeout: 5000 }, () => {
|
||||
const r = calculateDimensions(
|
||||
parsed({
|
||||
aspectW: 9,
|
||||
aspectH: 16,
|
||||
isAspect: true,
|
||||
hasDims: true,
|
||||
hintType: "h",
|
||||
hintValue: 1080,
|
||||
}),
|
||||
800
|
||||
);
|
||||
expect(r).toEqual({ width: 607, height: 1080 });
|
||||
});
|
||||
|
||||
it("resolves 1:1 square aspect", { timeout: 5000 }, () => {
|
||||
const r = calculateDimensions(
|
||||
parsed({
|
||||
aspectW: 1,
|
||||
aspectH: 1,
|
||||
isAspect: true,
|
||||
hasDims: true,
|
||||
}),
|
||||
500
|
||||
);
|
||||
expect(r).toEqual({ width: 500, height: 500 });
|
||||
});
|
||||
|
||||
it("h-hint with zero value falls back to default-width path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// hintType = "h" but hintValue = 0 — should hit the default width branch
|
||||
const r = calculateDimensions(
|
||||
parsed({
|
||||
aspectW: 1,
|
||||
aspectH: 1,
|
||||
isAspect: true,
|
||||
hasDims: true,
|
||||
hintType: "h",
|
||||
hintValue: 0,
|
||||
}),
|
||||
500
|
||||
);
|
||||
expect(r).toEqual({ width: 500, height: 500 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ImgproxyDimensions, ParsedImgproxyUrl } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Resolves a `ParsedImgproxyUrl` plus the configured `defaultWidth`
|
||||
* into concrete integer pixel dimensions.
|
||||
*
|
||||
* Mirrors the Go `calculateDimensions` precedence:
|
||||
* - No dimensions present → `{ 0, 0 }` (caller passes through).
|
||||
* - Pixel dimensions present → return them verbatim.
|
||||
* - Aspect ratio + `h` hint → height drives, width derived.
|
||||
* - Aspect ratio + `w` hint OR no hint → width drives (or default), height derived.
|
||||
*
|
||||
* All derived values use `Math.trunc` so the integer truncation
|
||||
* exactly matches Go's `int` division.
|
||||
*/
|
||||
export function calculateDimensions(
|
||||
parsed: ParsedImgproxyUrl,
|
||||
defaultWidth: number
|
||||
): ImgproxyDimensions {
|
||||
if (!parsed.hasDims) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
if (!parsed.isAspect) {
|
||||
return { width: parsed.pixelW, height: parsed.pixelH };
|
||||
}
|
||||
|
||||
if (parsed.hintType === "h" && parsed.hintValue > 0) {
|
||||
const height = parsed.hintValue;
|
||||
const width = Math.trunc((height * parsed.aspectW) / parsed.aspectH);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const width = parsed.hintValue > 0 ? parsed.hintValue : defaultWidth;
|
||||
const height = Math.trunc((width * parsed.aspectH) / parsed.aspectW);
|
||||
return { width, height };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { calculateDimensions } from "./calculate-dimensions.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# extractDomain
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`extractDomain` translates an incoming HTTP `Host` header into the
|
||||
S3 sub-path segment that the URL builder interpolates into the
|
||||
`s3://<bucket>/<domain>/<path>@<format>` template. It is the single
|
||||
place where the multi-tenant routing convention "the `images.` host
|
||||
prefix maps to the bare apex domain" is enforced.
|
||||
|
||||
The function is total — it never throws — so callers may apply it
|
||||
to any string. Hosts shorter than two labels (such as `localhost`)
|
||||
are returned unchanged because the heuristic only makes sense for
|
||||
fully-qualified domain names. Port suffixes are removed before
|
||||
splitting so `images.example.com:8080` and `images.example.com` map
|
||||
to the same value.
|
||||
|
||||
## Features
|
||||
|
||||
- Total function over arbitrary input strings.
|
||||
- Port-aware: strips `:<port>` before label splitting.
|
||||
- Single-label hosts (`localhost`) pass through unchanged.
|
||||
- Mirrors the upstream Go heuristic exactly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { extractDomain } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
extractDomain("images.example.com:8080"); // "example.com"
|
||||
extractDomain("cdn.example.com"); // "cdn.example.com"
|
||||
extractDomain("localhost"); // "localhost"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { extractDomain } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
// Apply per-request to build a tenant key:
|
||||
function tenantKey(host: string): string {
|
||||
return extractDomain(host).replace(/\./g, "-");
|
||||
}
|
||||
tenantKey("images.acme.example.com"); // "acme-example-com"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `extractDomain(host: string): string`
|
||||
|
||||
- **Parameters:** `host` — the raw `Host` header value (with or
|
||||
without a port suffix).
|
||||
- **Returns:** the apex domain when the prefix is `images.`,
|
||||
otherwise the host unchanged (minus any port suffix).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implemented as a two-step string scan: `indexOf(":")` to locate the
|
||||
optional port boundary, then `String.prototype.split(".")` to break
|
||||
the host into labels. The label check requires at least two segments
|
||||
because dropping the first label of a one-label host would yield the
|
||||
empty string. The function does not validate the host against any
|
||||
DNS spec — it accepts whatever the caller passes through.
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { extractDomain } from "./extract-domain.js";
|
||||
|
||||
describe("extractDomain", () => {
|
||||
it("strips port and drops the images. subdomain", { timeout: 5000 }, () => {
|
||||
expect(extractDomain("images.example.com:8080")).toBe("example.com");
|
||||
expect(extractDomain("images.example.com")).toBe("example.com");
|
||||
});
|
||||
|
||||
it("returns hosts without images. unchanged", { timeout: 5000 }, () => {
|
||||
expect(extractDomain("example.com")).toBe("example.com");
|
||||
expect(extractDomain("cdn.example.com")).toBe("cdn.example.com");
|
||||
});
|
||||
|
||||
it("returns short hosts unchanged", { timeout: 5000 }, () => {
|
||||
expect(extractDomain("localhost")).toBe("localhost");
|
||||
expect(extractDomain("localhost:8080")).toBe("localhost");
|
||||
});
|
||||
|
||||
it("handles images. with two-label host", { timeout: 5000 }, () => {
|
||||
expect(extractDomain("images.co.uk")).toBe("co.uk");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Strips any `:<port>` suffix and drops a leading `images.` subdomain
|
||||
* label. Mirrors the Go `extractDomain` helper byte-for-byte:
|
||||
*
|
||||
* - `images.example.com:8080` → `example.com`
|
||||
* - `images.example.com` → `example.com`
|
||||
* - `example.com` → `example.com`
|
||||
* - `localhost:8080` → `localhost`
|
||||
* - `images.co.uk` → `co.uk`
|
||||
*
|
||||
* Hosts shorter than two labels after port removal are returned
|
||||
* unchanged. The function is total — it never throws — so callers
|
||||
* can safely apply it to any `Host` header value.
|
||||
*/
|
||||
export function extractDomain(host: string): string {
|
||||
const colonIdx = host.indexOf(":");
|
||||
const withoutPort = colonIdx >= 0 ? host.slice(0, colonIdx) : host;
|
||||
|
||||
const parts = withoutPort.split(".");
|
||||
if (parts.length >= 2 && parts[0] === "images") {
|
||||
return parts.slice(1).join(".");
|
||||
}
|
||||
return withoutPort;
|
||||
}
|
||||
1
packages/media/image/caddy-imgproxy/src/domain/index.ts
Normal file
1
packages/media/image/caddy-imgproxy/src/domain/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { extractDomain } from "./extract-domain.js";
|
||||
@@ -0,0 +1,101 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `error-codes` module is the canonical home of the package's
|
||||
stable error-code registry and the typed `CaddyImgproxyError` class
|
||||
that carries those codes. Every failure path in the package raises a
|
||||
`CaddyImgproxyError` whose `code` is drawn from this registry;
|
||||
consumers discriminate on `error.code` rather than `instanceof` so
|
||||
the namespace survives module boundaries and runtime serialisation.
|
||||
|
||||
Codes are built with `@suaveplan/error`'s `defineCodes` helper, which
|
||||
emits the canonical `NAMESPACE.REASON` form (e.g.
|
||||
`"CADDY_IMGPROXY.INVALID_URL"`). The `CaddyImgproxyError` class
|
||||
extends `SuaveplanError<CaddyImgproxyCode>` so `code` narrows
|
||||
against the literal union at compile time.
|
||||
|
||||
## Features
|
||||
|
||||
- Built via `defineCodes` so the namespace + reason form is uniform.
|
||||
- `CaddyImgproxyError` carries `code`, `message`, `cause`, `context`,
|
||||
`timestamp`, and the standard `SuaveplanError` envelope.
|
||||
- Discriminating union literal (`CaddyImgproxyCode`) for exhaustive
|
||||
`switch` checks.
|
||||
- Every code in the registry is reachable from at least one call
|
||||
site exercised by the tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
CaddyImgproxyError,
|
||||
} from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
try {
|
||||
// … call into the package
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof CaddyImgproxyError &&
|
||||
err.code === CADDY_IMGPROXY_CODES.INVALID_URL
|
||||
) {
|
||||
// handle parse failure
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
CaddyImgproxyError,
|
||||
} from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
function classify(err: unknown): "parse" | "config" | "host" | "other" {
|
||||
if (!(err instanceof CaddyImgproxyError)) return "other";
|
||||
switch (err.code) {
|
||||
case CADDY_IMGPROXY_CODES.INVALID_URL:
|
||||
case CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS:
|
||||
return "parse";
|
||||
case CADDY_IMGPROXY_CODES.INVALID_OPTIONS:
|
||||
case CADDY_IMGPROXY_CODES.INVALID_KEY:
|
||||
case CADDY_IMGPROXY_CODES.INVALID_SALT:
|
||||
return "config";
|
||||
case CADDY_IMGPROXY_CODES.INVALID_HOST:
|
||||
return "host";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CADDY_IMGPROXY_CODES`
|
||||
|
||||
A frozen object mapping reason names to their `CADDY_IMGPROXY.REASON`
|
||||
code strings. Reasons: `INVALID_URL`, `MISSING_DIMENSIONS`,
|
||||
`INVALID_OPTIONS`, `INVALID_KEY`, `INVALID_SALT`, `INVALID_HOST`.
|
||||
|
||||
### `class CaddyImgproxyError extends SuaveplanError<CaddyImgproxyCode>`
|
||||
|
||||
Public constructor: `(code, message, options?)`. Options carry
|
||||
`cause` and `context`.
|
||||
|
||||
### `type CaddyImgproxyCode`
|
||||
|
||||
Discriminating union literal extracted from the registry values.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a frozen object indexed by reason name, with
|
||||
each value typed as `${Namespace}.${Reason}` for compile-time literal
|
||||
narrowing. The class deliberately exposes a public constructor —
|
||||
`SuaveplanError`'s own constructor is `protected` so subclasses must
|
||||
provide a concrete entry point. The `name = "CaddyImgproxyError"`
|
||||
assignment matters for stack-trace clarity; without it `new.target`
|
||||
would still set it correctly but explicit is clearer.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CADDY_IMGPROXY_CODES, CaddyImgproxyError } from "./error-codes.js";
|
||||
|
||||
describe("CADDY_IMGPROXY_CODES", () => {
|
||||
it("namespaces every code under CADDY_IMGPROXY.", { timeout: 5000 }, () => {
|
||||
for (const value of Object.values(CADDY_IMGPROXY_CODES)) {
|
||||
expect(value.startsWith("CADDY_IMGPROXY.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the expected leaf names", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(CADDY_IMGPROXY_CODES).sort()).toEqual([
|
||||
"INVALID_HOST",
|
||||
"INVALID_KEY",
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_SALT",
|
||||
"INVALID_URL",
|
||||
"MISSING_DIMENSIONS",
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries the supplied code, message, cause, and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new CaddyImgproxyError(
|
||||
CADDY_IMGPROXY_CODES.INVALID_URL,
|
||||
"bad url",
|
||||
{ cause, context: { path: "/x" } }
|
||||
);
|
||||
expect(err).toBeInstanceOf(CaddyImgproxyError);
|
||||
expect(err.code).toBe(CADDY_IMGPROXY_CODES.INVALID_URL);
|
||||
expect(err.message).toBe("bad url");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ path: "/x" });
|
||||
expect(err.name).toBe("CaddyImgproxyError");
|
||||
});
|
||||
|
||||
it("every registered code is thrown from at least one call site", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// INVALID_URL — from parse-url
|
||||
// MISSING_DIMENSIONS — from parse-url
|
||||
// INVALID_OPTIONS — from config
|
||||
// INVALID_KEY — from config / signing
|
||||
// INVALID_SALT — from config / signing
|
||||
// INVALID_HOST — from handler
|
||||
// Each is exercised by its own test file; this reachability
|
||||
// gate just confirms the registry has the expected closure.
|
||||
const expected = new Set([
|
||||
"CADDY_IMGPROXY.INVALID_URL",
|
||||
"CADDY_IMGPROXY.MISSING_DIMENSIONS",
|
||||
"CADDY_IMGPROXY.INVALID_OPTIONS",
|
||||
"CADDY_IMGPROXY.INVALID_KEY",
|
||||
"CADDY_IMGPROXY.INVALID_SALT",
|
||||
"CADDY_IMGPROXY.INVALID_HOST",
|
||||
]);
|
||||
const actual = new Set(Object.values(CADDY_IMGPROXY_CODES));
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/caddy-imgproxy`.
|
||||
*
|
||||
* Every failure path in the package surfaces a `CaddyImgproxyError`
|
||||
* whose `code` is drawn from this registry. Consumers SHOULD
|
||||
* discriminate on `error.code` rather than `instanceof` so the
|
||||
* namespace survives module boundaries and runtime serialisation
|
||||
* (see `@suaveplan/error`).
|
||||
*
|
||||
* - `INVALID_URL` — the input path does not match the imgproxy
|
||||
* grammar regex (missing prefix, bad quality, malformed dims, …).
|
||||
* - `MISSING_DIMENSIONS` — the parsed URL named an operation
|
||||
* (`v|c|f|s|r`) without dimensions.
|
||||
* - `INVALID_OPTIONS` — Zod validation of `ImgproxyConfig` failed.
|
||||
* - `INVALID_KEY` — `config.key` is not even-length lower-hex.
|
||||
* - `INVALID_SALT` — `config.salt` is not even-length lower-hex.
|
||||
* - `INVALID_HOST` — the rewriter received a host that could not be
|
||||
* processed by `extractDomain` (reserved for future use; raised
|
||||
* when host is empty).
|
||||
*/
|
||||
export const CADDY_IMGPROXY_CODES = defineCodes("CADDY_IMGPROXY", [
|
||||
"INVALID_URL",
|
||||
"MISSING_DIMENSIONS",
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_KEY",
|
||||
"INVALID_SALT",
|
||||
"INVALID_HOST",
|
||||
] as const);
|
||||
|
||||
/** Discriminating union literal for {@link CaddyImgproxyError.code}. */
|
||||
export type CaddyImgproxyCode =
|
||||
(typeof CADDY_IMGPROXY_CODES)[keyof typeof CADDY_IMGPROXY_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for every `@suaveplan/caddy-imgproxy` failure path.
|
||||
*
|
||||
* Extends `SuaveplanError<CaddyImgproxyCode>` so `code` narrows
|
||||
* against the literal union; consumers get exhaustiveness checking
|
||||
* on `switch (err.code)` blocks. Carries a public constructor so
|
||||
* `new CaddyImgproxyError(...)` works at the call site.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { CADDY_IMGPROXY_CODES, CaddyImgproxyError, parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
|
||||
*
|
||||
* try {
|
||||
* parseImgproxyUrl("/garbage");
|
||||
* } catch (err) {
|
||||
* if (err instanceof CaddyImgproxyError && err.code === CADDY_IMGPROXY_CODES.INVALID_URL) {
|
||||
* console.warn("not an imgproxy URL", err.context);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CaddyImgproxyError extends SuaveplanError<CaddyImgproxyCode> {
|
||||
public constructor(
|
||||
code: CaddyImgproxyCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; context?: Record<string, unknown> }
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "CaddyImgproxyError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CaddyImgproxyCode } from "./error-codes.js";
|
||||
export { CADDY_IMGPROXY_CODES, CaddyImgproxyError } from "./error-codes.js";
|
||||
74
packages/media/image/caddy-imgproxy/src/gravity/gravity.md
Normal file
74
packages/media/image/caddy-imgproxy/src/gravity/gravity.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# gravity
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `gravity` module exposes the bidirectional translation between the
|
||||
compact one- or two-character grammar codes (`t`, `tl`, `bl`, …) and
|
||||
the two-/four-letter compass tokens that imgproxy itself consumes
|
||||
(`no`, `nowe`, `sowe`, …). Every operation that takes a gravity
|
||||
parameter — `cover`, `crop` — routes through this module so the
|
||||
mapping lives in exactly one place.
|
||||
|
||||
The map is exported as a frozen `Readonly<Record<GravityCode, string>>`
|
||||
so consumers may inspect or build their own translation tables on top
|
||||
of it. The `gravityToImgproxy` helper handles unknown / empty input by
|
||||
falling back to `"ce"` (center), matching the Go default branch
|
||||
exactly.
|
||||
|
||||
## Features
|
||||
|
||||
- Total function — never throws.
|
||||
- Single source of truth for the gravity grammar.
|
||||
- Frozen map for safe sharing.
|
||||
- Center default for forward-compatibility with grammar extensions.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { gravityToImgproxy } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
gravityToImgproxy("t"); // "no"
|
||||
gravityToImgproxy("br"); // "soea"
|
||||
gravityToImgproxy(""); // "ce" (default)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GRAVITY_MAP } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
// Build a reverse lookup for parsing imgproxy URLs back into the
|
||||
// compact grammar.
|
||||
const reverse = Object.fromEntries(
|
||||
Object.entries(GRAVITY_MAP).map(([k, v]) => [v, k] as const)
|
||||
);
|
||||
reverse["sowe"]; // "bl"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `gravityToImgproxy(code: string): string`
|
||||
|
||||
- **Parameters:** `code` — one of `t|b|l|r|c|tl|tr|bl|br`, or any
|
||||
unknown value.
|
||||
- **Returns:** the imgproxy compass token, or `"ce"` when input is
|
||||
unknown or empty.
|
||||
|
||||
### `GRAVITY_MAP: Readonly<Record<GravityCode, string>>`
|
||||
|
||||
The frozen lookup table.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function uses an indexed object lookup rather than a `switch`
|
||||
statement because the map is exposed as a frozen value; the default
|
||||
branch handles both the empty-string and the unknown-code cases in a
|
||||
single line. Coverage for the unknown branch is exercised by the test
|
||||
`"defaults unknown codes to ce"`. The cast to `Record<string, string>`
|
||||
inside the function is necessary because the formal type uses the
|
||||
`GravityCode` union; at runtime any string may arrive (e.g. from a
|
||||
caller-constructed `ParsedImgproxyUrl`).
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GRAVITY_MAP, gravityToImgproxy } from "./gravity.js";
|
||||
|
||||
describe("gravityToImgproxy", () => {
|
||||
it("translates every direction to imgproxy tokens", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(gravityToImgproxy("t")).toBe("no");
|
||||
expect(gravityToImgproxy("b")).toBe("so");
|
||||
expect(gravityToImgproxy("l")).toBe("we");
|
||||
expect(gravityToImgproxy("r")).toBe("ea");
|
||||
expect(gravityToImgproxy("c")).toBe("ce");
|
||||
expect(gravityToImgproxy("tl")).toBe("nowe");
|
||||
expect(gravityToImgproxy("tr")).toBe("noea");
|
||||
expect(gravityToImgproxy("bl")).toBe("sowe");
|
||||
expect(gravityToImgproxy("br")).toBe("soea");
|
||||
});
|
||||
|
||||
it("defaults unknown codes to ce", { timeout: 5000 }, () => {
|
||||
expect(gravityToImgproxy("")).toBe("ce");
|
||||
expect(gravityToImgproxy("xx")).toBe("ce");
|
||||
});
|
||||
|
||||
it("GRAVITY_MAP is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(GRAVITY_MAP)).toBe(true);
|
||||
});
|
||||
});
|
||||
39
packages/media/image/caddy-imgproxy/src/gravity/gravity.ts
Normal file
39
packages/media/image/caddy-imgproxy/src/gravity/gravity.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { GravityCode } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Bidirectional grammar → imgproxy gravity token map. Mirrors the
|
||||
* upstream Go `gravityToImgproxy` switch statement exactly.
|
||||
*
|
||||
* Imgproxy gravity tokens follow the compass-direction convention:
|
||||
* - `no` north (top)
|
||||
* - `so` south (bottom)
|
||||
* - `we` west (left)
|
||||
* - `ea` east (right)
|
||||
* - `nowe`/`noea`/`sowe`/`soea` for the four corners
|
||||
* - `ce` center (default)
|
||||
*/
|
||||
export const GRAVITY_MAP: Readonly<Record<GravityCode, string>> = Object.freeze(
|
||||
{
|
||||
t: "no",
|
||||
b: "so",
|
||||
l: "we",
|
||||
r: "ea",
|
||||
c: "ce",
|
||||
tl: "nowe",
|
||||
tr: "noea",
|
||||
bl: "sowe",
|
||||
br: "soea",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Translates a single-character grammar gravity code into its
|
||||
* imgproxy token. Unknown / empty input falls back to `"ce"`
|
||||
* (center) — matching the Go default branch.
|
||||
*/
|
||||
export function gravityToImgproxy(code: string): string {
|
||||
if (code === "" || code === undefined) return "ce";
|
||||
const token = (GRAVITY_MAP as Record<string, string>)[code];
|
||||
if (token === undefined) return "ce";
|
||||
return token;
|
||||
}
|
||||
1
packages/media/image/caddy-imgproxy/src/gravity/index.ts
Normal file
1
packages/media/image/caddy-imgproxy/src/gravity/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GRAVITY_MAP, gravityToImgproxy } from "./gravity.js";
|
||||
121
packages/media/image/caddy-imgproxy/src/handler/handler.md
Normal file
121
packages/media/image/caddy-imgproxy/src/handler/handler.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# ImgproxyHandler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ImgproxyHandler` is the high-level orchestrator that wraps
|
||||
`parseImgproxyUrl`, `extractDomain`, and `buildImgproxyUrl` into a
|
||||
single `rewrite(request)` call suitable for use inside a CDN worker,
|
||||
an edge reverse proxy, or a build-time tooling pass. It owns the
|
||||
telemetry envelope around every rewrite, the structured logging on
|
||||
parse failures, and the discriminated `RewriteResult` that lets
|
||||
callers decide whether to forward to imgproxy, fall back, or return
|
||||
an error to the client.
|
||||
|
||||
The handler exposes three observable outcomes: `rewritten` (the path
|
||||
matched the grammar and a target imgproxy path was produced),
|
||||
`passthrough` (the path is `/unsafe/...` or `/health*`, meaning the
|
||||
caller should forward to imgproxy unchanged), and `invalid` (the
|
||||
path is not an imgproxy URL and the caller should fall back to the
|
||||
next handler in the chain).
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `Clock` for deterministic duration histograms.
|
||||
- Injectable `LoggerLike` for structured parse-failure warnings.
|
||||
- Injectable `SignerHashLike` for native / HSM / WebCrypto signing.
|
||||
- Discriminated `RewriteResult` — no exceptions on the happy path.
|
||||
- Telemetry: counter (`caddy_imgproxy_rewrite_total{outcome,
|
||||
operation}`) + histogram (`caddy_imgproxy_rewrite_duration_ms`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ImgproxyHandler, parseConfig } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const handler = new ImgproxyHandler({
|
||||
config: parseConfig({
|
||||
defaultWidth: 800,
|
||||
key: "aa11bbcc",
|
||||
salt: "deadbeef",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = handler.rewrite({
|
||||
path: "/uv16:9/avatar.png",
|
||||
host: "images.example.com",
|
||||
});
|
||||
|
||||
if (result.rewritten) {
|
||||
forwardToImgproxy(result.targetPath);
|
||||
} else if (result.reason === "passthrough") {
|
||||
forwardToImgproxy(originalPath);
|
||||
} else {
|
||||
fallbackToNextHandler();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
import { ImgproxyHandler, parseConfig } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const tick = createMonotonicTick();
|
||||
const clock: Clock = () => tick.nowMs();
|
||||
const logger: LoggerLike = {
|
||||
debug: (m, c) => log("debug", m, c),
|
||||
info: (m, c) => log("info", m, c),
|
||||
warn: (m, c) => log("warn", m, c),
|
||||
error: (m, c) => log("error", m, c),
|
||||
};
|
||||
|
||||
const handler = new ImgproxyHandler({
|
||||
config: parseConfig({}),
|
||||
clock,
|
||||
logger,
|
||||
});
|
||||
|
||||
const result = handler.rewrite({
|
||||
path: "/uvtr9:16h1080/hero.webp",
|
||||
host: "images.example.com",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class ImgproxyHandler`
|
||||
|
||||
#### `new ImgproxyHandler(options: ImgproxyHandlerOptions)`
|
||||
|
||||
- `options.config` — validated `ImgproxyConfig`.
|
||||
- `options.clock` — optional wall-clock; defaults to `Date.now`.
|
||||
- `options.logger` — optional `LoggerLike`; defaults to a silent
|
||||
no-op.
|
||||
- `options.hash` — optional signer hash override.
|
||||
|
||||
#### `rewrite(request: RewriteRequest): RewriteResult`
|
||||
|
||||
Returns one of `{ rewritten: true; targetPath; operation }`,
|
||||
`{ rewritten: false; reason: "passthrough" }`, or
|
||||
`{ rewritten: false; reason: "invalid"; errorCode; errorMessage }`.
|
||||
|
||||
### `RewriteRequest`
|
||||
|
||||
- `path` — incoming URL path including leading `/`.
|
||||
- `host` — incoming `Host` header.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Empty host is treated as a hard error (`INVALID_HOST`) rather than a
|
||||
silent passthrough because the S3 path interpolation depends on the
|
||||
host. `/unsafe/...` and `/health*` are detected only when
|
||||
`parseImgproxyUrl` throws — the grammar deliberately does not match
|
||||
these paths, so we treat the parse failure as a signal that this is a
|
||||
direct imgproxy passthrough rather than a user-facing rewrite. Both
|
||||
the clock and the logger are captured into instance fields at
|
||||
construction time so a single handler is safe to share across many
|
||||
requests in a long-lived server.
|
||||
153
packages/media/image/caddy-imgproxy/src/handler/handler.test.ts
Normal file
153
packages/media/image/caddy-imgproxy/src/handler/handler.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
import { parseConfig } from "../config/config.js";
|
||||
import { CADDY_IMGPROXY_CODES } from "../error-codes/error-codes.js";
|
||||
import { ImgproxyHandler, NOOP_LOGGER } from "./handler.js";
|
||||
|
||||
function makeLogger(): {
|
||||
logger: LoggerLike;
|
||||
warns: Array<{
|
||||
message: string;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
} {
|
||||
const warns: Array<{
|
||||
message: string;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
}> = [];
|
||||
return {
|
||||
warns,
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (message, context) => {
|
||||
warns.push({ message, context });
|
||||
},
|
||||
error: () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ImgproxyHandler", () => {
|
||||
it("rewrites a valid imgproxy URL", { timeout: 5000 }, () => {
|
||||
const config = parseConfig({
|
||||
defaultWidth: 800,
|
||||
key: "aa11bbcc",
|
||||
salt: "deadbeef",
|
||||
});
|
||||
const handler = new ImgproxyHandler({ config });
|
||||
const result = handler.rewrite({
|
||||
path: "/uv16:9/avatar.png",
|
||||
host: "images.example.com",
|
||||
});
|
||||
if (!result.rewritten) {
|
||||
throw new Error("expected rewritten=true");
|
||||
}
|
||||
expect(result.operation).toBe("cover");
|
||||
expect(result.targetPath.startsWith("/")).toBe(true);
|
||||
expect(result.targetPath).toContain("rs:fill:800:450");
|
||||
expect(result.targetPath).toContain(
|
||||
"plain/s3://images/example.com/avatar@png"
|
||||
);
|
||||
});
|
||||
|
||||
it("records caddy_imgproxy_rewrite_duration_ms via the injected clock", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let tick = 1000;
|
||||
const clock: Clock = () => {
|
||||
tick += 5;
|
||||
return tick;
|
||||
};
|
||||
const config = parseConfig({});
|
||||
const handler = new ImgproxyHandler({ config, clock });
|
||||
const result = handler.rewrite({
|
||||
path: "/uv16:9/avatar.png",
|
||||
host: "images.example.com",
|
||||
});
|
||||
expect(result.rewritten).toBe(true);
|
||||
// No exception means the clock-driven duration was recorded.
|
||||
});
|
||||
|
||||
it("reports passthrough for /unsafe paths", { timeout: 5000 }, () => {
|
||||
const config = parseConfig({});
|
||||
const handler = new ImgproxyHandler({ config });
|
||||
const result = handler.rewrite({
|
||||
path: "/unsafe/foo/bar.png",
|
||||
host: "example.com",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
rewritten: false,
|
||||
reason: "passthrough",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports passthrough for /health paths", { timeout: 5000 }, () => {
|
||||
const config = parseConfig({});
|
||||
const handler = new ImgproxyHandler({ config });
|
||||
const result = handler.rewrite({
|
||||
path: "/health",
|
||||
host: "example.com",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
rewritten: false,
|
||||
reason: "passthrough",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid for unparsable paths and warns the logger", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { logger, warns } = makeLogger();
|
||||
const config = parseConfig({});
|
||||
const handler = new ImgproxyHandler({ config, logger });
|
||||
const result = handler.rewrite({
|
||||
path: "/garbage",
|
||||
host: "example.com",
|
||||
});
|
||||
if (result.rewritten !== false || result.reason !== "invalid") {
|
||||
throw new Error("expected invalid result");
|
||||
}
|
||||
expect(result.errorCode).toBe(CADDY_IMGPROXY_CODES.INVALID_URL);
|
||||
expect(result.errorMessage.length).toBeGreaterThan(0);
|
||||
expect(warns.length).toBe(1);
|
||||
expect(warns[0]?.context?.code).toBe(CADDY_IMGPROXY_CODES.INVALID_URL);
|
||||
});
|
||||
|
||||
it("raises INVALID_HOST when host is empty", { timeout: 5000 }, () => {
|
||||
const { logger, warns } = makeLogger();
|
||||
const config = parseConfig({});
|
||||
const handler = new ImgproxyHandler({ config, logger });
|
||||
const result = handler.rewrite({
|
||||
path: "/uv16:9/avatar.png",
|
||||
host: "",
|
||||
});
|
||||
if (result.rewritten !== false || result.reason !== "invalid") {
|
||||
throw new Error("expected invalid result");
|
||||
}
|
||||
expect(result.errorCode).toBe(CADDY_IMGPROXY_CODES.INVALID_HOST);
|
||||
expect(warns.length).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the default no-op logger when not supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const config = parseConfig({});
|
||||
const handler = new ImgproxyHandler({ config });
|
||||
// No logger provided — default no-op must absorb the warn call.
|
||||
const result = handler.rewrite({
|
||||
path: "/garbage",
|
||||
host: "example.com",
|
||||
});
|
||||
expect(result.rewritten).toBe(false);
|
||||
});
|
||||
|
||||
it("NOOP_LOGGER every method is callable and returns undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(NOOP_LOGGER.debug("msg")).toBeUndefined();
|
||||
expect(NOOP_LOGGER.info("msg")).toBeUndefined();
|
||||
expect(NOOP_LOGGER.warn("msg")).toBeUndefined();
|
||||
expect(NOOP_LOGGER.error("msg")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
136
packages/media/image/caddy-imgproxy/src/handler/handler.ts
Normal file
136
packages/media/image/caddy-imgproxy/src/handler/handler.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
import { extractDomain } from "../domain/extract-domain.js";
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
CaddyImgproxyError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { parseImgproxyUrl } from "../parse/parse-url.js";
|
||||
import type { SignerHashLike } from "../signing/sign-path.js";
|
||||
import {
|
||||
recordRewriteDurationMs,
|
||||
recordRewriteOutcome,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import type { ImgproxyConfig, RewriteResult } from "../types/types.js";
|
||||
import { buildImgproxyUrl } from "../url-builder/url-builder.js";
|
||||
|
||||
/** Inputs for {@link ImgproxyHandler.rewrite}. */
|
||||
export interface RewriteRequest {
|
||||
/** Incoming URL path (e.g. `/uv16:9/avatar.png` or `/unsafe/...`). */
|
||||
readonly path: string;
|
||||
/** Incoming Host header (used to derive the S3 sub-path). */
|
||||
readonly host: string;
|
||||
}
|
||||
|
||||
/** Constructor options for {@link ImgproxyHandler}. */
|
||||
export interface ImgproxyHandlerOptions {
|
||||
readonly config: ImgproxyConfig;
|
||||
/** Wall-clock for histogram timing; defaults to `Date.now`. */
|
||||
readonly clock?: Clock | undefined;
|
||||
/** Structured logger for parse warnings; defaults to a silent no-op. */
|
||||
readonly logger?: LoggerLike | undefined;
|
||||
/** Optional injectable signer hash. */
|
||||
readonly hash?: SignerHashLike | undefined;
|
||||
}
|
||||
|
||||
/** Exported for testability — every method is a no-op. */
|
||||
export const NOOP_LOGGER: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* High-level orchestrator that wraps `parseImgproxyUrl` +
|
||||
* `extractDomain` + `buildImgproxyUrl` into a single
|
||||
* `rewrite(request)` call, emitting telemetry and structured logs
|
||||
* along the way.
|
||||
*
|
||||
* Returns a discriminated `RewriteResult`:
|
||||
* - `{ rewritten: true; targetPath; operation }` on success.
|
||||
* - `{ rewritten: false; reason: "passthrough" }` when the input
|
||||
* path is a direct imgproxy path (`/unsafe/...` or `/health*`)
|
||||
* that should bypass rewriting.
|
||||
* - `{ rewritten: false; reason: "invalid"; errorCode; errorMessage }`
|
||||
* when the path does not match the imgproxy grammar.
|
||||
*/
|
||||
export class ImgproxyHandler {
|
||||
private readonly config: ImgproxyConfig;
|
||||
private readonly clock: Clock;
|
||||
private readonly logger: LoggerLike;
|
||||
private readonly hash?: SignerHashLike | undefined;
|
||||
|
||||
public constructor(options: ImgproxyHandlerOptions) {
|
||||
this.config = options.config;
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
this.logger = options.logger ?? NOOP_LOGGER;
|
||||
this.hash = options.hash;
|
||||
}
|
||||
|
||||
public rewrite(request: RewriteRequest): RewriteResult {
|
||||
const start = this.clock();
|
||||
const result = this.executeRewrite(request);
|
||||
const duration = this.clock() - start;
|
||||
recordRewriteDurationMs(duration);
|
||||
return result;
|
||||
}
|
||||
|
||||
private executeRewrite(request: RewriteRequest): RewriteResult {
|
||||
if (request.host.length === 0) {
|
||||
const err = new CaddyImgproxyError(
|
||||
CADDY_IMGPROXY_CODES.INVALID_HOST,
|
||||
"host header is required",
|
||||
{ context: { path: request.path } }
|
||||
);
|
||||
this.logger.warn("imgproxy host missing", {
|
||||
code: err.code,
|
||||
});
|
||||
recordRewriteOutcome("invalid", "");
|
||||
return {
|
||||
rewritten: false,
|
||||
reason: "invalid",
|
||||
errorCode: err.code,
|
||||
errorMessage: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseImgproxyUrl(request.path);
|
||||
const domain = extractDomain(request.host);
|
||||
const targetPath = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain,
|
||||
config: this.config,
|
||||
hash: this.hash,
|
||||
});
|
||||
recordRewriteOutcome("rewritten", parsed.operation);
|
||||
return {
|
||||
rewritten: true,
|
||||
targetPath,
|
||||
operation: parsed.operation,
|
||||
};
|
||||
} catch (err) {
|
||||
const caddyErr = err as CaddyImgproxyError;
|
||||
if (
|
||||
request.path.startsWith("/unsafe/") ||
|
||||
request.path.startsWith("/health")
|
||||
) {
|
||||
recordRewriteOutcome("passthrough", "");
|
||||
return { rewritten: false, reason: "passthrough" };
|
||||
}
|
||||
this.logger.warn("imgproxy parse failed", {
|
||||
path: request.path,
|
||||
code: caddyErr.code,
|
||||
});
|
||||
recordRewriteOutcome("invalid", "");
|
||||
return {
|
||||
rewritten: false,
|
||||
reason: "invalid",
|
||||
errorCode: caddyErr.code,
|
||||
errorMessage: caddyErr.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/media/image/caddy-imgproxy/src/handler/index.ts
Normal file
2
packages/media/image/caddy-imgproxy/src/handler/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { ImgproxyHandlerOptions, RewriteRequest } from "./handler.js";
|
||||
export { ImgproxyHandler } from "./handler.js";
|
||||
50
packages/media/image/caddy-imgproxy/src/index.ts
Normal file
50
packages/media/image/caddy-imgproxy/src/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export { ImgproxyConfigSchema, parseConfig } from "./config/config.js";
|
||||
export { calculateDimensions } from "./dimensions/calculate-dimensions.js";
|
||||
export { extractDomain } from "./domain/extract-domain.js";
|
||||
export type { CaddyImgproxyCode } from "./error-codes/error-codes.js";
|
||||
export {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
CaddyImgproxyError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export { GRAVITY_MAP, gravityToImgproxy } from "./gravity/gravity.js";
|
||||
export type {
|
||||
ImgproxyHandlerOptions,
|
||||
RewriteRequest,
|
||||
} from "./handler/handler.js";
|
||||
export { ImgproxyHandler } from "./handler/handler.js";
|
||||
export { operationToImgproxy } from "./operation/operation.js";
|
||||
export { parseImgproxyUrl } from "./parse/parse-url.js";
|
||||
export type {
|
||||
SignerHashLike,
|
||||
SignImgproxyPathOptions,
|
||||
} from "./signing/sign-path.js";
|
||||
export { signImgproxyPath } from "./signing/sign-path.js";
|
||||
export type {
|
||||
ParseOutcome,
|
||||
RewriteOutcome,
|
||||
SignMode,
|
||||
} from "./telemetry/telemetry.js";
|
||||
export {
|
||||
parseTotalCounter,
|
||||
recordParseOutcome,
|
||||
recordRewriteDurationMs,
|
||||
recordRewriteOutcome,
|
||||
recordSignMode,
|
||||
rewriteDurationHistogram,
|
||||
rewriteTotalCounter,
|
||||
signTotalCounter,
|
||||
} from "./telemetry/telemetry.js";
|
||||
export type {
|
||||
GravityCode,
|
||||
HintType,
|
||||
ImgproxyConfig,
|
||||
ImgproxyDimensions,
|
||||
ImgproxyOperation,
|
||||
ParsedImgproxyUrl,
|
||||
RewriteInvalidResult,
|
||||
RewritePassthroughResult,
|
||||
RewriteResult,
|
||||
RewriteRewrittenResult,
|
||||
} from "./types/types.js";
|
||||
export type { BuildImgproxyUrlOptions } from "./url-builder/url-builder.js";
|
||||
export { buildImgproxyUrl } from "./url-builder/url-builder.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { operationToImgproxy } from "./operation.js";
|
||||
@@ -0,0 +1,76 @@
|
||||
# operationToImgproxy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`operationToImgproxy` converts a resolved operation + concrete pixel
|
||||
dimensions + gravity into the imgproxy processing-string segment that
|
||||
sits between the `/<signature>/` prefix and the `/plain/s3://…@<fmt>`
|
||||
suffix. It is the bridge between the compact grammar's abstract
|
||||
operations and imgproxy's concrete URL DSL.
|
||||
|
||||
The five operations behave exactly as the upstream Go module: `cover`
|
||||
and `crop` require a gravity prefix so anchoring takes effect during
|
||||
cropping; `fit` ignores gravity (it always preserves aspect ratio);
|
||||
`stretch` always emits `rs:force:W:H`; `resize` is the single-axis
|
||||
variant of `fit`. Passthrough (`""`) and unknown operations emit an
|
||||
empty string so the URL builder skips the processing segment entirely.
|
||||
|
||||
## Features
|
||||
|
||||
- Total function — every input returns a defined string, never
|
||||
throws.
|
||||
- Mirrors the Go switch table byte-for-byte.
|
||||
- Single-axis fallbacks (`g:<g>/w:W`, `w:W`, `h:H`) so callers can
|
||||
produce auto-axis resizes without re-implementing the table.
|
||||
- Empty-string fallback drives the URL builder to skip the segment.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { operationToImgproxy } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
operationToImgproxy("cover", 1920, 1080, "t");
|
||||
// → "g:no/rs:fill:1920:1080"
|
||||
|
||||
operationToImgproxy("fit", 800, 0, "c");
|
||||
// → "w:800"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
calculateDimensions,
|
||||
operationToImgproxy,
|
||||
parseImgproxyUrl,
|
||||
} from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const parsed = parseImgproxyUrl("/ucbl4:3w1920/photo.png");
|
||||
const { width, height } = calculateDimensions(parsed, 800);
|
||||
operationToImgproxy(parsed.operation, width, height, parsed.gravity);
|
||||
// → "g:sowe/c:1920:1440"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `operationToImgproxy(op: ImgproxyOperation, width: number, height: number, gravity: string): string`
|
||||
|
||||
- **Parameters:**
|
||||
- `op` — one of `""|cover|crop|fit|stretch|resize`.
|
||||
- `width`, `height` — non-negative integers; either may be zero.
|
||||
- `gravity` — a grammar code accepted by `gravityToImgproxy`.
|
||||
- **Returns:** the imgproxy processing string, or `""` when the
|
||||
operation cannot be expressed (e.g. `crop` without both dims).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The switch is exhaustive over the `ImgproxyOperation` union; the
|
||||
TypeScript compiler verifies no case escapes. Gravity is resolved
|
||||
once at the top of the function so the `cover`/`crop` branches share
|
||||
a single translation step. The empty-operation case returns `""`
|
||||
explicitly rather than falling through so the exhaustiveness check
|
||||
stays clean.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { operationToImgproxy } from "./operation.js";
|
||||
|
||||
describe("operationToImgproxy", () => {
|
||||
it("cover with both dims emits gravity-prefixed fill", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(operationToImgproxy("cover", 1920, 1080, "t")).toBe(
|
||||
"g:no/rs:fill:1920:1080"
|
||||
);
|
||||
});
|
||||
|
||||
it("cover with width only emits g:<g>/w:W", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("cover", 800, 0, "c")).toBe("g:ce/w:800");
|
||||
});
|
||||
|
||||
it("cover with height only emits g:<g>/h:H", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("cover", 0, 600, "c")).toBe("g:ce/h:600");
|
||||
});
|
||||
|
||||
it("cover with both dims zero emits empty string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(operationToImgproxy("cover", 0, 0, "c")).toBe("");
|
||||
});
|
||||
|
||||
it("fit emits rs:fit:W:H", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("fit", 800, 600, "c")).toBe(
|
||||
"rs:fit:800:600"
|
||||
);
|
||||
});
|
||||
|
||||
it("fit with width only emits w:W", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("fit", 800, 0, "c")).toBe("w:800");
|
||||
});
|
||||
|
||||
it("fit with height only emits h:H", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("fit", 0, 600, "c")).toBe("h:600");
|
||||
});
|
||||
|
||||
it("fit with no dims emits empty string", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("fit", 0, 0, "c")).toBe("");
|
||||
});
|
||||
|
||||
it("crop emits g:<g>/c:W:H", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("crop", 800, 600, "tl")).toBe(
|
||||
"g:nowe/c:800:600"
|
||||
);
|
||||
});
|
||||
|
||||
it("crop without both dims emits empty", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("crop", 800, 0, "c")).toBe("");
|
||||
});
|
||||
|
||||
it("stretch emits rs:force:W:H", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("stretch", 800, 600, "c")).toBe(
|
||||
"rs:force:800:600"
|
||||
);
|
||||
});
|
||||
|
||||
it("stretch without both dims emits empty", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("stretch", 800, 0, "c")).toBe("");
|
||||
});
|
||||
|
||||
it("resize emits w:W or h:H", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("resize", 800, 0, "c")).toBe("w:800");
|
||||
expect(operationToImgproxy("resize", 0, 600, "c")).toBe("h:600");
|
||||
});
|
||||
|
||||
it("resize without dims emits empty", { timeout: 5000 }, () => {
|
||||
expect(operationToImgproxy("resize", 0, 0, "c")).toBe("");
|
||||
});
|
||||
|
||||
it("passthrough and unknown operations emit empty string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(operationToImgproxy("", 800, 600, "c")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { gravityToImgproxy } from "../gravity/gravity.js";
|
||||
import type { ImgproxyOperation } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Converts a resolved operation + dimensions + gravity into the
|
||||
* imgproxy processing-string segment.
|
||||
*
|
||||
* Mirrors the upstream Go `operationToImgproxy` switch:
|
||||
* - `cover` → `g:<g>/rs:fill:W:H` when both dims present;
|
||||
* otherwise `g:<g>/w:W` or `g:<g>/h:H`. Gravity MUST come
|
||||
* before the resize directive for it to take effect during
|
||||
* cropping.
|
||||
* - `fit` → `rs:fit:W:H` (gravity does not apply); or `w:W` /
|
||||
* `h:H` for one-axis fits.
|
||||
* - `crop` → `g:<g>/c:W:H`. Gravity prefix required to anchor.
|
||||
* - `stretch` → `rs:force:W:H`.
|
||||
* - `resize` → `w:W` or `h:H`.
|
||||
* - `""` (passthrough) → empty string.
|
||||
*/
|
||||
export function operationToImgproxy(
|
||||
op: ImgproxyOperation,
|
||||
width: number,
|
||||
height: number,
|
||||
gravity: string
|
||||
): string {
|
||||
const g = gravityToImgproxy(gravity);
|
||||
|
||||
switch (op) {
|
||||
case "cover":
|
||||
if (width > 0 && height > 0) {
|
||||
return `g:${g}/rs:fill:${width}:${height}`;
|
||||
}
|
||||
if (width > 0) return `g:${g}/w:${width}`;
|
||||
if (height > 0) return `g:${g}/h:${height}`;
|
||||
return "";
|
||||
case "fit":
|
||||
if (width > 0 && height > 0) {
|
||||
return `rs:fit:${width}:${height}`;
|
||||
}
|
||||
if (width > 0) return `w:${width}`;
|
||||
if (height > 0) return `h:${height}`;
|
||||
return "";
|
||||
case "crop":
|
||||
if (width > 0 && height > 0) {
|
||||
return `g:${g}/c:${width}:${height}`;
|
||||
}
|
||||
return "";
|
||||
case "stretch":
|
||||
if (width > 0 && height > 0) {
|
||||
return `rs:force:${width}:${height}`;
|
||||
}
|
||||
return "";
|
||||
case "resize":
|
||||
if (width > 0) return `w:${width}`;
|
||||
if (height > 0) return `h:${height}`;
|
||||
return "";
|
||||
case "":
|
||||
return "";
|
||||
}
|
||||
}
|
||||
1
packages/media/image/caddy-imgproxy/src/parse/index.ts
Normal file
1
packages/media/image/caddy-imgproxy/src/parse/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { parseImgproxyUrl } from "./parse-url.js";
|
||||
98
packages/media/image/caddy-imgproxy/src/parse/parse-url.md
Normal file
98
packages/media/image/caddy-imgproxy/src/parse/parse-url.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# parseImgproxyUrl
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`parseImgproxyUrl` parses the compact imgproxy URL grammar used by the
|
||||
Caddy module — `/[s|u]<op><gravity><dims><modifiers>/<path>.<format>` —
|
||||
into a typed, frozen `ParsedImgproxyUrl`. It is the canonical entry
|
||||
point for every other helper in the package; the URL builder, the
|
||||
handler, and the dimensions calculator all consume the value it
|
||||
returns.
|
||||
|
||||
Behaviour mirrors the upstream Go implementation byte-for-byte so the
|
||||
TypeScript port and the Go module can be tested against a shared
|
||||
fixture corpus. The function never partially succeeds: invalid input
|
||||
throws a `CaddyImgproxyError` carrying a stable code from
|
||||
`CADDY_IMGPROXY_CODES`, and the parse-total counter ticks once per
|
||||
call with the matching outcome tag.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-pass regex grammar covering signed / unsigned prefix, five
|
||||
operations (`cover`, `crop`, `fit`, `stretch`, `resize`), nine
|
||||
gravities, aspect / pixel / square dimensions, and width / height /
|
||||
quality modifiers in arbitrary order.
|
||||
- Quality validation built into the grammar (`q1..q100`) — out-of-band
|
||||
values raise `INVALID_URL`.
|
||||
- Operation + dimension constraint enforcement (`MISSING_DIMENSIONS`).
|
||||
- Frozen, immutable return value safe to memoise.
|
||||
- OTel counter on every call (`caddy_imgproxy_parse_total`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
|
||||
parsed.operation; // "cover"
|
||||
parsed.aspectW; // 16
|
||||
parsed.path; // "avatar"
|
||||
parsed.format; // "png"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
CaddyImgproxyError,
|
||||
parseImgproxyUrl,
|
||||
} from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
function tryParse(path: string): string | null {
|
||||
try {
|
||||
const parsed = parseImgproxyUrl(path);
|
||||
return `${parsed.operation || "passthrough"} → ${parsed.path}.${parsed.format}`;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof CaddyImgproxyError &&
|
||||
err.code === CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS
|
||||
) {
|
||||
return "needs dims";
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseImgproxyUrl(path: string): ParsedImgproxyUrl`
|
||||
|
||||
- **Parameters:** `path` — the URL path including leading `/`.
|
||||
- **Returns:** a frozen `ParsedImgproxyUrl` (see `types/types.ts`).
|
||||
- **Throws:**
|
||||
- `CaddyImgproxyError(INVALID_URL)` when the regex rejects the
|
||||
input — bad prefix, malformed dimensions, quality outside
|
||||
`1..100`.
|
||||
- `CaddyImgproxyError(MISSING_DIMENSIONS)` when an operation
|
||||
(`v|c|f|s|r`) is present without any dimension grammar.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The grammar regex is a single anchored expression with thirteen
|
||||
capture groups that mirror the field order of the Go regex
|
||||
exactly. The modifier sub-grammar (`w<int>`, `h<int>`, `q1..100`) is
|
||||
extracted via a global secondary regex so order is irrelevant — the
|
||||
last `w`/`h` wins (matching Go), and `q` always wins for quality.
|
||||
|
||||
`Number.parseInt` with radix 10 is used for every numeric extraction
|
||||
to avoid the legacy octal-from-leading-zero behaviour. Defensive
|
||||
defaults are deliberately omitted from branches the regex can never
|
||||
reach so coverage stays honest; the only fallbacks in the function
|
||||
body protect against future grammar evolution (e.g. an empty gravity
|
||||
capture defaulting to `"c"`).
|
||||
233
packages/media/image/caddy-imgproxy/src/parse/parse-url.test.ts
Normal file
233
packages/media/image/caddy-imgproxy/src/parse/parse-url.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
CaddyImgproxyError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { parseImgproxyUrl } from "./parse-url.js";
|
||||
|
||||
describe("parseImgproxyUrl", () => {
|
||||
it("parses passthrough unsigned URL", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/u/photo.png");
|
||||
expect(result.signed).toBe(false);
|
||||
expect(result.operation).toBe("");
|
||||
expect(result.gravity).toBe("c");
|
||||
expect(result.path).toBe("photo");
|
||||
expect(result.format).toBe("png");
|
||||
expect(result.hasDims).toBe(false);
|
||||
});
|
||||
|
||||
it("parses passthrough signed URL", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/s/photo.png");
|
||||
expect(result.signed).toBe(true);
|
||||
expect(result.operation).toBe("");
|
||||
expect(result.path).toBe("photo");
|
||||
expect(result.format).toBe("png");
|
||||
});
|
||||
|
||||
it("parses cover with aspect ratio, top gravity, height hint", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseImgproxyUrl("/uvt9:16h1080/photos/hero.webp");
|
||||
expect(result.operation).toBe("cover");
|
||||
expect(result.gravity).toBe("t");
|
||||
expect(result.aspectW).toBe(9);
|
||||
expect(result.aspectH).toBe(16);
|
||||
expect(result.hintType).toBe("h");
|
||||
expect(result.hintValue).toBe(1080);
|
||||
expect(result.path).toBe("photos/hero");
|
||||
expect(result.format).toBe("webp");
|
||||
expect(result.isAspect).toBe(true);
|
||||
});
|
||||
|
||||
it("parses cover with aspect ratio, center gravity default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseImgproxyUrl("/uv16:9/avatar.png");
|
||||
expect(result.operation).toBe("cover");
|
||||
expect(result.gravity).toBe("c");
|
||||
expect(result.aspectW).toBe(16);
|
||||
expect(result.aspectH).toBe(9);
|
||||
expect(result.isAspect).toBe(true);
|
||||
});
|
||||
|
||||
it("parses fit with pixel dimensions both axes", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uf100x100/avatar.jpg");
|
||||
expect(result.operation).toBe("fit");
|
||||
expect(result.pixelW).toBe(100);
|
||||
expect(result.pixelH).toBe(100);
|
||||
expect(result.isAspect).toBe(false);
|
||||
});
|
||||
|
||||
it("parses fit with width only (auto height)", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uf800x0/banner.webp");
|
||||
expect(result.pixelW).toBe(800);
|
||||
expect(result.pixelH).toBe(0);
|
||||
});
|
||||
|
||||
it("parses fit with height only (auto width)", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uf0x600/portrait.jpg");
|
||||
expect(result.pixelW).toBe(0);
|
||||
expect(result.pixelH).toBe(600);
|
||||
});
|
||||
|
||||
it("parses crop with aspect ratio and bottom-left gravity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseImgproxyUrl("/ucbl4:3w1920/photo.png");
|
||||
expect(result.operation).toBe("crop");
|
||||
expect(result.gravity).toBe("bl");
|
||||
expect(result.aspectW).toBe(4);
|
||||
expect(result.aspectH).toBe(3);
|
||||
expect(result.hintType).toBe("w");
|
||||
expect(result.hintValue).toBe(1920);
|
||||
});
|
||||
|
||||
it("parses stretch with pixel dimensions", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/us800x600/photo.jpg");
|
||||
expect(result.operation).toBe("stretch");
|
||||
expect(result.pixelW).toBe(800);
|
||||
expect(result.pixelH).toBe(600);
|
||||
});
|
||||
|
||||
it("parses resize with width only", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/ur800x0/photo.jpg");
|
||||
expect(result.operation).toBe("resize");
|
||||
expect(result.pixelW).toBe(800);
|
||||
});
|
||||
|
||||
it("parses cover with top-right gravity", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uvtr16:9/photo.png");
|
||||
expect(result.gravity).toBe("tr");
|
||||
});
|
||||
|
||||
it("parses cover with bottom-right gravity", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uvbr16:9/photo.png");
|
||||
expect(result.gravity).toBe("br");
|
||||
});
|
||||
|
||||
it("parses cover with width hint", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uv16:9w1920/photo.png");
|
||||
expect(result.hintType).toBe("w");
|
||||
expect(result.hintValue).toBe(1920);
|
||||
});
|
||||
|
||||
it("parses fit with quality", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uf800x600q85/photo.jpg");
|
||||
expect(result.quality).toBe(85);
|
||||
});
|
||||
|
||||
it("parses cover with quality 100", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uv16:9q100/photo.jpg");
|
||||
expect(result.quality).toBe(100);
|
||||
});
|
||||
|
||||
it("parses square shorthand - cover 128", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uv128/avatar.png");
|
||||
expect(result.pixelW).toBe(128);
|
||||
expect(result.pixelH).toBe(128);
|
||||
expect(result.isAspect).toBe(false);
|
||||
expect(result.hasDims).toBe(true);
|
||||
});
|
||||
|
||||
it("parses square shorthand - fit 64 with quality", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseImgproxyUrl("/uf64q90/avatar.jpg");
|
||||
expect(result.pixelW).toBe(64);
|
||||
expect(result.pixelH).toBe(64);
|
||||
expect(result.quality).toBe(90);
|
||||
});
|
||||
|
||||
it("parses square shorthand - cover with gravity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseImgproxyUrl("/uvtr128/avatar.png");
|
||||
expect(result.gravity).toBe("tr");
|
||||
expect(result.pixelW).toBe(128);
|
||||
});
|
||||
|
||||
it("parses deep path", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uv16:9/folder/subfolder/photo.png");
|
||||
expect(result.path).toBe("folder/subfolder/photo");
|
||||
});
|
||||
|
||||
it("parses quality before width hint", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uf800x0q85w1920/avatar.jpg");
|
||||
expect(result.quality).toBe(85);
|
||||
expect(result.hintType).toBe("w");
|
||||
expect(result.hintValue).toBe(1920);
|
||||
});
|
||||
|
||||
it("parses quality before height hint", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uv9:16q85h1080/photo.jpg");
|
||||
expect(result.quality).toBe(85);
|
||||
expect(result.hintType).toBe("h");
|
||||
expect(result.hintValue).toBe(1080);
|
||||
});
|
||||
|
||||
it("parses pixel dims with quality first", { timeout: 5000 }, () => {
|
||||
const result = parseImgproxyUrl("/uf100x100q75/photo.jpg");
|
||||
expect(result.quality).toBe(75);
|
||||
expect(result.pixelW).toBe(100);
|
||||
});
|
||||
|
||||
it("raises MISSING_DIMENSIONS when operation is specified without dimensions", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseImgproxyUrl("/uv/photo.png");
|
||||
expect.fail("expected MISSING_DIMENSIONS");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CaddyImgproxyError);
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_URL on missing prefix", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseImgproxyUrl("/photo.png");
|
||||
expect.fail("expected INVALID_URL");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_URL
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_URL on quality zero", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseImgproxyUrl("/uf800x600q0/photo.jpg");
|
||||
expect.fail("expected INVALID_URL");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_URL
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_URL on quality over 100", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseImgproxyUrl("/uf800x600q101/photo.jpg");
|
||||
expect.fail("expected INVALID_URL");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_URL
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_URL on aspect ratio with zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseImgproxyUrl("/uv0:9/photo.png");
|
||||
expect.fail("expected INVALID_URL");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_URL
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
195
packages/media/image/caddy-imgproxy/src/parse/parse-url.ts
Normal file
195
packages/media/image/caddy-imgproxy/src/parse/parse-url.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
CaddyImgproxyError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { recordParseOutcome } from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
GravityCode,
|
||||
HintType,
|
||||
ImgproxyOperation,
|
||||
ParsedImgproxyUrl,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Matches the compact imgproxy URL grammar. Capture groups:
|
||||
* 1: signed/unsigned (`s`|`u`)
|
||||
* 2: operation (`v`|`c`|`f`|`s`|`r`) — optional
|
||||
* 3: gravity (`tl`|`tr`|`bl`|`br`|`t`|`b`|`l`|`r`|`c`) — optional
|
||||
* 4: aspect W (>=1)
|
||||
* 5: aspect H (>=1)
|
||||
* 6: pixel W (>=1, when followed by xH)
|
||||
* 7: pixel H (>=0)
|
||||
* 8: pixel W (=0)
|
||||
* 9: pixel H (>=1, when W=0)
|
||||
* 10: square dim
|
||||
* 11: modifiers
|
||||
* 12: path
|
||||
* 13: format
|
||||
*/
|
||||
const URL_PATTERN =
|
||||
/^\/([su])([vcfsr])?(tl|tr|bl|br|[tblrc])?(?:([1-9]\d*):([1-9]\d*)|([1-9]\d*)x(\d+)|(0)x([1-9]\d*)|([1-9]\d*))?((?:[wh][1-9]\d*|q(?:100|[1-9]\d?))*)\/(.+)\.([a-z]+)$/;
|
||||
|
||||
/** Extracts individual `w<int>`, `h<int>`, `q<int>` modifiers. */
|
||||
const MODIFIER_PATTERN = /([whq])(\d+)/g;
|
||||
|
||||
const OP_MAP: Record<string, ImgproxyOperation> = {
|
||||
v: "cover",
|
||||
c: "crop",
|
||||
f: "fit",
|
||||
s: "stretch",
|
||||
r: "resize",
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses an imgproxy URL into its typed components.
|
||||
*
|
||||
* Verbatim port of the upstream Go parseURL. Returns a frozen
|
||||
* ParsedImgproxyUrl on success and throws CaddyImgproxyError on
|
||||
* every failure. Increments caddy_imgproxy_parse_total once per call.
|
||||
*/
|
||||
export function parseImgproxyUrl(path: string): ParsedImgproxyUrl {
|
||||
const matches = URL_PATTERN.exec(path);
|
||||
if (matches === null) {
|
||||
recordParseOutcome("invalid");
|
||||
throw new CaddyImgproxyError(
|
||||
CADDY_IMGPROXY_CODES.INVALID_URL,
|
||||
"imgproxy URL does not match the expected grammar",
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
|
||||
const opToken = matches[2] ?? "";
|
||||
const operation =
|
||||
opToken === "" ? "" : (OP_MAP[opToken] as ImgproxyOperation);
|
||||
const gravity = resolveGravity(matches[3]);
|
||||
const { aspectW, aspectH, pixelW, pixelH, isAspect, hasDims } =
|
||||
resolveDimensions(matches);
|
||||
const { hintType, hintValue, quality } = parseModifiers(
|
||||
matches[11] as string
|
||||
);
|
||||
|
||||
if (operation !== "" && !hasDims) {
|
||||
recordParseOutcome("missing_dims");
|
||||
throw new CaddyImgproxyError(
|
||||
CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS,
|
||||
`dimensions required for operation: ${operation}`,
|
||||
{ context: { path, operation } }
|
||||
);
|
||||
}
|
||||
|
||||
recordParseOutcome("ok");
|
||||
return Object.freeze({
|
||||
signed: matches[1] === "s",
|
||||
operation,
|
||||
gravity,
|
||||
aspectW,
|
||||
aspectH,
|
||||
pixelW,
|
||||
pixelH,
|
||||
hintType,
|
||||
hintValue,
|
||||
quality,
|
||||
path: matches[12] as string,
|
||||
format: matches[13] as string,
|
||||
isAspect,
|
||||
hasDims,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveGravity(token: string | undefined): GravityCode {
|
||||
if (token === undefined || token === "") return "c";
|
||||
return token as GravityCode;
|
||||
}
|
||||
|
||||
interface DimensionParts {
|
||||
readonly aspectW: number;
|
||||
readonly aspectH: number;
|
||||
readonly pixelW: number;
|
||||
readonly pixelH: number;
|
||||
readonly isAspect: boolean;
|
||||
readonly hasDims: boolean;
|
||||
}
|
||||
|
||||
function resolveDimensions(matches: RegExpExecArray): DimensionParts {
|
||||
if (matches[4] !== undefined && matches[5] !== undefined) {
|
||||
return {
|
||||
aspectW: Number.parseInt(matches[4], 10),
|
||||
aspectH: Number.parseInt(matches[5], 10),
|
||||
pixelW: 0,
|
||||
pixelH: 0,
|
||||
isAspect: true,
|
||||
hasDims: true,
|
||||
};
|
||||
}
|
||||
if (matches[6] !== undefined) {
|
||||
return {
|
||||
aspectW: 0,
|
||||
aspectH: 0,
|
||||
pixelW: Number.parseInt(matches[6], 10),
|
||||
pixelH: Number.parseInt(matches[7] as string, 10),
|
||||
isAspect: false,
|
||||
hasDims: true,
|
||||
};
|
||||
}
|
||||
if (matches[8] !== undefined) {
|
||||
return {
|
||||
aspectW: 0,
|
||||
aspectH: 0,
|
||||
pixelW: 0,
|
||||
pixelH: Number.parseInt(matches[9] as string, 10),
|
||||
isAspect: false,
|
||||
hasDims: true,
|
||||
};
|
||||
}
|
||||
if (matches[10] !== undefined) {
|
||||
const dim = Number.parseInt(matches[10], 10);
|
||||
return {
|
||||
aspectW: 0,
|
||||
aspectH: 0,
|
||||
pixelW: dim,
|
||||
pixelH: dim,
|
||||
isAspect: false,
|
||||
hasDims: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
aspectW: 0,
|
||||
aspectH: 0,
|
||||
pixelW: 0,
|
||||
pixelH: 0,
|
||||
isAspect: false,
|
||||
hasDims: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface ModifierParts {
|
||||
readonly hintType: HintType;
|
||||
readonly hintValue: number;
|
||||
readonly quality: number;
|
||||
}
|
||||
|
||||
function parseModifiers(token: string): ModifierParts {
|
||||
let hintType: HintType = "";
|
||||
let hintValue = 0;
|
||||
let quality = 0;
|
||||
if (token === "") {
|
||||
return { hintType, hintValue, quality };
|
||||
}
|
||||
MODIFIER_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = MODIFIER_PATTERN.exec(token);
|
||||
while (match !== null) {
|
||||
const kind = match[1];
|
||||
const value = Number.parseInt(match[2] as string, 10);
|
||||
if (kind === "w") {
|
||||
hintType = "w";
|
||||
hintValue = value;
|
||||
} else if (kind === "h") {
|
||||
hintType = "h";
|
||||
hintValue = value;
|
||||
} else {
|
||||
quality = value;
|
||||
}
|
||||
match = MODIFIER_PATTERN.exec(token);
|
||||
}
|
||||
return { hintType, hintValue, quality };
|
||||
}
|
||||
2
packages/media/image/caddy-imgproxy/src/signing/index.ts
Normal file
2
packages/media/image/caddy-imgproxy/src/signing/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { SignerHashLike, SignImgproxyPathOptions } from "./sign-path.js";
|
||||
export { signImgproxyPath } from "./sign-path.js";
|
||||
95
packages/media/image/caddy-imgproxy/src/signing/sign-path.md
Normal file
95
packages/media/image/caddy-imgproxy/src/signing/sign-path.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# signImgproxyPath
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`signImgproxyPath` computes the HMAC-SHA256 URL signature that
|
||||
imgproxy requires when it is configured with a key + salt. It is the
|
||||
single source of truth for how the TypeScript port and the upstream
|
||||
Go module agree on bytes: any URL signed here verifies against the Go
|
||||
implementation and vice-versa.
|
||||
|
||||
The function decodes the hex-encoded `key` and `salt` into raw bytes,
|
||||
concatenates the salt with the UTF-8 encoded `path`, runs HMAC-SHA256
|
||||
under the key, and base64url-encodes the digest without padding. Bad
|
||||
hex on either input raises a typed `CaddyImgproxyError`
|
||||
(`INVALID_KEY` / `INVALID_SALT`) so misconfiguration surfaces
|
||||
immediately rather than at the imgproxy boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Verbatim byte-level port of the Go `signPath` function.
|
||||
- Pluggable hash via the optional `hash` parameter — supply a native
|
||||
/ HSM / WebCrypto-backed implementation when needed.
|
||||
- Hex validation stricter than `@suaveplan/crypto-hash`'s `fromHex`
|
||||
(lower-case only) to keep wire-format round-trips deterministic.
|
||||
- Throws typed `CaddyImgproxyError` on every failure path.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { signImgproxyPath } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const signature = signImgproxyPath({
|
||||
key: "aa11bbcc",
|
||||
salt: "deadbeef",
|
||||
path: "/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png",
|
||||
});
|
||||
signature; // "E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { signImgproxyPath, type SignerHashLike } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
// Plug a WebCrypto-backed signer in a Cloudflare Worker:
|
||||
const wcHash: SignerHashLike = {
|
||||
hmac(_algo, key, data) {
|
||||
// ... call into crypto.subtle.sign synchronously via a vendored
|
||||
// implementation, return the digest as Uint8Array.
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
const sig = signImgproxyPath({
|
||||
key: "deadbeef",
|
||||
salt: "feedface",
|
||||
path: "/plain/s3://images/example.com/photo@webp",
|
||||
hash: wcHash,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `signImgproxyPath(options: SignImgproxyPathOptions): string`
|
||||
|
||||
- **Parameters:**
|
||||
- `options.key` — lower-hex HMAC key, even length, non-empty.
|
||||
- `options.salt` — lower-hex salt, even length, non-empty.
|
||||
- `options.path` — the imgproxy path being signed.
|
||||
- `options.hash` — optional `SignerHashLike` injection seam.
|
||||
- **Returns:** base64url-encoded signature, no padding.
|
||||
- **Throws:**
|
||||
- `CaddyImgproxyError(INVALID_KEY)` for malformed / empty / odd-length key.
|
||||
- `CaddyImgproxyError(INVALID_SALT)` for malformed / empty / odd-length salt.
|
||||
|
||||
### `SignerHashLike`
|
||||
|
||||
Structural type with a single `hmac(algorithm, key, data)` method
|
||||
returning a `Uint8Array`. Lets callers swap the default Noble-backed
|
||||
implementation for a native, HSM, or WebCrypto-backed alternative.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function pre-allocates the `salt || path` buffer in one
|
||||
`Uint8Array(saltBytes.length + pathBytes.length)` allocation so the
|
||||
hot path never reallocates. UTF-8 encoding goes through
|
||||
`TextEncoder`, which is available in every modern JS runtime
|
||||
(Node ≥ 14, all browsers, Cloudflare Workers, Deno, Bun). The Go
|
||||
reference uses `[]byte(path)` which is identical for ASCII paths and
|
||||
spec-compliant for arbitrary UTF-8 (the imgproxy upstream uses the
|
||||
same convention).
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
type CaddyImgproxyError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { type SignerHashLike, signImgproxyPath } from "./sign-path.js";
|
||||
|
||||
const KEY = "aa11bbcc";
|
||||
const SALT = "deadbeef";
|
||||
const PATH = "/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png";
|
||||
const EXPECTED = "E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U";
|
||||
|
||||
describe("signImgproxyPath", () => {
|
||||
it("matches the known-answer signature vector", { timeout: 5000 }, () => {
|
||||
const result = signImgproxyPath({
|
||||
key: KEY,
|
||||
salt: SALT,
|
||||
path: PATH,
|
||||
});
|
||||
expect(result).toBe(EXPECTED);
|
||||
});
|
||||
|
||||
it("raises INVALID_KEY for malformed hex key", { timeout: 5000 }, () => {
|
||||
try {
|
||||
signImgproxyPath({ key: "xyz", salt: SALT, path: PATH });
|
||||
expect.fail("expected INVALID_KEY");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_KEY for odd-length key", { timeout: 5000 }, () => {
|
||||
try {
|
||||
signImgproxyPath({ key: "abc", salt: SALT, path: PATH });
|
||||
expect.fail("expected INVALID_KEY");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_KEY for empty key", { timeout: 5000 }, () => {
|
||||
try {
|
||||
signImgproxyPath({ key: "", salt: SALT, path: PATH });
|
||||
expect.fail("expected INVALID_KEY");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_SALT for malformed hex salt", { timeout: 5000 }, () => {
|
||||
try {
|
||||
signImgproxyPath({ key: KEY, salt: "zzz", path: PATH });
|
||||
expect.fail("expected INVALID_SALT");
|
||||
} catch (err) {
|
||||
expect((err as CaddyImgproxyError).code).toBe(
|
||||
CADDY_IMGPROXY_CODES.INVALID_SALT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the injected hash implementation", { timeout: 5000 }, () => {
|
||||
let invoked = false;
|
||||
const hash: SignerHashLike = {
|
||||
hmac: (algorithm, _key, _data) => {
|
||||
invoked = true;
|
||||
expect(algorithm).toBe("SHA-256");
|
||||
return new Uint8Array([1, 2, 3, 4]);
|
||||
},
|
||||
};
|
||||
const result = signImgproxyPath({
|
||||
key: KEY,
|
||||
salt: SALT,
|
||||
path: PATH,
|
||||
hash,
|
||||
});
|
||||
expect(invoked).toBe(true);
|
||||
// toBase64Url of [1,2,3,4] = "AQIDBA"
|
||||
expect(result).toBe("AQIDBA");
|
||||
});
|
||||
});
|
||||
76
packages/media/image/caddy-imgproxy/src/signing/sign-path.ts
Normal file
76
packages/media/image/caddy-imgproxy/src/signing/sign-path.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { fromHex, hmac, toBase64Url } from "@suaveplan/crypto-hash";
|
||||
import {
|
||||
CADDY_IMGPROXY_CODES,
|
||||
CaddyImgproxyError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Pluggable HMAC implementation. Allows a host to swap in a native
|
||||
* implementation, an HSM-backed signer, or a deterministic stub for
|
||||
* tests. The default uses `@suaveplan/crypto-hash`'s `hmac`.
|
||||
*/
|
||||
export interface SignerHashLike {
|
||||
hmac(algorithm: "SHA-256", key: Uint8Array, data: Uint8Array): Uint8Array;
|
||||
}
|
||||
|
||||
const defaultHash: SignerHashLike = {
|
||||
hmac: (algorithm, key, data) => hmac(algorithm, key, data),
|
||||
};
|
||||
|
||||
/** Inputs for {@link signImgproxyPath}. */
|
||||
export interface SignImgproxyPathOptions {
|
||||
/** Lower-hex HMAC key (even length). */
|
||||
readonly key: string;
|
||||
/** Lower-hex salt (even length). */
|
||||
readonly salt: string;
|
||||
/** Imgproxy path being signed (including leading slash + segments). */
|
||||
readonly path: string;
|
||||
/** Optional injectable hash implementation. */
|
||||
readonly hash?: SignerHashLike | undefined;
|
||||
}
|
||||
|
||||
const HEX_RE = /^[0-9a-f]*$/u;
|
||||
const utf8 = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Decodes a hex string into bytes, raising the supplied
|
||||
* caddy-imgproxy code on failure. The hex validation is stricter than
|
||||
* `crypto-hash.fromHex` (lower-case only) to keep wire-format
|
||||
* round-trips deterministic.
|
||||
*/
|
||||
function decodeHex(
|
||||
hex: string,
|
||||
code: "INVALID_KEY" | "INVALID_SALT",
|
||||
field: string
|
||||
): Uint8Array {
|
||||
if (hex.length === 0 || hex.length % 2 !== 0 || !HEX_RE.test(hex)) {
|
||||
throw new CaddyImgproxyError(
|
||||
CADDY_IMGPROXY_CODES[code],
|
||||
`${field} must be a non-empty even-length lower-hex string`,
|
||||
{ context: { field } }
|
||||
);
|
||||
}
|
||||
return fromHex(hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the imgproxy URL signature.
|
||||
*
|
||||
* The signature is `HMAC-SHA256(keyBytes, saltBytes || pathBytes)`,
|
||||
* base64url encoded without padding. Matches the upstream Go
|
||||
* `signPath` byte-for-byte so any URL signed by the Go module
|
||||
* verifies against the TypeScript port and vice-versa.
|
||||
*/
|
||||
export function signImgproxyPath(options: SignImgproxyPathOptions): string {
|
||||
const keyBytes = decodeHex(options.key, "INVALID_KEY", "key");
|
||||
const saltBytes = decodeHex(options.salt, "INVALID_SALT", "salt");
|
||||
const pathBytes = utf8.encode(options.path);
|
||||
|
||||
const data = new Uint8Array(saltBytes.length + pathBytes.length);
|
||||
data.set(saltBytes, 0);
|
||||
data.set(pathBytes, saltBytes.length);
|
||||
|
||||
const hash = options.hash ?? defaultHash;
|
||||
const digest = hash.hmac("SHA-256", keyBytes, data);
|
||||
return toBase64Url(digest);
|
||||
}
|
||||
11
packages/media/image/caddy-imgproxy/src/telemetry/index.ts
Normal file
11
packages/media/image/caddy-imgproxy/src/telemetry/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type { ParseOutcome, RewriteOutcome, SignMode } from "./telemetry.js";
|
||||
export {
|
||||
parseTotalCounter,
|
||||
recordParseOutcome,
|
||||
recordRewriteDurationMs,
|
||||
recordRewriteOutcome,
|
||||
recordSignMode,
|
||||
rewriteDurationHistogram,
|
||||
rewriteTotalCounter,
|
||||
signTotalCounter,
|
||||
} from "./telemetry.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# telemetry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `telemetry` module owns the OTel instruments emitted by every
|
||||
public entry point in `@suaveplan/caddy-imgproxy`. It declares three
|
||||
counters and one histogram, registers them against a single meter
|
||||
named `@suaveplan/caddy-imgproxy`, and exposes thin `record*`
|
||||
helpers so the consuming feature modules never need to import the
|
||||
raw OTel API.
|
||||
|
||||
When no `MeterProvider` is registered (the default in unit tests and
|
||||
production code that has not opted into telemetry), the OTel API
|
||||
returns a no-op meter and the helpers become no-ops. This makes the
|
||||
instruments safe to import unconditionally — the package never
|
||||
forces a runtime cost on consumers who do not want telemetry.
|
||||
|
||||
## Features
|
||||
|
||||
- Three counters: parse outcomes, rewrite outcomes (tagged with
|
||||
operation), sign mode.
|
||||
- One histogram: rewrite duration in milliseconds.
|
||||
- Helpers (`recordParseOutcome`, `recordRewriteOutcome`,
|
||||
`recordSignMode`, `recordRewriteDurationMs`) so feature code stays
|
||||
free of OTel boilerplate.
|
||||
- Discriminated tag literals (`ParseOutcome`, `RewriteOutcome`,
|
||||
`SignMode`) for compile-time safety on tag values.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordParseOutcome, recordSignMode } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
recordParseOutcome("ok");
|
||||
recordSignMode("signed");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { rewriteDurationHistogram } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
// Wire a real MeterProvider before importing the rest of the package
|
||||
// so the histogram is bound to an active exporter.
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
// rewriteDurationHistogram now records into the live provider.
|
||||
expect(rewriteDurationHistogram).toBeDefined();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Counters
|
||||
|
||||
- `parseTotalCounter` — name `caddy_imgproxy_parse_total`, tagged
|
||||
with `outcome` ∈ `ok | invalid | missing_dims`.
|
||||
- `rewriteTotalCounter` — `caddy_imgproxy_rewrite_total`, tagged
|
||||
with `outcome` ∈ `rewritten | passthrough | invalid` and
|
||||
`operation`.
|
||||
- `signTotalCounter` — `caddy_imgproxy_sign_total`, tagged with
|
||||
`mode` ∈ `signed | unsafe`.
|
||||
|
||||
### Histogram
|
||||
|
||||
- `rewriteDurationHistogram` — `caddy_imgproxy_rewrite_duration_ms`,
|
||||
unit `ms`.
|
||||
|
||||
### Helpers
|
||||
|
||||
- `recordParseOutcome(outcome)`
|
||||
- `recordRewriteOutcome(outcome, operation)`
|
||||
- `recordSignMode(mode)`
|
||||
- `recordRewriteDurationMs(durationMs)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is bound at module import time via `metrics.getMeter(...)`.
|
||||
When no provider is registered the OTel API returns a no-op meter,
|
||||
so the instrument constructors do not throw. Helpers funnel through
|
||||
the counters/histogram via the standard `add(value, tags)` /
|
||||
`record(value)` API; the tag object literal is constructed inline
|
||||
each call so V8 can inline it into the call site under hot loops.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
parseTotalCounter,
|
||||
recordParseOutcome,
|
||||
recordRewriteDurationMs,
|
||||
recordRewriteOutcome,
|
||||
recordSignMode,
|
||||
rewriteDurationHistogram,
|
||||
rewriteTotalCounter,
|
||||
signTotalCounter,
|
||||
} from "./telemetry.js";
|
||||
|
||||
describe("caddy-imgproxy telemetry", () => {
|
||||
it("exposes counters + histogram", { timeout: 5000 }, () => {
|
||||
expect(parseTotalCounter).toBeDefined();
|
||||
expect(rewriteTotalCounter).toBeDefined();
|
||||
expect(signTotalCounter).toBeDefined();
|
||||
expect(rewriteDurationHistogram).toBeDefined();
|
||||
});
|
||||
|
||||
it("records parse outcomes", { timeout: 5000 }, () => {
|
||||
expect(() => recordParseOutcome("ok")).not.toThrow();
|
||||
expect(() => recordParseOutcome("invalid")).not.toThrow();
|
||||
expect(() => recordParseOutcome("missing_dims")).not.toThrow();
|
||||
});
|
||||
|
||||
it("records rewrite outcomes and durations", { timeout: 5000 }, () => {
|
||||
expect(() => recordRewriteOutcome("rewritten", "cover")).not.toThrow();
|
||||
expect(() => recordRewriteOutcome("passthrough", "")).not.toThrow();
|
||||
expect(() => recordRewriteOutcome("invalid", "")).not.toThrow();
|
||||
expect(() => recordRewriteDurationMs(42)).not.toThrow();
|
||||
});
|
||||
|
||||
it("records sign modes", { timeout: 5000 }, () => {
|
||||
expect(() => recordSignMode("signed")).not.toThrow();
|
||||
expect(() => recordSignMode("unsafe")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/caddy-imgproxy";
|
||||
|
||||
/**
|
||||
* Lazy-bound meter — resolves the globally-registered `MeterProvider`
|
||||
* on first access. When no provider is registered the OTel API returns
|
||||
* a no-op meter, so the instruments are safe to import in production
|
||||
* code that does not opt into telemetry.
|
||||
*/
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/** Counter for parse-call outcomes. */
|
||||
export const parseTotalCounter: Counter = meter.createCounter(
|
||||
"caddy_imgproxy_parse_total",
|
||||
{
|
||||
description:
|
||||
"Total parseImgproxyUrl calls, tagged with outcome (ok|invalid|missing_dims)",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for rewrite-call outcomes. */
|
||||
export const rewriteTotalCounter: Counter = meter.createCounter(
|
||||
"caddy_imgproxy_rewrite_total",
|
||||
{
|
||||
description:
|
||||
"Total ImgproxyHandler.rewrite calls, tagged with outcome (rewritten|passthrough|invalid) and operation",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for sign / unsafe modes. */
|
||||
export const signTotalCounter: Counter = meter.createCounter(
|
||||
"caddy_imgproxy_sign_total",
|
||||
{
|
||||
description:
|
||||
"Total buildImgproxyUrl calls, tagged with mode (signed|unsafe)",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram for ImgproxyHandler.rewrite end-to-end duration in milliseconds. */
|
||||
export const rewriteDurationHistogram: Histogram = meter.createHistogram(
|
||||
"caddy_imgproxy_rewrite_duration_ms",
|
||||
{
|
||||
description:
|
||||
"ImgproxyHandler.rewrite end-to-end duration in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Parse outcome tag — one tick per `parseImgproxyUrl` call. */
|
||||
export type ParseOutcome = "ok" | "invalid" | "missing_dims";
|
||||
|
||||
/** Rewrite outcome tag. */
|
||||
export type RewriteOutcome = "rewritten" | "passthrough" | "invalid";
|
||||
|
||||
/** Sign mode tag. */
|
||||
export type SignMode = "signed" | "unsafe";
|
||||
|
||||
/** Records a `caddy_imgproxy_parse_total` data point. */
|
||||
export function recordParseOutcome(outcome: ParseOutcome): void {
|
||||
parseTotalCounter.add(1, { outcome });
|
||||
}
|
||||
|
||||
/** Records a `caddy_imgproxy_rewrite_total` data point with operation tag. */
|
||||
export function recordRewriteOutcome(
|
||||
outcome: RewriteOutcome,
|
||||
operation: string
|
||||
): void {
|
||||
rewriteTotalCounter.add(1, { outcome, operation });
|
||||
}
|
||||
|
||||
/** Records a `caddy_imgproxy_sign_total` data point. */
|
||||
export function recordSignMode(mode: SignMode): void {
|
||||
signTotalCounter.add(1, { mode });
|
||||
}
|
||||
|
||||
/** Records a duration sample on the rewrite histogram. */
|
||||
export function recordRewriteDurationMs(durationMs: number): void {
|
||||
rewriteDurationHistogram.record(durationMs);
|
||||
}
|
||||
12
packages/media/image/caddy-imgproxy/src/types/index.ts
Normal file
12
packages/media/image/caddy-imgproxy/src/types/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
GravityCode,
|
||||
HintType,
|
||||
ImgproxyConfig,
|
||||
ImgproxyDimensions,
|
||||
ImgproxyOperation,
|
||||
ParsedImgproxyUrl,
|
||||
RewriteInvalidResult,
|
||||
RewritePassthroughResult,
|
||||
RewriteResult,
|
||||
RewriteRewrittenResult,
|
||||
} from "./types.js";
|
||||
98
packages/media/image/caddy-imgproxy/src/types/types.md
Normal file
98
packages/media/image/caddy-imgproxy/src/types/types.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `types` module declares every cross-feature type that flows
|
||||
between the package's runtime modules: `ParsedImgproxyUrl` (the
|
||||
output of the parser), `ImgproxyConfig` (the public configuration
|
||||
shape), `ImgproxyDimensions` (the resolved pixel pair), and the
|
||||
discriminated `RewriteResult` union that `ImgproxyHandler.rewrite`
|
||||
returns. Centralising them here keeps the package's contract in one
|
||||
place and prevents circular imports between feature modules.
|
||||
|
||||
These types are runtime-free — `types.ts` has no executable code and
|
||||
is excluded from coverage by the vitest config. Consumers that only
|
||||
want the type contract can import from `@suaveplan/caddy-imgproxy/types`
|
||||
without pulling in any runtime dependency.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for the package's data contracts.
|
||||
- Discriminated union (`RewriteResult`) for branchless handling of
|
||||
the three rewrite outcomes.
|
||||
- Frozen-by-convention return values throughout the package — the
|
||||
types use `readonly` modifiers so accidental mutation is a
|
||||
compile-time error.
|
||||
- Closed unions (`ImgproxyOperation`, `GravityCode`, `HintType`)
|
||||
give the rest of the package exhaustive `switch` coverage.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ImgproxyConfig, ParsedImgproxyUrl } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
function describe(parsed: ParsedImgproxyUrl, config: ImgproxyConfig): string {
|
||||
return `${parsed.operation || "passthrough"} for ${config.s3Bucket}`;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { RewriteResult } from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
function summarise(result: RewriteResult): string {
|
||||
if (result.rewritten) return `→ ${result.targetPath}`;
|
||||
if (result.reason === "passthrough") return "passthrough";
|
||||
return `invalid (${result.errorCode}): ${result.errorMessage}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ImgproxyOperation`
|
||||
|
||||
Union of `""|cover|crop|fit|stretch|resize`.
|
||||
|
||||
### `GravityCode`
|
||||
|
||||
Union of `t|b|l|r|c|tl|tr|bl|br`.
|
||||
|
||||
### `HintType`
|
||||
|
||||
Union of `""|w|h`.
|
||||
|
||||
### `ParsedImgproxyUrl`
|
||||
|
||||
Frozen, immutable parser output. Carries `signed`, `operation`,
|
||||
`gravity`, `aspectW`, `aspectH`, `pixelW`, `pixelH`, `hintType`,
|
||||
`hintValue`, `quality`, `path`, `format`, `isAspect`, `hasDims`.
|
||||
|
||||
### `ImgproxyConfig`
|
||||
|
||||
Validated configuration carrying `upstream`, `s3Bucket`,
|
||||
`defaultWidth`, and optional `key` / `salt`.
|
||||
|
||||
### `ImgproxyDimensions`
|
||||
|
||||
`{ width: number; height: number }` — output of
|
||||
`calculateDimensions`.
|
||||
|
||||
### `RewriteResult`
|
||||
|
||||
Discriminated union of `RewriteRewrittenResult`,
|
||||
`RewritePassthroughResult`, and `RewriteInvalidResult`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every field uses `readonly` to communicate immutability through the
|
||||
type. The discriminated `RewriteResult` shape is deliberately tagged
|
||||
twice (`rewritten` for the boolean discriminator and `reason` for
|
||||
the failure sub-discriminator) so consumers can branch on either
|
||||
side without losing TypeScript narrowing. The optional `key` / `salt`
|
||||
on `ImgproxyConfig` carry an explicit `| undefined` because the
|
||||
package is built under `exactOptionalPropertyTypes`.
|
||||
113
packages/media/image/caddy-imgproxy/src/types/types.ts
Normal file
113
packages/media/image/caddy-imgproxy/src/types/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Shared, runtime-free type definitions for the @suaveplan/caddy-imgproxy
|
||||
* package. These are the cross-feature contracts that flow between
|
||||
* `parse`, `dimensions`, `url-builder`, `handler`, and `config`.
|
||||
*/
|
||||
|
||||
/** Imgproxy operation codes — the empty string is "passthrough" (no resize). */
|
||||
export type ImgproxyOperation =
|
||||
| ""
|
||||
| "cover"
|
||||
| "crop"
|
||||
| "fit"
|
||||
| "stretch"
|
||||
| "resize";
|
||||
|
||||
/** Gravity / anchor grammar — 9 directions plus center default. */
|
||||
export type GravityCode =
|
||||
| "t"
|
||||
| "b"
|
||||
| "l"
|
||||
| "r"
|
||||
| "c"
|
||||
| "tl"
|
||||
| "tr"
|
||||
| "bl"
|
||||
| "br";
|
||||
|
||||
/** Modifier hint type — either width-driven (`w`), height-driven (`h`), or none (`""`). */
|
||||
export type HintType = "" | "w" | "h";
|
||||
|
||||
/**
|
||||
* Parsed representation of an imgproxy URL — the output of
|
||||
* `parseImgproxyUrl`. Mirrors the Go `ParsedURL` struct field-for-field
|
||||
* so the TypeScript and Go implementations can be tested against the
|
||||
* same fixture corpus.
|
||||
*/
|
||||
export interface ParsedImgproxyUrl {
|
||||
/** `true` when the URL had the `s` prefix (signed), `false` for `u`. */
|
||||
readonly signed: boolean;
|
||||
/** Resolved operation; empty string means "passthrough". */
|
||||
readonly operation: ImgproxyOperation;
|
||||
/** Resolved gravity anchor; defaults to `"c"` (center) when omitted. */
|
||||
readonly gravity: GravityCode;
|
||||
/** Aspect ratio numerator (e.g. `16` in `16:9`); `0` when not aspect mode. */
|
||||
readonly aspectW: number;
|
||||
/** Aspect ratio denominator (e.g. `9` in `16:9`); `0` when not aspect mode. */
|
||||
readonly aspectH: number;
|
||||
/** Explicit pixel width; `0` for auto. */
|
||||
readonly pixelW: number;
|
||||
/** Explicit pixel height; `0` for auto. */
|
||||
readonly pixelH: number;
|
||||
/** Hint axis — width (`w`), height (`h`), or none (`""`). */
|
||||
readonly hintType: HintType;
|
||||
/** Hint magnitude in pixels. */
|
||||
readonly hintValue: number;
|
||||
/** JPEG-style quality 1..100; `0` means "default / unspecified". */
|
||||
readonly quality: number;
|
||||
/** Image path after the prefix and before the extension. */
|
||||
readonly path: string;
|
||||
/** Output format extension (e.g. `png`, `webp`). */
|
||||
readonly format: string;
|
||||
/** `true` when dimensions came from an aspect ratio expression. */
|
||||
readonly isAspect: boolean;
|
||||
/** `true` when any dimension was supplied. */
|
||||
readonly hasDims: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public configuration for the imgproxy URL rewriter. Validated by
|
||||
* `ImgproxyConfigSchema` / `parseConfig`. `key` and `salt` are
|
||||
* lower-hex strings; their absence flips the rewriter into `/unsafe`
|
||||
* mode (matching the Go module's behaviour).
|
||||
*/
|
||||
export interface ImgproxyConfig {
|
||||
readonly upstream: string;
|
||||
readonly s3Bucket: string;
|
||||
readonly defaultWidth: number;
|
||||
readonly key?: string | undefined;
|
||||
readonly salt?: string | undefined;
|
||||
}
|
||||
|
||||
/** Resolved pixel dimensions — the output of `calculateDimensions`. */
|
||||
export interface ImgproxyDimensions {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/** Result of `ImgproxyHandler.rewrite`. Discriminated by `rewritten`. */
|
||||
export type RewriteResult =
|
||||
| RewriteRewrittenResult
|
||||
| RewritePassthroughResult
|
||||
| RewriteInvalidResult;
|
||||
|
||||
/** A URL that successfully rewrote into an imgproxy target. */
|
||||
export interface RewriteRewrittenResult {
|
||||
readonly rewritten: true;
|
||||
readonly targetPath: string;
|
||||
readonly operation: ImgproxyOperation;
|
||||
}
|
||||
|
||||
/** A URL that should bypass rewriting (e.g. `/unsafe/...` or `/health`). */
|
||||
export interface RewritePassthroughResult {
|
||||
readonly rewritten: false;
|
||||
readonly reason: "passthrough";
|
||||
}
|
||||
|
||||
/** A URL that failed to parse — surfaced for logging / error handling. */
|
||||
export interface RewriteInvalidResult {
|
||||
readonly rewritten: false;
|
||||
readonly reason: "invalid";
|
||||
readonly errorCode: string;
|
||||
readonly errorMessage: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BuildImgproxyUrlOptions } from "./url-builder.js";
|
||||
export { buildImgproxyUrl } from "./url-builder.js";
|
||||
@@ -0,0 +1,92 @@
|
||||
# buildImgproxyUrl
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`buildImgproxyUrl` composes the final imgproxy URL path from a parsed
|
||||
grammar representation. It is the orchestrator that ties together
|
||||
`calculateDimensions`, `operationToImgproxy`, and `signImgproxyPath`
|
||||
into the single string a downstream proxy or CDN edge needs to
|
||||
forward an image-rewrite request to imgproxy.
|
||||
|
||||
The function mirrors the upstream Go `buildImgproxyURL` semantics in
|
||||
every detail: processing string first, optional `q:<quality>` next,
|
||||
then the `plain/s3://<bucket>/<domain>/<path>@<format>` payload, and
|
||||
finally either a `/<base64url-signature>` prefix (when the config
|
||||
carries both a key and a salt) or `/unsafe`. Every call records a
|
||||
single `caddy_imgproxy_sign_total` sample tagged with the mode.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for the URL composition order.
|
||||
- Telemetry on every call (sign-mode counter).
|
||||
- Honours the empty-string fallbacks from `operationToImgproxy` —
|
||||
passthrough URLs produce a clean `/unsafe/plain/...` shape.
|
||||
- Supports the injectable signer hash from `signImgproxyPath`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildImgproxyUrl,
|
||||
parseConfig,
|
||||
parseImgproxyUrl,
|
||||
} from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
|
||||
const config = parseConfig({ defaultWidth: 800 });
|
||||
|
||||
buildImgproxyUrl({ parsed, domain: "example.com", config });
|
||||
// → "/unsafe/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildImgproxyUrl,
|
||||
parseConfig,
|
||||
parseImgproxyUrl,
|
||||
} from "@suaveplan/caddy-imgproxy";
|
||||
|
||||
const config = parseConfig({
|
||||
s3Bucket: "media",
|
||||
defaultWidth: 1200,
|
||||
key: "aa11bbcc",
|
||||
salt: "deadbeef",
|
||||
});
|
||||
|
||||
// Quality + width hint + signed
|
||||
const parsed = parseImgproxyUrl("/uv16:9q90w1920/photo.png");
|
||||
const url = buildImgproxyUrl({ parsed, domain: "example.com", config });
|
||||
// → "/<signature>/g:ce/rs:fill:1920:1080/q:90/plain/s3://media/example.com/photo@png"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `buildImgproxyUrl(options: BuildImgproxyUrlOptions): string`
|
||||
|
||||
- **Parameters:**
|
||||
- `options.parsed` — output of `parseImgproxyUrl`.
|
||||
- `options.domain` — virtual-host segment for the S3 path.
|
||||
- `options.config` — validated `ImgproxyConfig`.
|
||||
- `options.hash` — optional signer hash override.
|
||||
- **Returns:** the full imgproxy path including the signature prefix.
|
||||
|
||||
### `BuildImgproxyUrlOptions`
|
||||
|
||||
Inputs interface — see above.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Signature mode is selected only when BOTH `config.key` and
|
||||
`config.salt` are non-empty strings. An empty string in either slot
|
||||
falls back to `/unsafe` so callers can disable signing at runtime
|
||||
without re-parsing the config. The `caddy_imgproxy_sign_total`
|
||||
counter is incremented exactly once per call — either as `signed` or
|
||||
as `unsafe` — even when the upstream signer throws (the throw
|
||||
propagates after the counter increments via the signer's own error
|
||||
path, since `signImgproxyPath` raises before this function records).
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseConfig } from "../config/config.js";
|
||||
import { parseImgproxyUrl } from "../parse/parse-url.js";
|
||||
import { buildImgproxyUrl } from "./url-builder.js";
|
||||
|
||||
describe("buildImgproxyUrl", () => {
|
||||
it("composes /unsafe URL when no key/salt", { timeout: 5000 }, () => {
|
||||
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
|
||||
const config = parseConfig({ defaultWidth: 800 });
|
||||
const result = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain: "example.com",
|
||||
config,
|
||||
});
|
||||
expect(result).toBe(
|
||||
"/unsafe/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png"
|
||||
);
|
||||
});
|
||||
|
||||
it("composes signed URL for cover with aspect ratio", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
|
||||
const config = parseConfig({
|
||||
defaultWidth: 800,
|
||||
key: "aa11bbcc",
|
||||
salt: "deadbeef",
|
||||
});
|
||||
const result = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain: "example.com",
|
||||
config,
|
||||
});
|
||||
expect(result).toBe(
|
||||
"/E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png"
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts q:<quality> when parsed.quality > 0", { timeout: 5000 }, () => {
|
||||
const parsed = parseImgproxyUrl("/uf800x600q85/photo.jpg");
|
||||
const config = parseConfig({});
|
||||
const result = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain: "example.com",
|
||||
config,
|
||||
});
|
||||
expect(result).toContain("/q:85/");
|
||||
});
|
||||
|
||||
it("emits passthrough URL when operation is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = parseImgproxyUrl("/u/photo.png");
|
||||
const config = parseConfig({});
|
||||
const result = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain: "example.com",
|
||||
config,
|
||||
});
|
||||
// No processing segment since operation === ""
|
||||
expect(result).toBe("/unsafe/plain/s3://images/example.com/photo@png");
|
||||
});
|
||||
|
||||
it("records caddy_imgproxy_sign_total for signed and unsafe modes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
|
||||
const unsafeCfg = parseConfig({});
|
||||
const signedCfg = parseConfig({
|
||||
key: "aa11bbcc",
|
||||
salt: "deadbeef",
|
||||
});
|
||||
const a = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain: "example.com",
|
||||
config: unsafeCfg,
|
||||
});
|
||||
const b = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain: "example.com",
|
||||
config: signedCfg,
|
||||
});
|
||||
expect(a.startsWith("/unsafe/")).toBe(true);
|
||||
expect(
|
||||
b.startsWith("/E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U/")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses /unsafe when key is set but salt missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Manually construct a config with key but no salt to hit the
|
||||
// branch where only one half is set.
|
||||
const parsed = parseImgproxyUrl("/u/photo.png");
|
||||
const result = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain: "example.com",
|
||||
config: {
|
||||
upstream: "localhost:8080",
|
||||
s3Bucket: "images",
|
||||
defaultWidth: 800,
|
||||
key: "aa11",
|
||||
salt: undefined,
|
||||
},
|
||||
});
|
||||
expect(result.startsWith("/unsafe/")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats empty-string key as unsafe mode", { timeout: 5000 }, () => {
|
||||
const parsed = parseImgproxyUrl("/u/photo.png");
|
||||
const result = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain: "example.com",
|
||||
config: {
|
||||
upstream: "localhost:8080",
|
||||
s3Bucket: "images",
|
||||
defaultWidth: 800,
|
||||
key: "",
|
||||
salt: "deadbeef",
|
||||
},
|
||||
});
|
||||
expect(result.startsWith("/unsafe/")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats empty-string salt as unsafe mode", { timeout: 5000 }, () => {
|
||||
const parsed = parseImgproxyUrl("/u/photo.png");
|
||||
const result = buildImgproxyUrl({
|
||||
parsed,
|
||||
domain: "example.com",
|
||||
config: {
|
||||
upstream: "localhost:8080",
|
||||
s3Bucket: "images",
|
||||
defaultWidth: 800,
|
||||
key: "aa11",
|
||||
salt: "",
|
||||
},
|
||||
});
|
||||
expect(result.startsWith("/unsafe/")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { calculateDimensions } from "../dimensions/calculate-dimensions.js";
|
||||
import { operationToImgproxy } from "../operation/operation.js";
|
||||
import { type SignerHashLike, signImgproxyPath } from "../signing/sign-path.js";
|
||||
import { recordSignMode } from "../telemetry/telemetry.js";
|
||||
import type { ImgproxyConfig, ParsedImgproxyUrl } from "../types/types.js";
|
||||
|
||||
/** Inputs for {@link buildImgproxyUrl}. */
|
||||
export interface BuildImgproxyUrlOptions {
|
||||
/** A `ParsedImgproxyUrl` previously produced by `parseImgproxyUrl`. */
|
||||
readonly parsed: ParsedImgproxyUrl;
|
||||
/** Tenant / virtual-host segment to interpolate into the S3 path. */
|
||||
readonly domain: string;
|
||||
/** Validated imgproxy configuration (defaults + optional key/salt). */
|
||||
readonly config: ImgproxyConfig;
|
||||
/** Optional injectable hash implementation for the signer. */
|
||||
readonly hash?: SignerHashLike | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the final imgproxy URL path from a parsed grammar
|
||||
* representation. Re-implements the Go `buildImgproxyURL` semantics:
|
||||
*
|
||||
* 1. Resolve `(width, height)` from the parsed dims + default width.
|
||||
* 2. Resolve the processing string for the operation + dims + gravity.
|
||||
* 3. Append `q:<quality>` when `parsed.quality > 0`.
|
||||
* 4. Append `plain/s3://<bucket>/<domain>/<path>@<format>`.
|
||||
* 5. Prefix with `<signature>` (signed mode) or `/unsafe`.
|
||||
*
|
||||
* Records `caddy_imgproxy_sign_total` exactly once per call.
|
||||
*/
|
||||
export function buildImgproxyUrl(options: BuildImgproxyUrlOptions): string {
|
||||
const { parsed, domain, config } = options;
|
||||
const { width, height } = calculateDimensions(parsed, config.defaultWidth);
|
||||
const processing = operationToImgproxy(
|
||||
parsed.operation,
|
||||
width,
|
||||
height,
|
||||
parsed.gravity
|
||||
);
|
||||
|
||||
const s3Path = `s3://${config.s3Bucket}/${domain}/${parsed.path}`;
|
||||
|
||||
const segments: string[] = [];
|
||||
if (processing !== "") segments.push(processing);
|
||||
if (parsed.quality > 0) segments.push(`q:${parsed.quality}`);
|
||||
segments.push("plain", `${s3Path}@${parsed.format}`);
|
||||
|
||||
const innerPath = `/${segments.join("/")}`;
|
||||
|
||||
if (
|
||||
config.key !== undefined &&
|
||||
config.salt !== undefined &&
|
||||
config.key.length > 0 &&
|
||||
config.salt.length > 0
|
||||
) {
|
||||
const signature = signImgproxyPath({
|
||||
key: config.key,
|
||||
salt: config.salt,
|
||||
path: innerPath,
|
||||
hash: options.hash,
|
||||
});
|
||||
recordSignMode("signed");
|
||||
return `/${signature}${innerPath}`;
|
||||
}
|
||||
|
||||
recordSignMode("unsafe");
|
||||
return `/unsafe${innerPath}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/media/image/caddy-imgproxy/tsconfig.lib.json
Normal file
12
packages/media/image/caddy-imgproxy/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
55
packages/media/image/caddy-imgproxy/vite.config.ts
Normal file
55
packages/media/image/caddy-imgproxy/vite.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/media-image-caddy-imgproxy",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"parse/index": "src/parse/index.ts",
|
||||
"dimensions/index": "src/dimensions/index.ts",
|
||||
"gravity/index": "src/gravity/index.ts",
|
||||
"operation/index": "src/operation/index.ts",
|
||||
"signing/index": "src/signing/index.ts",
|
||||
"url-builder/index": "src/url-builder/index.ts",
|
||||
"domain/index": "src/domain/index.ts",
|
||||
"handler/index": "src/handler/index.ts",
|
||||
"config/index": "src/config/index.ts",
|
||||
"error-codes/index": "src/error-codes/index.ts",
|
||||
"telemetry/index": "src/telemetry/index.ts",
|
||||
"types/index": "src/types/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/crypto-hash",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/media/image/caddy-imgproxy/vitest.config.ts
Normal file
33
packages/media/image/caddy-imgproxy/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"src/types/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
hookTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user