mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-10 07:12:59 +03:00
fix: explicitly cache images to prevent unexpected behavior
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()} />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
89
frontend/src/lib/utils/cached-image-util.ts
Normal file
89
frontend/src/lib/utils/cached-image-util.ts
Normal 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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user