Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions app/assets/js/nuxt-image/imgproxy.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

/**
Expand All @@ -78,15 +117,18 @@ const getImage: ProviderGetImage<ImgproxyProviderOptions> = (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)
Expand Down
4 changes: 3 additions & 1 deletion config/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface Social {
export interface Imgproxy {
baseUrl: string
internalProxyUrl: string
mediaProxyUrls: string[]
}

export interface DiscordOauth {
Expand Down Expand Up @@ -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'
Expand Down
5 changes: 3 additions & 2 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ export default defineNuxtConfig({
strictSeo: {
canonicalQueries: ['tags'] // Non-functional — kept as intent. See server/plugins/.
},

compactRoutes: true // TODO: Remove once default
}
},
Expand Down Expand Up @@ -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
}
}
},
Expand Down
63 changes: 63 additions & 0 deletions test/assets/imgproxy-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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'
)
})
})