diff --git a/apps/frontend/components/addresses/address-quick-section.tsx b/apps/frontend/components/addresses/address-quick-section.tsx index 5d6a1c4..f0769d1 100644 --- a/apps/frontend/components/addresses/address-quick-section.tsx +++ b/apps/frontend/components/addresses/address-quick-section.tsx @@ -44,6 +44,7 @@ export function AddressQuickSection({ isReady?: boolean; }) { const { user, token } = useAuth(); + const userId = user?.id; const { showToast } = useToast(); const [addresses, setAddresses] = useState([]); const [formOpen, setFormOpen] = useState(false); @@ -51,19 +52,19 @@ export function AddressQuickSection({ const [editingAddress, setEditingAddress] = useState(null); const loadAddresses = useCallback(async () => { - if (!user || !token || isPinLocked || !isReady) return; + if (!userId || !token || isPinLocked || !isReady) return; try { - const response = await fetchUserAddresses(user.id, token); + const response = await fetchUserAddresses(userId, token); setAddresses(response.addresses ?? []); } catch (error) { const message = getApiErrorMessage(error, 'Не удалось загрузить адреса'); if (message) showToast(message); } - }, [isPinLocked, isReady, showToast, token, user]); + }, [isPinLocked, isReady, showToast, token, userId]); useEffect(() => { - if (isReady && user && !isPinLocked) void loadAddresses(); - }, [isPinLocked, isReady, loadAddresses, user]); + if (isReady && userId && !isPinLocked) void loadAddresses(); + }, [isPinLocked, isReady, loadAddresses, userId]); const homeAddress = useMemo(() => getPrimaryAddress(addresses, 'HOME') as UserAddress | null, [addresses]); const workAddress = useMemo(() => getPrimaryAddress(addresses, 'WORK') as UserAddress | null, [addresses]); diff --git a/apps/frontend/components/family/mini-family-chat.tsx b/apps/frontend/components/family/mini-family-chat.tsx index 8df2c78..a8e8d9d 100644 --- a/apps/frontend/components/family/mini-family-chat.tsx +++ b/apps/frontend/components/family/mini-family-chat.tsx @@ -47,6 +47,7 @@ import { deleteChatRoom, editChatMessage, fetchBotChatMessages, + fetchChatMediaUrl, fetchChatMessages, fetchChatRooms, getApiErrorMessage, @@ -67,6 +68,7 @@ import { } from '@/lib/chat-message-utils'; import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media'; import { uploadChatMediaBatch } from '@/lib/chat-media-upload'; +import { fetchMediaBlobWithRetry } from '@/lib/resilient-media-fetch'; import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display'; import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils'; import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat'; @@ -85,6 +87,74 @@ function formatMessageTime(value: string) { return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value)); } +function MiniChatImageMessage({ message, token }: { message: ChatMessage; token: string }) { + const [src, setSrc] = useState(null); + const [failed, setFailed] = useState(false); + const metadata = parseMessageMetadata(message.metadataJson) as { fileName?: string; width?: number; height?: number }; + + useEffect(() => { + if (!message.storageKey) return; + let cancelled = false; + let objectUrl: string | null = null; + + setSrc(null); + setFailed(false); + + async function loadImage() { + try { + const { accessUrl } = await fetchChatMediaUrl(message.roomId, message.storageKey!, token, metadata.fileName); + const blob = await fetchMediaBlobWithRetry(accessUrl, token, { + mimeType: message.mimeType, + label: 'фото чата' + }); + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setSrc(objectUrl); + } catch { + if (!cancelled) setFailed(true); + } + } + + void loadImage(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [message.id, message.mimeType, message.roomId, message.storageKey, metadata.fileName, token]); + + const ratio = + metadata.width && metadata.height + ? Math.max(0.65, Math.min(1.35, metadata.height / metadata.width)) + : 0.72; + + if (failed) { + return ( +
+ + Фото недоступно +
+ ); + } + + if (!src) { + return ( +
+ +
+ ); + } + + return ( + {message.content?.trim() + ); +} + export function MiniFamilyChat() { const { user, token, isPinLocked, isContentReady } = useAuth(); const { showToast } = useToast(); @@ -1103,6 +1173,15 @@ export function MiniFamilyChat() { ); })} + ) : message.storageKey && message.type === 'IMAGE' && token ? ( +
+ + {visibleText?.trim() ? ( +
+ +
+ ) : null} +
) : message.storageKey ? (

{getMessagePreviewText(message, visibleText)}

) : isLargeEmoji ? ( diff --git a/apps/frontend/components/notifications/realtime-provider.tsx b/apps/frontend/components/notifications/realtime-provider.tsx index 4c6edaa..891342a 100644 --- a/apps/frontend/components/notifications/realtime-provider.tsx +++ b/apps/frontend/components/notifications/realtime-provider.tsx @@ -35,6 +35,7 @@ const RealtimeContext = React.createContext(null); export function RealtimeProvider({ children }: { children: React.ReactNode }) { const { user, token, isPinLocked, isLoading, isContentReady } = useAuth(); + const userId = user?.id; const [unreadCount, setUnreadCount] = React.useState(0); const [connected, setConnected] = React.useState(false); const listenersRef = React.useRef(new Set()); @@ -52,7 +53,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { }, []); React.useEffect(() => { - if (!user || !token || isPinLocked || isLoading || !isContentReady) { + if (!userId || !token || isPinLocked || isLoading || !isContentReady) { setUnreadCount(0); return; } @@ -68,10 +69,10 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { cancelled = true; window.clearTimeout(timer); }; - }, [isContentReady, isLoading, isPinLocked, token, user]); + }, [isContentReady, isLoading, isPinLocked, token, userId]); React.useEffect(() => { - if (!user || !token || isPinLocked || isLoading || !isContentReady) { + if (!userId || !token || isPinLocked || isLoading || !isContentReady) { socketRef.current?.close(); socketRef.current = null; setConnected(false); @@ -149,7 +150,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { socketRef.current = null; setConnected(false); }; - }, [emit, isContentReady, isLoading, isPinLocked, token, user]); + }, [emit, isContentReady, isLoading, isPinLocked, token, userId]); return ( diff --git a/apps/frontend/hooks/use-e2e-chat.ts b/apps/frontend/hooks/use-e2e-chat.ts index 6af361a..30453ba 100644 --- a/apps/frontend/hooks/use-e2e-chat.ts +++ b/apps/frontend/hooks/use-e2e-chat.ts @@ -11,6 +11,8 @@ import { type E2EMessagePayload } from '@/lib/e2e-chat'; +const publishedE2EKeys = new Set(); + export function resolveChatPeerUserId(room: ChatRoom | null | undefined, userId: string | undefined) { if (!room || !userId) return null; const fromMembers = room.members.find((member) => member.userId !== userId)?.userId; @@ -69,7 +71,10 @@ export function useE2EChat(options: { void (async () => { try { const { publicKey } = await ensureE2EKeyPair(options.userId!); + const cacheKey = `${options.userId}:${publicKey}`; + if (publishedE2EKeys.has(cacheKey)) return; await setUserE2EPublicKey(options.userId!, publicKey, options.token); + publishedE2EKeys.add(cacheKey); } catch { // фоновая инициализация E2E } diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 89fcf44..c44a792 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -925,6 +925,12 @@ export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, return apiFetch(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token); } +export async function fetchChatMediaUrl(roomId: string, storageKey: string, token?: string | null, fileName?: string) { + const query = new URLSearchParams({ storageKey }); + if (fileName) query.set('fileName', fileName); + return apiFetch(`/media/chat/${roomId}/media/url?${query.toString()}`, {}, token); +} + export interface AccountDeletionStatus { pending: boolean; deletionRequestedAt?: string; diff --git a/apps/sso-core/src/domain/auth.service.ts b/apps/sso-core/src/domain/auth.service.ts index 3b75469..af2d365 100644 --- a/apps/sso-core/src/domain/auth.service.ts +++ b/apps/sso-core/src/domain/auth.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, HttpException, HttpStatus, Injectable, Logger, OnModuleInit, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; @@ -30,7 +30,9 @@ const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60; type DeviceCommand = Pick; @Injectable() -export class AuthService { +export class AuthService implements OnModuleInit { + private readonly logger = new Logger(AuthService.name); + constructor( private readonly prisma: PrismaService, private readonly redis: RedisService, @@ -45,6 +47,10 @@ export class AuthService { private readonly rbac: RbacService ) {} + async onModuleInit() { + await this.repairMissingFirstHumanSuperAdmin(); + } + async register(command: RegisterCommand): Promise { if (!command.email && !command.phone) { throw new BadRequestException('Укажите почту или телефон'); @@ -62,7 +68,7 @@ export class AuthService { try { const user = await this.prisma.$transaction( async (tx) => { - const usersCount = await tx.user.count({ where: { deletedAt: null } }); + const grantSuperAdmin = await this.shouldGrantFirstHumanSuperAdmin(tx); return tx.user.create({ data: { email: command.email, @@ -70,7 +76,7 @@ export class AuthService { passwordHash, displayName: command.displayName, username: command.username, - isSuperAdmin: usersCount === 0 + isSuperAdmin: grantSuperAdmin } }); }, @@ -89,6 +95,79 @@ export class AuthService { } } + private async shouldGrantFirstHumanSuperAdmin(tx: Prisma.TransactionClient): Promise { + const [humanUsersCount, superAdminsCount] = await Promise.all([ + tx.user.count({ + where: { + deletedAt: null, + ...HUMAN_USER_WHERE + } + }), + tx.user.count({ + where: { + deletedAt: null, + isSuperAdmin: true, + ...HUMAN_USER_WHERE + } + }) + ]); + + return humanUsersCount === 0 && superAdminsCount === 0; + } + + private async repairMissingFirstHumanSuperAdmin(): Promise { + let lockAcquired = false; + + try { + lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000); + if (!lockAcquired) return; + + const promoted = await this.prisma.$transaction( + async (tx) => { + const superAdminsCount = await tx.user.count({ + where: { + deletedAt: null, + isSuperAdmin: true, + ...HUMAN_USER_WHERE + } + }); + + if (superAdminsCount > 0) return null; + + const firstHumanUser = await tx.user.findFirst({ + where: { + deletedAt: null, + ...HUMAN_USER_WHERE + }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + select: { id: true } + }); + + if (!firstHumanUser) return null; + + return tx.user.update({ + where: { id: firstHumanUser.id }, + data: { isSuperAdmin: true }, + select: { id: true } + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ); + + if (promoted) { + this.logger.warn(`Восстановлены права супер-администратора для первого пользователя: ${promoted.id}`); + } + } catch (error) { + this.logger.error( + `Не удалось проверить первого супер-администратора: ${error instanceof Error ? error.message : 'неизвестная ошибка'}` + ); + } finally { + if (lockAcquired) { + await this.redis.releaseLock(FIRST_ADMIN_LOCK); + } + } + } + async login(command: LoginCommand): Promise { const user = await this.prisma.user.findFirst({ where: { @@ -587,14 +666,14 @@ export class AuthService { try { const user = await this.prisma.$transaction( async (tx) => { - const usersCount = await tx.user.count({ where: { deletedAt: null } }); + const grantSuperAdmin = await this.shouldGrantFirstHumanSuperAdmin(tx); return tx.user.create({ data: { email: ldapResult.email ?? undefined, username: ldapResult.username, displayName: ldapResult.displayName, passwordHash: null, - isSuperAdmin: usersCount === 0, + isSuperAdmin: grantSuperAdmin, linkedAccounts: { create: { providerName: 'ldap', @@ -789,14 +868,14 @@ export class AuthService { try { const user = await this.prisma.$transaction( async (tx) => { - const usersCount = await tx.user.count({ where: { deletedAt: null } }); + const grantSuperAdmin = await this.shouldGrantFirstHumanSuperAdmin(tx); return tx.user.create({ data: { email: recipient.includes('@') ? recipient : undefined, phone: recipient.includes('@') ? undefined : recipient, passwordHash: null, displayName: recipient, - isSuperAdmin: usersCount === 0 + isSuperAdmin: grantSuperAdmin } }); },