Add configurable tweet media hosting mode and fxtwitter base URL

Adds nitterMediaMode (self-host/proxy/direct, default proxy) and
fxtwitterBaseUrl to global settings with a new Retention tab panel.
Tweet images and avatars now resolve through the chosen mode instead
of always being downloaded — proxy mode streams media through a new
SSRF-hardened /media/proxy route (hostname allowlist + DNS-rebinding
defense) so the origin server's IP is never exposed to Twitter's CDN,
direct hotlinks the original URL, and self-host keeps the prior
always-download behavior. fxtwitterBaseUrl lets the enrichment call
target a self-hosted FixTweet mirror instead of the public instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-22 23:43:52 +00:00
parent c0d90fc33b
commit c2b34623d1
11 changed files with 277 additions and 24 deletions
+117
View File
@@ -0,0 +1,117 @@
// Backs the "proxy" Nitter media mode (see Retention tab / GlobalSettings.nitterMediaMode):
// the visitor's browser requests media from this route instead of directly from
// Twitter/the Nitter instance's CDN, so only this server's IP is ever exposed to the
// remote host — the media itself is streamed straight through, never written to disk.
//
// Since this route fetches whatever URL it's given, it's a textbook SSRF vector unless
// tightly restricted: only twimg.com (Twitter's media CDN), the configured
// fxtwitterBaseUrl's host, and the hostnames of the admin's own configured Nitter
// sources are allowed — and even an allowed hostname is rejected if it resolves to a
// private/loopback/link-local address (defends against DNS rebinding, not just a
// hostname string check).
import type { FastifyInstance } from 'fastify';
import dns from 'node:dns/promises';
import { Readable } from 'node:stream';
import * as sourcesDb from '../storage/db/sources.js';
import { getSettings } from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
const USER_AGENT = 'Mozilla/5.0 (compatible; HomefeedBot/1.0; self-hosted RSS reader)';
const FETCH_TIMEOUT_MS = 15_000;
const TWITTER_MEDIA_HOST_RE = /(^|\.)twimg\.com$/i;
function hostnameOf(rawUrl: string | null): string | null {
if (!rawUrl) return null;
try {
return new URL(rawUrl).hostname.toLowerCase();
} catch {
return null;
}
}
function isAllowedHost(hostname: string): boolean {
const lower = hostname.toLowerCase();
if (TWITTER_MEDIA_HOST_RE.test(lower)) return true;
const settings = getSettings();
if (hostnameOf(settings.fxtwitterBaseUrl) === lower) return true;
const nitterHosts = sourcesDb
.listSources()
.filter((s) => s.type === 'nitter')
.map((s) => hostnameOf(s.url))
.filter((h): h is string => !!h);
return nitterHosts.includes(lower);
}
function isPrivateOrReservedIp(ip: string, family: number): boolean {
if (family === 4) {
const [a, b] = ip.split('.').map(Number);
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT range
return false;
}
const lower = ip.toLowerCase();
if (lower === '::1') return true;
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // unique local fc00::/7
if (lower.startsWith('fe80')) return true; // link-local
if (lower.startsWith('::ffff:')) {
const v4 = lower.split(':').pop();
if (v4?.includes('.')) return isPrivateOrReservedIp(v4, 4);
}
return false;
}
export async function registerMediaProxy(app: FastifyInstance) {
app.get('/media/proxy', async (req, reply) => {
const { url } = req.query as { url?: string };
if (!url) return reply.code(400).send({ error: 'url required' });
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return reply.code(400).send({ error: 'invalid url' });
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return reply.code(400).send({ error: 'unsupported scheme' });
}
if (!isAllowedHost(parsed.hostname)) {
logger.warn('media-proxy', `Blocked proxy request to disallowed host: ${parsed.hostname}`);
return reply.code(403).send({ error: 'host not allowed' });
}
let addresses: { address: string; family: number }[];
try {
addresses = await dns.lookup(parsed.hostname, { all: true });
} catch {
return reply.code(502).send({ error: 'DNS resolution failed' });
}
if (addresses.some((a) => isPrivateOrReservedIp(a.address, a.family))) {
logger.warn('media-proxy', `Blocked proxy request resolving to a private/reserved address: ${parsed.hostname}`);
return reply.code(403).send({ error: 'host not allowed' });
}
try {
const res = await fetch(parsed.toString(), {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!res.ok || !res.body) {
return reply.code(502).send({ error: `upstream responded ${res.status}` });
}
reply.header('content-type', res.headers.get('content-type') ?? 'application/octet-stream');
reply.header('cache-control', res.headers.get('cache-control') ?? 'public, max-age=3600');
return reply.send(Readable.fromWeb(res.body as any));
} catch (err) {
logger.warn('media-proxy', `Proxy fetch failed for ${parsed.toString()}: ${(err as Error).message}`);
return reply.code(502).send({ error: 'fetch failed' });
}
});
}
+6
View File
@@ -7,6 +7,7 @@ import { ADMIN_API_KEY } from './api/apiKey.js';
import { registerAuth } from './api/auth.js';
import { registerPublicRoutes } from './api/public.js';
import { registerAdminRoutes } from './api/admin.js';
import { registerMediaProxy } from './api/mediaProxy.js';
import { startScheduler } from './queue/scheduler.js';
import { logger } from './storage/db/logs.js';
@@ -79,6 +80,11 @@ async function main() {
return reply.send(fs.createReadStream(filePath));
});
// Static "/media/proxy" takes priority over the "/media/:filename" param route
// above regardless of registration order (find-my-way, Fastify's router, always
// prefers a static segment over a parametric one at the same depth).
await registerMediaProxy(app);
app.get('/health', async () => ({ ok: true }));
await app.listen({ port: PORT, host: '0.0.0.0' });
+9 -2
View File
@@ -10,6 +10,7 @@ import Parser from 'rss-parser';
import type { Source } from '../../storage/db/types.js';
import type { SourceAdapter, FetchedItem } from './base.js';
import { logger } from '../../storage/db/logs.js';
import { getSettings } from '../../storage/db/settings.js';
const parser = new Parser<Record<string, unknown>>();
@@ -31,10 +32,16 @@ interface FxTweet {
media?: { photos?: { url?: string }[] };
}
/** The endpoint is keyed by handle + status ID, e.g. https://api.fxtwitter.com/zerohedge/status/123 — no API version prefix. */
/**
* The endpoint is keyed by handle + status ID, e.g. https://api.fxtwitter.com/zerohedge/status/123
* — no API version prefix. The base URL is admin-configurable (Retention tab) so a
* self-hosted FixTweet mirror (or another compatible public instance) can be used
* instead of the public api.fxtwitter.com default.
*/
async function fetchFxTwitter(handle: string, tweetId: string): Promise<FxTweet | null> {
const baseUrl = getSettings().fxtwitterBaseUrl.replace(/\/+$/, '');
try {
const res = await fetch(`https://api.fxtwitter.com/${handle}/status/${tweetId}`, {
const res = await fetch(`${baseUrl}/${handle}/status/${tweetId}`, {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
+63 -18
View File
@@ -33,17 +33,18 @@ function deriveTitle(body: string): string {
}
/**
* Resolves the hero image for an article: try the best candidate from the source
* items, download and locally host it; if there isn't one, fall back to the site's
* favicon rather than leaving the article with no art at all. That favicon fallback
* is skipped for tweets (allowFaviconFallback: false) — a Nitter instance's own
* favicon slapped onto an image-less tweet reads as a mistake, not a placeholder;
* TweetCard.svelte already handles no-image tweets gracefully with no image at all.
* Resolves the hero image for a regular (non-tweet) article: try the best candidate
* from the source items, download and locally host it; if there isn't one, fall back
* to the site's favicon rather than leaving the article with no art at all. Tweets
* never reach this function — see resolveTweetMediaUrl below, which applies the
* admin-configured Nitter media mode instead of always downloading, and skips the
* favicon fallback entirely (a Nitter instance's own favicon slapped onto an
* image-less tweet reads as a mistake, not a placeholder; TweetCard.svelte already
* handles no-image tweets gracefully with no image at all).
*/
async function resolveHeroImage(
items: ContentItem[],
primaryLink: string,
allowFaviconFallback = true
primaryLink: string
): Promise<{ heroImage: MergedArticle['heroImage']; storedMediaId: string | null }> {
const selected = selectBestImage(items);
@@ -59,10 +60,6 @@ async function resolveHeroImage(
return { heroImage: selected, storedMediaId: null };
}
if (!allowFaviconFallback) {
return { heroImage: null, storedMediaId: null };
}
const favicon = faviconUrlFor(primaryLink);
if (favicon) {
const stored = await downloadAndStore(favicon, 'published', {});
@@ -77,6 +74,31 @@ async function resolveHeroImage(
return { heroImage: null, storedMediaId: null };
}
/**
* Applies the admin-configured Nitter media mode (Retention tab) to a single
* externally-hosted tweet media URL — an attached photo or the author's avatar.
* 'self-host' downloads and serves it locally like any other article image;
* 'proxy' routes it through this server's own /media/proxy route so only this
* server's IP is ever exposed to Twitter/the Nitter instance's CDN (the
* "anonymity" the admin asked for) without persisting anything to disk; 'direct'
* hotlinks the original URL unchanged, the cheapest option with no server involvement.
*/
async function resolveTweetMediaUrl(
url: string,
mode: GlobalSettings['nitterMediaMode']
): Promise<{ url: string; storedMediaId: string | null }> {
if (mode === 'direct') return { url, storedMediaId: null };
if (mode === 'proxy') {
return { url: `/media/proxy?url=${encodeURIComponent(url)}`, storedMediaId: null };
}
const stored = await downloadAndStore(url, 'published', {});
if (stored) return { url: stored.servedPath, storedMediaId: stored.id };
// Download failed — fall back to hotlinking rather than losing the media entirely.
return { url, storedMediaId: null };
}
/**
* Publishes a single item as-is, with no AI calls at all — used when the AI service
* isn't reachable (e.g. Ollama hasn't been set up yet, per the "assume it arrives
@@ -87,15 +109,38 @@ async function resolveHeroImage(
* tag-based thread detection — but these earlier articles aren't retroactively
* rewritten or merged with anything after the fact.
*/
export async function publishDirect(item: ContentItem): Promise<MergedArticle> {
export async function publishDirect(item: ContentItem, settings: GlobalSettings): Promise<MergedArticle> {
const category = uniqueCategories([item]);
const { heroImage, storedMediaId } = await resolveHeroImage([item], item.link, !item.tweet);
const storedMediaIds: string[] = [];
let heroImage: MergedArticle['heroImage'] = null;
if (item.tweet) {
const selected = selectBestImage([item]);
if (selected) {
const resolved = await resolveTweetMediaUrl(selected.url, settings.nitterMediaMode);
heroImage = { url: resolved.url, sourceItemId: selected.sourceItemId, selectionReason: selected.selectionReason };
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
}
} else {
const resolved = await resolveHeroImage([item], item.link);
heroImage = resolved.heroImage;
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
}
const video = item.videos[0]
? { url: item.videos[0].url, provider: item.videos[0].provider, embedUrl: item.videos[0].embedHtml, sourceItemId: item.id }
: null;
const tweet = item.tweet
? { authorName: item.tweet.authorName, authorHandle: item.tweet.authorHandle, avatarUrl: item.tweet.avatarUrl, sourceItemId: item.id }
: null;
let tweet: MergedArticle['tweet'] = null;
if (item.tweet) {
let avatarUrl: string | null = null;
if (item.tweet.avatarUrl) {
const resolved = await resolveTweetMediaUrl(item.tweet.avatarUrl, settings.nitterMediaMode);
avatarUrl = resolved.url;
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
}
tweet = { authorName: item.tweet.authorName, authorHandle: item.tweet.authorHandle, avatarUrl, sourceItemId: item.id };
}
const article = await articles.insertArticle({
title: item.title,
@@ -127,7 +172,7 @@ export async function publishDirect(item: ContentItem): Promise<MergedArticle> {
topStories: anyPushesToTopStories([item])
});
if (storedMediaId) promoteToPublished(storedMediaId, article.id);
for (const id of storedMediaIds) promoteToPublished(id, article.id);
return article;
}
+2 -2
View File
@@ -54,7 +54,7 @@ export async function runPassthroughCycle(settings: GlobalSettings): Promise<num
for (const item of ranked) {
try {
const article = await publishDirect(item);
const article = await publishDirect(item, settings);
contentItemsDb.assignCluster([item.id], article.id);
published++;
logger.info('synthesis', `Published "${article.title}" directly (no AI available)`);
@@ -88,7 +88,7 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
let publishedDirect = 0;
for (const item of directItems) {
try {
const article = await publishDirect(item);
const article = await publishDirect(item, settings);
contentItemsDb.assignCluster([item.id], article.id);
publishedDirect++;
const source = sourcesDb.getSource(item.sourceId);
+9 -1
View File
@@ -167,7 +167,9 @@ export function migrate() {
raw_item_max_age_days INTEGER DEFAULT 7,
storage_cap_enabled INTEGER NOT NULL DEFAULT 1,
storage_cap_value INTEGER NOT NULL DEFAULT 500,
storage_cap_unit TEXT NOT NULL DEFAULT 'GB'
storage_cap_unit TEXT NOT NULL DEFAULT 'GB',
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com'
);
`);
@@ -195,6 +197,12 @@ export function migrate() {
if (!hasColumn('merged_articles', 'tweet')) {
db.exec('ALTER TABLE merged_articles ADD COLUMN tweet TEXT');
}
if (!hasColumn('global_settings', 'nitter_media_mode')) {
db.exec("ALTER TABLE global_settings ADD COLUMN nitter_media_mode TEXT NOT NULL DEFAULT 'proxy'");
}
if (!hasColumn('global_settings', 'fxtwitter_base_url')) {
db.exec("ALTER TABLE global_settings ADD COLUMN fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com'");
}
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
// general news sources belong here, not on "Top stories" itself, which isn't a real
+5
View File
@@ -13,6 +13,8 @@ function rowToSettings(row: any): GlobalSettings {
aiServiceHost: row.ai_service_host,
aiServicePort: row.ai_service_port,
selectedModels: JSON.parse(row.selected_models),
nitterMediaMode: row.nitter_media_mode,
fxtwitterBaseUrl: row.fxtwitter_base_url,
retention: {
publishedArticleMaxAgeDays: row.published_article_max_age_days,
rawItemMaxAgeDays: row.raw_item_max_age_days,
@@ -41,6 +43,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
merge_strictness=?, default_poll_interval_minutes=?, hold_before_publish_minutes=?,
tag_dedup_threshold=?, tag_expiry_days=?, follow_up_min_hours_since_last=?, follow_up_min_new_sources=?,
ai_service_host=?, ai_service_port=?, selected_models=?,
nitter_media_mode=?, fxtwitter_base_url=?,
published_article_max_age_days=?, raw_item_max_age_days=?,
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?
WHERE id = 1`
@@ -55,6 +58,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
merged.aiServiceHost,
merged.aiServicePort,
JSON.stringify(merged.selectedModels),
merged.nitterMediaMode,
merged.fxtwitterBaseUrl,
merged.retention.publishedArticleMaxAgeDays,
merged.retention.rawItemMaxAgeDays,
merged.retention.storageCapEnabled ? 1 : 0,
+4
View File
@@ -119,6 +119,10 @@ export interface GlobalSettings {
aiServiceHost: string;
aiServicePort: number;
selectedModels: { embedding: string; image: string; synthesis: string };
/** How tweet media (attached photos, avatars) is served — see pipeline/publish.ts's resolveTweetMedia. */
nitterMediaMode: 'self-host' | 'proxy' | 'direct';
/** Base URL of the fxtwitter-compatible enrichment API — defaults to the public instance, overridable for a self-hosted FixTweet mirror. */
fxtwitterBaseUrl: string;
retention: {
publishedArticleMaxAgeDays: number | null;
rawItemMaxAgeDays: number | null;
+2
View File
@@ -25,6 +25,8 @@ export interface AdminSettings {
aiServiceHost: string;
aiServicePort: number;
selectedModels: { embedding: string; image: string; synthesis: string };
nitterMediaMode: 'self-host' | 'proxy' | 'direct';
fxtwitterBaseUrl: string;
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
}
+1 -1
View File
@@ -18,7 +18,7 @@
</div>
<div class="author-row">
{#if article.tweet?.avatarUrl}
<img class="avatar" src={article.tweet.avatarUrl} alt="" loading="lazy" />
<img class="avatar" src={resolveMediaUrl(article.tweet.avatarUrl)} alt="" loading="lazy" />
{:else}
<div class="avatar placeholder"></div>
{/if}
@@ -11,6 +11,31 @@
let clearing = $state<'articles' | 'media' | null>(null);
let clearResult = $state<string | null>(null);
let nitterMediaMode = $state(settings.nitterMediaMode);
let fxtwitterBaseUrl = $state(settings.fxtwitterBaseUrl);
let nitterStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let nitterSaveTimer: ReturnType<typeof setTimeout>;
function scheduleNitterSave() {
nitterStatus = 'saving';
clearTimeout(nitterSaveTimer);
nitterSaveTimer = setTimeout(async () => {
try {
await updateSettings({ nitterMediaMode, fxtwitterBaseUrl });
nitterStatus = 'saved';
setTimeout(() => (nitterStatus = 'idle'), 1500);
} catch {
nitterStatus = 'error';
}
}, 500);
}
const mediaModes: { label: string; value: 'self-host' | 'proxy' | 'direct' }[] = [
{ label: 'Self-host', value: 'self-host' },
{ label: 'Proxy (recommended)', value: 'proxy' },
{ label: 'Direct', value: 'direct' }
];
async function handleClearArticles() {
if (!confirm('Delete every published article and its media? Raw ingested items are kept, so sources can be re-synthesized fresh.')) return;
clearing = 'articles';
@@ -141,6 +166,40 @@
</div>
</div>
<div class="panel">
<div class="head">
<span class="panel-title">Nitter (tweet media)</span>
<SaveStatus status={nitterStatus} />
</div>
<p class="hint">
How images and video attached to ingested tweets are served to visitors. Self-hosting
downloads and stores everything locally, same as regular article images. Proxying streams
each request through this server without persisting anything, so only this server's IP is
ever exposed to Twitter's CDN. Direct hotlinks the original URL straight from Twitter, with
no server involvement at all.
</p>
<div class="pill-row">
{#each mediaModes as mode}
<button
class="pill"
class:active={nitterMediaMode === mode.value}
onclick={() => {
nitterMediaMode = mode.value;
scheduleNitterSave();
}}
>
{mode.label}
</button>
{/each}
</div>
<p class="hint" style="margin-top: 14px; margin-bottom: 6px;">
Enrichment API used to fetch full tweet text, author info, and media — any fxtwitter/FixTweet-
compatible endpoint works. Defaults to the public fxtwitter.com instance; point this at a
self-hosted FixTweet mirror (or another public instance) instead if you'd rather not depend on it.
</p>
<input type="text" bind:value={fxtwitterBaseUrl} oninput={scheduleNitterSave} placeholder="https://api.fxtwitter.com" style="width: 100%" />
</div>
<div class="panel">
<span class="panel-title">Clear content</span>
<p class="hint">