@@ -5,6 +5,9 @@ import compression from "compression";
55import morgan from "morgan" ;
66import swaggerUi from "swagger-ui-express" ;
77import cookieParser from "cookie-parser" ;
8+ import path from "path" ;
9+ import { fileURLToPath } from "url" ;
10+ import { networkInterfaces } from "os" ;
811
912// Configuration
1013import { env } from "./config/env.js" ;
@@ -92,22 +95,56 @@ app.use(
9295// Specific routes below will have their own more restrictive limits
9396app . use ( "/api/" , rateLimiters . standard ) ;
9497
95- // CORS configuration
96- app . use (
97- cors ( {
98- origin : env . CORS_ORIGIN . split ( "," ) . map ( ( origin ) => origin . trim ( ) ) ,
99- credentials : true ,
100- methods : [ "GET" , "POST" , "PUT" , "PATCH" , "DELETE" , "OPTIONS" ] ,
101- allowedHeaders : [
102- "Content-Type" ,
103- "Authorization" ,
104- "x-csrf-token" ,
105- "cookie" ,
106- "set-cookie" ,
107- ] ,
108- exposedHeaders : [ "set-cookie" ] ,
109- } )
110- ) ;
98+ // CORS configuration - allow localhost and LAN access
99+ const corsOrigins = env . CORS_ORIGIN . split ( "," ) . map ( ( origin ) => origin . trim ( ) ) ;
100+
101+ // Add dynamic origin checking for LAN access
102+ const corsOptions = {
103+ origin : (
104+ origin : string | undefined ,
105+ callback : ( err : Error | null , allow ?: boolean ) => void
106+ ) => {
107+ // Allow requests with no origin (like mobile apps, Postman, etc.)
108+ if ( ! origin ) return callback ( null , true ) ;
109+
110+ // Check if origin matches any of our configured origins
111+ const isConfiguredOrigin = corsOrigins . some ( ( configuredOrigin ) => {
112+ if ( configuredOrigin === origin ) return true ;
113+ // Allow any localhost or LAN IP on our port
114+ if (
115+ configuredOrigin . includes ( "localhost" ) &&
116+ origin . includes ( "localhost" )
117+ )
118+ return true ;
119+ if ( configuredOrigin . includes ( ":3000" ) && origin . includes ( ":3000" ) ) {
120+ // Allow any IP address on port 3000 (LAN access)
121+ const lanIpRegex = / ^ h t t p s ? : \/ \/ ( [ 0 - 9 ] { 1 , 3 } \. ) { 3 } [ 0 - 9 ] { 1 , 3 } : 3 0 0 0 $ / ;
122+ return lanIpRegex . test ( origin ) ;
123+ }
124+ return false ;
125+ } ) ;
126+
127+ if ( isConfiguredOrigin ) {
128+ callback ( null , true ) ;
129+ } else {
130+ // Log the blocked origin for debugging
131+ logger . info ( `CORS blocked origin: ${ origin } ` ) ;
132+ callback ( new Error ( "Not allowed by CORS" ) ) ;
133+ }
134+ } ,
135+ credentials : true ,
136+ methods : [ "GET" , "POST" , "PUT" , "PATCH" , "DELETE" , "OPTIONS" ] ,
137+ allowedHeaders : [
138+ "Content-Type" ,
139+ "Authorization" ,
140+ "x-csrf-token" ,
141+ "cookie" ,
142+ "set-cookie" ,
143+ ] ,
144+ exposedHeaders : [ "set-cookie" ] ,
145+ } ;
146+
147+ app . use ( cors ( corsOptions ) ) ;
111148
112149// Compression - Reduce response size
113150app . use ( compression ( ) ) ;
@@ -213,6 +250,28 @@ app.get("/metrics", metricsHandler);
213250// CSRF token endpoint (for web clients)
214251app . get ( "/api/v1/csrf-token" , csrfTokenHandler ) ;
215252
253+ // Server info endpoint for LAN discovery
254+ app . get ( "/api/v1/server-info" , ( _req , res ) => {
255+ const interfaces = networkInterfaces ( ) ;
256+ const lanIps : string [ ] = [ ] ;
257+
258+ // Find all LAN IP addresses
259+ Object . values ( interfaces ) . forEach ( ( netInterface ) => {
260+ netInterface ?. forEach ( ( details ) => {
261+ if ( details . family === "IPv4" && ! details . internal ) {
262+ lanIps . push ( details . address ) ;
263+ }
264+ } ) ;
265+ } ) ;
266+
267+ res . json ( {
268+ port : env . PORT ,
269+ lanIps,
270+ urls : lanIps . map ( ( ip ) => `http://${ ip } :${ env . PORT } ` ) ,
271+ localhost : `http://localhost:${ env . PORT } ` ,
272+ } ) ;
273+ } ) ;
274+
216275// ────────────────────────────────────────────────────────────────────────────
217276// Health Check Routes (no versioning, no rate limiting)
218277// ────────────────────────────────────────────────────────────────────────────
@@ -249,6 +308,55 @@ app.use(`${API_V1}/comics`, csrfProtection, comicsRouter);
249308app . use ( `${ API_V1 } /search` , rateLimiters . search , searchRouter ) ; // GET requests, no CSRF needed
250309app . use ( `${ API_V1 } /bulk` , csrfProtection , bulkRouter ) ;
251310
311+ // ────────────────────────────────────────────────────────────────────────────
312+ // Static File Serving (Web App)
313+ // ────────────────────────────────────────────────────────────────────────────
314+
315+ // Get the current file directory for path resolution
316+ const __filename = fileURLToPath ( import . meta. url ) ;
317+ const __dirname = path . dirname ( __filename ) ;
318+
319+ // Path to the built web app static files
320+ // In development: ../../web/dist (relative to src/)
321+ // In production Docker: ../web/dist (relative to dist/)
322+ const webDistPath =
323+ process . env . NODE_ENV === "production"
324+ ? path . join ( __dirname , "../web/dist" )
325+ : path . join ( __dirname , "../../../web/dist" ) ;
326+
327+ // Serve static files from the web app build directory
328+ app . use (
329+ express . static ( webDistPath , {
330+ maxAge : env . NODE_ENV === "production" ? "1y" : "0" , // Cache for 1 year in production
331+ etag : true ,
332+ lastModified : true ,
333+ setHeaders : ( res , path ) => {
334+ // Set proper MIME types for JavaScript and CSS files
335+ if ( path . endsWith ( ".js" ) ) {
336+ res . setHeader ( "Content-Type" , "application/javascript" ) ;
337+ } else if ( path . endsWith ( ".css" ) ) {
338+ res . setHeader ( "Content-Type" , "text/css" ) ;
339+ }
340+ } ,
341+ } )
342+ ) ;
343+
344+ // Handle client-side routing - serve index.html for all non-API routes
345+ app . get ( "*" , ( req , res , next ) => {
346+ // Skip if this is an API route, health check, metrics, or docs
347+ if (
348+ req . path . startsWith ( "/api" ) ||
349+ req . path . startsWith ( "/health" ) ||
350+ req . path . startsWith ( "/metrics" ) ||
351+ req . path . startsWith ( "/api-docs" )
352+ ) {
353+ return next ( ) ;
354+ }
355+
356+ // Serve the web app index.html for all other routes (client-side routing)
357+ res . sendFile ( path . join ( webDistPath , "index.html" ) ) ;
358+ } ) ;
359+
252360// ────────────────────────────────────────────────────────────────────────────
253361// Error Handlers
254362// ────────────────────────────────────────────────────────────────────────────
@@ -268,17 +376,21 @@ async function startServer() {
268376 logger . info ( "Verifying database connection..." ) ;
269377 await verifyDatabaseConnection ( ) ;
270378
271- // Start the HTTP server
272- const server = app . listen ( env . PORT , ( ) => {
379+ // Start the HTTP server - bind to all interfaces for LAN access
380+ const server = app . listen ( env . PORT , "0.0.0.0" , ( ) => {
273381 logger . info ( `🚀 Server started successfully` ) ;
274382 logger . info ( ` Environment: ${ env . NODE_ENV } ` ) ;
275383 logger . info ( ` Port: ${ env . PORT } ` ) ;
384+ logger . info ( ` Web App: http://localhost:${ env . PORT } ` ) ;
276385 logger . info ( ` API: http://localhost:${ env . PORT } /api/v1` ) ;
277386 logger . info ( ` Docs: http://localhost:${ env . PORT } /api-docs` ) ;
278387 logger . info ( ` Health: http://localhost:${ env . PORT } /health` ) ;
279388 logger . info ( ` Metrics: http://localhost:${ env . PORT } /metrics` ) ;
280389 logger . info ( ` WebSocket: ws://localhost:${ env . PORT } /ws` ) ;
281390 logger . info ( ` CSRF Protection: Enabled` ) ;
391+ logger . info (
392+ ` 🌐 Server is accessible on LAN - check your local IP address`
393+ ) ;
282394 } ) ;
283395
284396 // Initialize WebSocket server
0 commit comments