fix: explicitly cache images to prevent unexpected behavior

This commit is contained in:
Elias Schneider
2025-06-16 15:59:14 +02:00
parent 4ed312251e
commit 2e5d268798
17 changed files with 142 additions and 58 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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;
}

View File

@@ -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 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger
><Avatar.Root class="size-9">
<Avatar.Image src={getProfilePictureUrl($userStore?.id)} />
<Avatar.Image src={cachedProfilePicture.getUrl($userStore!.id)} />
</Avatar.Root></DropdownMenu.Trigger
>
<DropdownMenu.Content class="min-w-40" align="end">

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
import { cachedBackgroundImage } from '$lib/utils/cached-image-util';
import { cn } from '$lib/utils/style';
import type { Snippet } from 'svelte';
import { MediaQuery } from 'svelte/reactivity';
@@ -54,7 +55,7 @@
<!-- Background image with slide animation -->
<div class="{cn(animate && 'animate-slide-bg-container')} absolute top-0 right-0 bottom-0 z-0">
<img
src="/api/application-configuration/background-image"
src={cachedBackgroundImage.getUrl()}
class="h-screen rounded-l-[60px] object-cover {animate
? 'w-full'
: 'w-[calc(100vw-650px)]'}"
@@ -64,7 +65,7 @@
</div>
{:else}
<div
class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center"
class="flex h-screen items-center justify-center bg-[url('{cachedBackgroundImage.getUrl()}')] bg-cover bg-center text-center"
>
<Card.Root class="mx-3 w-full max-w-md" style={animate ? 'animation-delay: 200ms;' : ''}>
<Card.CardContent

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { cachedApplicationLogo } from '$lib/utils/cached-image-util';
import { mode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
let { ...props }: HTMLAttributes<HTMLImageElement> = $props();
const isDarkMode = $derived(mode.current === 'dark');
const isLightMode = $derived(mode.current === 'light');
</script>
<img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt={m.logo()} />
<img {...props} src={cachedApplicationLogo.getUrl(isLightMode)} alt={m.logo()} />

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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));
}

View File

@@ -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}
<img
class="size-10"
src="/api/oidc/clients/{client.id}/logo"
src={cachedOidcClientLogo.getUrl(client.id)}
draggable={false}
alt={m.client_logo()}
/>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached-image-util';
import ApplicationImage from './application-image.svelte';
let {
@@ -34,7 +35,7 @@
imageClass="size-32"
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL="/api/application-configuration/logo?light=true"
imageURL={cachedApplicationLogo.getUrl(true)}
forceColorScheme="light"
/>
<ApplicationImage
@@ -42,7 +43,7 @@
imageClass="size-32"
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL="/api/application-configuration/logo?light=false"
imageURL={cachedApplicationLogo.getUrl(false)}
forceColorScheme="dark"
/>
<ApplicationImage
@@ -50,7 +51,7 @@
imageClass="h-[350px] max-w-[500px]"
label={m.background_image()}
bind:image={backgroundImage}
imageURL="/api/application-configuration/background-image"
imageURL={cachedBackgroundImage.getUrl()}
/>
</div>
<div class="flex justify-end">

View File

@@ -7,6 +7,7 @@
import Label from '$lib/components/ui/label/label.svelte';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { cn } from '$lib/utils/style';
@@ -28,7 +29,7 @@
let showAdvancedOptions = $state(false);
let logo = $state<File | null | undefined>();
let logoDataURL: string | null = $state(
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
existingClient?.hasLogo ? cachedOidcClientLogo.getUrl(existingClient!.id) : null
);
const client = {

View File

@@ -8,6 +8,7 @@
import OIDCService from '$lib/services/oidc-service';
import type { OidcClient, OidcClientWithAllowedUserGroupsCount } from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
@@ -59,7 +60,7 @@
{#if item.hasLogo}
<ImageBox
class="min-h-8 min-w-8"
src={`/api/oidc/clients/${item.id}/logo`}
src={cachedOidcClientLogo.getUrl(item.id)}
alt={m.name_logo({ name: item.name })}
/>
{/if}