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/
|
dist/
|
||||||
# generated types
|
# generated types
|
||||||
.astro/
|
.astro/
|
||||||
|
# cached webmention avatars (populated at build time)
|
||||||
|
public/images/webmention/
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
import { DEFAULT_LOCALE, type Locale } from '~/consts';
|
import { DEFAULT_LOCALE, type Locale } from '~/consts';
|
||||||
import { getLocaleFromUrl, t } from '~/i18n/ui';
|
import { getLocaleFromUrl, t } from '~/i18n/ui';
|
||||||
|
import { cacheAvatar } from '~/lib/avatar-cache';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
|
|
@ -61,6 +62,16 @@ async function fetchMentions(target: string): Promise<WMEntry[]> {
|
||||||
const targetStr = target.toString();
|
const targetStr = target.toString();
|
||||||
const all = await fetchMentions(targetStr);
|
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 likes = all.filter((m) => m['wm-property'] === 'like-of');
|
||||||
const reposts = all.filter((m) => m['wm-property'] === 'repost-of');
|
const reposts = all.filter((m) => m['wm-property'] === 'repost-of');
|
||||||
const replies = all.filter((m) => m['wm-property'] === 'in-reply-to');
|
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>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function decode() {
|
import '~/scripts/decode-obf';
|
||||||
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
|
|
||||||
el.textContent = atob(el.dataset.obf!);
|
|
||||||
el.removeAttribute('data-obf');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
decode();
|
|
||||||
document.addEventListener('astro:page-load', decode);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,7 @@ import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function decode() {
|
import '~/scripts/decode-obf';
|
||||||
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
|
|
||||||
el.textContent = atob(el.dataset.obf!);
|
|
||||||
el.removeAttribute('data-obf');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
decode();
|
|
||||||
document.addEventListener('astro:page-load', decode);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<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