@@ -79,42 +79,41 @@ function parseStatusFeed($feedUrl, $filter = null) {
7979 throw new Exception ('Failed to fetch feed ' );
8080 }
8181
82- // Suppress XML errors and parse securely
83- libxml_use_internal_errors (true );
84- // Disable external entity processing to prevent XXE attacks
85- libxml_disable_entity_loader (true );
86- // Parse XML without entity substitution (secure by default)
87- // Do not use LIBXML_NOENT as it enables external entity substitution
88- $ xml = simplexml_load_string ($ feedContent , 'SimpleXMLElement ' );
89- libxml_clear_errors ();
90-
91- if ($ xml === false ) {
92- throw new Exception ('Failed to parse XML ' );
93- }
94-
95- $ status = [
96- 'indicator ' => 'none ' ,
97- 'description ' => 'All Systems Operational '
98- ];
99-
100- // Check if it's an Atom feed
101- if (isset ($ xml ->entry )) {
102- // If filter provided, find matching entry
103- $ latestEntry = null ;
104- if ($ filter !== null ) {
82+ // Delegate to string parser for consistent behavior and testing
83+ return parseStatusFeedFromString ($ feedContent , $ filter );
84+ // Choose the most recent relevant entry: prefer the newest matching filter if provided
85+ $ latestEntry = null ;
86+ $ latestEntryTimestamp = 0 ;
10587 foreach ($ xml ->entry as $ entry ) {
10688 $ entryTitle = isset ($ entry ->title ) ? (string )$ entry ->title : '' ;
107- if (stripos ($ entryTitle , $ filter ) !== false ) {
89+ if ($ filter !== null && stripos ($ entryTitle , $ filter ) === false ) {
90+ continue ; // skip non-matching entries
91+ }
92+
93+ // Determine entry timestamp preference: updated > published
94+ $ entryDateCandidate = null ;
95+ if (isset ($ entry ->updated )) {
96+ $ entryDateCandidate = strtotime ((string )$ entry ->updated );
97+ } elseif (isset ($ entry ->published )) {
98+ $ entryDateCandidate = strtotime ((string )$ entry ->published );
99+ }
100+
101+ // Prefer entries with a valid timestamp; if none have timestamps, fallback to first matching entry
102+ $ timestampValue = $ entryDateCandidate ? $ entryDateCandidate : 0 ;
103+ if ($ timestampValue > $ latestEntryTimestamp ) {
104+ $ latestEntryTimestamp = $ timestampValue ;
108105 $ latestEntry = $ entry ;
109- break ;
106+ } elseif ($ latestEntry === null && $ filter === null ) {
107+ // When no filter, ensure at least the first entry is selected even without timestamps
108+ $ latestEntry = $ xml ->entry [0 ];
110109 }
111110 }
112- // If no matching entry, return operational
113- if ($ latestEntry === null ) {
111+ // If no matching entry found (when filter used) , return operational
112+ if ($ filter !== null && $ latestEntry === null ) {
114113 return $ status ;
115114 }
116- }
117- $ latestEntry = $ latestEntry ?? $ xml ->entry [0 ];
115+ // If we couldn't find a timestamped entry and latestEntry is still null, fallback to first entry
116+ $ latestEntry = $ latestEntry ?? $ xml ->entry [0 ];
118117
119118 $ title = isset ($ latestEntry ->title ) ? (string )$ latestEntry ->title : '' ;
120119 $ content = isset ($ latestEntry ->content ) ? (string )$ latestEntry ->content : '' ;
@@ -125,7 +124,7 @@ function parseStatusFeed($feedUrl, $filter = null) {
125124 $ content = preg_replace ('/<!\[CDATA\[(.*?)\]\]>/s ' , '$1 ' , $ content );
126125 $ summary = preg_replace ('/<!\[CDATA\[(.*?)\]\]>/s ' , '$1 ' , $ summary );
127126
128- // Get entry timestamp
127+ // Get entry timestamp (prefer updated -> published)
129128 $ entryDate = null ;
130129 if (isset ($ latestEntry ->updated )) {
131130 $ entryDate = strtotime ((string )$ latestEntry ->updated );
@@ -139,8 +138,9 @@ function parseStatusFeed($feedUrl, $filter = null) {
139138 // For Brevo and similar feeds, prefer title only
140139 $ description = !empty ($ title ) ? $ title : (!empty ($ content ) ? $ content : $ summary );
141140
142- // Only show status if entry is within 24 hours, otherwise show operational
143- if (!$ isRecent || preg_match ('/operational|resolved|completed|fixed|normal/i ' , $ title )) {
141+ // Only show status if entry is within 24 hours and not resolved; otherwise show operational
142+ $ fullText = $ title . ' ' . $ content . ' ' . $ summary ;
143+ if (!$ isRecent || preg_match ('/operational|resolved|completed|fixed|normal/i ' , $ fullText )) {
144144 $ status ['indicator ' ] = 'none ' ;
145145 $ status ['description ' ] = 'All Systems Operational ' ;
146146 } elseif (preg_match ('/outage|down|major|critical|offline/i ' , $ title )) {
@@ -155,30 +155,46 @@ function parseStatusFeed($feedUrl, $filter = null) {
155155 if ($ status ['indicator ' ] !== 'none ' && $ status ['indicator ' ] !== 'major ' && $ status ['indicator ' ] !== 'minor ' ) {
156156 $ status ['description ' ] = sanitizeFeedText ($ title );
157157 }
158- }
158+
159159 // Check if it's an RSS feed
160160 elseif (isset ($ xml ->channel ->item )) {
161- // If filter provided, find matching item
161+ // Choose the most recent relevant item: prefer the newest matching filter if provided
162162 $ latestItem = null ;
163- if ($ filter !== null ) {
164- foreach ($ xml ->channel ->item as $ item ) {
165- $ itemTitle = isset ($ item ->title ) ? (string )$ item ->title : '' ;
166- if (stripos ($ itemTitle , $ filter ) !== false ) {
167- $ latestItem = $ item ;
168- break ;
169- }
163+ $ latestItemTimestamp = 0 ;
164+ foreach ($ xml ->channel ->item as $ item ) {
165+ $ itemTitle = isset ($ item ->title ) ? (string )$ item ->title : '' ;
166+ if ($ filter !== null && stripos ($ itemTitle , $ filter ) === false ) {
167+ continue ; // skip non-matching items
170168 }
171- // If no matching item, return operational
172- if ($ latestItem === null ) {
173- return $ status ;
169+
170+ // Determine item timestamp preference: pubDate > dc:date
171+ $ itemDateCandidate = null ;
172+ if (isset ($ item ->pubDate )) {
173+ $ itemDateCandidate = strtotime ((string )$ item ->pubDate );
174+ } elseif (isset ($ item ->children ('http://purl.org/dc/elements/1.1/ ' )->date )) {
175+ $ itemDateCandidate = strtotime ((string )$ item ->children ('http://purl.org/dc/elements/1.1/ ' )->date );
174176 }
177+
178+ $ timestampValue = $ itemDateCandidate ? $ itemDateCandidate : 0 ;
179+ if ($ timestampValue > $ latestItemTimestamp ) {
180+ $ latestItemTimestamp = $ timestampValue ;
181+ $ latestItem = $ item ;
182+ } elseif ($ latestItem === null && $ filter === null ) {
183+ // When no filter, ensure at least the first item is selected even without timestamps
184+ $ latestItem = $ xml ->channel ->item [0 ];
185+ }
186+ }
187+ // If no matching item found (when filter used), return operational
188+ if ($ filter !== null && $ latestItem === null ) {
189+ return $ status ;
175190 }
191+ // Fallback to first item if none selected
176192 $ latestItem = $ latestItem ?? $ xml ->channel ->item [0 ];
177193
178194 $ title = isset ($ latestItem ->title ) ? (string )$ latestItem ->title : '' ;
179195 $ description = isset ($ latestItem ->description ) ? (string )$ latestItem ->description : '' ;
180196
181- // Get item timestamp
197+ // Get item timestamp (prefer pubDate -> dc:date)
182198 $ itemDate = null ;
183199 if (isset ($ latestItem ->pubDate )) {
184200 $ itemDate = strtotime ((string )$ latestItem ->pubDate );
@@ -189,8 +205,9 @@ function parseStatusFeed($feedUrl, $filter = null) {
189205 // Check if item is within 24 hours (86400 seconds)
190206 $ isRecent = ($ itemDate && (time () - $ itemDate ) <= 86400 );
191207
192- // Only show status if item is within 24 hours, otherwise show operational
193- if (!$ isRecent || preg_match ('/operational|resolved|completed|fixed|normal/i ' , $ title )) {
208+ // Only show status if item is within 24 hours and not resolved; otherwise show operational
209+ $ fullText = $ title . ' ' . $ description ;
210+ if (!$ isRecent || preg_match ('/operational|resolved|completed|fixed|normal/i ' , $ fullText )) {
194211 $ status ['indicator ' ] = 'none ' ;
195212 $ status ['description ' ] = 'All Systems Operational ' ;
196213 } elseif (preg_match ('/outage|down|major|critical|offline/i ' , $ title )) {
@@ -220,6 +237,163 @@ function parseStatusFeed($feedUrl, $filter = null) {
220237 }
221238}
222239
240+ /**
241+ * Parse feed content directly from XML string (for testing/validation)
242+ */
243+ function parseStatusFeedFromString ($ feedContent , $ filter = null ) {
244+ try {
245+ libxml_use_internal_errors (true );
246+ $ xml = simplexml_load_string ($ feedContent , 'SimpleXMLElement ' );
247+ libxml_clear_errors ();
248+ if ($ xml === false ) {
249+ throw new Exception ('Failed to parse XML ' );
250+ }
251+
252+ // Use the same parsing branch as parseStatusFeed
253+ return parseStatusFeedFromXmlObject ($ xml , $ filter );
254+ } catch (Exception $ e ) {
255+ return [
256+ 'indicator ' => 'major ' ,
257+ 'description ' => 'Unable to fetch status '
258+ ];
259+ }
260+ }
261+
262+ /**
263+ * Core parsing logic extracted to separate function to avoid duplication
264+ */
265+ function parseStatusFeedFromXmlObject ($ xml , $ filter = null ) {
266+ $ status = [
267+ 'indicator ' => 'none ' ,
268+ 'description ' => 'All Systems Operational '
269+ ];
270+
271+ // Atom feed handling
272+ if (isset ($ xml ->entry )) {
273+ // Choose most recent matching entry
274+ $ latestEntry = null ;
275+ $ latestEntryTimestamp = 0 ;
276+ foreach ($ xml ->entry as $ entry ) {
277+ $ entryTitle = isset ($ entry ->title ) ? (string )$ entry ->title : '' ;
278+ if ($ filter !== null && stripos ($ entryTitle , $ filter ) === false ) {
279+ continue ;
280+ }
281+ $ entryDateCandidate = null ;
282+ if (isset ($ entry ->updated )) {
283+ $ entryDateCandidate = strtotime ((string )$ entry ->updated );
284+ } elseif (isset ($ entry ->published )) {
285+ $ entryDateCandidate = strtotime ((string )$ entry ->published );
286+ }
287+ $ timestampValue = $ entryDateCandidate ? $ entryDateCandidate : 0 ;
288+ if ($ timestampValue > $ latestEntryTimestamp ) {
289+ $ latestEntryTimestamp = $ timestampValue ;
290+ $ latestEntry = $ entry ;
291+ }
292+ }
293+ if ($ filter !== null && $ latestEntry === null ) {
294+ return $ status ;
295+ }
296+ $ latestEntry = $ latestEntry ?? $ xml ->entry [0 ];
297+
298+ $ title = isset ($ latestEntry ->title ) ? (string )$ latestEntry ->title : '' ;
299+ $ content = isset ($ latestEntry ->content ) ? (string )$ latestEntry ->content : '' ;
300+ $ summary = isset ($ latestEntry ->summary ) ? (string )$ latestEntry ->summary : '' ;
301+
302+ $ title = preg_replace ('/<!\[CDATA\[(.*?)\]\]>/s ' , '$1 ' , $ title );
303+ $ content = preg_replace ('/<!\[CDATA\[(.*?)\]\]>/s ' , '$1 ' , $ content );
304+ $ summary = preg_replace ('/<!\[CDATA\[(.*?)\]\]>/s ' , '$1 ' , $ summary );
305+
306+ $ entryDate = null ;
307+ if (isset ($ latestEntry ->updated )) {
308+ $ entryDate = strtotime ((string )$ latestEntry ->updated );
309+ } elseif (isset ($ latestEntry ->published )) {
310+ $ entryDate = strtotime ((string )$ latestEntry ->published );
311+ }
312+
313+ $ isRecent = ($ entryDate && (time () - $ entryDate ) <= 86400 );
314+
315+ $ description = !empty ($ title ) ? $ title : (!empty ($ content ) ? $ content : $ summary );
316+ if (!$ isRecent || preg_match ('/operational|resolved|completed|fixed|normal/i ' , $ title )) {
317+ $ status ['indicator ' ] = 'none ' ;
318+ $ status ['description ' ] = 'All Systems Operational ' ;
319+ } elseif (preg_match ('/outage|down|major|critical|offline/i ' , $ title )) {
320+ $ status ['indicator ' ] = 'major ' ;
321+ $ status ['description ' ] = sanitizeFeedText ($ description );
322+ } elseif (preg_match ('/degraded|issue|problem|investigating|identified|monitoring/i ' , $ title )) {
323+ $ status ['indicator ' ] = 'minor ' ;
324+ $ status ['description ' ] = sanitizeFeedText ($ description );
325+ }
326+
327+ if ($ status ['indicator ' ] !== 'none ' && $ status ['indicator ' ] !== 'major ' && $ status ['indicator ' ] !== 'minor ' ) {
328+ $ status ['description ' ] = sanitizeFeedText ($ title );
329+ }
330+ if (strlen ($ status ['description ' ]) > 200 ) {
331+ $ status ['description ' ] = substr ($ status ['description ' ], 0 , 197 ) . '... ' ;
332+ }
333+ return $ status ;
334+ }
335+
336+ // RSS feed handling (channel->item)
337+ if (isset ($ xml ->channel ->item )) {
338+ $ latestItem = null ;
339+ $ latestItemTimestamp = 0 ;
340+ foreach ($ xml ->channel ->item as $ item ) {
341+ $ itemTitle = isset ($ item ->title ) ? (string )$ item ->title : '' ;
342+ if ($ filter !== null && stripos ($ itemTitle , $ filter ) === false ) {
343+ continue ;
344+ }
345+ $ itemDateCandidate = null ;
346+ if (isset ($ item ->pubDate )) {
347+ $ itemDateCandidate = strtotime ((string )$ item ->pubDate );
348+ } elseif (isset ($ item ->children ('http://purl.org/dc/elements/1.1/ ' )->date )) {
349+ $ itemDateCandidate = strtotime ((string )$ item ->children ('http://purl.org/dc/elements/1.1/ ' )->date );
350+ }
351+ $ timestampValue = $ itemDateCandidate ? $ itemDateCandidate : 0 ;
352+ if ($ timestampValue > $ latestItemTimestamp ) {
353+ $ latestItemTimestamp = $ timestampValue ;
354+ $ latestItem = $ item ;
355+ }
356+ }
357+ if ($ filter !== null && $ latestItem === null ) {
358+ return $ status ;
359+ }
360+ $ latestItem = $ latestItem ?? $ xml ->channel ->item [0 ];
361+
362+ $ title = isset ($ latestItem ->title ) ? (string )$ latestItem ->title : '' ;
363+ $ description = isset ($ latestItem ->description ) ? (string )$ latestItem ->description : '' ;
364+
365+ $ itemDate = null ;
366+ if (isset ($ latestItem ->pubDate )) {
367+ $ itemDate = strtotime ((string )$ latestItem ->pubDate );
368+ } elseif (isset ($ latestItem ->children ('http://purl.org/dc/elements/1.1/ ' )->date )) {
369+ $ itemDate = strtotime ((string )$ latestItem ->children ('http://purl.org/dc/elements/1.1/ ' )->date );
370+ }
371+
372+ $ isRecent = ($ itemDate && (time () - $ itemDate ) <= 86400 );
373+
374+ if (!$ isRecent || preg_match ('/operational|resolved|completed|fixed|normal/i ' , $ title )) {
375+ $ status ['indicator ' ] = 'none ' ;
376+ $ status ['description ' ] = 'All Systems Operational ' ;
377+ } elseif (preg_match ('/outage|down|major|critical|offline/i ' , $ title )) {
378+ $ status ['indicator ' ] = 'major ' ;
379+ $ status ['description ' ] = sanitizeFeedText (!empty ($ description ) ? $ description : $ title );
380+ } elseif (preg_match ('/degraded|issue|problem|investigating|identified|monitoring/i ' , $ title )) {
381+ $ status ['indicator ' ] = 'minor ' ;
382+ $ status ['description ' ] = sanitizeFeedText (!empty ($ description ) ? $ description : $ title );
383+ }
384+ if (!preg_match ('/operational|resolved|completed|fixed|normal|outage|down|major|critical|offline|degraded|issue|problem|investigating|identified|monitoring/i ' , $ title )) {
385+ $ status ['description ' ] = sanitizeFeedText ($ title );
386+ }
387+ if (strlen ($ status ['description ' ]) > 200 ) {
388+ $ status ['description ' ] = substr ($ status ['description ' ], 0 , 197 ) . '... ' ;
389+ }
390+ return $ status ;
391+ }
392+
393+ // Default if not Atom or RSS
394+ return $ status ;
395+ }
396+
223397/**
224398 * Parse Google Workspace incidents JSON API
225399 */
0 commit comments