Skip to content

Commit d104f11

Browse files
committed
feat: add Docker health check, implement strict CSP policies, and configure route-specific CORS settings
1 parent a2e589a commit d104f11

2 files changed

Lines changed: 81 additions & 23 deletions

File tree

Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ VOLUME /usr/src/app/data
5454
# The entrypoint script runs as root to fix mounted-volume permissions,
5555
# then drops privileges to 'node' via su-exec before starting the app.
5656

57+
# Health check — uses wget (built into Alpine) to probe the /health endpoint.
58+
# --start-period gives the app time to initialize SQLite before probes begin.
59+
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
60+
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
61+
5762
EXPOSE 3000
5863

5964
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
60-

src/server.ts

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import express, { Request, Response } from 'express';
2+
import type { ParsedQs } from 'qs';
23
import cors from 'cors';
3-
import path from 'path';
4+
import path from 'node:path';
45
import dotenv from 'dotenv';
56
import helmet from 'helmet';
67
import { rateLimit } from 'express-rate-limit';
@@ -22,29 +23,64 @@ if (trustProxy === 'true') {
2223
app.set('trust proxy', true);
2324
} else if (trustProxy === 'false') {
2425
app.set('trust proxy', false);
25-
} else if (!isNaN(Number(trustProxy))) {
26+
} else if (!Number.isNaN(Number(trustProxy))) {
2627
app.set('trust proxy', Number(trustProxy));
2728
} else {
2829
app.set('trust proxy', trustProxy);
2930
}
3031

3132
// Helmet security headers (configured for card embedding support)
33+
// CSP fetch directives are enabled with a strict policy.
34+
// SVG cards are served as image/svg+xml and embedded via <img> tags, so they
35+
// are sandboxed by the browser regardless of CSP — no script or fetch
36+
// directives are needed for the cards themselves.
37+
// The policy below protects the HTML index page served by express.static.
3238
app.use(
3339
helmet({
34-
contentSecurityPolicy: false, // Disable CSP to allow SVG inline styles/fonts
40+
contentSecurityPolicy: {
41+
directives: {
42+
defaultSrc: ["'self'"],
43+
scriptSrc: ["'self'"],
44+
styleSrc: ["'self'", "'unsafe-inline'"], // inline styles needed for SVG previews on the index page
45+
imgSrc: ["'self'", 'data:'], // data: URIs used in SVG <image> elements
46+
fontSrc: ["'self'"],
47+
connectSrc: ["'none'"], // no client-side fetch/XHR allowed
48+
objectSrc: ["'none'"],
49+
frameSrc: ["'none'"],
50+
baseUri: ["'self'"],
51+
formAction: ["'none'"]
52+
}
53+
},
3554
crossOriginResourcePolicy: { policy: 'cross-origin' }, // Allow embedding on external sites (like GitHub)
3655
crossOriginEmbedderPolicy: false
3756
})
3857
);
3958

40-
app.use(cors());
59+
// CORS: public card endpoints must be embeddable from any origin (GitHub READMEs, etc.).
60+
// Metrics endpoints are intentionally excluded — they require a server-side API key
61+
// and should never be called cross-origin from a browser.
62+
const publicCardsCors = cors({
63+
origin: '*', // SVG cards are public read-only resources
64+
methods: ['GET'], // only GET is needed; no mutations
65+
allowedHeaders: [], // no custom request headers required
66+
exposedHeaders: ['Cache-Control', 'Content-Type'],
67+
credentials: false // credentials (cookies/auth) are never used with wildcard origin
68+
});
69+
70+
app.use('/api/stats', publicCardsCors);
71+
app.use('/api/languages', publicCardsCors);
72+
app.use('/api/repo', publicCardsCors);
73+
app.use('/api/rank', publicCardsCors);
74+
// /api/metrics routes intentionally have no CORS middleware → browser cross-origin
75+
// requests are blocked by default (same-origin policy), which is the desired behaviour.
76+
4177
app.use(express.json());
4278

4379
// Input validation regex matching official GitHub username rules
4480
const GITHUB_USERNAME_REGEX = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
4581
const GITHUB_REPO_REGEX = /^[a-z\d-_.]{1,100}$/i;
4682

47-
// Rate limiting to prevent Abuse, DoS, and GitHub API token exhaustion
83+
// Rate limiting to prevent abuse, DoS, and GitHub API token exhaustion
4884
const apiLimiter = rateLimit({
4985
windowMs: 15 * 60 * 1000, // 15 minutes
5086
max: 100, // Limit each IP to 100 requests per window
@@ -85,17 +121,30 @@ function renderErrorCard(message: string): string {
85121
}
86122

87123
// Helper to extract custom styling overrides from URL query params
88-
function extractThemeOverrides(query: any): Record<string, string> {
124+
function extractThemeOverrides(query: ParsedQs): Record<string, string> {
89125
const overrides: Record<string, string> = {};
90126
const keys = ['bg', 'text', 'title', 'accent', 'secondary', 'border', 'bgGradient'];
91127
for (const key of keys) {
92-
if (query[key] && typeof query[key] === 'string') {
93-
overrides[key] = query[key];
128+
const value = query[key];
129+
if (typeof value === 'string') {
130+
overrides[key] = value;
94131
}
95132
}
96133
return overrides;
97134
}
98135

136+
// ─── Health Check ──────────────────────────────────────────────────────────
137+
// Registered BEFORE the rate limiter so it is never throttled.
138+
// Used by Docker HEALTHCHECK, Coolify, Traefik and Caddy liveness probes.
139+
app.get('/health', (_req: Request, res: Response) => {
140+
res.status(200).json({
141+
status: 'ok',
142+
version: process.env.npm_package_version || '1.0.0',
143+
uptime: Math.floor(process.uptime()),
144+
environment: process.env.NODE_ENV || 'development'
145+
});
146+
});
147+
99148
// Route for General Stats Card SVG
100149
app.get('/api/stats', async (req: Request, res: Response) => {
101150
const { username, theme } = req.query;
@@ -120,10 +169,11 @@ app.get('/api/stats', async (req: Request, res: Response) => {
120169
ip: req.ip
121170
});
122171
return res.status(200).send(svg);
123-
} catch (error: any) {
172+
} catch (error: unknown) {
173+
const message = error instanceof Error ? error.message : 'Error desconocido';
124174
console.error(`Error in /api/stats for ${username}:`, error);
125175
res.setHeader('Content-Type', 'image/svg+xml');
126-
return res.status(500).send(renderErrorCard(error.message || 'Error al obtener datos'));
176+
return res.status(500).send(renderErrorCard(message || 'Error al obtener datos'));
127177
}
128178
});
129179

@@ -151,10 +201,11 @@ app.get('/api/languages', async (req: Request, res: Response) => {
151201
ip: req.ip
152202
});
153203
return res.status(200).send(svg);
154-
} catch (error: any) {
204+
} catch (error: unknown) {
205+
const message = error instanceof Error ? error.message : 'Error desconocido';
155206
console.error(`Error in /api/languages for ${username}:`, error);
156207
res.setHeader('Content-Type', 'image/svg+xml');
157-
return res.status(500).send(renderErrorCard(error.message || 'Error al obtener datos'));
208+
return res.status(500).send(renderErrorCard(message || 'Error al obtener datos'));
158209
}
159210
});
160211

@@ -187,12 +238,13 @@ app.get('/api/repo', async (req: Request, res: Response) => {
187238
ip: req.ip
188239
});
189240
return res.status(200).send(svg);
190-
} catch (error: any) {
241+
} catch (error: unknown) {
242+
const message = error instanceof Error ? error.message : 'Error desconocido';
191243
console.error(`Error in /api/repo for ${username}/${repo || 'featured'}:`, error);
192244
res.setHeader('Content-Type', 'image/svg+xml');
193245
return res
194246
.status(500)
195-
.send(renderErrorCard(error.message || 'Error al obtener datos del repositorio'));
247+
.send(renderErrorCard(message || 'Error al obtener datos del repositorio'));
196248
}
197249
});
198250

@@ -220,10 +272,11 @@ app.get('/api/rank', async (req: Request, res: Response) => {
220272
ip: req.ip
221273
});
222274
return res.status(200).send(svg);
223-
} catch (error: any) {
275+
} catch (error: unknown) {
276+
const message = error instanceof Error ? error.message : 'Error desconocido';
224277
console.error(`Error in /api/rank for ${username}:`, error);
225278
res.setHeader('Content-Type', 'image/svg+xml');
226-
return res.status(500).send(renderErrorCard(error.message || 'Error al obtener datos'));
279+
return res.status(500).send(renderErrorCard(message || 'Error al obtener datos'));
227280
}
228281
});
229282

@@ -246,29 +299,30 @@ function checkMetricsKey(req: Request, res: Response, next: () => void) {
246299
if (providedKey !== expectedKey) {
247300
return res
248301
.status(401)
249-
.json({ error: 'Acceso no autorizado. Se requiere una clave de metrica valida.' });
302+
.json({ error: 'Acceso no autorizado. Se requiere una clave de métrica válida.' });
250303
}
251304

252305
next();
253306
}
254307

255308
// Route to get persisted metrics
256-
app.get('/api/metrics', checkMetricsKey, (req: Request, res: Response) => {
309+
app.get('/api/metrics', checkMetricsKey, (_req: Request, res: Response) => {
257310
return res.status(200).json(getMetrics());
258311
});
259312

260313
// Route to get detailed user metrics
261-
app.get('/api/metrics/users', checkMetricsKey, async (req: Request, res: Response) => {
314+
app.get('/api/metrics/users', checkMetricsKey, async (_req: Request, res: Response) => {
262315
try {
263316
const userMetrics = await getAllUserMetrics();
264317
return res.status(200).json(userMetrics);
265-
} catch (error: any) {
266-
return res.status(500).json({ error: error.message });
318+
} catch (error: unknown) {
319+
const message = error instanceof Error ? error.message : 'Error desconocido';
320+
return res.status(500).json({ error: message });
267321
}
268322
});
269323

270324
// Catch-all route to serve the frontend (index.html)
271-
app.get('*', (req, res) => {
325+
app.get('*', (_req, res) => {
272326
res.sendFile(path.join(__dirname, '../public/index.html'));
273327
});
274328

0 commit comments

Comments
 (0)