From 29034b350d35ebaed52556448e46436aeb644e77 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Fri, 28 Mar 2025 06:02:34 +0800 Subject: [PATCH] fix(avatar): fix avatar cache busting by using avatarVersion (#1537) * 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 --- server/entity/User.ts | 6 ++ server/lib/imageproxy.ts | 24 ++++- .../1743107707465-AddUserAvatarCacheFields.ts | 21 +++++ .../1743107645301-AddUserAvatarCacheFields.ts | 69 ++++++++++++++ server/routes/auth.ts | 33 ++++++- server/routes/avatarproxy.ts | 92 +++++++++++++++++-- 6 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts create mode 100644 server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts diff --git a/server/entity/User.ts b/server/entity/User.ts index 91b667403..5f51af710 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -98,6 +98,12 @@ export class User { @Column() public avatar: string; + @Column({ type: 'varchar', nullable: true }) + public avatarETag?: string | null; + + @Column({ type: 'varchar', nullable: true }) + public avatarVersion?: string | null; + @RelationCount((user: User) => user.requests) public requestCount: number; diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 04e320a0b..607245698 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -193,14 +193,34 @@ class ImageProxy { 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 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'`, { + logger.debug(`Cleared ${files[0]} from cache 'avatar'`, { label: 'Image Cache', }); } catch (e) { diff --git a/server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts b/server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts new file mode 100644 index 000000000..1e61e996e --- /dev/null +++ b/server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserAvatarCacheFields1743107707465 + implements MigrationInterface +{ + name = 'AddUserAvatarCacheFields1743107707465'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "avatarETag" character varying` + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "avatarVersion" character varying` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarVersion"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarETag"`); + } +} diff --git a/server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts b/server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts new file mode 100644 index 000000000..aff3e357e --- /dev/null +++ b/server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts @@ -0,0 +1,69 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserAvatarCacheFields1743107645301 + implements MigrationInterface +{ + name = 'AddUserAvatarCacheFields1743107645301'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 4e470831a..df6908a12 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -10,6 +10,7 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import { checkAvatarChanged } from '@server/routes/avatarproxy'; import { ApiError } from '@server/types/error'; import { getHostname } from '@server/utils/getHostname'; import * as EmailValidator from 'email-validator'; @@ -216,6 +217,10 @@ authRoutes.post('/plex', async (req, res, next) => { } }); +function getUserAvatarUrl(user: User): string { + return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`; +} + authRoutes.post('/jellyfin', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); @@ -343,12 +348,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinDeviceId: deviceId, jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, - avatar: `/avatarproxy/${account.User.Id}`, userType: body.serverType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY, }); + user.avatar = getUserAvatarUrl(user); await userRepository.save(user); } else { @@ -375,7 +380,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { user.jellyfinDeviceId = deviceId; user.jellyfinAuthToken = account.AccessToken; user.permissions = Permission.ADMIN; - user.avatar = `/avatarproxy/${account.User.Id}`; + user.avatar = getUserAvatarUrl(user); user.userType = body.serverType === MediaServerType.JELLYFIN ? UserType.JELLYFIN @@ -422,7 +427,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUsername: account.User.Name, } ); - user.avatar = `/avatarproxy/${account.User.Id}`; + user.avatar = getUserAvatarUrl(user); user.jellyfinUsername = account.User.Name; if (user.username === account.User.Name) { @@ -460,12 +465,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, - avatar: `/avatarproxy/${account.User.Id}`, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY, }); + user.avatar = getUserAvatarUrl(user); //initialize Jellyfin/Emby users with local login const passedExplicitPassword = body.password && body.password.length > 0; @@ -475,6 +480,26 @@ authRoutes.post('/jellyfin', async (req, res, next) => { await userRepository.save(user); } + if (user && user.jellyfinUserId) { + try { + const { changed } = await checkAvatarChanged(user); + + if (changed) { + user.avatar = getUserAvatarUrl(user); + await userRepository.save(user); + logger.debug('Avatar updated during login', { + userId: user.id, + jellyfinUserId: user.jellyfinUserId, + }); + } + } catch (error) { + logger.error('Error handling avatar during login', { + label: 'Auth', + errorMessage: error.message, + }); + } + } + // Set logged in session if (req.session) { req.session.userId = user?.id; diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts index 5938fa945..0065f011b 100644 --- a/server/routes/avatarproxy.ts +++ b/server/routes/avatarproxy.ts @@ -8,10 +8,12 @@ import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; +import { createHash } from 'node:crypto'; const router = Router(); let _avatarImageProxy: ImageProxy | null = null; + async function initAvatarImageProxy() { if (!_avatarImageProxy) { const userRepository = getRepository(User); @@ -31,6 +33,79 @@ async function initAvatarImageProxy() { return _avatarImageProxy; } +function getJellyfinAvatarUrl(userId: string) { + const settings = getSettings(); + return settings.main.mediaServerType === MediaServerType.JELLYFIN + ? `${getHostname()}/UserImage?UserId=${userId}` + : `${getHostname()}/Users/${userId}/Images/Primary?quality=90`; +} + +function computeImageHash(buffer: Buffer): string { + return createHash('sha256').update(buffer).digest('hex'); +} + +export async function checkAvatarChanged( + user: User +): Promise<{ changed: boolean; etag?: string }> { + try { + if (!user || !user.jellyfinUserId) { + return { changed: false }; + } + + const jellyfinAvatarUrl = getJellyfinAvatarUrl(user.jellyfinUserId); + + const headResponse = await fetch(jellyfinAvatarUrl, { method: 'HEAD' }); + if (!headResponse.ok) { + return { changed: false }; + } + + const settings = getSettings(); + let remoteVersion: string; + if (settings.main.mediaServerType === MediaServerType.JELLYFIN) { + const remoteLastModifiedStr = + headResponse.headers.get('last-modified') || ''; + remoteVersion = ( + Date.parse(remoteLastModifiedStr) || Date.now() + ).toString(); + } else if (settings.main.mediaServerType === MediaServerType.EMBY) { + remoteVersion = + headResponse.headers.get('etag')?.replace(/"/g, '') || + Date.now().toString(); + } else { + remoteVersion = Date.now().toString(); + } + + if (user.avatarVersion && user.avatarVersion === remoteVersion) { + return { changed: false, etag: user.avatarETag ?? undefined }; + } + + const avatarImageCache = await initAvatarImageProxy(); + await avatarImageCache.clearCachedImage(jellyfinAvatarUrl); + const imageData = await avatarImageCache.getImage( + jellyfinAvatarUrl, + gravatarUrl(user.email || 'none', { default: 'mm', size: 200 }) + ); + + const newHash = computeImageHash(imageData.imageBuffer); + + const hasChanged = user.avatarETag !== newHash; + + user.avatarVersion = remoteVersion; + if (hasChanged) { + user.avatarETag = newHash; + } + + await getRepository(User).save(user); + + return { changed: hasChanged, etag: newHash }; + } catch (error) { + logger.error('Error checking avatar changes', { + errorMessage: error.message, + }); + return { changed: false }; + } +} + router.get('/:jellyfinUserId', async (req, res) => { try { if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) { @@ -46,6 +121,10 @@ router.get('/:jellyfinUserId', async (req, res) => { const avatarImageCache = await initAvatarImageProxy(); + const userEtag = req.headers['if-none-match']; + + const versionParam = req.query.v; + const user = await getRepository(User).findOne({ where: { jellyfinUserId: req.params.jellyfinUserId }, }); @@ -55,13 +134,7 @@ router.get('/:jellyfinUserId', async (req, res) => { size: 200, }); - const setttings = getSettings(); - const jellyfinAvatarUrl = - setttings.main.mediaServerType === MediaServerType.JELLYFIN - ? `${getHostname()}/UserImage?UserId=${req.params.jellyfinUserId}` - : `${getHostname()}/Users/${ - req.params.jellyfinUserId - }/Images/Primary?quality=90`; + const jellyfinAvatarUrl = getJellyfinAvatarUrl(req.params.jellyfinUserId); let imageData = await avatarImageCache.getImage( jellyfinAvatarUrl, @@ -73,10 +146,15 @@ router.get('/:jellyfinUserId', async (req, res) => { imageData = await avatarImageCache.getImage(fallbackUrl); } + if (userEtag && userEtag === `"${imageData.meta.etag}"` && !versionParam) { + return res.status(304).end(); + } + res.writeHead(200, { 'Content-Type': `image/${imageData.meta.extension}`, 'Content-Length': imageData.imageBuffer.length, 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, + ETag: `"${imageData.meta.etag}"`, 'OS-Cache-Key': imageData.meta.cacheKey, 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', });