diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index 4a05f580..16623336 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -3,6 +3,7 @@ package controller import ( "net/http" "strconv" + "time" "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/common" @@ -247,6 +248,8 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType mimeType := utils.GetImageMimeType(imageType) c.Header("Content-Type", mimeType) + + utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour) c.File(imagePath) } diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index ff1110a7..13adaf49 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/gin-gonic/gin" @@ -545,6 +546,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) { return } + utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour) + c.Header("Content-Type", mimeType) c.File(imagePath) } diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 509b408c..678d9b48 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -250,10 +250,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) { defer picture.Close() } - _, ok := c.GetQuery("skipCache") - if !ok { - c.Header("Cache-Control", "public, max-age=900") - } + utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour) c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) } diff --git a/backend/internal/utils/http_util.go b/backend/internal/utils/http_util.go index b8c81b3f..10f49e70 100644 --- a/backend/internal/utils/http_util.go +++ b/backend/internal/utils/http_util.go @@ -1,8 +1,11 @@ package utils import ( + "github.com/gin-gonic/gin" "net/http" + "strconv" "strings" + "time" ) // BearerAuth returns the value of the bearer token in the Authorization header if present @@ -16,3 +19,14 @@ func BearerAuth(r *http.Request) (string, bool) { return "", false } + +// SetCacheControlHeader sets the Cache-Control header for the response. +func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) { + _, ok := ctx.GetQuery("skipCache") + if !ok { + maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds())) + staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds())) + ctx.Header("Cache-Control", "public, max-age="+maxAgeSeconds+", stale-while-revalidate="+staleWhileRevalidateSeconds) + } + +} diff --git a/frontend/src/lib/components/form/profile-picture-settings.svelte b/frontend/src/lib/components/form/profile-picture-settings.svelte index 9c186d63..5160582d 100644 --- a/frontend/src/lib/components/form/profile-picture-settings.svelte +++ b/frontend/src/lib/components/form/profile-picture-settings.svelte @@ -3,7 +3,7 @@ import * as Avatar from '$lib/components/ui/avatar'; import Button from '$lib/components/ui/button/button.svelte'; import { m } from '$lib/paraglide/messages'; - import { getProfilePictureUrl } from '$lib/utils/profile-picture-util'; + import { cachedProfilePicture } from '$lib/utils/cached-image-util'; import { LucideLoader, LucideRefreshCw, LucideUpload } from '@lucide/svelte'; import { onMount } from 'svelte'; import { openConfirmDialog } from '../confirm-dialog'; @@ -25,7 +25,7 @@ onMount(() => { // The "skipCache" query will only be added to the profile picture url on client-side // because of that we need to set the imageDataURL after the component is mounted - imageDataURL = getProfilePictureUrl(userId); + imageDataURL = cachedProfilePicture.getUrl(userId); }); async function onImageChange(e: Event) { @@ -41,7 +41,7 @@ reader.readAsDataURL(file); await updateCallback(file).catch(() => { - imageDataURL = getProfilePictureUrl(userId); + imageDataURL = cachedProfilePicture.getUrl(userId); }); isLoading = false; } diff --git a/frontend/src/lib/components/header/header-avatar.svelte b/frontend/src/lib/components/header/header-avatar.svelte index 43ddaf3f..87e40695 100644 --- a/frontend/src/lib/components/header/header-avatar.svelte +++ b/frontend/src/lib/components/header/header-avatar.svelte @@ -5,7 +5,7 @@ import { m } from '$lib/paraglide/messages'; import WebAuthnService from '$lib/services/webauthn-service'; import userStore from '$lib/stores/user-store'; - import { getProfilePictureUrl } from '$lib/utils/profile-picture-util'; + import { cachedProfilePicture } from '$lib/utils/cached-image-util'; import { LucideLogOut, LucideUser } from '@lucide/svelte'; const webauthnService = new WebAuthnService(); @@ -19,7 +19,7 @@ - + diff --git a/frontend/src/lib/components/login-wrapper.svelte b/frontend/src/lib/components/login-wrapper.svelte index 9914f468..2ba3e93d 100644 --- a/frontend/src/lib/components/login-wrapper.svelte +++ b/frontend/src/lib/components/login-wrapper.svelte @@ -1,6 +1,7 @@ -{m.logo()} +{m.logo()} diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index eab50d96..b3505314 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -1,4 +1,5 @@ import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration'; +import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached-image-util'; import APIService from './api-service'; export default class AppConfigService extends APIService { @@ -36,6 +37,7 @@ export default class AppConfigService extends APIService { await this.api.put(`/application-configuration/logo`, formData, { params: { light } }); + cachedApplicationLogo.bustCache(light); } async updateBackgroundImage(backgroundImage: File) { @@ -43,6 +45,7 @@ export default class AppConfigService extends APIService { formData.append('file', backgroundImage!); await this.api.put(`/application-configuration/background-image`, formData); + cachedBackgroundImage.bustCache(); } async sendTestEmail() { diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index 9899b4c6..aea3a069 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -8,6 +8,7 @@ import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import { cachedOidcClientLogo } from '$lib/utils/cached-image-util'; import APIService from './api-service'; class OidcService extends APIService { @@ -80,10 +81,12 @@ class OidcService extends APIService { formData.append('file', image!); await this.api.post(`/oidc/clients/${client.id}/logo`, formData); + cachedOidcClientLogo.bustCache(client.id); } async removeClientLogo(id: string) { await this.api.delete(`/oidc/clients/${id}/logo`); + cachedOidcClientLogo.bustCache(id); } async createClientSecret(id: string) { diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index 25002db0..4a60782f 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -2,7 +2,7 @@ import userStore from '$lib/stores/user-store'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { UserGroup } from '$lib/types/user-group.type'; import type { User, UserCreate } from '$lib/types/user.type'; -import { bustProfilePictureCache } from '$lib/utils/profile-picture-util'; +import { cachedProfilePicture } from '$lib/utils/cached-image-util'; import { get } from 'svelte/store'; import APIService from './api-service'; @@ -52,26 +52,26 @@ export default class UserService extends APIService { const formData = new FormData(); formData.append('file', image!); - bustProfilePictureCache(userId); await this.api.put(`/users/${userId}/profile-picture`, formData); + cachedProfilePicture.bustCache(userId); } async updateCurrentUsersProfilePicture(image: File) { const formData = new FormData(); formData.append('file', image!); - bustProfilePictureCache(get(userStore)!.id); await this.api.put('/users/me/profile-picture', formData); + cachedProfilePicture.bustCache(get(userStore)!.id); } async resetCurrentUserProfilePicture() { - bustProfilePictureCache(get(userStore)!.id); await this.api.delete(`/users/me/profile-picture`); + cachedProfilePicture.bustCache(get(userStore)!.id); } async resetProfilePicture(userId: string) { - bustProfilePictureCache(userId); await this.api.delete(`/users/${userId}/profile-picture`); + cachedProfilePicture.bustCache(userId); } async createOneTimeAccessToken(expiresAt: Date, userId: string) { diff --git a/frontend/src/lib/utils/cached-image-util.ts b/frontend/src/lib/utils/cached-image-util.ts new file mode 100644 index 00000000..43229d5e --- /dev/null +++ b/frontend/src/lib/utils/cached-image-util.ts @@ -0,0 +1,89 @@ +type SkipCacheUntil = { + [key: string]: number; +}; + +type CachableImage = { + getUrl: (...props: any[]) => string; + bustCache: (...props: any[]) => void; +}; + +export const cachedApplicationLogo: CachableImage = { + getUrl: (light = true) => { + let url = '/api/application-configuration/logo'; + if (!light) { + url += '?light=false'; + } + return getCachedImageUrl(url); + }, + bustCache: (light = true) => { + let url = '/api/application-configuration/logo'; + if (!light) { + url += '?light=false'; + } + bustImageCache(url); + } +}; + +export const cachedBackgroundImage: CachableImage = { + getUrl: () => getCachedImageUrl('/api/application-configuration/background-image'), + bustCache: () => bustImageCache('/api/application-configuration/background-image') +}; + +export const cachedProfilePicture: CachableImage = { + getUrl: (userId: string) => { + const url = `/api/users/${userId}/profile-picture.png`; + return getCachedImageUrl(url); + }, + bustCache: (userId: string) => { + const url = `/api/users/${userId}/profile-picture.png`; + bustImageCache(url); + } +}; + +export const cachedOidcClientLogo: CachableImage = { + getUrl: (clientId: string) => { + const url = `/api/oidc/clients/${clientId}/logo`; + return getCachedImageUrl(url); + }, + bustCache: (clientId: string) => { + const url = `/api/oidc/clients/${clientId}/logo`; + bustImageCache(url); + } +}; + +function getCachedImageUrl(url: string) { + const skipCacheUntil = getSkipCacheUntil(url); + const skipCache = skipCacheUntil > Date.now(); + if (skipCache) { + const skipCacheParam = new URLSearchParams(); + skipCacheParam.append('skip-cache', skipCacheUntil.toString()); + url += '?' + skipCacheParam.toString(); + } + + return url.toString(); +} + +function bustImageCache(url: string) { + const skipCacheUntil: SkipCacheUntil = JSON.parse( + localStorage.getItem('skip-cache-until') ?? '{}' + ); + skipCacheUntil[hashKey(url)] = Date.now() + 1000 * 60 * 15; // 15 minutes + localStorage.setItem('skip-cache-until', JSON.stringify(skipCacheUntil)); +} + +function getSkipCacheUntil(url: string) { + const skipCacheUntil: SkipCacheUntil = JSON.parse( + localStorage.getItem('skip-cache-until') ?? '{}' + ); + return skipCacheUntil[hashKey(url)] ?? 0; +} + +function hashKey(key: string): string { + let hash = 0; + for (let i = 0; i < key.length; i++) { + const char = key.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash).toString(36); +} diff --git a/frontend/src/lib/utils/profile-picture-util.ts b/frontend/src/lib/utils/profile-picture-util.ts deleted file mode 100644 index a53db104..00000000 --- a/frontend/src/lib/utils/profile-picture-util.ts +++ /dev/null @@ -1,34 +0,0 @@ -type SkipCacheUntil = { - [key: string]: number; -}; - -export function getProfilePictureUrl(userId?: string) { - if (!userId) return ''; - - let url = `/api/users/${userId}/profile-picture.png`; - - const skipCacheUntil = getSkipCacheUntil(userId); - const skipCache = skipCacheUntil > Date.now(); - if (skipCache) { - const skipCacheParam = new URLSearchParams(); - skipCacheParam.append('skip-cache', skipCacheUntil.toString()); - url += '?' + skipCacheParam.toString(); - } - - return url.toString(); -} - -function getSkipCacheUntil(userId: string) { - const skipCacheUntil: SkipCacheUntil = JSON.parse( - localStorage.getItem('skip-cache-until') ?? '{}' - ); - return skipCacheUntil[userId] ?? 0; -} - -export function bustProfilePictureCache(userId: string) { - const skipCacheUntil: SkipCacheUntil = JSON.parse( - localStorage.getItem('skip-cache-until') ?? '{}' - ); - skipCacheUntil[userId] = Date.now() + 1000 * 60 * 15; // 15 minutes - localStorage.setItem('skip-cache-until', JSON.stringify(skipCacheUntil)); -} diff --git a/frontend/src/routes/authorize/components/client-provider-images.svelte b/frontend/src/routes/authorize/components/client-provider-images.svelte index dc2f2853..946e2986 100644 --- a/frontend/src/routes/authorize/components/client-provider-images.svelte +++ b/frontend/src/routes/authorize/components/client-provider-images.svelte @@ -5,6 +5,7 @@ import CrossAnimated from '$lib/icons/cross-animated.svelte'; import { m } from '$lib/paraglide/messages'; import type { OidcClientMetaData } from '$lib/types/oidc.type'; + import { cachedOidcClientLogo } from '$lib/utils/cached-image-util'; const { success, @@ -60,7 +61,7 @@ {:else if client.hasLogo} {m.client_logo()} diff --git a/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte b/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte index 2d87e38f..4127d010 100644 --- a/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte @@ -1,6 +1,7 @@