Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
28 changes: 14 additions & 14 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@
},
"devDependencies": {
"@biomejs/biome": "2.3.8",
"@types/bun": "^1.3.4"
"@types/bun": "^1.3.11"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},
"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"
}
}
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const CacheModeSchema = Type.Union([
Type.Literal("disk"),
Type.Literal("memory"),
Type.Literal("hybrid"),
Type.Literal("redis"),
Type.Literal("none"),
]);

Expand Down Expand Up @@ -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)
});
Expand Down Expand Up @@ -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),
};

Expand Down
61 changes: 60 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,12 +19,55 @@ 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({
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Requests without Origin/Referer headers bypass validation entirely.

Returning early when both headers are missing allows direct API calls (curl, server-side requests, tools like Postman) to bypass origin restrictions completely. If the intent is to restrict access to specific origins, this creates a bypass path.

If this is intentional (e.g., allowing server-to-server calls), consider documenting it. Otherwise, consider denying requests without a recognizable origin when allowedOrigins is configured.

🛡️ Proposed fix to deny requests without origin headers
-    if (!origin && !referer) return;
+    if (!origin && !referer) {
+      set.status = 403;
+      return { error: "FORBIDDEN", message: "Origin not allowed" };
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!origin && !referer) return;
if (!origin && !referer) {
set.status = 403;
return { error: "FORBIDDEN", message: "Origin not allowed" };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` at line 43, The early return on missing headers ("if (!origin
&& !referer) return;") lets requests without Origin/Referer bypass origin
checks; update the logic in the middleware handling origin/referer (the block
referencing origin, referer and allowedOrigins) to reject requests when both
headers are absent if allowedOrigins is configured (e.g., if allowedOrigins
exists and is non-empty, respond with a 403/forbidden), otherwise explicitly
allow and document the server-to-server allowance; replace the unconditional
return with a clear branch that either denies the request or documents/permits
it based on allowedOrigins.


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;
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Origin matching logic is inconsistent with URL vs hostname comparison.

The matching logic mixes URL and hostname comparisons:

  • Line 56: Exact string match (works for full URLs)
  • Line 60: parsed.hostname === allowed assumes allowed is a bare hostname

If ALLOWED_ORIGINS contains https://example.com, the hostname check ("example.com" === "https://example.com") will always fail. The subdomain check has the same issue.

Consider normalizing both sides to hostnames for consistent comparison:

♻️ Proposed fix for consistent hostname extraction
     const isAllowed = config.allowedOrigins.some((allowed) => {
       if (sourceOrigin === allowed) return true;
       try {
         const parsed = new URL(sourceOrigin);
+        // Normalize allowed to hostname (handle both "example.com" and "https://example.com")
+        let allowedHostname: string;
+        try {
+          allowedHostname = new URL(allowed).hostname;
+        } catch {
+          allowedHostname = allowed; // Treat as bare hostname
+        }
         return (
-          parsed.hostname === allowed || parsed.hostname.endsWith(`.${allowed}`)
+          parsed.hostname === allowedHostname || parsed.hostname.endsWith(`.${allowedHostname}`)
         );
       } catch {
         return sourceOrigin === allowed;
       }
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
});
const isAllowed = config.allowedOrigins.some((allowed) => {
if (sourceOrigin === allowed) return true;
try {
const parsed = new URL(sourceOrigin);
// Normalize allowed to hostname (handle both "example.com" and "https://example.com")
let allowedHostname: string;
try {
allowedHostname = new URL(allowed).hostname;
} catch {
allowedHostname = allowed; // Treat as bare hostname
}
return (
parsed.hostname === allowedHostname || parsed.hostname.endsWith(`.${allowedHostname}`)
);
} catch {
return sourceOrigin === allowed;
}
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 55 - 65, The origin check in isAllowed mixes
full-URL string equality with hostname comparisons leading to mismatches when
config.allowedOrigins contains full URLs (e.g., "https://example.com");
normalize both sides to hostnames before comparing: for each allowed entry in
config.allowedOrigins, attempt to parse it as a URL and use its hostname (fall
back to the raw string if parsing fails), then compare the request's
parsed.hostname (from sourceOrigin) to that normalized allowed hostname and use
=== or endsWith(`.${allowedHost}`) for subdomain checks; update the isAllowed
logic to derive allowedHost for each allowed entry and use that for the hostname
comparisons while keeping the original exact-match fallback for completely
unparseable values.


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") {
Expand Down Expand Up @@ -72,6 +119,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";
}
Expand Down Expand Up @@ -121,4 +172,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;
9 changes: 7 additions & 2 deletions src/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@ 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) {
status.status = "degraded";
}
}

// Check Redis connection health
if (config.cacheMode === "redis" && cacheStats.connected === false) {
status.status = "degraded";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

set.headers = {
"Cache-Control": "no-cache, no-store, must-revalidate",
};
Expand Down
Loading
Loading