Use DNS over HTTPS only when a host has only proxy fake IP addresses. Keep private address checks and pinned article connections. Test DNS failures, redirects, and interruption.
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
import dns from "node:dns/promises"
|
|
import { isIP } from "node:net"
|
|
import { Readability } from "@mozilla/readability"
|
|
import { RecordType } from "@multiformats/dns"
|
|
import { dnsJsonOverHttps } from "@multiformats/dns/resolvers"
|
|
import { Config, Effect } from "effect"
|
|
import ipaddr from "ipaddr.js"
|
|
import { parseHTML } from "linkedom"
|
|
import { Agent, fetch } from "undici"
|
|
import { AppError } from "./errors"
|
|
|
|
const MAX_BYTES = 2_000_000
|
|
const MAX_TEXT = 60_000
|
|
const NETWORK_TIMEOUT = 20_000
|
|
const FAKE_IP_RANGE = ipaddr.IPv4.parseCIDR("198.18.0.0/15")
|
|
const redirects = new Set([301, 302, 303, 307, 308])
|
|
|
|
export function isPublicAddress(address: string): boolean {
|
|
try {
|
|
return ipaddr.process(address).range() === "unicast"
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function canonicalUrl(value: string): string {
|
|
let url: URL
|
|
try {
|
|
url = new URL(value)
|
|
} catch {
|
|
throw new AppError({ message: "Enter a valid URL.", status: 400 })
|
|
}
|
|
if (
|
|
!["http:", "https:"].includes(url.protocol) ||
|
|
url.username ||
|
|
url.password ||
|
|
(url.port && !["80", "443"].includes(url.port))
|
|
) {
|
|
throw new AppError({
|
|
message: "Use a public HTTP or HTTPS URL.",
|
|
status: 400,
|
|
})
|
|
}
|
|
url.hash = ""
|
|
for (const key of [...url.searchParams.keys()]) {
|
|
if (
|
|
/^utm_/i.test(key) ||
|
|
["fbclid", "gclid", "mc_cid", "mc_eid"].includes(key)
|
|
) {
|
|
url.searchParams.delete(key)
|
|
}
|
|
}
|
|
url.searchParams.sort()
|
|
return url.toString()
|
|
}
|
|
|
|
const resolveProxyHostname = Effect.fn("content.resolveProxyHostname")(
|
|
function* (hostname: string) {
|
|
const endpoint = yield* Config.String("DNS_OVER_HTTPS_URL").pipe(
|
|
Config.withDefault("https://cloudflare-dns.com/dns-query"),
|
|
)
|
|
if (endpoint === "off") return []
|
|
const url = yield* Effect.try({
|
|
try: () => {
|
|
const url = new URL(endpoint)
|
|
if (
|
|
url.protocol !== "https:" ||
|
|
url.username ||
|
|
url.password ||
|
|
url.search ||
|
|
url.hash
|
|
)
|
|
throw new Error(
|
|
"Use an HTTPS DNS endpoint without credentials or parameters.",
|
|
)
|
|
return url
|
|
},
|
|
catch: (cause) =>
|
|
new AppError({
|
|
message: "Set DNS_OVER_HTTPS_URL to a valid HTTPS DNS endpoint.",
|
|
status: 500,
|
|
cause,
|
|
}),
|
|
})
|
|
const resolve = dnsJsonOverHttps(url.toString())
|
|
const responses = yield* Effect.forEach(
|
|
[RecordType.A, RecordType.AAAA],
|
|
(type) =>
|
|
Effect.tryPromise({
|
|
try: (signal) => resolve(hostname, { types: type, signal }),
|
|
catch: (cause) =>
|
|
new AppError({
|
|
message: "The public DNS lookup failed. Try again.",
|
|
status: 502,
|
|
cause,
|
|
}),
|
|
}).pipe(
|
|
Effect.filterOrFail(
|
|
(response) => response.Status === 0 && !response.TC,
|
|
() =>
|
|
new AppError({
|
|
message: "The public DNS lookup failed. Try again.",
|
|
status: 502,
|
|
}),
|
|
),
|
|
),
|
|
{ concurrency: 2 },
|
|
).pipe(
|
|
Effect.timeout("10 seconds"),
|
|
Effect.mapError((cause) =>
|
|
cause instanceof AppError
|
|
? cause
|
|
: new AppError({
|
|
message: "The public DNS lookup timed out. Try again.",
|
|
status: 502,
|
|
cause,
|
|
}),
|
|
),
|
|
)
|
|
return responses.flatMap((response) =>
|
|
response.Answer.flatMap((answer) => {
|
|
// The resolver normalizes numeric DNS record types to their names.
|
|
const type = String(answer.type)
|
|
return ["A", "AAAA", "1", "28"].includes(type)
|
|
? [{ address: answer.data, family: isIP(answer.data) }]
|
|
: []
|
|
}),
|
|
)
|
|
},
|
|
)
|
|
|
|
export const validatePublicUrl = Effect.fn("content.validatePublicUrl")(
|
|
function* (value: string) {
|
|
const url = yield* Effect.try({
|
|
try: () => new URL(canonicalUrl(value)),
|
|
catch: (cause) =>
|
|
cause instanceof AppError
|
|
? cause
|
|
: new AppError({ message: "Enter a valid URL.", status: 400, cause }),
|
|
})
|
|
const hostname = url.hostname.replace(/^\[|\]$/g, "")
|
|
if (
|
|
hostname === "localhost" ||
|
|
hostname.endsWith(".localhost") ||
|
|
hostname.endsWith(".local") ||
|
|
hostname.endsWith(".internal") ||
|
|
(!hostname.includes(".") && !isIP(hostname))
|
|
) {
|
|
return yield* new AppError({
|
|
message: "The URL must use a public host.",
|
|
status: 400,
|
|
})
|
|
}
|
|
let addresses = yield* Effect.tryPromise({
|
|
try: () =>
|
|
isIP(hostname)
|
|
? Promise.resolve([{ address: hostname, family: isIP(hostname) }])
|
|
: dns.lookup(hostname, { all: true, verbatim: true }),
|
|
catch: (cause) =>
|
|
new AppError({
|
|
message: "The URL host could not be found.",
|
|
status: 400,
|
|
cause,
|
|
}),
|
|
}).pipe(
|
|
Effect.timeout("10 seconds"),
|
|
Effect.mapError((cause) =>
|
|
cause instanceof AppError
|
|
? cause
|
|
: new AppError({
|
|
message: "The URL host lookup timed out.",
|
|
status: 502,
|
|
cause,
|
|
}),
|
|
),
|
|
)
|
|
if (
|
|
!isIP(hostname) &&
|
|
addresses.length > 0 &&
|
|
addresses.every(
|
|
({ address }) =>
|
|
isIP(address) === 4 &&
|
|
ipaddr.IPv4.parse(address).match(FAKE_IP_RANGE),
|
|
)
|
|
) {
|
|
addresses = yield* resolveProxyHostname(hostname)
|
|
}
|
|
if (
|
|
!addresses.length ||
|
|
addresses.some(({ address }) => !isPublicAddress(address))
|
|
) {
|
|
return yield* new AppError({
|
|
message: "The URL must use a public host.",
|
|
status: 400,
|
|
})
|
|
}
|
|
return { url, addresses }
|
|
},
|
|
)
|
|
|
|
export const fetchDocument = Effect.fn("content.fetchDocument")(function* (
|
|
value: string,
|
|
accept = "text/html,text/plain,application/xhtml+xml",
|
|
) {
|
|
let current = value
|
|
for (let count = 0; count <= 5; count++) {
|
|
const { url, addresses } = yield* validatePublicUrl(current)
|
|
const result = yield* Effect.acquireUseRelease(
|
|
Effect.sync(
|
|
() =>
|
|
new Agent({
|
|
connect: {
|
|
timeout: 10_000,
|
|
lookup: (_hostname, options, callback) => {
|
|
// The connection uses the addresses that passed the check.
|
|
const selected =
|
|
addresses.find(
|
|
({ family }) =>
|
|
!options.family || family === options.family,
|
|
) ?? addresses[0]
|
|
if (!selected) {
|
|
callback(new Error("No public address is available."), "", 4)
|
|
} else if (options.all) {
|
|
callback(null, addresses)
|
|
} else {
|
|
callback(null, selected.address, selected.family)
|
|
}
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
(dispatcher) =>
|
|
Effect.tryPromise({
|
|
try: async (signal) => {
|
|
const response = await fetch(url, {
|
|
dispatcher,
|
|
redirect: "manual",
|
|
signal: AbortSignal.any([
|
|
signal,
|
|
AbortSignal.timeout(NETWORK_TIMEOUT),
|
|
]),
|
|
headers: {
|
|
Accept: accept,
|
|
"User-Agent": "Stellar/1.0 (feed reader)",
|
|
},
|
|
})
|
|
if (redirects.has(response.status)) {
|
|
await response.body?.cancel()
|
|
const location = response.headers.get("location")
|
|
if (!location) throw new Error("The redirect has no URL.")
|
|
return {
|
|
kind: "redirect" as const,
|
|
url: new URL(location, url).toString(),
|
|
}
|
|
}
|
|
if (!response.ok) {
|
|
await response.body?.cancel()
|
|
throw new Error(
|
|
`The remote server returned HTTP ${response.status}.`,
|
|
)
|
|
}
|
|
if (Number(response.headers.get("content-length")) > MAX_BYTES) {
|
|
await response.body?.cancel()
|
|
throw new Error("The document exceeds the size limit.")
|
|
}
|
|
const contentType = response.headers.get("content-type") ?? ""
|
|
if (!/text\/|xml|json/i.test(contentType)) {
|
|
await response.body?.cancel()
|
|
throw new Error("The URL must return a text document.")
|
|
}
|
|
const reader = response.body?.getReader()
|
|
if (!reader) throw new Error("The document is empty.")
|
|
const decoder = new TextDecoder()
|
|
let bytes = 0
|
|
let text = ""
|
|
try {
|
|
while (true) {
|
|
const chunk = await reader.read()
|
|
if (chunk.done) break
|
|
bytes += chunk.value.byteLength
|
|
if (bytes > MAX_BYTES)
|
|
throw new Error("The document exceeds the size limit.")
|
|
text += decoder.decode(chunk.value, { stream: true })
|
|
}
|
|
text += decoder.decode()
|
|
} finally {
|
|
await reader.cancel()
|
|
}
|
|
return {
|
|
kind: "document" as const,
|
|
url: url.toString(),
|
|
text,
|
|
contentType,
|
|
}
|
|
},
|
|
catch: (cause) =>
|
|
new AppError({
|
|
message: "The document could not be read.",
|
|
status: 502,
|
|
cause,
|
|
}),
|
|
}),
|
|
(dispatcher) => Effect.promise(() => dispatcher.destroy()),
|
|
)
|
|
if (result.kind === "redirect") {
|
|
current = result.url
|
|
} else {
|
|
return result
|
|
}
|
|
}
|
|
return yield* new AppError({
|
|
message: "The URL has too many redirects.",
|
|
status: 400,
|
|
})
|
|
})
|
|
|
|
export const extractArticle = Effect.fn("content.extractArticle")(function* (
|
|
url: string,
|
|
) {
|
|
const source = yield* fetchDocument(url)
|
|
return yield* Effect.try({
|
|
try: () => {
|
|
if (/^text\/plain/i.test(source.contentType)) {
|
|
return {
|
|
url: source.url,
|
|
title: new URL(source.url).hostname,
|
|
content: source.text.slice(0, MAX_TEXT),
|
|
}
|
|
}
|
|
const { document } = parseHTML(source.text)
|
|
Object.defineProperty(document, "documentURI", { value: source.url })
|
|
Object.defineProperty(document, "baseURI", { value: source.url })
|
|
const article = new Readability(document as unknown as Document).parse()
|
|
for (const element of document.querySelectorAll(
|
|
"script,style,nav,footer,header",
|
|
)) {
|
|
element.remove()
|
|
}
|
|
const content = (article?.textContent || document.body?.textContent || "")
|
|
.replace(/\n[\t ]+/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim()
|
|
.slice(0, MAX_TEXT)
|
|
if (!content) throw new Error("The document has no article text.")
|
|
return {
|
|
url: source.url,
|
|
title: (
|
|
article?.title ||
|
|
document.title ||
|
|
new URL(source.url).hostname
|
|
).slice(0, 300),
|
|
content,
|
|
}
|
|
},
|
|
catch: (cause) =>
|
|
new AppError({
|
|
message: "The article text could not be read.",
|
|
status: 422,
|
|
cause,
|
|
}),
|
|
})
|
|
})
|