feat: implement avatar caching and refactor obfuscation decoding
This commit is contained in:
parent
981c81a865
commit
285ff01303
6 changed files with 101 additions and 16 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,6 +2,8 @@
|
|||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
# cached webmention avatars (populated at build time)
|
||||
public/images/webmention/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
|
|
|||
|
|
@ -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
78
src/lib/avatar-cache.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
8
src/scripts/decode-obf.ts
Normal file
8
src/scripts/decode-obf.ts
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue