mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
* fix(avatar): fix avatar cache busting by using avatarVersion Previously, avatar caching did not update the avatar when the remote image changed. This commit adds logic to check if the avatar was modified remotely by comparing aremote last-modified timestamp with a locally stored version (avatarVersion). If a change is detected, the cache is cleared, a new image is fetched, and avatarVersionis updated. Otherwise, the cached image is retained. * chore(db): add db migrations * refactor: refactor imagehelpers util to where its used * refactor: remove remnants from previous cache busting versions
371 lines
9.0 KiB
TypeScript
371 lines
9.0 KiB
TypeScript
import logger from '@server/logger';
|
|
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
|
import rateLimit from '@server/utils/rateLimit';
|
|
import { createHash } from 'crypto';
|
|
import { promises } from 'fs';
|
|
import mime from 'mime/lite';
|
|
import path, { join } from 'path';
|
|
|
|
type ImageResponse = {
|
|
meta: {
|
|
revalidateAfter: number;
|
|
curRevalidate: number;
|
|
isStale: boolean;
|
|
etag: string;
|
|
extension: string | null;
|
|
cacheKey: string;
|
|
cacheMiss: boolean;
|
|
};
|
|
imageBuffer: Buffer;
|
|
};
|
|
|
|
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
|
|
? `${process.env.CONFIG_DIRECTORY}/cache/images`
|
|
: path.join(__dirname, '../../config/cache/images');
|
|
|
|
class ImageProxy {
|
|
public static async clearCache(key: string) {
|
|
let deletedImages = 0;
|
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
|
|
|
try {
|
|
const files = await promises.readdir(cacheDirectory);
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(cacheDirectory, file);
|
|
const stat = await promises.lstat(filePath);
|
|
|
|
if (stat.isDirectory()) {
|
|
const imageFiles = await promises.readdir(filePath);
|
|
|
|
for (const imageFile of imageFiles) {
|
|
const [, expireAtSt] = imageFile.split('.');
|
|
const expireAt = Number(expireAtSt);
|
|
const now = Date.now();
|
|
|
|
if (now > expireAt) {
|
|
await promises.rm(path.join(filePath), {
|
|
recursive: true,
|
|
});
|
|
deletedImages += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') {
|
|
logger.error('Directory not found', {
|
|
label: 'Image Cache',
|
|
message: e.message,
|
|
});
|
|
} else {
|
|
logger.error('Failed to read directory', {
|
|
label: 'Image Cache',
|
|
message: e.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
|
|
label: 'Image Cache',
|
|
});
|
|
}
|
|
|
|
public static async getImageStats(
|
|
key: string
|
|
): Promise<{ size: number; imageCount: number }> {
|
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
|
|
|
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
|
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
|
|
|
return {
|
|
size: imageTotalSize,
|
|
imageCount,
|
|
};
|
|
}
|
|
|
|
private static async getDirectorySize(dir: string): Promise<number> {
|
|
try {
|
|
const files = await promises.readdir(dir, {
|
|
withFileTypes: true,
|
|
});
|
|
|
|
const paths = files.map(async (file) => {
|
|
const path = join(dir, file.name);
|
|
|
|
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
|
|
|
if (file.isFile()) {
|
|
const { size } = await promises.stat(path);
|
|
|
|
return size;
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
|
|
return (await Promise.all(paths))
|
|
.flat(Infinity)
|
|
.reduce((i, size) => i + size, 0);
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private static async getImageCount(dir: string) {
|
|
try {
|
|
const files = await promises.readdir(dir);
|
|
|
|
return files.length;
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private fetch: typeof fetch;
|
|
private cacheVersion;
|
|
private key;
|
|
private baseUrl;
|
|
private headers: HeadersInit | null = null;
|
|
|
|
constructor(
|
|
key: string,
|
|
baseUrl: string,
|
|
options: {
|
|
cacheVersion?: number;
|
|
rateLimitOptions?: RateLimitOptions;
|
|
headers?: HeadersInit;
|
|
} = {}
|
|
) {
|
|
this.cacheVersion = options.cacheVersion ?? 1;
|
|
this.baseUrl = baseUrl;
|
|
this.key = key;
|
|
|
|
if (options.rateLimitOptions) {
|
|
this.fetch = rateLimit(fetch, {
|
|
...options.rateLimitOptions,
|
|
});
|
|
} else {
|
|
this.fetch = fetch;
|
|
}
|
|
this.headers = options.headers || null;
|
|
}
|
|
|
|
public async getImage(
|
|
path: string,
|
|
fallbackPath?: string
|
|
): Promise<ImageResponse> {
|
|
const cacheKey = this.getCacheKey(path);
|
|
|
|
const imageResponse = await this.get(cacheKey);
|
|
|
|
if (!imageResponse) {
|
|
const newImage = await this.set(path, cacheKey);
|
|
|
|
if (!newImage) {
|
|
if (fallbackPath) {
|
|
return await this.getImage(fallbackPath);
|
|
} else {
|
|
throw new Error('Failed to load image');
|
|
}
|
|
}
|
|
|
|
return newImage;
|
|
}
|
|
|
|
// If the image is stale, we will revalidate it in the background.
|
|
if (imageResponse.meta.isStale) {
|
|
this.set(path, cacheKey);
|
|
}
|
|
|
|
return imageResponse;
|
|
}
|
|
|
|
public async clearCachedImage(path: string) {
|
|
// find cacheKey
|
|
const cacheKey = this.getCacheKey(path);
|
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
|
|
|
try {
|
|
await promises.access(directory);
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') {
|
|
logger.debug(
|
|
`Cache directory '${cacheKey}' does not exist; nothing to clear.`,
|
|
{
|
|
label: 'Image Cache',
|
|
}
|
|
);
|
|
return;
|
|
} else {
|
|
logger.error('Error checking cache directory existence', {
|
|
label: 'Image Cache',
|
|
message: e.message,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const files = await promises.readdir(directory);
|
|
|
|
await promises.rm(directory, { recursive: true });
|
|
|
|
logger.debug(`Cleared ${files[0]} from cache 'avatar'`, {
|
|
label: 'Image Cache',
|
|
});
|
|
} catch (e) {
|
|
logger.error('Failed to clear cached image', {
|
|
label: 'Image Cache',
|
|
message: e.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async get(cacheKey: string): Promise<ImageResponse | null> {
|
|
try {
|
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
|
const files = await promises.readdir(directory);
|
|
const now = Date.now();
|
|
|
|
for (const file of files) {
|
|
const [maxAgeSt, expireAtSt, etag, extension] = file.split('.');
|
|
const buffer = await promises.readFile(join(directory, file));
|
|
const expireAt = Number(expireAtSt);
|
|
const maxAge = Number(maxAgeSt);
|
|
|
|
return {
|
|
meta: {
|
|
curRevalidate: maxAge,
|
|
revalidateAfter: maxAge * 1000 + now,
|
|
isStale: now > expireAt,
|
|
etag,
|
|
extension,
|
|
cacheKey,
|
|
cacheMiss: false,
|
|
},
|
|
imageBuffer: buffer,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
// No files. Treat as empty cache.
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async set(
|
|
path: string,
|
|
cacheKey: string
|
|
): Promise<ImageResponse | null> {
|
|
try {
|
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
|
const href =
|
|
this.baseUrl +
|
|
(this.baseUrl.length > 0
|
|
? this.baseUrl.endsWith('/')
|
|
? ''
|
|
: '/'
|
|
: '') +
|
|
(path.startsWith('/') ? path.slice(1) : path);
|
|
const response = await this.fetch(href, {
|
|
headers: this.headers || undefined,
|
|
});
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
|
|
const extension = mime.getExtension(
|
|
response.headers.get('content-type') ?? ''
|
|
);
|
|
|
|
let maxAge = Number(
|
|
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
|
);
|
|
|
|
if (!maxAge) maxAge = 86400;
|
|
const expireAt = Date.now() + maxAge * 1000;
|
|
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
|
|
|
await this.writeToCacheDir(
|
|
directory,
|
|
extension,
|
|
maxAge,
|
|
expireAt,
|
|
buffer,
|
|
etag
|
|
);
|
|
|
|
return {
|
|
meta: {
|
|
curRevalidate: maxAge,
|
|
revalidateAfter: expireAt,
|
|
isStale: false,
|
|
etag,
|
|
extension,
|
|
cacheKey,
|
|
cacheMiss: true,
|
|
},
|
|
imageBuffer: buffer,
|
|
};
|
|
} catch (e) {
|
|
logger.debug('Something went wrong caching image.', {
|
|
label: 'Image Cache',
|
|
errorMessage: e.message,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async writeToCacheDir(
|
|
dir: string,
|
|
extension: string | null,
|
|
maxAge: number,
|
|
expireAt: number,
|
|
buffer: Buffer,
|
|
etag: string
|
|
) {
|
|
const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`);
|
|
|
|
await promises.rm(dir, { force: true, recursive: true }).catch(() => {
|
|
// do nothing
|
|
});
|
|
|
|
await promises.mkdir(dir, { recursive: true });
|
|
await promises.writeFile(filename, buffer);
|
|
}
|
|
|
|
private getCacheKey(path: string) {
|
|
return this.getHash([this.key, this.cacheVersion, path]);
|
|
}
|
|
|
|
private getHash(items: (string | number | Buffer)[]) {
|
|
const hash = createHash('sha256');
|
|
for (const item of items) {
|
|
if (typeof item === 'number') hash.update(String(item));
|
|
else {
|
|
hash.update(item);
|
|
}
|
|
}
|
|
// See https://en.wikipedia.org/wiki/Base64#Filenames
|
|
return hash.digest('base64').replace(/\//g, '-');
|
|
}
|
|
|
|
private getCacheDirectory() {
|
|
return path.join(baseCacheDirectory, this.key);
|
|
}
|
|
}
|
|
|
|
export default ImageProxy;
|