From 8d209599f8d96c18d0dcd6eccce6f3f422110399 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 16:28:00 +0000 Subject: [PATCH 01/11] feat: add Redis cache mode, update deps, fix bugs - Add "redis" cache mode using Bun's built-in RedisClient (no npm dep) - Supports TTL via native Redis SETEX, key prefixing, auto-reconnect - Graceful degradation: Redis failures return cache misses, not errors - New config: REDIS_URL, REDIS_KEY_PREFIX, REDIS_CONNECTION_TIMEOUT, REDIS_MAX_RETRIES - Update dependencies: elysia 1.4.28, @elysiajs/cors 1.4.1, satori 0.18.4, @types/bun 1.3.11 - Fix SSRF: reject hostnames with zero DNS records instead of allowing - Fix self-reference: allow /image path in addition to /og - Fix health check: check disk dir for hybrid mode (not just disk) - Fix OG generator: prevent infinite font preload retries on failure - Add graceful SIGTERM/SIGINT shutdown for Redis disconnect https://claude.ai/code/session_01HBLMJXsTLSxNLuvGXEnUaZ --- .env.example | 7 +++ bun.lock | 28 +++++------ package.json | 8 ++-- src/config.ts | 14 ++++++ src/index.ts | 23 ++++++++- src/routes/health.ts | 9 +++- src/services/cache.ts | 90 +++++++++++++++++++++++++++++++++++- src/services/og-generator.ts | 13 ++++-- src/utils/url-validator.ts | 9 ++-- 9 files changed, 171 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index 4a267c9..7b4cfe8 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ PORT=3000 # disk - Persist to disk only (survives restarts) # memory - In-memory only (fastest, lost on restart) # hybrid - Memory (L1) + Disk (L2) for speed and persistence +# redis - Redis/Valkey (uses Bun's built-in Redis client) # none - No caching CACHE_MODE=disk CACHE_DIR=./cache # Directory for disk cache (disk/hybrid modes) @@ -23,5 +24,11 @@ ALLOW_SELF_REFERENCE=false # Allow /image to fetch from own /og endpoint (for MAX_IMAGE_SIZE=10485760 # Max image size in bytes (default: 10MB) REQUEST_TIMEOUT=30000 # Request timeout in ms (default: 30s) +# Redis cache settings (only used when CACHE_MODE=redis) +REDIS_URL=redis://localhost:6379 # Redis connection URL (supports redis://, rediss://, redis+unix://) +REDIS_KEY_PREFIX=ps: # Key prefix to namespace cache entries +REDIS_CONNECTION_TIMEOUT=5000 # Connection timeout in ms +REDIS_MAX_RETRIES=10 # Max reconnection attempts + # Custom OG Templates TEMPLATES_DIR=./templates # Directory for custom OG image templates (JSON files) diff --git a/bun.lock b/bun.lock index f14a757..87329a1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,18 +5,18 @@ "": { "name": "pixelserve", "dependencies": { - "@elysiajs/cors": "latest", - "@resvg/resvg-js": "latest", - "elysia": "latest", - "satori": "latest", - "sharp": "latest", + "@elysiajs/cors": "^1.4.0", + "@resvg/resvg-js": "^2.6.2", + "elysia": "^1.4.18", + "satori": "^0.18.3", + "sharp": "^0.34.5", }, "devDependencies": { - "@biomejs/biome": "latest", - "@types/bun": "latest", + "@biomejs/biome": "2.3.8", + "@types/bun": "^1.3.4", }, "peerDependencies": { - "typescript": "latest", + "typescript": "^5.9.3", }, }, }, @@ -41,7 +41,7 @@ "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], - "@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-pb0SCzBfFbFSYA/U40HHO7R+YrcXBJXOWgL20eSViK33ol1e20ru2/KUaZYo5IMUn63yaTJI/bQERuQ+77ND8g=="], + "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -129,13 +129,13 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], - "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], @@ -157,13 +157,13 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "elysia": ["elysia@1.4.18", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "0.2.5", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-A6BhlipmSvgCy69SBgWADYZSdDIj3fT2gk8/9iMAC8iD+aGcnCr0fitziX0xr36MFDs/fsvVp8dWqxeq1VCgKg=="], + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "exact-mirror": ["exact-mirror@0.2.5", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-u8Wu2lO8nio5lKSJubOydsdNtQmH8ENba5m0nbQYmTvsjksXKYIS1nSShdDlO8Uem+kbo+N6eD5I03cpZ+QsRQ=="], + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -189,7 +189,7 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "satori": ["satori@0.18.3", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-T3DzWNmnrfVmk2gCIlAxLRLbGkfp3K7TyRva+Byyojqu83BNvnMeqVeYRdmUw4TKCsyH4RiQ/KuF/I4yEzgR5A=="], + "satori": ["satori@0.18.4", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-HanEzgXHlX3fzpGgxPoR3qI7FDpc/B+uE/KplzA6BkZGlWMaH98B/1Amq+OBF1pYPlGNzAXPYNHlrEVBvRBnHQ=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], diff --git a/package.json b/package.json index 284b25f..04e17fe 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,16 @@ }, "devDependencies": { "@biomejs/biome": "2.3.8", - "@types/bun": "^1.3.4" + "@types/bun": "^1.3.11" }, "peerDependencies": { "typescript": "^5.9.3" }, "dependencies": { - "@elysiajs/cors": "^1.4.0", + "@elysiajs/cors": "^1.4.1", "@resvg/resvg-js": "^2.6.2", - "elysia": "^1.4.18", - "satori": "^0.18.3", + "elysia": "^1.4.28", + "satori": "^0.18.4", "sharp": "^0.34.5" } } diff --git a/src/config.ts b/src/config.ts index eb2bb5f..9285126 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ const CacheModeSchema = Type.Union([ Type.Literal("disk"), Type.Literal("memory"), Type.Literal("hybrid"), + Type.Literal("redis"), Type.Literal("none"), ]); @@ -57,6 +58,12 @@ const ConfigSchema = Type.Object({ // Custom templates directory templatesDir: Type.String({ default: "./templates" }), + // Redis cache settings + redisUrl: Type.String({ default: "redis://localhost:6379" }), + redisKeyPrefix: Type.String({ default: "ps:" }), + redisConnectionTimeout: Type.Number({ default: 5000, minimum: 0 }), + redisMaxRetries: Type.Number({ default: 10, minimum: 0 }), + // Clustering clusterWorkers: Type.Number({ default: 0, minimum: 0 }), // 0 = auto (CPU cores) }); @@ -104,6 +111,13 @@ const rawConfig = { ogDefaultBg: "1a1a2e", ogDefaultFg: "ffffff", templatesDir: process.env.TEMPLATES_DIR || "./templates", + redisUrl: process.env.REDIS_URL || "redis://localhost:6379", + redisKeyPrefix: process.env.REDIS_KEY_PREFIX || "ps:", + redisConnectionTimeout: parseInt( + process.env.REDIS_CONNECTION_TIMEOUT || "5000", + 10, + ), + redisMaxRetries: parseInt(process.env.REDIS_MAX_RETRIES || "10", 10), clusterWorkers: parseInt(process.env.CLUSTER_WORKERS || "0", 10), }; diff --git a/src/index.ts b/src/index.ts index d9a119a..3c1accc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,11 @@ import { config } from "./config"; import { healthRoutes } from "./routes/health"; import { imageRoutes } from "./routes/image"; import { ogRoutes } from "./routes/og"; -import { startCacheCleanup } from "./services/cache"; +import { + disconnectRedisCache, + initRedisCache, + startCacheCleanup, +} from "./services/cache"; // Detect if running as a cluster worker const isWorker = process.env.PIXELSERVE_WORKER_ID !== undefined; @@ -15,6 +19,11 @@ if (config.cacheMode === "disk" || config.cacheMode === "hybrid") { await Bun.$`mkdir -p ${config.cacheDir}`.quiet(); } +// Initialize Redis cache if configured +if (config.cacheMode === "redis") { + await initRedisCache(); +} + const app = new Elysia() .use( cors({ @@ -72,6 +81,10 @@ function getCacheInfo(): string { return `memory (max ${config.maxMemoryCacheItems} items)`; case "hybrid": return `hybrid (memory + ${config.cacheDir})`; + case "redis": { + const masked = config.redisUrl.replace(/:\/\/[^@]*@/, "://***@"); + return `redis (${masked})`; + } case "none": return "disabled"; } @@ -121,4 +134,12 @@ ${c.magenta} ____ _ _ ____ ${c.dim}Discord:${c.reset} ${c.blue}https://go.climactic.co/discord${c.reset} `); +// Graceful shutdown +const shutdown = async () => { + await disconnectRedisCache(); + process.exit(0); +}; +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + export type App = typeof app; diff --git a/src/routes/health.ts b/src/routes/health.ts index c10c026..d27e5cc 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -14,8 +14,8 @@ export const healthRoutes = new Elysia().get("/health", async ({ set }) => { version: "1.0.0", }; - // Check disk cache directory exists if using disk mode - if (config.cacheMode === "disk") { + // Check disk cache directory exists if using disk/hybrid mode + if (config.cacheMode === "disk" || config.cacheMode === "hybrid") { const cacheDir = Bun.file(config.cacheDir); const cacheExists = await cacheDir.exists().catch(() => false); if (!cacheExists) { @@ -23,6 +23,11 @@ export const healthRoutes = new Elysia().get("/health", async ({ set }) => { } } + // Check Redis connection health + if (config.cacheMode === "redis" && cacheStats.connected === false) { + status.status = "degraded"; + } + set.headers = { "Cache-Control": "no-cache, no-store, must-revalidate", }; diff --git a/src/services/cache.ts b/src/services/cache.ts index ef389ea..d486cb7 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; +import { RedisClient } from "bun"; import { type CacheMode, config } from "../config"; import type { ImageFormat } from "../types"; @@ -80,6 +81,67 @@ class LRUCache { // Initialize memory cache const memoryCache = new LRUCache(config.maxMemoryCacheItems); +// Redis client state +let redisClient: RedisClient | null = null; +let redisConnected = false; + +export async function initRedisCache(): Promise { + if (config.cacheMode !== "redis") return; + + try { + redisClient = new RedisClient(config.redisUrl, { + connectionTimeout: config.redisConnectionTimeout, + autoReconnect: true, + maxRetries: config.redisMaxRetries, + enableOfflineQueue: true, + enableAutoPipelining: true, + }); + await redisClient.connect(); + redisConnected = true; + console.log("Redis cache connected"); + } catch (error) { + console.error("Redis connection failed:", error); + redisConnected = false; + } +} + +export async function disconnectRedisCache(): Promise { + if (redisClient) { + try { + await redisClient.quit(); + } catch { + // Ignore disconnect errors during shutdown + } + redisClient = null; + redisConnected = false; + } +} + +function redisKey(key: string): string { + return `${config.redisKeyPrefix}${key}`; +} + +// Redis cache operations +async function getRedisCached(key: string): Promise { + if (!redisClient || !redisConnected) return null; + try { + const data = await redisClient.getBuffer(redisKey(key)); + return data ? Buffer.from(data) : null; + } catch (error) { + console.error("Redis get error:", error); + return null; + } +} + +async function setRedisCache(key: string, data: Buffer): Promise { + if (!redisClient || !redisConnected) return; + try { + await redisClient.set(redisKey(key), data, "EX", config.cacheTTL); + } catch (error) { + console.error("Redis set error:", error); + } +} + export function generateCacheKey( params: Record, ): string { @@ -166,6 +228,8 @@ export async function getCached(key: string): Promise { } return diskHit; } + case "redis": + return getRedisCached(key); case "none": return null; default: @@ -188,6 +252,8 @@ export async function setCache( // Write to both memory (L1) and disk (L2) setMemoryCache(key, data); return setDiskCache(key, data); + case "redis": + return setRedisCache(key, data); case "none": return; } @@ -240,6 +306,7 @@ async function cleanupDiskCache(): Promise { export async function cleanupCache(): Promise { switch (config.cacheMode) { case "none": + case "redis": return 0; case "memory": return memoryCache.cleanup(); @@ -256,7 +323,7 @@ export async function cleanupCache(): Promise { export function startCacheCleanup(intervalMs: number = 3600000): void { if (cleanupInterval) return; - if (config.cacheMode === "none") return; + if (config.cacheMode === "none" || config.cacheMode === "redis") return; cleanupInterval = setInterval(async () => { const deleted = await cleanupCache(); @@ -276,11 +343,26 @@ export function stopCacheCleanup(): void { } } +// Mask credentials in Redis URL for display +function maskRedisUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.password) { + parsed.password = "***"; + } + return parsed.toString(); + } catch { + return url.replace(/:\/\/[^@]*@/, "://***@"); + } +} + // Export for health check export function getCacheStats(): { mode: CacheMode; items?: number; directory?: string; + connected?: boolean; + url?: string; } { switch (config.cacheMode) { case "memory": @@ -293,6 +375,12 @@ export function getCacheStats(): { items: memoryCache.size(), directory: config.cacheDir, }; + case "redis": + return { + mode: "redis", + connected: redisConnected, + url: maskRedisUrl(config.redisUrl), + }; case "none": return { mode: "none" }; } diff --git a/src/services/og-generator.ts b/src/services/og-generator.ts index 0441d31..2a3df33 100644 --- a/src/services/og-generator.ts +++ b/src/services/og-generator.ts @@ -11,11 +11,16 @@ import { } from "./fonts"; // Preload default fonts at startup -let defaultFontsLoaded = false; +let defaultFontsAttempted = false; async function ensureDefaultFontsLoaded(): Promise { - if (defaultFontsLoaded) return; - await preloadDefaultFonts(); - defaultFontsLoaded = true; + if (defaultFontsAttempted) return; + defaultFontsAttempted = true; + try { + await preloadDefaultFonts(); + } catch (err) { + console.warn("Default font preload failed (will retry per-request):", err); + defaultFontsAttempted = false; + } } // Templates support (all loaded from JSON files) diff --git a/src/utils/url-validator.ts b/src/utils/url-validator.ts index a4d959b..adaccf5 100644 --- a/src/utils/url-validator.ts +++ b/src/utils/url-validator.ts @@ -43,7 +43,7 @@ export async function validateUrl(urlString: string): Promise { hostname === "127.0.0.1" || hostname === "0.0.0.0" || hostname === "::1") && - url.pathname.startsWith("/og"); + (url.pathname.startsWith("/og") || url.pathname.startsWith("/image")); // Allow self-reference if configured (for /image -> /og chaining) if (isSelfReference && config.allowSelfReference) { @@ -88,10 +88,11 @@ export async function validateUrl(urlString: string): Promise { } } - // If no addresses resolved at all, that's suspicious + // If no addresses resolved at all, block the request if (addresses.length === 0 && ipv6Addresses.length === 0) { - // Could be a hosts file entry or local resolver - allow but log - console.warn(`No DNS records found for ${hostname}`); + throw new ForbiddenError( + `DNS resolution failed: no records found for ${hostname}`, + ); } } catch (error) { if (error instanceof ForbiddenError) { From f1dd47f85d6fafa867723196031b4b7189e9dfc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 17:01:38 +0000 Subject: [PATCH 02/11] fix: remove redisConnected flag for automatic reconnect recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the stale boolean flag and rely on Bun's autoReconnect + enableOfflineQueue to handle transient Redis failures. The client is always created at init (even if initial connect fails), so ops go through try/catch — cache misses during downtime, automatic recovery when Redis comes back. Zero per-request overhead from flag checks. https://claude.ai/code/session_01HBLMJXsTLSxNLuvGXEnUaZ --- src/services/cache.ts | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/services/cache.ts b/src/services/cache.ts index d486cb7..c3a11f0 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -81,27 +81,29 @@ class LRUCache { // Initialize memory cache const memoryCache = new LRUCache(config.maxMemoryCacheItems); -// Redis client state +// Redis client — no "connected" flag. Bun's autoReconnect + enableOfflineQueue +// handle transient failures; try/catch on each op provides graceful degradation. let redisClient: RedisClient | null = null; -let redisConnected = false; export async function initRedisCache(): Promise { if (config.cacheMode !== "redis") return; + redisClient = new RedisClient(config.redisUrl, { + connectionTimeout: config.redisConnectionTimeout, + autoReconnect: true, + maxRetries: config.redisMaxRetries, + enableOfflineQueue: true, + enableAutoPipelining: true, + }); + try { - redisClient = new RedisClient(config.redisUrl, { - connectionTimeout: config.redisConnectionTimeout, - autoReconnect: true, - maxRetries: config.redisMaxRetries, - enableOfflineQueue: true, - enableAutoPipelining: true, - }); await redisClient.connect(); - redisConnected = true; console.log("Redis cache connected"); } catch (error) { - console.error("Redis connection failed:", error); - redisConnected = false; + console.error( + "Redis initial connection failed (will auto-reconnect):", + error, + ); } } @@ -113,7 +115,6 @@ export async function disconnectRedisCache(): Promise { // Ignore disconnect errors during shutdown } redisClient = null; - redisConnected = false; } } @@ -123,7 +124,7 @@ function redisKey(key: string): string { // Redis cache operations async function getRedisCached(key: string): Promise { - if (!redisClient || !redisConnected) return null; + if (!redisClient) return null; try { const data = await redisClient.getBuffer(redisKey(key)); return data ? Buffer.from(data) : null; @@ -134,7 +135,7 @@ async function getRedisCached(key: string): Promise { } async function setRedisCache(key: string, data: Buffer): Promise { - if (!redisClient || !redisConnected) return; + if (!redisClient) return; try { await redisClient.set(redisKey(key), data, "EX", config.cacheTTL); } catch (error) { @@ -378,7 +379,7 @@ export function getCacheStats(): { case "redis": return { mode: "redis", - connected: redisConnected, + connected: redisClient !== null, url: maskRedisUrl(config.redisUrl), }; case "none": From 2f5093438559fae50c1fd18343a5e78ee6e117ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 18:17:16 +0000 Subject: [PATCH 03/11] Add server-side origin validation to actually block unauthorized requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CORS middleware only sets response headers — it never rejects requests. This means any client (curl, tags, server-to-server) could bypass origin restrictions entirely. Add an onRequest hook that checks Origin and Referer headers against allowedOrigins and returns 403 for unauthorized origins before any processing occurs. https://claude.ai/code/session_01HBLMJXsTLSxNLuvGXEnUaZ --- src/index.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/index.ts b/src/index.ts index 3c1accc..2b1fa2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,44 @@ const app = new Elysia() origin: config.allowedOrigins.length > 0 ? config.allowedOrigins : true, }), ) + .onRequest(({ request, set }) => { + if (config.allowedOrigins.length === 0) return; + + const url = new URL(request.url); + if (url.pathname === "/health") return; + + const origin = request.headers.get("Origin"); + const referer = request.headers.get("Referer"); + + if (!origin && !referer) return; + + const sourceOrigin = + origin || + (() => { + try { + return new URL(referer as string).origin; + } catch { + return referer as string; + } + })(); + + const isAllowed = config.allowedOrigins.some((allowed) => { + if (sourceOrigin === allowed) return true; + try { + const parsed = new URL(sourceOrigin); + return ( + parsed.hostname === allowed || parsed.hostname.endsWith(`.${allowed}`) + ); + } catch { + return sourceOrigin === allowed; + } + }); + + if (!isAllowed) { + set.status = 403; + return { error: "FORBIDDEN", message: "Origin not allowed" }; + } + }) .onError(({ error, code, set }) => { // Don't log Elysia's built-in NOT_FOUND errors (these are normal 404s) if (code === "NOT_FOUND") { From 4be346976a3c69bfc73382f26c9af92fcea171bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 18:29:51 +0000 Subject: [PATCH 04/11] Fix TSC error, switch from Biome to oxfmt/oxlint/tsdown, add origin validation tests - Fix TS2339: replace `redisClient.quit()` with `redisClient.close()` (Bun's RedisClient API has no `.quit()` method) - Remove Biome, add oxfmt for formatting, oxlint for linting, tsdown for building - Add `.oxfmtrc.json` (migrated from biome.json), `.oxlintrc.json`, `tsdown.config.ts` - Add lint/format/build scripts to package.json - Add origin validation unit tests (13 test cases covering allowed/blocked origins, referer fallback, health exemption, subdomain matching) https://claude.ai/code/session_01HBLMJXsTLSxNLuvGXEnUaZ --- .oxfmtrc.json | 14 ++ .oxlintrc.json | 8 + CLAUDE.md | 11 +- README.md | 15 +- biome.json | 34 ---- bun.lock | 224 ++++++++++++++++++++++++--- package.json | 27 ++-- src/cluster.ts | 4 +- src/services/cache.ts | 2 +- tests/unit/origin-validation.test.ts | 172 ++++++++++++++++++++ tsdown.config.ts | 8 + 11 files changed, 445 insertions(+), 74 deletions(-) create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json delete mode 100644 biome.json create mode 100644 tests/unit/origin-validation.test.ts create mode 100644 tsdown.config.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..0bfa6f0 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,14 @@ +{ + "useTabs": false, + "tabWidth": 2, + "printWidth": 80, + "singleQuote": false, + "jsxSingleQuote": false, + "quoteProps": "as-needed", + "trailingComma": "all", + "semi": true, + "arrowParens": "always", + "bracketSameLine": false, + "bracketSpacing": true, + "ignorePatterns": [] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..2b27964 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "ignorePatterns": ["node_modules", "dist", "cache"] +} diff --git a/CLAUDE.md b/CLAUDE.md index 056fb59..9f80223 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,9 @@ Image processing microservice with OG image generation. - **Framework**: Elysia (type-safe HTTP framework) - **Image Processing**: Sharp (libvips bindings) - **OG Generation**: Satori + resvg-js (SVG-based, no headless browser) -- **Linting/Formatting**: Biome +- **Linting**: oxlint (fast Rust-based linter) +- **Formatting**: oxfmt (Prettier-compatible Rust formatter) +- **Building**: tsdown (TypeScript bundler based on Rolldown) ## Commands @@ -18,6 +20,9 @@ bun run dev # Start with hot reload bun run start # Start production server bun test # Run all tests bun test tests/unit # Run unit tests only +bun run lint # Lint with oxlint +bun run format # Format with oxfmt +bun run build # Build with tsdown ``` ## Project Structure @@ -57,10 +62,10 @@ tests/ ## Code Style -- Use Biome for linting/formatting: `bunx biome check --write .` +- Use oxfmt for formatting: `bun run format` +- Use oxlint for linting: `bun run lint` - Double quotes for strings - Space indentation -- Organize imports automatically ## Bun-Specific Notes diff --git a/README.md b/README.md index bc0dfe3..d83e149 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,7 @@ Instead of creating template files, you can pass the entire template configurati **Encoding Options:** 1. **Base64** (recommended for complex templates): + ```bash # Encode your template echo '{"layout":{"elements":[{"type":"text","content":"{{title}}","fontSize":48}]}}' | base64 @@ -399,6 +400,7 @@ Instead of creating template files, you can pass the entire template configurati ``` 2. **URL-safe Base64** (uses `-` and `_` instead of `+` and `/`): + ```bash /og?config=eyJsYXlvdXQiOnsiZWxlbWVudHMiOlt7InR5cGUiOiJ0ZXh0IiwiY29udGVudCI6Int7dGl0bGV9fSIsImZvbnRTaXplIjo0OH1dfX0-&title=Hello ``` @@ -424,12 +426,12 @@ const template = { content: "{{title}}", fontSize: 56, fontWeight: 700, - color: "#ffffff" + color: "#ffffff", }, { type: "spacer", size: 20, - showIf: "description" + showIf: "description", }, { type: "text", @@ -437,10 +439,10 @@ const template = { fontSize: 24, color: "#ffffff", opacity: 0.9, - showIf: "description" - } - ] - } + showIf: "description", + }, + ], + }, }; // Encode and use @@ -540,6 +542,7 @@ docker run -e CLUSTER_WORKERS=4 ghcr.io/climactic/pixelserve:latest ``` **Notes:** + - Clustering requires Linux (uses `SO_REUSEPORT`). On macOS/Windows, it falls back to single process mode with a warning. - Each worker maintains its own memory cache; disk cache is shared across all workers. - Crashed workers are automatically respawned to maintain availability. diff --git a/biome.json b/biome.json deleted file mode 100644 index 2af808c..0000000 --- a/biome.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "ignoreUnknown": false - }, - "formatter": { - "enabled": true, - "indentStyle": "space" - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double" - } - }, - "assist": { - "enabled": true, - "actions": { - "source": { - "organizeImports": "on" - } - } - } -} diff --git a/bun.lock b/bun.lock index 87329a1..e6444e5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,17 @@ "": { "name": "pixelserve", "dependencies": { - "@elysiajs/cors": "^1.4.0", + "@elysiajs/cors": "^1.4.1", "@resvg/resvg-js": "^2.6.2", - "elysia": "^1.4.18", - "satori": "^0.18.3", + "elysia": "^1.4.28", + "satori": "^0.18.4", "sharp": "^0.34.5", }, "devDependencies": { - "@biomejs/biome": "2.3.8", - "@types/bun": "^1.3.4", + "@types/bun": "^1.3.11", + "oxfmt": "^0.41.0", + "oxlint": "^1.56.0", + "tsdown": "^0.21.4", }, "peerDependencies": { "typescript": "^5.9.3", @@ -21,30 +23,26 @@ }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], + "@babel/generator": ["@babel/generator@8.0.0-rc.2", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.2", "@babel/types": "^8.0.0-rc.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.2", "", {}, "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="], + "@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="], - - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="], - - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="], - - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="], - - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="], + "@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -95,6 +93,96 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.41.0", "", { "os": "android", "cpu": "arm64" }, "sha512-s0b1dxNgb2KomspFV2LfogC2XtSJB42POXF4bMCLJyvQmAGos4ZtjGPfQreToQEaY0FQFjz3030ggI36rF1q5g=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.41.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EGXGualADbv/ZmamE7/2DbsrYmjoPlAmHEpTL4vapLF4EfVD6fr8/uQDFnPJkUBjiSWFJZtFNsGeN1B6V3owmA=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.41.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WxySJEvdQQYMmyvISH3qDpTvoS0ebnIP63IMxLLWowJyPp/AAH0hdWtlo+iGNK5y3eVfa5jZguwNaQkDKWpGSw=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.41.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Y2kzMkv3U3oyuYaR4wTfGjOTYTXiFC/hXmG0yVASKkbh02BJkvD98Ij8bIevr45hNZ0DmZEgqiXF+9buD4yMYQ=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.41.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ptazDjdUyhket01IjPTT6ULS1KFuBfTUU97osTP96X5y/0oso+AgAaJzuH81oP0+XXyrWIHbRzozSAuQm4p48g=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.41.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UkoL2OKxFD+56bPEBcdGn+4juTW4HRv/T6w1dIDLnvKKWr6DbarB/mtHXlADKlFiJubJz8pRkttOR7qjYR6lTA=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.41.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gofu0PuumSOHYczD8p62CPY4UF6ee+rSLZJdUXkpwxg6pILiwSDBIouPskjF/5nF3A7QZTz2O9KFNkNxxFN9tA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.41.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.41.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.41.0", "", { "os": "linux", "cpu": "none" }, "sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.41.0", "", { "os": "linux", "cpu": "none" }, "sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.41.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.41.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.41.0", "", { "os": "linux", "cpu": "x64" }, "sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.41.0", "", { "os": "none", "cpu": "arm64" }, "sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.41.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z7NAtu/RN8kjCQ1y5oDD0nTAeRswh3GJ93qwcW51srmidP7XPBmZbLlwERu1W5veCevQJtPS9xmkpcDTYsGIwQ=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.41.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-uNxxP3l4bJ6VyzIeRqCmBU2Q0SkCFgIhvx9/9dJ9V8t/v+jP1IBsuaLwCXGR8JPHtkj4tFp+RHtUmU2ZYAUpMA=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.41.0", "", { "os": "win32", "cpu": "x64" }, "sha512-49ZSpbZ1noozyPapE8SUOSm3IN0Ze4b5nkO+4+7fq6oEYQQJFhE0saj5k/Gg4oewVPdjn0L3ZFeWk2Vehjcw7A=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="], + + "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], + "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], @@ -121,6 +209,38 @@ "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], + "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], @@ -129,14 +249,28 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], + "@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + + "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -155,43 +289,79 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="], + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], "file-type": ["file-type@21.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="], + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], + "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "oxfmt": ["oxfmt@0.41.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.41.0", "@oxfmt/binding-android-arm64": "0.41.0", "@oxfmt/binding-darwin-arm64": "0.41.0", "@oxfmt/binding-darwin-x64": "0.41.0", "@oxfmt/binding-freebsd-x64": "0.41.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.41.0", "@oxfmt/binding-linux-arm-musleabihf": "0.41.0", "@oxfmt/binding-linux-arm64-gnu": "0.41.0", "@oxfmt/binding-linux-arm64-musl": "0.41.0", "@oxfmt/binding-linux-ppc64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-musl": "0.41.0", "@oxfmt/binding-linux-s390x-gnu": "0.41.0", "@oxfmt/binding-linux-x64-gnu": "0.41.0", "@oxfmt/binding-linux-x64-musl": "0.41.0", "@oxfmt/binding-openharmony-arm64": "0.41.0", "@oxfmt/binding-win32-arm64-msvc": "0.41.0", "@oxfmt/binding-win32-ia32-msvc": "0.41.0", "@oxfmt/binding-win32-x64-msvc": "0.41.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg=="], + + "oxlint": ["oxlint@1.56.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.56.0", "@oxlint/binding-android-arm64": "1.56.0", "@oxlint/binding-darwin-arm64": "1.56.0", "@oxlint/binding-darwin-x64": "1.56.0", "@oxlint/binding-freebsd-x64": "1.56.0", "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", "@oxlint/binding-linux-arm-musleabihf": "1.56.0", "@oxlint/binding-linux-arm64-gnu": "1.56.0", "@oxlint/binding-linux-arm64-musl": "1.56.0", "@oxlint/binding-linux-ppc64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-musl": "1.56.0", "@oxlint/binding-linux-s390x-gnu": "1.56.0", "@oxlint/binding-linux-x64-gnu": "1.56.0", "@oxlint/binding-linux-x64-musl": "1.56.0", "@oxlint/binding-openharmony-arm64": "1.56.0", "@oxlint/binding-win32-arm64-msvc": "1.56.0", "@oxlint/binding-win32-ia32-msvc": "1.56.0", "@oxlint/binding-win32-x64-msvc": "1.56.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], "parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], + + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.5", "", { "dependencies": { "@babel/generator": "8.0.0-rc.2", "@babel/helper-validator-identifier": "8.0.0-rc.2", "@babel/parser": "8.0.0-rc.2", "@babel/types": "8.0.0-rc.2", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.6", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0 || ^6.0.0-beta", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw=="], + "satori": ["satori@0.18.4", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-HanEzgXHlX3fzpGgxPoR3qI7FDpc/B+uE/KplzA6BkZGlWMaH98B/1Amq+OBF1pYPlGNzAXPYNHlrEVBvRBnHQ=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -201,18 +371,34 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "tsdown": ["tsdown@0.21.4", "", { "dependencies": { "ansis": "^4.2.0", "cac": "^7.0.0", "defu": "^6.1.4", "empathic": "^2.0.0", "hookable": "^6.1.0", "import-without-cache": "^0.2.5", "obug": "^2.1.1", "picomatch": "^4.0.3", "rolldown": "1.0.0-rc.9", "rolldown-plugin-dts": "^0.22.5", "semver": "^7.7.4", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "unrun": "^0.2.32" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.21.4", "@tsdown/exe": "0.21.4", "@vitejs/devtools": "*", "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-unused": "^0.5.0" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "typescript", "unplugin-unused"], "bin": { "tsdown": "dist/run.mjs" } }, "sha512-Q/kBi8SXkr4X6JI/NAZKZY1UuiEcbuXtIskL4tZCsgpDiEPM/2W6lC+OonNA31S+V3KsWedFvbFDBs23hvt+Aw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "unrun": ["unrun@0.2.32", "", { "dependencies": { "rolldown": "1.0.0-rc.9" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-opd3z6791rf281JdByf0RdRQrpcc7WyzqittqIXodM/5meNWdTwrVxeyzbaCp4/Rgls/um14oUaif1gomO8YGg=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], } } diff --git a/package.json b/package.json index 04e17fe..994a606 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,19 @@ { "name": "pixelserve", "version": "1.0.0", - "module": "src/index.ts", - "type": "module", "private": true, + "type": "module", + "module": "src/index.ts", "scripts": { "dev": "bun --hot src/index.ts", "start": "bun src/index.ts", "start:cluster": "bun src/cluster.ts", - "test": "bun test" - }, - "devDependencies": { - "@biomejs/biome": "2.3.8", - "@types/bun": "^1.3.11" - }, - "peerDependencies": { - "typescript": "^5.9.3" + "test": "bun test", + "lint": "oxlint .", + "lint:fix": "oxlint --fix .", + "format": "oxfmt --write .", + "format:check": "oxfmt --check .", + "build": "tsdown" }, "dependencies": { "@elysiajs/cors": "^1.4.1", @@ -23,5 +21,14 @@ "elysia": "^1.4.28", "satori": "^0.18.4", "sharp": "^0.34.5" + }, + "devDependencies": { + "@types/bun": "^1.3.11", + "oxfmt": "^0.41.0", + "oxlint": "^1.56.0", + "tsdown": "^0.21.4" + }, + "peerDependencies": { + "typescript": "^5.9.3" } } diff --git a/src/cluster.ts b/src/cluster.ts index 88ac0a5..6f12c8b 100644 --- a/src/cluster.ts +++ b/src/cluster.ts @@ -35,7 +35,9 @@ function spawnWorker(id: number) { worker.exited.then((exitCode) => { workers.delete(id); if (!shuttingDown && exitCode !== 0) { - console.log(`⚠️ Worker #${id} crashed (exit ${exitCode}), respawning...`); + console.log( + `⚠️ Worker #${id} crashed (exit ${exitCode}), respawning...`, + ); spawnWorker(id); } }); diff --git a/src/services/cache.ts b/src/services/cache.ts index c3a11f0..75bab03 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -110,7 +110,7 @@ export async function initRedisCache(): Promise { export async function disconnectRedisCache(): Promise { if (redisClient) { try { - await redisClient.quit(); + redisClient.close(); } catch { // Ignore disconnect errors during shutdown } diff --git a/tests/unit/origin-validation.test.ts b/tests/unit/origin-validation.test.ts new file mode 100644 index 0000000..2cb997f --- /dev/null +++ b/tests/unit/origin-validation.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { Elysia } from "elysia"; + +function createAppWithOriginValidation(allowedOrigins: string[]) { + return new Elysia() + .onRequest(({ request, set }) => { + if (allowedOrigins.length === 0) return; + + const url = new URL(request.url); + if (url.pathname === "/health") return; + + const origin = request.headers.get("Origin"); + const referer = request.headers.get("Referer"); + + if (!origin && !referer) return; + + const sourceOrigin = + origin || + (() => { + try { + return new URL(referer as string).origin; + } catch { + return referer as string; + } + })(); + + const isAllowed = allowedOrigins.some((allowed) => { + if (sourceOrigin === allowed) return true; + try { + const parsed = new URL(sourceOrigin); + return ( + parsed.hostname === allowed || + parsed.hostname.endsWith(`.${allowed}`) + ); + } catch { + return sourceOrigin === allowed; + } + }); + + if (!isAllowed) { + set.status = 403; + return { error: "FORBIDDEN", message: "Origin not allowed" }; + } + }) + .get("/", () => ({ status: "ok" })) + .get("/health", () => ({ status: "healthy" })) + .get("/image", () => ({ status: "ok" })); +} + +describe("Origin Validation", () => { + describe("when allowedOrigins is configured", () => { + const app = createAppWithOriginValidation([ + "example.com", + "https://trusted.io", + ]); + + test("allows requests without Origin or Referer headers", async () => { + const response = await app.handle(new Request("http://localhost/")); + expect(response.status).toBe(200); + }); + + test("allows requests with an allowed Origin (bare domain match)", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Origin: "https://example.com" }, + }), + ); + expect(response.status).toBe(200); + }); + + test("allows requests with an allowed Origin (full URL match)", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Origin: "https://trusted.io" }, + }), + ); + expect(response.status).toBe(200); + }); + + test("blocks requests with a disallowed Origin", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Origin: "https://evil.com" }, + }), + ); + expect(response.status).toBe(403); + const json = await response.json(); + expect(json.error).toBe("FORBIDDEN"); + }); + + test("uses Referer as fallback when Origin is absent", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Referer: "https://example.com/page" }, + }), + ); + expect(response.status).toBe(200); + }); + + test("blocks requests with disallowed Referer when Origin is absent", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Referer: "https://evil.com/page" }, + }), + ); + expect(response.status).toBe(403); + }); + + test("always allows /health regardless of origin", async () => { + const response = await app.handle( + new Request("http://localhost/health", { + headers: { Origin: "https://evil.com" }, + }), + ); + expect(response.status).toBe(200); + }); + + test("allows subdomain matching", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Origin: "https://sub.example.com" }, + }), + ); + expect(response.status).toBe(200); + }); + + test("allows deep subdomain matching", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Origin: "https://a.b.example.com" }, + }), + ); + expect(response.status).toBe(200); + }); + + test("does not allow partial domain matches", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Origin: "https://notexample.com" }, + }), + ); + expect(response.status).toBe(403); + }); + + test("blocks on non-health routes like /image", async () => { + const response = await app.handle( + new Request("http://localhost/image", { + headers: { Origin: "https://evil.com" }, + }), + ); + expect(response.status).toBe(403); + }); + }); + + describe("when allowedOrigins is empty (no restrictions)", () => { + const app = createAppWithOriginValidation([]); + + test("allows all requests regardless of origin", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Origin: "https://anything.com" }, + }), + ); + expect(response.status).toBe(200); + }); + + test("allows requests without any origin headers", async () => { + const response = await app.handle(new Request("http://localhost/")); + expect(response.status).toBe(200); + }); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..77185ba --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: "esm", + outDir: "dist", + clean: true, +}); From d73a5d8c11495611f6be540233760f2a0b1c03be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 18:51:44 +0000 Subject: [PATCH 05/11] Address PR review: fix Redis status, SSRF bypass, update deps, deduplicate helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix getCacheStats().connected to use redisClient?.connected instead of null check, so health route accurately reports Redis availability - Fix SSRF bypass for IP literals by checking isIP() before DNS resolution and using Promise.allSettled() to distinguish real DNS errors from ENOTFOUND - Export maskRedisUrl from cache.ts and reuse in index.ts instead of duplicating the regex - Rate-limit font preload retries with 60s cooldown to reduce log noise - Quote REDIS_URL in .env.example for safe handling of special chars - Update satori 0.18.4 → 0.26.0 via bun update --latest - Update CI workflow to use oxlint/oxfmt instead of Biome https://claude.ai/code/session_01HBLMJXsTLSxNLuvGXEnUaZ --- .env.example | 2 +- .github/workflows/ci.yml | 7 ++-- bun.lock | 22 ++++++------ package.json | 2 +- src/index.ts | 7 ++-- src/services/cache.ts | 4 +-- src/services/og-generator.ts | 15 ++++++-- src/utils/url-validator.ts | 66 +++++++++++++++++++++++------------- 8 files changed, 78 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index 7b4cfe8..46b9a47 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,7 @@ MAX_IMAGE_SIZE=10485760 # Max image size in bytes (default: 10MB) REQUEST_TIMEOUT=30000 # Request timeout in ms (default: 30s) # Redis cache settings (only used when CACHE_MODE=redis) -REDIS_URL=redis://localhost:6379 # Redis connection URL (supports redis://, rediss://, redis+unix://) +REDIS_URL="redis://localhost:6379" # Redis connection URL — quote if credentials contain special chars REDIS_KEY_PREFIX=ps: # Key prefix to namespace cache entries REDIS_CONNECTION_TIMEOUT=5000 # Connection timeout in ms REDIS_MAX_RETRIES=10 # Max reconnection attempts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbce3e1..55bb7c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,11 @@ jobs: - name: Install dependencies run: bun install - - name: Run Biome - run: bunx biome check . + - name: Run oxlint + run: bun run lint + + - name: Check formatting + run: bun run format:check typecheck: name: Type Check diff --git a/bun.lock b/bun.lock index e6444e5..f694c0c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,20 +5,20 @@ "": { "name": "pixelserve", "dependencies": { - "@elysiajs/cors": "^1.4.1", - "@resvg/resvg-js": "^2.6.2", - "elysia": "^1.4.28", - "satori": "^0.18.4", - "sharp": "^0.34.5", + "@elysiajs/cors": "latest", + "@resvg/resvg-js": "latest", + "elysia": "latest", + "satori": "latest", + "sharp": "latest", }, "devDependencies": { - "@types/bun": "^1.3.11", - "oxfmt": "^0.41.0", - "oxlint": "^1.56.0", - "tsdown": "^0.21.4", + "@types/bun": "latest", + "oxfmt": "latest", + "oxlint": "latest", + "tsdown": "latest", }, "peerDependencies": { - "typescript": "^5.9.3", + "typescript": "latest", }, }, }, @@ -359,7 +359,7 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.5", "", { "dependencies": { "@babel/generator": "8.0.0-rc.2", "@babel/helper-validator-identifier": "8.0.0-rc.2", "@babel/parser": "8.0.0-rc.2", "@babel/types": "8.0.0-rc.2", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.6", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0 || ^6.0.0-beta", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw=="], - "satori": ["satori@0.18.4", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-HanEzgXHlX3fzpGgxPoR3qI7FDpc/B+uE/KplzA6BkZGlWMaH98B/1Amq+OBF1pYPlGNzAXPYNHlrEVBvRBnHQ=="], + "satori": ["satori@0.26.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], diff --git a/package.json b/package.json index 994a606..8c8e56f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@elysiajs/cors": "^1.4.1", "@resvg/resvg-js": "^2.6.2", "elysia": "^1.4.28", - "satori": "^0.18.4", + "satori": "^0.26.0", "sharp": "^0.34.5" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index 2b1fa2a..9394789 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { ogRoutes } from "./routes/og"; import { disconnectRedisCache, initRedisCache, + maskRedisUrl, startCacheCleanup, } from "./services/cache"; @@ -119,10 +120,8 @@ function getCacheInfo(): string { return `memory (max ${config.maxMemoryCacheItems} items)`; case "hybrid": return `hybrid (memory + ${config.cacheDir})`; - case "redis": { - const masked = config.redisUrl.replace(/:\/\/[^@]*@/, "://***@"); - return `redis (${masked})`; - } + case "redis": + return `redis (${maskRedisUrl(config.redisUrl)})`; case "none": return "disabled"; } diff --git a/src/services/cache.ts b/src/services/cache.ts index 75bab03..f9ca70e 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -345,7 +345,7 @@ export function stopCacheCleanup(): void { } // Mask credentials in Redis URL for display -function maskRedisUrl(url: string): string { +export function maskRedisUrl(url: string): string { try { const parsed = new URL(url); if (parsed.password) { @@ -379,7 +379,7 @@ export function getCacheStats(): { case "redis": return { mode: "redis", - connected: redisClient !== null, + connected: redisClient?.connected ?? false, url: maskRedisUrl(config.redisUrl), }; case "none": diff --git a/src/services/og-generator.ts b/src/services/og-generator.ts index 2a3df33..123969b 100644 --- a/src/services/og-generator.ts +++ b/src/services/og-generator.ts @@ -10,15 +10,26 @@ import { preloadDefaultFonts, } from "./fonts"; -// Preload default fonts at startup +// Preload default fonts at startup (with rate-limited retries) let defaultFontsAttempted = false; +let lastDefaultFontAttempt = 0; +const FONT_RETRY_INTERVAL_MS = 60_000; + async function ensureDefaultFontsLoaded(): Promise { if (defaultFontsAttempted) return; + + const now = Date.now(); + if (now - lastDefaultFontAttempt < FONT_RETRY_INTERVAL_MS) return; + lastDefaultFontAttempt = now; + defaultFontsAttempted = true; try { await preloadDefaultFonts(); } catch (err) { - console.warn("Default font preload failed (will retry per-request):", err); + console.warn( + "Default font preload failed (will retry after cooldown):", + err, + ); defaultFontsAttempted = false; } } diff --git a/src/utils/url-validator.ts b/src/utils/url-validator.ts index adaccf5..c6b07de 100644 --- a/src/utils/url-validator.ts +++ b/src/utils/url-validator.ts @@ -1,4 +1,5 @@ import dns from "node:dns/promises"; +import { isIP } from "node:net"; import { config } from "../config"; import { ForbiddenError, ValidationError } from "./errors"; @@ -20,6 +21,11 @@ function isPrivateIP(ip: string): boolean { return PRIVATE_IP_RANGES.some((range) => range.test(ip)); } +function isDnsNotFound(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException)?.code; + return code === "ENOTFOUND" || code === "ENODATA"; +} + export async function validateUrl(urlString: string): Promise { // Parse URL let url: URL; @@ -68,38 +74,50 @@ export async function validateUrl(urlString: string): Promise { } } - // DNS resolution check to prevent SSRF via DNS rebinding - try { - // Try IPv4 first - const addresses = await dns.resolve4(hostname).catch(() => []); - - for (const ip of addresses) { - if (isPrivateIP(ip)) { - throw new ForbiddenError("Private IP addresses are not allowed"); - } + // Check if hostname is an IP literal — validate directly without DNS + if (isIP(hostname)) { + if (isPrivateIP(hostname)) { + throw new ForbiddenError("Private IP addresses are not allowed"); } + return url; + } - // Also check IPv6 if available - const ipv6Addresses = await dns.resolve6(hostname).catch(() => []); - - for (const ip of ipv6Addresses) { - if (isPrivateIP(ip)) { - throw new ForbiddenError("Private IP addresses are not allowed"); - } + // DNS resolution check to prevent SSRF via DNS rebinding + const [v4Result, v6Result] = await Promise.allSettled([ + dns.resolve4(hostname), + dns.resolve6(hostname), + ]); + + const addresses: string[] = + v4Result.status === "fulfilled" ? v4Result.value : []; + const ipv6Addresses: string[] = + v6Result.status === "fulfilled" ? v6Result.value : []; + + for (const ip of [...addresses, ...ipv6Addresses]) { + if (isPrivateIP(ip)) { + throw new ForbiddenError("Private IP addresses are not allowed"); } + } - // If no addresses resolved at all, block the request - if (addresses.length === 0 && ipv6Addresses.length === 0) { + // If no addresses resolved, distinguish "no records" from real errors + if (addresses.length === 0 && ipv6Addresses.length === 0) { + const v4Error = v4Result.status === "rejected" ? v4Result.reason : null; + const v6Error = v6Result.status === "rejected" ? v6Result.reason : null; + + if (v4Error && !isDnsNotFound(v4Error)) { throw new ForbiddenError( - `DNS resolution failed: no records found for ${hostname}`, + `DNS resolution failed for ${hostname}: ${(v4Error as Error).message || v4Error}`, ); } - } catch (error) { - if (error instanceof ForbiddenError) { - throw error; + if (v6Error && !isDnsNotFound(v6Error)) { + throw new ForbiddenError( + `DNS resolution failed for ${hostname}: ${(v6Error as Error).message || v6Error}`, + ); } - // DNS resolution failed - might be temporary, allow but log - console.warn(`DNS resolution warning for ${hostname}:`, error); + + throw new ForbiddenError( + `DNS resolution failed: no records found for ${hostname}`, + ); } return url; From 4856045f756d1b18d45ab6d07ac7ff0688a6d315 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Fri, 20 Mar 2026 19:33:25 +0000 Subject: [PATCH 06/11] chore: update dependencies to specific versions in bun.lock --- bun.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index f694c0c..de82fe7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,20 +5,20 @@ "": { "name": "pixelserve", "dependencies": { - "@elysiajs/cors": "latest", - "@resvg/resvg-js": "latest", - "elysia": "latest", - "satori": "latest", - "sharp": "latest", + "@elysiajs/cors": "^1.4.1", + "@resvg/resvg-js": "^2.6.2", + "elysia": "^1.4.28", + "satori": "^0.26.0", + "sharp": "^0.34.5", }, "devDependencies": { - "@types/bun": "latest", - "oxfmt": "latest", - "oxlint": "latest", - "tsdown": "latest", + "@types/bun": "^1.3.11", + "oxfmt": "^0.41.0", + "oxlint": "^1.56.0", + "tsdown": "^0.21.4", }, "peerDependencies": { - "typescript": "latest", + "typescript": "^5.9.3", }, }, }, From be45f8a979a54a67ef9f8650cc7ae72a51eb9f69 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 21:32:21 +0000 Subject: [PATCH 07/11] Stop Elysia server before disconnecting Redis on shutdown Ensures the server stops accepting new requests before Redis is torn down, preventing in-flight requests from hitting a null Redis client. https://claude.ai/code/session_01HBLMJXsTLSxNLuvGXEnUaZ --- src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 9394789..b8b09e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -171,8 +171,11 @@ ${c.magenta} ____ _ _ ____ ${c.dim}Discord:${c.reset} ${c.blue}https://go.climactic.co/discord${c.reset} `); -// Graceful shutdown +// Graceful shutdown — stop server before tearing down Redis const shutdown = async () => { + if (typeof app.stop === "function") { + await app.stop(); + } await disconnectRedisCache(); process.exit(0); }; From d86c6cda15e30d15af869b8955486ac15a8c6955 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Fri, 20 Mar 2026 22:08:24 +0000 Subject: [PATCH 08/11] Refactor: extract middleware, decompose image processor, harden SSRF redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract origin validation to src/middleware/origin-validator.ts and shared error handler to src/middleware/error-handler.ts, removing duplication across routes and tests - Decompose 309-line processImage() into focused transform modules (crop, resize, adjustments, watermark, output) under src/services/transforms/ - Harden SSRF protection: switch to manual redirect handling with per-hop URL revalidation (max 5 redirects) - Extract magic numbers to src/constants.ts - Add 10s shutdown timeout to prevent hanging on graceful shutdown - Remove unused _format param from setCache(), fix sort() → toSorted() lint warnings - Update CLAUDE.md with architecture patterns and conventions Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 103 +++++++-- src/constants.ts | 18 ++ src/index.ts | 56 ++--- src/middleware/error-handler.ts | 25 ++ src/middleware/origin-validator.ts | 61 +++++ src/routes/image.ts | 21 +- src/routes/og.ts | 21 +- src/services/cache.ts | 8 +- src/services/fonts.ts | 2 +- src/services/image-fetcher.ts | 101 +++++--- src/services/image-processor.ts | 308 ++----------------------- src/services/transforms/adjustments.ts | 63 +++++ src/services/transforms/crop.ts | 35 +++ src/services/transforms/output.ts | 24 ++ src/services/transforms/resize.ts | 54 +++++ src/services/transforms/watermark.ts | 163 +++++++++++++ tests/unit/origin-validation.test.ts | 54 ++--- 17 files changed, 636 insertions(+), 481 deletions(-) create mode 100644 src/constants.ts create mode 100644 src/middleware/error-handler.ts create mode 100644 src/middleware/origin-validator.ts create mode 100644 src/services/transforms/adjustments.ts create mode 100644 src/services/transforms/crop.ts create mode 100644 src/services/transforms/output.ts create mode 100644 src/services/transforms/resize.ts create mode 100644 src/services/transforms/watermark.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9f80223..b75af69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ Image processing microservice with OG image generation. - **Linting**: oxlint (fast Rust-based linter) - **Formatting**: oxfmt (Prettier-compatible Rust formatter) - **Building**: tsdown (TypeScript bundler based on Rolldown) +- **Config Validation**: @sinclair/typebox (runtime schema validation) ## Commands @@ -18,10 +19,13 @@ Image processing microservice with OG image generation. bun install # Install dependencies bun run dev # Start with hot reload bun run start # Start production server +bun run start:cluster # Start with clustering bun test # Run all tests bun test tests/unit # Run unit tests only bun run lint # Lint with oxlint +bun run lint:fix # Lint and auto-fix bun run format # Format with oxfmt +bun run format:check # Check formatting without writing bun run build # Build with tsdown ``` @@ -29,47 +33,98 @@ bun run build # Build with tsdown ``` src/ -├── index.ts # Entry point, Elysia server setup -├── config.ts # Environment configuration +├── index.ts # Entry point — server setup, CORS, cache init, shutdown +├── config.ts # Env config with TypeBox schema validation +├── constants.ts # Named constants (timeouts, limits, defaults) +├── middleware/ +│ ├── origin-validator.ts # Origin/Referer validation guard (subdomain-aware) +│ └── error-handler.ts # Shared Elysia error handler (PixelServeError → JSON) ├── routes/ -│ ├── image.ts # GET /image - image transformations -│ ├── og.ts # GET /og - OG image generation -│ └── health.ts # GET /health - health check +│ ├── image.ts # GET /image — image transformations +│ ├── og.ts # GET /og — OG image generation +│ └── health.ts # GET /health — health check ├── services/ -│ ├── image-processor.ts # Sharp transformations -│ ├── image-fetcher.ts # Remote image fetching with SSRF protection -│ ├── cache.ts # Disk/memory caching -│ ├── og-generator.ts # Satori rendering -│ ├── custom-templates.ts # JSON template builder -│ └── fonts.ts # Dynamic Google Fonts loading +│ ├── image-processor.ts # Orchestrator: fetch → crop → resize → adjust → watermark → output +│ ├── image-fetcher.ts # Remote image fetching with SSRF protection + redirect validation +│ ├── cache.ts # Multi-backend caching (disk/memory/hybrid/redis/none) +│ ├── og-generator.ts # Satori rendering pipeline +│ ├── custom-templates.ts # JSON template builder for OG images +│ ├── fonts.ts # Dynamic Google Fonts loading + caching +│ └── transforms/ # Individual image transform steps +│ ├── crop.ts +│ ├── resize.ts +│ ├── adjustments.ts # rotate, flip, flop, brightness, saturation, grayscale, tint, blur, sharpen, trim +│ ├── watermark.ts # Text (via Satori) and image watermarks with positioning +│ └── output.ts # Format conversion (webp, avif, png, jpg, gif) ├── utils/ -│ ├── url-validator.ts # SSRF prevention (blocks private IPs) -│ └── errors.ts # Custom error classes -└── types/index.ts # TypeScript interfaces +│ ├── url-validator.ts # SSRF prevention (private IP blocking, DNS resolution, domain allowlist) +│ └── errors.ts # Error hierarchy: PixelServeError → Validation/Fetch/Timeout/Forbidden/NotFound/ImageProcessing +└── types/index.ts # TypeScript interfaces (ImageParams, OGParams, etc.) templates/ # Custom JSON templates for OG images tests/ -├── unit/ # Unit tests +├── unit/ # Unit tests (bun:test) └── integration/ # API integration tests ``` ## Key APIs -- `GET /image?url=&w=&h=&format=` - Transform remote images -- `GET /og?title=&description=<desc>&template=<name>` - Generate OG images -- `GET /og/templates` - List available templates -- `GET /health` - Health check with cache stats +- `GET /image?url=<source>&w=<width>&h=<height>&format=<webp|avif|png|jpg>` — Transform remote images +- `GET /og?title=<title>&description=<desc>&template=<name>` — Generate OG images +- `GET /og/templates` — List available templates with schema docs +- `GET /health` — Health check with cache stats + +## Architecture Patterns + +### Error handling + +- All domain errors extend `PixelServeError` (in `utils/errors.ts`) with `statusCode` and `code` fields. +- Routes use the shared `createErrorHandler()` from `middleware/error-handler.ts` — do NOT duplicate error handling inline. +- Cache write errors are fire-and-forget (logged but don't fail the request). + +### Middleware + +- Origin validation and error handling live in `src/middleware/`. Elysia hooks are created via factory functions (`createOriginGuard()`, `createErrorHandler()`) for testability. +- Tests import and test the real middleware — never duplicate implementation in test files. + +### Image processing pipeline + +- `image-processor.ts` is a thin orchestrator. Each transform step is in `services/transforms/`. Add new transforms as separate files there. +- Pipeline order: fetch → auto-orient → crop → resize → adjustments → watermark → output format. + +### SSRF protection + +- `url-validator.ts` blocks private IPs, loopback, and link-local addresses via DNS resolution. +- `image-fetcher.ts` handles redirects manually (max 5 hops) and re-validates each redirect target through `validateUrl()` to prevent redirect-to-private-IP attacks. Never use `redirect: "follow"`. + +### Caching + +- `cache.ts` abstracts multiple backends behind `getCached()`/`setCache()`. Cache mode is set via `CACHE_MODE` env var. +- Cache keys are SHA256 hashes of sorted params (via `generateCacheKey()`). + +### Config + +- `config.ts` parses env vars and validates against a TypeBox schema. Bun auto-loads `.env` — no dotenv needed. +- Named constants (timeouts, limits) go in `constants.ts`, not inline as magic numbers. ## Code Style -- Use oxfmt for formatting: `bun run format` -- Use oxlint for linting: `bun run lint` -- Double quotes for strings -- Space indentation +- **Formatter**: oxfmt (`bun run format`). Double quotes, trailing commas, semicolons, space indentation. +- **Linter**: oxlint (`bun run lint`). +- **Imports**: Use `import type` for type-only imports. Keep imports organized: external → internal (config/constants → middleware → services → types → utils). +- **No `any`**: Use proper types or narrowing. Avoid `as` casts unless structurally necessary (document why). +- **Errors**: Throw typed errors from `utils/errors.ts` — never throw plain strings or generic `Error`. ## Bun-Specific Notes - Bun auto-loads `.env` files (no dotenv needed) - Use `Bun.$\`cmd\`` for shell commands - Use `Bun.file()` over `node:fs` when possible -- Tests use `bun:test` (import { test, expect, describe } from "bun:test") +- Tests use `bun:test` (`import { test, expect, describe } from "bun:test"`) +- Redis uses Bun's built-in `RedisClient` (not ioredis) + +## Testing + +- Tests live in `tests/unit/` and `tests/integration/`. +- Unit tests mock external dependencies; integration tests use Elysia's `app.handle()` for in-process HTTP testing. +- Always run `bun test` after changes. Run `bun run lint` and `bun run format` before committing. diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..dc9711b --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,18 @@ +// Cache +export const CACHE_CLEANUP_INTERVAL_MS = 3_600_000; + +// Image processing - blur +export const BLUR_MIN = 0.3; +export const BLUR_MAX = 1000; + +// Image processing - watermark defaults +export const DEFAULT_WATERMARK_FONT_SIZE = 24; +export const TEXT_WIDTH_MULTIPLIER = 0.7; +export const DEFAULT_FALLBACK_WIDTH = 800; +export const DEFAULT_FALLBACK_HEIGHT = 600; + +// Fetch +export const MAX_REDIRECTS = 5; + +// Shutdown +export const SHUTDOWN_TIMEOUT_MS = 10_000; diff --git a/src/index.ts b/src/index.ts index b8b09e7..ea41e9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ import { cors } from "@elysiajs/cors"; import { Elysia } from "elysia"; import { config } from "./config"; +import { CACHE_CLEANUP_INTERVAL_MS, SHUTDOWN_TIMEOUT_MS } from "./constants"; +import { createOriginGuard } from "./middleware/origin-validator"; import { healthRoutes } from "./routes/health"; import { imageRoutes } from "./routes/image"; import { ogRoutes } from "./routes/og"; @@ -31,44 +33,7 @@ const app = new Elysia() origin: config.allowedOrigins.length > 0 ? config.allowedOrigins : true, }), ) - .onRequest(({ request, set }) => { - if (config.allowedOrigins.length === 0) return; - - const url = new URL(request.url); - if (url.pathname === "/health") return; - - const origin = request.headers.get("Origin"); - const referer = request.headers.get("Referer"); - - if (!origin && !referer) return; - - const sourceOrigin = - origin || - (() => { - try { - return new URL(referer as string).origin; - } catch { - return referer as string; - } - })(); - - const isAllowed = config.allowedOrigins.some((allowed) => { - if (sourceOrigin === allowed) return true; - try { - const parsed = new URL(sourceOrigin); - return ( - parsed.hostname === allowed || parsed.hostname.endsWith(`.${allowed}`) - ); - } catch { - return sourceOrigin === allowed; - } - }); - - if (!isAllowed) { - set.status = 403; - return { error: "FORBIDDEN", message: "Origin not allowed" }; - } - }) + .onRequest(createOriginGuard(config.allowedOrigins)) .onError(({ error, code, set }) => { // Don't log Elysia's built-in NOT_FOUND errors (these are normal 404s) if (code === "NOT_FOUND") { @@ -108,7 +73,7 @@ const app = new Elysia() // Start background cache cleanup (every hour) - only on primary worker if (!isWorker || workerId === "0") { - startCacheCleanup(3600000); + startCacheCleanup(CACHE_CLEANUP_INTERVAL_MS); } // Build cache info string based on mode @@ -173,11 +138,16 @@ ${c.magenta} ____ _ _ ____ // Graceful shutdown — stop server before tearing down Redis const shutdown = async () => { - if (typeof app.stop === "function") { - await app.stop(); + const forceExit = setTimeout(() => process.exit(1), SHUTDOWN_TIMEOUT_MS); + try { + if (typeof app.stop === "function") { + await app.stop(); + } + await disconnectRedisCache(); + } finally { + clearTimeout(forceExit); + process.exit(0); } - await disconnectRedisCache(); - process.exit(0); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); diff --git a/src/middleware/error-handler.ts b/src/middleware/error-handler.ts new file mode 100644 index 0000000..22fe342 --- /dev/null +++ b/src/middleware/error-handler.ts @@ -0,0 +1,25 @@ +import type { ErrorHandler } from "elysia"; +import { PixelServeError } from "../utils/errors"; + +/** + * Creates a shared Elysia onError handler that formats PixelServeError + * instances into structured JSON responses. + */ +export function createErrorHandler(logPrefix: string): ErrorHandler { + return ({ error, set }) => { + if (error instanceof PixelServeError) { + set.status = error.statusCode; + return { + error: error.code, + message: error.message, + }; + } + + console.error(`${logPrefix}:`, error); + set.status = 500; + return { + error: "INTERNAL_ERROR", + message: "An unexpected error occurred", + }; + }; +} diff --git a/src/middleware/origin-validator.ts b/src/middleware/origin-validator.ts new file mode 100644 index 0000000..175fa48 --- /dev/null +++ b/src/middleware/origin-validator.ts @@ -0,0 +1,61 @@ +import type { Context } from "elysia"; + +/** + * Creates an Elysia onRequest handler that validates the request origin + * against a list of allowed origins. Supports subdomain matching. + */ +export function createOriginGuard(allowedOrigins: string[]) { + return ({ request, set }: Context) => { + if (allowedOrigins.length === 0) return; + + const url = new URL(request.url); + if (url.pathname === "/health") return; + + const origin = request.headers.get("Origin"); + const referer = request.headers.get("Referer"); + + // When allowedOrigins is configured, reject requests missing both headers + // to prevent bypassing origin checks (e.g., via curl without headers). + if (!origin && !referer) { + set.status = 403; + return { error: "FORBIDDEN", message: "Origin not allowed" }; + } + + const sourceOrigin = + origin || + (() => { + try { + return new URL(referer as string).origin; + } catch { + return referer as string; + } + })(); + + const isAllowed = allowedOrigins.some((allowed) => { + if (sourceOrigin === allowed) return true; + + // Normalize allowed entry to hostname (handles full URLs like "https://example.com") + let allowedHost: string; + try { + allowedHost = new URL(allowed).hostname; + } catch { + allowedHost = allowed; + } + + try { + const parsed = new URL(sourceOrigin); + return ( + parsed.hostname === allowedHost || + parsed.hostname.endsWith(`.${allowedHost}`) + ); + } catch { + return sourceOrigin === allowed; + } + }); + + if (!isAllowed) { + set.status = 403; + return { error: "FORBIDDEN", message: "Origin not allowed" }; + } + }; +} diff --git a/src/routes/image.ts b/src/routes/image.ts index 67e3a8c..393c270 100644 --- a/src/routes/image.ts +++ b/src/routes/image.ts @@ -1,4 +1,5 @@ import { Elysia, t } from "elysia"; +import { createErrorHandler } from "../middleware/error-handler"; import { generateCacheKey, getCached, @@ -7,7 +8,6 @@ import { } from "../services/cache"; import { processImage } from "../services/image-processor"; import { type ImageParams, imageParamsToCacheKeyParams } from "../types"; -import { PixelServeError } from "../utils/errors"; const imageQuerySchema = t.Object({ url: t.String({ minLength: 1 }), @@ -84,22 +84,7 @@ const imageQuerySchema = t.Object({ }); export const imageRoutes = new Elysia({ prefix: "/image" }) - .onError(({ error, set }) => { - if (error instanceof PixelServeError) { - set.status = error.statusCode; - return { - error: error.code, - message: error.message, - }; - } - - console.error("Unexpected error:", error); - set.status = 500; - return { - error: "INTERNAL_ERROR", - message: "An unexpected error occurred", - }; - }) + .onError(createErrorHandler("Unexpected error")) .get( "/", async ({ query, set }) => { @@ -150,7 +135,7 @@ export const imageRoutes = new Elysia({ prefix: "/image" }) const { buffer, format } = await processImage(params); // Store in cache (async, don't wait) - setCache(cacheKey, buffer, format).catch((err) => + setCache(cacheKey, buffer).catch((err) => console.error("Cache write error:", err), ); diff --git a/src/routes/og.ts b/src/routes/og.ts index 0cdd112..420badb 100644 --- a/src/routes/og.ts +++ b/src/routes/og.ts @@ -1,4 +1,5 @@ import { Elysia, t } from "elysia"; +import { createErrorHandler } from "../middleware/error-handler"; import { generateCacheKey, getCached, @@ -12,7 +13,6 @@ import { getTemplateInfo, } from "../services/og-generator"; import type { OGParams } from "../types"; -import { PixelServeError } from "../utils/errors"; const ogQuerySchema = t.Object({ title: t.Optional(t.String({ maxLength: 200 })), @@ -45,22 +45,7 @@ const ogQuerySchema = t.Object({ }); export const ogRoutes = new Elysia({ prefix: "/og" }) - .onError(({ error, set }) => { - if (error instanceof PixelServeError) { - set.status = error.statusCode; - return { - error: error.code, - message: error.message, - }; - } - - console.error("OG generation error:", error); - set.status = 500; - return { - error: "INTERNAL_ERROR", - message: "An unexpected error occurred", - }; - }) + .onError(createErrorHandler("OG generation error")) // List available templates .get("/templates", async () => { const info = getTemplateInfo(); @@ -153,7 +138,7 @@ export const ogRoutes = new Elysia({ prefix: "/og" }) const buffer = await generateOGImage(params); // Store in cache - setCache(cacheKey, buffer, "png").catch((err) => + setCache(cacheKey, buffer).catch((err) => console.error("Cache write error:", err), ); diff --git a/src/services/cache.ts b/src/services/cache.ts index f9ca70e..e6ecbde 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -149,7 +149,7 @@ export function generateCacheKey( // Filter out undefined values and sort keys for consistent hashing const filtered = Object.entries(params) .filter(([, v]) => v !== undefined) - .sort(([a], [b]) => a.localeCompare(b)) + .toSorted(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}=${v}`) .join("&"); @@ -238,11 +238,7 @@ export async function getCached(key: string): Promise<Buffer | null> { } } -export async function setCache( - key: string, - data: Buffer, - _format: ImageFormat, -): Promise<void> { +export async function setCache(key: string, data: Buffer): Promise<void> { switch (config.cacheMode) { case "disk": return setDiskCache(key, data); diff --git a/src/services/fonts.ts b/src/services/fonts.ts index e1e64a1..50e4a49 100644 --- a/src/services/fonts.ts +++ b/src/services/fonts.ts @@ -169,7 +169,7 @@ async function loadFontVariant( } // Fetch CSS to get font URLs - deduplicate and sort weights (Google Fonts requires ascending order) - const weightsToFetch = [...new Set([weight, 400, 700])].sort( + const weightsToFetch = [...new Set([weight, 400, 700])].toSorted( (a, b) => a - b, ) as FontWeight[]; const urlMap = await fetchFontCss(fontName, weightsToFetch); diff --git a/src/services/image-fetcher.ts b/src/services/image-fetcher.ts index 333d227..521d468 100644 --- a/src/services/image-fetcher.ts +++ b/src/services/image-fetcher.ts @@ -1,58 +1,75 @@ import { config } from "../config"; +import { MAX_REDIRECTS } from "../constants"; import { FetchError, TimeoutError, ValidationError } from "../utils/errors"; import { validateUrl } from "../utils/url-validator"; export async function fetchImage(urlString: string): Promise<Buffer> { - const url = await validateUrl(urlString); + let currentUrl = await validateUrl(urlString); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.requestTimeout); try { - const response = await fetch(url.toString(), { - signal: controller.signal, - headers: { - "User-Agent": "PixelServe/1.0", - Accept: "image/*", - }, - redirect: "follow", - }); + for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) { + const response = await fetch(currentUrl.toString(), { + signal: controller.signal, + headers: { + "User-Agent": "PixelServe/1.0", + Accept: "image/*", + }, + redirect: "manual", + }); - if (!response.ok) { - throw new FetchError( - `Failed to fetch image: ${response.status} ${response.statusText}`, - ); - } + // Handle redirects manually to re-validate each target against SSRF + if (isRedirectStatus(response.status)) { + const location = response.headers.get("Location"); + if (!location) { + throw new FetchError("Redirect response missing Location header"); + } + // Resolve relative redirects and re-validate against SSRF + const resolvedUrl = new URL(location, currentUrl.toString()); + currentUrl = await validateUrl(resolvedUrl.toString()); + continue; + } - // Validate content type - const contentType = response.headers.get("content-type") || ""; - if (!contentType.startsWith("image/")) { - throw new ValidationError( - `URL does not return an image (got ${contentType})`, - ); - } + if (!response.ok) { + throw new FetchError( + `Failed to fetch image: ${response.status} ${response.statusText}`, + ); + } + + // Validate content type + const contentType = response.headers.get("content-type") || ""; + if (!contentType.startsWith("image/")) { + throw new ValidationError( + `URL does not return an image (got ${contentType})`, + ); + } - // Check content length if available - const contentLength = parseInt( - response.headers.get("content-length") || "0", - 10, - ); - if (contentLength > 0 && contentLength > config.maxImageSize) { - throw new ValidationError( - `Image exceeds maximum size limit (${Math.round(config.maxImageSize / 1024 / 1024)}MB)`, + // Check content length if available + const contentLength = parseInt( + response.headers.get("content-length") || "0", + 10, ); - } + if (contentLength > 0 && contentLength > config.maxImageSize) { + throw new ValidationError( + `Image exceeds maximum size limit (${Math.round(config.maxImageSize / 1024 / 1024)}MB)`, + ); + } - const arrayBuffer = await response.arrayBuffer(); + const arrayBuffer = await response.arrayBuffer(); - // Double-check actual size - if (arrayBuffer.byteLength > config.maxImageSize) { - throw new ValidationError( - `Image exceeds maximum size limit (${Math.round(config.maxImageSize / 1024 / 1024)}MB)`, - ); + // Double-check actual size + if (arrayBuffer.byteLength > config.maxImageSize) { + throw new ValidationError( + `Image exceeds maximum size limit (${Math.round(config.maxImageSize / 1024 / 1024)}MB)`, + ); + } + + return Buffer.from(arrayBuffer); } - return Buffer.from(arrayBuffer); + throw new FetchError(`Too many redirects (max ${MAX_REDIRECTS})`); } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new TimeoutError("Image fetch timed out"); @@ -62,3 +79,13 @@ export async function fetchImage(urlString: string): Promise<Buffer> { clearTimeout(timeout); } } + +function isRedirectStatus(status: number): boolean { + return ( + status === 301 || + status === 302 || + status === 303 || + status === 307 || + status === 308 + ); +} diff --git a/src/services/image-processor.ts b/src/services/image-processor.ts index 5a89087..cb815a2 100644 --- a/src/services/image-processor.ts +++ b/src/services/image-processor.ts @@ -1,31 +1,19 @@ -import { Resvg } from "@resvg/resvg-js"; -import satori from "satori"; import sharp from "sharp"; import { config } from "../config"; -import type { ImageFormat, ImageParams, WatermarkPosition } from "../types"; +import type { ImageFormat, ImageParams } from "../types"; import { ImageProcessingError, ValidationError } from "../utils/errors"; -import { loadFontsForSatori } from "./fonts"; +import { applyAdjustments } from "./transforms/adjustments"; +import { applyCrop } from "./transforms/crop"; +import { applyOutputFormat } from "./transforms/output"; +import { applyResize } from "./transforms/resize"; +import { applyWatermark } from "./transforms/watermark"; import { fetchImage } from "./image-fetcher"; -// Map user-friendly position names to sharp gravity values -const GRAVITY_MAP: Record<WatermarkPosition, string> = { - center: "center", - top: "north", - "top-left": "northwest", - "top-right": "northeast", - bottom: "south", - "bottom-left": "southwest", - "bottom-right": "southeast", - left: "west", - right: "east", -}; - export async function processImage( params: ImageParams, sourceBuffer?: Buffer, ): Promise<{ buffer: Buffer; format: ImageFormat }> { try { - // Fetch the source image if not provided const imageBuffer = sourceBuffer || (await fetchImage(params.url)); let pipeline = sharp(imageBuffer, { failOnError: false }); @@ -33,286 +21,20 @@ export async function processImage( // Auto-orient based on EXIF pipeline = pipeline.rotate(); - // Crop (before resize) - format: "x,y,w,h" - if (params.crop) { - const parts = params.crop.split(",").map(Number); - if ( - parts.length !== 4 || - parts.some(Number.isNaN) || - parts[0] === undefined || - parts[1] === undefined || - parts[2] === undefined || - parts[3] === undefined - ) { - throw new ValidationError( - "Invalid crop format. Expected: x,y,width,height", - ); - } - const left = parts[0]; - const top = parts[1]; - const width = parts[2]; - const height = parts[3]; - if (left < 0 || top < 0 || width <= 0 || height <= 0) { - throw new ValidationError("Invalid crop dimensions"); - } - pipeline = pipeline.extract({ left, top, width, height }); - } - - // Resize - if (params.size || params.w || params.h) { - let width: number | undefined; - let height: number | undefined; - const fit = params.fit || "cover"; - - if (params.size) { - // Size param: percentage (1-100) of original image size - // Skip resize entirely if size=100 - if (params.size < 100) { - const metadata = await sharp(imageBuffer).metadata(); - const originalWidth = metadata.width || 0; - const originalHeight = metadata.height || 0; - const scale = Math.max(1, params.size) / 100; - width = Math.round(originalWidth * scale); - height = Math.round(originalHeight * scale); - - pipeline = pipeline.resize({ - width, - height, - fit: "fill", // Exact dimensions since we calculated them proportionally - withoutEnlargement: true, - }); - } - } else { - width = params.w - ? Math.min(Math.max(1, params.w), config.maxWidth) - : undefined; - height = params.h - ? Math.min(Math.max(1, params.h), config.maxHeight) - : undefined; - - pipeline = pipeline.resize({ - width, - height, - fit, - position: params.position || "center", - withoutEnlargement: true, - }); - } - } - - // Rotation (explicit, after EXIF auto-orient) - if (params.rotate !== undefined) { - // Normalize to 0, 90, 180, 270 for best quality - const angle = ((params.rotate % 360) + 360) % 360; - if (angle !== 0) { - pipeline = pipeline.rotate(angle); - } - } - - // Flip/Flop - if (params.flip) { - pipeline = pipeline.flip(); - } - if (params.flop) { - pipeline = pipeline.flop(); - } - - // Color adjustments using modulate - if (params.brightness !== undefined || params.saturation !== undefined) { - pipeline = pipeline.modulate({ - brightness: params.brightness, - saturation: params.saturation, - }); - } - - // Grayscale - if (params.grayscale) { - pipeline = pipeline.grayscale(); - } - - // Tint - if (params.tint) { - const tintColor = params.tint.startsWith("#") - ? params.tint - : `#${params.tint}`; - pipeline = pipeline.tint(tintColor); - } - - // Blur - if (params.blur !== undefined) { - const sigma = Math.min(Math.max(params.blur, 0.3), 1000); - pipeline = pipeline.blur(sigma); - } - - // Sharpen - if (params.sharpen !== undefined) { - pipeline = pipeline.sharpen({ sigma: params.sharpen }); - } - - // Trim whitespace - if (params.trim) { - pipeline = pipeline.trim(); - } - - // Watermark/Overlay (image or text) - if (params.wm_image || params.wm_text) { - const mainMetadata = await pipeline.clone().metadata(); - const mainWidth = mainMetadata.width || 800; - const mainHeight = mainMetadata.height || 600; - - let watermarkInput: Buffer; - - if (params.wm_text) { - // Text watermark using Satori + Resvg for proper font support - const fontSize = params.wm_fontsize || 24; - const fontFamily = params.wm_font || "Inter"; - const color = params.wm_color ? `#${params.wm_color}` : "#ffffff"; - const opacity = params.wm_opacity ?? 0.7; - - // Load the font - const fonts = await loadFontsForSatori(fontFamily, [400, 700]); - - // Estimate dimensions (will be auto-sized by satori) - const estimatedWidth = params.wm_text.length * fontSize * 0.7; - const estimatedHeight = fontSize * 1.5; - - // Create text element for Satori - const element = { - type: "div", - props: { - style: { - display: "flex", - fontFamily, - fontSize, - color, - opacity, - whiteSpace: "nowrap", - }, - children: params.wm_text, - }, - }; - - // Render with Satori to SVG - const svg = await satori(element, { - width: estimatedWidth, - height: estimatedHeight, - fonts: fonts.map((f) => ({ - name: f.name, - data: f.data, - weight: f.weight, - style: f.style, - })), - }); - - // Convert SVG to PNG using Resvg - const resvg = new Resvg(svg); - const pngData = resvg.render(); - watermarkInput = Buffer.from(pngData.asPng()); - } else { - // Image watermark - const watermarkBuffer = await fetchImage(params.wm_image || ""); - let watermarkPipeline = sharp(watermarkBuffer); - - // Scale watermark if specified (percentage of main image width) - if (params.wm_scale) { - const wmMetadata = await watermarkPipeline.metadata(); - const wmWidth = wmMetadata.width || 100; - const wmHeight = wmMetadata.height || 100; - const targetWidth = Math.round(mainWidth * (params.wm_scale / 100)); - const scaleFactor = targetWidth / wmWidth; - const targetHeight = Math.round(wmHeight * scaleFactor); - - watermarkPipeline = watermarkPipeline.resize({ - width: targetWidth, - height: targetHeight, - fit: "inside", - }); - } - - // Apply opacity - if (params.wm_opacity !== undefined && params.wm_opacity < 1) { - const opacity = Math.max(0, Math.min(1, params.wm_opacity)); - watermarkPipeline = watermarkPipeline.ensureAlpha(opacity); - } + // Transform pipeline: crop → resize → adjustments → watermark → output + pipeline = applyCrop(pipeline, params); + pipeline = await applyResize(pipeline, params, imageBuffer, { + maxWidth: config.maxWidth, + maxHeight: config.maxHeight, + }); + pipeline = applyAdjustments(pipeline, params); + pipeline = await applyWatermark(pipeline, params); - watermarkInput = await watermarkPipeline.toBuffer(); - } - - // Calculate position with padding - const padding = params.wm_padding || 0; - const position = params.wm_position || "bottom-right"; - const gravity = GRAVITY_MAP[position] || "southeast"; - - // For padding, we need to use top/left offsets instead of gravity - if (padding > 0) { - const wmMeta = await sharp(watermarkInput).metadata(); - const wmWidth = wmMeta.width || 0; - const wmHeight = wmMeta.height || 0; - - let top = 0; - let left = 0; - - // Calculate position based on gravity + padding - if (position.includes("top")) { - top = padding; - } else if (position.includes("bottom")) { - top = mainHeight - wmHeight - padding; - } else { - top = Math.round((mainHeight - wmHeight) / 2); - } - - if (position.includes("left")) { - left = padding; - } else if (position.includes("right")) { - left = mainWidth - wmWidth - padding; - } else { - left = Math.round((mainWidth - wmWidth) / 2); - } - - pipeline = pipeline.composite([ - { - input: watermarkInput, - top: Math.max(0, top), - left: Math.max(0, left), - }, - ]); - } else { - pipeline = pipeline.composite([ - { - input: watermarkInput, - gravity, - }, - ]); - } - } - - // Output format const format: ImageFormat = params.format || config.defaultFormat; const quality = params.q || config.defaultQuality; - - switch (format) { - case "webp": - pipeline = pipeline.webp({ quality }); - break; - case "avif": - pipeline = pipeline.avif({ quality }); - break; - case "png": - pipeline = pipeline.png({ quality }); - break; - case "jpg": - case "jpeg": - pipeline = pipeline.jpeg({ quality, mozjpeg: true }); - break; - case "gif": - pipeline = pipeline.gif(); - break; - default: - pipeline = pipeline.webp({ quality }); - } + pipeline = applyOutputFormat(pipeline, format, quality); const buffer = await pipeline.toBuffer(); - return { buffer, format }; } catch (error) { if ( diff --git a/src/services/transforms/adjustments.ts b/src/services/transforms/adjustments.ts new file mode 100644 index 0000000..9674bcb --- /dev/null +++ b/src/services/transforms/adjustments.ts @@ -0,0 +1,63 @@ +import type sharp from "sharp"; +import { BLUR_MAX, BLUR_MIN } from "../../constants"; +import type { ImageParams } from "../../types"; + +export function applyAdjustments( + pipeline: sharp.Sharp, + params: ImageParams, +): sharp.Sharp { + // Rotation (explicit, after EXIF auto-orient) + if (params.rotate !== undefined) { + const angle = ((params.rotate % 360) + 360) % 360; + if (angle !== 0) { + pipeline = pipeline.rotate(angle); + } + } + + // Flip/Flop + if (params.flip) { + pipeline = pipeline.flip(); + } + if (params.flop) { + pipeline = pipeline.flop(); + } + + // Color adjustments using modulate + if (params.brightness !== undefined || params.saturation !== undefined) { + pipeline = pipeline.modulate({ + brightness: params.brightness, + saturation: params.saturation, + }); + } + + // Grayscale + if (params.grayscale) { + pipeline = pipeline.grayscale(); + } + + // Tint + if (params.tint) { + const tintColor = params.tint.startsWith("#") + ? params.tint + : `#${params.tint}`; + pipeline = pipeline.tint(tintColor); + } + + // Blur + if (params.blur !== undefined) { + const sigma = Math.min(Math.max(params.blur, BLUR_MIN), BLUR_MAX); + pipeline = pipeline.blur(sigma); + } + + // Sharpen + if (params.sharpen !== undefined) { + pipeline = pipeline.sharpen({ sigma: params.sharpen }); + } + + // Trim whitespace + if (params.trim) { + pipeline = pipeline.trim(); + } + + return pipeline; +} diff --git a/src/services/transforms/crop.ts b/src/services/transforms/crop.ts new file mode 100644 index 0000000..10f7fae --- /dev/null +++ b/src/services/transforms/crop.ts @@ -0,0 +1,35 @@ +import type sharp from "sharp"; +import type { ImageParams } from "../../types"; +import { ValidationError } from "../../utils/errors"; + +export function applyCrop( + pipeline: sharp.Sharp, + params: ImageParams, +): sharp.Sharp { + if (!params.crop) return pipeline; + + const parts = params.crop.split(",").map(Number); + if ( + parts.length !== 4 || + parts.some(Number.isNaN) || + parts[0] === undefined || + parts[1] === undefined || + parts[2] === undefined || + parts[3] === undefined + ) { + throw new ValidationError( + "Invalid crop format. Expected: x,y,width,height", + ); + } + + const left = parts[0]; + const top = parts[1]; + const width = parts[2]; + const height = parts[3]; + + if (left < 0 || top < 0 || width <= 0 || height <= 0) { + throw new ValidationError("Invalid crop dimensions"); + } + + return pipeline.extract({ left, top, width, height }); +} diff --git a/src/services/transforms/output.ts b/src/services/transforms/output.ts new file mode 100644 index 0000000..83ba496 --- /dev/null +++ b/src/services/transforms/output.ts @@ -0,0 +1,24 @@ +import type sharp from "sharp"; +import type { ImageFormat } from "../../types"; + +export function applyOutputFormat( + pipeline: sharp.Sharp, + format: ImageFormat, + quality: number, +): sharp.Sharp { + switch (format) { + case "webp": + return pipeline.webp({ quality }); + case "avif": + return pipeline.avif({ quality }); + case "png": + return pipeline.png({ quality }); + case "jpg": + case "jpeg": + return pipeline.jpeg({ quality, mozjpeg: true }); + case "gif": + return pipeline.gif(); + default: + return pipeline.webp({ quality }); + } +} diff --git a/src/services/transforms/resize.ts b/src/services/transforms/resize.ts new file mode 100644 index 0000000..84c22f8 --- /dev/null +++ b/src/services/transforms/resize.ts @@ -0,0 +1,54 @@ +import sharp from "sharp"; +import type { ImageParams } from "../../types"; + +interface ResizeConfig { + maxWidth: number; + maxHeight: number; +} + +export async function applyResize( + pipeline: sharp.Sharp, + params: ImageParams, + imageBuffer: Buffer, + resizeConfig: ResizeConfig, +): Promise<sharp.Sharp> { + if (!params.size && !params.w && !params.h) return pipeline; + + if (params.size) { + // Size param: percentage (1-100) of original image size + // Skip resize entirely if size=100 + if (params.size < 100) { + const metadata = await sharp(imageBuffer).metadata(); + const originalWidth = metadata.width || 0; + const originalHeight = metadata.height || 0; + const scale = Math.max(1, params.size) / 100; + const width = Math.round(originalWidth * scale); + const height = Math.round(originalHeight * scale); + + return pipeline.resize({ + width, + height, + fit: "fill", + withoutEnlargement: true, + }); + } + } else { + const width = params.w + ? Math.min(Math.max(1, params.w), resizeConfig.maxWidth) + : undefined; + const height = params.h + ? Math.min(Math.max(1, params.h), resizeConfig.maxHeight) + : undefined; + const fit = params.fit || "cover"; + + return pipeline.resize({ + width, + height, + fit, + position: params.position || "center", + withoutEnlargement: true, + }); + } + + return pipeline; +} diff --git a/src/services/transforms/watermark.ts b/src/services/transforms/watermark.ts new file mode 100644 index 0000000..12ce3ef --- /dev/null +++ b/src/services/transforms/watermark.ts @@ -0,0 +1,163 @@ +import { Resvg } from "@resvg/resvg-js"; +import satori from "satori"; +import sharp from "sharp"; +import { + DEFAULT_FALLBACK_HEIGHT, + DEFAULT_FALLBACK_WIDTH, + DEFAULT_WATERMARK_FONT_SIZE, + TEXT_WIDTH_MULTIPLIER, +} from "../../constants"; +import type { ImageParams, WatermarkPosition } from "../../types"; +import { loadFontsForSatori } from "../fonts"; +import { fetchImage } from "../image-fetcher"; + +const GRAVITY_MAP: Record<WatermarkPosition, string> = { + center: "center", + top: "north", + "top-left": "northwest", + "top-right": "northeast", + bottom: "south", + "bottom-left": "southwest", + "bottom-right": "southeast", + left: "west", + right: "east", +}; + +export async function applyWatermark( + pipeline: sharp.Sharp, + params: ImageParams, +): Promise<sharp.Sharp> { + if (!params.wm_image && !params.wm_text) return pipeline; + + const mainMetadata = await pipeline.clone().metadata(); + const mainWidth = mainMetadata.width || DEFAULT_FALLBACK_WIDTH; + const mainHeight = mainMetadata.height || DEFAULT_FALLBACK_HEIGHT; + + let watermarkInput: Buffer; + + if (params.wm_text) { + watermarkInput = await renderTextWatermark(params); + } else { + watermarkInput = await renderImageWatermark(params, mainWidth); + } + + // Calculate position with padding + const padding = params.wm_padding || 0; + const position = params.wm_position || "bottom-right"; + const gravity = GRAVITY_MAP[position] || "southeast"; + + if (padding > 0) { + const wmMeta = await sharp(watermarkInput).metadata(); + const wmWidth = wmMeta.width || 0; + const wmHeight = wmMeta.height || 0; + + let top = 0; + let left = 0; + + if (position.includes("top")) { + top = padding; + } else if (position.includes("bottom")) { + top = mainHeight - wmHeight - padding; + } else { + top = Math.round((mainHeight - wmHeight) / 2); + } + + if (position.includes("left")) { + left = padding; + } else if (position.includes("right")) { + left = mainWidth - wmWidth - padding; + } else { + left = Math.round((mainWidth - wmWidth) / 2); + } + + return pipeline.composite([ + { + input: watermarkInput, + top: Math.max(0, top), + left: Math.max(0, left), + }, + ]); + } + + return pipeline.composite([ + { + input: watermarkInput, + gravity, + }, + ]); +} + +async function renderTextWatermark(params: ImageParams): Promise<Buffer> { + const fontSize = params.wm_fontsize || DEFAULT_WATERMARK_FONT_SIZE; + const fontFamily = params.wm_font || "Inter"; + const color = params.wm_color ? `#${params.wm_color}` : "#ffffff"; + const opacity = params.wm_opacity ?? 0.7; + + const fonts = await loadFontsForSatori(fontFamily, [400, 700]); + + const estimatedWidth = + (params.wm_text as string).length * fontSize * TEXT_WIDTH_MULTIPLIER; + const estimatedHeight = fontSize * 1.5; + + const element = { + type: "div", + props: { + style: { + display: "flex", + fontFamily, + fontSize, + color, + opacity, + whiteSpace: "nowrap", + }, + children: params.wm_text, + }, + }; + + const svg = await satori(element, { + width: estimatedWidth, + height: estimatedHeight, + fonts: fonts.map((f) => ({ + name: f.name, + data: f.data, + weight: f.weight, + style: f.style, + })), + }); + + const resvg = new Resvg(svg); + const pngData = resvg.render(); + return Buffer.from(pngData.asPng()); +} + +async function renderImageWatermark( + params: ImageParams, + mainWidth: number, +): Promise<Buffer> { + const watermarkBuffer = await fetchImage(params.wm_image || ""); + let watermarkPipeline = sharp(watermarkBuffer); + + // Scale watermark if specified (percentage of main image width) + if (params.wm_scale) { + const wmMetadata = await watermarkPipeline.metadata(); + const wmWidth = wmMetadata.width || 100; + const wmHeight = wmMetadata.height || 100; + const targetWidth = Math.round(mainWidth * (params.wm_scale / 100)); + const scaleFactor = targetWidth / wmWidth; + const targetHeight = Math.round(wmHeight * scaleFactor); + + watermarkPipeline = watermarkPipeline.resize({ + width: targetWidth, + height: targetHeight, + fit: "inside", + }); + } + + // Apply opacity + if (params.wm_opacity !== undefined && params.wm_opacity < 1) { + const opacity = Math.max(0, Math.min(1, params.wm_opacity)); + watermarkPipeline = watermarkPipeline.ensureAlpha(opacity); + } + + return watermarkPipeline.toBuffer(); +} diff --git a/tests/unit/origin-validation.test.ts b/tests/unit/origin-validation.test.ts index 2cb997f..15ebd7b 100644 --- a/tests/unit/origin-validation.test.ts +++ b/tests/unit/origin-validation.test.ts @@ -1,47 +1,10 @@ import { describe, expect, test } from "bun:test"; import { Elysia } from "elysia"; +import { createOriginGuard } from "../../src/middleware/origin-validator"; function createAppWithOriginValidation(allowedOrigins: string[]) { return new Elysia() - .onRequest(({ request, set }) => { - if (allowedOrigins.length === 0) return; - - const url = new URL(request.url); - if (url.pathname === "/health") return; - - const origin = request.headers.get("Origin"); - const referer = request.headers.get("Referer"); - - if (!origin && !referer) return; - - const sourceOrigin = - origin || - (() => { - try { - return new URL(referer as string).origin; - } catch { - return referer as string; - } - })(); - - const isAllowed = allowedOrigins.some((allowed) => { - if (sourceOrigin === allowed) return true; - try { - const parsed = new URL(sourceOrigin); - return ( - parsed.hostname === allowed || - parsed.hostname.endsWith(`.${allowed}`) - ); - } catch { - return sourceOrigin === allowed; - } - }); - - if (!isAllowed) { - set.status = 403; - return { error: "FORBIDDEN", message: "Origin not allowed" }; - } - }) + .onRequest(createOriginGuard(allowedOrigins)) .get("/", () => ({ status: "ok" })) .get("/health", () => ({ status: "healthy" })) .get("/image", () => ({ status: "ok" })); @@ -54,9 +17,9 @@ describe("Origin Validation", () => { "https://trusted.io", ]); - test("allows requests without Origin or Referer headers", async () => { + test("blocks requests without Origin or Referer headers", async () => { const response = await app.handle(new Request("http://localhost/")); - expect(response.status).toBe(200); + expect(response.status).toBe(403); }); test("allows requests with an allowed Origin (bare domain match)", async () => { @@ -133,6 +96,15 @@ describe("Origin Validation", () => { expect(response.status).toBe(200); }); + test("allows subdomain matching against full-URL allowed entry", async () => { + const response = await app.handle( + new Request("http://localhost/", { + headers: { Origin: "https://sub.trusted.io" }, + }), + ); + expect(response.status).toBe(200); + }); + test("does not allow partial domain matches", async () => { const response = await app.handle( new Request("http://localhost/", { From 64b2d574e7c46e8cdbc9f0d7d74d6a63165578d9 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi <aditya@climactic.co> Date: Fri, 20 Mar 2026 22:21:16 +0000 Subject: [PATCH 09/11] Fix CI: replace tsc with tsdown build, fix origin guard type error - Switch CI typecheck job from `bunx tsc --noEmit` to `bun run build` (tsdown) to match the project's actual build toolchain - Use structural type for origin guard handler to satisfy Elysia's PreHandler type instead of importing Context Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .github/workflows/ci.yml | 10 +++++----- src/middleware/origin-validator.ts | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55bb7c2..f26163a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,12 +26,12 @@ jobs: - name: Check formatting run: bun run format:check - typecheck: - name: Type Check + build: + name: Build runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Bun uses: oven-sh/setup-bun@v2 @@ -39,8 +39,8 @@ jobs: - name: Install dependencies run: bun install - - name: Run TypeScript check - run: bunx tsc --noEmit + - name: Build with tsdown + run: bun run build test: name: Test diff --git a/src/middleware/origin-validator.ts b/src/middleware/origin-validator.ts index 175fa48..a6a407a 100644 --- a/src/middleware/origin-validator.ts +++ b/src/middleware/origin-validator.ts @@ -1,11 +1,9 @@ -import type { Context } from "elysia"; - /** * Creates an Elysia onRequest handler that validates the request origin * against a list of allowed origins. Supports subdomain matching. */ export function createOriginGuard(allowedOrigins: string[]) { - return ({ request, set }: Context) => { + return ({ request, set }: { request: Request; set: { status?: number } }) => { if (allowedOrigins.length === 0) return; const url = new URL(request.url); From 65e6fd40d585b4d478656336b50fbee59db4e0b4 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi <aditya@climactic.co> Date: Fri, 20 Mar 2026 22:24:05 +0000 Subject: [PATCH 10/11] Fix Dockerfile: replace tsc with tsdown build step Switch builder stage from `bun x tsc --noEmit || true` to `bun run build` (tsdown) to match the project's build toolchain. Build failures now correctly fail the Docker build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 625cb92..f1b25af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,12 +26,12 @@ COPY package.json bun.lock ./ # Install all dependencies (including dev) RUN bun install --frozen-lockfile -# Copy source code +# Copy source code and config COPY src ./src -COPY tsconfig.json ./ +COPY tsconfig.json tsdown.config.ts ./ -# Type check (optional but recommended) -RUN bun x tsc --noEmit || true +# Build with tsdown +RUN bun run build # ============================================ # Stage 3: Production runtime From 0e2a2cd1af8a60736a5d1f0778aec35fbe1a85e8 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi <aditya@climactic.co> Date: Fri, 20 Mar 2026 22:54:22 +0000 Subject: [PATCH 11/11] Fix review findings: crop validation, PNG output, resize dimensions, watermark opacity/fonts, Redis types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crop: reject non-finite and non-integer values (Infinity, decimals) - output: PNG uses compressionLevel instead of no-op quality param - resize: use toBuffer({ resolveWithObject: true }) for accurate post-transform dimensions, clamp to min 1 - watermark: fix color double-# prefix, use linear() for alpha channel opacity instead of no-op ensureAlpha(opacity), resolve actual post-transform dimensions, capture actual font name after fallback - og-generator: same font fallback fix — use actualFontFamily from loaded fonts - cache: remove redundant Buffer.from() on Redis data, unify getCached return to Uint8Array | null - image-processor: sort imports - CLAUDE.md: add language specifier to code fence Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 2 +- src/services/cache.ts | 6 +++--- src/services/image-processor.ts | 4 ++-- src/services/og-generator.ts | 13 ++++++++++--- src/services/transforms/crop.ts | 14 +++++++++++++- src/services/transforms/output.ts | 5 ++++- src/services/transforms/resize.ts | 18 ++++++++++-------- src/services/transforms/watermark.ts | 22 +++++++++++++++------- 8 files changed, 58 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b75af69..ec06172 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ bun run build # Build with tsdown ## Project Structure -``` +```text src/ ├── index.ts # Entry point — server setup, CORS, cache init, shutdown ├── config.ts # Env config with TypeBox schema validation diff --git a/src/services/cache.ts b/src/services/cache.ts index e6ecbde..9f90c0f 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -123,11 +123,11 @@ function redisKey(key: string): string { } // Redis cache operations -async function getRedisCached(key: string): Promise<Buffer | null> { +async function getRedisCached(key: string): Promise<Uint8Array | null> { if (!redisClient) return null; try { const data = await redisClient.getBuffer(redisKey(key)); - return data ? Buffer.from(data) : null; + return data ?? null; } catch (error) { console.error("Redis get error:", error); return null; @@ -211,7 +211,7 @@ function setMemoryCache(key: string, data: Buffer): void { } // Unified cache interface -export async function getCached(key: string): Promise<Buffer | null> { +export async function getCached(key: string): Promise<Uint8Array | null> { switch (config.cacheMode) { case "disk": return getDiskCached(key); diff --git a/src/services/image-processor.ts b/src/services/image-processor.ts index cb815a2..cf98625 100644 --- a/src/services/image-processor.ts +++ b/src/services/image-processor.ts @@ -2,12 +2,12 @@ import sharp from "sharp"; import { config } from "../config"; import type { ImageFormat, ImageParams } from "../types"; import { ImageProcessingError, ValidationError } from "../utils/errors"; +import { fetchImage } from "./image-fetcher"; import { applyAdjustments } from "./transforms/adjustments"; import { applyCrop } from "./transforms/crop"; import { applyOutputFormat } from "./transforms/output"; import { applyResize } from "./transforms/resize"; import { applyWatermark } from "./transforms/watermark"; -import { fetchImage } from "./image-fetcher"; export async function processImage( params: ImageParams, @@ -23,7 +23,7 @@ export async function processImage( // Transform pipeline: crop → resize → adjustments → watermark → output pipeline = applyCrop(pipeline, params); - pipeline = await applyResize(pipeline, params, imageBuffer, { + pipeline = await applyResize(pipeline, params, { maxWidth: config.maxWidth, maxHeight: config.maxHeight, }); diff --git a/src/services/og-generator.ts b/src/services/og-generator.ts index 123969b..96821e5 100644 --- a/src/services/og-generator.ts +++ b/src/services/og-generator.ts @@ -87,6 +87,9 @@ export async function generateOGImage(params: OGParams): Promise<Buffer> { throw new ImageProcessingError("Failed to load fonts"); } + // Use actual loaded font name in case of fallback (e.g., requested font failed, fell back to Inter) + const actualFontFamily = fonts[0]?.name || fontFamily; + let element: ElementNode; // Check for inline template config first (takes highest priority) @@ -105,14 +108,14 @@ export async function generateOGImage(params: OGParams): Promise<Buffer> { ); } - element = buildTemplateFromConfig(inlineConfig, params, fontFamily); + element = buildTemplateFromConfig(inlineConfig, params, actualFontFamily); } else { // Get template from loaded templates const templateName = params.template || "default"; const template = getCustomTemplate(templateName); if (template) { - element = buildTemplateFromConfig(template, params, fontFamily); + element = buildTemplateFromConfig(template, params, actualFontFamily); } else { // Fall back to default template if not found const defaultTemplate = getCustomTemplate("default"); @@ -121,7 +124,11 @@ export async function generateOGImage(params: OGParams): Promise<Buffer> { `Template "${templateName}" not found and no default template available`, ); } - element = buildTemplateFromConfig(defaultTemplate, params, fontFamily); + element = buildTemplateFromConfig( + defaultTemplate, + params, + actualFontFamily, + ); } } diff --git a/src/services/transforms/crop.ts b/src/services/transforms/crop.ts index 10f7fae..462575f 100644 --- a/src/services/transforms/crop.ts +++ b/src/services/transforms/crop.ts @@ -11,7 +11,6 @@ export function applyCrop( const parts = params.crop.split(",").map(Number); if ( parts.length !== 4 || - parts.some(Number.isNaN) || parts[0] === undefined || parts[1] === undefined || parts[2] === undefined || @@ -27,6 +26,19 @@ export function applyCrop( const width = parts[2]; const height = parts[3]; + if ( + !Number.isFinite(left) || + !Number.isFinite(top) || + !Number.isFinite(width) || + !Number.isFinite(height) || + !Number.isInteger(left) || + !Number.isInteger(top) || + !Number.isInteger(width) || + !Number.isInteger(height) + ) { + throw new ValidationError("Crop dimensions must be finite integers"); + } + if (left < 0 || top < 0 || width <= 0 || height <= 0) { throw new ValidationError("Invalid crop dimensions"); } diff --git a/src/services/transforms/output.ts b/src/services/transforms/output.ts index 83ba496..a5ca373 100644 --- a/src/services/transforms/output.ts +++ b/src/services/transforms/output.ts @@ -12,7 +12,10 @@ export function applyOutputFormat( case "avif": return pipeline.avif({ quality }); case "png": - return pipeline.png({ quality }); + // PNG quality only applies with palette mode; use compressionLevel for full-color PNGs + return pipeline.png({ + compressionLevel: Math.round(((100 - quality) / 100) * 9), + }); case "jpg": case "jpeg": return pipeline.jpeg({ quality, mozjpeg: true }); diff --git a/src/services/transforms/resize.ts b/src/services/transforms/resize.ts index 84c22f8..348c83b 100644 --- a/src/services/transforms/resize.ts +++ b/src/services/transforms/resize.ts @@ -1,4 +1,4 @@ -import sharp from "sharp"; +import type sharp from "sharp"; import type { ImageParams } from "../../types"; interface ResizeConfig { @@ -9,21 +9,23 @@ interface ResizeConfig { export async function applyResize( pipeline: sharp.Sharp, params: ImageParams, - imageBuffer: Buffer, resizeConfig: ResizeConfig, ): Promise<sharp.Sharp> { if (!params.size && !params.w && !params.h) return pipeline; if (params.size) { - // Size param: percentage (1-100) of original image size + // Size param: percentage (1-100) of current image dimensions // Skip resize entirely if size=100 if (params.size < 100) { - const metadata = await sharp(imageBuffer).metadata(); - const originalWidth = metadata.width || 0; - const originalHeight = metadata.height || 0; + // Resolve the pipeline to get actual post-transform dimensions + const { info } = await pipeline + .clone() + .toBuffer({ resolveWithObject: true }); + const currentWidth = info.width || 0; + const currentHeight = info.height || 0; const scale = Math.max(1, params.size) / 100; - const width = Math.round(originalWidth * scale); - const height = Math.round(originalHeight * scale); + const width = Math.max(1, Math.round(currentWidth * scale)); + const height = Math.max(1, Math.round(currentHeight * scale)); return pipeline.resize({ width, diff --git a/src/services/transforms/watermark.ts b/src/services/transforms/watermark.ts index 12ce3ef..4e76ccd 100644 --- a/src/services/transforms/watermark.ts +++ b/src/services/transforms/watermark.ts @@ -29,9 +29,10 @@ export async function applyWatermark( ): Promise<sharp.Sharp> { if (!params.wm_image && !params.wm_text) return pipeline; - const mainMetadata = await pipeline.clone().metadata(); - const mainWidth = mainMetadata.width || DEFAULT_FALLBACK_WIDTH; - const mainHeight = mainMetadata.height || DEFAULT_FALLBACK_HEIGHT; + // Get actual post-transform dimensions by resolving the pipeline + const { info } = await pipeline.clone().toBuffer({ resolveWithObject: true }); + const mainWidth = info.width || DEFAULT_FALLBACK_WIDTH; + const mainHeight = info.height || DEFAULT_FALLBACK_HEIGHT; let watermarkInput: Buffer; @@ -90,10 +91,15 @@ export async function applyWatermark( async function renderTextWatermark(params: ImageParams): Promise<Buffer> { const fontSize = params.wm_fontsize || DEFAULT_WATERMARK_FONT_SIZE; const fontFamily = params.wm_font || "Inter"; - const color = params.wm_color ? `#${params.wm_color}` : "#ffffff"; + const color = params.wm_color + ? params.wm_color.startsWith("#") + ? params.wm_color + : `#${params.wm_color}` + : "#ffffff"; const opacity = params.wm_opacity ?? 0.7; const fonts = await loadFontsForSatori(fontFamily, [400, 700]); + const actualFontFamily = fonts[0]?.name || fontFamily; const estimatedWidth = (params.wm_text as string).length * fontSize * TEXT_WIDTH_MULTIPLIER; @@ -104,7 +110,7 @@ async function renderTextWatermark(params: ImageParams): Promise<Buffer> { props: { style: { display: "flex", - fontFamily, + fontFamily: actualFontFamily, fontSize, color, opacity, @@ -153,10 +159,12 @@ async function renderImageWatermark( }); } - // Apply opacity + // Apply opacity by scaling the alpha channel directly if (params.wm_opacity !== undefined && params.wm_opacity < 1) { const opacity = Math.max(0, Math.min(1, params.wm_opacity)); - watermarkPipeline = watermarkPipeline.ensureAlpha(opacity); + watermarkPipeline = watermarkPipeline + .ensureAlpha() + .linear([1, 1, 1, opacity], [0, 0, 0, 0]); } return watermarkPipeline.toBuffer();