Ctrl K
ComunidadGuías…

lib/middleware/anti-hotlink.ts

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string>(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;