Webmention-Avatare zur Build-Zeit lokal cachen
Ein kleiner Astro-Helper, der Autor-Fotos von Webmentions beim Build runterlädt, dedupliziert und lokal ausliefert — für eine strikte CSP, mehr Privatsphäre und bessere Verfügbarkeit.
Kategorie: Technik
Dieser Beitrag knüpft an die Security-Header-Umstellung meiner Seite an. Nachdem eine strikte Content Security Policy mit img-src 'self' data: live war, zeigte der nächste Scan ein neues Problem: die Mention-Fotos auf den Post-Seiten waren weg.
Der naheliegende Fix — img-src auf 'self' data: https: öffnen — löst das Symptom, aber nicht das eigentliche Problem. Dieser Beitrag zeigt den Weg, der beides gleichzeitig erledigt: CSP bleibt strikt, und die Leser schicken beim Aufruf keine einzige Anfrage mehr an den externen Avatar-Host.
Problem
Meine Webmentions.astro-Komponente rendert die Facepile und Replies mit externen Avatar-URLs direkt aus der webmention.io-API:
<img src="https://avatars.webmention.io/files.mastodon.social/0ec46...jpg" alt="" loading="lazy" />
Drei Probleme auf einmal:
- CSP-Konflikt.
img-src 'self' data:blockt externe Bilder. Die Alternativeimg-src 'self' data: https:macht die Direktive praktisch wertlos. - Datenschutz für meine Leser. Bei jedem Aufruf einer Seite mit Mentions machen ihre Browser Anfragen an
avatars.webmention.io— ein Tracking-Vektor, den meine Leser nie bewusst gewählt haben. Der Avatar-Host sieht IP-Adresse, User-Agent, Referer und den Pfad jedes Posts mit Mentions. Unter Art. 5.1.c DSGVO (Datenminimierung) ist das unnötige Weitergabe personenbezogener Daten. - Verfügbarkeit. Ist webmention.io down oder langsam, fehlen die Avatare oder verzögern das Rendering.
Alle drei Probleme lösen sich, wenn die Avatare nicht mehr vom Browser geladen werden, sondern vom eigenen Server aus.
Idee
Die Webmentions werden sowieso zur Build-Zeit geholt, nicht zur Lesezeit. Heißt: Jede Avatar-URL ist bereits beim Rendern des Posts bekannt. Also: die Bilder gleich mit runterladen, lokal speichern, und im HTML auf den lokalen Pfad umschreiben.
Die Umsetzung braucht vier Bausteine:
- Einen Cache-Ordner unter
public/, der zwischen Builds überlebt. - Einen Mirror nach
dist/, weil Astropublic/vor dem Rendering nachdist/kopiert — spätere Downloads inpublic/würden sonst im aktuellen Build-Output fehlen. - Eine Deduplizierung, damit derselbe Avatar bei mehreren Mentions (ein Autor liked und repostet den gleichen Post) nicht mehrfach runtergeladen wird.
- Eine Garbage-Collection am Build-Ende, die verwaiste Avatare aus dem Cache entfernt — sonst bleiben Dateien ewig liegen, wenn ein Nutzer seinen Like oder Kommentar später löscht.
Umsetzung
Die Hilfsdatei src/lib/avatar-cache.ts:
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { copyFile, mkdir, readdir, unlink, 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/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/avif': 'avif',
'image/svg+xml': 'svg',
};
const memo = new Map<string, Promise<string | null>>();
const usedFilenames = new Set<string>();
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/ gibt's in `astro dev` nicht — ignorieren
}
}
async function download(url: string): Promise<string | null> {
const hash = createHash('sha1').update(url).digest('hex').slice(0, 16);
await mkdir(PUBLIC_DIR, { recursive: true });
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] ?? '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);
}
usedFilenames.add(filename);
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;
}
async function sweepDir(dir: string): Promise<number> {
if (!existsSync(dir)) return 0;
const entries = await readdir(dir);
let removed = 0;
await Promise.all(
entries.map(async (name) => {
if (!usedFilenames.has(name)) {
await unlink(path.join(dir, name));
removed++;
}
}),
);
return removed;
}
export async function sweepCache(): Promise<{ removed: number }> {
const publicRemoved = await sweepDir(PUBLIC_DIR);
await sweepDir(DIST_DIR);
return { removed: publicRemoved };
}
Vier Details, die leicht untergehen:
- SHA1 der URL als Dateiname dedupliziert deterministisch. Der gleiche Avatar bekommt immer denselben Dateinamen, egal in wie vielen Builds er auftaucht.
- MIME-Type aus dem Response-Header bestimmt die Extension. Sich auf die URL-Endung zu verlassen ist fragil — webmention.io serviert einige Avatare hinter URLs ohne Dateiendung.
memo-Map verhindert Doppel-Downloads in einem Build.Webmentions.astroruftcacheAvatarüberPromise.allauf; ohne Memoization würde derselbe Autor, der eine Seite liked und repostet, zweimal parallel runtergeladen.usedFilenames-Set plussweepCachemacht aus dem Cache einen Mark-and-Sweep-Cache. Während des Builds markiert jeder Treffer den Dateinamen als “benutzt”; am Build-Ende löscht der Sweep alles, was nicht markiert wurde. Ohne das würden Avatare von gelöschten Mentions (Like zurückgenommen, Kommentar entfernt) für immer im Ordner liegen bleiben.
In Webmentions.astro läuft der Cache-Schritt direkt nach dem fetchMentions:
---
import { cacheAvatar } from '~/lib/avatar-cache';
// ...
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;
}
}),
);
---
Wenn der Download scheitert (Timeout, 404, was auch immer), wird author.photo entfernt — die Komponente fällt dann automatisch auf den Avatar-Fallback mit den Initialen des Autors zurück.
Garbage Collection als Astro-Integration
sweepCache muss erst laufen, wenn alle Seiten gerendert sind — sonst löscht er Avatare, die eine später gerenderte Seite noch gebraucht hätte. Astros astro:build:done-Hook ist genau der richtige Zeitpunkt.
Die Integration src/integrations/avatar-cache-sweep.ts:
import type { AstroIntegration } from 'astro';
import { sweepCache } from '../lib/avatar-cache';
export default function avatarCacheSweep(): AstroIntegration {
return {
name: 'avatar-cache-sweep',
hooks: {
'astro:build:done': async ({ logger }) => {
const { removed } = await sweepCache();
if (removed > 0) {
logger.info(`swept ${removed} orphaned webmention avatar${removed === 1 ? '' : 's'}`);
}
},
},
};
}
Registriert in astro.config.mjs:
import avatarCacheSweep from './src/integrations/avatar-cache-sweep';
export default defineConfig({
integrations: [
mdx(),
sitemap({ /* ... */ }),
avatarCacheSweep(),
],
});
Ein wichtiger Nebeneffekt: Im astro dev-Modus läuft astro:build:done nicht. Der Sweep greift also nur bei astro build — genau dort, wo es drauf ankommt. Während der Entwicklung bleibt der Cache unangetastet, und es kann nicht passieren, dass ein Dev-Server versehentlich Avatare wegräumt, die beim nächsten Prod-Build wieder gebraucht werden.
Warum der Pfad /images/webmention/
Mein erster Versuch war /webmention-avatars/ unter dem Site-Root. Das funktioniert, passt aber nicht zu den Caddy-Regeln, die ich schon für Caching habe:
header /images/* Cache-Control "public, max-age=604800"
Unter /images/webmention/... greift diese Regel automatisch — eine Woche Browser-Cache für Avatare, ohne extra Config.
.gitignore
Der Cache-Ordner gehört nicht ins Repo — er wird bei jedem Build neu befüllt, wenn er leer ist, und bleibt sonst aus einem vorigen Lauf bestehen:
public/images/webmention/
In CI-Umgebungen ohne persistentes Caching werden die Avatare bei jedem Build einmal neu geholt. Da es nur ein paar kleine JPEGs sind, ist das unkritisch.
Lösung
Im gebauten HTML steht jetzt:
<img src="/images/webmention/556ae159c81f2fc1.jpg" alt="" loading="lazy" />
Vier Dinge auf einmal gelöst:
- CSP kann strikt bleiben.
img-src 'self' data:reicht aus — kein Freibrief für beliebige HTTPS-Quellen. - Null Drittanbieter-Requests beim Aufruf. Leser laden die Avatare vom eigenen Server der Seite, der Mention-Host sieht sie nie. Art. 5.1.c DSGVO erfüllt.
- Verfügbarkeit entkoppelt. Das Rendering der Avatare hängt nur noch davon ab, ob der Build erfolgreich war, nicht davon, ob webmention.io gerade erreichbar ist.
- Selbstaufräumender Cache. Zurückgezogene Likes und gelöschte Kommentare nehmen ihren Avatar beim nächsten Build mit. Der Ordner bleibt schlank und es liegen keine Datei-Leichen von Leuten rum, die nicht mehr verlinkt sind — auch das ist gelebte Datenminimierung.
Der Caddy-/images/*-Cache-Header greift out of the box. Bei jedem Build, der etwas Neues bringt, liegen die Avatare in dist/images/webmention/ — unverändert für bereits gesehene URLs, frisch geladen für neue.
Was du dir mitnimmst
- Externe Ressourcen, die zur Build-Zeit bekannt sind, gehören gecached. Das ist eine alte Empfehlung für Google Fonts — dasselbe Argument gilt für Webmention-Avatare, externe Icons, Mastodon-Badges und alles, was an einer statischen URL hängt.
- CSP-Compliance und Datenschutz zeigen auf dasselbe Fix-Pattern. Wer die Avatare nicht mehr extern lädt, muss die CSP nicht aufweichen und verrät die IP seiner Leser nicht mehr.
- Deduplizierung per URL-Hash ist trivial und robust. Keine Datenbank, keine externe Config — ein Cache-Ordner plus SHA1 reicht.
- Verfügbarkeit ist ein oft übersehener Privacy-Bonus. Selbstgehostete Ressourcen fallen nicht aus, wenn der externe Anbieter seine Preise erhöht, die API ändert oder komplett abschaltet.
- Ein Cache ohne Garbage Collection ist ein Datenfriedhof. Mark-and-Sweep ist fünf Zeilen Code und verhindert, dass sich Altlasten ansammeln — egal ob aus Plattenplatz- oder aus Datenschutzsicht.