feat: link official news to Ubisoft
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy production / deploy (push) Successful in 49s
Validate and deploy staging / deploy (push) Successful in 28s

This commit is contained in:
jb
2026-08-01 22:26:52 -04:00
parent bc40a737a8
commit dc08eefce6
13 changed files with 618 additions and 42 deletions
+115
View File
@@ -0,0 +1,115 @@
import { createHash } from 'node:crypto';
import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import { fetchUbisoftNewsThumbnail } from '$lib/server/ubisoft-news-client';
import type { RequestHandler } from './$types';
const ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const CACHE_TTL_MS = 60 * 60 * 1_000;
const CACHE_LIMIT_BYTES = 16 * 1024 * 1024;
type CachedImage = Awaited<ReturnType<typeof fetchUbisoftNewsThumbnail>> & {
sourceUrl: string;
etag: string;
expiresAt: number;
};
const cache = new Map<string, CachedImage>();
const pending = new Map<string, Promise<CachedImage>>();
let cacheBytes = 0;
function removeCached(id: string) {
const image = cache.get(id);
if (!image) return;
cache.delete(id);
cacheBytes -= image.body.byteLength;
}
function pruneCache(now: number) {
for (const [id, image] of cache) {
if (image.expiresAt <= now) removeCached(id);
}
while (cacheBytes > CACHE_LIMIT_BYTES) {
const oldest = cache.keys().next().value;
if (!oldest) break;
removeCached(oldest);
}
}
async function loadImage(id: string, sourceUrl: string) {
const now = Date.now();
pruneCache(now);
const cached = cache.get(id);
if (cached?.sourceUrl === sourceUrl && cached.expiresAt > now) {
cache.delete(id);
cache.set(id, cached);
return cached;
}
if (cached) removeCached(id);
const key = `${id}:${sourceUrl}`;
const active = pending.get(key);
if (active) return active;
const imageRequest = fetchUbisoftNewsThumbnail(sourceUrl).then((image) => {
const loaded: CachedImage = {
...image,
sourceUrl,
etag: `"${createHash('sha256').update(image.body).digest('hex')}"`,
expiresAt: Date.now() + CACHE_TTL_MS,
};
if (loaded.body.byteLength <= CACHE_LIMIT_BYTES) {
removeCached(id);
cache.set(id, loaded);
cacheBytes += loaded.body.byteLength;
pruneCache(Date.now());
}
return loaded;
});
pending.set(key, imageRequest);
try {
return await imageRequest;
} finally {
pending.delete(key);
}
}
export const GET: RequestHandler = async ({ params, request }) => {
if (!ID_PATTERN.test(params.id)) error(404, 'Image not found');
const row = await (
await (
await getDatabase()
).prepare(`SELECT source.thumbnail_url FROM external_news_sources source
JOIN articles ON articles.id=source.article_id
WHERE source.article_id=? AND source.provider='ubisoft' AND source.thumbnail_url IS NOT NULL
AND articles.visibility IN ('public','unlisted') AND articles.deleted_at IS NULL`)
).get(params.id);
if (!row?.thumbnail_url) error(404, 'Image not found');
let image: CachedImage;
try {
image = await loadImage(params.id, String(row.thumbnail_url));
} catch {
error(502, 'Source image unavailable');
}
const etag = image.etag;
if (
request.headers
.get('if-none-match')
?.split(',')
.some((value) => value.trim() === etag)
) {
return new Response(null, {
status: 304,
headers: { etag, 'cache-control': 'public, max-age=3600' },
});
}
return new Response(image.body, {
headers: {
'cache-control': 'public, max-age=3600, stale-while-revalidate=86400',
'cross-origin-resource-policy': 'same-origin',
'content-length': String(image.body.byteLength),
'content-type': image.contentType,
etag,
'x-content-type-options': 'nosniff',
},
});
};
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
get: vi.fn(),
fetchThumbnail: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => ({
prepare: vi.fn(async () => ({ get: mocks.get })),
})),
}));
vi.mock('$lib/server/ubisoft-news-client', () => ({
fetchUbisoftNewsThumbnail: mocks.fetchThumbnail,
}));
import { GET } from './+server';
const articleId = '9890c398-0522-4e8c-8fdb-78de9c66deb6';
const sourceUrl = 'https://staticctf.ubisoft.com/news/cover.jpg';
describe('Ubisoft News image proxy', () => {
beforeEach(() => {
mocks.get.mockReset().mockResolvedValue({ thumbnail_url: sourceUrl });
mocks.fetchThumbnail.mockReset().mockResolvedValue({
body: new Uint8Array([1, 2, 3]),
contentType: 'image/jpeg',
});
});
it('serves a same-origin cached image and honors its ETag', async () => {
const first = await GET({
params: { id: articleId },
request: new Request(`http://localhost/images/news/${articleId}`),
} as never);
expect(first.status).toBe(200);
expect(first.headers.get('content-type')).toBe('image/jpeg');
expect(first.headers.get('cross-origin-resource-policy')).toBe(
'same-origin',
);
const etag = first.headers.get('etag');
expect(etag).toBeTruthy();
const second = await GET({
params: { id: articleId },
request: new Request(`http://localhost/images/news/${articleId}`, {
headers: { 'if-none-match': String(etag) },
}),
} as never);
expect(second.status).toBe(304);
expect(mocks.fetchThumbnail).toHaveBeenCalledTimes(1);
});
});