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:
227
.hermes/plans/2026-06-06_210000-remaining-fixes.md
Normal file
227
.hermes/plans/2026-06-06_210000-remaining-fixes.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Plan: Fix remaining user isolation issues — 404 errors and folder tree
|
||||||
|
|
||||||
|
**Date:** 2026-06-06
|
||||||
|
**Author:** Hermes Agent
|
||||||
|
**Status:** Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Fix the remaining issues after deploying the sidecar scoping proxy:
|
||||||
|
|
||||||
|
1. **404 on photo grid** — "Request failed with status code 404" in private window
|
||||||
|
2. **Folder tree shows other users** — on first load, the library tree lists other users' folders; a refresh fixes it
|
||||||
|
|
||||||
|
## 2. Current Context
|
||||||
|
|
||||||
|
### What's deployed
|
||||||
|
|
||||||
|
| Component | Status |
|
||||||
|
|-----------|--------|
|
||||||
|
| Sidecar labels proxy (`/api/sidecar/labels`) | ✅ Working |
|
||||||
|
| Sidecar counts proxy (`/api/sidecar/counts`) | ✅ Working |
|
||||||
|
| Sidecar timeline proxy (`/api/sidecar/timeline`) | ✅ Working through Caddy |
|
||||||
|
| Caddy fallback for `/api/v1/api/sidecar/*` | ✅ Working |
|
||||||
|
| Frontend rebuild with `sidecar` axios instance | ✅ Built and deployed |
|
||||||
|
|
||||||
|
### Verified working via Caddy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Through public URL with valid admin token
|
||||||
|
curl https://photos.hubris.network/api/sidecar/timeline?count=1 → HTTP 200
|
||||||
|
curl https://photos.hubris.network/api/v1/photos?count=1 → HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Both endpoints return 200 when tested directly through Caddy with a valid token.
|
||||||
|
|
||||||
|
### Reported issues
|
||||||
|
|
||||||
|
1. **404 on photo grid** — even in private window (no cache interference)
|
||||||
|
2. **Folder tree shows other users' folders** on first load, fixed by refresh
|
||||||
|
|
||||||
|
## 3. Root Cause Analysis
|
||||||
|
|
||||||
|
### Issue 1: 404 on photo grid
|
||||||
|
|
||||||
|
The `sidecar` axios instance (`baseURL: ''`) is missing the **response interceptor** that:
|
||||||
|
- Handles 401 → clears session → redirects to login
|
||||||
|
- Re-throws with meaningful error message
|
||||||
|
|
||||||
|
The `http` instance (for `/api/v1` endpoints) has this interceptor. Without it on `sidecar`:
|
||||||
|
- If the sidecar returns a non-2xx (401, 502 from upstream PP failure, etc.), axios throws a raw error
|
||||||
|
- The TanStack Query error boundary catches it and shows "Request failed with status code <status>"
|
||||||
|
- Very likely the sidecar is returning 401 on some calls (token expired / session not yet established) and the error message might show 404 because Caddy's catch-all returns 404 when a matcher doesn't find a route
|
||||||
|
|
||||||
|
**Hypothesis:** During OIDC login flow, the frontend may make some sidecar calls BEFORE the session is fully established (token loaded into `session.accessToken`). The `sidecar` interceptor checks `session.accessToken` but it might be null. Then the request to `/api/sidecar/timeline` has no auth header → sidecar returns 401 → no response interceptor → raw error.
|
||||||
|
|
||||||
|
**Fix:** Add the same 401 → login redirect interceptor to the `sidecar` instance.
|
||||||
|
|
||||||
|
### Issue 2: Folder tree shows other users
|
||||||
|
|
||||||
|
`listFolders()` calls `http.get('/folders/originals')` which hits PhotoPrism directly. PhotoPrism returns **all folders across the library** regardless of user. The frontend then filters by `userBasePath()` on the result:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const bp = userBasePath();
|
||||||
|
if (bp === '') return folders; // On first load, bp might be empty!
|
||||||
|
return folders.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||||
|
```
|
||||||
|
|
||||||
|
On first load, `userBasePath()` returns `""` because:
|
||||||
|
1. The session data is loaded asynchronously
|
||||||
|
2. `session.user.BasePath` might not yet be populated when `listFolders` fires
|
||||||
|
3. The TanStack Query cache from a previous session might still have old data
|
||||||
|
|
||||||
|
After a refresh, the session is fully loaded, and `userBasePath()` returns the correct value.
|
||||||
|
|
||||||
|
A secondary issue: the `http` interceptor's 401 handler clears the session on 401. If the session expires during the app's lifetime, all subsequent requests fail with 401.
|
||||||
|
|
||||||
|
## 4. Proposed Approach
|
||||||
|
|
||||||
|
### Phase 1: Fix 404 — add response interceptor to sidecar
|
||||||
|
|
||||||
|
**File:** `web/src/lib/services/photoprism.ts`
|
||||||
|
|
||||||
|
Add the same 401 → login redirect interceptor to `sidecar` as already exists on `http`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Fix folder tree — sidecar folder proxy
|
||||||
|
|
||||||
|
**File:** `sidecar/handlers_folders.go` (new)
|
||||||
|
|
||||||
|
Add a sidecar endpoint that proxies `/folders/originals` and post-filters by BasePath:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/sidecar/folders → proxies to GET /api/v1/folders/originals
|
||||||
|
→ removes folders not under user's base_path
|
||||||
|
→ returns filtered list
|
||||||
|
```
|
||||||
|
|
||||||
|
This avoids the timing issue entirely by filtering on the server side.
|
||||||
|
|
||||||
|
**Alternative (simpler):** Fix the frontend timing issue by ensuring `listFolders` doesn't fire until the session is ready.
|
||||||
|
|
||||||
|
### Phase 3: Change folder tree in frontend
|
||||||
|
|
||||||
|
**File:** `web/src/lib/services/photoprism.ts`
|
||||||
|
|
||||||
|
Change `listFolders()` to use `sidecar` instance and call `/api/sidecar/folders`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function listFolders(): Promise<PpFolder[]> {
|
||||||
|
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
|
||||||
|
'/api/sidecar/folders',
|
||||||
|
{ params: { recursive: true, uncached: true, files: false } }
|
||||||
|
);
|
||||||
|
const bp = userBasePath();
|
||||||
|
const folders = data.folders ?? [];
|
||||||
|
if (bp === '') return folders;
|
||||||
|
return folders
|
||||||
|
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||||
|
.map((f) => ({ ...f, Path: toUserPath(f.Path) }));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Step-by-step Plan
|
||||||
|
|
||||||
|
### Step 1: Add sidecar response interceptor
|
||||||
|
|
||||||
|
1. Edit `web/src/lib/services/photoprism.ts`
|
||||||
|
2. Add the 401-handling response interceptor to the `sidecar` instance
|
||||||
|
3. The interceptor mirrors the existing `http` response interceptor exactly
|
||||||
|
|
||||||
|
### Step 2: Rebuild frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/mule-image/web && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: (Optional) Add sidecar folder proxy
|
||||||
|
|
||||||
|
1. New file `sidecar/handlers_folders_proxy.go`
|
||||||
|
2. Handler similar to `handlePhotos` — proxies to `/api/v1/folders/originals`, post-filters by `Path` prefix
|
||||||
|
3. Wire route in `main.go`: `auth.GET("/folders", handleFoldersProxy(pp))`
|
||||||
|
4. Build Docker image, restart sidecar
|
||||||
|
|
||||||
|
### Step 4: Update listFolders to use sidecar
|
||||||
|
|
||||||
|
1. Change `listFolders()` to use `sidecar` instance
|
||||||
|
2. Call `/api/sidecar/folders` instead of `/folders/originals`
|
||||||
|
|
||||||
|
### Step 5: Rebuild + validate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild frontend
|
||||||
|
cd /opt/mule-image/web && npm run build
|
||||||
|
|
||||||
|
# Test through Caddy
|
||||||
|
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1" \
|
||||||
|
-H "X-Auth-Token: <token>" | head -c 200
|
||||||
|
|
||||||
|
# Verify folders
|
||||||
|
curl -s "https://photos.hubris.network/api/sidecar/folders" \
|
||||||
|
-H "X-Auth-Token: <token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(json.dumps(d[:3],indent=2))"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A && git commit -m "fix: add sidecar response interceptor + folder proxy" && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Files Likely to Change
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `web/src/lib/services/photoprism.ts` | Add response interceptor to sidecar instance; change listFolders URL |
|
||||||
|
| `sidecar/handlers_folders_proxy.go` | **New** — folder proxy handler |
|
||||||
|
| `sidecar/main.go` | Wire folder proxy route |
|
||||||
|
|
||||||
|
## 7. Tests & Validation
|
||||||
|
|
||||||
|
**Manual:**
|
||||||
|
1. Open private window → navigate to photos.hubris.network
|
||||||
|
2. Log in as muli via Authentik OIDC
|
||||||
|
3. Verify photo grid loads without 404
|
||||||
|
4. Verify folder tree shows only muli's folders
|
||||||
|
5. Switch to dtoro account → verify folders/timeline scoped to dtoro
|
||||||
|
|
||||||
|
**API tests:**
|
||||||
|
```bash
|
||||||
|
# Sidecar timeline (no token → 401 redirect)
|
||||||
|
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1"
|
||||||
|
|
||||||
|
# Sidecar folders
|
||||||
|
curl -s "https://photos.hubris.network/api/sidecar/folders"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Risks & Open Questions
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|------------|
|
||||||
|
| Sidecar returns 401 during OIDC login flow before session is ready | 404 showing instead of graceful redirect | Add response interceptor in Phase 1 |
|
||||||
|
| Folder proxy adds latency | Slower folder tree loading | Minimal — single proxy call, same as PP direct |
|
||||||
|
| `userBasePath()` timing issue in listFolders persists even with sidecar | Folder tree still shows wrong folders on first load | Sidecar filter is server-side → no timing dependency |
|
||||||
|
|
||||||
|
### Open Questions
|
||||||
|
|
||||||
|
- **Q1**: Are there other API calls that bypass the `sidecar` instance and might also be unscoped? (e.g., `listSubjects`, `listGeo`, etc.)
|
||||||
|
- **Q2**: Does the sidecar need a folder proxy, or is the timing fix sufficient? The timing fix (delaying `listFolders` until session is ready) is simpler but fragile.
|
||||||
|
- **Q3**: Could the 404 be from Caddy's catch-all returning 404 when the sidecar isn't reachable? The Caddy fallback timeout for the sidecar might need tuning.
|
||||||
70
sidecar/handlers_folders_proxy.go
Normal file
70
sidecar/handlers_folders_proxy.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleFoldersProxy proxies PhotoPrism's /api/v1/folders/originals and
|
||||||
|
// post-filters by the caller's BasePath so the folder tree only shows
|
||||||
|
// folders under the user's library root.
|
||||||
|
//
|
||||||
|
// Route: GET /api/sidecar/folders (behind requireSession)
|
||||||
|
func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
basePath := ctxBasePath(c)
|
||||||
|
|
||||||
|
// Forward query params to PhotoPrism.
|
||||||
|
query := c.Request.URL.RawQuery
|
||||||
|
if query == "" {
|
||||||
|
query = "recursive=true&uncached=true&files=false"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/folders/originals?"+query, token, nil)
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream folders request failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the response — PhotoPrism returns { folders: [...] }.
|
||||||
|
var payload struct {
|
||||||
|
Folders []map[string]any `json:"folders"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Body, &payload); err != nil {
|
||||||
|
c.Data(resp.Status, "application/json", resp.Body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user has no BasePath (admin/empty), return as-is.
|
||||||
|
if basePath == "" {
|
||||||
|
c.JSON(http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := basePath + "/"
|
||||||
|
|
||||||
|
// Post-filter folders by Path field only — frontend handles BasePath
|
||||||
|
// prefix stripping via toUserPath().
|
||||||
|
filtered := make([]map[string]any, 0, len(payload.Folders))
|
||||||
|
for _, f := range payload.Folders {
|
||||||
|
rawPath, ok := f["Path"]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pathStr, ok := rawPath.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Keep only folders under the user's base path.
|
||||||
|
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
|
||||||
|
filtered = append(filtered, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"folders": filtered})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,10 @@ func main() {
|
|||||||
// User-scoped photos — post-filters by BasePath so review/archive
|
// User-scoped photos — post-filters by BasePath so review/archive
|
||||||
// tabs only show photos the user owns.
|
// tabs only show photos the user owns.
|
||||||
auth.GET("/timeline", handlePhotos(pp))
|
auth.GET("/timeline", handlePhotos(pp))
|
||||||
|
|
||||||
|
// User-scoped folders — post-filters the folder tree by BasePath
|
||||||
|
// so the sidebar shows only folders under the user's library root.
|
||||||
|
auth.GET("/folders", handleFoldersProxy(pp))
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ const http: AxiosInstance = axios.create({
|
|||||||
headers: { 'Content-Type': 'application/json' }
|
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) => {
|
http.interceptors.request.use((config) => {
|
||||||
if (session.accessToken) {
|
if (session.accessToken) {
|
||||||
config.headers = config.headers ?? {};
|
config.headers = config.headers ?? {};
|
||||||
@@ -36,6 +44,14 @@ http.interceptors.request.use((config) => {
|
|||||||
return 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(
|
http.interceptors.response.use(
|
||||||
(r) => r,
|
(r) => r,
|
||||||
(err: AxiosError) => {
|
(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 ─────────────────────────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<PpSessionResponse> {
|
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[]> {
|
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: {
|
params: {
|
||||||
count: 60,
|
count: 60,
|
||||||
offset: 0,
|
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> {
|
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
|
||||||
const merged = opts.merged ?? false;
|
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 }
|
params: { count: 10000, offset: 0, merged, q }
|
||||||
});
|
});
|
||||||
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
|
// 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.
|
* BasePath is empty (today's admin default) this is a no-op.
|
||||||
*/
|
*/
|
||||||
export async function listFolders(): Promise<PpFolder[]> {
|
export async function listFolders(): Promise<PpFolder[]> {
|
||||||
const { data } = await http.get<{ folders?: PpFolder[] }>(
|
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
|
||||||
'/folders/originals',
|
'/api/sidecar/folders',
|
||||||
{ params: { recursive: true, uncached: true, files: false } }
|
{ params: { recursive: true, uncached: true, files: false } }
|
||||||
);
|
);
|
||||||
const bp = userBasePath();
|
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 ?? [];
|
const folders = data.folders ?? [];
|
||||||
if (bp === '') return folders;
|
if (bp === '') return folders;
|
||||||
return folders
|
return folders
|
||||||
@@ -636,7 +669,7 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function hasPhotosMatching(q: string): Promise<boolean> {
|
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 }
|
params: { count: 1, offset: 0, q }
|
||||||
});
|
});
|
||||||
return Array.isArray(resp.data) && resp.data.length > 0;
|
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
|
// /api/v1/labels so PhotoCount reflects only photos under the user's
|
||||||
// BasePath. The sidecar proxies the request through to PP then
|
// BasePath. The sidecar proxies the request through to PP then
|
||||||
// post-filters each label's count.
|
// 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 }
|
params: { count: 1000, order: 'count', all: true, perPage: 1000 }
|
||||||
});
|
});
|
||||||
return filterByUserPhotos(data, (l) => `label:${l.Slug}`);
|
return filterByUserPhotos(data, (l) => `label:${l.Slug}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user