Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Proxy and cache avatar images #907

Merged
merged 16 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ module.exports = {
remotePatterns: [
{ hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' },
{ hostname: '*', protocol: 'https' },
],
},
webpack(config) {
Expand Down
9 changes: 9 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2790,6 +2790,15 @@ paths:
imageCount:
type: number
example: 123
avatar:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches:
type: array
items:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"formik": "^2.4.6",
"gravatar-url": "3.1.0",
"lodash": "4.17.21",
"mime": "3",
gauthier-th marked this conversation as resolved.
Show resolved Hide resolved
"next": "^14.2.4",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
Expand Down Expand Up @@ -119,6 +120,7 @@
"@types/express": "4.17.17",
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "20.14.8",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",
Expand Down
25 changes: 18 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
Expand Down Expand Up @@ -202,6 +203,7 @@ app

// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);

server.get('*', (req, res) => handle(req, res));
server.use(
Expand Down
2 changes: 1 addition & 1 deletion server/interfaces/api/settingsInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export interface CacheItem {

export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
}

export interface StatusResponse {
Expand Down
3 changes: 3 additions & 0 deletions server/job/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ export const startJobs = (): void => {
});
// Clean TMDB image cache
ImageProxy.clearCache('tmdb');

// Clean users avatar image cache
ImageProxy.clearCache('avatar');
}),
});

Expand Down
141 changes: 102 additions & 39 deletions server/lib/imageproxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 = {
Expand All @@ -11,7 +12,7 @@ type ImageResponse = {
curRevalidate: number;
isStale: boolean;
etag: string;
extension: string;
extension: string | null;
cacheKey: string;
cacheMiss: boolean;
};
Expand All @@ -27,29 +28,45 @@ class ImageProxy {
let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key);

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();
try {
const files = await promises.readdir(cacheDirectory);

if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile));
deletedImages += 1;
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`, {
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
label: 'Image Cache',
});
}
Expand All @@ -69,33 +86,49 @@ class ImageProxy {
}

private static async getDirectorySize(dir: string): Promise<number> {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
try {
const files = await promises.readdir(dir, {
withFileTypes: true,
});

const paths = files.map(async (file) => {
const path = join(dir, file.name);
const paths = files.map(async (file) => {
const path = join(dir, file.name);

if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);

if (file.isFile()) {
const { size } = await promises.stat(path);
if (file.isFile()) {
const { size } = await promises.stat(path);

return size;
}
return size;
}

return 0;
});
return 0;
});

return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}

return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
return 0;
}

private static async getImageCount(dir: string) {
const files = await promises.readdir(dir);
try {
const files = await promises.readdir(dir);

return files.length;
return files.length;
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}

return 0;
}

private fetch: typeof fetch;
Expand Down Expand Up @@ -147,6 +180,27 @@ class ImageProxy {
return imageResponse;
}

public async clearCachedImage(path: string) {
// find cacheKey
const cacheKey = this.getCacheKey(path);

try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);

await promises.rm(directory, { recursive: true });

logger.info(`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);
Expand Down Expand Up @@ -187,16 +241,25 @@ class ImageProxy {
const directory = join(this.getCacheDirectory(), cacheKey);
const href =
this.baseUrl +
(this.baseUrl.endsWith('/') ? '' : '/') +
(this.baseUrl.length > 0
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);

const extension = path.split('.').pop() ?? '';
const maxAge = Number(
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, '');

Expand Down Expand Up @@ -232,7 +295,7 @@ class ImageProxy {

private async writeToCacheDir(
dir: string,
extension: string,
extension: string | null,
maxAge: number,
expireAt: number,
buffer: Buffer,
Expand Down
Loading
Loading