feat: link official news to Ubisoft
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user