Files
mule-image/web/src/lib/stores/session.svelte.ts
Claudio 986dab7334 Clear TanStack Query cache on session change
Sidebar counts, marks, folder counts, etc. were keyed only on query
name, not on the authenticated user. Logging in as a non-admin kept
rendering the previous admin session's data because the cache was
never invalidated. clearSession and adoptSession now wipe the cache
so each identity starts fresh.

User-observed: the "test" user (role guest, BasePath="") saw the
admin library counts in the left sidebar after signing in.
2026-05-18 20:01:17 +00:00

206 lines
7.2 KiB
TypeScript

import { browser } from '$app/environment';
import { queryClient } from '$lib/queryClient';
import type { PpClientConfig, PpSessionResponse, PpUser } from '$lib/types/photoprism';
const STORAGE_KEY = 'pp_session';
interface PersistedSession {
id: string;
accessToken: string;
previewToken: string;
downloadToken: string;
user: PpUser;
}
function loadInitial(): PersistedSession | null {
if (!browser) return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as PersistedSession;
} catch {
return null;
}
}
/**
* Single-source session state for the Svelte client. Components import the
* `session` object and read its reactive fields; mutations go through the
* helpers below. State is mirrored to localStorage so a hard refresh keeps
* the user signed in.
*/
const initial = loadInitial();
export const session = $state<{
id: string | null;
accessToken: string | null;
previewToken: string | null;
downloadToken: string | null;
user: PpUser | null;
}>({
id: initial?.id ?? null,
accessToken: initial?.accessToken ?? null,
previewToken: initial?.previewToken ?? null,
downloadToken: initial?.downloadToken ?? null,
user: initial?.user ?? null
});
export function isAuthenticated(): boolean {
return Boolean(session.accessToken);
}
export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): void {
// Drop any cached data from the prior identity before installing the
// new session. The TanStack cache is keyed on query name, not user —
// so without an explicit clear, the new login keeps showing the
// previous user's `/api/v1/config.count`, marks, folder counts, etc.
// (Hit this with the `test` user seeing the admin's library counts
// in the left sidebar.)
queryClient.clear();
session.id = resp.id;
session.accessToken = resp.access_token;
session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
session.downloadToken = (cfg ?? resp.config)?.downloadToken ?? '';
session.user = resp.user;
persist();
}
export function clearSession(): void {
session.id = null;
session.accessToken = null;
session.previewToken = null;
session.downloadToken = null;
session.user = null;
if (browser) localStorage.removeItem(STORAGE_KEY);
// Same reasoning as adoptSession — wipe the cache so the next user
// who logs in (or the login screen itself) doesn't render with the
// previous identity's data.
queryClient.clear();
}
function persist(): void {
if (!browser || !session.accessToken) return;
const payload: PersistedSession = {
id: session.id ?? '',
accessToken: session.accessToken,
previewToken: session.previewToken ?? '',
downloadToken: session.downloadToken ?? '',
user: session.user!
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
/**
* Build a thumbnail URL for a photo. PhotoPrism's thumb endpoint is
* /api/v1/t/:hash/:token/:size — the token is the per-session
* previewToken, which the session response provides on login.
*/
export function thumbUrl(hash: string, size = 'tile_500'): string {
if (!session.previewToken) return '';
return `/api/v1/t/${hash}/${session.previewToken}/${size}`;
}
/**
* PhotoPrism's square-cropped tile sizes (px). These are the variants
* the indexer generates by default for the `tile_*` family. fit_* exists
* for non-square sizing but is the wrong fit for grid cells with
* `object-cover` — we always render a square.
*/
const TILE_SIZES = [100, 224, 500] as const;
/**
* Pick the smallest PhotoPrism tile variant whose pixel count is at or
* above the on-screen target. Falls through to the largest (500) for
* anything bigger — we don't have a tile_720+ variant. Used by both the
* 1x and 2x slots of the srcset helper below.
*/
function pickTileSize(targetPx: number): string {
for (const s of TILE_SIZES) {
if (s >= targetPx) return `tile_${s}`;
}
return `tile_${TILE_SIZES[TILE_SIZES.length - 1]}`;
}
/**
* Build a thumbnail `srcset` for a photo at a given on-screen tile
* size. The browser picks the right variant for the current device
* pixel ratio — on a 2x display we serve `tile_500` for a 272px XL
* tile, on a 1x display the same tile gets `tile_500` only if no
* smaller variant covers it, so most users save bandwidth.
*
* Returns the `srcset` value (no `src` attribute — pair with `thumbUrl`
* for the 1x fallback). Two variants is enough: PhotoPrism only
* indexes three square sizes (100/224/500), so 1x and 2x cover the
* realistic DPR range without flooding the cache.
*/
export function thumbSrcSet(hash: string, targetPx: number): string {
if (!session.previewToken) return '';
const one = pickTileSize(targetPx);
const two = pickTileSize(targetPx * 2);
const url1x = thumbUrl(hash, one);
const url2x = thumbUrl(hash, two);
if (url1x === url2x) return `${url1x} 1x`;
return `${url1x} 1x, ${url2x} 2x`;
}
/** Companion to `thumbSrcSet` — the `src` attribute value (1x). */
export function thumbSrc(hash: string, targetPx: number): string {
if (!session.previewToken) return '';
return thumbUrl(hash, pickTileSize(targetPx));
}
/**
* Build a video stream URL. PhotoPrism's endpoint is
* /api/v1/videos/:hash/:token/:format — same previewToken as thumbnails.
* `format=avc` is the standard h264 transcode; HEVC sources are
* transcoded on first request and cached server-side.
*/
export function videoUrl(hash: string, format = 'avc'): string {
if (!session.previewToken) return '';
return `/api/v1/videos/${hash}/${session.previewToken}/${format}`;
}
/**
* The signed-in user's library root, originals-relative, no leading/trailing
* slash. `""` means "whole library" — used today by admin accounts whose
* BasePath isn't configured in PhotoPrism. Non-empty values gate every place
* that crosses the user↔server seam (sidebar tree, timeline `path:` filter,
* folder counts, heap convert) so each user sees only their own subtree.
*/
export function userBasePath(): string {
return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, '');
}
/**
* Translate a user-relative path (what the sidebar and URL deal in) to a
* server-absolute, originals-relative path (what PhotoPrism's `path:`
* operator and the sidecar's filesystem ops want).
*
* "" or "/" → BasePath (user's root)
* "2024/01" → "<basePath>/2024/01"
* null → "" (caller decides to omit the filter entirely)
*/
export function toOriginalsPath(uiPath: string | null): string {
if (uiPath === null) return '';
const bp = userBasePath();
const rel = uiPath.replace(/^\/+|\/+$/g, '');
if (rel === '') return bp;
return bp === '' ? rel : `${bp}/${rel}`;
}
/**
* Inverse of `toOriginalsPath` — strips the user's BasePath prefix so the
* UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
* are equal to the BasePath collapse to `""` (the user's root sentinel).
* Paths outside the BasePath are returned as-is, but callers should
* already have filtered those out via `listFolders`'s post-filter.
*/
export function toUserPath(serverPath: string): string {
const bp = userBasePath();
const sp = serverPath.replace(/^\/+|\/+$/g, '');
if (bp === '') return sp;
if (sp === bp) return '';
if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1);
return sp;
}