Skip to content

Commit 272ffc7

Browse files
committed
Add edge script for bunny.net
1 parent 0adc6a9 commit 272ffc7

4 files changed

Lines changed: 285 additions & 10 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"web": "expo start --web",
4848
"use-build-number": "./scripts/useBuildNumberEnv.sh",
4949
"use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh",
50-
"build-web": "expo export:web && node ./scripts/post-web-build.js && node ./scripts/gen-oauth-metadata.js",
50+
"build-web": "expo export:web && node ./scripts/post-web-build.js && node ./scripts/gen-oauth-metadata.js && node ./scripts/gen-newsroom-hosts.js",
5151
"deploy:cloudflare": "bash scripts/deploy-cloudflare.sh",
5252
"build-all": "pnpm intl:build && pnpm use-build-number-with-bump eas build --platform all",
5353
"build-ios": "pnpm use-build-number-with-bump eas build -p ios",

scripts/gen-newsroom-hosts.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Emits the static `newsroom-hosts.json` into the web build output.
3+
*
4+
* Runs at build time (chained off `build-web`). Extracts the feed URLs from
5+
* the newsroom publisher registry (src/features/newsrooms/publishers.ts) and
6+
* writes their registrable domains for the production RSS proxy
7+
* (services/rss/bunny/index.ts) to consume as its host allowlist. Adding a
8+
* publisher to the registry therefore propagates to the proxy on the next web
9+
* deploy, with no proxy-side change.
10+
*/
11+
/* eslint-disable import-x/no-nodejs-modules, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- Node build script, not app source */
12+
const fs = require('node:fs')
13+
const path = require('node:path')
14+
15+
const projectRoot = path.join(__dirname, '..')
16+
17+
// Second-level TLDs where "last two labels" is a registry suffix, not a
18+
// registrable domain. Coarse on purpose: the registry is small and curated.
19+
const SECOND_LEVEL_TLDS = new Set([
20+
'co.uk',
21+
'org.uk',
22+
'ac.uk',
23+
'gov.uk',
24+
'com.au',
25+
'net.au',
26+
'org.au',
27+
'co.nz',
28+
'co.jp',
29+
'co.in',
30+
'com.br',
31+
'com.mx',
32+
'com.tr',
33+
])
34+
35+
function registrableDomain(hostname) {
36+
const labels = hostname.toLowerCase().split('.')
37+
const lastTwo = labels.slice(-2).join('.')
38+
const take = SECOND_LEVEL_TLDS.has(lastTwo) ? 3 : 2
39+
return labels.slice(-take).join('.')
40+
}
41+
42+
const registry = fs.readFileSync(
43+
path.join(projectRoot, 'src', 'features', 'newsrooms', 'publishers.ts'),
44+
'utf8',
45+
)
46+
// Match any `url: 'http...'` regardless of how prettier wraps the source
47+
// entry; feed URLs are the only http(s) `url:` fields in the registry.
48+
const feedUrls = [...registry.matchAll(/url: '(https?:[^']+)'/g)].map(m => m[1])
49+
if (feedUrls.length === 0) {
50+
throw new Error('gen-newsroom-hosts: no feed URLs found in publishers.ts')
51+
}
52+
53+
const hosts = [
54+
...new Set(feedUrls.map(u => registrableDomain(new URL(u).hostname))),
55+
].sort()
56+
57+
const outDir = path.join(projectRoot, 'web-build')
58+
fs.mkdirSync(outDir, {recursive: true})
59+
const outFile = path.join(outDir, 'newsroom-hosts.json')
60+
fs.writeFileSync(outFile, JSON.stringify({hosts}, null, 2) + '\n')
61+
console.log(`gen-newsroom-hosts: wrote ${hosts.length} hosts to ${outFile}`)

services/rss/README.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
# RSS proxy
22

3-
Curated publisher pages pull each publisher's real articles from its RSS/Atom
4-
feed (see `src/features/newsrooms/rss`). Most news feeds send no CORS headers,
5-
so a web build cannot fetch them directly. This service is a passthrough that
6-
adds CORS.
3+
Newsroom pages pull each publisher's real articles from its RSS/Atom feed (see
4+
`src/features/newsrooms/rss`). Most news feeds send no CORS headers, so a web
5+
build cannot fetch them directly. This service is a passthrough that adds CORS.
76

87
## Local dev (web)
98

@@ -22,8 +21,20 @@ the proxy is only needed for web.
2221

2322
## Production
2423

25-
Stand up an equivalent edge passthrough (Bunny/Cloudflare), mirroring the
26-
sibling services (`services/footballData`, `services/og`), and point
27-
`EXPO_PUBLIC_RSS_PROXY_URL` at it. The proxy should restrict which upstream hosts
28-
it will fetch to the publishers in the registry rather than proxying arbitrary
29-
URLs.
24+
`bunny/index.ts` is the production equivalent (Bunny Edge Scripting), mirroring
25+
the sibling services (`services/footballData`, `services/og`): create a
26+
standalone Edge Script on its own hostname, paste the file, and point
27+
`EXPO_PUBLIC_RSS_PROXY_URL` at it in the web build.
28+
29+
Beyond the dev proxy it adds a host allowlist, hop-validated redirects so a
30+
feed cannot redirect the proxy off the allowlist, and edge caching
31+
(`CACHE_SECONDS`, default 300) so many readers cost roughly one upstream fetch
32+
per interval.
33+
34+
The allowlist maintains itself: `build-web` runs
35+
`scripts/gen-newsroom-hosts.js`, which derives the publishers' registrable
36+
domains from the registry and emits `newsroom-hosts.json` into the web build.
37+
The proxy fetches that file (`HOSTS_URL`, cached `HOSTS_TTL` seconds), so
38+
adding a publisher to `publishers.ts` reaches the proxy on the next web deploy
39+
with no proxy-side change. `ALLOWED_HOSTS` remains as a manual override, and a
40+
hardcoded fallback covers cold starts if the hosted list is unreachable.

services/rss/bunny/index.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* Publisher RSS/article proxy (Bunny.net Edge Scripting).
3+
*
4+
* First-party passthrough for the newsroom pages (see
5+
* ../../../src/features/newsrooms/). Publisher feeds and article pages send no
6+
* Access-Control-Allow-Origin header, so a web build cannot fetch them from
7+
* the browser; this service refetches them server-side and re-serves the body
8+
* with CORS headers. The app points EXPO_PUBLIC_RSS_PROXY_URL at this host and
9+
* appends `?url=<target>` itself (see src/features/newsrooms/rss/config.ts).
10+
* It proxies two kinds of targets: feed XML, and article HTML (for the hero's
11+
* og:image scrape).
12+
*
13+
* Lockdown: only GET, only http(s) targets, and only hosts under the
14+
* registered publishers' domains - the proxy must not fetch arbitrary URLs on
15+
* our egress. The allowlist is the build-emitted `newsroom-hosts.json` served
16+
* next to the web app (scripts/gen-newsroom-hosts.js derives it from the
17+
* publisher registry), so adding a publisher propagates here on the next web
18+
* deploy with no proxy change. Redirects are followed manually so a hop
19+
* cannot escape the allowlist. Responses are cached at the edge so N readers
20+
* cost roughly one upstream fetch per interval.
21+
*
22+
* Config (Bunny dashboard -> script -> Env Configuration), all optional:
23+
* HOSTS_URL where the emitted allowlist lives
24+
* (default https://mu.social/newsroom-hosts.json).
25+
* HOSTS_TTL seconds to cache the fetched allowlist (default 300).
26+
* ALLOWED_HOSTS comma-separated domain-suffix override; when set, the
27+
* hosted list is not consulted at all.
28+
* ALLOWED_ORIGIN Access-Control-Allow-Origin (default *).
29+
* CACHE_SECONDS edge/browser cache TTL for proxied bodies (default 300).
30+
*/
31+
import * as BunnySDK from 'https://esm.sh/@bunny.net/edgescript-sdk@0.11.2'
32+
33+
// Cold-start fallback, used only until the hosted list has been fetched once.
34+
// Not the source of truth - that is the registry via newsroom-hosts.json.
35+
const FALLBACK_HOSTS = [
36+
'ft.com',
37+
'theverge.com',
38+
'wired.com',
39+
'nytimes.com',
40+
'euractiv.com',
41+
'404media.co',
42+
'cnn.com',
43+
'nrc.nl',
44+
'propublica.org',
45+
'euobserver.com',
46+
'theguardian.com',
47+
'lemonde.fr',
48+
'elpais.com',
49+
'next.ink',
50+
]
51+
52+
const MAX_REDIRECTS = 5
53+
54+
function env(name: string): string | undefined {
55+
return Deno.env.get(name) || undefined
56+
}
57+
58+
function baseHeaders(): Record<string, string> {
59+
const cacheSeconds = Number(env('CACHE_SECONDS') || '300')
60+
return {
61+
'Access-Control-Allow-Origin': env('ALLOWED_ORIGIN') || '*',
62+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
63+
'Access-Control-Allow-Headers': 'Accept, Content-Type',
64+
'Cache-Control': `public, max-age=60, s-maxage=${cacheSeconds}`,
65+
}
66+
}
67+
68+
let hostsCache: {hosts: string[]; fetchedAt: number} | null = null
69+
70+
async function allowedHosts(): Promise<string[]> {
71+
const override = env('ALLOWED_HOSTS')
72+
if (override) {
73+
return override
74+
.split(',')
75+
.map(s => s.trim().toLowerCase())
76+
.filter(Boolean)
77+
}
78+
79+
const ttlMs = Number(env('HOSTS_TTL') || '300') * 1000
80+
if (hostsCache && Date.now() - hostsCache.fetchedAt < ttlMs) {
81+
return hostsCache.hosts
82+
}
83+
try {
84+
const res = await fetch(
85+
env('HOSTS_URL') || 'https://mu.social/newsroom-hosts.json',
86+
{headers: {Accept: 'application/json'}},
87+
)
88+
if (res.ok) {
89+
const data = await res.json()
90+
const hosts = Array.isArray(data?.hosts)
91+
? data.hosts
92+
.map((h: unknown) => String(h).toLowerCase())
93+
.filter(Boolean)
94+
: []
95+
if (hosts.length > 0) {
96+
hostsCache = {hosts, fetchedAt: Date.now()}
97+
return hosts
98+
}
99+
}
100+
} catch {
101+
// fall through to stale / fallback below
102+
}
103+
// Refresh failed: serve the stale list (and wait a full TTL before retrying,
104+
// so a broken hosts endpoint does not add a failed fetch to every request).
105+
if (hostsCache) {
106+
hostsCache.fetchedAt = Date.now()
107+
return hostsCache.hosts
108+
}
109+
return FALLBACK_HOSTS
110+
}
111+
112+
function isAllowed(target: URL, hosts: string[]): boolean {
113+
if (target.protocol !== 'https:' && target.protocol !== 'http:') return false
114+
const host = target.hostname.toLowerCase()
115+
return hosts.some(domain => host === domain || host.endsWith('.' + domain))
116+
}
117+
118+
/**
119+
* Fetch with redirects validated hop by hop: a registered feed redirecting to
120+
* an unregistered host (or a non-http scheme) is refused rather than followed.
121+
*/
122+
async function fetchAllowed(
123+
target: URL,
124+
hosts: string[],
125+
): Promise<Response | null> {
126+
let current = target
127+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
128+
if (!isAllowed(current, hosts)) return null
129+
const res = await fetch(current.toString(), {
130+
redirect: 'manual',
131+
headers: {
132+
// Broad Accept: this proxies both feeds (XML) and article pages (HTML).
133+
Accept:
134+
'text/html, application/xhtml+xml, application/rss+xml, application/atom+xml, application/xml, text/xml, */*;q=0.8',
135+
// Some servers reject requests without a UA.
136+
'User-Agent': 'Mozilla/5.0 (compatible; mu-newsrooms-proxy/1.0)',
137+
},
138+
})
139+
if (res.status >= 300 && res.status < 400) {
140+
const location = res.headers.get('location')
141+
if (!location) return res
142+
current = new URL(location, current)
143+
continue
144+
}
145+
return res
146+
}
147+
return null
148+
}
149+
150+
BunnySDK.net.http.serve(async (req: Request): Promise<Response> => {
151+
const headers = baseHeaders()
152+
153+
if (req.method === 'OPTIONS') {
154+
return new Response(null, {status: 204, headers})
155+
}
156+
if (req.method !== 'GET') {
157+
return new Response(null, {status: 405, headers})
158+
}
159+
160+
let target: URL
161+
try {
162+
const raw = new URL(req.url).searchParams.get('url') || ''
163+
target = new URL(raw)
164+
} catch {
165+
return new Response(
166+
JSON.stringify({error: 'Missing or invalid url param'}),
167+
{status: 400, headers: {...headers, 'Content-Type': 'application/json'}},
168+
)
169+
}
170+
171+
const hosts = await allowedHosts()
172+
if (!isAllowed(target, hosts)) {
173+
return new Response(JSON.stringify({error: 'Host not allowed'}), {
174+
status: 403,
175+
headers: {...headers, 'Content-Type': 'application/json'},
176+
})
177+
}
178+
179+
try {
180+
const upstream = await fetchAllowed(target, hosts)
181+
if (!upstream) {
182+
return new Response(JSON.stringify({error: 'Host not allowed'}), {
183+
status: 403,
184+
headers: {...headers, 'Content-Type': 'application/json'},
185+
})
186+
}
187+
const body = await upstream.text()
188+
return new Response(body, {
189+
status: upstream.status,
190+
headers: {
191+
...headers,
192+
'Content-Type':
193+
upstream.headers.get('content-type') ||
194+
'application/xml; charset=utf-8',
195+
},
196+
})
197+
} catch (err) {
198+
return new Response(JSON.stringify({error: String(err)}), {
199+
status: 502,
200+
headers: {...headers, 'Content-Type': 'application/json'},
201+
})
202+
}
203+
})

0 commit comments

Comments
 (0)