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`, `h`, `q` modifiers. */ const MODIFIER_PATTERN = /([whq])(\d+)/g; const OP_MAP: Record = { 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 }; }