fix and update

This commit is contained in:
lendry
2026-06-30 16:03:33 +03:00
parent 69063c8fba
commit 2eeb928a72
6 changed files with 138 additions and 19 deletions

View File

@@ -107,15 +107,13 @@ function applySessionState(
function readInitialAuthState() { function readInitialAuthState() {
if (typeof window === 'undefined') { 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 token = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refresh = window.localStorage.getItem(AUTH_REFRESH_KEY); const refresh = window.localStorage.getItem(AUTH_REFRESH_KEY);
const cachedUser = readCachedUserProfile();
return { return {
token, token,
hasStoredSession: Boolean(refresh || token), hasStoredSession: Boolean(refresh || token)
cachedUser
}; };
} }
@@ -125,7 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const { showToast } = useToast(); const { showToast } = useToast();
const initialAuth = React.useMemo(() => readInitialAuthState(), []); const initialAuth = React.useMemo(() => readInitialAuthState(), []);
const [token, setToken] = React.useState<string | null>(initialAuth.token); const [token, setToken] = React.useState<string | null>(initialAuth.token);
const [user, setUser] = React.useState<PublicUser | null>(initialAuth.cachedUser); const [user, setUser] = React.useState<PublicUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const [isApiReady, setIsApiReady] = React.useState(false); const [isApiReady, setIsApiReady] = React.useState(false);
const [isSessionReady, setIsSessionReady] = React.useState(false); const [isSessionReady, setIsSessionReady] = React.useState(false);
@@ -269,8 +267,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setHasStoredSession(Boolean(refreshToken)); setHasStoredSession(Boolean(refreshToken));
if (!currentToken && !refreshToken) { if (!currentToken && !refreshToken) {
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
setUser(null);
setToken(null);
initialBootstrapDoneRef.current = true; initialBootstrapDoneRef.current = true;
setIsSessionReady(true); setIsSessionReady(true);
setIsApiReady(true);
setIsLoading(false); setIsLoading(false);
return; return;
} }
@@ -282,7 +284,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
try { try {
for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) { for (let bootstrapAttempt = 0; bootstrapAttempt < 4; bootstrapAttempt += 1) {
try { try {
if (isInitialBootstrap) { if (isInitialBootstrap) {
await waitForStableGateway(3); await waitForStableGateway(3);
@@ -293,11 +295,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try { try {
const session = await fetchAuthSession(currentToken); const session = await fetchAuthSession(currentToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsApiReady(true);
setIsSessionReady(true); setIsSessionReady(true);
return; return;
} catch (error) { } catch (error) {
if (isPinRequiredError(error)) { if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return; return;
} }
if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') { if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') {
@@ -321,11 +326,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
cacheUserProfile(refreshed.user); cacheUserProfile(refreshed.user);
} }
activatePinLock(refreshed.sessionId ?? ''); activatePinLock(refreshed.sessionId ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return; return;
} }
if (refreshed.accessToken) { if (refreshed.accessToken) {
const session = await fetchAuthSession(refreshed.accessToken); const session = await fetchAuthSession(refreshed.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
setIsApiReady(true);
setIsSessionReady(true); setIsSessionReady(true);
return; return;
} }
@@ -333,17 +341,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
throw new ApiError('Сессия недействительна', 401); throw new ApiError('Сессия недействительна', 401);
} catch (error) { } catch (error) {
if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 6) { if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 4) {
invalidateApiGatewayReady(); invalidateApiGatewayReady();
if (bootstrapAttempt >= 1) {
setBootstrapError(
'Не удалось подключиться к серверу API (502). Подождите несколько секунд и нажмите «Повторить».'
);
}
await new Promise((resolve) => setTimeout(resolve, 1200)); await new Promise((resolve) => setTimeout(resolve, 1200));
continue; continue;
} }
if (isPinRequiredError(error)) { if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return; return;
} }
if (refreshToken && error instanceof ApiError && error.status === 403) { if (refreshToken && error instanceof ApiError && error.status === 403) {
activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
setIsApiReady(true);
setIsSessionReady(true);
return; return;
} }
if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) { if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) {
@@ -612,7 +629,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const isBootstrapping = const isBootstrapping =
!isPinLocked && !isPinLocked &&
Boolean(hasStoredSession || token || user) && Boolean(hasStoredSession || token) &&
(isLoading || !isApiReady || !isSessionReady); (isLoading || !isApiReady || !isSessionReady);
return ( return (
@@ -649,6 +666,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
? () => { ? () => {
setBootstrapError(null); setBootstrapError(null);
initialBootstrapDoneRef.current = false; initialBootstrapDoneRef.current = false;
invalidateApiGatewayReady();
void refreshProfile(); void refreshProfile();
} }
: undefined : undefined

View File

@@ -7,14 +7,17 @@ import { useAuth } from '@/components/id/auth-provider';
export function useRequireAuth() { export function useRequireAuth() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); 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(() => { useEffect(() => {
if (isLoading || !isApiReady || !isSessionReady) return; if (!authResolved) return;
if (user || isPinLocked || hasStoredSession) return; if (isAuthenticated || hasStoredSession || token) return;
if (pathname.startsWith('/auth/')) return; if (pathname.startsWith('/auth/')) return;
router.replace('/auth/login'); router.replace('/auth/login');
}, [hasStoredSession, isApiReady, isLoading, isPinLocked, isSessionReady, pathname, router, user]); }, [authResolved, hasStoredSession, isAuthenticated, pathname, router, token]);
return { return {
user, user,
@@ -22,6 +25,7 @@ export function useRequireAuth() {
isPinLocked, isPinLocked,
isApiReady, isApiReady,
isSessionReady, isSessionReady,
isReady: !isLoading && isApiReady && isSessionReady && Boolean(user || isPinLocked || hasStoredSession) authResolved,
isReady: authResolved && isAuthenticated && isApiReady
}; };
} }

View File

@@ -449,7 +449,10 @@ export function isPinRequiredError(error: unknown): error is ApiError {
} }
export function isGatewayUnavailableError(error: unknown): boolean { 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 { export function getApiErrorMessage(error: unknown, fallback: string): string | null {
@@ -681,7 +684,7 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]); const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const GATEWAY_RETRY_ATTEMPTS = 4; const GATEWAY_RETRY_ATTEMPTS = 4;
const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800; const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800;
const GATEWAY_STABLE_MAX_ROUNDS = 18; const GATEWAY_STABLE_MAX_ROUNDS = 12;
const API_MAX_CONCURRENT = 6; const API_MAX_CONCURRENT = 6;
const API_BURST_MAX_CONCURRENT = 2; const API_BURST_MAX_CONCURRENT = 2;
const API_BURST_WINDOW_MS = 5000; const API_BURST_WINDOW_MS = 5000;

View File

@@ -267,12 +267,17 @@ services:
image: nginx:1.27-alpine image: nginx:1.27-alpine
container_name: lendry-id-nginx container_name: lendry-id-nginx
restart: unless-stopped restart: unless-stopped
environment:
IDP_API_GATEWAY_HOST: lendry-id-api-gateway
IDP_API_GATEWAY_PORT: "3000"
IDP_FRONTEND_DOMAIN: ${DOMAIN_FRONTEND:-}
ports: ports:
- "${NGINX_HTTP_PORT:-80}:80" - "${NGINX_HTTP_PORT:-80}:80"
- "${NGINX_HTTPS_PORT:-443}:443" - "${NGINX_HTTPS_PORT:-443}:443"
volumes: volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/certs:/etc/nginx/certs:ro - ./nginx/certs:/etc/nginx/certs:ro
- ./infra/nginx/99-idp-gateway-watch.sh:/docker-entrypoint.d/99-idp-gateway-watch.sh:ro
depends_on: depends_on:
api-gateway: api-gateway:
condition: service_healthy condition: service_healthy
@@ -286,12 +291,12 @@ services:
test: test:
[ [
"CMD-SHELL", "CMD-SHELL",
"wget -qO- --timeout=3 http://lendry-id-api-gateway:3000/health 2>/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 interval: 15s
timeout: 5s timeout: 8s
retries: 5 retries: 5
start_period: 30s start_period: 45s
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -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 &

View File

@@ -419,7 +419,11 @@ sync_nginx_after_api_gateway_restart() {
wait_for_api_gateway_http 90 || warn "api-gateway /health на :3000 не отвечает" wait_for_api_gateway_http 90 || warn "api-gateway /health на :3000 не отвечает"
sleep 2 sleep 2
reload_docker_nginx 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() { 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)" wget -qO- --timeout=5 http://lendry-id-api-gateway:3000/health 2>/dev/null || true)"
if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then if [[ "$body" == *'"status":"ok"'* ]] || [[ "$body" == *'"status": "ok"'* ]]; then
ok "Docker Nginx → lendry-id-api-gateway:3000/health OK (IP api-gateway: ${gw_ip:-?})" ok "Docker Nginx → lendry-id-api-gateway:3000/health OK (IP api-gateway: ${gw_ip:-?})"
verify_nginx_idp_api_location || return 1
return 0 return 0
fi fi
attempt=$((attempt + 1)) attempt=$((attempt + 1))
@@ -2671,6 +2676,38 @@ verify_nginx_reaches_api_gateway() {
return 1 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() { verify_nginx_reaches_media_ws() {
docker_nginx_container_running || return 0 docker_nginx_container_running || return 0
build_compose_stack_cmd build_compose_stack_cmd