From 2eeb928a726b9bc98b90f4a8cb82e2dc8eb99bc8 Mon Sep 17 00:00:00 2001 From: lendry Date: Tue, 30 Jun 2026 16:03:33 +0300 Subject: [PATCH] fix and update --- apps/frontend/components/id/auth-provider.tsx | 34 +++++++++--- apps/frontend/hooks/use-require-auth.ts | 14 +++-- apps/frontend/lib/api.ts | 7 ++- docker-compose.yml | 11 ++-- infra/nginx/99-idp-gateway-watch.sh | 52 +++++++++++++++++++ install.sh | 39 +++++++++++++- 6 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 infra/nginx/99-idp-gateway-watch.sh diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 1df35c5..d922bdf 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -107,15 +107,13 @@ function applySessionState( function readInitialAuthState() { if (typeof window === 'undefined') { - return { token: null as string | null, hasStoredSession: false, cachedUser: null as PublicUser | null }; + return { token: null as string | null, hasStoredSession: false }; } const token = window.localStorage.getItem(AUTH_TOKEN_KEY); const refresh = window.localStorage.getItem(AUTH_REFRESH_KEY); - const cachedUser = readCachedUserProfile(); return { token, - hasStoredSession: Boolean(refresh || token), - cachedUser + hasStoredSession: Boolean(refresh || token) }; } @@ -125,7 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { showToast } = useToast(); const initialAuth = React.useMemo(() => readInitialAuthState(), []); const [token, setToken] = React.useState(initialAuth.token); - const [user, setUser] = React.useState(initialAuth.cachedUser); + const [user, setUser] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [isApiReady, setIsApiReady] = React.useState(false); const [isSessionReady, setIsSessionReady] = React.useState(false); @@ -269,8 +267,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setHasStoredSession(Boolean(refreshToken)); if (!currentToken && !refreshToken) { + window.localStorage.removeItem(AUTH_USER_CACHE_KEY); + setUser(null); + setToken(null); initialBootstrapDoneRef.current = true; setIsSessionReady(true); + setIsApiReady(true); setIsLoading(false); return; } @@ -282,7 +284,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } try { - for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) { + for (let bootstrapAttempt = 0; bootstrapAttempt < 4; bootstrapAttempt += 1) { try { if (isInitialBootstrap) { await waitForStableGateway(3); @@ -293,11 +295,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { try { const session = await fetchAuthSession(currentToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); + setIsApiReady(true); setIsSessionReady(true); return; } catch (error) { if (isPinRequiredError(error)) { activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); + setIsApiReady(true); + setIsSessionReady(true); return; } if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') { @@ -321,11 +326,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { cacheUserProfile(refreshed.user); } activatePinLock(refreshed.sessionId ?? ''); + setIsApiReady(true); + setIsSessionReady(true); return; } if (refreshed.accessToken) { const session = await fetchAuthSession(refreshed.accessToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); + setIsApiReady(true); setIsSessionReady(true); return; } @@ -333,17 +341,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { throw new ApiError('Сессия недействительна', 401); } catch (error) { - if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 6) { + if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 4) { invalidateApiGatewayReady(); + if (bootstrapAttempt >= 1) { + setBootstrapError( + 'Не удалось подключиться к серверу API (502). Подождите несколько секунд и нажмите «Повторить».' + ); + } await new Promise((resolve) => setTimeout(resolve, 1200)); continue; } if (isPinRequiredError(error)) { activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); + setIsApiReady(true); + setIsSessionReady(true); return; } if (refreshToken && error instanceof ApiError && error.status === 403) { activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); + setIsApiReady(true); + setIsSessionReady(true); return; } if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) { @@ -612,7 +629,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const isBootstrapping = !isPinLocked && - Boolean(hasStoredSession || token || user) && + Boolean(hasStoredSession || token) && (isLoading || !isApiReady || !isSessionReady); return ( @@ -649,6 +666,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ? () => { setBootstrapError(null); initialBootstrapDoneRef.current = false; + invalidateApiGatewayReady(); void refreshProfile(); } : undefined diff --git a/apps/frontend/hooks/use-require-auth.ts b/apps/frontend/hooks/use-require-auth.ts index 3c5defb..4ad9479 100644 --- a/apps/frontend/hooks/use-require-auth.ts +++ b/apps/frontend/hooks/use-require-auth.ts @@ -7,14 +7,17 @@ import { useAuth } from '@/components/id/auth-provider'; export function useRequireAuth() { const router = useRouter(); const pathname = usePathname(); - const { user, isLoading, isApiReady, isSessionReady, isPinLocked, hasStoredSession } = useAuth(); + const { user, isLoading, isApiReady, isSessionReady, isPinLocked, hasStoredSession, token } = useAuth(); + + const authResolved = !isLoading && isSessionReady; + const isAuthenticated = Boolean(user || isPinLocked); useEffect(() => { - if (isLoading || !isApiReady || !isSessionReady) return; - if (user || isPinLocked || hasStoredSession) return; + if (!authResolved) return; + if (isAuthenticated || hasStoredSession || token) return; if (pathname.startsWith('/auth/')) return; router.replace('/auth/login'); - }, [hasStoredSession, isApiReady, isLoading, isPinLocked, isSessionReady, pathname, router, user]); + }, [authResolved, hasStoredSession, isAuthenticated, pathname, router, token]); return { user, @@ -22,6 +25,7 @@ export function useRequireAuth() { isPinLocked, isApiReady, isSessionReady, - isReady: !isLoading && isApiReady && isSessionReady && Boolean(user || isPinLocked || hasStoredSession) + authResolved, + isReady: authResolved && isAuthenticated && isApiReady }; } diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index ca53ac5..f527c27 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -449,7 +449,10 @@ export function isPinRequiredError(error: unknown): error is ApiError { } export function isGatewayUnavailableError(error: unknown): boolean { - return error instanceof ApiError && [502, 503, 504].includes(error.status); + return ( + error instanceof ApiError && + (error.code === 'GATEWAY_UNAVAILABLE' || [502, 503, 504].includes(error.status)) + ); } export function getApiErrorMessage(error: unknown, fallback: string): string | null { @@ -681,7 +684,7 @@ export function buildSystemSettingPayload(setting: Pick/dev/null | grep -q '\"status\"' || exit 1", + "if [ -n \"$IDP_FRONTEND_DOMAIN\" ]; then wget -qO- --timeout=4 --no-check-certificate --header=\"Host: $IDP_FRONTEND_DOMAIN\" \"https://127.0.0.1/idp-api/health\" 2>/dev/null | grep -q '\"status\"' || wget -qO- --timeout=4 --header=\"Host: $IDP_FRONTEND_DOMAIN\" \"http://127.0.0.1/idp-api/health\" 2>/dev/null | grep -q '\"status\"' || exit 1; else wget -qO- --timeout=4 http://lendry-id-api-gateway:3000/health 2>/dev/null | grep -q '\"status\"' || exit 1; fi", ] interval: 15s - timeout: 5s + timeout: 8s retries: 5 - start_period: 30s + start_period: 45s volumes: postgres_data: diff --git a/infra/nginx/99-idp-gateway-watch.sh b/infra/nginx/99-idp-gateway-watch.sh new file mode 100644 index 0000000..2abae2a --- /dev/null +++ b/infra/nginx/99-idp-gateway-watch.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# После recreate api-gateway Docker выдаёт новый IP; без reload nginx шлёт на старый адрес → 502. +set -eu + +GW_HOST="${IDP_API_GATEWAY_HOST:-lendry-id-api-gateway}" +GW_PORT="${IDP_API_GATEWAY_PORT:-3000}" +WATCH_INTERVAL="${IDP_NGINX_WATCH_INTERVAL:-5}" + +log() { + echo "[idp-nginx-watch] $*" +} + +wait_for_gateway() { + attempt=0 + while [ "$attempt" -lt 120 ]; do + if wget -qO- --timeout=3 "http://${GW_HOST}:${GW_PORT}/health" 2>/dev/null | grep -q '"status"'; then + log "api-gateway доступен (${GW_HOST}:${GW_PORT})" + return 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + log "WARN: api-gateway не ответил за 120с — nginx стартует без ожидания" + return 0 +} + +watch_gateway() { + last_ip="" + while true; do + sleep "$WATCH_INTERVAL" + current_ip="$(getent hosts "$GW_HOST" 2>/dev/null | awk '{print $1}' | head -n1 || true)" + healthy=0 + if wget -qO- --timeout=3 "http://${GW_HOST}:${GW_PORT}/health" 2>/dev/null | grep -q '"status"'; then + healthy=1 + fi + if [ "$healthy" -eq 0 ]; then + log "api-gateway недоступен — reload nginx" + nginx -s reload 2>/dev/null || true + continue + fi + if [ -n "$current_ip" ] && [ -n "$last_ip" ] && [ "$current_ip" != "$last_ip" ]; then + log "IP ${GW_HOST} изменился (${last_ip} → ${current_ip}) — reload nginx" + nginx -s reload 2>/dev/null || true + fi + if [ -n "$current_ip" ]; then + last_ip="$current_ip" + fi + done +} + +wait_for_gateway +watch_gateway & diff --git a/install.sh b/install.sh index 0a3cfb7..0fcf462 100644 --- a/install.sh +++ b/install.sh @@ -419,7 +419,11 @@ sync_nginx_after_api_gateway_restart() { wait_for_api_gateway_http 90 || warn "api-gateway /health на :3000 не отвечает" sleep 2 reload_docker_nginx - verify_nginx_reaches_api_gateway 24 5 || warn "Nginx → api-gateway: проверка не прошла, см. docker compose logs nginx api-gateway" + if ! verify_nginx_reaches_api_gateway 24 5; then + warn "Reload не помог — пересоздаём lendry-id-nginx..." + recreate_docker_nginx + verify_nginx_reaches_api_gateway 12 5 || warn "Nginx → api-gateway: проверка не прошла, см. docker compose logs nginx api-gateway" + fi } recreate_docker_nginx() { @@ -2658,6 +2662,7 @@ verify_nginx_reaches_api_gateway() { wget -qO- --timeout=5 http://lendry-id-api-gateway:3000/health 2>/dev/null || true)" if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then ok "Docker Nginx → lendry-id-api-gateway:3000/health OK (IP api-gateway: ${gw_ip:-?})" + verify_nginx_idp_api_location || return 1 return 0 fi attempt=$((attempt + 1)) @@ -2671,6 +2676,38 @@ verify_nginx_reaches_api_gateway() { return 1 } +# Тот же путь, что в браузере: location ^~ /idp-api/ + rewrite + proxy_pass $idp_up_api +verify_nginx_idp_api_location() { + docker_nginx_container_running || return 0 + build_compose_stack_cmd + load_env + local frontend_domain body code + frontend_domain="$(env_get DOMAIN_FRONTEND "")" + [[ -n "$frontend_domain" ]] || return 0 + + body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \ + wget -qO- --timeout=6 --header="Host: ${frontend_domain}" \ + "http://127.0.0.1/idp-api/health" 2>/dev/null || true)" + if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then + ok "Docker Nginx location /idp-api/ → api-gateway OK (Host: ${frontend_domain})" + return 0 + fi + + reload_docker_nginx + sleep 2 + body="$(docker_cmd "${COMPOSE_STACK_CMD[@]}" exec -T nginx \ + wget -qO- --timeout=6 --header="Host: ${frontend_domain}" \ + "http://127.0.0.1/idp-api/health" 2>/dev/null || true)" + if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then + ok "Docker Nginx location /idp-api/ → api-gateway OK после reload" + return 0 + fi + + warn "Nginx location /idp-api/ не проксирует на api-gateway (Host: ${frontend_domain})" + warn "Проверьте: grep -E 'idp-api|idp_up_api|resolver' nginx/conf.d/${NGINX_PREFIX}-frontend.conf" + return 1 +} + verify_nginx_reaches_media_ws() { docker_nginx_container_running || return 0 build_compose_stack_cmd