11import fs from 'fs/promises' ;
22import http from 'http' ;
33import path from 'path' ;
4+ import crypto from 'crypto' ;
45import rssParser from './rss-parser.js' ;
56import jsonToRss from './json-to-rss.js' ;
67
7- // RFC-compliant hostname pattern:
8- // - Must start and end with alphanumeric character
9- // - Can contain alphanumeric, hyphens, and dots in between
10- // - No consecutive dots, no leading/trailing dots or hyphens
11- const HOSTNAME_PATTERN = / ^ [ a - z A - Z 0 - 9 ] ( [ a - z A - Z 0 - 9 \- ] * [ a - z A - Z 0 - 9 ] ) ? ( \. [ a - z A - Z 0 - 9 ] ( [ a - z A - Z 0 - 9 \- ] * [ a - z A - Z 0 - 9 ] ) ? ) * $ / ;
12-
138class Server {
149 constructor ( ) {
1510 this . _cache = { } ;
@@ -18,6 +13,10 @@ class Server {
1813 }
1914 async init ( ) {
2015 await this . updateConfig ( ) ;
16+ // Warn if baseUrl is not set
17+ if ( ! this . config . baseUrl ) {
18+ console . warn ( 'WARNING: config.baseUrl is not set. RSS feed links will be omitted. Set baseUrl in config.json for production use.' ) ;
19+ }
2120 this . _http = http . createServer ( async ( req , res ) => {
2221 try {
2322 await this . handleRequest ( req , res ) ;
@@ -29,15 +28,12 @@ class Server {
2928 console . log ( `Server started listening on ${ this . config . port } ` ) ;
3029 }
3130 parseQuery ( req ) {
32- const queryString = req . url . split ( '?' ) [ 1 ] ?? '' ;
33- const query = { } ;
31+ const url = new URL ( req . url , 'http://localhost' ) ;
32+ const feeds = url . searchParams . get ( 'feeds' ) ;
33+ const query = { feeds : 'default' , limit : url . searchParams . get ( 'limit' ) , since : url . searchParams . get ( 'since' ) } ;
3434
35- queryString . split ( '&' ) . forEach ( pair => {
36- const [ key , value ] = pair . split ( '=' ) ;
37- query [ key ] = value ;
38- } ) ;
39- if ( ! query . feeds || ! this . config . feeds [ query . feeds ] ) {
40- query . feeds = 'default' ;
35+ if ( feeds && this . config . feeds [ feeds ] ) {
36+ query . feeds = feeds ;
4137 }
4238 return query ;
4339 }
@@ -53,31 +49,33 @@ class Server {
5349 return this . sendResponse ( res , { data : this . _metrics } ) ;
5450 }
5551 if ( req . method === 'GET' && req . url . startsWith ( '/api/news' ) ) {
56- return this . sendResponse ( res , { data : await this . getRSS ( query ) } ) ;
52+ const data = await this . getRSS ( query ) ;
53+ return this . sendResponseWithETag ( req , res , { data } ) ;
5754 }
5855 if ( req . method === 'GET' && req . url . startsWith ( '/api/rss' ) ) {
5956 const newsData = await this . getRSS ( query ) ;
60- // Determine base URL for RSS feed links
61- // RECOMMENDED: Set config.baseUrl in production for security and consistency
62- // Fallback uses Host header (sanitized) and protocol detection
63- // Note: Host header and X-Forwarded-Proto are client-controllable;
64- // config.baseUrl should be used in production environments
65- let baseUrl = this . config . baseUrl ;
66- if ( ! baseUrl ) {
67- const protocol = this . getProtocol ( req ) ;
68- baseUrl = `${ protocol } ://${ this . sanitizeHost ( req . headers . host ) } ` ;
69- }
70- const rssXml = jsonToRss . toRSS ( newsData , {
57+ const rssXml = jsonToRss . toRSS ( newsData . items , {
7158 title : this . config . name || 'Newsflash' ,
7259 description : 'Aggregated news feed' ,
73- link : baseUrl
60+ link : this . config . baseUrl || ''
7461 } ) ;
75- return this . sendResponse ( res , { contentType : 'application/rss+xml' , data : rssXml } ) ;
62+ return this . sendResponseWithETag ( req , res , { contentType : 'application/rss+xml' , data : rssXml } ) ;
7663 }
7764 await this . serveStatic ( req , res ) ;
7865 }
7966 async updateConfig ( ) {
80- this . config = JSON . parse ( await fs . readFile ( './config.json' ) )
67+ try {
68+ const configData = await fs . readFile ( './config.json' , 'utf-8' ) ;
69+ this . config = JSON . parse ( configData ) ;
70+ } catch ( e ) {
71+ if ( ! this . config ) {
72+ // First load failed - exit with clear error message
73+ console . error ( 'ERROR: Failed to load config.json:' , e . message ) ;
74+ process . exit ( 1 ) ;
75+ }
76+ // Keep previous config on subsequent failures
77+ console . error ( 'ERROR: Failed to reload config.json, keeping previous configuration:' , e . message ) ;
78+ }
8179 }
8280 async getRSS ( query ) {
8381 await this . updateConfig ( ) ;
@@ -88,25 +86,40 @@ class Server {
8886 return this . _applyFilters ( cached . data , query )
8987 }
9088 const results = [ ] ;
91- await Promise . allSettled ( this . config . feeds [ query . feeds ] . map ( f => {
89+ const errors = [ ] ;
90+ let timedOut = false ;
91+
92+ // Cap total aggregation time
93+ const aggregationTimeout = ( this . config . aggregationTimeout || 30 ) * 1000 ;
94+ const fetchPromise = Promise . allSettled ( this . config . feeds [ query . feeds ] . map ( f => {
9295 return new Promise ( async ( resolve , reject ) => {
93- const t = setTimeout ( ( ) => {
94- console . log ( `RSS load timeout for ${ f . feed } ` ) ;
96+ let timeoutHandle = setTimeout ( ( ) => {
97+ if ( ! timedOut ) {
98+ const msg = `RSS load timeout for ${ f . feed } ` ;
99+ console . log ( msg ) ;
100+ errors . push ( { feed : f . name || f . feed , message : 'Request timeout' } ) ;
101+ }
95102 reject ( ) ;
96- } , this . config . timeout ) ;
103+ } , this . config . timeout || 10000 ) ;
97104 const fetchStart = Date . now ( ) ;
98105 try {
99106 const d = await rssParser . parse ( f . feed ) ;
100- clearTimeout ( t ) ;
101- if ( d . items ) results . push ( ...d . items . map ( i => Object . assign ( i , { feed : f . name ?? this . generateTitle ( d ) , type : f . type ?? 'news' } ) ) ) ;
107+ clearTimeout ( timeoutHandle ) ;
108+ if ( ! timedOut && d . items ) {
109+ results . push ( ...d . items . map ( i => Object . assign ( i , { feed : f . name ?? this . generateTitle ( d ) , type : f . type ?? 'news' } ) ) ) ;
110+ }
102111 this . _metrics [ f . feed ] = Object . assign ( this . _metrics [ f . feed ] || { } , {
103112 lastFetchMs : Date . now ( ) - fetchStart ,
104113 lastSuccess : Date . now ( ) ,
105114 failureCount : ( this . _metrics [ f . feed ] ?. failureCount ) || 0
106115 } ) ;
107116 } catch ( e ) {
108- console . log ( `Failed to parse ${ f . feed } ` , e . errno ) ;
109- console . log ( e ) ;
117+ clearTimeout ( timeoutHandle ) ;
118+ if ( ! timedOut ) {
119+ console . log ( `Failed to parse ${ f . feed } ` , e . errno ) ;
120+ console . log ( e ) ;
121+ errors . push ( { feed : f . name || f . feed , message : e . message || 'Parse error' } ) ;
122+ }
110123 this . _metrics [ f . feed ] = Object . assign ( this . _metrics [ f . feed ] || { } , {
111124 lastFetchMs : Date . now ( ) - fetchStart ,
112125 lastFailure : Date . now ( ) ,
@@ -116,75 +129,49 @@ class Server {
116129 resolve ( ) ;
117130 } ) ;
118131 } ) ) ;
119- const allData = results
132+
133+ // Race against aggregation timeout
134+ let aggregationTimeoutHandle ;
135+ const timeoutPromise = new Promise ( ( resolve ) => {
136+ aggregationTimeoutHandle = setTimeout ( ( ) => {
137+ timedOut = true ;
138+ errors . push ( { feed : 'aggregation' , message : `Aggregation timeout after ${ this . config . aggregationTimeout || 30 } s` } ) ;
139+ resolve ( ) ;
140+ } , aggregationTimeout ) ;
141+ } ) ;
142+
143+ await Promise . race ( [ fetchPromise , timeoutPromise ] ) ;
144+
145+ // Clear aggregation timeout if fetch finished first
146+ if ( aggregationTimeoutHandle ) {
147+ clearTimeout ( aggregationTimeoutHandle ) ;
148+ }
149+
150+ // Clone results and errors to prevent post-response mutations
151+ const items = [ ...results ]
120152 . sort ( ( a , b ) => {
121153 if ( a . created < b . created ) return 1 ;
122154 if ( a . created > b . created ) return - 1 ;
123155 return 0 ;
124156 } ) ;
125- this . _cache [ cacheKey ] = { time : Date . now ( ) , data : allData }
126- return this . _applyFilters ( allData , query )
157+ const data = { items, errors : [ ...errors ] } ;
158+ this . _cache [ cacheKey ] = { time : Date . now ( ) , data }
159+ return this . _applyFilters ( data , query )
127160 }
128161 _applyFilters ( data , query ) {
129162 const parsedLimit = parseInt ( query . limit ) ;
130163 const limit = ( Number . isInteger ( parsedLimit ) && parsedLimit > 0 ) ? Math . min ( parsedLimit , 100 ) : 100 ;
131164 const parsedSince = parseInt ( query . since ) ;
132165 const since = ( Number . isInteger ( parsedSince ) && parsedSince > 0 ) ? parsedSince : null ;
133- let result = data ;
166+ let filteredItems = data . items ;
134167 if ( since ) {
135- result = result . filter ( i => i . created > since ) ;
168+ filteredItems = filteredItems . filter ( i => i . created > since ) ;
136169 }
137- return result . slice ( 0 , limit ) ;
170+ return { items : filteredItems . slice ( 0 , limit ) , errors : data . errors } ;
138171 }
139172 generateTitle ( data ) {
140173 return data . title . split ( / [ ^ A - Z a - z 0 - 9 \s ] / ) [ 0 ] . trim ( ) ;
141174 }
142- getProtocol ( req ) {
143- // Detect protocol from request (for proper RSS feed URLs)
144- // Note: X-Forwarded-Proto is trusted by default, which is standard for proxy deployments
145- // For production use, set config.baseUrl instead of relying on headers
146- // Check X-Forwarded-Proto header (set by proxies/load balancers)
147- const forwardedProto = req . headers [ 'x-forwarded-proto' ] ;
148- if ( forwardedProto === 'https' ) {
149- return 'https' ;
150- }
151- // Check if connection is encrypted (direct HTTPS)
152- if ( req . socket && req . socket . encrypted ) {
153- return 'https' ;
154- }
155- // Default to http
156- return 'http' ;
157- }
158- sanitizeHost ( host ) {
159- // Validate and sanitize Host header to prevent injection attacks
160- if ( ! host || typeof host !== 'string' ) {
161- return `localhost:${ this . config . port } ` ;
162- }
163-
164- // Reject hosts with multiple colons (invalid format)
165- const colonCount = ( host . match ( / : / g) || [ ] ) . length ;
166- if ( colonCount > 1 ) {
167- return `localhost:${ this . config . port } ` ;
168- }
169-
170- // Split hostname and port
171- const [ hostname , portStr ] = host . split ( ':' ) ;
172-
173- // Validate hostname using RFC-compliant pattern
174- if ( ! HOSTNAME_PATTERN . test ( hostname ) ) {
175- return `localhost:${ this . config . port } ` ;
176- }
177-
178- // Validate port if specified
179- if ( portStr !== undefined ) {
180- const port = parseInt ( portStr , 10 ) ;
181- if ( isNaN ( port ) || port < 1 || port > 65535 ) {
182- return `localhost:${ this . config . port } ` ;
183- }
184- }
185-
186- return host ;
187- }
188175 async serveStatic ( req , res ) {
189176 const [ url ] = req . url . split ( '?' ) ;
190177 const filePath = url === '/' ? '/index.html' : url ;
@@ -196,9 +183,42 @@ class Server {
196183 const fileExt = resolvedPath . slice ( resolvedPath . lastIndexOf ( '.' ) + 1 ) ;
197184 this . sendResponse ( res , { contentType : this . extToMime ( fileExt ) , data : await fs . readFile ( resolvedPath ) } ) ;
198185 }
199- sendResponse ( res , { statusCode= 200 , contentType= 'application/json' , data } ) {
200- res . writeHead ( statusCode , { 'Content-Type' : contentType } ) ;
201- res . end ( contentType === 'application/json' ? JSON . stringify ( data ) : data , 'utf-8' ) ;
186+ sendResponse ( res , { statusCode= 200 , contentType= 'application/json' , data, headers= { } , serializedBody= null } ) {
187+ const baseHeaders = {
188+ 'Content-Type' : contentType ,
189+ 'X-Content-Type-Options' : 'nosniff' ,
190+ 'Referrer-Policy' : 'strict-origin-when-cross-origin' ,
191+ ...headers
192+ } ;
193+ res . writeHead ( statusCode , baseHeaders ) ;
194+ const body = serializedBody !== null ? serializedBody : ( contentType === 'application/json' ? JSON . stringify ( data ) : data ) ;
195+ res . end ( body , 'utf-8' ) ;
196+ }
197+ sendResponseWithETag ( req , res , { statusCode= 200 , contentType= 'application/json' , data } ) {
198+ // Serialize body once for both ETag and response
199+ const body = contentType === 'application/json' ? JSON . stringify ( data ) : data ;
200+ const etag = '"' + crypto . createHash ( 'md5' ) . update ( body ) . digest ( 'hex' ) . substring ( 0 , 16 ) + '"' ;
201+
202+ // Check If-None-Match header
203+ const ifNoneMatch = req . headers [ 'if-none-match' ] ;
204+ if ( ifNoneMatch === etag ) {
205+ res . writeHead ( 304 , {
206+ 'ETag' : etag ,
207+ 'X-Content-Type-Options' : 'nosniff' ,
208+ 'Referrer-Policy' : 'strict-origin-when-cross-origin'
209+ } ) ;
210+ res . end ( ) ;
211+ return ;
212+ }
213+
214+ // Send response with ETag using pre-serialized body
215+ this . sendResponse ( res , {
216+ statusCode,
217+ contentType,
218+ data,
219+ headers : { 'ETag' : etag } ,
220+ serializedBody : body
221+ } ) ;
202222 }
203223 extToMime ( ext ) {
204224 switch ( ext ) {
0 commit comments