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.
206 lines
7.2 KiB
TypeScript
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;
|
|
}
|