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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ SENTRY_DSN=
GITHUB_TOKEN=

BOORU_AUTH_CONFIG='{"gelbooru.com":[{"user":"001","password":"gelbooru-api-key","rateLimit":{"requests":10,"windowSeconds":1}}],"rule34.xxx":[{"user":"002","password":"rule34-api-key","rateLimit":{"requests":60,"windowSeconds":60}}]}'

# Optional URL-rewriter proxy per provider. The proxy must accept the final upstream URL in targetParam.
BOORU_OUTBOUND_PROXY_CONFIG='{"gelbooru.com":[{"baseUrl":"https://cors-proxy2.rule34.workers.dev/","targetParam":"q"},{"baseUrl":"https://cors-proxy.refinedsoftware00.workers.dev/","targetParam":"q"}]}'
39 changes: 29 additions & 10 deletions src/booru/booru-auth.live.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ function sanitizeLiveSmokeErrorMessage(message: string): string {
return sanitizedMessage
}

function createSanitizedLiveSmokeErrorCause(error: unknown): Error {
if (!(error instanceof Error)) {
return new Error(sanitizeLiveSmokeErrorMessage(String(error)))
}

const cause = new Error(sanitizeLiveSmokeErrorMessage(error.message))
cause.name = error.name

if (error.stack !== undefined) {
cause.stack = sanitizeLiveSmokeErrorMessage(error.stack)
}

return cause
}

describe('live smoke error sanitization', () => {
it('redacts auth query params before rethrowing non-quota live errors', () => {
const message =
Expand All @@ -54,6 +69,17 @@ describe('live smoke error sanitization', () => {
expect(sanitizedMessage).not.toContain('user_id=123')
expect(sanitizedMessage).not.toContain('auth_pass=pass')
})

it('uses a sanitized cause when rethrowing non-quota live errors', () => {
const originalError = new Error('HTTP 403 https://gelbooru.com/index.php?page=dapi&api_key=secret&user_id=123')
originalError.stack = 'Error: api_key=secret user_id=123'

const cause = createSanitizedLiveSmokeErrorCause(originalError)

expect(cause.name).toBe('Error')
expect(cause.message).toBe('HTTP 403 https://gelbooru.com/index.php?page=dapi&api_key=REDACTED&user_id=REDACTED')
expect(cause.stack).toBe('Error: api_key=REDACTED user_id=REDACTED')
})
})

describeLive('authenticated booru live smoke tests', () => {
Expand Down Expand Up @@ -110,16 +136,9 @@ describeLive('authenticated booru live smoke tests', () => {

const message = error instanceof Error ? error.message : String(error)
const sanitizedMessage = sanitizeLiveSmokeErrorMessage(message)

if (error instanceof Error) {
error.message = sanitizedMessage

if (error.stack !== undefined) {
error.stack = sanitizeLiveSmokeErrorMessage(error.stack)
}
}

throw new Error(sanitizedMessage, { cause: error })
// Do not preserve the original cause here; live upstream errors can carry credential-bearing request objects.
// eslint-disable-next-line preserve-caught-error
throw new Error(sanitizedMessage, { cause: createSanitizedLiveSmokeErrorCause(error) })
}

expect(posts.length).toBeGreaterThan(0)
Expand Down
248 changes: 246 additions & 2 deletions src/booru/booru.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ function getApiAuth(api: unknown): ApiAuth | undefined {
return (api as ApiAuthOptions).options?.auth
}

function buildPostUrl(api: unknown): URL {
const internalApi = api as {
generateEndpointUrl(endpoint: string): URL
addPostQueries(url: URL, queries: { limit: number; pageID: number; tags: string[] }): URL
}

return internalApi.addPostQueries(internalApi.generateEndpointUrl('/index.php?page=dapi&s=post&q=index'), {
limit: 1,
pageID: 1,
tags: ['diana']
})
}

function buildTagUrl(api: unknown): URL {
const internalApi = api as {
generateEndpointUrl(endpoint: string): URL
addTagsQueries(url: URL, queries: { tag: string; limit: number }): URL
}

return internalApi.addTagsQueries(internalApi.generateEndpointUrl('/index.php?page=dapi&s=tag&q=index'), {
tag: 'dian',
limit: 1
})
}

describe('BooruService', () => {
let service: BooruService
let mockAuthManager: MockAuthManager
Expand All @@ -47,6 +72,8 @@ describe('BooruService', () => {
}

beforeEach(async () => {
mockConfigService.get.mockReset()

mockAuthManager = {
reserveAvailableCredential: jest.fn() as jest.MockedFunction<
BooruAuthManagerService['reserveAvailableCredential']
Expand All @@ -72,8 +99,6 @@ describe('BooruService', () => {

service = module.get<BooruService>(BooruService)

jest.clearAllMocks()

mockAuthManager.getDomainStats.mockReturnValue({
domain: 'gelbooru.com',
total: 1,
Expand Down Expand Up @@ -181,6 +206,225 @@ describe('BooruService', () => {
})
})

describe('Outbound Proxy Resolution', () => {
it('should proxy configured provider URLs after query and auth parameters are applied', () => {
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'BOORU_OUTBOUND_PROXY_CONFIG') {
return JSON.stringify({
'gelbooru.com': {
baseUrl: 'https://cors-proxy2.rule34.workers.dev/',
targetParam: 'q'
}
})
}

return undefined
})

const queries = {
...baseQueries,
baseEndpoint: 'gelbooru.com',
auth_user: 'managed_1',
auth_pass: 'pass_1'
} as booruQueriesDTO

const api = service.buildApiClass(mockParams, queries)
const proxiedUrl = buildPostUrl(api)
const upstreamUrl = new URL(proxiedUrl.searchParams.get('q') ?? '')

expect(proxiedUrl.origin).toBe('https://cors-proxy2.rule34.workers.dev')
expect(upstreamUrl.origin).toBe('https://gelbooru.com')
expect(upstreamUrl.searchParams.get('limit')).toBe('1')
expect(upstreamUrl.searchParams.get('pid')).toBe('1')
expect(upstreamUrl.searchParams.get('tags')).toBe('diana')
expect(upstreamUrl.searchParams.get('user_id')).toBe('managed_1')
expect(upstreamUrl.searchParams.get('api_key')).toBe('pass_1')
})

it('should proxy configured provider tag URLs with the same policy', () => {
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'BOORU_OUTBOUND_PROXY_CONFIG') {
return JSON.stringify({
'gelbooru.com': {
baseUrl: 'https://r34.app/api/cors-proxy/',
targetParam: 'q'
}
})
}

return undefined
})

const queries = {
...baseQueries,
baseEndpoint: 'gelbooru.com',
auth_user: 'managed_1',
auth_pass: 'pass_1'
} as booruQueriesDTO

const api = service.buildApiClass(mockParams, queries)
const proxiedUrl = buildTagUrl(api)
const upstreamUrl = new URL(proxiedUrl.searchParams.get('q') ?? '')

expect(proxiedUrl.origin).toBe('https://r34.app')
expect(proxiedUrl.pathname).toBe('/api/cors-proxy/')
expect(upstreamUrl.origin).toBe('https://gelbooru.com')
expect(upstreamUrl.searchParams.get('name_pattern')).toBe('dian%')
expect(upstreamUrl.searchParams.get('limit')).toBe('1')
expect(upstreamUrl.searchParams.get('user_id')).toBe('managed_1')
expect(upstreamUrl.searchParams.get('api_key')).toBe('pass_1')
})

it('should leave unconfigured provider URLs direct', () => {
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'BOORU_OUTBOUND_PROXY_CONFIG') {
return JSON.stringify({
'rule34.xxx': {
baseUrl: 'https://cors-proxy2.rule34.workers.dev/',
targetParam: 'q'
}
})
}

return undefined
})

const queries = {
...baseQueries,
baseEndpoint: 'gelbooru.com',
auth_user: 'managed_1',
auth_pass: 'pass_1'
} as booruQueriesDTO

const api = service.buildApiClass(mockParams, queries)
const directUrl = buildPostUrl(api)

expect(directUrl.origin).toBe('https://gelbooru.com')
expect(directUrl.searchParams.get('q')).toBe('index')
expect(directUrl.searchParams.get('user_id')).toBe('managed_1')
expect(directUrl.searchParams.get('api_key')).toBe('pass_1')
})

it('should rotate through multiple configured provider proxies', () => {
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'BOORU_OUTBOUND_PROXY_CONFIG') {
return JSON.stringify({
'gelbooru.com': [
{
baseUrl: 'https://cors-proxy2.rule34.workers.dev/',
targetParam: 'q'
},
{
baseUrl: 'https://cors-proxy.refinedsoftware00.workers.dev/',
targetParam: 'q'
}
]
})
}

return undefined
})

const queries = {
...baseQueries,
baseEndpoint: 'gelbooru.com',
auth_user: 'managed_1',
auth_pass: 'pass_1'
} as booruQueriesDTO

const firstUrl = buildPostUrl(service.buildApiClass(mockParams, queries))
const secondUrl = buildPostUrl(service.buildApiClass(mockParams, queries))
const thirdUrl = buildPostUrl(service.buildApiClass(mockParams, queries))

expect(firstUrl.origin).toBe('https://cors-proxy2.rule34.workers.dev')
expect(secondUrl.origin).toBe('https://cors-proxy.refinedsoftware00.workers.dev')
expect(thirdUrl.origin).toBe('https://cors-proxy2.rule34.workers.dev')
})

it('should not rotate proxies while building an API that never fetches', () => {
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'BOORU_OUTBOUND_PROXY_CONFIG') {
return JSON.stringify({
'gelbooru.com': [
{
baseUrl: 'https://cors-proxy2.rule34.workers.dev/',
targetParam: 'q'
},
{
baseUrl: 'https://cors-proxy.refinedsoftware00.workers.dev/',
targetParam: 'q'
}
]
})
}

return undefined
})

const queries = {
...baseQueries,
baseEndpoint: 'gelbooru.com',
auth_user: 'managed_1',
auth_pass: 'pass_1'
} as booruQueriesDTO

service.buildApiClass(mockParams, queries)
const firstFetchedUrl = buildPostUrl(service.buildApiClass(mockParams, queries))
const secondFetchedUrl = buildPostUrl(service.buildApiClass(mockParams, queries))

expect(firstFetchedUrl.origin).toBe('https://cors-proxy2.rule34.workers.dev')
expect(secondFetchedUrl.origin).toBe('https://cors-proxy.refinedsoftware00.workers.dev')
})

it('should reject invalid outbound proxy config shapes', () => {
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'BOORU_OUTBOUND_PROXY_CONFIG') {
return JSON.stringify({
'gelbooru.com': {
baseUrl: 'not-a-url',
targetParam: 'q'
}
})
}

return undefined
})

const queries = {
...baseQueries,
baseEndpoint: 'gelbooru.com'
} as booruQueriesDTO

expect(() => service.buildApiClass(mockParams, queries)).toThrow(
'Invalid BOORU_OUTBOUND_PROXY_CONFIG baseUrl for gelbooru.com'
)
})

it('should reject plaintext outbound proxy URLs', () => {
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'BOORU_OUTBOUND_PROXY_CONFIG') {
return JSON.stringify({
'gelbooru.com': {
baseUrl: 'http://cors-proxy.example.test/',
targetParam: 'q'
}
})
}

return undefined
})

const queries = {
...baseQueries,
baseEndpoint: 'gelbooru.com'
} as booruQueriesDTO

expect(() => service.buildApiClass(mockParams, queries)).toThrow(
'Invalid BOORU_OUTBOUND_PROXY_CONFIG baseUrl for gelbooru.com'
)
})
})

describe('Managed Strategy Execution', () => {
it('should not fallback when explicit auth is provided', async () => {
const queries = {
Expand Down
Loading