feat: implement avatar caching and refactor obfuscation decoding

This commit is contained in:
Adrian Altner 2026-04-22 18:35:23 +02:00
parent 981c81a865
commit 285ff01303
6 changed files with 101 additions and 16 deletions

2
.gitignore vendored
View file

@ -2,6 +2,8 @@
dist/
# generated types
.astro/
# cached webmention avatars (populated at build time)
public/images/webmention/
# dependencies
node_modules/

View file

@ -1,6 +1,7 @@
---
import { DEFAULT_LOCALE, type Locale } from '~/consts';
import { getLocaleFromUrl, t } from '~/i18n/ui';
import { cacheAvatar } from '~/lib/avatar-cache';
declare global {
// eslint-disable-next-line no-var
@ -61,6 +62,16 @@ async function fetchMentions(target: string): Promise<WMEntry[]> {
const targetStr = target.toString();
const all = await fetchMentions(targetStr);
await Promise.all(
all.map(async (m) => {
if (m.author?.photo) {
const local = await cacheAvatar(m.author.photo);
if (local) m.author.photo = local;
else delete m.author.photo;
}
}),
);
const likes = all.filter((m) => m['wm-property'] === 'like-of');
const reposts = all.filter((m) => m['wm-property'] === 'repost-of');
const replies = all.filter((m) => m['wm-property'] === 'in-reply-to');

78
src/lib/avatar-cache.ts Normal file
View file

@ -0,0 +1,78 @@
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
const PUBLIC_DIR = path.resolve(process.cwd(), 'public', 'images', 'webmention');
const DIST_DIR = path.resolve(process.cwd(), 'dist', 'images', 'webmention');
const URL_PATH = '/images/webmention';
const EXT_BY_MIME: Record<string, string> = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/avif': 'avif',
'image/svg+xml': 'svg',
};
const memo = new Map<string, Promise<string | null>>();
function extFromUrl(url: string): string | null {
const m = url.match(/\.(jpe?g|png|webp|gif|avif|svg)(?:\?|#|$)/i);
return m ? m[1].toLowerCase().replace('jpeg', 'jpg') : null;
}
async function mirrorToDist(filename: string, srcPath: string) {
try {
await mkdir(DIST_DIR, { recursive: true });
const dest = path.join(DIST_DIR, filename);
if (!existsSync(dest)) await copyFile(srcPath, dest);
} catch {
// dist/ may not exist in `astro dev` — ignore
}
}
async function download(url: string): Promise<string | null> {
const hash = createHash('sha1').update(url).digest('hex').slice(0, 16);
const urlExt = extFromUrl(url);
await mkdir(PUBLIC_DIR, { recursive: true });
if (urlExt) {
const cached = path.join(PUBLIC_DIR, `${hash}.${urlExt}`);
if (existsSync(cached)) {
await mirrorToDist(`${hash}.${urlExt}`, cached);
return `${URL_PATH}/${hash}.${urlExt}`;
}
}
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const mime = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
const ext = EXT_BY_MIME[mime] ?? urlExt ?? 'jpg';
const filename = `${hash}.${ext}`;
const filepath = path.join(PUBLIC_DIR, filename);
if (!existsSync(filepath)) {
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(filepath, buf);
}
await mirrorToDist(filename, filepath);
return `${URL_PATH}/${filename}`;
} catch {
return null;
}
}
export function cacheAvatar(url: string | undefined): Promise<string | null> {
if (!url) return Promise.resolve(null);
if (!/^https?:\/\//i.test(url)) return Promise.resolve(url);
let pending = memo.get(url);
if (!pending) {
pending = download(url);
memo.set(url, pending);
}
return pending;
}

View file

@ -45,14 +45,7 @@ import BaseLayout from '~/layouts/BaseLayout.astro';
</BaseLayout>
<script>
function decode() {
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
el.textContent = atob(el.dataset.obf!);
el.removeAttribute('data-obf');
});
}
decode();
document.addEventListener('astro:page-load', decode);
import '~/scripts/decode-obf';
</script>
<style>

View file

@ -45,14 +45,7 @@ import BaseLayout from '~/layouts/BaseLayout.astro';
</BaseLayout>
<script>
function decode() {
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
el.textContent = atob(el.dataset.obf!);
el.removeAttribute('data-obf');
});
}
decode();
document.addEventListener('astro:page-load', decode);
import '~/scripts/decode-obf';
</script>
<style>

View file

@ -0,0 +1,8 @@
function decode() {
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
el.textContent = atob(el.dataset.obf!);
el.removeAttribute('data-obf');
});
}
decode();
document.addEventListener('astro:page-load', decode);