diff --git a/app/assets/js/nuxt-image/imgproxy.provider.ts b/app/assets/js/nuxt-image/imgproxy.provider.ts index 7cd5df91..881aedd8 100644 --- a/app/assets/js/nuxt-image/imgproxy.provider.ts +++ b/app/assets/js/nuxt-image/imgproxy.provider.ts @@ -55,6 +55,45 @@ const defaultModifiers = { interface ImgproxyProviderOptions { baseUrl: string internalProxyUrl: string + mediaProxyUrls?: string[] +} + +const gelbooruHostSuffix = 'gelbooru.com' + +function isGelbooruMediaSource(sourceUrl: URL) { + return sourceUrl.hostname === gelbooruHostSuffix || sourceUrl.hostname.endsWith(`.${gelbooruHostSuffix}`) +} + +function stableIndex(value: string, length: number) { + let hash = 0 + + for (const char of value) { + hash = (hash + char.charCodeAt(0)) >>> 0 + } + + return hash % length +} + +function getMediaProxySourceUrl(src: string, mediaProxyUrls: string[] | undefined) { + if (!mediaProxyUrls?.length) { + return + } + + const selectedProxyUrl = mediaProxyUrls[stableIndex(src, mediaProxyUrls.length)] + + if (!selectedProxyUrl) { + return + } + + const proxyUrl = URL.parse(selectedProxyUrl) + + if (!proxyUrl) { + return + } + + proxyUrl.searchParams.set('q', src) + + return proxyUrl.toString() } /** @@ -78,15 +117,18 @@ const getImage: ProviderGetImage = (src, options) => { return { url: src } } - const { modifiers, baseUrl, internalProxyUrl } = options + const { modifiers, baseUrl, internalProxyUrl, mediaProxyUrls } = options const mergeModifiers = { ...defaultModifiers, ...modifiers } // Remove dimensions from imgproxy operations so the same source reuses a common cached URL. const { width, height, ...modifiersWithoutSize } = mergeModifiers - // Build rewriter URL: nginx-proxy fetches the source and strips headers - const rewriterUrl = `${internalProxyUrl}${src}` + const mediaProxySourceUrl = isGelbooruMediaSource(sourceUrl) ? getMediaProxySourceUrl(src, mediaProxyUrls) : undefined + + // Build rewriter URL: nginx-proxy fetches the source and strips headers. + // Gelbooru media is fetched through Cloudflare Workers first because Gelbooru rate-limits the VM egress IP. + const rewriterUrl = mediaProxySourceUrl ?? `${internalProxyUrl}${src}` const encodedUrl = urlSafeBase64(rewriterUrl) const path = joinURL('/insecure', operationsGenerator(modifiersWithoutSize), encodedUrl) diff --git a/config/project.ts b/config/project.ts index 68ec4206..c5b62410 100644 --- a/config/project.ts +++ b/config/project.ts @@ -41,6 +41,7 @@ export interface Social { export interface Imgproxy { baseUrl: string internalProxyUrl: string + mediaProxyUrls: string[] } export interface DiscordOauth { @@ -99,7 +100,8 @@ export const project: ProjectConfig = { }, imgproxy: { baseUrl: 'https://imgproxy2.r34.app', - internalProxyUrl: 'http://nginx-proxy/proxy?url=' + internalProxyUrl: 'http://nginx-proxy/proxy?url=', + mediaProxyUrls: ['https://cors-proxy2.rule34.workers.dev/', 'https://cors-proxy.refinedsoftware00.workers.dev/'] }, sentry: { applicationKey: 'r34-app' diff --git a/nuxt.config.ts b/nuxt.config.ts index 4633b898..37857541 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -281,7 +281,7 @@ export default defineNuxtConfig({ strictSeo: { canonicalQueries: ['tags'] // Non-functional — kept as intent. See server/plugins/. }, - + compactRoutes: true // TODO: Remove once default } }, @@ -339,7 +339,8 @@ export default defineNuxtConfig({ provider: '~/assets/js/nuxt-image/imgproxy.provider', options: { baseUrl: project.imgproxy.baseUrl, - internalProxyUrl: project.imgproxy.internalProxyUrl + internalProxyUrl: project.imgproxy.internalProxyUrl, + mediaProxyUrls: project.imgproxy.mediaProxyUrls } } }, diff --git a/test/assets/imgproxy-provider.test.ts b/test/assets/imgproxy-provider.test.ts new file mode 100644 index 00000000..2bedaaa1 --- /dev/null +++ b/test/assets/imgproxy-provider.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import imgproxyProvider from '../../app/assets/js/nuxt-image/imgproxy.provider' + +function decodeImgproxySource(url: string) { + const parsedUrl = URL.parse(url) + + if (!parsedUrl) { + throw new Error(`Invalid imgproxy URL: ${url}`) + } + + const encodedSource = parsedUrl.pathname.split('/').at(-1) + + if (!encodedSource) { + throw new Error('Missing encoded imgproxy source') + } + + const padded = encodedSource.padEnd(Math.ceil(encodedSource.length / 4) * 4, '=') + return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8') +} + +describe('imgproxy provider', () => { + const providerOptions = { + baseUrl: 'https://imgproxy2.r34.app', + internalProxyUrl: 'http://nginx-proxy/proxy?url=', + mediaProxyUrls: ['https://cors-proxy2.rule34.workers.dev/', 'https://cors-proxy.refinedsoftware00.workers.dev/'], + modifiers: { + format: 'webp', + width: 320 + } + } + + it('routes Gelbooru media through the configured outbound media proxies before imgproxy fetches it', () => { + const sourceUrl = 'https://img2.gelbooru.com/images/fb/59/fb593a551b9f89548b9a4b47f4c3bac4.png' + + const image = imgproxyProvider().getImage(sourceUrl, providerOptions) + + expect(decodeImgproxySource(image.url)).toBe( + 'https://cors-proxy2.rule34.workers.dev/?q=https%3A%2F%2Fimg2.gelbooru.com%2Fimages%2Ffb%2F59%2Ffb593a551b9f89548b9a4b47f4c3bac4.png' + ) + }) + + it('keeps non-Gelbooru media on the internal nginx proxy', () => { + const sourceUrl = 'https://api-cdn.rule34.xxx/images/1234/example.png' + + const image = imgproxyProvider().getImage(sourceUrl, providerOptions) + + expect(decodeImgproxySource(image.url)).toBe( + 'http://nginx-proxy/proxy?url=https://api-cdn.rule34.xxx/images/1234/example.png' + ) + }) + + it('chooses the same outbound media proxy for the same source URL', () => { + const sourceUrl = 'https://img2.gelbooru.com/images/aa/bb/example.png' + + const firstImage = imgproxyProvider().getImage(sourceUrl, providerOptions) + const secondImage = imgproxyProvider().getImage(sourceUrl, providerOptions) + + expect(decodeImgproxySource(secondImage.url)).toBe(decodeImgproxySource(firstImage.url)) + expect(decodeImgproxySource(firstImage.url)).toBe( + 'https://cors-proxy.refinedsoftware00.workers.dev/?q=https%3A%2F%2Fimg2.gelbooru.com%2Fimages%2Faa%2Fbb%2Fexample.png' + ) + }) +})