diff --git a/.gitignore b/.gitignore index 5d89661..1679487 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ dist/ # generated types .astro/ +# cached webmention avatars (populated at build time) +public/images/webmention/ # dependencies node_modules/ diff --git a/src/components/Webmentions.astro b/src/components/Webmentions.astro index 5d0f588..d5ade8a 100644 --- a/src/components/Webmentions.astro +++ b/src/components/Webmentions.astro @@ -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 { 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'); diff --git a/src/lib/avatar-cache.ts b/src/lib/avatar-cache.ts new file mode 100644 index 0000000..f133ce7 --- /dev/null +++ b/src/lib/avatar-cache.ts @@ -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 = { + '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>(); + +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 { + 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 { + 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; +} diff --git a/src/pages/en/contact.astro b/src/pages/en/contact.astro index 63d0bd8..229ff82 100644 --- a/src/pages/en/contact.astro +++ b/src/pages/en/contact.astro @@ -45,14 +45,7 @@ import BaseLayout from '~/layouts/BaseLayout.astro';