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: [ ` `, ` `, ], desc: ' - Powered by RSSHub', }, processed: { items: [ ` `, ` `, ], desc: ' - Powered by RSSHub', }, urlencoded: { items: [ ` `, ` `, ], desc: ' - Powered by RSSHub', }, }, multimedia: { origin: { items: [ ` `, ], desc: ' - Powered by RSSHub', }, relayed: { items: [ ` `, ], desc: ' - Powered by RSSHub', }, partlyRelayed: { items: [ ` `, ], desc: ' - Powered by RSSHub', }, }, extraComplicated: { origin: { items: [ { content: '\n\n\n\n\n\n\n\n\n\n', itunes: {}, }, { content: '\n', itunes: {}, }, { content: '\n\n', 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: ' - Powered by RSSHub', }, processed: { items: [ { content: '\n\n\n\n\n\n\n\n\n\n', itunes: {}, }, { content: '\n', itunes: {}, }, { content: '\n\n', 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: ' - Powered by RSSHub', }, urlencoded: { items: [ { content: '\n\n\n\n\n\n\n\n\n\n', itunes: {}, }, { content: '\n', itunes: {}, }, { content: '\n\n', 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: ' - Powered by RSSHub', }, }, extraMultimedia: { origin: { items: [ { content: '\n\n\n\n', }, { content: '\n', enclosure: { url: 'https://mock.com/DIYgod/RSSHub.mp4', type: 'video/mp4', }, }, ], description: ' - Powered by RSSHub', }, relayed: { items: [ { content: '\n\n\n\n', }, { content: '\n', enclosure: { url: 'https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4', type: 'video/mp4', }, }, ], description: ' - Powered by RSSHub', }, partlyRelayed: { items: [ { content: '\n\n\n\n', }, { content: '\n', enclosure: { url: 'https://i3.wp.com/mock.com/DIYgod/RSSHub.mp4', type: 'video/mp4', }, }, ], description: ' - Powered by RSSHub', }, }, }; const testAntiHotlink = async (path, expectObj, query?: Record) => { 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) => { 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) => { await testAntiHotlink('/test/complicated', expects.complicated.processed, query); await testAntiHotlinkExtra('/test/complicated', expects.extraComplicated.processed, query); }; const expectImgUrlencoded = async (query?: Record) => { await testAntiHotlink('/test/complicated', expects.complicated.urlencoded, query); await testAntiHotlinkExtra('/test/complicated', expects.extraComplicated.urlencoded, query); }; const expectMultimediaOrigin = async (query?: Record) => { await testAntiHotlink('/test/multimedia', expects.multimedia.origin, query); await testAntiHotlinkExtra('/test/multimedia', expects.extraMultimedia.origin, query); }; const expectMultimediaRelayed = async (query?: Record) => { await testAntiHotlink('/test/multimedia', expects.multimedia.relayed, query); await testAntiHotlinkExtra('/test/multimedia', expects.extraMultimedia.relayed, query); }; const expectMultimediaPartlyRelayed = async (query?: Record) => { 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, 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: '', 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'); }); });