From 933f7fb9e1dd74bf97f8732a327ee38e88bb16cc Mon Sep 17 00:00:00 2001 From: lendry Date: Thu, 25 Jun 2026 08:31:36 +0300 Subject: [PATCH] update --- apps/api-gateway/Dockerfile | 2 - .../src/controllers/profile.controller.ts | 23 +- apps/docs/Dockerfile | 2 - apps/docs/lib/api-endpoints.ts | 44 +- apps/docs/lib/docs-pages.ts | 105 +- apps/docs/lib/navigation.ts | 2 +- apps/frontend/Dockerfile | 2 - apps/frontend/app/data/page.tsx | 109 +- .../components/family/family-group-view.tsx | 72 +- apps/frontend/lib/api.ts | 34 +- apps/frontend/lib/system-settings-catalog.ts | 8 + apps/sso-core/Dockerfile | 2 - apps/sso-core/prisma/schema.prisma | 1 + apps/sso-core/src/app.module.ts | 4 +- .../src/domain/auth-grpc.controller.ts | 15 + apps/sso-core/src/domain/family.service.ts | 135 +- .../domain/maintenance-scheduler.service.ts | 53 + apps/sso-core/src/domain/profile.service.ts | 104 +- .../src/domain/system-settings.seed.ts | 5 + apps/sso-core/src/infra/minio.service.ts | 28 + apps/sso-core/src/infra/sms.service.ts | 1718 +++++++++++------ shared/proto/profile.proto | 23 + 22 files changed, 1871 insertions(+), 620 deletions(-) create mode 100644 apps/sso-core/src/domain/maintenance-scheduler.service.ts diff --git a/apps/api-gateway/Dockerfile b/apps/api-gateway/Dockerfile index e021d29..1882b7f 100644 --- a/apps/api-gateway/Dockerfile +++ b/apps/api-gateway/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker/dockerfile:1.4 - FROM node:24-alpine WORKDIR /app diff --git a/apps/api-gateway/src/controllers/profile.controller.ts b/apps/api-gateway/src/controllers/profile.controller.ts index 0493266..626fb59 100644 --- a/apps/api-gateway/src/controllers/profile.controller.ts +++ b/apps/api-gateway/src/controllers/profile.controller.ts @@ -115,14 +115,29 @@ export class ProfileController { @Post('self-delete') @ApiOperation({ - summary: 'Удалить свой профиль', - description: 'Мягко удаляет профиль: помечает аккаунт как удалённый, освобождает логин и контакты для повторной регистрации, сбрасывает роли и завершает все сессии.' + summary: 'Запросить удаление своего профиля', + description: + 'Планирует удаление аккаунта через период ожидания ACCOUNT_DELETE_GRACE_DAYS. До истечения срока пользователь может отменить удаление.' }) @ApiParam({ name: 'userId', description: 'ID пользователя' }) - @ApiResponse({ status: 201, description: 'Профиль удалён' }) + @ApiResponse({ status: 201, description: 'Удаление запланировано' }) async selfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { await this.assertSelfAccess(authorization, userId); - return this.core.profile.SoftDeleteProfile({ userId }); + return this.core.profile.RequestAccountDeletion({ userId }); + } + + @Post('self-delete/cancel') + @ApiOperation({ summary: 'Отменить запланированное удаление профиля' }) + async cancelSelfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { + await this.assertSelfAccess(authorization, userId); + return this.core.profile.CancelAccountDeletion({ userId }); + } + + @Get('self-delete/status') + @ApiOperation({ summary: 'Статус запланированного удаления профиля' }) + async selfDeleteStatus(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { + await this.assertSelfAccess(authorization, userId); + return this.core.profile.GetAccountDeletionStatus({ userId }); } } diff --git a/apps/docs/Dockerfile b/apps/docs/Dockerfile index 86f618f..3bbcfa8 100644 --- a/apps/docs/Dockerfile +++ b/apps/docs/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker/dockerfile:1.4 - FROM node:24-alpine WORKDIR /app diff --git a/apps/docs/lib/api-endpoints.ts b/apps/docs/lib/api-endpoints.ts index 2da840e..dd798aa 100644 --- a/apps/docs/lib/api-endpoints.ts +++ b/apps/docs/lib/api-endpoints.ts @@ -46,7 +46,26 @@ export const apiReference: ApiTagGroup[] = [ { method: 'PATCH', path: '/profile/users/{userId}/avatar', summary: 'Обновить аватар', auth: true }, { method: 'PATCH', path: '/profile/users/{userId}/contacts', summary: 'Обновить контакты', auth: true }, { method: 'POST', path: '/profile/users/{userId}/password', summary: 'Установить пароль', auth: true }, - { method: 'POST', path: '/profile/users/{userId}/self-delete', summary: 'Удалить свой профиль', auth: true } + { + method: 'POST', + path: '/profile/users/{userId}/self-delete', + summary: 'Запланировать удаление профиля', + description: 'Не удаляет аккаунт сразу. Запускает период ожидания ACCOUNT_DELETE_GRACE_DAYS (по умолчанию 30 дней).', + auth: true + }, + { + method: 'POST', + path: '/profile/users/{userId}/self-delete/cancel', + summary: 'Отменить запланированное удаление профиля', + auth: true + }, + { + method: 'GET', + path: '/profile/users/{userId}/self-delete/status', + summary: 'Статус запланированного удаления профиля', + description: 'Возвращает pending, deletionRequestedAt, effectiveAt и graceDays.', + auth: true + } ] }, { @@ -92,7 +111,28 @@ export const apiReference: ApiTagGroup[] = [ endpoints: [ { method: 'POST', path: '/family/groups', summary: 'Создать семейную группу', auth: true }, { method: 'GET', path: '/family/users/{userId}/groups', summary: 'Список семей пользователя', auth: true }, - { method: 'POST', path: '/family/groups/{groupId}/invites', summary: 'Пригласить участника', auth: true } + { method: 'GET', path: '/family/groups/{groupId}', summary: 'Получить семейную группу', auth: true }, + { method: 'PATCH', path: '/family/groups/{groupId}', summary: 'Обновить семейную группу (название)', auth: true }, + { + method: 'DELETE', + path: '/family/groups/{groupId}', + summary: 'Удалить семейную группу', + description: 'Только создатель семьи. Удаляет всех участников, приглашения, чаты, сообщения и медиа семьи.', + auth: true + }, + { method: 'POST', path: '/family/groups/{groupId}/members', summary: 'Добавить участника', auth: true }, + { + method: 'DELETE', + path: '/family/members/{memberId}', + summary: 'Исключить участника или выйти из семьи', + description: 'Создатель может удалить участника; участник может удалить себя («Выйти»). Владельца семьи удалить нельзя.', + auth: true + }, + { method: 'POST', path: '/family/groups/{groupId}/invites', summary: 'Пригласить участника', auth: true }, + { method: 'GET', path: '/family/groups/{groupId}/invite-search', summary: 'Поиск пользователей для приглашения', auth: true }, + { method: 'GET', path: '/family/invites', summary: 'Входящие приглашения', auth: true }, + { method: 'POST', path: '/family/invites/{inviteId}/respond', summary: 'Принять или отклонить приглашение', auth: true }, + { method: 'GET', path: '/family/groups/{groupId}/presence', summary: 'Онлайн-статус участников семьи', auth: true } ] }, { diff --git a/apps/docs/lib/docs-pages.ts b/apps/docs/lib/docs-pages.ts index 78bf1d4..9538b63 100644 --- a/apps/docs/lib/docs-pages.ts +++ b/apps/docs/lib/docs-pages.ts @@ -838,8 +838,8 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ }, { slug: 'sessions', - title: 'Сессии и PIN', - description: 'Управление устройствами, PIN-блокировка и отзыв сессий.', + title: 'Сессии, PIN и удаление аккаунта', + description: 'Управление устройствами, PIN-блокировка, отзыв сессий и отложенное удаление профиля.', sections: [ { id: 'totp', @@ -866,6 +866,46 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ { type: 'paragraph', text: 'PIN хранится как bcrypt hash. Таймаут блокировки читается из SystemSetting PIN_LOCK_TIMEOUT_MINUTES — значение не захардкожено во frontend.' + }, + { + type: 'list', + items: [ + 'PIN_DELETE_GRACE_MINUTES — задержка перед окончательным удалением PIN после запроса', + 'PIN_REQUIRE_ON_DELETE — требовать текущий PIN при запросе удаления защиты' + ] + } + ] + }, + { + id: 'account-deletion', + title: 'Удаление аккаунта', + blocks: [ + { + type: 'paragraph', + text: 'Пользователь может запланировать удаление профиля в личном кабинете: раздел «Данные» (/data) → «Удалить профиль». Аккаунт не удаляется мгновенно — начинается период ожидания, настраиваемый администратором.' + }, + { + type: 'table', + headers: ['Ключ SystemSetting', 'Описание', 'По умолчанию'], + rows: [ + ['ACCOUNT_DELETE_GRACE_DAYS', 'Через сколько дней после запроса окончательно удалить аккаунт', '30'] + ] + }, + { + type: 'list', + items: [ + 'POST /profile/users/{userId}/self-delete — запланировать удаление (только свой профиль)', + 'GET /profile/users/{userId}/self-delete/status — дата окончательного удаления и статус pending', + 'POST /profile/users/{userId}/self-delete/cancel — отменить запрос до истечения срока', + 'До истечения срока пользователь может входить и пользоваться сервисом как обычно', + 'На странице /data отображается предупреждение с датой удаления и кнопкой отмены' + ] + }, + { + type: 'callout', + variant: 'warning', + title: 'Что происходит при финализации', + text: 'Фоновый планировщик sso-core (каждые 5 минут) находит аккаунты с истёкшим сроком ожидания и выполняет soft-delete: анонимизация email/телефона/username, отзыв сессий, снятие ролей. Семьи, где пользователь — создатель, удаляются полностью (участники, чаты, медиа). Из остальных семей пользователь исключается.' } ] }, @@ -888,7 +928,7 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ { slug: 'family-chat', title: 'Семья и чат', - description: 'Семейные группы, приглашения, чат и realtime через WebSocket.', + description: 'Семейные группы, приглашения, чат, удаление семьи и realtime через WebSocket.', sections: [ { id: 'family', @@ -896,7 +936,61 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ blocks: [ { type: 'paragraph', - text: 'Пользователь создаёт семью, приглашает участников по email/телефону/логину. Лимиты (max family members) берутся из SystemSetting.' + text: 'Пользователь создаёт семью, приглашает участников по email/телефону/логину или через поиск в интерфейсе. Лимит участников задаётся в SystemSetting MAX_FAMILY_MEMBERS (по умолчанию 6). При создании семьи автоматически создаётся общий чат «Общий чат».' + }, + { + type: 'list', + items: [ + 'POST /family/groups — создать семью', + 'GET /family/users/{userId}/groups — список семей пользователя', + 'PATCH /family/groups/{groupId} — переименовать (только участники, название — создатель)', + 'POST /family/groups/{groupId}/invites — отправить приглашение', + 'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей для приглашения' + ] + } + ] + }, + { + id: 'family-leave', + title: 'Выход и исключение участников', + blocks: [ + { + type: 'paragraph', + text: 'Участник может выйти из семьи самостоятельно; создатель семьи может исключить любого участника, кроме себя. При выходе или исключении пользователь удаляется из всех чатов этой семьи.' + }, + { + type: 'list', + items: [ + 'DELETE /family/members/{memberId} — выход (если memberId свой) или исключение (если запрос от создателя семьи)', + 'Создателя семьи (role: owner) удалить через этот endpoint нельзя — только удалить всю семью целиком' + ] + } + ] + }, + { + id: 'family-delete', + title: 'Удаление семьи', + blocks: [ + { + type: 'paragraph', + text: 'Только создатель семьи может полностью удалить группу. В интерфейсе: страница семьи → «Участники семьи» → «Удалить семью». Действие необратимо.' + }, + { + type: 'list', + items: [ + 'DELETE /family/groups/{groupId} — удалить семью (только ownerId === текущий пользователь)', + 'Все участники исключаются из семьи', + 'Все приглашения (FamilyInvite) удаляются', + 'Все чаты семьи (ChatRoom), сообщения, опросы и голоса удаляются каскадом', + 'Медиа в MinIO (аватар семьи, аватары чатов, вложения сообщений) удаляются best-effort', + 'Остальным участникам отправляется уведомление family_group_deleted и событие WebSocket' + ] + }, + { + type: 'callout', + variant: 'warning', + title: 'Без передачи владения', + text: 'Перед удалением семьи нельзя «передать» роль создателя другому участнику — только полное удаление группы или выход участников по отдельности.' } ] }, @@ -909,7 +1003,8 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ items: [ 'REST: /chat/groups/{groupId}/rooms, /chat/rooms/{roomId}/messages', 'WebSocket: ws://localhost:8085/ws с JWT в query или заголовке', - 'Медиа чата: presigned upload + защищённый stream с Authorization' + 'Медиа чата: presigned upload + защищённый stream с Authorization', + 'События realtime: chat_message, chat_message_updated, chat_message_deleted, family_group_deleted' ] } ] diff --git a/apps/docs/lib/navigation.ts b/apps/docs/lib/navigation.ts index cb02b00..38bb2d0 100644 --- a/apps/docs/lib/navigation.ts +++ b/apps/docs/lib/navigation.ts @@ -11,7 +11,7 @@ export const docNavigation: DocNavItem[] = [ { slug: 'authentication', title: 'Аутентификация', group: 'Интеграция' }, { slug: 'oauth', title: 'OAuth 2.0', group: 'Интеграция' }, { slug: 'ldap', title: 'LDAP / LDAPS', group: 'Интеграция' }, - { slug: 'sessions', title: 'Сессии и PIN', group: 'Безопасность' }, + { slug: 'sessions', title: 'Сессии, PIN и удаление аккаунта', group: 'Безопасность' }, { slug: 'family-chat', title: 'Семья и чат', group: 'Функции' }, { slug: 'api-reference', title: 'Справочник API', group: 'Справочник' } ]; diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 662ea97..894b280 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker/dockerfile:1.4 - FROM node:24-alpine WORKDIR /app diff --git a/apps/frontend/app/data/page.tsx b/apps/frontend/app/data/page.tsx index f4de9d2..beaff5f 100644 --- a/apps/frontend/app/data/page.tsx +++ b/apps/frontend/app/data/page.tsx @@ -21,7 +21,17 @@ import { indexDocumentsByType, type DocumentTypeCode } from '@/lib/document-catalog'; -import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument, UserProfileResponse } from '@/lib/api'; +import { apiFetch, getApiErrorMessage, cancelAccountDeletion, fetchAccountDeletionStatus, requestAccountDeletion, type AccountDeletionStatus, UserDocument, UserProfileResponse } from '@/lib/api'; + +function formatDeletionDate(value: string) { + return new Intl.DateTimeFormat('ru-RU', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(new Date(value)); +} function Row({ icon: Icon, @@ -66,7 +76,7 @@ function Row({ export default function DataPage() { const router = useRouter(); - const { user, token, refreshProfile, logout } = useAuth(); + const { user, token, refreshProfile } = useAuth(); const { isReady, isPinLocked } = useRequireAuth(); const { showToast } = useToast(); const [displayName, setDisplayName] = useState(''); @@ -84,6 +94,8 @@ export default function DataPage() { const [activeDocumentType, setActiveDocumentType] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [deletionStatus, setDeletionStatus] = useState(null); + const [cancellingDeletion, setCancellingDeletion] = useState(false); const loadDocuments = useCallback(async () => { if (!user || !token || isPinLocked) return; @@ -124,6 +136,20 @@ export default function DataPage() { if (isReady && user && !isPinLocked) void loadDocuments(); }, [isPinLocked, isReady, loadDocuments, user]); + const loadDeletionStatus = useCallback(async () => { + if (!user || !token || isPinLocked) return; + try { + const status = await fetchAccountDeletionStatus(user.id, token); + setDeletionStatus(status); + } catch { + setDeletionStatus(null); + } + }, [isPinLocked, token, user]); + + useEffect(() => { + if (isReady && user && !isPinLocked) void loadDeletionStatus(); + }, [isPinLocked, isReady, loadDeletionStatus, user]); + const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]); function openDocument(type: DocumentTypeCode) { @@ -174,18 +200,37 @@ export default function DataPage() { if (!user || !token) return; setIsDeleting(true); try { - await softDeleteProfile(user.id, token); + const response = await requestAccountDeletion(user.id, token); + setDeletionStatus(response); setDeleteDialogOpen(false); - showToast('Профиль удалён'); - logout(); + showToast( + response.effectiveAt + ? `Удаление запланировано на ${formatDeletionDate(response.effectiveAt)}` + : 'Удаление аккаунта запланировано' + ); } catch (error) { - const message = getApiErrorMessage(error, 'Не удалось удалить профиль'); + const message = getApiErrorMessage(error, 'Не удалось запланировать удаление профиля'); if (message) showToast(message); } finally { setIsDeleting(false); } } + async function handleCancelDeletion() { + if (!user || !token) return; + setCancellingDeletion(true); + try { + await cancelAccountDeletion(user.id, token); + setDeletionStatus({ pending: false, graceDays: deletionStatus?.graceDays ?? 30 }); + showToast('Удаление аккаунта отменено'); + } catch (error) { + const message = getApiErrorMessage(error, 'Не удалось отменить удаление'); + if (message) showToast(message); + } finally { + setCancellingDeletion(false); + } + } + return (
@@ -282,15 +327,40 @@ export default function DataPage() {

Управление данными

-
- setDeleteDialogOpen(true)} - destructive - /> -
+ {deletionStatus?.pending && deletionStatus.effectiveAt ? ( +
+

Удаление аккаунта запланировано

+

+ Профиль будет окончательно удалён {formatDeletionDate(deletionStatus.effectiveAt)}. До этой даты вы + можете пользоваться сервисом или отменить удаление. +

+ +
+ ) : ( +
+ setDeleteDialogOpen(true)} + destructive + /> +
+ )}
{activeDocumentType && user ? ( @@ -313,9 +383,10 @@ export default function DataPage() { Удалить профиль?

- Ваш аккаунт будет помечен как удалённый. Вы сразу выйдете из системы и больше не сможете войти с текущими - данными. Почта, телефон и логин будут освобождены для новой регистрации. Административные роли будут сняты. - Запись в базе сохранится в архивном виде. + Аккаунт не удалится сразу. После подтверждения начнётся период ожидания (по умолчанию 30 дней — срок + задаётся в настройках администратора). По истечении срока профиль будет окончательно удалён: контакты и + логин освободятся, семьи и чаты будут удалены или покинууты, сессии завершены. До этого момента удаление + можно отменить на этой странице.

diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx index b533405..a352f92 100644 --- a/apps/frontend/components/family/family-group-view.tsx +++ b/apps/frontend/components/family/family-group-view.tsx @@ -45,6 +45,7 @@ import { ChatRoom, createChatRoom, deleteChatMessage, + deleteFamilyGroup, editChatMessage, FamilyGroup, FamilyInviteCandidate, @@ -221,6 +222,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const [editingMessageId, setEditingMessageId] = useState(null); const [editDraft, setEditDraft] = useState(''); const [presenceMembers, setPresenceMembers] = useState([]); + const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false); + const [deletingFamily, setDeletingFamily] = useState(false); const messagesEndRef = useRef(null); const readReceiptTimerRef = useRef | null>(null); @@ -350,9 +353,15 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { if (event.type === 'chat_read_receipt' && activeRoomId) { if (readReceiptTimerRef.current) clearTimeout(readReceiptTimerRef.current); readReceiptTimerRef.current = setTimeout(() => void loadMessages(activeRoomId), 400); + return; + } + + if (event.type === 'family_group_deleted' && payload?.groupId === groupId) { + showToast('Семья была удалена'); + router.push('/family'); } }); - }, [activeRoomId, appendMessage, loadGroup, loadMessages, subscribe]); + }, [activeRoomId, appendMessage, groupId, loadGroup, loadMessages, router, showToast, subscribe]); const loadChatMedia = useCallback( async (message: ChatMessage, force = false) => { @@ -508,6 +517,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { if (!token) return; try { await removeFamilyMember(memberId, token); + const removedMember = group?.members?.find((item) => item.id === memberId); + if (removedMember?.userId === user?.id) { + showToast('Вы вышли из семьи'); + router.push('/family'); + return; + } await loadGroup(); setMembersOpen(false); setFamilyMembersOpen(false); @@ -517,6 +532,22 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { } } + async function confirmDeleteFamily() { + if (!token) return; + setDeletingFamily(true); + try { + await deleteFamilyGroup(groupId, token); + setDeleteFamilyDialogOpen(false); + setFamilyMembersOpen(false); + showToast('Семья удалена'); + router.push('/family'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось удалить семью') ?? 'Ошибка'); + } finally { + setDeletingFamily(false); + } + } + async function saveRename() { if (!token || !renameValue.trim()) return; try { @@ -1268,6 +1299,45 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { Пригласить в семью + {isFamilyOwner ? ( + + ) : null} + + + + + + + Удалить семью «{group?.name}»? + +

+ Все участники будут исключены из семьи, все чаты и сообщения будут удалены без возможности + восстановления. Это действие нельзя отменить. +

+
+ + +
diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 3a79925..679c044 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -562,8 +562,36 @@ export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, return apiFetch(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token); } +export interface AccountDeletionStatus { + pending: boolean; + deletionRequestedAt?: string; + effectiveAt?: string; + graceDays: number; +} + +export async function requestAccountDeletion(userId: string, token?: string | null) { + return apiFetch( + `/profile/users/${userId}/self-delete`, + { method: 'POST' }, + token + ); +} + +export async function cancelAccountDeletion(userId: string, token?: string | null) { + return apiFetch<{ userId: string; cancelled: boolean }>( + `/profile/users/${userId}/self-delete/cancel`, + { method: 'POST' }, + token + ); +} + +export async function fetchAccountDeletionStatus(userId: string, token?: string | null) { + return apiFetch(`/profile/users/${userId}/self-delete/status`, {}, token); +} + +/** @deprecated Используйте requestAccountDeletion — удаление теперь отложенное */ export async function softDeleteProfile(userId: string, token?: string | null) { - return apiFetch<{ success: boolean }>(`/profile/users/${userId}/self-delete`, { method: 'POST' }, token); + return requestAccountDeletion(userId, token); } export interface UserAddress { @@ -726,6 +754,10 @@ export async function updateFamilyGroup(groupId: string, name: string, token?: s return apiFetch(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token); } +export async function deleteFamilyGroup(groupId: string, token?: string | null) { + return apiFetch<{ count: number }>(`/family/groups/${groupId}`, { method: 'DELETE' }, token); +} + export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId: string; target?: string }, token?: string | null) { return apiFetch(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token); } diff --git a/apps/frontend/lib/system-settings-catalog.ts b/apps/frontend/lib/system-settings-catalog.ts index 15cb105..e80d12d 100644 --- a/apps/frontend/lib/system-settings-catalog.ts +++ b/apps/frontend/lib/system-settings-catalog.ts @@ -32,6 +32,14 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [ { key: 'PIN_MAX_LENGTH', label: 'Макс. длина PIN', group: 'pin', type: 'number', unit: 'цифр' }, { key: 'OTP_EXPIRY_MINUTES', label: 'Срок жизни OTP', group: 'auth', type: 'number', unit: 'мин' }, { key: 'OTP_MAX_ATTEMPTS', label: 'Попыток ввода OTP', group: 'auth', type: 'number' }, + { + key: 'ACCOUNT_DELETE_GRACE_DAYS', + label: 'Отложенное удаление аккаунта', + group: 'auth', + type: 'number', + unit: 'дн', + hint: 'Срок ожидания перед окончательным удалением профиля после запроса пользователя' + }, { key: 'SESSION_REFRESH_DAYS', label: 'Срок refresh-токена', group: 'auth', type: 'number', unit: 'дн' }, { key: 'PASSWORD_MIN_LENGTH', label: 'Мин. длина пароля', group: 'auth', type: 'number', unit: 'симв' }, { key: 'REGISTRATION_ENABLED', label: 'Регистрация включена', group: 'auth', type: 'boolean' }, diff --git a/apps/sso-core/Dockerfile b/apps/sso-core/Dockerfile index 96dc8c2..72857fa 100644 --- a/apps/sso-core/Dockerfile +++ b/apps/sso-core/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker/dockerfile:1.4 - FROM node:24-alpine WORKDIR /app diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma index dbf04dd..f96c76d 100644 --- a/apps/sso-core/prisma/schema.prisma +++ b/apps/sso-core/prisma/schema.prisma @@ -53,6 +53,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? + deletionRequestedAt DateTime? pinCode PinCode? sessions Session[] devices Device[] diff --git a/apps/sso-core/src/app.module.ts b/apps/sso-core/src/app.module.ts index 6e30e65..de3092c 100644 --- a/apps/sso-core/src/app.module.ts +++ b/apps/sso-core/src/app.module.ts @@ -33,6 +33,7 @@ import { LdapClientService } from './infra/ldap-client.service'; import { MessagingService } from './infra/messaging.service'; import { SmsService } from './infra/sms.service'; import { TotpService } from './domain/totp.service'; +import { MaintenanceSchedulerService } from './domain/maintenance-scheduler.service'; @Module({ imports: [ @@ -70,7 +71,8 @@ import { TotpService } from './domain/totp.service'; MinioService, LdapClientService, MessagingService, - SmsService + SmsService, + MaintenanceSchedulerService ] }) export class AppModule {} diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index 13c2adf..3f0f346 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -547,6 +547,21 @@ export class AuthGrpcController { return this.profile.softDeleteProfile(command.userId); } + @GrpcMethod('ProfileService', 'RequestAccountDeletion') + requestAccountDeletion(command: { userId: string }) { + return this.profile.requestAccountDeletion(command.userId); + } + + @GrpcMethod('ProfileService', 'CancelAccountDeletion') + cancelAccountDeletion(command: { userId: string }) { + return this.profile.cancelAccountDeletion(command.userId); + } + + @GrpcMethod('ProfileService', 'GetAccountDeletionStatus') + getAccountDeletionStatus(command: { userId: string }) { + return this.profile.getAccountDeletionStatus(command.userId); + } + @GrpcMethod('DocumentsService', 'CreateDocument') createDocument(command: { userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) { return this.documents.create(command); diff --git a/apps/sso-core/src/domain/family.service.ts b/apps/sso-core/src/domain/family.service.ts index 318ac5c..9436d51 100644 --- a/apps/sso-core/src/domain/family.service.ts +++ b/apps/sso-core/src/domain/family.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../infra/prisma.service'; import { SettingsService } from './settings.service'; import { NotificationsService } from './notifications.service'; +import { MinioService } from '../infra/minio.service'; @@ -22,7 +23,9 @@ export class FamilyService { private readonly settings: SettingsService, - private readonly notifications: NotificationsService + private readonly notifications: NotificationsService, + + private readonly minio: MinioService ) {} @@ -158,12 +161,140 @@ export class FamilyService { this.assertOwner(group, requesterId); - await this.prisma.familyGroup.delete({ where: { id: groupId } }); + await this.destroyGroupWithCleanup(group); return { count: 1 }; } + async destroyOwnedGroupsForUser(userId: string) { + + const groups = await this.prisma.familyGroup.findMany({ + + where: { ownerId: userId }, + + include: { members: { include: { user: true } } } + + }); + + for (const group of groups) { + + await this.destroyGroupWithCleanup(group); + + } + + } + + async leaveAllMembershipsForUser(userId: string) { + + const memberships = await this.prisma.familyMember.findMany({ + + where: { userId, role: { not: 'owner' } } + + }); + + for (const member of memberships) { + + await this.removeMember(userId, member.id); + + } + + } + + private async destroyGroupWithCleanup(group: { + + id: string; + + ownerId: string; + + name: string; + + avatarStorageKey: string | null; + + members: Array<{ userId: string }>; + + }) { + + const rooms = await this.prisma.chatRoom.findMany({ + + where: { groupId: group.id }, + + include: { messages: { select: { storageKey: true } } } + + }); + + const storageKeys: string[] = []; + + if (group.avatarStorageKey) { + + storageKeys.push(group.avatarStorageKey); + + } + + for (const room of rooms) { + + if (room.avatarStorageKey) { + + storageKeys.push(room.avatarStorageKey); + + } + + for (const message of room.messages) { + + if (message.storageKey) { + + storageKeys.push(message.storageKey); + + } + + } + + } + + await this.minio.deleteObjects(storageKeys); + + await this.prisma.familyGroup.delete({ where: { id: group.id } }); + + for (const member of group.members) { + + await this.notifications.publishRealtime( + + member.userId, + + 'family_group_deleted', + + 'Семья удалена', + + `Семья «${group.name}» была удалена`, + + { groupId: group.id, groupName: group.name } + + ); + + if (member.userId === group.ownerId) { + + continue; + + } + + await this.notifications.create( + + member.userId, + + 'family_group_deleted', + + 'Семья удалена', + + `Семья «${group.name}» была удалена создателем`, + + { groupId: group.id, groupName: group.name } + + ); + + } + + } + async addMember(groupId: string, userId: string, role: string) { diff --git a/apps/sso-core/src/domain/maintenance-scheduler.service.ts b/apps/sso-core/src/domain/maintenance-scheduler.service.ts new file mode 100644 index 0000000..0c54dc1 --- /dev/null +++ b/apps/sso-core/src/domain/maintenance-scheduler.service.ts @@ -0,0 +1,53 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PinService } from './pin.service'; +import { ProfileService } from './profile.service'; + +const INTERVAL_MS = 5 * 60 * 1000; + +@Injectable() +export class MaintenanceSchedulerService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MaintenanceSchedulerService.name); + private timer?: NodeJS.Timeout; + + constructor( + private readonly pin: PinService, + private readonly profile: ProfileService + ) {} + + onModuleInit() { + this.timer = setInterval(() => { + void this.runMaintenance(); + }, INTERVAL_MS); + void this.runMaintenance(); + } + + onModuleDestroy() { + if (this.timer) { + clearInterval(this.timer); + } + } + + private async runMaintenance() { + try { + const pinResult = await this.pin.finalizeDuePinDeletions(); + if (pinResult.count > 0) { + this.logger.log(`Окончательно удалено PIN-кодов: ${pinResult.count}`); + } + } catch (error) { + this.logger.error( + `Ошибка финализации удаления PIN: ${error instanceof Error ? error.message : error}` + ); + } + + try { + const accountResult = await this.profile.finalizeDueAccountDeletions(); + if (accountResult.count > 0) { + this.logger.log(`Окончательно удалено аккаунтов: ${accountResult.count}`); + } + } catch (error) { + this.logger.error( + `Ошибка финализации удаления аккаунтов: ${error instanceof Error ? error.message : error}` + ); + } + } +} diff --git a/apps/sso-core/src/domain/profile.service.ts b/apps/sso-core/src/domain/profile.service.ts index 2a2a33c..a5d3174 100644 --- a/apps/sso-core/src/domain/profile.service.ts +++ b/apps/sso-core/src/domain/profile.service.ts @@ -7,6 +7,8 @@ import { SessionStatus, UserStatus } from '../generated/prisma/client'; import { PrismaService } from '../infra/prisma.service'; import { OtpService } from './otp.service'; import { TotpService } from './totp.service'; +import { SettingsService } from './settings.service'; +import { FamilyService } from './family.service'; @@ -74,7 +76,9 @@ export class ProfileService { constructor( private readonly prisma: PrismaService, private readonly otp: OtpService, - private readonly totp: TotpService + private readonly totp: TotpService, + private readonly settings: SettingsService, + private readonly family: FamilyService ) {} @@ -414,6 +418,96 @@ export class ProfileService { + async requestAccountDeletion(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('Пользователь не найден'); + } + if (user.status === UserStatus.DELETED || user.deletedAt) { + throw new BadRequestException('Профиль уже удалён'); + } + if (user.deletionRequestedAt) { + throw new BadRequestException('Удаление аккаунта уже запланировано'); + } + + const graceDays = await this.settings.getNumber('ACCOUNT_DELETE_GRACE_DAYS', 30); + const deletionRequestedAt = new Date(); + const effectiveAt = new Date(deletionRequestedAt.getTime() + graceDays * 24 * 60 * 60 * 1000); + + await this.prisma.user.update({ + where: { id: userId }, + data: { deletionRequestedAt } + }); + + return { + userId, + deletionRequestedAt: deletionRequestedAt.toISOString(), + effectiveAt: effectiveAt.toISOString(), + graceDays + }; + } + + async cancelAccountDeletion(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('Пользователь не найден'); + } + if (!user.deletionRequestedAt) { + throw new BadRequestException('Запрос на удаление аккаунта не найден'); + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { deletionRequestedAt: null } + }); + + return { userId, cancelled: true }; + } + + async getAccountDeletionStatus(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('Пользователь не найден'); + } + + const graceDays = await this.settings.getNumber('ACCOUNT_DELETE_GRACE_DAYS', 30); + if (!user.deletionRequestedAt) { + return { pending: false, graceDays }; + } + + const effectiveAt = new Date(user.deletionRequestedAt.getTime() + graceDays * 24 * 60 * 60 * 1000); + return { + pending: true, + deletionRequestedAt: user.deletionRequestedAt.toISOString(), + effectiveAt: effectiveAt.toISOString(), + graceDays + }; + } + + async finalizeDueAccountDeletions() { + const graceDays = await this.settings.getNumber('ACCOUNT_DELETE_GRACE_DAYS', 30); + const threshold = new Date(Date.now() - graceDays * 24 * 60 * 60 * 1000); + const due = await this.prisma.user.findMany({ + where: { + status: UserStatus.ACTIVE, + deletionRequestedAt: { not: null, lte: threshold } + }, + select: { id: true } + }); + + for (const user of due) { + await this.finalizeAccountDeletion(user.id); + } + + return { count: due.length }; + } + + async finalizeAccountDeletion(userId: string) { + await this.family.destroyOwnedGroupsForUser(userId); + await this.family.leaveAllMembershipsForUser(userId); + await this.softDeleteProfile(userId); + } + async softDeleteProfile(userId: string) { const user = await this.prisma.user.findUnique({ @@ -486,7 +580,9 @@ export class ProfileService { status: UserStatus.DELETED, - deletedAt: new Date() + deletedAt: new Date(), + + deletionRequestedAt: null } @@ -586,7 +682,9 @@ export class ProfileService { birthDate: user.birthDate?.toISOString().slice(0, 10) ?? null, - hasPassword: Boolean(user.passwordHash) + hasPassword: Boolean(user.passwordHash), + + deletionRequestedAt: (user as { deletionRequestedAt?: Date | null }).deletionRequestedAt?.toISOString() }; diff --git a/apps/sso-core/src/domain/system-settings.seed.ts b/apps/sso-core/src/domain/system-settings.seed.ts index e5686a0..10ad595 100644 --- a/apps/sso-core/src/domain/system-settings.seed.ts +++ b/apps/sso-core/src/domain/system-settings.seed.ts @@ -13,6 +13,11 @@ export const DEFAULT_SYSTEM_SETTINGS = [ { key: 'MAX_FAMILY_MEMBERS', value: '6', description: 'Максимальное количество участников в семейной группе' }, { key: 'OTP_EXPIRY_MINUTES', value: '10', description: 'Время жизни OTP-кода для входа (минуты)' }, { key: 'OTP_MAX_ATTEMPTS', value: '5', description: 'Максимальное количество попыток ввода OTP' }, + { + key: 'ACCOUNT_DELETE_GRACE_DAYS', + value: '30', + description: 'Через сколько дней после запроса окончательно удалить аккаунт пользователя' + }, { key: 'SESSION_REFRESH_DAYS', value: '30', description: 'Срок жизни refresh-токена (дни)' }, { key: 'REGISTRATION_ENABLED', value: 'true', description: 'Разрешить регистрацию новых пользователей' }, { key: 'AVATAR_URL_TTL_MINUTES', value: '15', description: 'Время жизни подписанной ссылки на аватар (минуты)' }, diff --git a/apps/sso-core/src/infra/minio.service.ts b/apps/sso-core/src/infra/minio.service.ts index 1b29c25..1de8c01 100644 --- a/apps/sso-core/src/infra/minio.service.ts +++ b/apps/sso-core/src/infra/minio.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CreateBucketCommand, + DeleteObjectsCommand, HeadBucketCommand, PutBucketCorsCommand, PutObjectCommand, @@ -163,4 +164,31 @@ export class MinioService implements OnModuleInit { getBucket() { return this.bucket; } + + async deleteObjects(keys: string[]) { + const uniqueKeys = [...new Set(keys.filter(Boolean))]; + if (!uniqueKeys.length) { + return; + } + + const chunkSize = 1000; + for (let index = 0; index < uniqueKeys.length; index += chunkSize) { + const chunk = uniqueKeys.slice(index, index + chunkSize); + try { + await this.internalClient.send( + new DeleteObjectsCommand({ + Bucket: this.bucket, + Delete: { + Objects: chunk.map((Key) => ({ Key })), + Quiet: true + } + }) + ); + } catch (error) { + this.logger.warn( + `Не удалось удалить объекты MinIO (${chunk.length} шт.): ${error instanceof Error ? error.message : error}` + ); + } + } + } } diff --git a/apps/sso-core/src/infra/sms.service.ts b/apps/sso-core/src/infra/sms.service.ts index 131d5d7..981673a 100644 --- a/apps/sso-core/src/infra/sms.service.ts +++ b/apps/sso-core/src/infra/sms.service.ts @@ -1,573 +1,1145 @@ -import { createHash } from 'node:crypto'; -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { SettingsService } from '../domain/settings.service'; - -interface ModemSessionTokens { - sesInfo: string; - tokInfo: string; -} - -interface ModemCredentials { - username: string; - password: string; -} - -@Injectable() -export class SmsService { - private readonly logger = new Logger(SmsService.name); - private readonly requestTimeoutMs = 15_000; - private readonly logTokenChunkSize = 60; - - constructor( - private readonly settings: SettingsService, - private readonly config: ConfigService - ) {} - - async sendOtp(phoneNumber: string, messageText: string): Promise { - let modemUrl = ''; - try { - modemUrl = await this.resolveModemUrl(); - const tokens = await this.fetchSessionTokens(modemUrl); - await this.postSms(modemUrl, tokens, phoneNumber, messageText); - this.logger.log(`SMS отправлено через Huawei HiLink на ${this.maskPhone(phoneNumber)}`); - return true; - } catch (error) { - const message = this.formatError(error, modemUrl); - this.logger.error(`Не удалось отправить SMS через Huawei HiLink: ${message}`); - return false; - } - } - - private async resolveModemUrl(): Promise { - const fromDb = (await this.settings.getValue('SMS_GATEWAY_URL', '')).trim(); - if (fromDb) { - return this.normalizeModemUrl(fromDb); - } - - const fromEnv = (this.config.get('HUAWEI_MODEM_URL') ?? '').trim(); - if (fromEnv) { - return this.normalizeModemUrl(fromEnv); - } - - return 'http://192.168.8.1'; - } - - private async resolveModemCredentials(): Promise { - const usernameFromDb = (await this.settings.getValue('SMS_GATEWAY_USERNAME', '')).trim(); - const passwordFromDb = (await this.settings.getValue('SMS_GATEWAY_PASSWORD', '')).trim(); - - const username = - usernameFromDb || - (this.config.get('HUAWEI_MODEM_USERNAME') ?? '').trim() || - 'admin'; - - const password = - passwordFromDb || (this.config.get('HUAWEI_MODEM_PASSWORD') ?? '').trim(); - - return { username, password }; - } - - private normalizeModemUrl(value: string): string { - const trimmed = value.trim().replace(/\/+$/, ''); - if (!/^https?:\/\//i.test(trimmed)) { - return `http://${trimmed}`; - } - return trimmed; - } - - private async fetchSessionTokens(modemUrl: string): Promise { - const initial = await this.requestSesTokInfo(modemUrl); - let sessionCookie = initial.sesInfo; - - this.logDebugBlock('SesTokInfo (initial) Cookie / SesInfo', sessionCookie); - this.logDebugBlock('SesTokInfo (initial) TokInfo', initial.tokInfo); - - const loginState = await this.resolveModemLoginState(modemUrl, sessionCookie); - - if (loginState.required) { - const credentials = await this.resolveModemCredentials(); - if (!credentials.password) { - throw new Error( - 'Модем Huawei требует авторизацию (код 100003). Укажите SMS_GATEWAY_USERNAME и SMS_GATEWAY_PASSWORD в настройках SMS или переменные HUAWEI_MODEM_USERNAME / HUAWEI_MODEM_PASSWORD' - ); - } - - this.logger.debug( - `Huawei HiLink: выполняется вход под пользователем ${credentials.username}` - ); - - const loggedIn = await this.loginToModem( - modemUrl, - sessionCookie, - initial.tokInfo, - credentials, - loginState.passwordType - ); - sessionCookie = loggedIn.sesInfo; - - this.logDebugBlock('Huawei login: новый Cookie / SesInfo', sessionCookie); - this.logDebugBlock('Huawei login: __RequestVerificationToken', loggedIn.tokInfo); - } - - const refreshed = await this.requestSesTokInfo(modemUrl, sessionCookie); - - this.logDebugBlock('SesTokInfo (refreshed) Cookie / SesInfo', refreshed.sesInfo); - this.logDebugBlock('SesTokInfo (refreshed) TokInfo for POST', refreshed.tokInfo); - - return { - sesInfo: refreshed.sesInfo, - tokInfo: refreshed.tokInfo - }; - } - - private async resolveModemLoginState( - modemUrl: string, - sessionCookie: string - ): Promise<{ required: boolean; passwordType: string }> { - const loginDisabled = await this.isModemLoginDisabled(modemUrl, sessionCookie); - if (loginDisabled) { - return { required: false, passwordType: '4' }; - } - - const { loggedIn, passwordType } = await this.fetchLoginState(modemUrl, sessionCookie); - return { required: !loggedIn, passwordType }; - } - - private async isModemLoginDisabled(modemUrl: string, sessionCookie: string): Promise { - try { - const response = await this.fetchWithTimeout(`${modemUrl}/config/global/config.xml`, { - method: 'GET', - headers: { - Cookie: sessionCookie, - Accept: '*/*', - 'X-Requested-With': 'XMLHttpRequest' - } - }); - - if (!response.ok) { - return false; - } - - const configXml = await response.text(); - const loginFlag = this.extractXmlValue(configXml, 'login'); - return loginFlag === '0'; - } catch { - return false; - } - } - - private async fetchLoginState( - modemUrl: string, - sessionCookie: string - ): Promise<{ loggedIn: boolean; passwordType: string; state: string | null }> { - const response = await this.fetchWithTimeout(`${modemUrl}/api/user/state-login`, { - method: 'GET', - headers: { - Cookie: sessionCookie, - Accept: '*/*', - 'X-Requested-With': 'XMLHttpRequest' - } - }); - - if (!response.ok) { - throw new Error(`state-login HTTP ${response.status}`); - } - - const xml = await response.text(); - this.logger.debug(`state-login raw XML:\n${this.formatMultilineForLog(xml, 120, false)}`); - - const state = this.extractXmlValue(xml, 'State'); - const passwordType = this.extractXmlValue(xml, 'password_type') ?? '4'; - const loggedIn = state === '0'; - - this.logger.debug( - `state-login: State=${state ?? 'unknown'}, loggedIn=${loggedIn}, password_type=${passwordType}` - ); - - return { loggedIn, passwordType, state }; - } - - private async loginToModem( - modemUrl: string, - sessionCookie: string, - verificationToken: string, - credentials: ModemCredentials, - passwordType: string - ): Promise { - const encodedPassword = this.encodeHuaweiPassword( - credentials.username, - credentials.password, - verificationToken - ); - - const body = [ - '', - '', - ` ${this.escapeXml(credentials.username)}`, - ` ${encodedPassword}`, - ` ${this.escapeXml(passwordType)}`, - '' - ].join('\n'); - - this.logDebugBlock('Huawei login POST Cookie', sessionCookie); - this.logDebugBlock('Huawei login POST __RequestVerificationToken', verificationToken); - console.log('[SmsService] Huawei login POST body:\n' + body); - - const response = await this.fetchWithTimeout(`${modemUrl}/api/user/login`, { - method: 'POST', - headers: { - Cookie: sessionCookie, - __RequestVerificationToken: verificationToken, - 'Content-Type': 'text/xml; charset=UTF-8', - Accept: '*/*', - 'X-Requested-With': 'XMLHttpRequest', - Origin: modemUrl, - Referer: `${modemUrl}/html/index.html` - }, - body - }); - - const responseText = await response.text(); - this.logger.debug(`login response:\n${this.formatMultilineForLog(responseText, 120, false)}`); - - const errorCode = this.extractXmlValue(responseText, 'code'); - if (errorCode && errorCode !== '0') { - const errorMessage = this.extractXmlValue(responseText, 'message'); - throw new Error( - errorMessage?.trim() || `login: код ошибки модема ${errorCode}` - ); - } - - if (!/OK/i.test(responseText) && !response.ok) { - throw new Error(`login HTTP ${response.status}: ${responseText.slice(0, 200).trim() || 'пустой ответ'}`); - } - - const newCookie = - this.extractSessionCookieFromHeaders(response) ?? - this.normalizeSesInfoCookie(this.extractXmlValue(responseText, 'SesInfo') ?? sessionCookie); - - const newToken = - this.extractVerificationTokenFromHeaders(response) ?? verificationToken; - - return { - sesInfo: newCookie, - tokInfo: newToken - }; - } - - private encodeHuaweiPassword(username: string, rawPassword: string, token: string): string { - const sha256Hex = (value: string) => - createHash('sha256').update(value, 'utf8').digest('hex'); - - const passwordHashB64 = Buffer.from(sha256Hex(rawPassword), 'utf8').toString('base64'); - const combined = username + passwordHashB64 + token; - return Buffer.from(sha256Hex(combined), 'utf8').toString('base64'); - } - - private async requestSesTokInfo(modemUrl: string, sessionCookie?: string): Promise { - const headers: Record = { - Accept: '*/*', - 'X-Requested-With': 'XMLHttpRequest' - }; - if (sessionCookie) { - headers.Cookie = sessionCookie; - } - - const response = await this.fetchWithTimeout(`${modemUrl}/api/webserver/SesTokInfo`, { - method: 'GET', - headers - }); - - if (!response.ok) { - throw new Error(`SesTokInfo HTTP ${response.status}`); - } - - const xml = await response.text(); - this.logger.debug( - `SesTokInfo raw XML (${sessionCookie ? 'with cookie' : 'initial'}):\n${this.formatMultilineForLog(xml, 120, false)}` - ); - this.logResponseHeaderTokens(response, 'SesTokInfo'); - - const sesInfoRaw = - this.extractSessionCookieFromHeaders(response) ?? - this.extractXmlValue(xml, 'SesInfo'); - - const tokInfoRaw = - this.extractVerificationTokenFromHeaders(response) ?? - this.extractXmlValue(xml, 'TokInfo'); - - if (!sesInfoRaw || !tokInfoRaw) { - throw new Error('Не удалось извлечь SesInfo/TokInfo из ответа модема'); - } - - const errorCode = this.extractXmlValue(xml, 'code'); - if (errorCode && errorCode !== '0') { - const errorMessage = this.extractXmlValue(xml, 'message'); - throw new Error(errorMessage?.trim() || `SesTokInfo: код ошибки модема ${errorCode}`); - } - - return { - sesInfo: this.normalizeSesInfoCookie(sesInfoRaw), - tokInfo: this.normalizeVerificationToken(tokInfoRaw) - }; - } - - private extractSessionCookieFromHeaders(response: Response): string | null { - const setCookies = this.readSetCookieHeaders(response); - for (const entry of setCookies) { - const match = entry.match(/SessionID=([^;]+)/i); - if (match?.[1]) { - return `SessionID=${this.sanitizeTokenValue(match[1])}`; - } - } - return null; - } - - private extractVerificationTokenFromHeaders(response: Response): string | null { - const headerToken = response.headers.get('__RequestVerificationToken'); - if (!headerToken) { - return null; - } - - const tokens = headerToken - .split('#') - .map((part) => this.sanitizeTokenValue(part)) - .filter(Boolean); - - return tokens[0] ?? null; - } - - private readSetCookieHeaders(response: Response): string[] { - if (typeof response.headers.getSetCookie === 'function') { - return response.headers.getSetCookie(); - } - - const single = response.headers.get('set-cookie'); - return single ? [single] : []; - } - - private logResponseHeaderTokens(response: Response, label: string) { - const verificationHeader = response.headers.get('__RequestVerificationToken'); - if (verificationHeader) { - this.logDebugBlock(`${label} response header __RequestVerificationToken`, verificationHeader); - } - - const setCookies = this.readSetCookieHeaders(response); - if (setCookies.length) { - this.logDebugBlock(`${label} response Set-Cookie`, setCookies.join('\n')); - } - } - - private normalizeSesInfoCookie(sesInfo: string): string { - const compact = this.sanitizeTokenValue(sesInfo); - if (!compact) { - throw new Error('SesInfo пустой'); - } - - // XML returns SessionID=YOUR_TOKEN — pass EXACTLY this string as Cookie. - if (/^SessionID=/i.test(compact)) { - return compact; - } - - if (compact.includes('=')) { - return compact; - } - - return `SessionID=${compact}`; - } - - private normalizeVerificationToken(tokInfo: string): string { - return this.sanitizeTokenValue(tokInfo); - } - - private sanitizeTokenValue(value: string): string { - return value.replace(/\s+/g, '').trim(); - } - - private async postSms( - modemUrl: string, - tokens: ModemSessionTokens, - phoneNumber: string, - messageText: string - ): Promise { - const phone = this.formatPhoneForModem(phoneNumber); - const content = messageText.trim(); - const body = [ - '', - '', - ' -1', - ' ', - ` ${this.escapeXml(phone)}`, - ' ', - ' ', - ` ${this.escapeXml(content)}`, - ' -1', - ' 1', - ' -1', - '' - ].join('\n'); - - const cookieHeader = tokens.sesInfo; - const verificationToken = tokens.tokInfo; - - this.logSendSmsDebug(modemUrl, cookieHeader, verificationToken, body); - - const response = await this.fetchWithTimeout(`${modemUrl}/api/sms/send-sms`, { - method: 'POST', - headers: { - Cookie: cookieHeader, - __RequestVerificationToken: verificationToken, - 'Content-Type': 'text/xml; charset=UTF-8', - Accept: '*/*', - 'X-Requested-With': 'XMLHttpRequest', - Origin: modemUrl, - Referer: `${modemUrl}/html/smsinbox.html` - }, - body - }); - - const responseText = await response.text(); - this.logger.debug(`send-sms response:\n${this.formatMultilineForLog(responseText, 120, false)}`); - - if (!response.ok) { - throw new Error(`send-sms HTTP ${response.status}: ${responseText.slice(0, 200).trim() || 'пустой ответ'}`); - } - - const errorCode = this.extractXmlValue(responseText, 'code'); - if (errorCode && errorCode !== '0') { - const errorMessage = this.extractXmlValue(responseText, 'message'); - throw new Error(errorMessage?.trim() || `send-sms: код ошибки модема ${errorCode}`); - } - - if (//i.test(responseText)) { - throw new Error('Модем вернул ошибку при отправке SMS'); - } - - if (!/OK/i.test(responseText) && !//i.test(responseText)) { - throw new Error(`Неожиданный ответ модема: ${responseText.slice(0, 200).trim() || 'пустой ответ'}`); - } - } - - private logSendSmsDebug(modemUrl: string, cookie: string, token: string, body: string) { - const cookieMeta = `[length=${cookie.length}, hasWhitespace=${/\s/.test(cookie)}]`; - const tokenMeta = `[length=${token.length}, hasWhitespace=${/\s/.test(token)}]`; - - const lines = [ - '[SmsService] Huawei send-sms debug', - `URL: ${modemUrl}/api/sms/send-sms`, - `Cookie (SesInfo, used as-is) ${cookieMeta}:`, - this.formatTokenForLog(cookie), - `__RequestVerificationToken (TokInfo) ${tokenMeta}:`, - this.formatTokenForLog(token), - 'XML body:', - body - ]; - - const output = lines.join('\n'); - console.log(output); - this.logger.debug(output); - } - - private logDebugBlock(label: string, value: string) { - const meta = `[length=${value.length}, hasWhitespace=${/\s/.test(value)}]`; - const output = `[SmsService] ${label} ${meta}:\n${this.formatTokenForLog(value)}`; - console.log(output); - this.logger.debug(output); - } - - private formatTokenForLog(value: string, chunkSize = this.logTokenChunkSize): string { - const normalized = this.sanitizeTokenValue(value); - return this.formatMultilineForLog(normalized, chunkSize, false); - } - - private formatMultilineForLog(value: string, chunkSize: number, trimValue: boolean): string { - const source = trimValue ? value.trim() : value; - if (!source) { - return '(empty)'; - } - - const chunks: string[] = []; - for (let index = 0; index < source.length; index += chunkSize) { - chunks.push(source.slice(index, index + chunkSize)); - } - return chunks.join('\n'); - } - - private async fetchWithTimeout(url: string, init: RequestInit): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Таймаут запроса к модему (${this.requestTimeoutMs / 1000} с)`); - } - throw error; - } finally { - clearTimeout(timer); - } - } - - private extractXmlValue(xml: string, tag: string): string | null { - const cdataMatch = xml.match(new RegExp(`<${tag}><\\/${tag}>`, 's')); - if (cdataMatch?.[1]) { - return cdataMatch[1].trim(); - } - - const plainMatch = xml.match(new RegExp(`<${tag}>(.*?)<\\/${tag}>`, 's')); - return plainMatch?.[1]?.trim() ?? null; - } - - private formatPhoneForModem(phoneNumber: string): string { - const digits = phoneNumber.replace(/\D/g, ''); - if (digits.length === 11 && digits.startsWith('8')) { - return `+7${digits.slice(1)}`; - } - if (digits.length === 10) { - return `+7${digits}`; - } - if (digits.startsWith('7')) { - return `+${digits}`; - } - return phoneNumber.startsWith('+') ? phoneNumber : `+${digits}`; - } - - private escapeXml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - private formatError(error: unknown, modemUrl: string): string { - if (!(error instanceof Error)) { - return 'Неизвестная ошибка'; - } - - const parts = [error.message?.trim()].filter(Boolean); - const cause = (error as Error & { cause?: unknown }).cause; - if (cause instanceof Error && cause.message.trim()) { - parts.push(cause.message.trim()); - } else if (typeof cause === 'object' && cause !== null && 'code' in cause) { - const code = String((cause as { code?: string }).code ?? ''); - if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EHOSTUNREACH') { - parts.push( - `Не удалось подключиться к ${modemUrl || 'модему'}. Проверьте IP в настройках SMS_GATEWAY_URL и доступность модема из контейнера sso-core` - ); - } else if (code) { - parts.push(code); - } - } - - return parts.join('. ') || 'Неизвестная ошибка'; - } - - private maskPhone(value: string): string { - const digits = value.replace(/\D/g, ''); - return `***${digits.slice(-4)}`; - } -} - \ No newline at end of file +import { createHash } from 'node:crypto'; + +import { Injectable, Logger } from '@nestjs/common'; + +import { ConfigService } from '@nestjs/config'; + +import { SettingsService } from '../domain/settings.service'; + + + +interface ModemSessionTokens { + + sesInfo: string; + + tokInfo: string; + +} + + + +interface ModemCredentials { + + username: string; + + password: string; + +} + + + +@Injectable() + +export class SmsService { + + private readonly logger = new Logger(SmsService.name); + + private readonly requestTimeoutMs = 15_000; + + private readonly logTokenChunkSize = 60; + + + + constructor( + + private readonly settings: SettingsService, + + private readonly config: ConfigService + + ) {} + + + + async sendOtp(phoneNumber: string, messageText: string): Promise { + + let modemUrl = ''; + + try { + + modemUrl = await this.resolveModemUrl(); + + const tokens = await this.fetchSessionTokens(modemUrl); + + await this.postSms(modemUrl, tokens, phoneNumber, messageText); + + this.logger.log(`SMS отправлено через Huawei HiLink на ${this.maskPhone(phoneNumber)}`); + + return true; + + } catch (error) { + + const message = this.formatError(error, modemUrl); + + this.logger.error(`Не удалось отправить SMS через Huawei HiLink: ${message}`); + + return false; + + } + + } + + + + private async resolveModemUrl(): Promise { + + const fromDb = (await this.settings.getValue('SMS_GATEWAY_URL', '')).trim(); + + if (fromDb) { + + return this.normalizeModemUrl(fromDb); + + } + + + + const fromEnv = (this.config.get('HUAWEI_MODEM_URL') ?? '').trim(); + + if (fromEnv) { + + return this.normalizeModemUrl(fromEnv); + + } + + + + return 'http://192.168.8.1'; + + } + + + + private async resolveModemCredentials(): Promise { + + const usernameFromDb = (await this.settings.getValue('SMS_GATEWAY_USERNAME', '')).trim(); + + const passwordFromDb = (await this.settings.getValue('SMS_GATEWAY_PASSWORD', '')).trim(); + + + + const username = + + usernameFromDb || + + (this.config.get('HUAWEI_MODEM_USERNAME') ?? '').trim() || + + 'admin'; + + + + const password = + + passwordFromDb || (this.config.get('HUAWEI_MODEM_PASSWORD') ?? '').trim(); + + + + return { username, password }; + + } + + + + private normalizeModemUrl(value: string): string { + + const trimmed = value.trim().replace(/\/+$/, ''); + + if (!/^https?:\/\//i.test(trimmed)) { + + return `http://${trimmed}`; + + } + + return trimmed; + + } + + + + private async fetchSessionTokens(modemUrl: string): Promise { + + const initial = await this.requestSesTokInfo(modemUrl); + + let sessionCookie = initial.sesInfo; + + + + this.logDebugBlock('SesTokInfo (initial) Cookie / SesInfo', sessionCookie); + + this.logDebugBlock('SesTokInfo (initial) TokInfo', initial.tokInfo); + + + + const loginState = await this.resolveModemLoginState(modemUrl, sessionCookie); + + + + if (loginState.required) { + + const credentials = await this.resolveModemCredentials(); + + if (!credentials.password) { + + throw new Error( + + 'Модем Huawei требует авторизацию (код 100003). Укажите SMS_GATEWAY_USERNAME и SMS_GATEWAY_PASSWORD в настройках SMS или переменные HUAWEI_MODEM_USERNAME / HUAWEI_MODEM_PASSWORD' + + ); + + } + + + + this.logger.debug( + + `Huawei HiLink: выполняется вход под пользователем ${credentials.username}` + + ); + + + + const loggedIn = await this.loginToModem( + + modemUrl, + + sessionCookie, + + initial.tokInfo, + + credentials, + + loginState.passwordType + + ); + + sessionCookie = loggedIn.sesInfo; + + + + this.logDebugBlock('Huawei login: новый Cookie / SesInfo', sessionCookie); + + this.logDebugBlock('Huawei login: __RequestVerificationToken', loggedIn.tokInfo); + + } + + + + const refreshed = await this.requestSesTokInfo(modemUrl, sessionCookie); + + + + this.logDebugBlock('SesTokInfo (refreshed) Cookie / SesInfo', refreshed.sesInfo); + + this.logDebugBlock('SesTokInfo (refreshed) TokInfo for POST', refreshed.tokInfo); + + + + return { + + sesInfo: refreshed.sesInfo, + + tokInfo: refreshed.tokInfo + + }; + + } + + + + private async resolveModemLoginState( + + modemUrl: string, + + sessionCookie: string + + ): Promise<{ required: boolean; passwordType: string }> { + + const loginDisabled = await this.isModemLoginDisabled(modemUrl, sessionCookie); + + if (loginDisabled) { + + return { required: false, passwordType: '4' }; + + } + + + + const { loggedIn, passwordType } = await this.fetchLoginState(modemUrl, sessionCookie); + + return { required: !loggedIn, passwordType }; + + } + + + + private async isModemLoginDisabled(modemUrl: string, sessionCookie: string): Promise { + + try { + + const response = await this.fetchWithTimeout(`${modemUrl}/config/global/config.xml`, { + + method: 'GET', + + headers: { + + Cookie: sessionCookie, + + Accept: '*/*', + + 'X-Requested-With': 'XMLHttpRequest' + + } + + }); + + + + if (!response.ok) { + + return false; + + } + + + + const configXml = await response.text(); + + const loginFlag = this.extractXmlValue(configXml, 'login'); + + return loginFlag === '0'; + + } catch { + + return false; + + } + + } + + + + private async fetchLoginState( + + modemUrl: string, + + sessionCookie: string + + ): Promise<{ loggedIn: boolean; passwordType: string; state: string | null }> { + + const response = await this.fetchWithTimeout(`${modemUrl}/api/user/state-login`, { + + method: 'GET', + + headers: { + + Cookie: sessionCookie, + + Accept: '*/*', + + 'X-Requested-With': 'XMLHttpRequest' + + } + + }); + + + + if (!response.ok) { + + throw new Error(`state-login HTTP ${response.status}`); + + } + + + + const xml = await response.text(); + + this.logger.debug(`state-login raw XML:\n${this.formatMultilineForLog(xml, 120, false)}`); + + + + const state = this.extractXmlValue(xml, 'State'); + + const passwordType = this.extractXmlValue(xml, 'password_type') ?? '4'; + + const loggedIn = state === '0'; + + + + this.logger.debug( + + `state-login: State=${state ?? 'unknown'}, loggedIn=${loggedIn}, password_type=${passwordType}` + + ); + + + + return { loggedIn, passwordType, state }; + + } + + + + private async loginToModem( + + modemUrl: string, + + sessionCookie: string, + + verificationToken: string, + + credentials: ModemCredentials, + + passwordType: string + + ): Promise { + + const encodedPassword = this.encodeHuaweiPassword( + + credentials.username, + + credentials.password, + + verificationToken + + ); + + + + const body = [ + + '', + + '', + + ` ${this.escapeXml(credentials.username)}`, + + ` ${encodedPassword}`, + + ` ${this.escapeXml(passwordType)}`, + + '' + + ].join('\n'); + + + + this.logDebugBlock('Huawei login POST Cookie', sessionCookie); + + this.logDebugBlock('Huawei login POST __RequestVerificationToken', verificationToken); + + console.log('[SmsService] Huawei login POST body:\n' + body); + + + + const response = await this.fetchWithTimeout(`${modemUrl}/api/user/login`, { + + method: 'POST', + + headers: { + + Cookie: sessionCookie, + + __RequestVerificationToken: verificationToken, + + 'Content-Type': 'text/xml; charset=UTF-8', + + Accept: '*/*', + + 'X-Requested-With': 'XMLHttpRequest', + + Origin: modemUrl, + + Referer: `${modemUrl}/html/index.html` + + }, + + body + + }); + + + + const responseText = await response.text(); + + this.logger.debug(`login response:\n${this.formatMultilineForLog(responseText, 120, false)}`); + + + + const errorCode = this.extractXmlValue(responseText, 'code'); + + if (errorCode && errorCode !== '0') { + + const errorMessage = this.extractXmlValue(responseText, 'message'); + + throw new Error( + + errorMessage?.trim() || `login: код ошибки модема ${errorCode}` + + ); + + } + + + + if (!/OK/i.test(responseText) && !response.ok) { + + throw new Error(`login HTTP ${response.status}: ${responseText.slice(0, 200).trim() || 'пустой ответ'}`); + + } + + + + const newCookie = + + this.extractSessionCookieFromHeaders(response) ?? + + this.normalizeSesInfoCookie(this.extractXmlValue(responseText, 'SesInfo') ?? sessionCookie); + + + + const newToken = + + this.extractVerificationTokenFromHeaders(response) ?? verificationToken; + + + + return { + + sesInfo: newCookie, + + tokInfo: newToken + + }; + + } + + + + private encodeHuaweiPassword(username: string, rawPassword: string, token: string): string { + + const sha256Hex = (value: string) => + + createHash('sha256').update(value, 'utf8').digest('hex'); + + + + const passwordHashB64 = Buffer.from(sha256Hex(rawPassword), 'utf8').toString('base64'); + + const combined = username + passwordHashB64 + token; + + return Buffer.from(sha256Hex(combined), 'utf8').toString('base64'); + + } + + + + private async requestSesTokInfo(modemUrl: string, sessionCookie?: string): Promise { + + const headers: Record = { + + Accept: '*/*', + + 'X-Requested-With': 'XMLHttpRequest' + + }; + + if (sessionCookie) { + + headers.Cookie = sessionCookie; + + } + + + + const response = await this.fetchWithTimeout(`${modemUrl}/api/webserver/SesTokInfo`, { + + method: 'GET', + + headers + + }); + + + + if (!response.ok) { + + throw new Error(`SesTokInfo HTTP ${response.status}`); + + } + + + + const xml = await response.text(); + + this.logger.debug( + + `SesTokInfo raw XML (${sessionCookie ? 'with cookie' : 'initial'}):\n${this.formatMultilineForLog(xml, 120, false)}` + + ); + + this.logResponseHeaderTokens(response, 'SesTokInfo'); + + + + const sesInfoRaw = + + this.extractSessionCookieFromHeaders(response) ?? + + this.extractXmlValue(xml, 'SesInfo'); + + + + const tokInfoRaw = + + this.extractVerificationTokenFromHeaders(response) ?? + + this.extractXmlValue(xml, 'TokInfo'); + + + + if (!sesInfoRaw || !tokInfoRaw) { + + throw new Error('Не удалось извлечь SesInfo/TokInfo из ответа модема'); + + } + + + + const errorCode = this.extractXmlValue(xml, 'code'); + + if (errorCode && errorCode !== '0') { + + const errorMessage = this.extractXmlValue(xml, 'message'); + + throw new Error(errorMessage?.trim() || `SesTokInfo: код ошибки модема ${errorCode}`); + + } + + + + return { + + sesInfo: this.normalizeSesInfoCookie(sesInfoRaw), + + tokInfo: this.normalizeVerificationToken(tokInfoRaw) + + }; + + } + + + + private extractSessionCookieFromHeaders(response: Response): string | null { + + const setCookies = this.readSetCookieHeaders(response); + + for (const entry of setCookies) { + + const match = entry.match(/SessionID=([^;]+)/i); + + if (match?.[1]) { + + return `SessionID=${this.sanitizeTokenValue(match[1])}`; + + } + + } + + return null; + + } + + + + private extractVerificationTokenFromHeaders(response: Response): string | null { + + const headerToken = response.headers.get('__RequestVerificationToken'); + + if (!headerToken) { + + return null; + + } + + + + const tokens = headerToken + + .split('#') + + .map((part) => this.sanitizeTokenValue(part)) + + .filter(Boolean); + + + + return tokens[0] ?? null; + + } + + + + private readSetCookieHeaders(response: Response): string[] { + + if (typeof response.headers.getSetCookie === 'function') { + + return response.headers.getSetCookie(); + + } + + + + const single = response.headers.get('set-cookie'); + + return single ? [single] : []; + + } + + + + private logResponseHeaderTokens(response: Response, label: string) { + + const verificationHeader = response.headers.get('__RequestVerificationToken'); + + if (verificationHeader) { + + this.logDebugBlock(`${label} response header __RequestVerificationToken`, verificationHeader); + + } + + + + const setCookies = this.readSetCookieHeaders(response); + + if (setCookies.length) { + + this.logDebugBlock(`${label} response Set-Cookie`, setCookies.join('\n')); + + } + + } + + + + private normalizeSesInfoCookie(sesInfo: string): string { + + const compact = this.sanitizeTokenValue(sesInfo); + + if (!compact) { + + throw new Error('SesInfo пустой'); + + } + + + + // XML returns SessionID=YOUR_TOKEN — pass EXACTLY this string as Cookie. + + if (/^SessionID=/i.test(compact)) { + + return compact; + + } + + + + if (compact.includes('=')) { + + return compact; + + } + + + + return `SessionID=${compact}`; + + } + + + + private normalizeVerificationToken(tokInfo: string): string { + + return this.sanitizeTokenValue(tokInfo); + + } + + + + private sanitizeTokenValue(value: string): string { + + return value.replace(/\s+/g, '').trim(); + + } + + + + private async postSms( + + modemUrl: string, + + tokens: ModemSessionTokens, + + phoneNumber: string, + + messageText: string + + ): Promise { + + const phone = this.formatPhoneForModem(phoneNumber); + + const content = messageText.trim(); + + const body = [ + + '', + + '', + + ' -1', + + ' ', + + ` ${this.escapeXml(phone)}`, + + ' ', + + ' ', + + ` ${this.escapeXml(content)}`, + + ' -1', + + ' 1', + + ' -1', + + '' + + ].join('\n'); + + + + const cookieHeader = tokens.sesInfo; + + const verificationToken = tokens.tokInfo; + + + + this.logSendSmsDebug(modemUrl, cookieHeader, verificationToken, body); + + + + const response = await this.fetchWithTimeout(`${modemUrl}/api/sms/send-sms`, { + + method: 'POST', + + headers: { + + Cookie: cookieHeader, + + __RequestVerificationToken: verificationToken, + + 'Content-Type': 'text/xml; charset=UTF-8', + + Accept: '*/*', + + 'X-Requested-With': 'XMLHttpRequest', + + Origin: modemUrl, + + Referer: `${modemUrl}/html/smsinbox.html` + + }, + + body + + }); + + + + const responseText = await response.text(); + + this.logger.debug(`send-sms response:\n${this.formatMultilineForLog(responseText, 120, false)}`); + + + + if (!response.ok) { + + throw new Error(`send-sms HTTP ${response.status}: ${responseText.slice(0, 200).trim() || 'пустой ответ'}`); + + } + + + + const errorCode = this.extractXmlValue(responseText, 'code'); + + if (errorCode && errorCode !== '0') { + + const errorMessage = this.extractXmlValue(responseText, 'message'); + + throw new Error(errorMessage?.trim() || `send-sms: код ошибки модема ${errorCode}`); + + } + + + + if (//i.test(responseText)) { + + throw new Error('Модем вернул ошибку при отправке SMS'); + + } + + + + if (!/OK/i.test(responseText) && !//i.test(responseText)) { + + throw new Error(`Неожиданный ответ модема: ${responseText.slice(0, 200).trim() || 'пустой ответ'}`); + + } + + } + + + + private logSendSmsDebug(modemUrl: string, cookie: string, token: string, body: string) { + + const cookieMeta = `[length=${cookie.length}, hasWhitespace=${/\s/.test(cookie)}]`; + + const tokenMeta = `[length=${token.length}, hasWhitespace=${/\s/.test(token)}]`; + + + + const lines = [ + + '[SmsService] Huawei send-sms debug', + + `URL: ${modemUrl}/api/sms/send-sms`, + + `Cookie (SesInfo, used as-is) ${cookieMeta}:`, + + this.formatTokenForLog(cookie), + + `__RequestVerificationToken (TokInfo) ${tokenMeta}:`, + + this.formatTokenForLog(token), + + 'XML body:', + + body + + ]; + + + + const output = lines.join('\n'); + + console.log(output); + + this.logger.debug(output); + + } + + + + private logDebugBlock(label: string, value: string) { + + const meta = `[length=${value.length}, hasWhitespace=${/\s/.test(value)}]`; + + const output = `[SmsService] ${label} ${meta}:\n${this.formatTokenForLog(value)}`; + + console.log(output); + + this.logger.debug(output); + + } + + + + private formatTokenForLog(value: string, chunkSize = this.logTokenChunkSize): string { + + const normalized = this.sanitizeTokenValue(value); + + return this.formatMultilineForLog(normalized, chunkSize, false); + + } + + + + private formatMultilineForLog(value: string, chunkSize: number, trimValue: boolean): string { + + const source = trimValue ? value.trim() : value; + + if (!source) { + + return '(empty)'; + + } + + + + const chunks: string[] = []; + + for (let index = 0; index < source.length; index += chunkSize) { + + chunks.push(source.slice(index, index + chunkSize)); + + } + + return chunks.join('\n'); + + } + + + + private async fetchWithTimeout(url: string, init: RequestInit): Promise { + + const controller = new AbortController(); + + const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); + + try { + + return await fetch(url, { ...init, signal: controller.signal }); + + } catch (error) { + + if (error instanceof Error && error.name === 'AbortError') { + + throw new Error(`Таймаут запроса к модему (${this.requestTimeoutMs / 1000} с)`); + + } + + throw error; + + } finally { + + clearTimeout(timer); + + } + + } + + + + private extractXmlValue(xml: string, tag: string): string | null { + + const cdataMatch = xml.match(new RegExp(`<${tag}><\\/${tag}>`, 's')); + + if (cdataMatch?.[1]) { + + return cdataMatch[1].trim(); + + } + + + + const plainMatch = xml.match(new RegExp(`<${tag}>(.*?)<\\/${tag}>`, 's')); + + return plainMatch?.[1]?.trim() ?? null; + + } + + + + private formatPhoneForModem(phoneNumber: string): string { + + const digits = phoneNumber.replace(/\D/g, ''); + + if (digits.length === 11 && digits.startsWith('8')) { + + return `+7${digits.slice(1)}`; + + } + + if (digits.length === 10) { + + return `+7${digits}`; + + } + + if (digits.startsWith('7')) { + + return `+${digits}`; + + } + + return phoneNumber.startsWith('+') ? phoneNumber : `+${digits}`; + + } + + + + private escapeXml(value: string): string { + + return value + + .replace(/&/g, '&') + + .replace(//g, '>') + + .replace(/"/g, '"') + + .replace(/'/g, '''); + + } + + + + private formatError(error: unknown, modemUrl: string): string { + + if (!(error instanceof Error)) { + + return 'Неизвестная ошибка'; + + } + + + + const parts = [error.message?.trim()].filter(Boolean); + + const cause = (error as Error & { cause?: unknown }).cause; + + if (cause instanceof Error && cause.message.trim()) { + + parts.push(cause.message.trim()); + + } else if (typeof cause === 'object' && cause !== null && 'code' in cause) { + + const code = String((cause as { code?: string }).code ?? ''); + + if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EHOSTUNREACH') { + + parts.push( + + `Не удалось подключиться к ${modemUrl || 'модему'}. Проверьте IP в настройках SMS_GATEWAY_URL и доступность модема из контейнера sso-core` + + ); + + } else if (code) { + + parts.push(code); + + } + + } + + + + return parts.join('. ') || 'Неизвестная ошибка'; + + } + + + + private maskPhone(value: string): string { + + const digits = value.replace(/\D/g, ''); + + return `***${digits.slice(-4)}`; + + } + +} + + diff --git a/shared/proto/profile.proto b/shared/proto/profile.proto index 0b8889d..df9c99c 100644 --- a/shared/proto/profile.proto +++ b/shared/proto/profile.proto @@ -12,6 +12,9 @@ service ProfileService { rpc ChangePassword (ChangePasswordRequest) returns (SetPasswordResponse); rpc RemovePassword (RemovePasswordRequest) returns (SetPasswordResponse); rpc SoftDeleteProfile (UserProfileRequest) returns (SoftDeleteProfileResponse); + rpc RequestAccountDeletion (UserProfileRequest) returns (AccountDeletionResponse); + rpc CancelAccountDeletion (UserProfileRequest) returns (AccountDeletionCancelResponse); + rpc GetAccountDeletionStatus (UserProfileRequest) returns (AccountDeletionStatusResponse); } message SetPasswordRequest { @@ -95,6 +98,26 @@ message ProfileResponse { optional string backupPhone = 13; optional string birthDate = 15; bool hasPassword = 16; + optional string deletionRequestedAt = 17; +} + +message AccountDeletionResponse { + string userId = 1; + string deletionRequestedAt = 2; + string effectiveAt = 3; + int32 graceDays = 4; +} + +message AccountDeletionCancelResponse { + string userId = 1; + bool cancelled = 2; +} + +message AccountDeletionStatusResponse { + bool pending = 1; + optional string deletionRequestedAt = 2; + optional string effectiveAt = 3; + int32 graceDays = 4; } message SoftDeleteProfileResponse {