Ctrl K
ComunidadGuías…

lib/middleware/anti-hotlink.test.ts

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

Archivo original ↗Preguntar a KiBot
import { Context } from 'hono';
import Parser from 'rss-parser';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';

import type { Data } from '@/types';

const parser = new Parser();

afterAll(() => {
    delete process.env.HOTLINK_TEMPLATE;
    delete process.env.HOTLINK_INCLUDE_PATHS;
    delete process.env.HOTLINK_EXCLUDE_PATHS;
    delete process.env.ALLOW_USER_HOTLINK_TEMPLATE;
});

afterEach(() => {
    delete process.env.HOTLINK_TEMPLATE;
    delete process.env.HOTLINK_INCLUDE_PATHS;
    delete process.env.HOTLINK_EXCLUDE_PATHS;
    delete process.env.ALLOW_USER_HOTLINK_TEMPLATE;
    vi.resetModules();
});

const expects = {
    complicated: {
        origin: {
            items: [
                `<a href="https://mock.com/DIYgod/RSSHub"></a>
<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">

<a href="http://mock.com/DIYgod/RSSHub"></a>
<img src="https://mock.com/DIYgod/RSSHub.jpg" data-src="/DIYgod/RSSHub0.jpg" referrerpolicy="no-referrer">
<img data-src="/DIYgod/RSSHub.jpg" src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
<img data-mock="/DIYgod/RSSHub.png" src="https://mock.com/DIYgod/RSSHub.png" referrerpolicy="no-referrer">
<img mock="/DIYgod/RSSHub.gif" src="https://mock.com/DIYgod/RSSHub.gif" referrerpolicy="no-referrer">
<img src="http://mock.com/DIYgod/DIYgod/RSSHub" referrerpolicy="no-referrer">
<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">`,
                `<a href="https://mock.com/DIYgod/RSSHub"></a>
<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">`,
            ],
            desc: '<img src="http://mock.com/DIYgod/DIYgod/RSSHub"> - Powered by RSSHub',
        },
        processed: {
            items: [
                `<a href="https://mock.com/DIYgod/RSSHub"></a>
<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">

<a href="http://mock.com/DIYgod/RSSHub"></a>
<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" data-src="/DIYgod/RSSHub0.jpg" referrerpolicy="no-referrer">
<img data-src="/DIYgod/RSSHub.jpg" src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
<img data-mock="/DIYgod/RSSHub.png" src="https://i3.wp.com/mock.com/DIYgod/RSSHub.png" referrerpolicy="no-referrer">
<img mock="/DIYgod/RSSHub.gif" src="https://i3.wp.com/mock.com/DIYgod/RSSHub.gif" referrerpolicy="no-referrer">
<img src="https://i3.wp.com/mock.com/DIYgod/DIYgod/RSSHub" referrerpolicy="no-referrer">
<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">`,
                `<a href="https://mock.com/DIYgod/RSSHub"></a>
<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">`,
            ],
            desc: '<img src="https://i3.wp.com/mock.com/DIYgod/DIYgod/RSSHub"> - Powered by RSSHub',
        },
        urlencoded: {
            items: [
                `<a href="https://mock.com/DIYgod/RSSHub"></a>
<img src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" referrerpolicy="no-referrer">

<a href="http://mock.com/DIYgod/RSSHub"></a>
<img src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" data-src="/DIYgod/RSSHub0.jpg" referrerpolicy="no-referrer">
<img data-src="/DIYgod/RSSHub.jpg" src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" referrerpolicy="no-referrer">
<img data-mock="/DIYgod/RSSHub.png" src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.png" referrerpolicy="no-referrer">
<img mock="/DIYgod/RSSHub.gif" src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.gif" referrerpolicy="no-referrer">
<img src="https://images.weserv.nl?url=http%3A%2F%2Fmock.com%2FDIYgod%2FDIYgod%2FRSSHub" referrerpolicy="no-referrer">
<img src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" referrerpolicy="no-referrer">
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">`,
                `<a href="https://mock.com/DIYgod/RSSHub"></a>
<img src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" referrerpolicy="no-referrer">`,
            ],
            desc: '<img src="https://images.weserv.nl?url=http%3A%2F%2Fmock.com%2FDIYgod%2FDIYgod%2FRSSHub"> - Powered by RSSHub',
        },
    },
    multimedia: {
        origin: {
            items: [
                `<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
<video src="https://mock.com/DIYgod/RSSHub.mp4"></video>
<video poster="https://mock.com/DIYgod/RSSHub.jpg">
<source src="https://mock.com/DIYgod/RSSHub.mp4" type="video/mp4">
<source src="https://mock.com/DIYgod/RSSHub.webm" type="video/webm">
</video>
<audio src="https://mock.com/DIYgod/RSSHub.mp3"></audio>
<iframe src="https://mock.com/DIYgod/RSSHub.html" referrerpolicy="no-referrer"></iframe>`,
            ],
            desc: '<video src="http://mock.com/DIYgod/DIYgod/RSSHub"></video> - Powered by RSSHub',
        },
        relayed: {
            items: [
                `<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
<video src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4"></video>
<video poster="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg">
<source src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4" type="video/mp4">
<source src="https://i3.wp.com/mock.com/DIYgod/RSSHub.webm" type="video/webm">
</video>
<audio src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp3"></audio>
<iframe src="https://mock.com/DIYgod/RSSHub.html" referrerpolicy="no-referrer"></iframe>`,
            ],
            desc: '<video src="https://i3.wp.com/mock.com/DIYgod/DIYgod/RSSHub"></video> - Powered by RSSHub',
        },
        partlyRelayed: {
            items: [
                `<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">
<video src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4"></video>
<video poster="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg">
<source src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4" type="video/mp4">
<source src="https://i3.wp.com/mock.com/DIYgod/RSSHub.webm" type="video/webm">
</video>
<audio src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp3"></audio>
<iframe src="https://mock.com/DIYgod/RSSHub.html" referrerpolicy="no-referrer"></iframe>`,
            ],
            desc: '<video src="https://i3.wp.com/mock.com/DIYgod/DIYgod/RSSHub"></video> - Powered by RSSHub',
        },
    },
    extraComplicated: {
        origin: {
            items: [
                {
                    content:
                        '<a href="https://mock.com/DIYgod/RSSHub"></a>\n<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n\n<a href="http://mock.com/DIYgod/RSSHub"></a>\n<img src="https://mock.com/DIYgod/RSSHub.jpg" data-src="/DIYgod/RSSHub0.jpg" referrerpolicy="no-referrer">\n<img data-src="/DIYgod/RSSHub.jpg" src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<img data-mock="/DIYgod/RSSHub.png" src="https://mock.com/DIYgod/RSSHub.png" referrerpolicy="no-referrer">\n<img mock="/DIYgod/RSSHub.gif" src="https://mock.com/DIYgod/RSSHub.gif" referrerpolicy="no-referrer">\n<img src="http://mock.com/DIYgod/DIYgod/RSSHub" referrerpolicy="no-referrer">\n<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">',
                    itunes: {},
                },
                {
                    content: '<a href="https://mock.com/DIYgod/RSSHub"></a>\n<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">',
                    itunes: {},
                },
                {
                    content:
                        '<a href="https://mock.com/DIYgod/RSSHub"></a>\n<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">',
                    enclosure: {
                        url: 'https://mock.com/DIYgod/RSSHub.png',
                        type: 'image/png',
                    },
                    itunes: {
                        image: 'https://mock.com/DIYgod/RSSHub.gif',
                    },
                },
            ],
            image: {
                link: 'https://github.com/DIYgod/RSSHub',
                url: 'https://mock.com/DIYgod/RSSHub.png',
                title: 'Test complicated',
            },
            description: '<img src="http://mock.com/DIYgod/DIYgod/RSSHub"> - Powered by RSSHub',
        },
        processed: {
            items: [
                {
                    content:
                        '<a href="https://mock.com/DIYgod/RSSHub"></a>\n<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n\n<a href="http://mock.com/DIYgod/RSSHub"></a>\n<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" data-src="/DIYgod/RSSHub0.jpg" referrerpolicy="no-referrer">\n<img data-src="/DIYgod/RSSHub.jpg" src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<img data-mock="/DIYgod/RSSHub.png" src="https://i3.wp.com/mock.com/DIYgod/RSSHub.png" referrerpolicy="no-referrer">\n<img mock="/DIYgod/RSSHub.gif" src="https://i3.wp.com/mock.com/DIYgod/RSSHub.gif" referrerpolicy="no-referrer">\n<img src="https://i3.wp.com/mock.com/DIYgod/DIYgod/RSSHub" referrerpolicy="no-referrer">\n<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">',
                    itunes: {},
                },
                {
                    content: '<a href="https://mock.com/DIYgod/RSSHub"></a>\n<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">',
                    itunes: {},
                },
                {
                    content:
                        '<a href="https://mock.com/DIYgod/RSSHub"></a>\n<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">',
                    enclosure: {
                        url: 'https://i3.wp.com/mock.com/DIYgod/RSSHub.png',
                        type: 'image/png',
                    },
                    itunes: {
                        image: 'https://i3.wp.com/mock.com/DIYgod/RSSHub.gif',
                    },
                },
            ],
            image: {
                link: 'https://github.com/DIYgod/RSSHub',
                url: 'https://i3.wp.com/mock.com/DIYgod/RSSHub.png',
                title: 'Test complicated',
            },
            description: '<img src="https://i3.wp.com/mock.com/DIYgod/DIYgod/RSSHub"> - Powered by RSSHub',
        },
        urlencoded: {
            items: [
                {
                    content:
                        '<a href="https://mock.com/DIYgod/RSSHub"></a>\n<img src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" referrerpolicy="no-referrer">\n\n<a href="http://mock.com/DIYgod/RSSHub"></a>\n<img src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" data-src="/DIYgod/RSSHub0.jpg" referrerpolicy="no-referrer">\n<img data-src="/DIYgod/RSSHub.jpg" src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" referrerpolicy="no-referrer">\n<img data-mock="/DIYgod/RSSHub.png" src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.png" referrerpolicy="no-referrer">\n<img mock="/DIYgod/RSSHub.gif" src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.gif" referrerpolicy="no-referrer">\n<img src="https://images.weserv.nl?url=http%3A%2F%2Fmock.com%2FDIYgod%2FDIYgod%2FRSSHub" referrerpolicy="no-referrer">\n<img src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" referrerpolicy="no-referrer">\n<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">',
                    itunes: {},
                },
                {
                    content: '<a href="https://mock.com/DIYgod/RSSHub"></a>\n<img src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" referrerpolicy="no-referrer">',
                    itunes: {},
                },
                {
                    content:
                        '<a href="https://mock.com/DIYgod/RSSHub"></a>\n<img src="https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.jpg" referrerpolicy="no-referrer">\n<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" referrerpolicy="no-referrer">',
                    enclosure: {
                        url: 'https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.png',
                        type: 'image/png',
                    },
                    itunes: {
                        image: 'https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.gif',
                    },
                },
            ],
            image: {
                link: 'https://github.com/DIYgod/RSSHub',
                url: 'https://images.weserv.nl?url=https%3A%2F%2Fmock.com%2FDIYgod%2FRSSHub.png',
                title: 'Test complicated',
            },
            description: '<img src="https://images.weserv.nl?url=http%3A%2F%2Fmock.com%2FDIYgod%2FDIYgod%2FRSSHub"> - Powered by RSSHub',
        },
    },
    extraMultimedia: {
        origin: {
            items: [
                {
                    content:
                        '<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<video src="https://mock.com/DIYgod/RSSHub.mp4"></video>\n<video poster="https://mock.com/DIYgod/RSSHub.jpg">\n<source src="https://mock.com/DIYgod/RSSHub.mp4" type="video/mp4">\n<source src="https://mock.com/DIYgod/RSSHub.webm" type="video/webm">\n</video>\n<audio src="https://mock.com/DIYgod/RSSHub.mp3"></audio>\n<iframe src="https://mock.com/DIYgod/RSSHub.html" referrerpolicy="no-referrer"></iframe>',
                },
                {
                    content: '<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<video src="https://mock.com/DIYgod/RSSHub.mp4"></video>',
                    enclosure: {
                        url: 'https://mock.com/DIYgod/RSSHub.mp4',
                        type: 'video/mp4',
                    },
                },
            ],
            description: '<video src="http://mock.com/DIYgod/DIYgod/RSSHub"></video> - Powered by RSSHub',
        },
        relayed: {
            items: [
                {
                    content:
                        '<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<video src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4"></video>\n<video poster="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg">\n<source src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4" type="video/mp4">\n<source src="https://i3.wp.com/mock.com/DIYgod/RSSHub.webm" type="video/webm">\n</video>\n<audio src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp3"></audio>\n<iframe src="https://mock.com/DIYgod/RSSHub.html" referrerpolicy="no-referrer"></iframe>',
                },
                {
                    content: '<img src="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<video src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4"></video>',
                    enclosure: {
                        url: 'https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4',
                        type: 'video/mp4',
                    },
                },
            ],
            description: '<video src="https://i3.wp.com/mock.com/DIYgod/DIYgod/RSSHub"></video> - Powered by RSSHub',
        },
        partlyRelayed: {
            items: [
                {
                    content:
                        '<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<video src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4"></video>\n<video poster="https://i3.wp.com/mock.com/DIYgod/RSSHub.jpg">\n<source src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4" type="video/mp4">\n<source src="https://i3.wp.com/mock.com/DIYgod/RSSHub.webm" type="video/webm">\n</video>\n<audio src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp3"></audio>\n<iframe src="https://mock.com/DIYgod/RSSHub.html" referrerpolicy="no-referrer"></iframe>',
                },
                {
                    content: '<img src="https://mock.com/DIYgod/RSSHub.jpg" referrerpolicy="no-referrer">\n<video src="https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4"></video>',
                    enclosure: {
                        url: 'https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4',
                        type: 'video/mp4',
                    },
                },
            ],
            description: '<video src="https://i3.wp.com/mock.com/DIYgod/DIYgod/RSSHub"></video> - Powered by RSSHub',
        },
    },
};

const testAntiHotlink = async (path, expectObj, query?: Record<string, string>) => {
    const app = (await import('@/app')).default;

    let queryStr;
    if (query) {
        queryStr = Object.entries(query)
            .map(([key, value]) => `${key}=${value}`)
            .join('&');
    }
    path += queryStr ? `?${queryStr}` : '';

    const response = await app.request(path);
    const parsed = await parser.parseString(await response.text());
    expect({
        items: parsed.items.slice(0, expectObj.items.length).map((i) => i.content),
        desc: parsed.description,
    }).toStrictEqual(expectObj);

    return parsed;
};

const testAntiHotlinkExtra = async (path, expectObj, query?: Record<string, string>) => {
    const app = (await import('@/app')).default;

    path += query ? `?${new URLSearchParams(query).toString()}` : '';

    const response = await app.request(path);
    const parsed = await parser.parseString(await response.text());
    const obj = {
        description: parsed.description,
        image: parsed.image,
        items: parsed.items.slice(0, expectObj.items.length).map((e) => ({
            content: e.content,
            enclosure: e.enclosure,
            itunes: e.itunes,
        })),
    };
    expect(obj).toEqual(expectObj);

    return parsed;
};

const expectImgOrigin = async () => {
    await testAntiHotlink('/test/complicated', expects.complicated.origin);
    await testAntiHotlinkExtra('/test/complicated', expects.extraComplicated.origin);
};
const expectImgProcessed = async (query?: Record<string, string>) => {
    await testAntiHotlink('/test/complicated', expects.complicated.processed, query);
    await testAntiHotlinkExtra('/test/complicated', expects.extraComplicated.processed, query);
};

const expectImgUrlencoded = async (query?: Record<string, string>) => {
    await testAntiHotlink('/test/complicated', expects.complicated.urlencoded, query);
    await testAntiHotlinkExtra('/test/complicated', expects.extraComplicated.urlencoded, query);
};

const expectMultimediaOrigin = async (query?: Record<string, string>) => {
    await testAntiHotlink('/test/multimedia', expects.multimedia.origin, query);
    await testAntiHotlinkExtra('/test/multimedia', expects.extraMultimedia.origin, query);
};

const expectMultimediaRelayed = async (query?: Record<string, string>) => {
    await testAntiHotlink('/test/multimedia', expects.multimedia.relayed, query);
    await testAntiHotlinkExtra('/test/multimedia', expects.extraMultimedia.relayed, query);
};

const expectMultimediaPartlyRelayed = async (query?: Record<string, string>) => {
    await testAntiHotlink('/test/multimedia', expects.multimedia.partlyRelayed, query);
    await testAntiHotlinkExtra('/test/multimedia', expects.extraMultimedia.partlyRelayed, query);
};

describe('anti-hotlink', () => {
    it('template-legacy', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        await expectImgProcessed();
    });

    it('template-experimental', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        process.env.ALLOW_USER_HOTLINK_TEMPLATE = 'true';
        await expectImgProcessed();
        await expectMultimediaRelayed({ multimedia_hotlink_template: process.env.HOTLINK_TEMPLATE });
    });

    it('url', async () => {
        process.env.HOTLINK_TEMPLATE = '${protocol}//${host}${pathname}';
        await expectImgOrigin();
        await expectMultimediaOrigin({ multimedia_hotlink_template: process.env.HOTLINK_TEMPLATE });
    });

    it('url-encoded', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://images.weserv.nl?url=${href_ue}';
        await expectImgUrlencoded();
    });

    it('template-priority-legacy', async () => {
        process.env.HOTLINK_TEMPLATE = '${protocol}//${host}${pathname}';
        await expectImgOrigin();
    });

    it('template-priority-experimental', async () => {
        process.env.ALLOW_USER_HOTLINK_TEMPLATE = 'true';
        await expectImgOrigin();
        await expectImgProcessed({ image_hotlink_template: 'https://i3.wp.com/${host}${pathname}' });
    });

    it('no-template', async () => {
        process.env.HOTLINK_TEMPLATE = '';
        await expectImgOrigin();
        await expectMultimediaOrigin();
    });

    it('multimedia-template-experimental', async () => {
        process.env.ALLOW_USER_HOTLINK_TEMPLATE = 'true';
        await expectMultimediaOrigin({ multimedia_hotlink_template: '${protocol}//${host}${pathname}' });
        await expectMultimediaPartlyRelayed({ multimedia_hotlink_template: 'https://i3.wp.com/${host}${pathname}' });
    });

    it('include-paths-partial-matched', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        process.env.HOTLINK_INCLUDE_PATHS = '/test';
        await expectImgProcessed();
    });

    it('include-paths-fully-matched', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        process.env.HOTLINK_INCLUDE_PATHS = '/test/complicated';
        await expectImgProcessed();
    });

    it('include-paths-unmatched', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        process.env.HOTLINK_INCLUDE_PATHS = '/t';
        await expectImgOrigin();
    });

    it('exclude-paths-partial-matched', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        process.env.HOTLINK_EXCLUDE_PATHS = '/test';
        await expectImgOrigin();
    });

    it('exclude-paths-fully-matched', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        process.env.HOTLINK_EXCLUDE_PATHS = '/test/complicated';
        await expectImgOrigin();
    });

    it('exclude-paths-unmatched', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        process.env.HOTLINK_EXCLUDE_PATHS = '/t';
        await expectImgProcessed();
    });

    it('include-exclude-paths-mixed-filtered-out', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        process.env.HOTLINK_INCLUDE_PATHS = '/test';
        process.env.HOTLINK_EXCLUDE_PATHS = '/test/complicated';
        await expectImgOrigin();
    });

    it('include-exclude-paths-mixed-unfiltered-out', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${host}${pathname}';
        process.env.HOTLINK_INCLUDE_PATHS = '/test';
        process.env.HOTLINK_EXCLUDE_PATHS = '/test/c';
        await expectImgProcessed();
    });

    it('invalid-property', async () => {
        process.env.HOTLINK_TEMPLATE = 'https://i3.wp.com/${createObjectURL}';
        const app = (await import('@/app')).default;
        const response = await app.request('/test/complicated');
        expect(await response.text()).toContain('Error: Invalid URL property: createObjectURL');
    });
});

const createCtx = (query: Record<string, string>, data: Data) => {
    const url = new URL('http://localhost/test/path');
    for (const [key, value] of Object.entries(query)) {
        url.searchParams.set(key, value);
    }
    const ctx = new Context(new Request(url), { env: {}, path: url.pathname });
    ctx.set('data', data);
    return ctx;
};

describe('anti-hotlink edge cases', () => {
    it('logs parse errors and keeps invalid urls', async () => {
        const { config } = await import('@/config');
        config.feature.allow_user_hotlink_template = true;

        const { default: logger } = await import('@/utils/logger');
        const errorSpy = vi.spyOn(logger, 'error');
        const { default: antiHotlink } = await import('@/middleware/anti-hotlink');
        const data = {
            title: 'test',
            image: 'http://invalid url',
        };
        const ctx = createCtx({ image_hotlink_template: 'https://img.test/${href}' }, data);

        await antiHotlink(ctx, async () => {});

        expect(data.image).toBe('http://invalid url');
        expect(errorSpy).toHaveBeenCalled();
        errorSpy.mockRestore();
    });

    it('returns original url when template is missing', async () => {
        const { config } = await import('@/config');
        config.feature.allow_user_hotlink_template = true;

        const { default: antiHotlink } = await import('@/middleware/anti-hotlink');
        const data = {
            title: 'test',
            image: 'https://example.com/img.jpg',
        };
        const ctx = createCtx({ multimedia_hotlink_template: 'https://media.test/${href}' }, data);

        await antiHotlink(ctx, async () => {});

        expect(data.image).toBe('https://example.com/img.jpg');
    });
});

describe('image_hotlink_domains', () => {
    it('rewrites only selected image domains and keeps multimedia rewriting independent', async () => {
        process.env.ALLOW_USER_HOTLINK_TEMPLATE = 'true';
        const { default: middleware } = await import('@/middleware/anti-hotlink');
        const ctx = new Context(
            new Request(
                'http://localhost/feed?image_hotlink_template=https%3A%2F%2Fproxy.example%2F%24%7Bhostname%7D%24%7Bpathname%7D&image_hotlink_domains=images.example%2C%20CDN.EXAMPLE&multimedia_hotlink_template=https%3A%2F%2Fmedia.example%2F%24%7Bhostname%7D%24%7Bpathname%7D'
            )
        );
        ctx.set('data', {
            title: 'Feed',
            image: 'https://other.example/logo.png',
            item: [
                {
                    title: 'Post',
                    image: 'https://images.example/cover.png',
                    description:
                        '<img src="https://images.example/a.png"><img src="https://other.example/a.png"><img src="https://cdn.example/b.png"><img src="https://sub.images.example/c.png"><video src="https://other.example/v.mp4" poster="https://other.example/poster.png"></video>',
                    enclosure_url: 'https://other.example/audio.mp3',
                    enclosure_type: 'audio/mpeg',
                },
            ],
        });
        await middleware(ctx, async () => {});
        const data = ctx.get('data');
        expect(data.image).toBe('https://other.example/logo.png');
        expect(data.item[0].image).toBe('https://proxy.example/images.example/cover.png');
        expect(data.item[0].description).toContain('https://proxy.example/images.example/a.png');
        expect(data.item[0].description).toContain('https://proxy.example/cdn.example/b.png');
        expect(data.item[0].description).toContain('https://other.example/a.png');
        expect(data.item[0].description).toContain('https://sub.images.example/c.png');
        expect(data.item[0].description).toContain('poster="https://other.example/poster.png"');
        expect(data.item[0].enclosure_url).toBe('https://media.example/other.example/audio.mp3');
    });
});