-
Notifications
You must be signed in to change notification settings - Fork 0
Restore blog RSS post rendering with resilient /api/rss fallback parsing
#27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5da1c5b
4e167b0
81ad667
ef1a10b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,7 +11,7 @@ type FeedItem = { | |||||||||||
|
|
||||||||||||
| const extractTag = (block: string, tags: string[]): string => { | ||||||||||||
| for (const tag of tags) { | ||||||||||||
| const match = block.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i')); | ||||||||||||
| const match = block.match(new RegExp(`<${tag}(?:\s[^>]*)?>([\s\S]*?)<\/${tag}>`, 'i')); | ||||||||||||
| if (match?.[1]) { | ||||||||||||
| return match[1].trim(); | ||||||||||||
| } | ||||||||||||
|
|
@@ -22,20 +22,34 @@ const extractTag = (block: string, tags: string[]): string => { | |||||||||||
|
|
||||||||||||
| const stripCdata = (value: string): string => value.replace(/^<!\[CDATA\[(.*)\]\]>$/s, '$1').trim(); | ||||||||||||
|
|
||||||||||||
| const safeFromCodePoint = (codePoint: number): string => { | ||||||||||||
| if (codePoint >= 0 && codePoint <= 0x10ffff) { | ||||||||||||
| return String.fromCodePoint(codePoint); | ||||||||||||
| } | ||||||||||||
| return '\uFFFD'; | ||||||||||||
| }; | ||||||||||||
|
Comment on lines
+25
to
+30
|
||||||||||||
|
|
||||||||||||
| const decodeXmlEntities = (value: string): string => | ||||||||||||
| value | ||||||||||||
| .replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => safeFromCodePoint(Number.parseInt(hex, 16))) | ||||||||||||
| .replace(/&#(\d+);/g, (_match, dec: string) => safeFromCodePoint(Number.parseInt(dec, 10))) | ||||||||||||
| .replace(/</g, '<') | ||||||||||||
| .replace(/>/g, '>') | ||||||||||||
| .replace(/"/g, '"') | ||||||||||||
| .replace(/'/g, "'") | ||||||||||||
| .replace(/&/g, '&'); | ||||||||||||
|
|
||||||||||||
| const cleanDescription = (value: string): string => { | ||||||||||||
| const text = decodeXmlEntities(stripCdata(value)) | ||||||||||||
| /** Strips HTML tags, decodes entities, and normalises whitespace — without truncation. */ | ||||||||||||
| const cleanText = (value: string): string => | ||||||||||||
| decodeXmlEntities(stripCdata(value)) | ||||||||||||
| .replace(/<[^>]+>/g, ' ') | ||||||||||||
| .replace(/\s+/g, ' ') | ||||||||||||
| .trim(); | ||||||||||||
|
|
||||||||||||
| /** Like cleanText but truncates to 200 characters with an ellipsis. */ | ||||||||||||
| const cleanDescription = (value: string): string => { | ||||||||||||
| const text = cleanText(value); | ||||||||||||
|
|
||||||||||||
| if (text.length <= 200) { | ||||||||||||
| return text; | ||||||||||||
| } | ||||||||||||
|
|
@@ -104,6 +118,74 @@ const getMax = (value: string | null): number => { | |||||||||||
| return Math.max(1, Math.min(parsed, 20)); | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * Wraps fetch with a timeout. Uses AbortSignal.timeout when available; otherwise | ||||||||||||
| * falls back to AbortController + setTimeout and always clears the timer in a | ||||||||||||
| * finally block so the event loop is never kept alive by a stale timer. | ||||||||||||
| */ | ||||||||||||
| const fetchWithTimeout = async ( | ||||||||||||
| input: RequestInfo | URL, | ||||||||||||
| init: Omit<RequestInit, 'signal'> = {}, | ||||||||||||
| timeoutMs = 8000 | ||||||||||||
| ): Promise<Response> => { | ||||||||||||
| if (typeof AbortSignal.timeout === 'function') { | ||||||||||||
| return fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) }); | ||||||||||||
|
Comment on lines
+131
to
+132
|
||||||||||||
| if (typeof AbortSignal.timeout === 'function') { | |
| return fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) }); | |
| const abortSignalCtor = globalThis.AbortSignal; | |
| if (typeof abortSignalCtor?.timeout === 'function') { | |
| return fetch(input, { ...init, signal: abortSignalCtor.timeout(timeoutMs) }); |
Copilot
AI
Apr 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The WordPress fallback reuses parsedFeedUrl.origin derived from the user-controlled url parameter, which means the endpoint will now make an additional request to /<origin>/wp-json/wp/v2/posts for arbitrary hosts. This increases SSRF blast radius (extra internal endpoints hit on the same host) compared to the RSS-only fetch. Consider restricting allowed origins (e.g., a known blog host), enforcing http/https only, and/or blocking private IP ranges before issuing either fetch.
Copilot
AI
Apr 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fallback behavior is now triggered in several new cases (non-OK status, invalid feed XML, empty parsed items, thrown errors), but the new tests only cover the text/html trigger. Add at least one more test that demonstrates a successful fallback for another trigger (e.g., upstream 503 or invalid feed body) and a test that asserts no fallback request is made when the primary RSS/Atom parse yields items.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
extractTagbuilds aRegExpfrom a string that contains\s/\Sescapes, but the string literal currently uses single backslashes (\sis written as\sin the source). In JS/TS string literals\sis not a valid escape and becomes justs, so the regex will not match whitespace/newlines and tag extraction will break. Use double-escaped backslashes (e.g.\\s,\\S) orString.rawso the intended regex escapes reach theRegExpconstructor.