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', });