fix and update
This commit is contained in:
@@ -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 в браузере */
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { fetchMediaBlobWithRetry } from '@/lib/resilient-media-fetch';
|
||||
|
||||
export async function fetchAuthenticatedMediaBlob(accessUrl: string, token: string, mimeType?: string) {
|
||||
const response = await fetch(accessUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить файл');
|
||||
}
|
||||
const raw = await response.blob();
|
||||
if (mimeType && (!raw.type || raw.type === 'application/octet-stream')) {
|
||||
return new Blob([await raw.arrayBuffer()], { type: mimeType });
|
||||
}
|
||||
return raw;
|
||||
return fetchMediaBlobWithRetry(accessUrl, token, { mimeType, label: 'файл чата' });
|
||||
}
|
||||
|
||||
export function createBlobObjectUrl(blob: Blob) {
|
||||
|
||||
81
apps/frontend/lib/device-client.ts
Normal file
81
apps/frontend/lib/device-client.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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 function resolveDeviceType(): string {
|
||||
if (typeof navigator === 'undefined') return 'WEB';
|
||||
const ua = navigator.userAgent;
|
||||
if (/Android/i.test(ua)) return 'ANDROID';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return 'IOS';
|
||||
if (/Windows/i.test(ua)) return 'DESKTOP';
|
||||
if (/Mac OS X/i.test(ua)) return 'DESKTOP';
|
||||
return 'WEB';
|
||||
}
|
||||
|
||||
export function resolveDeviceLabel(): string {
|
||||
if (typeof navigator === 'undefined') return 'Browser';
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
const browser = (() => {
|
||||
if (/Edg\//i.test(ua)) return 'Microsoft Edge';
|
||||
if (/YaBrowser/i.test(ua)) return 'Яндекс Браузер';
|
||||
if (/Firefox\//i.test(ua)) return 'Firefox';
|
||||
if (/CriOS/i.test(ua)) return 'Chrome';
|
||||
if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua)) return 'Chrome';
|
||||
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
|
||||
return 'Браузер';
|
||||
})();
|
||||
|
||||
const os = (() => {
|
||||
if (/Windows NT/i.test(ua)) return 'Windows';
|
||||
if (/Android/i.test(ua)) return 'Android';
|
||||
if (/iPhone/i.test(ua)) return 'iPhone';
|
||||
if (/iPad/i.test(ua)) return 'iPad';
|
||||
if (/Mac OS X/i.test(ua)) return 'macOS';
|
||||
if (/Linux/i.test(ua)) return 'Linux';
|
||||
return 'Устройство';
|
||||
})();
|
||||
|
||||
return `${browser} · ${os}`;
|
||||
}
|
||||
|
||||
export function buildAuthDevicePayload() {
|
||||
return {
|
||||
fingerprint: getDeviceFingerprint(),
|
||||
deviceName: resolveDeviceLabel(),
|
||||
deviceType: resolveDeviceType()
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUserAgentLabel(userAgent?: string | null): string | null {
|
||||
if (!userAgent?.trim()) return null;
|
||||
const ua = userAgent.trim();
|
||||
|
||||
const browser = (() => {
|
||||
if (/Edg\//i.test(ua)) return 'Microsoft Edge';
|
||||
if (/YaBrowser/i.test(ua)) return 'Яндекс Браузер';
|
||||
if (/Firefox\//i.test(ua)) return 'Firefox';
|
||||
if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua)) return 'Chrome';
|
||||
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
|
||||
return null;
|
||||
})();
|
||||
|
||||
const os = (() => {
|
||||
if (/Windows NT/i.test(ua)) return 'Windows';
|
||||
if (/Android/i.test(ua)) return 'Android';
|
||||
if (/iPhone/i.test(ua)) return 'iPhone';
|
||||
if (/iPad/i.test(ua)) return 'iPad';
|
||||
if (/Mac OS X/i.test(ua)) return 'macOS';
|
||||
if (/Linux/i.test(ua)) return 'Linux';
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (browser && os) return `${browser} · ${os}`;
|
||||
return browser ?? os;
|
||||
}
|
||||
60
apps/frontend/lib/resilient-media-fetch.ts
Normal file
60
apps/frontend/lib/resilient-media-fetch.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ensureApiGatewayReady, invalidateApiGatewayReady } from '@/lib/api';
|
||||
|
||||
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
|
||||
const MEDIA_FETCH_MAX_ATTEMPTS = 8;
|
||||
const MEDIA_SLOW_LOAD_WARN_MS = 8_000;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function warnSlowMediaLoad(label: string, startedAt: number, error: unknown) {
|
||||
if (Date.now() - startedAt < MEDIA_SLOW_LOAD_WARN_MS) return;
|
||||
const detail = error instanceof Error ? error.message : 'неизвестная ошибка';
|
||||
console.warn(`[media] ${label}: не удалось загрузить за ${Math.round((Date.now() - startedAt) / 1000)} с (${detail})`);
|
||||
}
|
||||
|
||||
export async function fetchMediaBlobWithRetry(
|
||||
accessUrl: string,
|
||||
token: string,
|
||||
options?: { mimeType?: string; label?: string }
|
||||
): Promise<Blob> {
|
||||
const startedAt = Date.now();
|
||||
const label = options?.label ?? 'изображение';
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < MEDIA_FETCH_MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
await ensureApiGatewayReady();
|
||||
const response = await fetch(accessUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < MEDIA_FETCH_MAX_ATTEMPTS) {
|
||||
invalidateApiGatewayReady();
|
||||
await sleep(350 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const raw = await response.blob();
|
||||
const mimeType = options?.mimeType;
|
||||
if (mimeType && (!raw.type || raw.type === 'application/octet-stream')) {
|
||||
return new Blob([await raw.arrayBuffer()], { type: mimeType });
|
||||
}
|
||||
return raw;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt + 1 < MEDIA_FETCH_MAX_ATTEMPTS) {
|
||||
await sleep(350 * (attempt + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warnSlowMediaLoad(label, startedAt, lastError);
|
||||
throw lastError instanceof Error ? lastError : new Error('Не удалось загрузить файл');
|
||||
}
|
||||
Reference in New Issue
Block a user