refactor: Proxy and cache avatar images (#907)

* refactor: proxy and cache user avatar images

* fix: extract keys

* fix: set avatar image URL

* fix: show the correct avatar in the list of available users in advanced request

* fix(s): set correct src URL for cached image

* fix: remove unexpired unused image when a user changes their avatar

* fix: requested changes

* refactor: use 'mime' package to detmerine file extension

* style: grammar

* refactor: checks if the default avatar is cached to avoid creating duplicates for different users

* fix: fix vulnerability

* fix: fix incomplete URL substring sanitization

* refactor: only cache avatar with http url protocol

* fix: remove log and correctly set the if statement for the cached image component

* fix: avatar images not showing on issues page

* style: formatting

---------

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
This commit is contained in:
Joaquin Olivero
2024-09-18 23:38:14 -03:00
committed by GitHub
parent 2b05ffface
commit edfd80444c
25 changed files with 238 additions and 84 deletions

View File

@@ -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';
@@ -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(

View File

@@ -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 {

View File

@@ -227,6 +227,9 @@ export const startJobs = (): void => {
});
// Clean TMDB image cache
ImageProxy.clearCache('tmdb');
// Clean users avatar image cache
ImageProxy.clearCache('avatar');
}),
});

View File

@@ -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 = {
@@ -11,7 +12,7 @@ type ImageResponse = {
curRevalidate: number;
isStale: boolean;
etag: string;
extension: string;
extension: string | null;
cacheKey: string;
cacheMiss: boolean;
};
@@ -27,29 +28,45 @@ class ImageProxy {
let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key);
const files = await promises.readdir(cacheDirectory);
try {
const files = await promises.readdir(cacheDirectory);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
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);
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();
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, imageFile));
deletedImages += 1;
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',
});
}
@@ -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 (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
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;
@@ -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);
@@ -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, '');
@@ -232,7 +295,7 @@ class ImageProxy {
private async writeToCacheDir(
dir: string,
extension: string,
extension: string | null,
maxAge: number,
expireAt: number,
buffer: Buffer,

View File

@@ -6,6 +6,7 @@ import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -342,6 +343,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}),
userType: UserType.EMBY,
});
break;
case MediaServerType.JELLYFIN:
settings.main.mediaServerType = MediaServerType.JELLYFIN;
@@ -360,6 +362,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}),
userType: UserType.JELLYFIN,
});
break;
default:
throw new Error('select_server_type');
@@ -407,12 +410,24 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
);
// Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) {
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
const avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
} else {
user.avatar = gravatarUrl(user.email || account.User.Name, {
const avatar = gravatarUrl(user.email || account.User.Name, {
default: 'mm',
size: 200,
});
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
}
user.jellyfinUsername = account.User.Name;
@@ -462,6 +477,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
? UserType.JELLYFIN
: UserType.EMBY,
});
//initialize Jellyfin/Emby users with local login
const passedExplicitPassword = body.password && body.password.length > 0;
if (passedExplicitPassword) {

View File

@@ -0,0 +1,32 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
const router = Router();
const avatarImageProxy = new ImageProxy('avatar', '');
// Proxy avatar images
router.get('/*', async (req, res) => {
const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url;
try {
const imageData = await avatarImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy avatar image', {
imagePath,
errorMessage: e.message,
});
}
});
export default router;

View File

@@ -746,11 +746,13 @@ settingsRoutes.get('/cache', async (_req, res) => {
}));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
avatar: avatarImageCache,
},
});
});