fix: sidecar response interceptor + folder proxy

- Added 401-handling response interceptor to sidecar axios instance
  (matches existing http instance) so expired/invalid tokens redirect
  to login instead of showing raw 404/401 errors
- Added GET /api/sidecar/folders — proxies PhotoPrism's
  /api/v1/folders/originals with BasePath post-filter
- Updated listFolders() frontend to call sidecar proxy
- Updated plan with remaining fixes
This commit is contained in:
2026-06-06 23:01:38 +02:00
parent 14a1b4e54e
commit 243e5d3831
4 changed files with 342 additions and 8 deletions

View File

@@ -28,6 +28,14 @@ const http: AxiosInstance = axios.create({
headers: { 'Content-Type': 'application/json' }
});
/** Axios instance for sidecar endpoints — no baseURL prefix so paths
* like `/api/sidecar/timeline` resolve directly through Caddy's
* `/api/sidecar/*` rule instead of becoming `/api/v1/api/sidecar/*`. */
const sidecar: AxiosInstance = axios.create({
baseURL: '',
headers: { 'Content-Type': 'application/json' }
});
http.interceptors.request.use((config) => {
if (session.accessToken) {
config.headers = config.headers ?? {};
@@ -36,6 +44,14 @@ http.interceptors.request.use((config) => {
return config;
});
sidecar.interceptors.request.use((config) => {
if (session.accessToken) {
config.headers = config.headers ?? {};
(config.headers as Record<string, string>)['X-Auth-Token'] = session.accessToken;
}
return config;
});
http.interceptors.response.use(
(r) => r,
(err: AxiosError) => {
@@ -51,6 +67,20 @@ http.interceptors.response.use(
}
);
sidecar.interceptors.response.use(
(r) => r,
(err: AxiosError) => {
if (err.response?.status === 401 && browser) {
clearSession();
const url = err.config?.url ?? '';
if (!url.endsWith('/session')) {
void goto('/login', { replaceState: true });
}
}
return Promise.reject(err);
}
);
// ── Auth ─────────────────────────────────────────────────────────────────────
export async function login(username: string, password: string): Promise<PpSessionResponse> {
@@ -144,7 +174,7 @@ export interface ListPhotosParams {
}
export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto[]> {
const { data } = await http.get<PpPhoto[]>('/api/sidecar/timeline', {
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
params: {
count: 60,
offset: 0,
@@ -260,7 +290,7 @@ export async function listPhotosAround(p: AroundParams): Promise<PpPhoto[]> {
*/
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
const merged = opts.merged ?? false;
const resp = await http.get<PpPhoto[]>('/api/sidecar/timeline', {
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
params: { count: 10000, offset: 0, merged, q }
});
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
@@ -454,11 +484,14 @@ export interface PpFolder {
* BasePath is empty (today's admin default) this is a no-op.
*/
export async function listFolders(): Promise<PpFolder[]> {
const { data } = await http.get<{ folders?: PpFolder[] }>(
'/folders/originals',
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
'/api/sidecar/folders',
{ params: { recursive: true, uncached: true, files: false } }
);
const bp = userBasePath();
// Sidecar already filters by BasePath; the frontend still applies the
// filter + path rewrite as a safety net for admin (bp="") and for any
// folders that might have slipped through.
const folders = data.folders ?? [];
if (bp === '') return folders;
return folders
@@ -636,7 +669,7 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
}
async function hasPhotosMatching(q: string): Promise<boolean> {
const resp = await http.get<PpPhoto[]>('/api/sidecar/timeline', {
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
params: { count: 1, offset: 0, q }
});
return Array.isArray(resp.data) && resp.data.length > 0;
@@ -677,7 +710,7 @@ export async function listLabels(): Promise<PpLabel[]> {
// /api/v1/labels so PhotoCount reflects only photos under the user's
// BasePath. The sidecar proxies the request through to PP then
// post-filters each label's count.
const { data } = await http.get<PpLabel[]>('/api/sidecar/labels', {
const { data } = await sidecar.get<PpLabel[]>('/api/sidecar/labels', {
params: { count: 1000, order: 'count', all: true, perPage: 1000 }
});
return filterByUserPhotos(data, (l) => `label:${l.Slug}`);