deploy 2/2 : image server / api url fix - responsive images
This commit is contained in:
parent
b5dbf55c71
commit
426b66828f
293
packages/ui/docs/supabase/zitadel-service-connection.md
Normal file
293
packages/ui/docs/supabase/zitadel-service-connection.md
Normal file
@ -0,0 +1,293 @@
|
||||
# Zitadel Service Connection (M2M)
|
||||
|
||||
How the **pm-pics server** authenticates to Zitadel as a machine principal — for background jobs, management-API calls, and server-side E2E tests — without a browser or a human user.
|
||||
|
||||
For the human OIDC/PKCE flow (SPA login, `id_token`) see [`auth-zitadel.md`](./auth-zitadel.md).
|
||||
For the `ZITADEL_SERVER_ACCESS_TOKEN` quick-reference see [`server/docs/zitadel-server-token.md`](../../server/docs/zitadel-server-token.md).
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Server Zitadel (auth.polymech.info)
|
||||
| |
|
||||
|-- JWT assertion ----------------->| POST /oauth/v2/token
|
||||
| (signed with service key) | grant_type=jwt-bearer
|
||||
|<-- access_token (JWT) -----------|
|
||||
|
|
||||
| OR simply:
|
||||
| ZITADEL_SERVER_ACCESS_TOKEN (PAT pre-issued in Zitadel console)
|
||||
|
|
||||
|-- Bearer <access_token> -------->| JWKS verification (same as human user)
|
||||
```
|
||||
|
||||
There are **two ways** to get a service JWT. Priority order in `getServiceAccessToken()`:
|
||||
|
||||
| Priority | Method | Set up via | Token lifetime |
|
||||
|---|---|---|---|
|
||||
| **1 — PAT** | Personal Access Token from Zitadel console | `ZITADEL_SERVER_ACCESS_TOKEN` | Days–months (Zitadel policy) |
|
||||
| **2 — JWT assertion** | Server signs a JWT with its private key; Zitadel exchanges it (RFC 7523) | `ZITADEL_SERVICE_KEY_JSON` | ~36 h per exchange; key valid until `expirationDate` |
|
||||
|
||||
The resulting access token is a **Zitadel-issued JWT** — it JWKS-verifies exactly like a human user token, so `getUserCached()` and all downstream middleware work identically.
|
||||
|
||||
---
|
||||
|
||||
## Registered service identity
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Zitadel machine user `sub` | `368155730978537473` |
|
||||
| Zitadel key ID (`kid`) | `368190655136006145` |
|
||||
| Key expiry | `2026-06-11` |
|
||||
| DB `profiles.user_id` | `fe6c94a9-aeec-40db-be63-07618e502585` |
|
||||
| DB role | `admin` (`public.user_roles`) |
|
||||
| `aud` in issued access tokens | `["service"]` |
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
All live in `server/.env.zitadel.local` (git-ignored; loaded with `override: true` in vitest so it wins over `.env`).
|
||||
|
||||
```env
|
||||
# ── Service identity (required for test:zitadel and test:zitadel:server) ───────
|
||||
# The same token is used by both test suites. Refresh with:
|
||||
# npm run zitadel:write-service-token
|
||||
ZITADEL_SERVER_ACCESS_TOKEN='eyJ...'
|
||||
|
||||
# ── Option B: JWT assertion key (used by zitadel:write-service-token itself) ───
|
||||
# Download: Zitadel console → Service Users → <user> → Keys → Download JSON
|
||||
# The public key must be uploaded to Zitadel before this works.
|
||||
# Verify: npm run zitadel:key-inspect
|
||||
ZITADEL_SERVICE_KEY_JSON='{"type":"serviceaccount","keyId":"368190655136006145","key":"-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n","expirationDate":"2026-06-11T22:00:00Z","userId":"368155730978537473"}'
|
||||
|
||||
# ── Human PKCE flow (optional, for testing with a human user token) ─────────────
|
||||
# Set only when running npm run zitadel:token (browser OIDC login).
|
||||
# zitadel.e2e.test.ts falls back to this when ZITADEL_SERVER_ACCESS_TOKEN is absent.
|
||||
ZITADEL_TEST_ID_TOKEN='eyJ...'
|
||||
```
|
||||
|
||||
Persistent values in `server/.env`:
|
||||
|
||||
```env
|
||||
ZITADEL_ISSUER=https://auth.polymech.info
|
||||
# ZITADEL_AUDIENCE intentionally unset — service token aud ["service"] is accepted
|
||||
# because verifyZitadelAccessToken() skips audience validation when not configured
|
||||
```
|
||||
|
||||
> **`ZITADEL_TEST_ACCESS_TOKEN` is removed.** It was a redundant copy of `ZITADEL_SERVER_ACCESS_TOKEN` written by the old `zitadel:write-service-token` script. Both test suites now read `ZITADEL_SERVER_ACCESS_TOKEN` directly.
|
||||
|
||||
---
|
||||
|
||||
## Production CLI (no `tsx` required)
|
||||
|
||||
The webpack bundle (`dist/server.js`) doubles as a CLI. Any recognised sub-command runs the operation and exits; no sub-command starts the HTTP server as normal.
|
||||
|
||||
```bash
|
||||
# Dev (tsx source)
|
||||
npm run cli:register-service-admin -- --sub 368155730978537473
|
||||
npm run cli:refresh-service-token
|
||||
npm run cli:verify-admin
|
||||
|
||||
# Production bundle (systemd / Docker exec / any shell with node)
|
||||
node dist/server.js register-service-admin --sub 368155730978537473
|
||||
node dist/server.js refresh-service-token
|
||||
node dist/server.js verify-admin
|
||||
node dist/server.js --help # list all commands
|
||||
```
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `register-service-admin --sub <sub> [--role admin]` | Upserts `profiles.zitadel_sub` row + grants role in `user_roles`. Idempotent. |
|
||||
| `refresh-service-token` | Runs JWT-assertion exchange, prints claims (`sub`, `iss`, `aud`, `exp`). No file write. |
|
||||
| `verify-admin` | Reads `ZITADEL_SERVER_ACCESS_TOKEN`, verifies via JWKS, asserts `isAdmin()` in Postgres. |
|
||||
|
||||
All commands share the same env-load order as the HTTP server (`.env` → `.env.zitadel.local` → `.env-db`), so no extra setup is needed.
|
||||
|
||||
---
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
# ── Token refresh (run whenever service tokens expire, ~36 h) ─────────────────
|
||||
npm run zitadel:write-service-token
|
||||
# Uses ZITADEL_SERVICE_KEY_JSON → JWT assertion → fresh access token →
|
||||
# writes ZITADEL_SERVER_ACCESS_TOKEN into .env.zitadel.local (merges, preserves other keys).
|
||||
|
||||
npm run zitadel:m2m
|
||||
# Same exchange but prints token + decoded payload to stdout only (no file write).
|
||||
# Use to inspect a fresh token without committing it anywhere.
|
||||
|
||||
# ── Diagnostics ───────────────────────────────────────────────────────────────
|
||||
npm run zitadel:key-inspect
|
||||
# Extracts public key PEM from ZITADEL_SERVICE_KEY_JSON, runs a live token
|
||||
# exchange, and prints the result. "500 Errors.Internal" = key not registered.
|
||||
# Also prints step-by-step Zitadel console upload instructions.
|
||||
|
||||
npm run verify:admin
|
||||
# Reads ZITADEL_SERVER_ACCESS_TOKEN from .env.zitadel.local, verifies the JWT
|
||||
# via JWKS, then checks isAdmin() against DATABASE_URL. Quick sanity check that
|
||||
# the service identity is correctly wired end-to-end.
|
||||
|
||||
# ── One-time DB setup ─────────────────────────────────────────────────────────
|
||||
npx tsx scripts/register-service-admin.ts --sub 368155730978537473
|
||||
# Creates profiles row + admin user_roles entry for the machine user. Idempotent.
|
||||
|
||||
# ── Human PKCE flow (optional) ────────────────────────────────────────────────
|
||||
npm run zitadel:token
|
||||
# Opens browser → OIDC PKCE login → merges ZITADEL_TEST_ID_TOKEN into .env.zitadel.local.
|
||||
|
||||
npm run zitadel:token:password
|
||||
# Password grant (no browser) → same file write. Requires ROPC enabled on the OIDC app.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E2E test suites
|
||||
|
||||
```bash
|
||||
npm run test:zitadel:server # zitadel-service.e2e.test.ts — service JWT + DB admin
|
||||
npm run test:zitadel # zitadel.e2e.test.ts — same service JWT + DB admin
|
||||
```
|
||||
|
||||
Both suites read `ZITADEL_SERVER_ACCESS_TOKEN`. `test:zitadel` falls back to `ZITADEL_TEST_ID_TOKEN` when the service token is absent (human PKCE flow).
|
||||
|
||||
**Expected result when everything is set up:**
|
||||
|
||||
```
|
||||
Test Files 2 passed (2)
|
||||
Tests 18 passed | 1 skipped (19)
|
||||
```
|
||||
|
||||
The skipped test is the password-grant test (requires `ZITADEL_ENABLE_PASSWORD_GRANT_TEST=1`).
|
||||
|
||||
### `test:zitadel:server`
|
||||
|
||||
Reads `ZITADEL_SERVER_ACCESS_TOKEN`. If absent, `beforeAll` calls `getServiceAccessToken()` via `ZITADEL_SERVICE_KEY_JSON` as a fallback. Tests:
|
||||
|
||||
1. `getUserCached(serviceJwt)` → non-null user (JWKS verification)
|
||||
2. Tampered JWT is rejected
|
||||
3. `GET /api/admin/authz/debug` with Bearer → `{ verified: true, sub: "..." }`
|
||||
4. `getUserCached` + `isAdmin` → `true` (service user is admin in DB)
|
||||
5. JWT payload has `sub`, `iss`, `aud`
|
||||
6. `jwtVerify` (jose) against JWKS passes
|
||||
|
||||
### `test:zitadel`
|
||||
|
||||
Reads `ZITADEL_SERVER_ACCESS_TOKEN` (preferred) or `ZITADEL_TEST_ID_TOKEN`. Tests JWKS reachability, `getUserCached`, `authz/debug`, and `isAdmin`.
|
||||
|
||||
---
|
||||
|
||||
## How JWT assertion works internally
|
||||
|
||||
When `ZITADEL_SERVER_ACCESS_TOKEN` is absent or expired, `getServiceAccessToken()` does RFC 7523:
|
||||
|
||||
```
|
||||
1. Parse ZITADEL_SERVICE_KEY_JSON
|
||||
clientId = keyConfig.userId ("368155730978537473")
|
||||
keyId = keyConfig.keyId ("368190655136006145")
|
||||
privateKey = RSA PEM
|
||||
|
||||
2. Sign an assertion JWT:
|
||||
Header: { alg: "RS256", kid: keyId }
|
||||
Payload: { iss: clientId, sub: clientId, aud: ZITADEL_ISSUER,
|
||||
iat: now, exp: now+3600, jti: random }
|
||||
|
||||
3. POST https://auth.polymech.info/oauth/v2/token
|
||||
grant_type = urn:ietf:params:oauth:grant-type:jwt-bearer
|
||||
assertion = <signed JWT>
|
||||
scope = openid profile email
|
||||
|
||||
4. Zitadel verifies signature against the registered public key →
|
||||
returns { access_token (JWT), expires_in, token_type: "Bearer" }
|
||||
|
||||
5. Token is cached with a 1-min safety buffer before exp
|
||||
```
|
||||
|
||||
> **Why `500 Errors.Internal`?** This is Zitadel's response when the `kid` doesn't match any key registered for that user. The JSON file alone is not enough — the **public key must be uploaded to Zitadel first**. Run `npm run zitadel:key-inspect` to get the PEM and exact console steps.
|
||||
|
||||
---
|
||||
|
||||
## Service token payload
|
||||
|
||||
Machine user tokens have `aud: ["service"]` and **no `email` claim**:
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "https://auth.polymech.info",
|
||||
"sub": "368155730978537473",
|
||||
"aud": ["service"],
|
||||
"exp": 1776086012,
|
||||
"iat": 1775956412,
|
||||
"client_id": "service"
|
||||
}
|
||||
```
|
||||
|
||||
Since `ZITADEL_AUDIENCE` is unset, `verifyZitadelAccessToken()` skips audience validation and the token verifies cleanly via JWKS. `claimsToUser()` sets `email: ''`. `isAdmin` resolves correctly via the app UUID (`fe6c94a9-...`) which has an `admin` row in `user_roles`.
|
||||
|
||||
---
|
||||
|
||||
## One-time DB registration
|
||||
|
||||
```bash
|
||||
npx tsx scripts/register-service-admin.ts --sub 368155730978537473
|
||||
```
|
||||
|
||||
What it does:
|
||||
1. Checks `profiles.zitadel_sub = '368155730978537473'` — creates row if missing (`gen_random_uuid()` for `user_id`)
|
||||
2. Grants `role = 'admin'` in `user_roles` if not already present
|
||||
|
||||
After registration: `resolveAppUserId('368155730978537473', null)` → `fe6c94a9-...` → `isAdmin(uuid, '')` → `true`.
|
||||
|
||||
---
|
||||
|
||||
## One-time key registration in Zitadel
|
||||
|
||||
If you have `ZITADEL_SERVICE_KEY_JSON` but get `500 Errors.Internal`:
|
||||
|
||||
```bash
|
||||
npm run zitadel:key-inspect # prints public key PEM + exact console steps
|
||||
```
|
||||
|
||||
Manual steps:
|
||||
1. Open `https://auth.polymech.info/ui/console`
|
||||
2. Service Users → user ID `368155730978537473`
|
||||
3. Keys → Add Key → paste the public key PEM → confirm key ID is `368190655136006145`
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day workflow
|
||||
|
||||
```
|
||||
First time only (dev):
|
||||
npm run cli:register-service-admin -- --sub 368155730978537473
|
||||
npm run zitadel:key-inspect # verify key is registered in Zitadel
|
||||
|
||||
First time only (production):
|
||||
node dist/server.js register-service-admin --sub 368155730978537473
|
||||
|
||||
When tokens expire (~36 h) — dev:
|
||||
npm run zitadel:write-service-token
|
||||
npm run verify:admin # quick sanity check: JWKS + isAdmin
|
||||
|
||||
When tokens expire (~36 h) — production:
|
||||
node dist/server.js refresh-service-token # confirm exchange works
|
||||
node dist/server.js verify-admin # JWKS + isAdmin
|
||||
|
||||
Run tests:
|
||||
npm run test:zitadel:server
|
||||
npm run test:zitadel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `500 Errors.Internal` at token endpoint | Public key not registered in Zitadel | `npm run zitadel:key-inspect` → upload PEM |
|
||||
| `getUserCached` returns null | Token expired | `npm run zitadel:write-service-token` |
|
||||
| `isAdmin` returns false | Service user not in DB | `npx tsx scripts/register-service-admin.ts --sub 368155730978537473` |
|
||||
| `test:zitadel` shows "token expired" despite fresh `.env.zitadel.local` | Expired token in `.env` wins (no override) | `.env.zitadel.local` uses `override: true` in vitest — make sure the key exists in the local file, not just `.env` |
|
||||
| `zitadel:write-service-token` fails "credentials missing" | `ZITADEL_SERVICE_KEY_JSON` not in env | Restore `.env.zitadel.local` with the service key JSON |
|
||||
@ -312,6 +312,7 @@ const PhotoCard = ({
|
||||
responsiveSizes={variant === 'grid' ? [320, 640, 1024, 1280] : [640, 1024, 1280, 1920]} // 1920 added
|
||||
data={responsive}
|
||||
apiUrl={apiUrl}
|
||||
rootMargin={variant === 'feed' ? '160px 0px' : '200px 0px'}
|
||||
/>
|
||||
{/* Helper Badge for External Images */}
|
||||
{isExternal && (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
||||
import { useResponsiveImage } from '@/hooks/useResponsiveImage';
|
||||
|
||||
interface ResponsiveImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
@ -41,7 +41,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
formats = DEFAULT_FORMATS,
|
||||
alt,
|
||||
onDataLoaded,
|
||||
rootMargin = '800px',
|
||||
rootMargin = '240px',
|
||||
onLoad,
|
||||
onError,
|
||||
data: providedData,
|
||||
@ -51,33 +51,36 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
// Lazy load logic
|
||||
const [isInView, setIsInView] = useState(props.loading === 'eager');
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
const ref = React.useRef<HTMLSpanElement>(null);
|
||||
const imgRef = React.useRef<HTMLImageElement>(null);
|
||||
/** When render URLs fail, show original src (e.g. VFS) — same as opening in a new tab */
|
||||
const [fallbackOriginal, setFallbackOriginal] = useState(false);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const placeholderRef = useCallback(
|
||||
(node: HTMLSpanElement | null) => {
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
if (!node || props.loading === 'eager') return;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.loading === 'eager' || providedData) {
|
||||
setIsInView(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
}, { rootMargin });
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
}, {
|
||||
rootMargin
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
},
|
||||
[props.loading, rootMargin],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
};
|
||||
}, [props.loading, rootMargin, providedData]);
|
||||
}, []);
|
||||
|
||||
const hookResult = useResponsiveImage({
|
||||
src: src as string | File,
|
||||
@ -100,6 +103,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
// Reset loaded state when src changes
|
||||
useEffect(() => {
|
||||
setImgLoaded(false);
|
||||
setFallbackOriginal(false);
|
||||
}, [src]);
|
||||
|
||||
// Check if image is already loaded (from cache)
|
||||
@ -118,7 +122,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
if (!isInView || isLoadingOrPending) {
|
||||
// Use className for wrapper if provided, otherwise generic
|
||||
// We attach the ref here to detect when this placeholder comes into view
|
||||
return <span ref={ref} className={`block animate-pulse dark:bg-gray-800 bg-gray-200 w-full h-full ${className || ''}`} />;
|
||||
return <span ref={placeholderRef} className={`block animate-pulse dark:bg-gray-800 bg-gray-200 w-full h-full ${className || ''}`} />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
@ -129,6 +133,22 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
return <span className="block text-red-500 text-xs">Failed to load image</span>;
|
||||
}
|
||||
|
||||
if (fallbackOriginal && typeof src === 'string') {
|
||||
return (
|
||||
<span className={`relative block w-full h-full overflow-hidden ${className || ''}`}>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${imgClassName || ''} transition-opacity duration-300 opacity-100`}
|
||||
loading={props.loading || 'lazy'}
|
||||
decoding="async"
|
||||
{...props}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`relative block w-full h-full overflow-hidden ${className || ''}`}>
|
||||
<picture>
|
||||
@ -149,6 +169,10 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
onLoad?.(e);
|
||||
}}
|
||||
onError={(e) => {
|
||||
if (typeof src === 'string') {
|
||||
setFallbackOriginal(true);
|
||||
return;
|
||||
}
|
||||
setImgLoaded(true);
|
||||
onError?.(e);
|
||||
}}
|
||||
@ -174,7 +198,9 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
prev.loading === next.loading &&
|
||||
prev.data === next.data &&
|
||||
prev.responsiveSizes === next.responsiveSizes &&
|
||||
prev.formats === next.formats;
|
||||
prev.formats === next.formats &&
|
||||
prev.rootMargin === next.rootMargin &&
|
||||
prev.apiUrl === next.apiUrl;
|
||||
});
|
||||
|
||||
export default ResponsiveImage;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ResponsiveData } from '@/components/ResponsiveImage';
|
||||
import { serverUrl } from '@/lib/db';
|
||||
import { serverUrl, apiClient } from '@/lib/db';
|
||||
|
||||
interface UseResponsiveImageProps {
|
||||
src: string | File | null;
|
||||
@ -18,6 +18,27 @@ const DEFAULT_FORMATS = ['avif', 'webp', 'jpeg'];
|
||||
// Key: stringified request params, Value: Promise<ResponsiveData>
|
||||
const requestCache = new Map<string, Promise<ResponsiveData>>();
|
||||
|
||||
// Concurrency limiter — at most N responsive-image requests in flight at once
|
||||
const MAX_CONCURRENT = 3;
|
||||
let _activeSlots = 0;
|
||||
const _slotQueue: (() => void)[] = [];
|
||||
|
||||
function _acquireSlot(): Promise<void> {
|
||||
if (_activeSlots < MAX_CONCURRENT) {
|
||||
_activeSlots++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>(resolve => { _slotQueue.push(resolve); });
|
||||
}
|
||||
|
||||
function _releaseSlot(): void {
|
||||
if (_slotQueue.length > 0) {
|
||||
_slotQueue.shift()!();
|
||||
} else {
|
||||
_activeSlots--;
|
||||
}
|
||||
}
|
||||
|
||||
export const useResponsiveImage = ({
|
||||
src,
|
||||
responsiveSizes = DEFAULT_SIZES,
|
||||
@ -49,47 +70,38 @@ export const useResponsiveImage = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Data URIs bypass the concurrency limiter (no network)
|
||||
if (typeof src === 'string' && src.startsWith('data:')) {
|
||||
if (isMounted) {
|
||||
setData({
|
||||
img: { src, width: 0, height: 0, format: 'unknown' },
|
||||
sources: []
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await _acquireSlot();
|
||||
if (!isMounted) { _releaseSlot(); return; }
|
||||
|
||||
if (isMounted) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
// Only cache string URLs (remote images)
|
||||
// File objects are harder to cache reliably and usually come from user input anyway
|
||||
if (typeof src === 'string') {
|
||||
// Check for Data URI
|
||||
if (src.startsWith('data:')) {
|
||||
if (isMounted) {
|
||||
setData({
|
||||
img: {
|
||||
src: src,
|
||||
width: 0, // Unknown dimensions without parsing
|
||||
height: 0,
|
||||
format: 'unknown'
|
||||
},
|
||||
sources: [] // No alternative sources for raw data URI
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = JSON.stringify({ src, sizes: responsiveSizes, formats, apiUrl });
|
||||
|
||||
if (!requestCache.has(cacheKey)) {
|
||||
const requestPromise = (async () => {
|
||||
const serverBase = apiUrl;
|
||||
// Resolve relative URLs to absolute so the server-side API can fetch them
|
||||
let resolvedSrc = src;
|
||||
if (src.startsWith('/')) {
|
||||
resolvedSrc = `${window.location.origin}${src}`;
|
||||
} else if (src.startsWith('./')) {
|
||||
// For files like ./image033.jpg, resolve relative to current path or origin
|
||||
// Standard URL constructor resolves './' against the base URL
|
||||
resolvedSrc = new URL(src, window.location.href).href;
|
||||
resolvedSrc = `${serverBase}${src}`;
|
||||
} else if (!src.startsWith('http://') && !src.startsWith('https://')) {
|
||||
resolvedSrc = new URL(src, window.location.href).href;
|
||||
resolvedSrc = new URL(src, serverBase).href;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
url: resolvedSrc,
|
||||
@ -97,16 +109,13 @@ export const useResponsiveImage = ({
|
||||
formats: JSON.stringify(formats),
|
||||
});
|
||||
|
||||
const response = await fetch(`${serverBase}/api/images/responsive?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const txt = await response.text();
|
||||
// Remove from cache on error so it can be retried
|
||||
const responsiveUrl = `${serverBase.replace(/\/$/, '')}/api/images/responsive?${params.toString()}`;
|
||||
try {
|
||||
return await apiClient<ResponsiveData>(responsiveUrl);
|
||||
} catch (e) {
|
||||
requestCache.delete(cacheKey);
|
||||
throw new Error(txt || `Failed to generate responsive images for ${src}`);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
})();
|
||||
|
||||
requestCache.set(cacheKey, requestPromise);
|
||||
@ -117,7 +126,6 @@ export const useResponsiveImage = ({
|
||||
setData(result);
|
||||
}
|
||||
} else {
|
||||
// Handle File objects (no caching)
|
||||
const formData = new FormData();
|
||||
formData.append('file', src);
|
||||
formData.append('sizes', JSON.stringify(responsiveSizes));
|
||||
@ -144,6 +152,7 @@ export const useResponsiveImage = ({
|
||||
setError(err.message);
|
||||
}
|
||||
} finally {
|
||||
_releaseSlot();
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@ -77,7 +77,10 @@ export const getAuthToken = async (): Promise<string | undefined> => {
|
||||
/** Helper function to get authorization headers */
|
||||
export const getAuthHeaders = async (): Promise<HeadersInit> => {
|
||||
const token = await getAuthToken();
|
||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
return headers;
|
||||
};
|
||||
@ -97,23 +100,76 @@ async function apiResponseJson<T>(response: Response, endpoint: string): Promise
|
||||
throw new Error(`API Error on ${endpoint}: ${msg}`);
|
||||
}
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
if (contentType?.includes('text/html')) {
|
||||
throw new Error(
|
||||
`API Error on ${endpoint}: HTTP ${response.status} returned text/html instead of JSON — SPA fallback or API upstream unavailable`,
|
||||
);
|
||||
}
|
||||
if (contentType?.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text() as unknown as T;
|
||||
}
|
||||
|
||||
/** True when the edge/proxy likely returned the SPA shell or a transient upstream error instead of the API. */
|
||||
function isTransientApiFailure(response: Response): boolean {
|
||||
if ([502, 503, 504].includes(response.status)) return true;
|
||||
if (!response.ok) return false;
|
||||
const ct = response.headers.get('content-type') || '';
|
||||
return ct.includes('text/html');
|
||||
}
|
||||
|
||||
const API_CLIENT_MAX_RETRIES = 2;
|
||||
const API_CLIENT_RETRY_BASE_MS = 350;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function isLikelyNetworkError(err: Error): boolean {
|
||||
const m = err.message;
|
||||
return m.includes('fetch') || m.includes('network') || m.includes('Failed to fetch');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries when the edge returns the SPA index.html (200 + text/html), 502/503/504,
|
||||
* or a thrown fetch — common during Node restarts or mis-timed proxy static fallback.
|
||||
*/
|
||||
async function fetchWithApiRetries(url: string, init: RequestInit, labelForLog: string): Promise<Response> {
|
||||
let lastErr: Error | null = null;
|
||||
for (let attempt = 0; attempt <= API_CLIENT_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (isTransientApiFailure(response) && attempt < API_CLIENT_MAX_RETRIES) {
|
||||
const ct = response.headers.get('content-type') || '';
|
||||
console.warn(
|
||||
`[apiClient] transient (${response.status}${ct ? ` ${ct.split(';')[0]}` : ''}) on ${labelForLog} — retry ${attempt + 1}/${API_CLIENT_MAX_RETRIES}`,
|
||||
);
|
||||
await sleep(API_CLIENT_RETRY_BASE_MS * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
lastErr = e instanceof Error ? e : new Error(String(e));
|
||||
if (isLikelyNetworkError(lastErr) && attempt < API_CLIENT_MAX_RETRIES) {
|
||||
console.warn(`[apiClient] network error on ${labelForLog} — retry ${attempt + 1}/${API_CLIENT_MAX_RETRIES}`);
|
||||
await sleep(API_CLIENT_RETRY_BASE_MS * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error(`fetch failed after retries: ${labelForLog}`);
|
||||
}
|
||||
|
||||
export async function apiClient<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = await getAuthHeaders();
|
||||
const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://');
|
||||
const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithApiRetries(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
headers: { ...headers, ...options.headers },
|
||||
}, endpoint);
|
||||
return apiResponseJson<T>(response, endpoint);
|
||||
}
|
||||
|
||||
@ -121,13 +177,10 @@ export async function apiClientOr404<T>(endpoint: string, options: RequestInit =
|
||||
const headers = await getAuthHeaders();
|
||||
const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://');
|
||||
const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithApiRetries(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
headers: { ...headers, ...options.headers },
|
||||
}, endpoint);
|
||||
if (response.status === 404) return null;
|
||||
return apiResponseJson<T>(response, endpoint);
|
||||
}
|
||||
|
||||
@ -69,12 +69,14 @@ export function useVfsAdapter({
|
||||
const clean = dirPath.replace(/^\/+/, '');
|
||||
if (isSearchMode) {
|
||||
const data = await fetchVfsSearch(mount, clean, searchQuery, { accessToken });
|
||||
setNodes(data.results || []);
|
||||
if (onFetchedRef.current) onFetchedRef.current(data.results || [], true);
|
||||
const results = Array.isArray(data?.results) ? data.results : [];
|
||||
setNodes(results);
|
||||
if (onFetchedRef.current) onFetchedRef.current(results, true);
|
||||
} else {
|
||||
const data = await fetchVfsDirectory(mount, clean, { accessToken, includeSize });
|
||||
setNodes(data);
|
||||
if (onFetchedRef.current) onFetchedRef.current(data, false);
|
||||
const results = Array.isArray(data) ? data : [];
|
||||
setNodes(results);
|
||||
if (onFetchedRef.current) onFetchedRef.current(results, false);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to load directory');
|
||||
@ -120,6 +122,7 @@ export function useVfsAdapter({
|
||||
}, [currentPath, jail, jailRoot]);
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!Array.isArray(nodes)) return [];
|
||||
const regex = globToRegex(currentGlob);
|
||||
return nodes.filter(n => {
|
||||
const isDir = n.type === 'dir' || n.mime === 'inode/directory';
|
||||
|
||||
@ -15,7 +15,7 @@ type AuthzDebugResponse = {
|
||||
appAdmin?: boolean;
|
||||
resolvedUserId?: string | null;
|
||||
userSecrets?: unknown;
|
||||
dbUsersNext?: { poolOk: boolean; database: string; profileCount: number };
|
||||
dbPing?: { poolOk: boolean; database: string; profileCount: number };
|
||||
error?: string;
|
||||
hint?: string;
|
||||
};
|
||||
@ -103,7 +103,7 @@ function Row({
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev playground: exercises the new Zitadel/JWKS path (`commons/zitadel.ts` + `DATABASE_URL_NEXT` pool).
|
||||
* Dev playground: exercises the Zitadel/JWKS auth path (`commons/zitadel.ts` + Postgres pool).
|
||||
* Calls like `GET /api/admin/authz/debug` and `GET /api/admin/authz/experiment/users` are for
|
||||
* integration testing only — not production policy.
|
||||
*/
|
||||
@ -114,10 +114,10 @@ export default function PlaygroundAuth() {
|
||||
const [last, setLast] = useState<AuthzDebugResponse | null>(null);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [lastHttp, setLastHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null);
|
||||
const [lastDbNext, setLastDbNext] = useState<AuthzDebugResponse | null>(null);
|
||||
const [lastDbNextHttp, setLastDbNextHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null);
|
||||
const [loadingDbNext, setLoadingDbNext] = useState(false);
|
||||
const [fetchErrorDbNext, setFetchErrorDbNext] = useState<string | null>(null);
|
||||
const [lastDbPing, setLastDbPing] = useState<AuthzDebugResponse | null>(null);
|
||||
const [lastDbPingHttp, setLastDbPingHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null);
|
||||
const [loadingDbPing, setLoadingDbPing] = useState(false);
|
||||
const [fetchErrorDbPing, setFetchErrorDbPing] = useState<string | null>(null);
|
||||
/** `/api/admin/authz/experiment/users` — admin-gated user list for new-auth experiments only */
|
||||
const [loadingExpUsers, setLoadingExpUsers] = useState(false);
|
||||
const [lastExpUsers, setLastExpUsers] = useState<unknown>(null);
|
||||
@ -189,36 +189,36 @@ export default function PlaygroundAuth() {
|
||||
}
|
||||
}, [auth.user, base]);
|
||||
|
||||
/** `GET /api/admin/authz/db-next` — same JWT + `authzDbNextPing()` via `pool` (`DATABASE_URL_NEXT`, `db-users-next.ts`). */
|
||||
const runDbNextCheck = useCallback(async () => {
|
||||
/** `GET /api/admin/authz/db-ping` — same JWT + Postgres pool ping (`DATABASE_URL`). */
|
||||
const runDbPingCheck = useCallback(async () => {
|
||||
const picked = pickBearerJwtForApi(auth.user ?? undefined);
|
||||
if (!picked) {
|
||||
setLastDbNext({
|
||||
setLastDbPing({
|
||||
verified: false,
|
||||
error: 'opaque_or_non_jwt_token',
|
||||
hint: 'Need a JWT id_token (or JWT access_token).',
|
||||
});
|
||||
setFetchErrorDbNext(null);
|
||||
setLastDbNextHttp(null);
|
||||
setFetchErrorDbPing(null);
|
||||
setLastDbPingHttp(null);
|
||||
return;
|
||||
}
|
||||
setLoadingDbNext(true);
|
||||
setFetchErrorDbNext(null);
|
||||
const url = `${base.replace(/\/$/, '')}/api/admin/authz/db-next`;
|
||||
setLoadingDbPing(true);
|
||||
setFetchErrorDbPing(null);
|
||||
const url = `${base.replace(/\/$/, '')}/api/admin/authz/db-ping`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${picked.token}` },
|
||||
});
|
||||
setLastDbNextHttp({ status: res.status, ok: res.ok, url });
|
||||
setLastDbPingHttp({ status: res.status, ok: res.ok, url });
|
||||
const body = (await res.json()) as AuthzDebugResponse;
|
||||
setLastDbNext(body);
|
||||
setLastDbPing(body);
|
||||
} catch (e) {
|
||||
setLastDbNextHttp(null);
|
||||
setFetchErrorDbNext(e instanceof Error ? e.message : String(e));
|
||||
setLastDbNext(null);
|
||||
setLastDbPingHttp(null);
|
||||
setFetchErrorDbPing(e instanceof Error ? e.message : String(e));
|
||||
setLastDbPing(null);
|
||||
} finally {
|
||||
setLoadingDbNext(false);
|
||||
setLoadingDbPing(false);
|
||||
}
|
||||
}, [auth.user, base]);
|
||||
|
||||
@ -296,11 +296,11 @@ export default function PlaygroundAuth() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void runDbNextCheck()}
|
||||
disabled={loadingDbNext || !base}
|
||||
onClick={() => void runDbPingCheck()}
|
||||
disabled={loadingDbPing || !base}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loadingDbNext ? 'animate-spin' : ''}`} />
|
||||
{loadingDbNext ? 'Pool…' : 'db-users-next (pool)'}
|
||||
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loadingDbPing ? 'animate-spin' : ''}`} />
|
||||
{loadingDbPing ? 'Pinging…' : 'DB ping (pool)'}
|
||||
</Button>
|
||||
{/* New-auth experiment only — see server `authzExperimentUsersRoute` */}
|
||||
<Button
|
||||
@ -462,24 +462,23 @@ export default function PlaygroundAuth() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{fetchErrorDbNext && (
|
||||
{fetchErrorDbPing && (
|
||||
<pre className="text-xs bg-destructive/10 text-destructive rounded-md p-3 overflow-x-auto">
|
||||
{fetchErrorDbNext}
|
||||
{fetchErrorDbPing}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{lastDbNext && (
|
||||
{lastDbPing && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
API response (/api/admin/authz/db-next) — <code className="text-xs">DATABASE_URL_NEXT</code> via{' '}
|
||||
<code className="text-xs">db-users-next.ts</code>
|
||||
API response (/api/admin/authz/db-ping) — Postgres pool ping
|
||||
</h3>
|
||||
<Row label="request">{lastDbNextHttp?.url ?? '—'}</Row>
|
||||
<Row label="request">{lastDbPingHttp?.url ?? '—'}</Row>
|
||||
<Row label="status">
|
||||
{lastDbNextHttp ? `${lastDbNextHttp.status} ${lastDbNextHttp.ok ? 'OK' : ''}` : '—'}
|
||||
{lastDbPingHttp ? `${lastDbPingHttp.status} ${lastDbPingHttp.ok ? 'OK' : ''}` : '—'}
|
||||
</Row>
|
||||
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-[360px]">
|
||||
{JSON.stringify(lastDbNext, null, 2)}
|
||||
{JSON.stringify(lastDbPing, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user