fix and update

This commit is contained in:
lendry
2026-06-29 22:51:25 +03:00
parent 885b07d76b
commit 57cb58347b
30 changed files with 799 additions and 187 deletions

View File

@@ -219,13 +219,14 @@ export interface QrLoginSession {
auth?: AuthTokens & { user?: PublicUser };
}
export async function createQrLoginSession(deviceName: string) {
export async function createQrLoginSession(deviceName?: string) {
const { buildAuthDevicePayload } = await import('@/lib/device-client');
const device = buildAuthDevicePayload();
return apiFetch<QrLoginSession>('/auth/advanced/qr/session', {
method: 'POST',
body: JSON.stringify({
deviceName,
fingerprint: getDeviceFingerprint(),
deviceType: 'WEB'
...device,
deviceName: deviceName?.trim() || device.deviceName
})
});
}
@@ -368,6 +369,19 @@ export const AUTH_REFRESH_KEY = 'lendry_refresh_token';
export const AUTH_SESSION_KEY = 'lendry_session_id';
export const AUTH_USER_CACHE_KEY = 'lendry_cached_user';
/** Актуальный access token из localStorage (не полагается на React state). */
export function getAccessToken(): string | null {
if (typeof window === 'undefined') return null;
const value = window.localStorage.getItem(AUTH_TOKEN_KEY)?.trim();
return value || null;
}
function resolveAccessToken(explicit?: string | null): string | null {
const trimmed = explicit?.trim();
if (trimmed) return trimmed;
return getAccessToken();
}
export class ApiError extends Error {
status: number;
code?: string;
@@ -441,6 +455,7 @@ export function isGatewayUnavailableError(error: unknown): boolean {
export function getApiErrorMessage(error: unknown, fallback: string): string | null {
if (isPinRequiredError(error)) return null;
if (isGatewayUnavailableError(error)) return null;
if (error instanceof ApiError && error.code === 'NO_TOKEN') return null;
if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) {
return null;
}
@@ -526,15 +541,18 @@ export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
return response.json() as Promise<RefreshSessionResponse>;
}
export async function syncFedcmSession(accessToken: string) {
if (typeof window === 'undefined' || !accessToken.trim()) return;
export async function syncFedcmSession(accessToken?: string | null) {
const token = resolveAccessToken(accessToken);
if (typeof window === 'undefined' || !token) return;
try {
await ensureApiGatewayReady();
await fetchWithGatewayRetry(`${getApiUrl()}/fedcm/session/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken.trim()}` },
credentials: 'include'
});
await apiFetch<{ synced: boolean }>(
'/fedcm/session/sync',
{
method: 'POST',
credentials: 'include'
},
token
);
} catch {
// фоновая синхронизация FedCM cookie
}
@@ -602,6 +620,7 @@ export interface SignInEvent {
reason?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
deviceName?: string | null;
createdAt: string;
}
@@ -609,6 +628,8 @@ export interface ActiveSession {
id: string;
userId: string;
deviceId?: string | null;
deviceName?: string | null;
deviceType?: string | null;
pinVerified: boolean;
status: string;
ipAddress?: string | null;
@@ -633,10 +654,11 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
}
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const GATEWAY_RETRY_ATTEMPTS = 6;
const GATEWAY_WARMUP_MAX_ATTEMPTS = 40;
const GATEWAY_WARMUP_DELAY_MS = 300;
const GATEWAY_RETRY_ATTEMPTS = 8;
const GATEWAY_WARMUP_MAX_ATTEMPTS = 80;
const GATEWAY_WARMUP_DELAY_MS = 400;
const GATEWAY_READY_CONSECUTIVE_OK = 2;
const GATEWAY_WARMUP_MAX_DELAY_MS = 2500;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -650,6 +672,15 @@ export function resetApiGatewayWarmup() {
gatewayReadyPromise = null;
}
export function invalidateApiGatewayReady() {
gatewayReadyResolved = false;
gatewayReadyPromise = null;
}
export function isApiGatewayReady() {
return gatewayReadyResolved;
}
export function markApiGatewayReady() {
gatewayReadyResolved = true;
}
@@ -671,8 +702,12 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
if (typeof window === 'undefined') return;
if (gatewayReadyResolved && !force) return;
if (gatewayReadyPromise && !force) return gatewayReadyPromise;
if (force) {
gatewayReadyResolved = false;
gatewayReadyPromise = null;
}
gatewayReadyPromise = (async () => {
await sleep(600);
let consecutiveOk = 0;
for (let attempt = 0; attempt < GATEWAY_WARMUP_MAX_ATTEMPTS; attempt++) {
if (await probeGatewayReady()) {
@@ -684,9 +719,10 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
} else {
consecutiveOk = 0;
}
await sleep(GATEWAY_WARMUP_DELAY_MS);
const delay = Math.min(GATEWAY_WARMUP_DELAY_MS * 1.35 ** attempt, GATEWAY_WARMUP_MAX_DELAY_MS);
await sleep(delay);
}
gatewayReadyResolved = true;
gatewayReadyPromise = null;
})();
return gatewayReadyPromise;
@@ -699,6 +735,8 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
try {
const response = await fetch(url, init);
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
invalidateApiGatewayReady();
await ensureApiGatewayReady(true);
await sleep(400 * (attempt + 1));
continue;
}
@@ -706,6 +744,8 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
} catch (error) {
lastNetworkError = error;
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
invalidateApiGatewayReady();
await ensureApiGatewayReady(true);
await sleep(400 * (attempt + 1));
continue;
}
@@ -716,11 +756,11 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
}
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
if (typeof window !== 'undefined' && path !== '/health' && !path.startsWith('/health?')) {
if (typeof window !== 'undefined' && !path.startsWith('/health')) {
await ensureApiGatewayReady();
}
const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null);
const resolvedToken = resolveAccessToken(token);
const method = (options.method ?? 'GET').toUpperCase();
const hasBody = options.body !== undefined && options.body !== null;
const headers: Record<string, string> = {
@@ -762,21 +802,19 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
}
}
if (allowRetry && isGatewayUnavailableError(error)) {
invalidateApiGatewayReady();
await ensureApiGatewayReady(true);
return apiFetch<T>(path, options, token, false);
}
throw error;
}
return response.json() as Promise<T>;
}
export function getDeviceFingerprint(): string {
if (typeof window === 'undefined') return 'server';
const key = 'lendry_device_fingerprint';
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const value = crypto.randomUUID();
window.localStorage.setItem(key, value);
return value;
}
export { getDeviceFingerprint, buildAuthDevicePayload, resolveDeviceLabel, formatUserAgentLabel } from '@/lib/device-client';
export interface MediaAccessResponse {
accessUrl: string;
@@ -1022,7 +1060,11 @@ export interface ChatRoom {
}
export async function fetchFamilyGroups(userId: string, token?: string | null) {
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, token);
const accessToken = resolveAccessToken(token);
if (!accessToken) {
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
}
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, accessToken);
}
export async function fetchFamilyGroup(groupId: string, token?: string | null) {
@@ -1339,7 +1381,11 @@ export async function reportChatTyping(roomId: string, token?: string | null) {
}
export async function fetchFamilyPresence(groupId: string, token?: string | null) {
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, token);
const accessToken = resolveAccessToken(token);
if (!accessToken) {
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
}
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, accessToken);
}
/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */