diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index fe62d326..4a6cbe00 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -254,7 +254,10 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) { defer picture.Close() } - c.Header("Cache-Control", "public, max-age=300") + _, ok := c.GetQuery("skipCache") + if !ok { + c.Header("Cache-Control", "public, max-age=900") + } c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) } diff --git a/frontend/src/lib/components/form/profile-picture-settings.svelte b/frontend/src/lib/components/form/profile-picture-settings.svelte index 1fcf142e..5e68026e 100644 --- a/frontend/src/lib/components/form/profile-picture-settings.svelte +++ b/frontend/src/lib/components/form/profile-picture-settings.svelte @@ -2,10 +2,11 @@ import FileInput from '$lib/components/form/file-input.svelte'; import * as Avatar from '$lib/components/ui/avatar'; import Button from '$lib/components/ui/button/button.svelte'; - import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte'; - import { openConfirmDialog } from '../confirm-dialog'; import { m } from '$lib/paraglide/messages'; - import type UserService from '$lib/services/user-service'; + import { getProfilePictureUrl } from '$lib/utils/profile-picture-util'; + import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte'; + import { onMount } from 'svelte'; + import { openConfirmDialog } from '../confirm-dialog'; let { userId, @@ -20,7 +21,12 @@ } = $props(); let isLoading = $state(false); - let imageDataURL = $state(`/api/users/${userId}/profile-picture.png`); + let imageDataURL = $state(''); + 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); + }); async function onImageChange(e: Event) { const file = (e.target as HTMLInputElement).files?.[0] || null; @@ -35,7 +41,7 @@ reader.readAsDataURL(file); await updateCallback(file).catch(() => { - imageDataURL = `/api/users/${userId}/profile-picture.png`; + imageDataURL = getProfilePictureUrl(userId); }); isLoading = false; } diff --git a/frontend/src/lib/components/header/header-avatar.svelte b/frontend/src/lib/components/header/header-avatar.svelte index 3670f9a2..6e15ae04 100644 --- a/frontend/src/lib/components/header/header-avatar.svelte +++ b/frontend/src/lib/components/header/header-avatar.svelte @@ -4,6 +4,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 { LucideLogOut, LucideUser } from 'lucide-svelte'; const webauthnService = new WebAuthnService(); @@ -17,7 +18,7 @@ - + diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index 9e87199c..97efa326 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -1,6 +1,9 @@ +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 { get } from 'svelte/store'; import APIService from './api-service'; export default class UserService extends APIService { @@ -49,6 +52,7 @@ 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); } @@ -56,14 +60,17 @@ export default class UserService extends APIService { const formData = new FormData(); formData.append('file', image!); + bustProfilePictureCache(get(userStore)!.id); await this.api.put('/users/me/profile-picture', formData); } async resetCurrentUserProfilePicture() { + bustProfilePictureCache(get(userStore)!.id); await this.api.delete(`/users/me/profile-picture`); } async resetProfilePicture(userId: string) { + bustProfilePictureCache(userId); await this.api.delete(`/users/${userId}/profile-picture`); } diff --git a/frontend/src/lib/utils/profile-picture-util.ts b/frontend/src/lib/utils/profile-picture-util.ts new file mode 100644 index 00000000..ef36f7f5 --- /dev/null +++ b/frontend/src/lib/utils/profile-picture-util.ts @@ -0,0 +1,38 @@ +import { browser } from '$app/environment'; + +type SkipCacheUntil = { + [key: string]: number; +}; + +export function getProfilePictureUrl(userId?: string) { + if (!userId) return ''; + + let url = `/api/users/${userId}/profile-picture.png`; + + if (browser) { + 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)); +}