11import express , { Request , Response } from 'express' ;
2+ import type { ParsedQs } from 'qs' ;
23import cors from 'cors' ;
3- import path from 'path' ;
4+ import path from 'node: path' ;
45import dotenv from 'dotenv' ;
56import helmet from 'helmet' ;
67import { 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.
3238app . 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+
4177app . use ( express . json ( ) ) ;
4278
4379// Input validation regex matching official GitHub username rules
4480const GITHUB_USERNAME_REGEX = / ^ [ a - z \d ] (?: [ a - z \d ] | - (? = [ a - z \d ] ) ) { 0 , 38 } $ / i;
4581const 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
4884const 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
100149app . 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