92 lines
2.9 KiB
TypeScript
92 lines
2.9 KiB
TypeScript
|
|
import path from 'node:path';
|
||
|
|
import fs from 'node:fs';
|
||
|
|
import fsPromises from 'node:fs/promises';
|
||
|
|
import crypto from 'node:crypto';
|
||
|
|
import { Response } from 'express';
|
||
|
|
import { db } from '../../db/database';
|
||
|
|
|
||
|
|
const TREK_PHOTO_DIR = path.join(__dirname, '../../../uploads/photos/trek');
|
||
|
|
export const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
||
|
|
|
||
|
|
const inFlight = new Map<string, Promise<Buffer | null>>();
|
||
|
|
|
||
|
|
export function cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string {
|
||
|
|
return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex');
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureDir(): void {
|
||
|
|
if (!fs.existsSync(TREK_PHOTO_DIR)) {
|
||
|
|
fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function cachedFilePath(key: string): string {
|
||
|
|
return path.join(TREK_PHOTO_DIR, `${key}.bin`);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getFresh(key: string): { filePath: string; contentType: string } | null {
|
||
|
|
const row = db.prepare(
|
||
|
|
'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?'
|
||
|
|
).get(key) as { content_type: string; fetched_at: number } | undefined;
|
||
|
|
|
||
|
|
if (!row) return null;
|
||
|
|
|
||
|
|
if (Date.now() - row.fetched_at >= CACHE_TTL) {
|
||
|
|
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const fp = cachedFilePath(key);
|
||
|
|
if (!fs.existsSync(fp)) {
|
||
|
|
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return { filePath: fp, contentType: row.content_type };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function put(key: string, bytes: Buffer, contentType: string): Promise<void> {
|
||
|
|
ensureDir();
|
||
|
|
const fp = cachedFilePath(key);
|
||
|
|
const tmp = fp + '.tmp';
|
||
|
|
|
||
|
|
await fsPromises.writeFile(tmp, bytes);
|
||
|
|
await fsPromises.rename(tmp, fp);
|
||
|
|
|
||
|
|
db.prepare(
|
||
|
|
'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)'
|
||
|
|
).run(key, contentType, Date.now());
|
||
|
|
}
|
||
|
|
|
||
|
|
export function serveFresh(res: Response, key: string): boolean {
|
||
|
|
const entry = getFresh(key);
|
||
|
|
if (!entry) return false;
|
||
|
|
|
||
|
|
res.set('Content-Type', entry.contentType);
|
||
|
|
res.set('Cache-Control', 'public, max-age=3600');
|
||
|
|
res.sendFile(entry.filePath);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getInFlight(key: string): Promise<Buffer | null> | undefined {
|
||
|
|
return inFlight.get(key);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function setInFlight(key: string, promise: Promise<Buffer | null>): void {
|
||
|
|
inFlight.set(key, promise);
|
||
|
|
promise.finally(() => inFlight.delete(key));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function sweepExpired(): void {
|
||
|
|
const cutoff = Date.now() - CACHE_TTL * 2;
|
||
|
|
const stale = db.prepare(
|
||
|
|
'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?'
|
||
|
|
).all(cutoff) as { cache_key: string }[];
|
||
|
|
|
||
|
|
for (const row of stale) {
|
||
|
|
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(row.cache_key);
|
||
|
|
const fp = cachedFilePath(row.cache_key);
|
||
|
|
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||
|
|
}
|
||
|
|
}
|