From ffd9deaec61f1faa93427d9e4e0c1ad9db6b40dd Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 2 Nov 2026 01:15:04 +0000 Subject: [PATCH] Refuse removed publishers in framed reader pages --- apps/web/src/app/api/frame/route.js | 9 ++++++++- apps/web/src/lib/frameRemoval.js | 20 ++++++++++++++++++++ apps/web/test/frame-removal.test.js | 26 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/frameRemoval.js create mode 100644 apps/web/test/frame-removal.test.js diff --git a/apps/web/src/app/api/frame/route.js b/apps/web/src/app/api/frame/route.js index 4a90a9d..e37d9ae 100644 --- a/apps/web/src/app/api/frame/route.js +++ b/apps/web/src/app/api/frame/route.js @@ -1,7 +1,9 @@ import { normalizeUrl, probePage, readableArticle, reframePage } from '@rssamplifier/feed'; +import { removals } from '@rssamplifier/db'; import { withPageSlot } from '../../../lib/pageGate.js'; -import { siteUrl } from '../../../lib/db.js'; +import { db, siteUrl } from '../../../lib/db.js'; +import { removedPublisherResponse } from '../../../lib/frameRemoval.js'; export const dynamic = 'force-dynamic'; @@ -79,6 +81,11 @@ export async function GET(req) { * @returns {Promise} */ async function openPage({ target, site, self, through }) { + const removed = await removedPublisherResponse(target, (url) => + removals.isRemovedUrl(db(), url), + ); + if (removed) return removed; + const probe = await probePage(target, { origin: site, wantHtml: 'always' }); const landed = probe.url ?? target; diff --git a/apps/web/src/lib/frameRemoval.js b/apps/web/src/lib/frameRemoval.js new file mode 100644 index 0000000..f308bbd --- /dev/null +++ b/apps/web/src/lib/frameRemoval.js @@ -0,0 +1,20 @@ +/** + * Refuse a publisher that has been removed from the directory before the + * reader's frame makes any request to that host. + * + * @param {string} url + * @param {(url: string) => Promise} isRemovedUrl + * @returns {Promise} + */ +export async function removedPublisherResponse(url, isRemovedUrl) { + if (!(await isRemovedUrl(url))) return null; + + return new Response('This publisher has been removed from RSS Amplifier.', { + status: 410, + headers: { + 'cache-control': 'no-store', + 'content-type': 'text/plain; charset=utf-8', + 'x-robots-tag': 'noindex', + }, + }); +} diff --git a/apps/web/test/frame-removal.test.js b/apps/web/test/frame-removal.test.js new file mode 100644 index 0000000..ccc78c7 --- /dev/null +++ b/apps/web/test/frame-removal.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { removedPublisherResponse } from '../src/lib/frameRemoval.js'; + +test('a removed publisher gets a no-store 410 response before it can be framed', async () => { + let checkedUrl = null; + const response = await removedPublisherResponse( + 'https://blog.marcolancini.it/posts/one', + async (url) => { + checkedUrl = url; + return true; + }, + ); + + assert.equal(checkedUrl, 'https://blog.marcolancini.it/posts/one'); + assert.equal(response.status, 410); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('x-robots-tag'), 'noindex'); + assert.match(await response.text(), /removed from RSS Amplifier/); +}); + +test('a publisher without a removal record continues through the normal frame path', async () => { + const response = await removedPublisherResponse('https://example.com/post', async () => false); + assert.equal(response, null); +});