Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,7 @@
"privateNetwork": "The destination points to an unauthorized private network.",
"responseTooLarge": "The response exceeds the maximum allowed size.",
"tooManyRedirects": "Too many redirects.",
"insecureRedirect": "Refused to follow a redirect from HTTPS to HTTP.",
"httpStatus": "HTTP {status}",
"connectFailed": "Connection failed: {reason}",
"feedRefreshing": "This feed is already being refreshed.",
Expand Down
1 change: 1 addition & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,7 @@
"privateNetwork": "El destino apunta a una red privada no autorizada.",
"responseTooLarge": "La respuesta supera el tamaño máximo permitido.",
"tooManyRedirects": "Demasiadas redirecciones.",
"insecureRedirect": "Redirección de HTTPS a HTTP rechazada.",
"httpStatus": "HTTP {status}",
"connectFailed": "Fallo de conexión: {reason}",
"feedRefreshing": "Este feed ya se está actualizando.",
Expand Down
1 change: 1 addition & 0 deletions messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,7 @@
"privateNetwork": "O destino aponta para uma rede privada não autorizada.",
"responseTooLarge": "A resposta excede o tamanho máximo permitido.",
"tooManyRedirects": "Redirecionamentos demais.",
"insecureRedirect": "Redirecionamento de HTTPS para HTTP recusado.",
"httpStatus": "HTTP {status}",
"connectFailed": "Falha na conexão: {reason}",
"feedRefreshing": "Este feed já está sendo atualizado.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "article" ADD COLUMN "contentHash" TEXT;
2 changes: 2 additions & 0 deletions prisma/postgres/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ model Article {
contentHtml String?
snippet String?
imageUrl String?
/// Impressão do título, link e HTML crus. Muda quando o feed republica o item.
contentHash String?
publishedAt DateTime @default(now())
fullContentHtml String?
storyId String? // artigos de feeds diferentes sobre o mesmo fato compartilham este id
Expand Down
2 changes: 2 additions & 0 deletions prisma/schema.base.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ model Article {
contentHtml String?
snippet String?
imageUrl String?
/// Impressão do título, link e HTML crus. Muda quando o feed republica o item.
contentHash String?
publishedAt DateTime @default(now())
fullContentHtml String?
storyId String? // artigos de feeds diferentes sobre o mesmo fato compartilham este id
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "article" ADD COLUMN "contentHash" TEXT;
2 changes: 2 additions & 0 deletions prisma/sqlite/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ model Article {
contentHtml String?
snippet String?
imageUrl String?
/// Impressão do título, link e HTML crus. Muda quando o feed republica o item.
contentHash String?
publishedAt DateTime @default(now())
fullContentHtml String?
storyId String? // artigos de feeds diferentes sobre o mesmo fato compartilham este id
Expand Down
6 changes: 3 additions & 3 deletions src/lib/feeds/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import "server-only";
import { staticTranslator } from "@/i18n/static";
import { LocalizedError } from "@/lib/localized-error";
import { commonPathCandidates, extractFeedCandidates, platformFeedCandidates, youtubeFeedFromHtml } from "./discover-candidates";
import { FetchError, safeFetch, type SafeFetchResult } from "./net";
import { FetchError, privateFeedsAllowed, safeFetch, type SafeFetchResult } from "./net";
import { looksLikeFeed, parseFeed, type ParsedFeed } from "./parse";

export type DiscoveredFeed = { url: string; title: string; siteUrl: string | null; itemCount: number; feed: ParsedFeed };
Expand Down Expand Up @@ -108,7 +108,7 @@ class Discovery {

private async probe(url: string): Promise<Probe> {
try {
const res = await safeFetch(url, { timeoutMs: PROBE_TIMEOUT_MS });
const res = await safeFetch(url, { timeoutMs: PROBE_TIMEOUT_MS, allowPrivateNetwork: privateFeedsAllowed() });
this.tried.add(res.url);
if (looksLikeFeed(res.body)) return { feed: await toDiscovered(res) };
return { html: res.body, url: res.url };
Expand Down Expand Up @@ -166,7 +166,7 @@ export async function discoverFeeds(input: string): Promise<DiscoveredFeed[]> {
let pageError: unknown = null;
discovery.markTried(url);
try {
page = await safeFetch(url);
page = await safeFetch(url, { allowPrivateNetwork: privateFeedsAllowed() });
} catch (error) {
if (isFatal(error)) throw error;
pageError = error;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/feeds/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export type ExtractedPage = {
};

/** Baixa a página e extrai o conteúdo principal (modo leitura) e os metadados. */
export async function extractPage(url: string): Promise<ExtractedPage> {
const res = await safeFetch(url, { accept: "text/html,application/xhtml+xml" });
export async function extractPage(url: string, opts: { allowPrivateNetwork?: boolean } = {}): Promise<ExtractedPage> {
const res = await safeFetch(url, { accept: "text/html,application/xhtml+xml", allowPrivateNetwork: opts.allowPrivateNetwork ?? false });
const { document } = parseHTML(res.body);
const pageTitle = document.querySelector("title")?.textContent?.trim() || null;
const base = document.createElement("base");
Expand Down
64 changes: 54 additions & 10 deletions src/lib/feeds/net.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "server-only";
import { isIP } from "node:net";
import { pinnedFetch } from "@/lib/network";
import { LocalizedError, type ServerErrorKey } from "@/lib/localized-error";

Expand Down Expand Up @@ -34,10 +35,38 @@ export function parseRetryAfter(value: string | null, now = Date.now()): number
return Number.isNaN(date) ? undefined : Math.max(0, Math.round((date - now) / 1000));
}

/** Feeds na rede local (RSS-Bridge). Não vale para capa nem extração de artigo. */
export const privateFeedsAllowed = () => process.env.ALLOW_PRIVATE_FEEDS === "true";

const MULTI_PART_SUFFIX = new Set([
"com.br", "net.br", "org.br", "gov.br",
"co.uk", "org.uk", "com.au", "co.jp", "co.nz", "com.mx", "com.ar", "com.tr", "co.za",
"com.cn", "com.tw", "co.kr", "com.sg", "com.pt", "com.co",
]);

function registrableDomain(hostname: string): string {
const host = hostname.replace(/\.$/, "").toLowerCase();
if (isIP(host)) return host;
const parts = host.split(".").filter(Boolean);
if (parts.length < 2) return host;
const tail = parts.slice(-2).join(".");
if (MULTI_PART_SUFFIX.has(tail) && parts.length >= 3) return parts.slice(-3).join(".");
return tail;
}

/** Mesmo site (domínio registrável), para aceitar uma URL nova de feed depois de um 301/308. */
export function sameSite(a: string, b: string): boolean {
try {
return registrableDomain(new URL(a).hostname) === registrableDomain(new URL(b).hostname);
} catch {
return false;
}
}

/** fetch com timeout, limite de tamanho, redirects checados e proteção SSRF. */
export async function safeFetch(
input: string,
opts: { headers?: Record<string, string>; timeoutMs?: number; maxBytes?: number; accept?: string } = {},
opts: { headers?: Record<string, string>; timeoutMs?: number; maxBytes?: number; accept?: string; allowPrivateNetwork?: boolean } = {},
): Promise<SafeFetchResult> {
const { timeoutMs = 15000, maxBytes = 5 * 1024 * 1024 } = opts;
let url = new URL(input);
Expand All @@ -55,25 +84,29 @@ export async function safeFetch(
headers: {
"user-agent": USER_AGENT,
accept: opts.accept ?? "application/rss+xml, application/atom+xml, application/xml, text/xml, text/html;q=0.8, */*;q=0.5",
...opts.headers,
// Cache condicional só no primeiro hop: um 304 do destino do redirect não é "o feed não mudou".
...(hop === 0 ? opts.headers : {}),
},
},
process.env.ALLOW_PRIVATE_FEEDS === "true",
opts.allowPrivateNetwork ?? false,
);
} catch (error) {
if (error instanceof LocalizedError) throw error;
throw new FetchError("connectFailed", { reason: (error instanceof Error ? error.message : String(error)).slice(0, 300) });
}
try {
const res = connection.response;
const movedPermanently = redirects > 0 && allPermanent;
// 304 não é redirect, mesmo com Location.
if (res.status === 304) return { status: 304, url: url.toString(), headers: res.headers, body: "", movedPermanently };
if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
url = new URL(res.headers.get("location")!, url);
const next = new URL(res.headers.get("location")!, url);
if (url.protocol === "https:" && next.protocol !== "https:") throw new FetchError("insecureRedirect");
url = next;
redirects++;
allPermanent &&= res.status === 301 || res.status === 308;
continue;
}
const movedPermanently = redirects > 0 && allPermanent;
if (res.status === 304) return { status: 304, url: url.toString(), headers: res.headers, body: "", movedPermanently };
if (!res.ok) throw new FetchError("httpStatus", { status: res.status }, res.status, parseRetryAfter(res.headers.get("retry-after")));

const reader = res.body?.getReader();
Expand All @@ -99,14 +132,25 @@ export async function safeFetch(
throw new FetchError("tooManyRedirects");
}

function decode(buffer: Buffer, headers: { get(name: string): string | null }): string {
const fromHeader = /charset=([^;]+)/i.exec(headers.get("content-type") ?? "")?.[1];
function unquoteCharset(value: string | undefined): string | undefined {
if (!value) return undefined;
const cleaned = value.trim().replace(/^["']+|["']+$/g, "").trim().toLowerCase();
return cleaned || undefined;
}

/** Decodifica o corpo usando o charset do header (sem aspas) ou da declaração do XML. */
export function decodeFeedBody(buffer: Buffer, contentType: string | null): string {
const fromHeader = unquoteCharset(/charset=\s*("[^"]*"|'[^']*'|[^;\s]+)/i.exec(contentType ?? "")?.[1]);
const head = buffer.subarray(0, 1024).toString("latin1");
const fromBody = /encoding=["']([^"']+)["']/i.exec(head)?.[1] ?? /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)?.[1];
const charset = (fromHeader ?? fromBody ?? "utf-8").trim().toLowerCase();
const fromBody = unquoteCharset(/encoding=["']([^"']+)["']/i.exec(head)?.[1] ?? /<meta[^>]+charset=["']?([\w-]+)/i.exec(head)?.[1]);
const charset = fromHeader ?? fromBody ?? "utf-8";
try {
return new TextDecoder(charset).decode(buffer);
} catch {
return new TextDecoder("utf-8").decode(buffer);
}
}

function decode(buffer: Buffer, headers: { get(name: string): string | null }): string {
return decodeFeedBody(buffer, headers.get("content-type"));
}
Loading
Loading