import type { CheerioAPI } from 'cheerio'; import { load } from 'cheerio'; import type { MiddlewareHandler } from 'hono'; import { config } from '@/config'; import type { Data } from '@/types'; import logger from '@/utils/logger'; const templateRegex = /\$\{([^{}]+)\}/g; const urlProperties = ['hash', 'host', 'hostname', 'href', 'origin', 'password', 'pathname', 'port', 'protocol', 'search', 'searchParams', 'username'] as const; type UrlProperty = (typeof urlProperties)[number]; const allowedUrlProperties = new Set(urlProperties); const isUrlProperty = (prop: string): prop is UrlProperty => allowedUrlProperties.has(prop); // match path or sub-path const matchPath = (path: string, paths: string[]) => { for (const p of paths) { if (path.startsWith(p) && (path.length === p.length || path[p.length] === '/')) { return true; } } return false; }; // return true if the path needs to be processed const filterPath = (path: string) => { const include = config.hotlink.includePaths; const exclude = config.hotlink.excludePaths; return !(include && !matchPath(path, include)) && !(exclude && matchPath(path, exclude)); }; const interpolate = (str: string, url: URL) => str.replaceAll(templateRegex, (_, prop: string) => { let needEncode = false; if (prop.endsWith('_ue')) { // url encode prop = prop.slice(0, -3); needEncode = true; } if (!isUrlProperty(prop)) { throw new Error(`Invalid URL property: ${prop}`); } const value = String(url[prop]); return needEncode ? encodeURIComponent(value) : value; }); const parseUrl = (str: string) => { let url; try { url = new URL(str); } catch { logger.error(`Failed to parse ${str}`); } return url; }; const matchesDomain = (url: URL, domains?: string[]) => !domains?.length || domains.includes(url.hostname.toLowerCase()); const replaceUrl = (template?: string, url?: string, domains?: string[]) => { if (!template || !url) { return url; } const oldUrl = parseUrl(url); if (oldUrl && oldUrl.protocol !== 'data:' && matchesDomain(oldUrl, domains)) { return interpolate(template, oldUrl); } return url; }; const replaceUrls = ($: CheerioAPI, selector: string, template: string, attribute = 'src', domains?: string[]) => { $(selector).each((_, el) => { const oldSrc = $(el).attr(attribute); if (!oldSrc) { return; } const url = parseUrl(oldSrc); if (url && url.protocol !== 'data:' && matchesDomain(url, domains)) { // Cheerio will do the right thing to prohibit XSS. $(el).attr(attribute, interpolate(template, url)); } }); }; const process = (html: string, image_hotlink_template?: string, multimedia_hotlink_template?: string, imageDomains?: string[]) => { const $ = load(html, undefined, false); if (image_hotlink_template) { replaceUrls($, 'img, picture > source', image_hotlink_template, 'src', imageDomains); replaceUrls($, 'video[poster]', image_hotlink_template, 'poster', imageDomains); replaceUrls($, '*[data-rsshub-image="href"]', image_hotlink_template, 'href', imageDomains); } if (multimedia_hotlink_template) { replaceUrls($, 'video, video > source, audio, audio > source', multimedia_hotlink_template); if (!image_hotlink_template) { replaceUrls($, 'video[poster]', multimedia_hotlink_template, 'poster'); } } return $.html(); }; const validateTemplate = (template?: string) => { if (!template) { return; } for (const match of template.matchAll(templateRegex)) { const prop = match[1].endsWith('_ue') ? match[1].slice(0, -3) : match[1]; if (!allowedUrlProperties.has(prop)) { throw new Error(`Invalid URL property: ${prop}`); } } }; const middleware: MiddlewareHandler = async (ctx, next) => { await next(); let imageHotlinkTemplate: string | undefined; let multimediaHotlinkTemplate: string | undefined; let imageDomains: string[] | undefined; // Read params if enabled if (config.feature.allow_user_hotlink_template) { // By default, the config turns these features off. Set corresponding config to // true to turn this feature on. // A risk is that the media URLs will be replaced by user-supplied templates, // so a user could literally take the control of "where are the media from", // but only in their personal-use feed URL. multimediaHotlinkTemplate = ctx.req.query('multimedia_hotlink_template'); imageHotlinkTemplate = ctx.req.query('image_hotlink_template'); imageDomains = ctx.req .query('image_hotlink_domains') ?.split(',') .map((domain) => domain.trim().toLowerCase()) .filter(Boolean); } // Force config hotlink template on conflict if (config.hotlink.template) { imageDomains = undefined; imageHotlinkTemplate = filterPath(ctx.req.path) ? config.hotlink.template : undefined; multimediaHotlinkTemplate = filterPath(ctx.req.path) ? config.hotlink.template : undefined; } if (!imageHotlinkTemplate && !multimediaHotlinkTemplate) { return; } validateTemplate(imageHotlinkTemplate); validateTemplate(multimediaHotlinkTemplate); // Assume that only description include image link // and here we will only check them in description. // Use Cheerio to load the description as html and filter all // image link const data: Data = ctx.get('data'); if (!data) { return; } if (data.image) { data.image = replaceUrl(imageHotlinkTemplate, data.image, imageDomains); } if (data.description) { data.description = process(data.description, imageHotlinkTemplate, multimediaHotlinkTemplate, imageDomains); } if (data.item) { for (const item of data.item) { if (item.description) { item.description = process(item.description, imageHotlinkTemplate, multimediaHotlinkTemplate, imageDomains); } if (item.enclosure_url && item.enclosure_type) { if (item.enclosure_type.startsWith('image/')) { item.enclosure_url = replaceUrl(imageHotlinkTemplate, item.enclosure_url, imageDomains); } else if (/^(?:video|audio)\//.test(item.enclosure_type)) { item.enclosure_url = replaceUrl(multimediaHotlinkTemplate, item.enclosure_url); } } if (item.image) { item.image = replaceUrl(imageHotlinkTemplate, item.image, imageDomains); } if (item.itunes_item_image) { item.itunes_item_image = replaceUrl(imageHotlinkTemplate, item.itunes_item_image, imageDomains); } } } ctx.set('data', data); }; export default middleware;