diff --git a/apps/frontend/app/icon.svg b/apps/frontend/app/icon.svg
new file mode 100644
index 0000000..1e194cc
--- /dev/null
+++ b/apps/frontend/app/icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/apps/frontend/app/layout.tsx b/apps/frontend/app/layout.tsx
index 56bd834..c02ba29 100644
--- a/apps/frontend/app/layout.tsx
+++ b/apps/frontend/app/layout.tsx
@@ -4,13 +4,18 @@ import './globals.css';
export const metadata: Metadata = {
title: 'MVK ID',
- description: 'Единый аккаунт для сервисов Lendry'
+ description: 'Единый аккаунт для сервисов Lendry',
+ icons: {
+ icon: '/icon.svg',
+ shortcut: '/icon.svg',
+ apple: '/icon.svg'
+ }
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
-
-
+
+
{children}
diff --git a/apps/frontend/components/documents/document-photo-gallery.tsx b/apps/frontend/components/documents/document-photo-gallery.tsx
index e89628b..015fffa 100644
--- a/apps/frontend/components/documents/document-photo-gallery.tsx
+++ b/apps/frontend/components/documents/document-photo-gallery.tsx
@@ -4,6 +4,7 @@ import * as React from 'react';
import { Loader2, Trash2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { fetchDocumentPhotoUrl } from '@/lib/api';
+import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch';
import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
interface DocumentPhotoGalleryProps {
@@ -33,7 +34,7 @@ export function DocumentPhotoGallery({ userId, token, storageKeys, readOnly, onR
storageKeys.map(async (storageKey) => {
try {
const response = await fetchDocumentPhotoUrl(userId, storageKey, token);
- return { storageKey, accessUrl: response.accessUrl };
+ return { storageKey, accessUrl: resolveBrowserMediaUrl(response.accessUrl) };
} catch {
return { storageKey, accessUrl: '' };
}
diff --git a/apps/frontend/components/notifications/realtime-provider.tsx b/apps/frontend/components/notifications/realtime-provider.tsx
index 7c15f6c..354d0e2 100644
--- a/apps/frontend/components/notifications/realtime-provider.tsx
+++ b/apps/frontend/components/notifications/realtime-provider.tsx
@@ -5,6 +5,9 @@ import { useAuth } from '@/components/id/auth-provider';
import { ensureApiGatewayReady, fetchUnreadNotificationCount, getWsUrl, type ChatMessage } from '@/lib/api';
import { dispatchAvatarUpdated, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events';
+const WS_RECONNECT_BASE_MS = 2_000;
+const WS_RECONNECT_MAX_MS = 30_000;
+
export interface RealtimeEvent {
userId?: string;
type: string;
@@ -37,6 +40,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const listenersRef = React.useRef(new Set());
const socketRef = React.useRef(null);
const reconnectTimer = React.useRef | null>(null);
+ const reconnectAttemptRef = React.useRef(0);
const subscribe = React.useCallback((listener: Listener) => {
listenersRef.current.add(listener);
@@ -76,24 +80,35 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
let cancelled = false;
+ function scheduleReconnect() {
+ if (cancelled) return;
+ if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
+ const attempt = reconnectAttemptRef.current;
+ const delay = Math.min(WS_RECONNECT_MAX_MS, WS_RECONNECT_BASE_MS * 2 ** Math.min(attempt, 4));
+ reconnectAttemptRef.current = attempt + 1;
+ reconnectTimer.current = setTimeout(connect, delay);
+ }
+
async function connect() {
if (cancelled) return;
try {
await ensureApiGatewayReady();
} catch {
- // Пробуем подключиться даже если health не ответил в срок.
+ scheduleReconnect();
+ return;
}
if (cancelled) return;
const url = `${getWsUrl()}?token=${encodeURIComponent(token!)}`;
const socket = new WebSocket(url);
socketRef.current = socket;
- socket.onopen = () => setConnected(true);
+ socket.onopen = () => {
+ reconnectAttemptRef.current = 0;
+ setConnected(true);
+ };
socket.onclose = () => {
setConnected(false);
- if (!cancelled) {
- reconnectTimer.current = setTimeout(connect, 3000);
- }
+ scheduleReconnect();
};
socket.onerror = () => socket.close();
socket.onmessage = (event) => {
@@ -136,6 +151,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
return () => {
cancelled = true;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
+ reconnectAttemptRef.current = 0;
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
diff --git a/apps/frontend/lib/resilient-media-fetch.ts b/apps/frontend/lib/resilient-media-fetch.ts
index ca21f2c..1bef974 100644
--- a/apps/frontend/lib/resilient-media-fetch.ts
+++ b/apps/frontend/lib/resilient-media-fetch.ts
@@ -3,6 +3,8 @@ import { ensureApiGatewayReady } 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;
+const MEDIA_STREAM_PATH = '/media/stream/';
+const SAME_ORIGIN_API_PREFIX = '/idp-api';
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -14,6 +16,23 @@ function warnSlowMediaLoad(label: string, startedAt: number, error: unknown) {
console.warn(`[media] ${label}: не удалось загрузить за ${Math.round((Date.now() - startedAt) / 1000)} с (${detail})`);
}
+export function resolveBrowserMediaUrl(accessUrl: string): string {
+ if (typeof window === 'undefined') return accessUrl;
+
+ try {
+ const url = new URL(accessUrl, window.location.origin);
+ const streamIndex = url.pathname.indexOf(MEDIA_STREAM_PATH);
+ if (streamIndex === -1) {
+ return accessUrl;
+ }
+
+ const streamPath = url.pathname.slice(streamIndex);
+ return `${window.location.origin}${SAME_ORIGIN_API_PREFIX}${streamPath}${url.search}`;
+ } catch {
+ return accessUrl;
+ }
+}
+
export async function fetchMediaBlobWithRetry(
accessUrl: string,
token: string,
@@ -21,12 +40,13 @@ export async function fetchMediaBlobWithRetry(
): Promise {
const startedAt = Date.now();
const label = options?.label ?? 'изображение';
+ const mediaUrl = resolveBrowserMediaUrl(accessUrl);
let lastError: unknown;
for (let attempt = 0; attempt < MEDIA_FETCH_MAX_ATTEMPTS; attempt += 1) {
try {
await ensureApiGatewayReady();
- const response = await fetch(accessUrl, {
+ const response = await fetch(mediaUrl, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store'
});
diff --git a/install.sh b/install.sh
index a9b4a45..d01843b 100644
--- a/install.sh
+++ b/install.sh
@@ -2177,7 +2177,7 @@ render_proxy_idp_ws() {
local up
up="$(nginx_upstream ws)"
if [[ "$NGINX_MODE" == "docker" ]]; then
- printf ' set $idp_up_ws %s;\n rewrite ^/idp-api/ws/?(.*)$ /ws/$1 break;\n proxy_pass $idp_up_ws;' "$up"
+ printf ' set $idp_up_ws %s;\n rewrite ^/idp-api/ws$ /ws break;\n rewrite ^/idp-api/ws/(.*)$ /ws/$1 break;\n proxy_pass $idp_up_ws;' "$up"
return 0
fi
printf ' proxy_pass %s/ws;' "$up"