Skip to content

Commit

Permalink
Merge pull request #910 from serlo/asset-proxy
Browse files Browse the repository at this point in the history
feat(asset): add proxy for fetching assets of external resources
  • Loading branch information
hugotiburtino authored Oct 31, 2024
2 parents 25c345b + c2030ce commit 47e4e02
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 28 deletions.
6 changes: 6 additions & 0 deletions __tests__/__utils__/expect-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,9 @@ export function expectSentryEvent({
export function expectNoSentryError() {
expect(globalThis.sentryEvents).toHaveLength(0)
}

export function expectIsPlaceholderResponse(response: Response) {
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/png')
expect(response.headers.get('content-length')).toBe('135')
}
78 changes: 78 additions & 0 deletions __tests__/asset-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { http } from 'msw'

import {
currentTestEnvironment,
expectIsPlaceholderResponse,
} from './__utils__'

beforeEach(() => {
globalThis.server.use(
http.get('https://whatever.org/image', () => {
return new Response('', {
headers: {
'content-type': 'image/png',
'Set-Cookie':
'sessionId=abc123; Expires=Wed, 09 Nov 2024 07:28:00 GMT; Path=/; Domain=whatever.org; Secure; HttpOnly; SameSite=None',
},
})
}),
http.get('https://whatever.org/notimage', () => {
return new Response('', {
headers: { 'content-type': 'application/json' },
})
}),
)
})

test('request to https://asset-proxy.serlo.org/image?url=* gets asset from url query parameter', async () => {
const env = currentTestEnvironment()
const response = await env.fetch({
subdomain: 'asset-proxy',
pathname: '/image?url=https://whatever.org/image',
})
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/png')
expect(response.headers.get('Set-Cookie')).toBeNull()
expect(response.headers.get('cache-control')).toBe(
'public, max-age=31536000, immutable',
)
})

describe('returns placeholder', () => {
test('when url parameter is empty', async () => {
const response = await requestAsset('')

expectIsPlaceholderResponse(response)
})

test('when url is invalid', async () => {
const response = await requestAsset('42')

expectIsPlaceholderResponse(response)
})

test('when url query parameter is missing', async () => {
const response = await currentTestEnvironment().fetch({
subdomain: 'asset-proxy',
pathname: '/image',
})

expectIsPlaceholderResponse(response)
})

test('when response is not image', async () => {
const response = await requestAsset('https://whatever.org/notimage')

expectIsPlaceholderResponse(response)
})
})

async function requestAsset(
url: string,
env = currentTestEnvironment(),
): Promise<Response> {
return await env.fetch({
subdomain: 'asset-proxy',
pathname: '/image?url=' + encodeURIComponent(url),
})
}
7 changes: 1 addition & 6 deletions __tests__/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
localTestEnvironment,
expectSentryEvent,
expectNoSentryError,
expectIsPlaceholderResponse,
} from './__utils__'

describe('embed.serlo.org/thumbnail?url=...', () => {
Expand Down Expand Up @@ -651,12 +652,6 @@ describe('embed.serlo.org/thumbnail?url=...', () => {
})
})

function expectIsPlaceholderResponse(response: Response) {
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/png')
expect(response.headers.get('content-length')).toBe('135')
}

async function requestThumbnail(
url: string,
env = currentTestEnvironment(),
Expand Down
33 changes: 33 additions & 0 deletions src/asset-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Url, getPlaceholder, isImageResponse } from './utils'

export async function assetProxy(request: Request): Promise<Response | null> {
const url = Url.fromRequest(request)

if (url.subdomain !== 'asset-proxy') return null
if (url.pathname !== '/image') return null

const urlParam = url.searchParams.get('url')

if (!urlParam) return getPlaceholder()

let assetUrl: Url

try {
assetUrl = new Url(urlParam)
} catch {
return getPlaceholder()
}

const originalResponse = await fetch(assetUrl, {
cf: { cacheTtl: 24 * 60 * 60 * 30 },
})

if (originalResponse.ok && isImageResponse(originalResponse)) {
const response = new Response(originalResponse.body, originalResponse)
response.headers.delete('set-cookie')
response.headers.set('cache-control', 'public, max-age=31536000, immutable')
return response
}

return getPlaceholder()
}
30 changes: 8 additions & 22 deletions src/embed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import * as t from 'io-ts'

import { SentryFactory, SentryReporter, responseToContext, Url } from './utils'
import {
SentryFactory,
SentryReporter,
responseToContext,
Url,
getPlaceholder,
isImageResponse,
} from './utils'

export async function embed(
request: Request,
Expand Down Expand Up @@ -271,24 +278,3 @@ async function getWikimediaThumbnail(url: URL) {

return getPlaceholder()
}

function getPlaceholder() {
const placeholderBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAwAAAAGwAQMAAAAkGpCRAAAAA1BMVEXv9/t0VvapAAAAP0lEQVR42u3BMQEAAADCIPuntsUuYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOqOwAAHrgHqAAAAAAElFTkSuQmCC'
const placeholder = Uint8Array.from(atob(placeholderBase64), (c) =>
c.charCodeAt(0),
)
return new Response(placeholder, {
status: 200,
statusText: 'OK',
headers: {
'Content-Type': 'image/png',
'Content-Length': placeholder.length.toString(),
},
})
}

function isImageResponse(res: Response): boolean {
const contentType = res.headers.get('content-type') ?? ''
return res.status === 200 && contentType.startsWith('image/')
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { api } from './api'
import { assetProxy } from './asset-proxy'
import { semanticFileNames } from './assets'
import { auth } from './auth'
import { cloudflareWorkerDev } from './cloudflare-worker-dev'
Expand Down Expand Up @@ -38,6 +39,7 @@ export default {
(await semanticFileNames(request)) ||
(await api(request, env)) ||
(await frontendProxy(request, sentryFactory, env)) ||
(await assetProxy(request)) ||
(await fetch(request))
)
} catch (e) {
Expand Down
20 changes: 20 additions & 0 deletions src/utils/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export function getPlaceholder() {
const placeholderBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAwAAAAGwAQMAAAAkGpCRAAAAA1BMVEXv9/t0VvapAAAAP0lEQVR42u3BMQEAAADCIPuntsUuYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOqOwAAHrgHqAAAAAAElFTkSuQmCC'
const placeholder = Uint8Array.from(atob(placeholderBase64), (c) =>
c.charCodeAt(0),
)
return new Response(placeholder, {
status: 200,
statusText: 'OK',
headers: {
'Content-Type': 'image/png',
'Content-Length': placeholder.length.toString(),
},
})
}

export function isImageResponse(res: Response): boolean {
const contentType = res.headers.get('content-type') ?? ''
return res.status === 200 && contentType.startsWith('image/')
}
1 change: 1 addition & 0 deletions src/utils/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './cf-environment'
export * from './url'
export * from './ui'
export * from './cache'
export * from './image'

0 comments on commit 47e4e02

Please sign in to comment.