From 995adeedd430bf338635a56b54b9fc1cb0f5fa2f Mon Sep 17 00:00:00 2001 From: lendry Date: Wed, 24 Jun 2026 14:37:15 +0300 Subject: [PATCH] first commit --- .cursorrules | 41 + .dockerignore | 12 + .env.example | 50 + .gitignore | 13 + .npmrc | 8 + apps/api-gateway/Dockerfile | 38 + apps/api-gateway/nest-cli.json | 8 + apps/api-gateway/package.json | 31 + apps/api-gateway/src/app.module.ts | 60 + apps/api-gateway/src/auth-token.ts | 14 + .../src/controllers/addresses.controller.ts | 68 + .../src/controllers/admin.controller.ts | 69 + .../controllers/advanced-auth.controller.ts | 42 + .../src/controllers/auth.controller.ts | 129 + .../src/controllers/chat.controller.ts | 142 + .../src/controllers/documents.controller.ts | 97 + .../src/controllers/family.controller.ts | 130 + .../src/controllers/health.controller.ts | 9 + .../src/controllers/media.controller.ts | 215 + .../controllers/notifications.controller.ts | 82 + .../src/controllers/oauth.controller.ts | 38 + .../src/controllers/otp.controller.ts | 26 + .../src/controllers/profile.controller.ts | 83 + .../controllers/public-settings.controller.ts | 21 + .../src/controllers/rbac.controller.ts | 103 + .../src/controllers/security.controller.ts | 111 + .../src/controllers/settings.controller.ts | 125 + apps/api-gateway/src/core-grpc.service.ts | 44 + .../src/decorators/current-admin.decorator.ts | 7 + apps/api-gateway/src/document-access.ts | 52 + apps/api-gateway/src/dto/addresses.dto.ts | 53 + apps/api-gateway/src/dto/admin.dto.ts | 60 + apps/api-gateway/src/dto/auth.dto.ts | 189 + apps/api-gateway/src/dto/chat.dto.ts | 68 + apps/api-gateway/src/dto/documents.dto.ts | 62 + apps/api-gateway/src/dto/identity.dto.ts | 140 + apps/api-gateway/src/dto/media.dto.ts | 40 + apps/api-gateway/src/dto/notifications.dto.ts | 1 + apps/api-gateway/src/dto/profile.dto.ts | 79 + apps/api-gateway/src/dto/rbac.dto.ts | 79 + apps/api-gateway/src/dto/security.dto.ts | 25 + apps/api-gateway/src/dto/settings.dto.ts | 69 + apps/api-gateway/src/grpc-exception.filter.ts | 44 + apps/api-gateway/src/guards/admin.guard.ts | 78 + apps/api-gateway/src/main.ts | 34 + .../src/media-content-disposition.ts | 5 + apps/api-gateway/src/session-auth.ts | 62 + apps/api-gateway/tsconfig.json | 10 + apps/docs/Dockerfile | 40 + apps/docs/app/api/public-settings/route.ts | 17 + apps/docs/app/docs/[slug]/page.tsx | 45 + apps/docs/app/docs/layout.tsx | 27 + apps/docs/app/docs/page.tsx | 5 + apps/docs/app/globals.css | 46 + apps/docs/app/layout.tsx | 37 + apps/docs/app/page.tsx | 5 + apps/docs/components/api-endpoint-card.tsx | 45 + apps/docs/components/auth-code-tabs.tsx | 8 + apps/docs/components/auth-ldap-code-tabs.tsx | 8 + apps/docs/components/code-block.tsx | 43 + apps/docs/components/code-example-tabs.tsx | 26 + apps/docs/components/doc-block-renderer.tsx | 78 + apps/docs/components/docs-home-content.tsx | 103 + apps/docs/components/docs-shell.tsx | 109 + apps/docs/components/oauth-code-tabs.tsx | 8 + apps/docs/components/ui/badge.tsx | 14 + apps/docs/components/ui/button.tsx | 41 + apps/docs/components/ui/card.tsx | 22 + apps/docs/components/ui/scroll-area.tsx | 31 + apps/docs/components/ui/tabs.tsx | 31 + apps/docs/lib/api-endpoints.ts | 116 + apps/docs/lib/api.ts | 59 + apps/docs/lib/auth-examples.ts | 494 + apps/docs/lib/code-example.ts | 6 + apps/docs/lib/docs-pages.ts | 615 + apps/docs/lib/navigation.ts | 31 + apps/docs/lib/oauth-examples.ts | 273 + apps/docs/lib/public-settings-cache.ts | 32 + apps/docs/lib/utils.ts | 6 + apps/docs/next-env.d.ts | 6 + apps/docs/next.config.ts | 5 + apps/docs/package.json | 31 + apps/docs/postcss.config.mjs | 7 + .../providers/public-settings-provider.tsx | 80 + apps/docs/providers/theme-provider.tsx | 51 + apps/docs/tsconfig.json | 20 + apps/frontend/Dockerfile | 42 + apps/frontend/app/admin/layout.tsx | 23 + apps/frontend/app/admin/oauth/page.tsx | 262 + apps/frontend/app/admin/page.tsx | 5 + apps/frontend/app/admin/rbac/page.tsx | 143 + apps/frontend/app/admin/settings/page.tsx | 219 + apps/frontend/app/admin/users/page.tsx | 323 + apps/frontend/app/auth/login/page.tsx | 365 + apps/frontend/app/auth/register/page.tsx | 82 + apps/frontend/app/data/page.tsx | 291 + apps/frontend/app/documents/page.tsx | 133 + apps/frontend/app/family/[groupId]/page.tsx | 14 + apps/frontend/app/family/page.tsx | 97 + apps/frontend/app/globals.css | 45 + apps/frontend/app/icon.tsx | 28 + apps/frontend/app/layout.tsx | 18 + apps/frontend/app/page.tsx | 137 + apps/frontend/app/providers.tsx | 20 + apps/frontend/app/security/page.tsx | 277 + apps/frontend/components.json | 19 + .../addresses/address-form-dialog.tsx | 299 + .../addresses/address-map-picker.tsx | 102 + .../addresses/address-quick-section.tsx | 154 + .../documents/document-form-dialog.tsx | 502 + .../documents/document-photo-gallery.tsx | 105 + .../documents/document-photo-viewer.tsx | 109 + .../documents/user-documents-dialog.tsx | 112 + .../components/family/family-group-view.tsx | 805 ++ apps/frontend/components/id/action-tile.tsx | 42 + apps/frontend/components/id/admin-badge.tsx | 24 + apps/frontend/components/id/admin-nav.tsx | 41 + apps/frontend/components/id/admin-shell.tsx | 47 + apps/frontend/components/id/auth-provider.tsx | 473 + apps/frontend/components/id/avatar-upload.tsx | 127 + apps/frontend/components/id/brand-logo.tsx | 43 + .../frontend/components/id/pin-lock-modal.tsx | 62 + apps/frontend/components/id/project-head.tsx | 18 + .../id/public-settings-provider.tsx | 64 + apps/frontend/components/id/shell.tsx | 20 + apps/frontend/components/id/sidebar.tsx | 57 + .../frontend/components/id/toast-provider.tsx | 58 + apps/frontend/components/id/user-menu.tsx | 125 + .../notifications/notification-bell.tsx | 233 + .../notifications/realtime-provider.tsx | 110 + apps/frontend/components/ui/avatar.tsx | 16 + apps/frontend/components/ui/button.tsx | 38 + apps/frontend/components/ui/card.tsx | 22 + apps/frontend/components/ui/command.tsx | 34 + apps/frontend/components/ui/dialog.tsx | 30 + apps/frontend/components/ui/dropdown-menu.tsx | 15 + apps/frontend/components/ui/form.tsx | 20 + apps/frontend/components/ui/input.tsx | 15 + apps/frontend/components/ui/otp-input.tsx | 91 + apps/frontend/components/ui/phone-input.tsx | 140 + apps/frontend/components/ui/popover.tsx | 20 + apps/frontend/components/ui/table.tsx | 26 + apps/frontend/components/ui/tabs.tsx | 23 + apps/frontend/components/ui/toast.tsx | 17 + apps/frontend/hooks/use-avatar-url.ts | 39 + apps/frontend/hooks/use-require-auth.ts | 25 + apps/frontend/lib/address-catalog.ts | 28 + apps/frontend/lib/api.ts | 582 + apps/frontend/lib/authenticated-media.ts | 41 + apps/frontend/lib/chat-media.ts | 115 + apps/frontend/lib/document-catalog.ts | 308 + apps/frontend/lib/geocoding.ts | 76 + apps/frontend/lib/system-settings-catalog.ts | 62 + apps/frontend/lib/utils.ts | 6 + apps/frontend/next-env.d.ts | 4 + apps/frontend/next.config.ts | 7 + apps/frontend/package.json | 42 + apps/frontend/postcss.config.mjs | 7 + apps/frontend/public/flags/by.svg | 1 + apps/frontend/public/flags/kz.svg | 1 + apps/frontend/public/flags/ru.svg | 1 + apps/frontend/tsconfig.json | 25 + apps/ldap-auth/Dockerfile | 20 + apps/ldap-auth/go.mod | 14 + apps/ldap-auth/go.sum | 36 + apps/ldap-auth/main.go | 604 + apps/media-ws/Dockerfile | 22 + apps/media-ws/go.mod | 15 + apps/media-ws/go.sum | 18 + apps/media-ws/main.go | 273 + apps/media-ws/media-ws | Bin 0 -> 10101065 bytes apps/sso-core | 1 + docker-compose.yml | 252 + install.sh | 1252 ++ package-lock.json | 11244 ++++++++++++++++ package.json | 22 + shared/proto/addresses.proto | 56 + shared/proto/admin.proto | 58 + shared/proto/auth.proto | 171 + shared/proto/chat.proto | 133 + shared/proto/documents.proto | 58 + shared/proto/identity.proto | 230 + shared/proto/media.proto | 106 + shared/proto/notifications.proto | 50 + shared/proto/profile.proto | 69 + shared/proto/rbac.proto | 129 + shared/proto/security.proto | 218 + tsconfig.base.json | 20 + 188 files changed, 28810 insertions(+) create mode 100644 .cursorrules create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 apps/api-gateway/Dockerfile create mode 100644 apps/api-gateway/nest-cli.json create mode 100644 apps/api-gateway/package.json create mode 100644 apps/api-gateway/src/app.module.ts create mode 100644 apps/api-gateway/src/auth-token.ts create mode 100644 apps/api-gateway/src/controllers/addresses.controller.ts create mode 100644 apps/api-gateway/src/controllers/admin.controller.ts create mode 100644 apps/api-gateway/src/controllers/advanced-auth.controller.ts create mode 100644 apps/api-gateway/src/controllers/auth.controller.ts create mode 100644 apps/api-gateway/src/controllers/chat.controller.ts create mode 100644 apps/api-gateway/src/controllers/documents.controller.ts create mode 100644 apps/api-gateway/src/controllers/family.controller.ts create mode 100644 apps/api-gateway/src/controllers/health.controller.ts create mode 100644 apps/api-gateway/src/controllers/media.controller.ts create mode 100644 apps/api-gateway/src/controllers/notifications.controller.ts create mode 100644 apps/api-gateway/src/controllers/oauth.controller.ts create mode 100644 apps/api-gateway/src/controllers/otp.controller.ts create mode 100644 apps/api-gateway/src/controllers/profile.controller.ts create mode 100644 apps/api-gateway/src/controllers/public-settings.controller.ts create mode 100644 apps/api-gateway/src/controllers/rbac.controller.ts create mode 100644 apps/api-gateway/src/controllers/security.controller.ts create mode 100644 apps/api-gateway/src/controllers/settings.controller.ts create mode 100644 apps/api-gateway/src/core-grpc.service.ts create mode 100644 apps/api-gateway/src/decorators/current-admin.decorator.ts create mode 100644 apps/api-gateway/src/document-access.ts create mode 100644 apps/api-gateway/src/dto/addresses.dto.ts create mode 100644 apps/api-gateway/src/dto/admin.dto.ts create mode 100644 apps/api-gateway/src/dto/auth.dto.ts create mode 100644 apps/api-gateway/src/dto/chat.dto.ts create mode 100644 apps/api-gateway/src/dto/documents.dto.ts create mode 100644 apps/api-gateway/src/dto/identity.dto.ts create mode 100644 apps/api-gateway/src/dto/media.dto.ts create mode 100644 apps/api-gateway/src/dto/notifications.dto.ts create mode 100644 apps/api-gateway/src/dto/profile.dto.ts create mode 100644 apps/api-gateway/src/dto/rbac.dto.ts create mode 100644 apps/api-gateway/src/dto/security.dto.ts create mode 100644 apps/api-gateway/src/dto/settings.dto.ts create mode 100644 apps/api-gateway/src/grpc-exception.filter.ts create mode 100644 apps/api-gateway/src/guards/admin.guard.ts create mode 100644 apps/api-gateway/src/main.ts create mode 100644 apps/api-gateway/src/media-content-disposition.ts create mode 100644 apps/api-gateway/src/session-auth.ts create mode 100644 apps/api-gateway/tsconfig.json create mode 100644 apps/docs/Dockerfile create mode 100644 apps/docs/app/api/public-settings/route.ts create mode 100644 apps/docs/app/docs/[slug]/page.tsx create mode 100644 apps/docs/app/docs/layout.tsx create mode 100644 apps/docs/app/docs/page.tsx create mode 100644 apps/docs/app/globals.css create mode 100644 apps/docs/app/layout.tsx create mode 100644 apps/docs/app/page.tsx create mode 100644 apps/docs/components/api-endpoint-card.tsx create mode 100644 apps/docs/components/auth-code-tabs.tsx create mode 100644 apps/docs/components/auth-ldap-code-tabs.tsx create mode 100644 apps/docs/components/code-block.tsx create mode 100644 apps/docs/components/code-example-tabs.tsx create mode 100644 apps/docs/components/doc-block-renderer.tsx create mode 100644 apps/docs/components/docs-home-content.tsx create mode 100644 apps/docs/components/docs-shell.tsx create mode 100644 apps/docs/components/oauth-code-tabs.tsx create mode 100644 apps/docs/components/ui/badge.tsx create mode 100644 apps/docs/components/ui/button.tsx create mode 100644 apps/docs/components/ui/card.tsx create mode 100644 apps/docs/components/ui/scroll-area.tsx create mode 100644 apps/docs/components/ui/tabs.tsx create mode 100644 apps/docs/lib/api-endpoints.ts create mode 100644 apps/docs/lib/api.ts create mode 100644 apps/docs/lib/auth-examples.ts create mode 100644 apps/docs/lib/code-example.ts create mode 100644 apps/docs/lib/docs-pages.ts create mode 100644 apps/docs/lib/navigation.ts create mode 100644 apps/docs/lib/oauth-examples.ts create mode 100644 apps/docs/lib/public-settings-cache.ts create mode 100644 apps/docs/lib/utils.ts create mode 100644 apps/docs/next-env.d.ts create mode 100644 apps/docs/next.config.ts create mode 100644 apps/docs/package.json create mode 100644 apps/docs/postcss.config.mjs create mode 100644 apps/docs/providers/public-settings-provider.tsx create mode 100644 apps/docs/providers/theme-provider.tsx create mode 100644 apps/docs/tsconfig.json create mode 100644 apps/frontend/Dockerfile create mode 100644 apps/frontend/app/admin/layout.tsx create mode 100644 apps/frontend/app/admin/oauth/page.tsx create mode 100644 apps/frontend/app/admin/page.tsx create mode 100644 apps/frontend/app/admin/rbac/page.tsx create mode 100644 apps/frontend/app/admin/settings/page.tsx create mode 100644 apps/frontend/app/admin/users/page.tsx create mode 100644 apps/frontend/app/auth/login/page.tsx create mode 100644 apps/frontend/app/auth/register/page.tsx create mode 100644 apps/frontend/app/data/page.tsx create mode 100644 apps/frontend/app/documents/page.tsx create mode 100644 apps/frontend/app/family/[groupId]/page.tsx create mode 100644 apps/frontend/app/family/page.tsx create mode 100644 apps/frontend/app/globals.css create mode 100644 apps/frontend/app/icon.tsx create mode 100644 apps/frontend/app/layout.tsx create mode 100644 apps/frontend/app/page.tsx create mode 100644 apps/frontend/app/providers.tsx create mode 100644 apps/frontend/app/security/page.tsx create mode 100644 apps/frontend/components.json create mode 100644 apps/frontend/components/addresses/address-form-dialog.tsx create mode 100644 apps/frontend/components/addresses/address-map-picker.tsx create mode 100644 apps/frontend/components/addresses/address-quick-section.tsx create mode 100644 apps/frontend/components/documents/document-form-dialog.tsx create mode 100644 apps/frontend/components/documents/document-photo-gallery.tsx create mode 100644 apps/frontend/components/documents/document-photo-viewer.tsx create mode 100644 apps/frontend/components/documents/user-documents-dialog.tsx create mode 100644 apps/frontend/components/family/family-group-view.tsx create mode 100644 apps/frontend/components/id/action-tile.tsx create mode 100644 apps/frontend/components/id/admin-badge.tsx create mode 100644 apps/frontend/components/id/admin-nav.tsx create mode 100644 apps/frontend/components/id/admin-shell.tsx create mode 100644 apps/frontend/components/id/auth-provider.tsx create mode 100644 apps/frontend/components/id/avatar-upload.tsx create mode 100644 apps/frontend/components/id/brand-logo.tsx create mode 100644 apps/frontend/components/id/pin-lock-modal.tsx create mode 100644 apps/frontend/components/id/project-head.tsx create mode 100644 apps/frontend/components/id/public-settings-provider.tsx create mode 100644 apps/frontend/components/id/shell.tsx create mode 100644 apps/frontend/components/id/sidebar.tsx create mode 100644 apps/frontend/components/id/toast-provider.tsx create mode 100644 apps/frontend/components/id/user-menu.tsx create mode 100644 apps/frontend/components/notifications/notification-bell.tsx create mode 100644 apps/frontend/components/notifications/realtime-provider.tsx create mode 100644 apps/frontend/components/ui/avatar.tsx create mode 100644 apps/frontend/components/ui/button.tsx create mode 100644 apps/frontend/components/ui/card.tsx create mode 100644 apps/frontend/components/ui/command.tsx create mode 100644 apps/frontend/components/ui/dialog.tsx create mode 100644 apps/frontend/components/ui/dropdown-menu.tsx create mode 100644 apps/frontend/components/ui/form.tsx create mode 100644 apps/frontend/components/ui/input.tsx create mode 100644 apps/frontend/components/ui/otp-input.tsx create mode 100644 apps/frontend/components/ui/phone-input.tsx create mode 100644 apps/frontend/components/ui/popover.tsx create mode 100644 apps/frontend/components/ui/table.tsx create mode 100644 apps/frontend/components/ui/tabs.tsx create mode 100644 apps/frontend/components/ui/toast.tsx create mode 100644 apps/frontend/hooks/use-avatar-url.ts create mode 100644 apps/frontend/hooks/use-require-auth.ts create mode 100644 apps/frontend/lib/address-catalog.ts create mode 100644 apps/frontend/lib/api.ts create mode 100644 apps/frontend/lib/authenticated-media.ts create mode 100644 apps/frontend/lib/chat-media.ts create mode 100644 apps/frontend/lib/document-catalog.ts create mode 100644 apps/frontend/lib/geocoding.ts create mode 100644 apps/frontend/lib/system-settings-catalog.ts create mode 100644 apps/frontend/lib/utils.ts create mode 100644 apps/frontend/next-env.d.ts create mode 100644 apps/frontend/next.config.ts create mode 100644 apps/frontend/package.json create mode 100644 apps/frontend/postcss.config.mjs create mode 100644 apps/frontend/public/flags/by.svg create mode 100644 apps/frontend/public/flags/kz.svg create mode 100644 apps/frontend/public/flags/ru.svg create mode 100644 apps/frontend/tsconfig.json create mode 100644 apps/ldap-auth/Dockerfile create mode 100644 apps/ldap-auth/go.mod create mode 100644 apps/ldap-auth/go.sum create mode 100644 apps/ldap-auth/main.go create mode 100644 apps/media-ws/Dockerfile create mode 100644 apps/media-ws/go.mod create mode 100644 apps/media-ws/go.sum create mode 100644 apps/media-ws/main.go create mode 100644 apps/media-ws/media-ws create mode 160000 apps/sso-core create mode 100644 docker-compose.yml create mode 100644 install.sh create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 shared/proto/addresses.proto create mode 100644 shared/proto/admin.proto create mode 100644 shared/proto/auth.proto create mode 100644 shared/proto/chat.proto create mode 100644 shared/proto/documents.proto create mode 100644 shared/proto/identity.proto create mode 100644 shared/proto/media.proto create mode 100644 shared/proto/notifications.proto create mode 100644 shared/proto/profile.proto create mode 100644 shared/proto/rbac.proto create mode 100644 shared/proto/security.proto create mode 100644 tsconfig.base.json diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..8ad19a7 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,41 @@ +# Project Context +You are building an Enterprise Identity Provider (IdP) ecosystem, heavily inspired by "Yandex ID". +Domain: id.lendry.ru +The project is a monorepo consisting of microservices. + +# Tech Stack & Constraints +- Backend (Core): NestJS, TypeScript, @nestjs/microservices. +- Backend (Media/WS): Golang (Gorilla WebSocket). +- Frontend & Docs: Next.js (App Router), React, Tailwind CSS. +- UI Library: STRICTLY `shadcn/ui` (latest) and `lucide-react` for icons. Do NOT use other UI libraries. +- Database & ORM: PostgreSQL, **Prisma v7.0.0+**. CRITICAL: Use strictly Prisma 7+ syntax. Do not use deprecated Prisma features. +- Infrastructure: Docker, Redis, RabbitMQ, gRPC (Protobuf). + +# Language & Localization (CRITICAL) +- The ENTIRE user-facing application, UI elements, tooltips, placeholders, and documentation MUST be in Russian. +- ALL backend error messages, validation messages (e.g., class-validator), logs, and exception throws MUST be in Russian (e.g., `throw new UnauthorizedException('Неверный пароль')`). +- Variable names, functions, database columns, and comments should remain in English. + +# Autonomous Execution Rules +- You are running in an autonomous Agent mode. +- ALWAYS write complete, production-ready code. NEVER leave `// TODO: implement later` or placeholder stubs. +- Whenever you finish a logical block or phase, use the terminal to verify your work (e.g., `npm run lint`, `npx tsc --noEmit`, `npx prisma validate`, `go build`). +- If a terminal command fails, read the error output carefully, fix the code, and run the command again. You have 3 attempts to self-correct before halting. +- Do NOT modify existing working code unless explicitly necessary to fix a bug or integrate a new feature. + +# Specific Business Logic Rules +- Super Admin Race Condition: The first registered user MUST become `isSuperAdmin = true`. Use Redis locks or Serializable PG transactions to prevent race conditions. +- PIN Code Logic: If `PinCode.isEnabled` is true, login yields a session with `pinVerified = false` (temporary state). User must enter PIN to unlock full session. +- NO HARDCODING: Global business rules (like session lock timeouts, max family members) must be dynamic. Fetch them from the `SystemSetting` database table via API, do not hardcode values in the frontend codebase. + +# ANTI-LAZINESS & UI/UX FIDELITY DIRECTIVES (CRITICAL) +- DO NOT TAKE SHORTCUTS: Never choose the "easy", "cheap", or "lazy" implementation path. If a complex UI component is required, build it properly. +- NO STATIC MOCKS: Do not build "Potemkin villages". Every button, tab, and form you create MUST have actual state management, onClick handlers, and API connections attached to it immediately. +- UNIFIED COMPONENTS: When a visual reference shows a complex, monolithic component (e.g., a flag dropdown integrated *inside* a masked phone input), you MUST build the custom unified version using wrappers and focus-within states. DO NOT fallback to placing disconnected standard components next to each other. +- PREMIUM FEEL: Always implement micro-interactions. Use input masking (e.g., `+7 (999) 000-00-00`), loading spinners inside buttons during API calls, smooth transitions, and proper empty/error states. +- COMPLETE THE JOB: Never leave `// TODO: implement later` comments. Write the full, production-ready logic right now. + +# HOLISTIC IMPLEMENTATION & VISUAL COMPLIANCE (CRITICAL) +- SYSTEMIC THINKING: If you modify a UI component or business logic flow (e.g., Auth/Login), you MUST actively check if parallel or related flows (e.g., Registration, Password Reset, Profile Edit) require the same updates. Never leave half the system outdated. +- EXPLICIT VISUALS: If instructions mention specific visual elements (e.g., "Unicode emoji flags", "specific icons"), you must implement them EXACTLY. Do not substitute with plain text labels (like "RU" instead of 🇷🇺) to save time. +- REUSABILITY: Never duplicate complex UI logic. Complex elements (like a masked phone input with a dropdown) MUST be extracted into standalone, reusable React components (e.g., `components/ui/phone-input.tsx`) and imported wherever needed. \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2e0ea56 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +**/node_modules +dist +**/dist +.next +**/.next +.git +.env +apps/sso-core/.env +coverage +*.log +*.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0731897 --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# Lendry ID — переменные для docker compose +# Скопируйте в .env или запустите: ./install.sh + +# Режим: local (порты) | production (Nginx + SSL) +INSTALL_MODE=local + +# Домены без протокола (install.sh подставит PUBLIC_* URL) +DOMAIN_API= +DOMAIN_FRONTEND= +DOMAIN_DOCS= +# Пусто = WebSocket на DOMAIN_API по пути /ws +DOMAIN_WS= +DOMAIN_MINIO= +DOMAIN_MINIO_CONSOLE= + +# Публичные URL (генерируются install.sh) +PUBLIC_API_URL=http://localhost:3000 +PUBLIC_FRONTEND_URL=http://localhost:3002 +PUBLIC_DOCS_URL=http://localhost:3003 +PUBLIC_WS_URL=ws://localhost:8085/ws + +# Nginx + Let's Encrypt +USE_NGINX_SSL=false +CERTBOT_EMAIL= + +# PostgreSQL +POSTGRES_USER=lendry +POSTGRES_PASSWORD=change-me-postgres +POSTGRES_DB=lendry_id + +# RabbitMQ +RABBITMQ_DEFAULT_USER=lendry +RABBITMQ_DEFAULT_PASS=change-me-rabbitmq + +# MinIO +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=change-me-minio +MINIO_BUCKET=lendry-id +MINIO_PUBLIC_ENDPOINT=localhost:9000 +MINIO_USE_SSL=false + +# JWT и шифрование данных (обязательно смените в production) +JWT_ACCESS_SECRET=change-me-access-secret +JWT_REFRESH_SECRET=change-me-refresh-secret +DATA_ENCRYPTION_KEY=change-me-data-encryption-key-32-chars-min + +# NPM registry (опционально) +NPM_REGISTRY=https://registry.npmjs.org + +COMPOSE_PROJECT_NAME=lendry-id diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a3bac1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules +dist +.next +.turbo +.env +.env.* +!.env.example +docker-compose.override.yml +.idp-install.json +coverage +generated +*.tsbuildinfo +apps/media-ws/media-ws.exe diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6ca2454 --- /dev/null +++ b/.npmrc @@ -0,0 +1,8 @@ +registry=https://registry.npmjs.org +fetch-retries=5 +fetch-retry-mintimeout=20000 +fetch-retry-maxtimeout=120000 +fetch-timeout=600000 +maxsockets=5 +audit=false +fund=false diff --git a/apps/api-gateway/Dockerfile b/apps/api-gateway/Dockerfile new file mode 100644 index 0000000..e021d29 --- /dev/null +++ b/apps/api-gateway/Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1.4 + +FROM node:24-alpine + +WORKDIR /app + +ARG NPM_REGISTRY=https://registry.npmjs.org + +COPY package.json package-lock.json .npmrc ./ +COPY apps/sso-core/package.json ./apps/sso-core/ +COPY apps/api-gateway/package.json ./apps/api-gateway/ +COPY apps/frontend/package.json ./apps/frontend/ +COPY apps/docs/package.json ./apps/docs/ + +RUN --mount=type=cache,target=/root/.npm \ + npm config set registry "${NPM_REGISTRY}" && \ + for i in 1 2 3 4 5; do \ + echo "npm ci: попытка $i/5" && \ + npm ci --no-audit --no-fund && exit 0; \ + echo "npm ci: ошибка сети, повтор через $((i * 15)) сек..."; \ + sleep $((i * 15)); \ + done; \ + echo "npm ci: все попытки исчерпаны"; \ + exit 1 + +COPY apps ./apps +COPY shared ./shared +COPY tsconfig.base.json ./ + +ENV PORT="3000" +ENV SSO_CORE_GRPC_URL="sso-core:50051" +ENV JWT_ACCESS_SECRET="docker-access-secret" + +RUN npm --workspace @lendry/api-gateway run build + +EXPOSE 3000 + +CMD ["node", "apps/api-gateway/dist/main.js"] diff --git a/apps/api-gateway/nest-cli.json b/apps/api-gateway/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api-gateway/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api-gateway/package.json b/apps/api-gateway/package.json new file mode 100644 index 0000000..1c5de41 --- /dev/null +++ b/apps/api-gateway/package.json @@ -0,0 +1,31 @@ +{ + "name": "@lendry/api-gateway", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1075.0", + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", + "@nestjs/common": "^11.1.9", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.1.9", + "@nestjs/jwt": "^11.0.2", + "@nestjs/microservices": "^11.1.9", + "@nestjs/platform-express": "^11.1.9", + "@nestjs/swagger": "^11.2.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.14", + "@types/node": "^24.10.1", + "typescript": "^5.9.3" + } +} diff --git a/apps/api-gateway/src/app.module.ts b/apps/api-gateway/src/app.module.ts new file mode 100644 index 0000000..5d658f5 --- /dev/null +++ b/apps/api-gateway/src/app.module.ts @@ -0,0 +1,60 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { join } from 'node:path'; +import { AdminController } from './controllers/admin.controller'; +import { AuthController } from './controllers/auth.controller'; +import { RbacController } from './controllers/rbac.controller'; +import { SecurityController } from './controllers/security.controller'; +import { SettingsController } from './controllers/settings.controller'; +import { HealthController } from './controllers/health.controller'; +import { PublicSettingsController } from './controllers/public-settings.controller'; +import { ProfileController } from './controllers/profile.controller'; +import { DocumentsController } from './controllers/documents.controller'; +import { AddressesController } from './controllers/addresses.controller'; +import { OAuthController } from './controllers/oauth.controller'; +import { AdvancedAuthController } from './controllers/advanced-auth.controller'; +import { FamilyController } from './controllers/family.controller'; +import { ChatController } from './controllers/chat.controller'; +import { NotificationsController } from './controllers/notifications.controller'; +import { MediaController } from './controllers/media.controller'; +import { CoreGrpcService } from './core-grpc.service'; +import { AdminGuard, SuperAdminGuard } from './guards/admin.guard'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + JwtModule.register({}), + ClientsModule.registerAsync([ + { + name: 'SSO_CORE', + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + transport: Transport.GRPC, + options: { + package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat'], + protoPath: [ + join(__dirname, '../../../shared/proto/auth.proto'), + join(__dirname, '../../../shared/proto/admin.proto'), + join(__dirname, '../../../shared/proto/rbac.proto'), + join(__dirname, '../../../shared/proto/security.proto'), + join(__dirname, '../../../shared/proto/profile.proto'), + join(__dirname, '../../../shared/proto/documents.proto'), + join(__dirname, '../../../shared/proto/addresses.proto'), + join(__dirname, '../../../shared/proto/identity.proto'), + join(__dirname, '../../../shared/proto/media.proto'), + join(__dirname, '../../../shared/proto/notifications.proto'), + join(__dirname, '../../../shared/proto/chat.proto') + ], + url: config.get('SSO_CORE_GRPC_URL', 'localhost:50051') + } + }) + } + ]) + ], + controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController], + providers: [CoreGrpcService, AdminGuard, SuperAdminGuard] +}) +export class AppModule {} diff --git a/apps/api-gateway/src/auth-token.ts b/apps/api-gateway/src/auth-token.ts new file mode 100644 index 0000000..13d01f1 --- /dev/null +++ b/apps/api-gateway/src/auth-token.ts @@ -0,0 +1,14 @@ +import { UnauthorizedException } from '@nestjs/common'; + +export function extractBearerToken(authorization?: string): string { + if (!authorization?.startsWith('Bearer ')) { + throw new UnauthorizedException('Не передан токен доступа'); + } + + const token = authorization.slice('Bearer '.length).trim(); + if (!token) { + throw new UnauthorizedException('Не передан токен доступа'); + } + + return token; +} diff --git a/apps/api-gateway/src/controllers/addresses.controller.ts b/apps/api-gateway/src/controllers/addresses.controller.ts new file mode 100644 index 0000000..206575b --- /dev/null +++ b/apps/api-gateway/src/controllers/addresses.controller.ts @@ -0,0 +1,68 @@ +import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Post } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { firstValueFrom } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { CoreGrpcService } from '../core-grpc.service'; +import { getAuthorizedUserId } from '../document-access'; +import { UpsertAddressDto } from '../dto/addresses.dto'; + +@ApiTags('Адреса') +@ApiBearerAuth() +@Controller('addresses/users/:userId') +export class AddressesController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + private async assertSelfAccess(authorization: string | undefined, userId: string) { + const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization); + if (requesterId !== userId) { + throw new ForbiddenException('Можно управлять только своими адресами'); + } + return requesterId; + } + + @Get() + @ApiOperation({ summary: 'Список адресов пользователя' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { + await this.assertSelfAccess(authorization, userId); + return firstValueFrom( + this.core.addresses.ListAddresses({ userId }).pipe( + map((response) => { + const payload = response as { addresses?: unknown[] }; + return { addresses: payload.addresses ?? [] }; + }) + ) + ); + } + + @Post() + @ApiOperation({ summary: 'Создать или обновить адрес' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: UpsertAddressDto }) + async upsert( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Body() dto: UpsertAddressDto + ) { + await this.assertSelfAccess(authorization, userId); + return firstValueFrom(this.core.addresses.UpsertAddress({ userId, ...dto })); + } + + @Delete(':addressId') + @ApiOperation({ summary: 'Удалить адрес' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'addressId', description: 'ID адреса' }) + @ApiResponse({ status: 200, description: 'Адрес удалён' }) + async delete( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Param('addressId') addressId: string + ) { + await this.assertSelfAccess(authorization, userId); + return firstValueFrom(this.core.addresses.DeleteAddress({ userId, addressId })); + } +} diff --git a/apps/api-gateway/src/controllers/admin.controller.ts b/apps/api-gateway/src/controllers/admin.controller.ts new file mode 100644 index 0000000..623d220 --- /dev/null +++ b/apps/api-gateway/src/controllers/admin.controller.ts @@ -0,0 +1,69 @@ +import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import { map } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; +import { CurrentAdmin } from '../decorators/current-admin.decorator'; +import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, UpdateUserDto } from '../dto/admin.dto'; +import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard'; + +@ApiTags('Администрирование') +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Controller('admin/users') +export class AdminController { + constructor(private readonly core: CoreGrpcService) {} + + @Get() + @ApiOperation({ summary: 'Список пользователей', description: 'Возвращает пользователей для административной таблицы.' }) + listUsers(@Query() query: ListUsersQueryDto, @CurrentAdmin() admin: AdminRequestUser) { + if (!admin.canViewUsers && !admin.canManageUsers) { + throw new ForbiddenException('Недостаточно прав для просмотра пользователей'); + } + return this.core.admin.ListUsers(query).pipe( + map((response) => { + const payload = response as { users?: Array<{ roles?: string[] }> }; + return { + users: (payload.users ?? []).map((user) => ({ + ...user, + roles: user.roles ?? [] + })) + }; + }) + ); + } + + @Patch(':userId') + @ApiOperation({ summary: 'Обновить профиль пользователя', description: 'Обновляет основные и резервные контакты пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: UpdateUserDto }) + updateUser(@Param('userId') userId: string, @Body() dto: UpdateUserDto, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageUsers'); + return this.core.admin.UpdateUserProfile({ userId, ...dto }); + } + + @Post(':userId/reset-password') + @ApiOperation({ summary: 'Сбросить пароль', description: 'Назначает пользователю новый пароль.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: ResetPasswordDto }) + resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageUsers'); + return this.core.admin.ResetPassword({ userId, newPassword: dto.newPassword }); + } + + @Post(':userId/suspend') + @ApiOperation({ summary: 'Заблокировать пользователя', description: 'Переводит пользователя в статус SUSPENDED и отзывает активные сессии.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + suspend(@Param('userId') userId: string, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageUsers'); + return this.core.admin.SuspendUser({ userId }); + } + + @Patch(':userId/super-admin') + @UseGuards(SuperAdminGuard) + @ApiOperation({ summary: 'Выдать или снять права супер-администратора', description: 'Доступно только супер-администратору.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: SetSuperAdminDto }) + setSuperAdmin(@Param('userId') userId: string, @Body() dto: SetSuperAdminDto, @CurrentAdmin() admin: AdminRequestUser) { + return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin }); + } +} diff --git a/apps/api-gateway/src/controllers/advanced-auth.controller.ts b/apps/api-gateway/src/controllers/advanced-auth.controller.ts new file mode 100644 index 0000000..e7fd904 --- /dev/null +++ b/apps/api-gateway/src/controllers/advanced-auth.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CoreGrpcService } from '../core-grpc.service'; +import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto'; + +@ApiTags('Биометрия и QR-вход') +@Controller('auth/advanced') +export class AdvancedAuthController { + constructor(private readonly core: CoreGrpcService) {} + + @Post('webauthn/register/challenge') + @ApiOperation({ summary: 'Challenge регистрации WebAuthn', description: 'Создает challenge для регистрации лица/отпечатка. Endpoint готов для подключения настоящего WebAuthn attestation.' }) + @ApiBody({ type: WebAuthnDto }) + @ApiResponse({ status: 201, description: 'Challenge создан' }) + webAuthnRegister(@Body() dto: WebAuthnDto) { + return this.core.advancedAuth.CreateWebAuthnRegistrationChallenge(dto); + } + + @Post('webauthn/login/challenge') + @ApiOperation({ summary: 'Challenge входа WebAuthn', description: 'Создает challenge для входа по лицу или отпечатку.' }) + @ApiBody({ type: WebAuthnDto }) + @ApiResponse({ status: 201, description: 'Challenge создан' }) + webAuthnLogin(@Body() dto: WebAuthnDto) { + return this.core.advancedAuth.CreateWebAuthnLoginChallenge(dto); + } + + @Post('qr/session') + @ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' }) + @ApiBody({ type: QrSessionDto }) + @ApiResponse({ status: 201, description: 'QR-сессия создана' }) + createQr(@Body() dto: QrSessionDto) { + return this.core.advancedAuth.CreateQrSession(dto); + } + + @Get('qr/session/:sessionId') + @ApiOperation({ summary: 'Проверить QR-сессию', description: 'Возвращает текущий статус QR-сессии: PENDING/CONFIRMED/EXPIRED.' }) + @ApiParam({ name: 'sessionId', description: 'ID QR-сессии' }) + @ApiResponse({ status: 200, description: 'Статус QR-сессии получен' }) + pollQr(@Param('sessionId') sessionId: string) { + return this.core.advancedAuth.PollQrSession({ sessionId }); + } +} diff --git a/apps/api-gateway/src/controllers/auth.controller.ts b/apps/api-gateway/src/controllers/auth.controller.ts new file mode 100644 index 0000000..f1b0ea5 --- /dev/null +++ b/apps/api-gateway/src/controllers/auth.controller.ts @@ -0,0 +1,129 @@ +import { Body, Controller, Get, Headers, Post } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { firstValueFrom } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; +import { IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto } from '../dto/auth.dto'; +import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth'; + +@ApiTags('Аутентификация') +@Controller('auth') +export class AuthController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + @Post('register') + @ApiOperation({ summary: 'Регистрация пользователя', description: 'Создает пользователя. Первый пользователь безопасно получает права super admin.' }) + @ApiBody({ type: RegisterDto }) + register(@Body() dto: RegisterDto) { + return this.core.auth.Register(dto); + } + + @Post('login') + @ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' }) + @ApiBody({ type: LoginDto }) + login(@Body() dto: LoginDto) { + return this.core.auth.Login(dto); + } + + @Post('identify') + @ApiOperation({ summary: 'Проверить способ входа', description: 'Identifier-first шаг: проверяет, существует ли пользователь, задан ли пароль и включен ли PIN.' }) + @ApiBody({ type: IdentifyDto }) + identify(@Body() dto: IdentifyDto) { + return this.core.auth.Identify(dto); + } + + @Post('otp/send') + @ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' }) + @ApiBody({ type: PasswordlessOtpDto }) + sendOtp(@Body() dto: PasswordlessOtpDto) { + return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel }); + } + + @Post('otp/verify') + @ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' }) + @ApiBody({ type: PasswordlessVerifyDto }) + verifyOtp(@Body() dto: PasswordlessVerifyDto) { + return this.core.auth.VerifyOtp(dto); + } + + @Post('login/password') + @ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' }) + @ApiBody({ type: PasswordLoginDto }) + loginWithPassword(@Body() dto: PasswordLoginDto) { + return this.core.auth.LoginWithPassword(dto); + } + + @Post('ldap/login') + @ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' }) + @ApiBody({ type: LdapLoginDto }) + loginWithLdap(@Body() dto: LdapLoginDto) { + return this.core.auth.LoginWithLdap(dto); + } + + @Post('pin/verify') + @ApiOperation({ summary: 'Подтвердить PIN-код', description: 'Разблокирует временную сессию и выдает JWT с pinVerified=true.' }) + @ApiBody({ type: VerifyPinDto }) + verifyPin(@Body() dto: VerifyPinDto) { + return this.core.auth.VerifyPin(dto); + } + + @Post('refresh') + @ApiOperation({ summary: 'Обновить access token', description: 'Обновляет JWT по refresh token. Если сессия заблокирована PIN-кодом, возвращает requiresPin=true без выхода из аккаунта.' }) + @ApiBody({ type: RefreshSessionDto }) + refresh(@Body() dto: RefreshSessionDto) { + return this.core.auth.RefreshSession(dto); + } + + @Get('session') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Состояние текущей сессии', + description: 'Возвращает профиль и флаг PIN-блокировки. Не выбрасывает пользователя из аккаунта при заблокированном PIN.' + }) + async session(@Headers('authorization') authorization?: string) { + const payload = await verifyAccessToken(this.jwt, authorization); + + if (!payload.sessionId) { + const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub })); + return { requiresPin: false, sessionId: null, pinVerified: true, user }; + } + + const validation = (await firstValueFrom( + this.core.auth.ValidateSession({ + userId: payload.sub, + sessionId: payload.sessionId, + touchActivity: false + }) + )) as { requiresPin: boolean; sessionId: string; pinVerified: boolean }; + + const user = await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub })); + + if (!validation.requiresPin) { + await firstValueFrom( + this.core.auth.ValidateSession({ + userId: payload.sub, + sessionId: payload.sessionId, + touchActivity: true + }) + ); + } + + return { + requiresPin: validation.requiresPin, + sessionId: validation.sessionId, + pinVerified: validation.pinVerified, + user + }; + } + + @Get('me') + @ApiBearerAuth() + @ApiOperation({ summary: 'Текущий пользователь', description: 'Возвращает профиль пользователя по access token.' }) + async me(@Headers('authorization') authorization?: string) { + const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization); + return firstValueFrom(this.core.auth.GetMe({ userId: payload.sub })); + } +} diff --git a/apps/api-gateway/src/controllers/chat.controller.ts b/apps/api-gateway/src/controllers/chat.controller.ts new file mode 100644 index 0000000..94018db --- /dev/null +++ b/apps/api-gateway/src/controllers/chat.controller.ts @@ -0,0 +1,142 @@ +import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { firstValueFrom } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { CoreGrpcService } from '../core-grpc.service'; +import { getAuthorizedUserId } from '../document-access'; +import { + CreateChatRoomDto, + SendChatMessageDto, + SetRoomNotificationsMutedDto, + UpdateChatRoomDto, + VotePollDto +} from '../dto/chat.dto'; + +@ApiTags('Чат') +@ApiBearerAuth() +@Controller('chat') +export class ChatController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + private async auth(authorization?: string) { + return getAuthorizedUserId(this.jwt, this.core, authorization); + } + + @Get('groups/:groupId/rooms') + @ApiOperation({ summary: 'Список чатов семьи' }) + async listRooms(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { + const userId = await this.auth(authorization); + return firstValueFrom( + this.core.chat.ListRooms({ userId, groupId }).pipe( + map((response) => { + const payload = response as { rooms?: unknown[] }; + return { rooms: payload.rooms ?? [] }; + }) + ) + ); + } + + @Post('groups/:groupId/rooms') + @ApiOperation({ summary: 'Создать групповой чат' }) + async createRoom( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @Body() dto: CreateChatRoomDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom( + this.core.chat.CreateRoom({ userId, groupId, name: dto.name, memberUserIds: dto.memberUserIds ?? [] }) + ); + } + + @Patch('rooms/:roomId') + @ApiOperation({ summary: 'Настройки чата' }) + async updateRoom( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Body() dto: UpdateChatRoomDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom( + this.core.chat.UpdateRoomSettings({ + userId, + roomId, + name: dto.name, + notificationsMuted: dto.notificationsMuted + }) + ); + } + + @Get('rooms/:roomId/messages') + @ApiOperation({ summary: 'Сообщения чата' }) + async listMessages( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Query('beforeMessageId') beforeMessageId?: string, + @Query('limit') limit?: string + ) { + const userId = await this.auth(authorization); + return firstValueFrom( + this.core.chat.ListMessages({ + userId, + roomId, + beforeMessageId, + limit: limit ? Number(limit) : 50 + }).pipe( + map((response) => { + const payload = response as { messages?: unknown[] }; + return { messages: payload.messages ?? [] }; + }) + ) + ); + } + + @Post('rooms/:roomId/messages') + @ApiOperation({ summary: 'Отправить сообщение' }) + async sendMessage( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Body() dto: SendChatMessageDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom( + this.core.chat.SendMessage({ + userId, + roomId, + type: dto.type, + content: dto.content, + replyToId: dto.replyToId, + storageKey: dto.storageKey, + mimeType: dto.mimeType, + metadataJson: dto.metadataJson, + poll: dto.poll + }) + ); + } + + @Post('messages/:messageId/vote') + @ApiOperation({ summary: 'Голос в опросе' }) + async votePoll( + @Headers('authorization') authorization: string | undefined, + @Param('messageId') messageId: string, + @Body() dto: VotePollDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom(this.core.chat.VotePoll({ userId, messageId, optionIds: dto.optionIds })); + } + + @Post('rooms/:roomId/mute') + @ApiOperation({ summary: 'Выключить/включить уведомления чата' }) + async setMuted( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Body() dto: SetRoomNotificationsMutedDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom(this.core.chat.SetRoomNotificationsMuted({ userId, roomId, muted: dto.muted })); + } +} diff --git a/apps/api-gateway/src/controllers/documents.controller.ts b/apps/api-gateway/src/controllers/documents.controller.ts new file mode 100644 index 0000000..e6a6061 --- /dev/null +++ b/apps/api-gateway/src/controllers/documents.controller.ts @@ -0,0 +1,97 @@ +import { Body, Controller, Delete, Get, Headers, Param, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { JwtService } from '@nestjs/jwt'; +import { firstValueFrom } from 'rxjs'; +import { map } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; +import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access'; +import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto'; + +@ApiTags('Хранилище данных и документы') +@ApiBearerAuth() +@Controller('documents/users/:userId') +export class DocumentsController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + @Post() + @ApiOperation({ summary: 'Создать документ', description: 'Создает документ пользователя с данными формы. Поля шифруются в SSO Core.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: CreateDocumentDto }) + async create( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Body() dto: CreateDocumentDto + ) { + await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId); + return firstValueFrom( + this.core.documents.CreateDocument({ + userId, + type: dto.type, + number: dto.number ?? '—', + issuedAt: dto.issuedAt, + expiresAt: dto.expiresAt, + metadataJson: dto.metadataJson + }) + ); + } + + @Get() + @ApiOperation({ summary: 'Список документов', description: 'Возвращает все документы пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { + await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId); + return firstValueFrom( + this.core.documents.ListDocuments({ userId }).pipe( + map((response) => { + const payload = response as { documents?: unknown[] }; + return { documents: payload.documents ?? [] }; + }) + ) + ); + } + + @Get(':documentId') + @ApiOperation({ summary: 'Получить документ', description: 'Возвращает один документ пользователя с расшифрованными полями.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'documentId', description: 'ID документа' }) + async get( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Param('documentId') documentId: string + ) { + await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId); + return firstValueFrom(this.core.documents.GetDocument({ userId, documentId })); + } + + @Patch(':documentId') + @ApiOperation({ summary: 'Обновить документ', description: 'Обновляет данные формы документа.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'documentId', description: 'ID документа' }) + @ApiBody({ type: UpdateDocumentDto }) + async update( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Param('documentId') documentId: string, + @Body() dto: UpdateDocumentDto + ) { + await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId); + return firstValueFrom(this.core.documents.UpdateDocument({ userId, documentId, ...dto })); + } + + @Delete(':documentId') + @ApiOperation({ summary: 'Удалить документ', description: 'Удаляет документ пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'documentId', description: 'ID документа' }) + @ApiResponse({ status: 200, description: 'Документ удалён' }) + async delete( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Param('documentId') documentId: string + ) { + await assertDocumentsWriteAccess(this.jwt, this.core, authorization, userId); + return firstValueFrom(this.core.documents.DeleteDocument({ userId, documentId })); + } +} diff --git a/apps/api-gateway/src/controllers/family.controller.ts b/apps/api-gateway/src/controllers/family.controller.ts new file mode 100644 index 0000000..04cd372 --- /dev/null +++ b/apps/api-gateway/src/controllers/family.controller.ts @@ -0,0 +1,130 @@ +import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import { firstValueFrom } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { CoreGrpcService } from '../core-grpc.service'; +import { getAuthorizedUserId } from '../document-access'; +import { AddFamilyMemberDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto'; + +@ApiTags('Семья') +@ApiBearerAuth() +@Controller('family') +export class FamilyController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + private async auth(authorization?: string) { + return getAuthorizedUserId(this.jwt, this.core, authorization); + } + + @Post('groups') + @ApiOperation({ summary: 'Создать семейную группу' }) + @ApiBody({ type: CreateFamilyGroupDto }) + async createGroup(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateFamilyGroupDto) { + const userId = await this.auth(authorization); + if (dto.ownerId !== userId) { + throw new ForbiddenException('Можно создавать семью только от своего имени'); + } + return firstValueFrom(this.core.family.CreateFamilyGroup(dto)); + } + + @Get('users/:userId/groups') + @ApiOperation({ summary: 'Список семей пользователя' }) + async listGroups(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { + const requesterId = await this.auth(authorization); + if (requesterId !== userId) { + throw new ForbiddenException('Можно просматривать только свои семьи'); + } + return firstValueFrom( + this.core.family.ListFamilyGroups({ userId }).pipe( + map((response) => { + const payload = response as { groups?: unknown[] }; + return { groups: payload.groups ?? [] }; + }) + ) + ); + } + + @Get('groups/:groupId') + @ApiOperation({ summary: 'Получить семейную группу' }) + async getGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { + const requesterId = await this.auth(authorization); + return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId })); + } + + @Patch('groups/:groupId') + @ApiOperation({ summary: 'Обновить семейную группу' }) + async updateGroup( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @Body() dto: UpdateFamilyGroupDto + ) { + const requesterId = await this.auth(authorization); + return firstValueFrom(this.core.family.UpdateFamilyGroup({ requesterId, groupId, name: dto.name })); + } + + @Delete('groups/:groupId') + @ApiOperation({ summary: 'Удалить семейную группу' }) + async deleteGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { + const requesterId = await this.auth(authorization); + return firstValueFrom(this.core.family.DeleteFamilyGroup({ requesterId, groupId })); + } + + @Post('groups/:groupId/members') + @ApiOperation({ summary: 'Добавить участника семьи' }) + async addMember( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @Body() dto: AddFamilyMemberDto + ) { + await this.auth(authorization); + return firstValueFrom(this.core.family.AddFamilyMember({ groupId, ...dto })); + } + + @Delete('members/:memberId') + @ApiOperation({ summary: 'Удалить участника семьи' }) + async removeMember(@Headers('authorization') authorization: string | undefined, @Param('memberId') memberId: string) { + const requesterId = await this.auth(authorization); + return firstValueFrom(this.core.family.RemoveFamilyMember({ requesterId, memberId })); + } + + @Post('groups/:groupId/invites') + @ApiOperation({ summary: 'Пригласить участника в семью' }) + async sendInvite( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @Body() dto: SendFamilyInviteDto + ) { + const requesterId = await this.auth(authorization); + return firstValueFrom(this.core.family.SendFamilyInvite({ requesterId, groupId, target: dto.target })); + } + + @Get('invites') + @ApiOperation({ summary: 'Список приглашений' }) + async listInvites(@Headers('authorization') authorization: string | undefined) { + const userId = await this.auth(authorization); + return firstValueFrom( + this.core.family.ListFamilyInvites({ userId }).pipe( + map((response) => { + const payload = response as { invites?: unknown[] }; + return { invites: payload.invites ?? [] }; + }) + ) + ); + } + + @Post('invites/:inviteId/respond') + @ApiOperation({ summary: 'Принять или отклонить приглашение' }) + async respondInvite( + @Headers('authorization') authorization: string | undefined, + @Param('inviteId') inviteId: string, + @Body() dto: RespondFamilyInviteDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom(this.core.family.RespondFamilyInvite({ userId, inviteId, accept: dto.accept })); + } +} + \ No newline at end of file diff --git a/apps/api-gateway/src/controllers/health.controller.ts b/apps/api-gateway/src/controllers/health.controller.ts new file mode 100644 index 0000000..0c56ac0 --- /dev/null +++ b/apps/api-gateway/src/controllers/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { status: 'ok', service: 'api-gateway' }; + } +} diff --git a/apps/api-gateway/src/controllers/media.controller.ts b/apps/api-gateway/src/controllers/media.controller.ts new file mode 100644 index 0000000..5ac3c10 --- /dev/null +++ b/apps/api-gateway/src/controllers/media.controller.ts @@ -0,0 +1,215 @@ +import { Body, Controller, Get, Headers, Param, Post, Query, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { JwtService } from '@nestjs/jwt'; +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { firstValueFrom } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; +import { getAuthorizedUserId } from '../document-access'; +import { AvatarUploadDto, ChatMediaUploadDto, ConfirmAvatarDto, DocumentPhotoUploadDto } from '../dto/media.dto'; +import { buildContentDisposition } from '../media-content-disposition'; + +type StreamResponse = { + setHeader: (key: string, value: string) => void; + status: (code: number) => { json: (body: unknown) => void }; +} & NodeJS.WritableStream; + +@ApiTags('Медиа') +@Controller('media') +export class MediaController { + private s3Client: S3Client | null = null; + + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + private getS3Client() { + if (!this.s3Client) { + const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost:9000'; + const useSsl = process.env.MINIO_USE_SSL === 'true'; + this.s3Client = new S3Client({ + endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`, + region: process.env.MINIO_REGION ?? 'us-east-1', + credentials: { + accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin', + secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin' + }, + forcePathStyle: true + }); + } + return this.s3Client; + } + + private async authUserId(authorization?: string) { + return getAuthorizedUserId(this.jwt, this.core, authorization); + } + + @Post('avatars/upload-url') + @ApiBearerAuth() + @ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' }) + @ApiBody({ type: AvatarUploadDto }) + async createAvatarUploadUrl(@Headers('authorization') authorization: string | undefined, @Body() dto: AvatarUploadDto) { + const userId = await this.authUserId(authorization); + return firstValueFrom(this.core.media.CreateAvatarUploadUrl({ userId, contentType: dto.contentType })); + } + + @Post('avatars/confirm') + @ApiBearerAuth() + @ApiOperation({ summary: 'Подтвердить загрузку аватара', description: 'Сохраняет ключ объекта MinIO как аватар пользователя.' }) + @ApiBody({ type: ConfirmAvatarDto }) + async confirmAvatar(@Headers('authorization') authorization: string | undefined, @Body() dto: ConfirmAvatarDto) { + const userId = await this.authUserId(authorization); + return firstValueFrom(this.core.media.ConfirmAvatar({ userId, storageKey: dto.storageKey })); + } + + @Get('avatars/:userId/url') + @ApiBearerAuth() + @ApiOperation({ summary: 'Получить временную ссылку на аватар', description: 'Возвращает ссылку, действующую 15 минут. Без авторизации ссылка не выдаётся.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + async getAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom(this.core.media.GetAvatarAccessUrl({ requesterId, targetUserId: userId })); + } + + @Post('documents/:documentId/photo/upload-url') + @ApiBearerAuth() + @ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' }) + @ApiBody({ type: DocumentPhotoUploadDto }) + async createDocumentPhotoUploadUrl( + @Headers('authorization') authorization: string | undefined, + @Param('documentId') documentId: string, + @Body() dto: DocumentPhotoUploadDto + ) { + const userId = await this.authUserId(authorization); + return firstValueFrom(this.core.media.CreateDocumentPhotoUploadUrl({ userId, documentId, contentType: dto.contentType })); + } + + @Get('users/:userId/documents/photo-url') + @ApiBearerAuth() + @ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' }) + @ApiParam({ name: 'userId', description: 'ID владельца документа' }) + @ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' }) + async getDocumentPhotoUrl( + @Headers('authorization') authorization: string | undefined, + @Param('userId') userId: string, + @Query('storageKey') storageKey: string + ) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom( + this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey }) + ); + } + + @Get('stream/:token') + @ApiOperation({ summary: 'Потоковая выдача медиа', description: 'Отдаёт файл по временному токену. Для файлов чата требуется Authorization. Токен действует 15 минут.' }) + @ApiParam({ name: 'token', description: 'Временный JWT-токен доступа' }) + @ApiResponse({ status: 403, description: 'Ссылка недействительна или истекла' }) + async stream( + @Param('token') token: string, + @Headers('authorization') authorization: string | undefined, + @Query('download') download: string | undefined, + @Res({ passthrough: false }) response: StreamResponse + ) { + let requesterId: string | undefined; + if (authorization) { + try { + requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization); + } catch { + requesterId = undefined; + } + } + + const resolved = await firstValueFrom(this.core.media.ResolveStreamToken({ token, requesterId })); + const payload = resolved as { storageKey: string; contentType: string; fileName?: string }; + const bucket = process.env.MINIO_BUCKET ?? 'lendry-id'; + const object = await this.getS3Client().send( + new GetObjectCommand({ + Bucket: bucket, + Key: payload.storageKey + }) + ); + + response.setHeader('Content-Type', payload.contentType); + response.setHeader('Cache-Control', 'private, no-store'); + if (payload.fileName) { + const asAttachment = download === '1' || download === 'true'; + response.setHeader( + 'Content-Disposition', + buildContentDisposition(asAttachment ? 'attachment' : 'inline', payload.fileName) + ); + } + const body = object.Body; + if (!body) { + response.status(404).json({ message: 'Файл не найден' }); + return; + } + + const stream = body as NodeJS.ReadableStream; + stream.pipe(response); + } + + @Post('families/:groupId/avatar/upload-url') + @ApiBearerAuth() + async createFamilyAvatarUploadUrl( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @Body() dto: AvatarUploadDto + ) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom( + this.core.media.CreateFamilyAvatarUploadUrl({ requesterId, groupId, contentType: dto.contentType }) + ); + } + + @Post('families/:groupId/avatar/confirm') + @ApiBearerAuth() + async confirmFamilyAvatar( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @Body() dto: ConfirmAvatarDto + ) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom( + this.core.media.ConfirmFamilyAvatar({ requesterId, groupId, storageKey: dto.storageKey }) + ); + } + + @Get('families/:groupId/avatar/url') + @ApiBearerAuth() + async getFamilyAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom(this.core.media.GetFamilyAvatarAccessUrl({ requesterId, groupId })); + } + + @Post('chat/:roomId/media/upload-url') + @ApiBearerAuth() + async createChatMediaUploadUrl( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Body() dto: ChatMediaUploadDto + ) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom( + this.core.media.CreateChatMediaUploadUrl({ + requesterId, + roomId, + contentType: dto.contentType, + fileName: dto.fileName + }) + ); + } + + @Get('chat/:roomId/media/url') + @ApiBearerAuth() + async getChatMediaUrl( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Query('storageKey') storageKey: string, + @Query('fileName') fileName?: string + ) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom( + this.core.media.GetChatMediaAccessUrl({ requesterId, roomId, storageKey, fileName }) + ); + } +} diff --git a/apps/api-gateway/src/controllers/notifications.controller.ts b/apps/api-gateway/src/controllers/notifications.controller.ts new file mode 100644 index 0000000..d95142a --- /dev/null +++ b/apps/api-gateway/src/controllers/notifications.controller.ts @@ -0,0 +1,82 @@ +import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { firstValueFrom } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { CoreGrpcService } from '../core-grpc.service'; +import { getAuthorizedUserId } from '../document-access'; +import { MarkNotificationReadDto } from '../dto/notifications.dto'; + +@ApiTags('Уведомления') +@ApiBearerAuth() +@Controller('notifications') +export class NotificationsController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + @Get() + @ApiOperation({ summary: 'Список уведомлений' }) + async list( + @Headers('authorization') authorization: string | undefined, + @Query('limit') limit?: string, + @Query('unreadOnly') unreadOnly?: string + ) { + const userId = await getAuthorizedUserId(this.jwt, this.core, authorization); + return firstValueFrom( + this.core.notifications.ListNotifications({ + userId, + limit: limit ? Number(limit) : 30, + unreadOnly: unreadOnly === 'true' + }).pipe( + map((response) => { + const payload = response as { notifications?: unknown[] }; + return { notifications: payload.notifications ?? [] }; + }) + ) + ); + } + + @Get('unread-count') + @ApiOperation({ summary: 'Количество непрочитанных уведомлений' }) + async unreadCount(@Headers('authorization') authorization: string | undefined) { + const userId = await getAuthorizedUserId(this.jwt, this.core, authorization); + return firstValueFrom(this.core.notifications.GetUnreadCount({ userId })); + } + + @Patch(':notificationId/read') + @ApiOperation({ summary: 'Удалить просмотренное уведомление' }) + async markRead( + @Headers('authorization') authorization: string | undefined, + @Param('notificationId') notificationId: string, + @Body() _dto: MarkNotificationReadDto + ) { + const userId = await getAuthorizedUserId(this.jwt, this.core, authorization); + return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId })); + } + + @Delete(':notificationId') + @ApiOperation({ summary: 'Удалить уведомление' }) + async deleteNotification( + @Headers('authorization') authorization: string | undefined, + @Param('notificationId') notificationId: string + ) { + const userId = await getAuthorizedUserId(this.jwt, this.core, authorization); + return firstValueFrom(this.core.notifications.DeleteNotification({ userId, notificationId })); + } + + @Post('read-all') + @ApiOperation({ summary: 'Удалить все уведомления' }) + async markAllRead(@Headers('authorization') authorization: string | undefined) { + const userId = await getAuthorizedUserId(this.jwt, this.core, authorization); + return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId })); + } + + @Delete() + @ApiOperation({ summary: 'Удалить все уведомления' }) + async deleteAll(@Headers('authorization') authorization: string | undefined) { + const userId = await getAuthorizedUserId(this.jwt, this.core, authorization); + return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId })); + } +} diff --git a/apps/api-gateway/src/controllers/oauth.controller.ts b/apps/api-gateway/src/controllers/oauth.controller.ts new file mode 100644 index 0000000..24c051a --- /dev/null +++ b/apps/api-gateway/src/controllers/oauth.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CoreGrpcService } from '../core-grpc.service'; +import { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto'; +import { extractBearerToken } from '../auth-token'; + +@ApiTags('OAuth 2.0') +@Controller('oauth') +export class OAuthController { + constructor(private readonly core: CoreGrpcService) {} + + @Get('authorize') + @ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' }) + @ApiQuery({ name: 'userId', description: 'ID пользователя' }) + @ApiQuery({ name: 'clientId', description: 'OAuth client_id' }) + @ApiQuery({ name: 'redirectUri', description: 'redirect_uri' }) + @ApiQuery({ name: 'scope', description: 'Scopes через пробел' }) + @ApiResponse({ status: 200, description: 'Authorization code создан' }) + authorize(@Query() query: OAuthAuthorizeQueryDto) { + return this.core.oauth.Authorize(query); + } + + @Post('token') + @ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' }) + @ApiBody({ type: OAuthTokenDto }) + @ApiResponse({ status: 201, description: 'OAuth токены выданы' }) + token(@Body() dto: OAuthTokenDto) { + return this.core.oauth.Token(dto); + } + + @Get('userinfo') + @ApiBearerAuth() + @ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' }) + @ApiResponse({ status: 200, description: 'Профиль пользователя получен' }) + userInfo(@Headers('authorization') authorization?: string) { + return this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) }); + } +} diff --git a/apps/api-gateway/src/controllers/otp.controller.ts b/apps/api-gateway/src/controllers/otp.controller.ts new file mode 100644 index 0000000..9e3fa2f --- /dev/null +++ b/apps/api-gateway/src/controllers/otp.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CoreGrpcService } from '../core-grpc.service'; +import { SendOtpDto, VerifyOtpDto } from '../dto/identity.dto'; + +@ApiTags('OTP и 2FA') +@Controller('auth/otp') +export class OtpController { + constructor(private readonly core: CoreGrpcService) {} + + @Post('send') + @ApiOperation({ summary: 'Отправить OTP код', description: 'Создает одноразовый код в AuthCode. Fallback-доставка пишет код в console.log.' }) + @ApiBody({ type: SendOtpDto }) + @ApiResponse({ status: 201, description: 'Код создан и отправлен' }) + send(@Body() dto: SendOtpDto) { + return this.core.otp.SendOtp(dto); + } + + @Post('verify') + @ApiOperation({ summary: 'Проверить OTP код', description: 'Проверяет одноразовый код, срок жизни и помечает его использованным.' }) + @ApiBody({ type: VerifyOtpDto }) + @ApiResponse({ status: 201, description: 'Код подтвержден' }) + verify(@Body() dto: VerifyOtpDto) { + return this.core.otp.VerifyOtp(dto); + } +} diff --git a/apps/api-gateway/src/controllers/profile.controller.ts b/apps/api-gateway/src/controllers/profile.controller.ts new file mode 100644 index 0000000..e53b075 --- /dev/null +++ b/apps/api-gateway/src/controllers/profile.controller.ts @@ -0,0 +1,83 @@ +import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CoreGrpcService } from '../core-grpc.service'; +import { getAuthorizedUserId } from '../document-access'; +import { SetPasswordDto, UpdateAvatarDto, UpdateContactsDto, UpdateProfileDto } from '../dto/profile.dto'; + +@ApiTags('Профиль и биометрия') +@ApiBearerAuth() +@Controller('profile/users/:userId') +export class ProfileController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + private async assertSelfAccess(authorization: string | undefined, userId: string) { + const requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization); + if (requesterId !== userId) { + throw new ForbiddenException('Можно изменять только свой профиль'); + } + return requesterId; + } + + @Get() + @ApiOperation({ summary: 'Получить профиль пользователя', description: 'Возвращает публичный профиль, аватар и основные/резервные контакты пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiResponse({ status: 200, description: 'Профиль пользователя успешно получен' }) + @ApiResponse({ status: 404, description: 'Пользователь не найден' }) + getProfile(@Param('userId') userId: string) { + return this.core.profile.GetProfile({ userId }); + } + + @Patch('avatar') + @ApiOperation({ summary: 'Обновить аватар', description: 'Сохраняет URL или ключ объекта MinIO как аватар пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: UpdateAvatarDto }) + @ApiResponse({ status: 200, description: 'Аватар обновлен' }) + updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) { + return this.core.profile.UpdateAvatar({ userId, avatarUrl: dto.avatarUrl, avatarStorageKey: dto.avatarStorageKey }); + } + + @Patch() + @ApiOperation({ summary: 'Обновить профиль', description: 'Обновляет firstName, lastName, bio, age и gender в UserProfile, а displayName синхронизирует с именем и фамилией.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: UpdateProfileDto }) + @ApiResponse({ status: 200, description: 'Профиль обновлен' }) + updateProfile(@Param('userId') userId: string, @Body() dto: UpdateProfileDto) { + return this.core.profile.UpdateProfile({ userId, ...dto }); + } + + @Patch('contacts') + @ApiOperation({ summary: 'Обновить контакты', description: 'Обновляет основную и резервную почту/телефон в модели User.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: UpdateContactsDto }) + @ApiResponse({ status: 200, description: 'Контакты обновлены' }) + @ApiResponse({ status: 400, description: 'Переданы некорректные контактные данные' }) + updateContacts(@Param('userId') userId: string, @Body() dto: UpdateContactsDto) { + return this.core.profile.UpdateContacts({ userId, ...dto }); + } + + @Post('password') + @ApiOperation({ summary: 'Установить пароль', description: 'Устанавливает пароль для аккаунта. После этого пароль становится доступным как альтернативный способ входа.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: SetPasswordDto }) + @ApiResponse({ status: 201, description: 'Пароль установлен' }) + setPassword(@Param('userId') userId: string, @Body() dto: SetPasswordDto) { + return this.core.profile.SetPassword({ userId, password: dto.password }); + } + + @Post('self-delete') + @ApiOperation({ + summary: 'Удалить свой профиль', + description: 'Мягко удаляет профиль: помечает аккаунт как удалённый, освобождает логин и контакты для повторной регистрации, сбрасывает роли и завершает все сессии.' + }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @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 }); + } +} + \ No newline at end of file diff --git a/apps/api-gateway/src/controllers/public-settings.controller.ts b/apps/api-gateway/src/controllers/public-settings.controller.ts new file mode 100644 index 0000000..be323ed --- /dev/null +++ b/apps/api-gateway/src/controllers/public-settings.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { map } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; + +@ApiTags('Публичные настройки') +@Controller('settings') +export class PublicSettingsController { + constructor(private readonly core: CoreGrpcService) {} + + @Get('public') + @ApiOperation({ summary: 'Публичные настройки', description: 'Возвращает несекретные настройки для интерфейса без авторизации.' }) + listPublic() { + return this.core.settings.ListPublicSettings({}).pipe( + map((response) => { + const payload = response as { settings?: Array<{ key: string; value: string }> }; + return { settings: payload.settings ?? [] }; + }) + ); + } +} diff --git a/apps/api-gateway/src/controllers/rbac.controller.ts b/apps/api-gateway/src/controllers/rbac.controller.ts new file mode 100644 index 0000000..6a79453 --- /dev/null +++ b/apps/api-gateway/src/controllers/rbac.controller.ts @@ -0,0 +1,103 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import { map } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; +import { CurrentAdmin } from '../decorators/current-admin.decorator'; +import { AssignUserRoleDto, CreateOAuthClientDto, CreateRoleDto, UpdateOAuthClientDto } from '../dto/rbac.dto'; +import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard'; + +@ApiTags('RBAC и OAuth') +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Controller('admin/rbac') +export class RbacController { + constructor(private readonly core: CoreGrpcService) {} + + @Get('roles') + @ApiOperation({ summary: 'Список ролей', description: 'Возвращает роли вместе с назначенными правами.' }) + listRoles() { + return this.core.rbac.ListRoles({}).pipe( + map((response) => { + const payload = response as { roles?: Array<{ permissions?: unknown[] }> }; + return { + roles: (payload.roles ?? []).map((role) => ({ + ...role, + permissions: role.permissions ?? [] + })) + }; + }) + ); + } + + @Get('permissions') + @UseGuards(SuperAdminGuard) + @ApiOperation({ summary: 'Список прав', description: 'Возвращает все доступные permissions.' }) + listPermissions() { + return this.core.rbac.ListPermissions({}); + } + + @Get('oauth-scopes') + @ApiOperation({ summary: 'OAuth scopes', description: 'Возвращает доступные scopes для OAuth-приложений.' }) + listOAuthScopes(@CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageOAuth'); + return this.core.rbac.ListOAuthScopes({}); + } + + @Get('oauth-clients') + @ApiOperation({ summary: 'OAuth приложения', description: 'Возвращает OAuth-клиенты и доступные scopes.' }) + listOAuthClients(@CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageOAuth'); + return this.core.rbac.ListOAuthClients({}); + } + + @Post('roles') + @UseGuards(SuperAdminGuard) + @ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав. Только супер-администратор.' }) + @ApiBody({ type: CreateRoleDto }) + createRole(@Body() dto: CreateRoleDto, @CurrentAdmin() admin: AdminRequestUser) { + return this.core.rbac.CreateRole({ actorUserId: admin.id, ...dto }); + } + + @Post('users/:userId/roles') + @UseGuards(SuperAdminGuard) + @ApiOperation({ summary: 'Назначить роль пользователю', description: 'Только супер-администратор может назначать роли.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: AssignUserRoleDto }) + assignRole(@Param('userId') userId: string, @Body() dto: AssignUserRoleDto, @CurrentAdmin() admin: AdminRequestUser) { + return this.core.rbac.AssignUserRole({ actorUserId: admin.id, userId, roleSlug: dto.roleSlug }); + } + + @Delete('users/:userId/roles/:roleSlug') + @UseGuards(SuperAdminGuard) + @ApiOperation({ summary: 'Снять роль с пользователя', description: 'Только супер-администратор может снимать роли.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'roleSlug', description: 'Slug роли' }) + removeRole(@Param('userId') userId: string, @Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) { + return this.core.rbac.RemoveUserRole({ actorUserId: admin.id, userId, roleSlug }); + } + + @Post('oauth-clients') + @ApiOperation({ summary: 'Создать OAuth-приложение', description: 'Создаёт OAuth2-клиент и возвращает client secret один раз.' }) + @ApiBody({ type: CreateOAuthClientDto }) + createOAuthClient(@Body() dto: CreateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageOAuth'); + return this.core.rbac.CreateOAuthClient({ actorUserId: admin.id, ...dto }); + } + + @Patch('oauth-clients/:clientId') + @ApiOperation({ summary: 'Обновить OAuth-приложение', description: 'Изменяет redirect URI, scopes и статус приложения.' }) + @ApiParam({ name: 'clientId', description: 'Client ID приложения' }) + @ApiBody({ type: UpdateOAuthClientDto }) + updateOAuthClient(@Param('clientId') clientId: string, @Body() dto: UpdateOAuthClientDto, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageOAuth'); + return this.core.rbac.UpdateOAuthClient({ actorUserId: admin.id, clientId, ...dto }); + } + + @Post('oauth-clients/:clientId/rotate-secret') + @ApiOperation({ summary: 'Перевыпустить client secret', description: 'Генерирует новый secret для confidential-клиента.' }) + @ApiParam({ name: 'clientId', description: 'Client ID приложения' }) + rotateSecret(@Param('clientId') clientId: string, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageOAuth'); + return this.core.rbac.RotateOAuthSecret({ actorUserId: admin.id, clientId }); + } +} diff --git a/apps/api-gateway/src/controllers/security.controller.ts b/apps/api-gateway/src/controllers/security.controller.ts new file mode 100644 index 0000000..c2a88d7 --- /dev/null +++ b/apps/api-gateway/src/controllers/security.controller.ts @@ -0,0 +1,111 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CoreGrpcService } from '../core-grpc.service'; +import { OptionalPinDto, PinDto, VerifySecurityPinDto } from '../dto/security.dto'; + +@ApiTags('Безопасность') +@ApiBearerAuth() +@Controller('security') +export class SecurityController { + constructor(private readonly core: CoreGrpcService) {} + + @Get('users/:userId/devices') + @ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiResponse({ status: 200, description: 'Список устройств получен' }) + listDevices(@Param('userId') userId: string) { + return this.core.security.ListActiveDevices({ userId }); + } + + @Get('users/:userId/sessions') + @ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiResponse({ status: 200, description: 'Список активных сессий получен' }) + listSessions(@Param('userId') userId: string) { + return this.core.security.ListActiveSessions({ userId }); + } + + @Get('users/:userId/sign-in-history') + @ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiResponse({ status: 200, description: 'История входов получена' }) + listHistory(@Param('userId') userId: string) { + return this.core.security.ListSignInHistory({ userId }); + } + + @Post('users/:userId/pin/setup') + @ApiOperation({ summary: 'Настроить PIN-код', description: 'Создает или включает PIN-код пользователя. PIN хранится только в виде bcrypt hash.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: PinDto }) + @ApiResponse({ status: 201, description: 'PIN-код настроен' }) + setupPin(@Param('userId') userId: string, @Body() dto: PinDto) { + return this.core.security.SetupPin({ userId, pin: dto.pin }); + } + + @Post('users/:userId/pin/update') + @ApiOperation({ summary: 'Обновить PIN-код', description: 'Меняет PIN-код и переводит активные сессии пользователя в LOCKED до повторной проверки.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: PinDto }) + @ApiResponse({ status: 201, description: 'PIN-код обновлен' }) + updatePin(@Param('userId') userId: string, @Body() dto: PinDto) { + return this.core.security.UpdatePin({ userId, pin: dto.pin }); + } + + @Post('users/:userId/pin/delete-request') + @ApiOperation({ summary: 'Запросить удаление PIN-кода', description: 'Запускает период ожидания перед отключением PIN-кода согласно SystemSetting.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: OptionalPinDto }) + requestPinDeletion(@Param('userId') userId: string, @Body() dto: OptionalPinDto) { + return this.core.security.RequestPinDeletion({ userId, pin: dto.pin ?? '' }); + } + + @Post('users/:userId/pin/delete-cancel') + @ApiOperation({ summary: 'Отменить удаление PIN-кода', description: 'Отменяет запланированное удаление PIN-кода.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + cancelPinDeletion(@Param('userId') userId: string) { + return this.core.security.CancelPinDeletion({ userId }); + } + + @Post('pin/verify') + @ApiOperation({ summary: 'Проверить PIN-код', description: 'Проверяет PIN-код и возвращает access token с pinVerified=true для указанной сессии.' }) + @ApiBody({ type: VerifySecurityPinDto }) + @ApiResponse({ status: 201, description: 'PIN-код проверен' }) + verifyPin(@Body() dto: VerifySecurityPinDto) { + return this.core.security.VerifyPinCode(dto); + } + + @Post('users/:userId/sessions/:sessionId/lock') + @ApiOperation({ summary: 'Заблокировать сессию', description: 'Переводит активную сессию в LOCKED и сбрасывает pinVerified=false.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'sessionId', description: 'ID сессии' }) + @ApiResponse({ status: 201, description: 'Сессия заблокирована' }) + lockSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) { + return this.core.security.LockSession({ userId, sessionId }); + } + + @Post('users/:userId/sessions/:sessionId/revoke') + @ApiOperation({ summary: 'Выйти с устройства', description: 'Отзывает конкретную сессию пользователя и завершает доступ с выбранного устройства.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'sessionId', description: 'ID сессии' }) + @ApiResponse({ status: 201, description: 'Сессия отозвана' }) + revokeSession(@Param('userId') userId: string, @Param('sessionId') sessionId: string) { + return this.core.security.RevokeSession({ userId, sessionId }); + } + + @Post('users/:userId/devices/:deviceId/revoke') + @ApiOperation({ summary: 'Завершить сессии устройства', description: 'Отзывает все активные сессии выбранного устройства пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'deviceId', description: 'ID устройства' }) + @ApiResponse({ status: 201, description: 'Сессии устройства завершены' }) + revokeDevice(@Param('userId') userId: string, @Param('deviceId') deviceId: string) { + return this.core.security.RevokeDevice({ userId, deviceId }); + } + + @Post('users/:userId/revoke-all-sessions') + @ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiResponse({ status: 201, description: 'Все сессии отозваны' }) + revokeAll(@Param('userId') userId: string) { + return this.core.security.RevokeAllSessions({ userId }); + } +} diff --git a/apps/api-gateway/src/controllers/settings.controller.ts b/apps/api-gateway/src/controllers/settings.controller.ts new file mode 100644 index 0000000..e230abd --- /dev/null +++ b/apps/api-gateway/src/controllers/settings.controller.ts @@ -0,0 +1,125 @@ +import { Body, Controller, Delete, Get, Param, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { map } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; +import { CurrentAdmin } from '../decorators/current-admin.decorator'; +import { ConnectLinkedAccountDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto'; +import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard'; + +const settingsWritePipe = new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: false +}); + +@ApiTags('Глобальные настройки') +@ApiBearerAuth() +@UseGuards(AdminGuard) +@Controller('admin/settings') +export class SettingsController { + constructor(private readonly core: CoreGrpcService) {} + + @Get() + @ApiOperation({ summary: 'Список системных настроек', description: 'Возвращает динамические настройки, включая PIN timeout и OAuth providers.' }) + @ApiResponse({ status: 200, description: 'Список настроек получен' }) + list(@CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageSettings'); + return this.core.settings.ListSettings({}).pipe( + map((response) => { + const payload = response as { settings?: unknown[] }; + return { settings: payload.settings ?? [] }; + }) + ); + } + + @Get('key/:key') + @ApiOperation({ summary: 'Получить настройку', description: 'Возвращает SystemSetting по ключу.' }) + @ApiParam({ name: 'key', description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' }) + @ApiResponse({ status: 200, description: 'Настройка получена' }) + get(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageSettings'); + return this.core.settings.GetSetting({ key }); + } + + @Put() + @UsePipes(settingsWritePipe) + @ApiOperation({ summary: 'Создать или обновить настройку', description: 'Меняет значение SystemSetting без изменения кода frontend.' }) + @ApiBody({ type: UpsertSettingDto }) + @ApiResponse({ status: 200, description: 'Настройка создана или обновлена' }) + upsert(@Body() dto: UpsertSettingDto, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageSettings'); + return this.core.settings.UpsertSetting({ + key: dto.key, + value: dto.value, + description: dto.description, + isSecret: dto.isSecret + }); + } + + @Delete('key/:key') + @ApiOperation({ summary: 'Удалить настройку', description: 'Удаляет SystemSetting по ключу.' }) + @ApiParam({ name: 'key', description: 'Ключ настройки' }) + @ApiResponse({ status: 200, description: 'Настройка удалена' }) + delete(@Param('key') key: string, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageSettings'); + return this.core.settings.DeleteSetting({ key }); + } + + @Get('oauth/providers') + @ApiOperation({ summary: 'Список OAuth провайдеров', description: 'Возвращает глобальные настройки Google/Yandex и других social login providers.' }) + @ApiResponse({ status: 200, description: 'Список провайдеров получен' }) + listProviders(@CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageSettings'); + return this.core.settings.ListSocialProviders({}); + } + + @Put('oauth/providers') + @UsePipes(settingsWritePipe) + @ApiOperation({ summary: 'Создать или обновить OAuth провайдера', description: 'Управляет clientId/clientSecret и глобальным включением Google/Yandex входа.' }) + @ApiBody({ type: UpsertSocialProviderDto }) + @ApiResponse({ status: 200, description: 'Провайдер создан или обновлен' }) + upsertProvider(@Body() dto: UpsertSocialProviderDto, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageSettings'); + return this.core.settings.UpsertSocialProvider({ + providerName: dto.providerName, + clientId: dto.clientId, + clientSecret: dto.clientSecret, + isEnabled: dto.isEnabled + }); + } + + @Delete('oauth/providers/:providerName') + @ApiOperation({ summary: 'Удалить OAuth провайдера', description: 'Удаляет глобальную настройку social login provider.' }) + @ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' }) + @ApiResponse({ status: 200, description: 'Провайдер удален' }) + deleteProvider(@Param('providerName') providerName: string, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageSettings'); + return this.core.settings.DeleteSocialProvider({ providerName }); + } + + @Get('linked-accounts/users/:userId') + @ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiResponse({ status: 200, description: 'Список внешних аккаунтов получен' }) + listLinkedAccounts(@Param('userId') userId: string) { + return this.core.security.ListLinkedAccounts({ userId }); + } + + @Put('linked-accounts/users/:userId') + @ApiOperation({ summary: 'Подключить внешний аккаунт', description: 'Создает или обновляет LinkedAccount для social OAuth логина пользователя.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: ConnectLinkedAccountDto }) + @ApiResponse({ status: 200, description: 'Внешний аккаунт подключен' }) + connectLinkedAccount(@Param('userId') userId: string, @Body() dto: ConnectLinkedAccountDto) { + return this.core.security.ConnectLinkedAccount({ userId, ...dto }); + } + + @Delete('linked-accounts/users/:userId/:providerName') + @ApiOperation({ summary: 'Отключить внешний аккаунт', description: 'Удаляет связь пользователя с Google/Yandex или другим social provider.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'providerName', description: 'Название провайдера', example: 'google' }) + @ApiResponse({ status: 200, description: 'Внешний аккаунт отключен' }) + disconnectLinkedAccount(@Param('userId') userId: string, @Param('providerName') providerName: string) { + return this.core.security.DisconnectLinkedAccount({ userId, providerName }); + } +} diff --git a/apps/api-gateway/src/core-grpc.service.ts b/apps/api-gateway/src/core-grpc.service.ts new file mode 100644 index 0000000..72f3d30 --- /dev/null +++ b/apps/api-gateway/src/core-grpc.service.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { ClientGrpc } from '@nestjs/microservices'; +import { Observable } from 'rxjs'; + +type GrpcMethod = (request: TRequest) => Observable; + +@Injectable() +export class CoreGrpcService implements OnModuleInit { + auth!: Record; + admin!: Record; + rbac!: Record; + security!: Record; + settings!: Record; + profile!: Record; + documents!: Record; + addresses!: Record; + oauth!: Record; + otp!: Record; + advancedAuth!: Record; + notifications!: Record; + chat!: Record; + family!: Record; + media!: Record; + + constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {} + + onModuleInit() { + this.auth = this.client.getService>('AuthService'); + this.admin = this.client.getService>('AdminService'); + this.rbac = this.client.getService>('RbacService'); + this.security = this.client.getService>('SecurityService'); + this.settings = this.client.getService>('SettingsService'); + this.profile = this.client.getService>('ProfileService'); + this.documents = this.client.getService>('DocumentsService'); + this.addresses = this.client.getService>('AddressesService'); + this.oauth = this.client.getService>('OAuthCoreService'); + this.otp = this.client.getService>('OtpService'); + this.advancedAuth = this.client.getService>('AdvancedAuthService'); + this.family = this.client.getService>('FamilyService'); + this.notifications = this.client.getService>('NotificationsService'); + this.chat = this.client.getService>('ChatService'); + this.media = this.client.getService>('MediaService'); + } +} diff --git a/apps/api-gateway/src/decorators/current-admin.decorator.ts b/apps/api-gateway/src/decorators/current-admin.decorator.ts new file mode 100644 index 0000000..01f1aa2 --- /dev/null +++ b/apps/api-gateway/src/decorators/current-admin.decorator.ts @@ -0,0 +1,7 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AdminRequestUser } from '../guards/admin.guard'; + +export const CurrentAdmin = createParamDecorator((_data: unknown, context: ExecutionContext): AdminRequestUser => { + const request = context.switchToHttp().getRequest<{ adminUser: AdminRequestUser }>(); + return request.adminUser; +}); diff --git a/apps/api-gateway/src/document-access.ts b/apps/api-gateway/src/document-access.ts new file mode 100644 index 0000000..7b3be42 --- /dev/null +++ b/apps/api-gateway/src/document-access.ts @@ -0,0 +1,52 @@ +import { ForbiddenException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { firstValueFrom } from 'rxjs'; +import { CoreGrpcService } from './core-grpc.service'; +import { assertSessionUnlocked, verifyAccessToken } from './session-auth'; + +interface RequesterProfile { + id: string; + isSuperAdmin: boolean; + canViewUserDocuments?: boolean; +} + +async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise { + const payload = await verifyAccessToken(jwt, authorization); + await assertSessionUnlocked(core, payload); + const profile = (await firstValueFrom(core.auth.GetMe({ userId: payload.sub }))) as RequesterProfile & { id: string }; + return { id: profile.id, isSuperAdmin: profile.isSuperAdmin, canViewUserDocuments: profile.canViewUserDocuments }; +} + +export async function assertDocumentsReadAccess( + jwt: JwtService, + core: CoreGrpcService, + authorization: string | undefined, + targetUserId: string +) { + const requester = await getRequesterProfile(jwt, core, authorization); + if (requester.id === targetUserId) { + return requester.id; + } + if (!requester.canViewUserDocuments && !requester.isSuperAdmin) { + throw new ForbiddenException('Нет доступа к документам пользователя'); + } + return requester.id; +} + +export async function assertDocumentsWriteAccess( + jwt: JwtService, + core: CoreGrpcService, + authorization: string | undefined, + targetUserId: string +) { + const requester = await getRequesterProfile(jwt, core, authorization); + if (requester.id !== targetUserId) { + throw new ForbiddenException('Нельзя изменять документы другого пользователя'); + } + return requester.id; +} + +export async function getAuthorizedUserId(jwt: JwtService, core: CoreGrpcService, authorization?: string) { + const requester = await getRequesterProfile(jwt, core, authorization); + return requester.id; +} diff --git a/apps/api-gateway/src/dto/addresses.dto.ts b/apps/api-gateway/src/dto/addresses.dto.ts new file mode 100644 index 0000000..0f61eaa --- /dev/null +++ b/apps/api-gateway/src/dto/addresses.dto.ts @@ -0,0 +1,53 @@ +import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpsertAddressDto { + @ApiPropertyOptional({ description: 'ID адреса для обновления' }) + @IsOptional() + @IsUUID('4', { message: 'ID адреса должен быть UUID' }) + addressId?: string; + + @ApiProperty({ description: 'Тип адреса', enum: ['HOME', 'WORK', 'OTHER'] }) + @IsIn(['HOME', 'WORK', 'OTHER'], { message: 'Некорректный тип адреса' }) + label!: 'HOME' | 'WORK' | 'OTHER'; + + @ApiProperty({ description: 'Город' }) + @IsString({ message: 'Город должен быть строкой' }) + @MinLength(1, { message: 'Укажите город' }) + city!: string; + + @ApiProperty({ description: 'Улица' }) + @IsString({ message: 'Улица должна быть строкой' }) + @MinLength(1, { message: 'Укажите улицу' }) + street!: string; + + @ApiProperty({ description: 'Дом' }) + @IsString({ message: 'Дом должен быть строкой' }) + @MinLength(1, { message: 'Укажите дом' }) + house!: string; + + @ApiPropertyOptional({ description: 'Квартира или офис' }) + @IsOptional() + @IsString({ message: 'Квартира должна быть строкой' }) + apartment?: string; + + @ApiPropertyOptional({ description: 'Комментарий' }) + @IsOptional() + @IsString({ message: 'Комментарий должен быть строкой' }) + comment?: string; + + @ApiPropertyOptional({ description: 'Широта' }) + @IsOptional() + @IsNumber({}, { message: 'Широта должна быть числом' }) + latitude?: number; + + @ApiPropertyOptional({ description: 'Долгота' }) + @IsOptional() + @IsNumber({}, { message: 'Долгота должна быть числом' }) + longitude?: number; + + @ApiPropertyOptional({ description: 'Полный адрес одной строкой' }) + @IsOptional() + @IsString({ message: 'Полный адрес должен быть строкой' }) + fullAddress?: string; +} diff --git a/apps/api-gateway/src/dto/admin.dto.ts b/apps/api-gateway/src/dto/admin.dto.ts new file mode 100644 index 0000000..7498c4d --- /dev/null +++ b/apps/api-gateway/src/dto/admin.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, Matches, MinLength } from 'class-validator'; + +export enum GatewayUserStatus { + ACTIVE = 'ACTIVE', + SUSPENDED = 'SUSPENDED', + DELETED = 'DELETED' +} + +export class ListUsersQueryDto { + @ApiPropertyOptional({ description: 'Поиск по почте, телефону, имени или логину' }) + @IsOptional() + @IsString({ message: 'Поисковая строка должна быть текстом' }) + search?: string; +} + +export class UpdateUserDto { + @ApiPropertyOptional({ description: 'Отображаемое имя' }) + @IsOptional() + @IsString({ message: 'Имя должно быть строкой' }) + displayName?: string; + + @ApiPropertyOptional({ description: 'Основная почта' }) + @IsOptional() + @IsEmail({}, { message: 'Укажите корректную почту' }) + email?: string; + + @ApiPropertyOptional({ description: 'Основной телефон' }) + @IsOptional() + @Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный телефон' }) + phone?: string; + + @ApiPropertyOptional({ description: 'Резервная почта' }) + @IsOptional() + @IsEmail({}, { message: 'Укажите корректную резервную почту' }) + backupEmail?: string; + + @ApiPropertyOptional({ description: 'Резервный телефон' }) + @IsOptional() + @Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный резервный телефон' }) + backupPhone?: string; + + @ApiPropertyOptional({ description: 'Статус пользователя', enum: GatewayUserStatus }) + @IsOptional() + @IsEnum(GatewayUserStatus, { message: 'Укажите корректный статус пользователя' }) + status?: GatewayUserStatus; +} + +export class ResetPasswordDto { + @ApiProperty({ description: 'Новый пароль пользователя', minLength: 8 }) + @IsString({ message: 'Пароль должен быть строкой' }) + @MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' }) + newPassword!: string; +} + +export class SetSuperAdminDto { + @ApiProperty({ description: 'Выдать или снять права супер-администратора' }) + @IsBoolean({ message: 'isSuperAdmin должно быть boolean' }) + isSuperAdmin!: boolean; +} diff --git a/apps/api-gateway/src/dto/auth.dto.ts b/apps/api-gateway/src/dto/auth.dto.ts new file mode 100644 index 0000000..239b000 --- /dev/null +++ b/apps/api-gateway/src/dto/auth.dto.ts @@ -0,0 +1,189 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsOptional, IsString, Length, Matches, MinLength } from 'class-validator'; + +const EMAIL_OR_PHONE_PATTERN = /^([^\s@]+@[^\s@]+\.[^\s@]+|\+?[1-9]\d{9,14})$/; + +export class RegisterDto { + @ApiPropertyOptional({ description: 'Основная почта пользователя', example: 'user@example.com' }) + @IsOptional() + @IsEmail({}, { message: 'Укажите корректную почту' }) + email?: string; + + @ApiPropertyOptional({ description: 'Основной телефон пользователя', example: '+79990000000' }) + @IsOptional() + @Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный номер телефона' }) + phone?: string; + + @ApiPropertyOptional({ description: 'Пароль пользователя. Не требуется для passwordless-регистрации.', minLength: 8 }) + @IsOptional() + @IsString({ message: 'Пароль должен быть строкой' }) + @MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' }) + password?: string; + + @ApiProperty({ description: 'Имя, которое отображается в профиле', example: 'Иван Петров' }) + @IsString({ message: 'Имя должно быть строкой' }) + @IsNotEmpty({ message: 'Укажите имя профиля' }) + displayName!: string; + + @ApiPropertyOptional({ description: 'Публичный логин пользователя', example: 'ivan' }) + @IsOptional() + @IsString({ message: 'Логин должен быть строкой' }) + username?: string; +} + +export class LoginDto { + @ApiProperty({ description: 'Почта, телефон или логин', example: 'user@example.com' }) + @IsString({ message: 'Логин должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите логин' }) + login!: string; + + @ApiProperty({ description: 'Пароль пользователя' }) + @IsString({ message: 'Пароль должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите пароль' }) + password!: string; + + @ApiProperty({ description: 'Уникальный отпечаток устройства' }) + @IsString({ message: 'Отпечаток устройства должен быть строкой' }) + @IsNotEmpty({ message: 'Передайте отпечаток устройства' }) + fingerprint!: string; + + @ApiPropertyOptional({ description: 'Название устройства', example: 'Chrome на Windows' }) + @IsOptional() + @IsString({ message: 'Название устройства должно быть строкой' }) + deviceName?: string; + + @ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' }) + @IsOptional() + @IsString({ message: 'Тип устройства должен быть строкой' }) + deviceType?: string; +} + +export class IdentifyDto { + @ApiProperty({ description: 'Почта или телефон пользователя', example: 'user@example.com' }) + @IsString({ message: 'Логин должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите почту или телефон' }) + @Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' }) + login!: string; +} + +export class PasswordlessOtpDto { + @ApiProperty({ description: 'Почта или телефон для входа', example: 'user@example.com' }) + @IsString({ message: 'Получатель должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите почту или телефон' }) + @Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' }) + recipient!: string; + + @ApiPropertyOptional({ description: 'Альтернативный канал доставки кода', enum: ['primary', 'email', 'phone', 'backupEmail', 'backupPhone'] }) + @IsOptional() + @IsString({ message: 'Канал должен быть строкой' }) + channel?: string; +} + +export class PasswordlessVerifyDto { + @ApiProperty({ description: 'Почта или телефон, на который отправлен код', example: 'user@example.com' }) + @IsString({ message: 'Получатель должен быть строкой' }) + @Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' }) + recipient!: string; + + @ApiProperty({ description: 'OTP-код из 6 цифр', example: '123456' }) + @IsString({ message: 'Код должен быть строкой' }) + @Length(6, 6, { message: 'Код должен содержать 6 цифр' }) + @Matches(/^\d+$/, { message: 'Код должен содержать только цифры' }) + code!: string; + + @ApiProperty({ description: 'Уникальный отпечаток устройства' }) + @IsString({ message: 'Отпечаток устройства должен быть строкой' }) + fingerprint!: string; + + @ApiPropertyOptional({ description: 'Название устройства' }) + @IsOptional() + @IsString({ message: 'Название устройства должно быть строкой' }) + deviceName?: string; + + @ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' }) + @IsOptional() + @IsString({ message: 'Тип устройства должен быть строкой' }) + deviceType?: string; +} + +export class PasswordLoginDto { + @ApiPropertyOptional({ description: 'Почта или телефон пользователя для identifier-first входа' }) + @IsOptional() + @IsString({ message: 'Логин должен быть строкой' }) + @Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' }) + login?: string; + + @ApiPropertyOptional({ description: 'Временный auth_token после успешной OTP-проверки, если используется legacy challenge flow' }) + @IsOptional() + @IsString({ message: 'Временный токен должен быть строкой' }) + tempAuthToken?: string; + + @ApiProperty({ description: 'Пароль пользователя' }) + @IsString({ message: 'Пароль должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите пароль' }) + password!: string; + + @ApiProperty({ description: 'Уникальный отпечаток устройства' }) + @IsString({ message: 'Отпечаток устройства должен быть строкой' }) + fingerprint!: string; + + @ApiPropertyOptional({ description: 'Название устройства' }) + @IsOptional() + @IsString({ message: 'Название устройства должно быть строкой' }) + deviceName?: string; + + @ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' }) + @IsOptional() + @IsString({ message: 'Тип устройства должен быть строкой' }) + deviceType?: string; +} + +export class VerifyPinDto { + @ApiProperty({ description: 'ID временно заблокированной сессии' }) + @IsString({ message: 'ID сессии должен быть строкой' }) + sessionId!: string; + + @ApiProperty({ description: 'PIN-код из 4-6 цифр', example: '1234' }) + @IsString({ message: 'PIN-код должен быть строкой' }) + @Length(4, 6, { message: 'PIN-код должен содержать от 4 до 6 символов' }) + @Matches(/^\d+$/, { message: 'PIN-код должен содержать только цифры' }) + pin!: string; +} + +export class RefreshSessionDto { + @ApiProperty({ description: 'Refresh token текущей сессии' }) + @IsString({ message: 'Refresh token должен быть строкой' }) + @IsNotEmpty({ message: 'Передайте refresh token' }) + refreshToken!: string; + + @ApiPropertyOptional({ description: 'ID сессии для ускоренной проверки' }) + @IsOptional() + @IsString({ message: 'ID сессии должен быть строкой' }) + sessionId?: string; +} + +export class LdapLoginDto { + @ApiProperty({ description: 'Логин LDAP (sAMAccountName, uid, mail и т.д.)', example: 'ivan.petrov' }) + @IsString({ message: 'Логин должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите LDAP-логин' }) + username!: string; + + @ApiProperty({ description: 'Пароль LDAP' }) + @IsString({ message: 'Пароль должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите пароль' }) + password!: string; + + @ApiProperty({ description: 'Уникальный отпечаток устройства' }) + @IsString({ message: 'Отпечаток устройства должен быть строкой' }) + fingerprint!: string; + + @ApiPropertyOptional({ description: 'Название устройства' }) + @IsOptional() + @IsString({ message: 'Название устройства должно быть строкой' }) + deviceName?: string; + + @ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' }) + @IsOptional() + @IsString({ message: 'Тип устройства должен быть строкой' }) + deviceType?: string; +} diff --git a/apps/api-gateway/src/dto/chat.dto.ts b/apps/api-gateway/src/dto/chat.dto.ts new file mode 100644 index 0000000..923483a --- /dev/null +++ b/apps/api-gateway/src/dto/chat.dto.ts @@ -0,0 +1,68 @@ +import { IsArray, IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator'; + +export class CreateChatRoomDto { + @IsString() + @MinLength(1) + name!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + memberUserIds?: string[]; +} + +export class UpdateChatRoomDto { + @IsOptional() + @IsString() + @MinLength(1) + name?: string; + + @IsOptional() + @IsBoolean() + notificationsMuted?: boolean; +} + +export class SendChatMessageDto { + @IsString() + @IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL']) + type!: string; + + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsString() + replyToId?: string; + + @IsOptional() + @IsString() + storageKey?: string; + + @IsOptional() + @IsString() + mimeType?: string; + + @IsOptional() + @IsString() + metadataJson?: string; + + @IsOptional() + poll?: { + question: string; + options: string[]; + allowsMultiple?: boolean; + isAnonymous?: boolean; + }; +} + +export class VotePollDto { + @IsArray() + @IsString({ each: true }) + optionIds!: string[]; +} + +export class SetRoomNotificationsMutedDto { + @IsBoolean() + muted!: boolean; +} diff --git a/apps/api-gateway/src/dto/documents.dto.ts b/apps/api-gateway/src/dto/documents.dto.ts new file mode 100644 index 0000000..2685ca9 --- /dev/null +++ b/apps/api-gateway/src/dto/documents.dto.ts @@ -0,0 +1,62 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsEnum, IsJSON, IsOptional, IsString } from 'class-validator'; + +export enum DocumentTypeDto { + PASSPORT_RF = 'PASSPORT_RF', + FOREIGN_PASSPORT = 'FOREIGN_PASSPORT', + BIRTH_CERTIFICATE = 'BIRTH_CERTIFICATE', + DRIVER_LICENSE = 'DRIVER_LICENSE', + VEHICLE_REGISTRATION = 'VEHICLE_REGISTRATION', + OMS = 'OMS', + DMS = 'DMS', + INN = 'INN', + SNILS = 'SNILS' +} + +export class CreateDocumentDto { + @ApiProperty({ description: 'Тип документа', enum: DocumentTypeDto, example: DocumentTypeDto.PASSPORT_RF }) + @IsEnum(DocumentTypeDto, { message: 'Некорректный тип документа' }) + type!: DocumentTypeDto; + + @ApiPropertyOptional({ description: 'Номер документа. Перед сохранением будет зашифрован.', example: '4512 123456' }) + @IsOptional() + @IsString({ message: 'Номер документа должен быть строкой' }) + number?: string; + + @ApiPropertyOptional({ description: 'Дата выдачи документа в ISO формате', example: '2020-01-10T00:00:00.000Z' }) + @IsOptional() + @IsDateString({}, { message: 'Дата выдачи должна быть в ISO формате' }) + issuedAt?: string; + + @ApiPropertyOptional({ description: 'Дата окончания действия документа в ISO формате', example: '2030-01-10T00:00:00.000Z' }) + @IsOptional() + @IsDateString({}, { message: 'Дата окончания должна быть в ISO формате' }) + expiresAt?: string; + + @ApiPropertyOptional({ description: 'Данные формы документа в JSON. Перед сохранением будут зашифрованы.' }) + @IsOptional() + @IsJSON({ message: 'Дополнительные данные должны быть валидным JSON' }) + metadataJson?: string; +} + +export class UpdateDocumentDto { + @ApiPropertyOptional({ description: 'Номер документа' }) + @IsOptional() + @IsString({ message: 'Номер документа должен быть строкой' }) + number?: string; + + @ApiPropertyOptional({ description: 'Дата выдачи в ISO формате' }) + @IsOptional() + @IsDateString({}, { message: 'Дата выдачи должна быть в ISO формате' }) + issuedAt?: string; + + @ApiPropertyOptional({ description: 'Дата окончания в ISO формате' }) + @IsOptional() + @IsDateString({}, { message: 'Дата окончания должна быть в ISO формате' }) + expiresAt?: string; + + @ApiPropertyOptional({ description: 'Данные формы документа в JSON' }) + @IsOptional() + @IsJSON({ message: 'Дополнительные данные должны быть валидным JSON' }) + metadataJson?: string; +} diff --git a/apps/api-gateway/src/dto/identity.dto.ts b/apps/api-gateway/src/dto/identity.dto.ts new file mode 100644 index 0000000..c8876e9 --- /dev/null +++ b/apps/api-gateway/src/dto/identity.dto.ts @@ -0,0 +1,140 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class OAuthAuthorizeQueryDto { + @ApiProperty({ description: 'ID пользователя, который подтверждает OAuth доступ' }) + @IsString({ message: 'ID пользователя должен быть строкой' }) + userId!: string; + + @ApiProperty({ description: 'OAuth client_id приложения' }) + @IsString({ message: 'client_id должен быть строкой' }) + clientId!: string; + + @ApiProperty({ description: 'Разрешенный redirect_uri приложения' }) + @IsString({ message: 'redirect_uri должен быть строкой' }) + redirectUri!: string; + + @ApiProperty({ description: 'Запрошенные scopes через пробел', example: 'openid profile email' }) + @IsString({ message: 'scope должен быть строкой' }) + scope!: string; + + @ApiPropertyOptional({ description: 'OAuth state для защиты клиента' }) + @IsOptional() + @IsString({ message: 'state должен быть строкой' }) + state?: string; +} + +export class OAuthTokenDto { + @ApiProperty({ description: 'Тип grant', enum: ['authorization_code', 'refresh_token'] }) + @IsIn(['authorization_code', 'refresh_token'], { message: 'grant_type должен быть authorization_code или refresh_token' }) + grantType!: string; + + @ApiPropertyOptional({ description: 'Authorization code' }) + @IsOptional() + @IsString({ message: 'code должен быть строкой' }) + code?: string; + + @ApiPropertyOptional({ description: 'Refresh token' }) + @IsOptional() + @IsString({ message: 'refresh_token должен быть строкой' }) + refreshToken?: string; + + @ApiProperty({ description: 'OAuth client_id' }) + @IsString({ message: 'client_id должен быть строкой' }) + clientId!: string; + + @ApiPropertyOptional({ description: 'OAuth client_secret' }) + @IsOptional() + @IsString({ message: 'client_secret должен быть строкой' }) + clientSecret?: string; + + @ApiPropertyOptional({ description: 'redirect_uri для authorization_code grant' }) + @IsOptional() + @IsString({ message: 'redirect_uri должен быть строкой' }) + redirectUri?: string; +} + +export class SendOtpDto { + @ApiProperty({ description: 'Почта или телефон получателя' }) + @IsString({ message: 'Получатель должен быть строкой' }) + target!: string; + + @ApiProperty({ description: 'Канал доставки', enum: ['email', 'sms'] }) + @IsIn(['email', 'sms'], { message: 'Канал должен быть email или sms' }) + channel!: string; + + @ApiProperty({ description: 'Назначение кода', example: 'login' }) + @IsString({ message: 'Назначение должно быть строкой' }) + purpose!: string; + + @ApiPropertyOptional({ description: 'ID пользователя, если код привязан к аккаунту' }) + @IsOptional() + @IsString({ message: 'ID пользователя должен быть строкой' }) + userId?: string; +} + +export class VerifyOtpDto { + @ApiProperty({ description: 'Почта или телефон получателя' }) + @IsString({ message: 'Получатель должен быть строкой' }) + target!: string; + + @ApiProperty({ description: 'Код подтверждения' }) + @IsString({ message: 'Код должен быть строкой' }) + code!: string; + + @ApiProperty({ description: 'Назначение кода', example: 'login' }) + @IsString({ message: 'Назначение должно быть строкой' }) + purpose!: string; +} + +export class WebAuthnDto { + @ApiProperty({ description: 'ID пользователя' }) + @IsString({ message: 'ID пользователя должен быть строкой' }) + userId!: string; +} + +export class QrSessionDto { + @ApiProperty({ description: 'Название устройства', example: 'Chrome на Windows' }) + @IsString({ message: 'Название устройства должно быть строкой' }) + deviceName!: string; +} + +export class CreateFamilyGroupDto { + @ApiProperty({ description: 'ID владельца семьи' }) + @IsString({ message: 'ID владельца должен быть строкой' }) + ownerId!: string; + + @ApiProperty({ description: 'Название семейной группы', example: 'Семья Мамедовых' }) + @IsString({ message: 'Название семьи должно быть строкой' }) + @IsNotEmpty({ message: 'Укажите название семьи' }) + name!: string; +} + +export class UpdateFamilyGroupDto { + @ApiProperty({ description: 'Новое название семейной группы' }) + @IsString({ message: 'Название семьи должно быть строкой' }) + name!: string; +} + +export class AddFamilyMemberDto { + @ApiProperty({ description: 'ID пользователя, которого нужно добавить' }) + @IsString({ message: 'ID пользователя должен быть строкой' }) + userId!: string; + + @ApiProperty({ description: 'Роль участника семьи', example: 'member' }) + @IsString({ message: 'Роль должна быть строкой' }) + role!: string; +} + +export class SendFamilyInviteDto { + @ApiProperty({ description: 'Email, телефон или логин приглашаемого' }) + @IsString({ message: 'Укажите контакт приглашаемого' }) + @IsNotEmpty({ message: 'Укажите контакт приглашаемого' }) + target!: string; +} + +export class RespondFamilyInviteDto { + @ApiProperty({ description: 'Принять приглашение' }) + @IsBoolean({ message: 'Укажите accept: true или false' }) + accept!: boolean; +} diff --git a/apps/api-gateway/src/dto/media.dto.ts b/apps/api-gateway/src/dto/media.dto.ts new file mode 100644 index 0000000..0a8f6b0 --- /dev/null +++ b/apps/api-gateway/src/dto/media.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const; +export class AvatarUploadDto { + @ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES }) + @IsString({ message: 'Укажите MIME-тип изображения' }) + @IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' }) + contentType!: string; +} + +export class ConfirmAvatarDto { + @ApiProperty({ description: 'Ключ объекта в MinIO после загрузки', example: 'avatars/uuid/photo.jpg' }) + @IsString({ message: 'Ключ хранилища должен быть строкой' }) + storageKey!: string; +} + +export class DocumentPhotoUploadDto { + @ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES }) + @IsString({ message: 'Укажите MIME-тип изображения' }) + @IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' }) + contentType!: string; +} + +export class ChatMediaUploadDto { + @ApiProperty({ description: 'MIME-тип файла', example: 'audio/webm' }) + @IsString({ message: 'Укажите MIME-тип файла' }) + contentType!: string; + + @ApiPropertyOptional({ description: 'Имя файла для определения расширения', example: 'voice.webm' }) + @IsOptional() + @IsString({ message: 'Имя файла должно быть строкой' }) + fileName?: string; +} + +export class UpdateAvatarStorageDto { + @ApiProperty({ description: 'Ключ объекта в MinIO', example: 'avatars/uuid/photo.jpg' }) + @IsString({ message: 'Ключ хранилища должен быть строкой' }) + storageKey!: string; +} diff --git a/apps/api-gateway/src/dto/notifications.dto.ts b/apps/api-gateway/src/dto/notifications.dto.ts new file mode 100644 index 0000000..f6cfe1f --- /dev/null +++ b/apps/api-gateway/src/dto/notifications.dto.ts @@ -0,0 +1 @@ +export class MarkNotificationReadDto {} diff --git a/apps/api-gateway/src/dto/profile.dto.ts b/apps/api-gateway/src/dto/profile.dto.ts new file mode 100644 index 0000000..393df5a --- /dev/null +++ b/apps/api-gateway/src/dto/profile.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Matches, Max, Min, MinLength } from 'class-validator'; + +export class UpdateAvatarDto { + @ApiPropertyOptional({ description: 'Публичная ссылка на аватар (legacy)', example: 'https://cdn.lendry.ru/avatars/user.png' }) + @IsOptional() + @IsUrl({}, { message: 'Укажите корректную ссылку на аватар' }) + avatarUrl?: string; + + @ApiPropertyOptional({ description: 'Ключ объекта в MinIO', example: 'avatars/uuid/photo.jpg' }) + @IsOptional() + @IsString({ message: 'Ключ хранилища должен быть строкой' }) + avatarStorageKey?: string; +} + +export class UpdateProfileDto { + @ApiPropertyOptional({ description: 'Имя пользователя', example: 'Дмитрий' }) + @IsOptional() + @IsString({ message: 'Имя должно быть строкой' }) + firstName?: string; + + @ApiPropertyOptional({ description: 'Фамилия пользователя', example: 'Мамедов' }) + @IsOptional() + @IsString({ message: 'Фамилия должна быть строкой' }) + lastName?: string; + + @ApiPropertyOptional({ description: 'Краткое описание профиля', example: 'Разработчик и владелец аккаунта' }) + @IsOptional() + @IsString({ message: 'Описание должно быть строкой' }) + bio?: string; + + @ApiPropertyOptional({ description: 'Возраст пользователя', minimum: 0, maximum: 130, example: 24 }) + @IsOptional() + @IsInt({ message: 'Возраст должен быть целым числом' }) + @Min(0, { message: 'Возраст не может быть отрицательным' }) + @Max(130, { message: 'Возраст указан некорректно' }) + age?: number; + + @ApiPropertyOptional({ description: 'Пол пользователя', example: 'male' }) + @IsOptional() + @IsString({ message: 'Пол должен быть строкой' }) + gender?: string; +} + +export class UpdateContactsDto { + @ApiPropertyOptional({ description: 'Основная почта пользователя', example: 'user@example.com' }) + @IsOptional() + @IsEmail({}, { message: 'Укажите корректную почту' }) + email?: string; + + @ApiPropertyOptional({ description: 'Основной телефон пользователя', example: '+79990000000' }) + @IsOptional() + @Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный телефон' }) + phone?: string; + + @ApiPropertyOptional({ description: 'Резервная почта пользователя', example: 'backup@example.com' }) + @IsOptional() + @IsEmail({}, { message: 'Укажите корректную резервную почту' }) + backupEmail?: string; + + @ApiPropertyOptional({ description: 'Резервный телефон пользователя', example: '+79991112233' }) + @IsOptional() + @Matches(/^\+?[1-9]\d{7,14}$/, { message: 'Укажите корректный резервный телефон' }) + backupPhone?: string; +} + +export class SetPasswordDto { + @ApiProperty({ description: 'Новый пароль для входа', minLength: 8 }) + @IsString({ message: 'Пароль должен быть строкой' }) + @MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' }) + password!: string; +} + +export class UserIdParamDto { + @ApiProperty({ description: 'ID пользователя' }) + @IsString({ message: 'ID пользователя должен быть строкой' }) + @IsNotEmpty({ message: 'Передайте ID пользователя' }) + userId!: string; +} diff --git a/apps/api-gateway/src/dto/rbac.dto.ts b/apps/api-gateway/src/dto/rbac.dto.ts new file mode 100644 index 0000000..df98068 --- /dev/null +++ b/apps/api-gateway/src/dto/rbac.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; + +export enum GatewayOAuthClientType { + CONFIDENTIAL = 'CONFIDENTIAL', + PUBLIC = 'PUBLIC' +} + +export class CreateRoleDto { + @ApiProperty({ description: 'Уникальный slug роли', example: 'support' }) + @IsString({ message: 'Slug роли должен быть строкой' }) + slug!: string; + + @ApiProperty({ description: 'Название роли', example: 'Поддержка' }) + @IsString({ message: 'Название роли должно быть строкой' }) + name!: string; + + @ApiPropertyOptional({ description: 'Описание роли' }) + @IsOptional() + @IsString({ message: 'Описание должно быть строкой' }) + description?: string; + + @ApiPropertyOptional({ description: 'Список slug прав', type: [String] }) + @IsOptional() + @IsArray({ message: 'Права должны быть массивом' }) + @IsString({ each: true, message: 'Каждое право должно быть строкой' }) + permissionSlugs?: string[]; +} + +export class AssignUserRoleDto { + @ApiProperty({ description: 'Slug роли', example: 'admin' }) + @IsString({ message: 'Slug роли должен быть строкой' }) + roleSlug!: string; +} + +export class CreateOAuthClientDto { + @ApiProperty({ description: 'Название приложения', example: 'Lendry Docs' }) + @IsString({ message: 'Название должно быть строкой' }) + name!: string; + + @ApiProperty({ description: 'Redirect URI', type: [String], example: ['https://app.example.com/oauth/callback'] }) + @IsArray({ message: 'Redirect URI должны быть массивом' }) + @IsString({ each: true, message: 'Каждый redirect URI должен быть строкой' }) + redirectUris!: string[]; + + @ApiProperty({ description: 'Scopes', type: [String], example: ['openid', 'profile', 'email'] }) + @IsArray({ message: 'Scopes должны быть массивом' }) + @IsString({ each: true, message: 'Каждый scope должен быть строкой' }) + scopes!: string[]; + + @ApiPropertyOptional({ description: 'Тип клиента', enum: GatewayOAuthClientType }) + @IsOptional() + @IsEnum(GatewayOAuthClientType, { message: 'Укажите корректный тип клиента' }) + type?: GatewayOAuthClientType; +} + +export class UpdateOAuthClientDto { + @ApiPropertyOptional({ description: 'Название приложения' }) + @IsOptional() + @IsString({ message: 'Название должно быть строкой' }) + name?: string; + + @ApiPropertyOptional({ description: 'Redirect URI', type: [String] }) + @IsOptional() + @IsArray({ message: 'Redirect URI должны быть массивом' }) + @IsString({ each: true, message: 'Каждый redirect URI должен быть строкой' }) + redirectUris?: string[]; + + @ApiPropertyOptional({ description: 'Scopes', type: [String] }) + @IsOptional() + @IsArray({ message: 'Scopes должны быть массивом' }) + @IsString({ each: true, message: 'Каждый scope должен быть строкой' }) + scopes?: string[]; + + @ApiPropertyOptional({ description: 'Активность приложения' }) + @IsOptional() + @IsBoolean({ message: 'isActive должно быть boolean' }) + isActive?: boolean; +} diff --git a/apps/api-gateway/src/dto/security.dto.ts b/apps/api-gateway/src/dto/security.dto.ts new file mode 100644 index 0000000..0e5d529 --- /dev/null +++ b/apps/api-gateway/src/dto/security.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, Length, Matches } from 'class-validator'; + +export class PinDto { + @ApiProperty({ description: 'PIN-код из 4-6 цифр', example: '1234' }) + @IsString({ message: 'PIN-код должен быть строкой' }) + @Length(4, 6, { message: 'PIN-код должен содержать от 4 до 6 символов' }) + @Matches(/^\d+$/, { message: 'PIN-код должен содержать только цифры' }) + pin!: string; +} + +export class OptionalPinDto { + @ApiPropertyOptional({ description: 'PIN-код для подтверждения действия' }) + @IsOptional() + @IsString({ message: 'PIN-код должен быть строкой' }) + @Length(4, 6, { message: 'PIN-код должен содержать от 4 до 6 символов' }) + @Matches(/^\d+$/, { message: 'PIN-код должен содержать только цифры' }) + pin?: string; +} + +export class VerifySecurityPinDto extends PinDto { + @ApiProperty({ description: 'ID сессии, которую нужно разблокировать PIN-кодом' }) + @IsString({ message: 'ID сессии должен быть строкой' }) + sessionId!: string; +} diff --git a/apps/api-gateway/src/dto/settings.dto.ts b/apps/api-gateway/src/dto/settings.dto.ts new file mode 100644 index 0000000..35e5832 --- /dev/null +++ b/apps/api-gateway/src/dto/settings.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class UpsertSettingDto { + @ApiProperty({ description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' }) + @IsString({ message: 'Ключ настройки должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите ключ настройки' }) + key!: string; + + @ApiProperty({ description: 'Значение настройки', example: '15' }) + @IsString({ message: 'Значение настройки должно быть строкой' }) + value!: string; + + @ApiPropertyOptional({ description: 'Описание настройки для администраторов' }) + @IsOptional() + @IsString({ message: 'Описание должно быть строкой' }) + description?: string; + + @ApiPropertyOptional({ description: 'Скрывать ли значение в интерфейсе' }) + @IsOptional() + @IsBoolean({ message: 'Признак секрета должен быть логическим значением' }) + isSecret?: boolean; +} + +export class UpsertSocialProviderDto { + @ApiProperty({ description: 'Название OAuth провайдера', example: 'google' }) + @IsString({ message: 'Название провайдера должно быть строкой' }) + @IsNotEmpty({ message: 'Укажите провайдера' }) + providerName!: string; + + @ApiProperty({ description: 'OAuth Client ID провайдера' }) + @IsString({ message: 'Client ID должен быть строкой' }) + @IsNotEmpty({ message: 'Укажите Client ID' }) + clientId!: string; + + @ApiPropertyOptional({ description: 'OAuth Client Secret провайдера' }) + @IsOptional() + @IsString({ message: 'Client Secret должен быть строкой' }) + clientSecret?: string; + + @ApiProperty({ description: 'Включен ли провайдер для входа' }) + @IsBoolean({ message: 'Статус провайдера должен быть логическим значением' }) + isEnabled!: boolean; +} + +export class ConnectLinkedAccountDto { + @ApiProperty({ description: 'Название провайдера', example: 'google' }) + @IsString({ message: 'Название провайдера должно быть строкой' }) + providerName!: string; + + @ApiProperty({ description: 'ID пользователя у внешнего провайдера', example: 'google-user-123' }) + @IsString({ message: 'ID провайдера должен быть строкой' }) + providerId!: string; + + @ApiPropertyOptional({ description: 'Почта внешнего аккаунта' }) + @IsOptional() + @IsString({ message: 'Почта внешнего аккаунта должна быть строкой' }) + email?: string; + + @ApiPropertyOptional({ description: 'Отображаемое имя внешнего аккаунта' }) + @IsOptional() + @IsString({ message: 'Имя внешнего аккаунта должно быть строкой' }) + displayName?: string; + + @ApiPropertyOptional({ description: 'Аватар внешнего аккаунта' }) + @IsOptional() + @IsString({ message: 'Аватар внешнего аккаунта должен быть строкой' }) + avatarUrl?: string; +} diff --git a/apps/api-gateway/src/grpc-exception.filter.ts b/apps/api-gateway/src/grpc-exception.filter.ts new file mode 100644 index 0000000..7a6375d --- /dev/null +++ b/apps/api-gateway/src/grpc-exception.filter.ts @@ -0,0 +1,44 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; +import { status as GrpcStatus } from '@grpc/grpc-js'; + +interface HttpResponseLike { + status(code: number): { json(body: unknown): unknown }; +} + +function grpcToHttp(code?: number): number { + switch (code) { + case GrpcStatus.INVALID_ARGUMENT: + case GrpcStatus.FAILED_PRECONDITION: + case GrpcStatus.OUT_OF_RANGE: + return 400; + case GrpcStatus.UNAUTHENTICATED: + return 401; + case GrpcStatus.PERMISSION_DENIED: + return 403; + case GrpcStatus.NOT_FOUND: + return 404; + case GrpcStatus.ALREADY_EXISTS: + return 409; + default: + return 500; + } +} + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + + if (exception instanceof HttpException) { + const statusCode = exception.getStatus(); + const payload = exception.getResponse(); + response.status(statusCode).json(typeof payload === 'string' ? { statusCode, message: payload } : payload); + return; + } + + const grpcError = exception as { code?: number; details?: string; message?: string }; + const statusCode = grpcToHttp(grpcError?.code); + const message = grpcError?.details || grpcError?.message || 'Ошибка сервиса'; + response.status(statusCode).json({ statusCode, message }); + } +} diff --git a/apps/api-gateway/src/guards/admin.guard.ts b/apps/api-gateway/src/guards/admin.guard.ts new file mode 100644 index 0000000..b0e2088 --- /dev/null +++ b/apps/api-gateway/src/guards/admin.guard.ts @@ -0,0 +1,78 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { firstValueFrom } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; +import { assertSessionUnlocked, verifyAccessToken } from '../session-auth'; + +export interface AdminRequestUser { + id: string; + isSuperAdmin: boolean; + canAccessAdmin: boolean; + canManageRoles: boolean; + canManageOAuth: boolean; + canManageUsers: boolean; + canViewUsers: boolean; + canManageSettings: boolean; + roles: string[]; + permissions: string[]; +} + +@Injectable() +export class AdminGuard implements CanActivate { + constructor( + private readonly jwt: JwtService, + private readonly core: CoreGrpcService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ headers: { authorization?: string }; adminUser?: AdminRequestUser }>(); + + let payload; + try { + payload = await verifyAccessToken(this.jwt, request.headers.authorization); + await assertSessionUnlocked(this.core, payload); + } catch (error) { + if (error instanceof UnauthorizedException || error instanceof ForbiddenException) { + throw error; + } + throw new UnauthorizedException('Недействительный токен доступа'); + } + + const profile = (await firstValueFrom(this.core.auth.GetMe({ userId: payload.sub }))) as AdminRequestUser & { id: string }; + if (!profile.canAccessAdmin) { + throw new ForbiddenException('Доступ к админ-панели запрещён'); + } + + request.adminUser = { + id: profile.id, + isSuperAdmin: profile.isSuperAdmin, + canAccessAdmin: Boolean(profile.canAccessAdmin), + canManageRoles: Boolean(profile.canManageRoles), + canManageOAuth: Boolean(profile.canManageOAuth), + canManageUsers: Boolean(profile.canManageUsers), + canManageSettings: Boolean(profile.canManageSettings), + canViewUsers: Boolean(profile.canViewUsers), + roles: profile.roles ?? [], + permissions: profile.permissions ?? [] + }; + + return true; + } +} + +@Injectable() +export class SuperAdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ adminUser?: AdminRequestUser }>(); + if (!request.adminUser?.isSuperAdmin) { + throw new ForbiddenException('Только супер-администратор может выполнять это действие'); + } + return true; + } +} + +export function assertAdminPermission(user: AdminRequestUser | undefined, permission: keyof Pick) { + if (!user?.[permission]) { + throw new ForbiddenException('Недостаточно прав для выполнения действия'); + } +} diff --git a/apps/api-gateway/src/main.ts b/apps/api-gateway/src/main.ts new file mode 100644 index 0000000..f72fb6a --- /dev/null +++ b/apps/api-gateway/src/main.ts @@ -0,0 +1,34 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { AppModule } from './app.module'; +import { AllExceptionsFilter } from './grpc-exception.filter'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableCors({ origin: true, credentials: true }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true + }) + ); + app.useGlobalFilters(new AllExceptionsFilter()); + + const config = new DocumentBuilder() + .setTitle('Lendry ID API') + .setDescription('REST API для единого входа, безопасности, RBAC и администрирования Lendry ID.') + .setVersion('0.1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('docs', app, document, { + swaggerOptions: { persistAuthorization: true }, + customSiteTitle: 'Документация Lendry ID API' + }); + + await app.listen(process.env.PORT ? Number(process.env.PORT) : 3000); +} + +void bootstrap(); diff --git a/apps/api-gateway/src/media-content-disposition.ts b/apps/api-gateway/src/media-content-disposition.ts new file mode 100644 index 0000000..3705b96 --- /dev/null +++ b/apps/api-gateway/src/media-content-disposition.ts @@ -0,0 +1,5 @@ +export function buildContentDisposition(type: 'inline' | 'attachment', fileName: string) { + const asciiFallback = fileName.replace(/[^\x20-\x7E]+/g, '_').replace(/["\\]/g, '_') || 'file'; + const encoded = encodeURIComponent(fileName); + return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`; +} diff --git a/apps/api-gateway/src/session-auth.ts b/apps/api-gateway/src/session-auth.ts new file mode 100644 index 0000000..8e30c0b --- /dev/null +++ b/apps/api-gateway/src/session-auth.ts @@ -0,0 +1,62 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { firstValueFrom } from 'rxjs'; +import { extractBearerToken } from './auth-token'; +import { CoreGrpcService } from './core-grpc.service'; + +export interface AccessTokenPayload { + sub: string; + sessionId?: string; + pinVerified?: boolean; + isSuperAdmin?: boolean; +} + +export async function verifyAccessToken(jwt: JwtService, authorization?: string): Promise { + const token = extractBearerToken(authorization); + + try { + return await jwt.verifyAsync(token, { + secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret', + issuer: 'id.lendry.ru' + }); + } catch { + throw new UnauthorizedException({ + statusCode: 401, + message: 'Токен доступа недействителен или истёк', + code: 'TOKEN_EXPIRED' + }); + } +} + +export async function assertSessionUnlocked(core: CoreGrpcService, payload: AccessTokenPayload) { + if (!payload.sessionId) { + return; + } + + const validation = (await firstValueFrom( + core.auth.ValidateSession({ + userId: payload.sub, + sessionId: payload.sessionId, + touchActivity: true + }) + )) as { requiresPin: boolean; sessionId: string }; + + if (validation.requiresPin) { + throw new ForbiddenException({ + statusCode: 403, + message: 'Требуется подтверждение PIN-кода', + code: 'PIN_REQUIRED', + sessionId: validation.sessionId + }); + } +} + +export async function resolveAuthorizedPayload( + jwt: JwtService, + core: CoreGrpcService, + authorization?: string +): Promise { + const payload = await verifyAccessToken(jwt, authorization); + await assertSessionUnlocked(core, payload); + return payload; +} diff --git a/apps/api-gateway/tsconfig.json b/apps/api-gateway/tsconfig.json new file mode 100644 index 0000000..0719f4e --- /dev/null +++ b/apps/api-gateway/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/docs/Dockerfile b/apps/docs/Dockerfile new file mode 100644 index 0000000..86f618f --- /dev/null +++ b/apps/docs/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1.4 + +FROM node:24-alpine + +WORKDIR /app + +ARG NPM_REGISTRY=https://registry.npmjs.org +ARG NEXT_PUBLIC_API_URL=http://localhost:3000 + +COPY package.json package-lock.json .npmrc ./ +COPY apps/sso-core/package.json ./apps/sso-core/ +COPY apps/api-gateway/package.json ./apps/api-gateway/ +COPY apps/frontend/package.json ./apps/frontend/ +COPY apps/docs/package.json ./apps/docs/ + +RUN --mount=type=cache,target=/root/.npm \ + npm config set registry "${NPM_REGISTRY}" && \ + for i in 1 2 3 4 5; do \ + echo "npm ci: попытка $i/5" && \ + npm ci --no-audit --no-fund && exit 0; \ + echo "npm ci: ошибка сети, повтор через $((i * 15)) сек..."; \ + sleep $((i * 15)); \ + done; \ + echo "npm ci: все попытки исчерпаны"; \ + exit 1 + +COPY apps ./apps +COPY shared ./shared +COPY tsconfig.base.json ./ + +ENV NEXT_TELEMETRY_DISABLED="1" +ENV PORT="3000" +ENV HOSTNAME="0.0.0.0" +ENV NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}" + +RUN npm --workspace @lendry/docs run build + +EXPOSE 3000 + +CMD ["npm", "--workspace", "@lendry/docs", "run", "start"] diff --git a/apps/docs/app/api/public-settings/route.ts b/apps/docs/app/api/public-settings/route.ts new file mode 100644 index 0000000..e6d2197 --- /dev/null +++ b/apps/docs/app/api/public-settings/route.ts @@ -0,0 +1,17 @@ +import { fetchPublicSettings } from '@/lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const settings = await fetchPublicSettings(); + return Response.json( + { + settings: Object.entries(settings).map(([key, value]) => ({ key, value })) + }, + { + headers: { + 'Cache-Control': 'no-store, max-age=0' + } + } + ); +} diff --git a/apps/docs/app/docs/[slug]/page.tsx b/apps/docs/app/docs/[slug]/page.tsx new file mode 100644 index 0000000..4e687ed --- /dev/null +++ b/apps/docs/app/docs/[slug]/page.tsx @@ -0,0 +1,45 @@ +import { notFound } from 'next/navigation'; +import { DocBlockRenderer } from '@/components/doc-block-renderer'; +import { DocsToc } from '@/components/docs-shell'; +import { getAllDocSlugs, getDocPage } from '@/lib/docs-pages'; + +export function generateStaticParams() { + return getAllDocSlugs().map((slug) => ({ slug })); +} + +export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const page = getDocPage(slug); + if (!page) notFound(); + + return ( + <> +
+
+

Документация

+

{page.title}

+

{page.description}

+
+ +
+ {page.sections.map((section) => ( +
+

{section.title}

+
+ {section.blocks.map((block, index) => ( + + ))} +
+
+ ))} +
+
+ + + + ); +} diff --git a/apps/docs/app/docs/layout.tsx b/apps/docs/app/docs/layout.tsx new file mode 100644 index 0000000..e4d6aa4 --- /dev/null +++ b/apps/docs/app/docs/layout.tsx @@ -0,0 +1,27 @@ +import { docNavigation, groupDocNavigation } from '@/lib/navigation'; +import { fetchPublicSettings } from '@/lib/api'; +import { PublicSettingsProvider } from '@/providers/public-settings-provider'; +import { DocsHeader, DocsSidebar } from '@/components/docs-shell'; + +export const dynamic = 'force-dynamic'; + +export default async function DocsLayout({ children }: { children: React.ReactNode }) { + const initialSettings = await fetchPublicSettings(); + const groups = groupDocNavigation(docNavigation); + + return ( + +
+ +
+ + {children} +
+
+
+ ); +} diff --git a/apps/docs/app/docs/page.tsx b/apps/docs/app/docs/page.tsx new file mode 100644 index 0000000..b762dfd --- /dev/null +++ b/apps/docs/app/docs/page.tsx @@ -0,0 +1,5 @@ +import { DocsHomeContent } from '@/components/docs-home-content'; + +export default function DocsHomePage() { + return ; +} diff --git a/apps/docs/app/globals.css b/apps/docs/app/globals.css new file mode 100644 index 0000000..ca40162 --- /dev/null +++ b/apps/docs/app/globals.css @@ -0,0 +1,46 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +:root { + --background: #ffffff; + --foreground: #09090b; + --muted: #f4f4f5; + --border: #e4e4e7; +} + +.dark { + --background: #09090b; + --foreground: #fafafa; + --muted: #18181b; + --border: #27272a; +} + +@theme inline { + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, monospace; +} + +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +a { + text-decoration: none; +} + +::selection { + background: rgb(24 24 27 / 0.15); +} + +.dark ::selection { + background: rgb(250 250 250 / 0.15); +} + +.prose-docs h2 { + scroll-margin-top: 5rem; +} diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx new file mode 100644 index 0000000..5174ba5 --- /dev/null +++ b/apps/docs/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import { ThemeProvider } from '@/providers/theme-provider'; +import './globals.css'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin', 'cyrillic'] +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'] +}); + +export const metadata: Metadata = { + title: { + default: 'Документация', + template: '%s — Docs' + }, + description: 'Документация по интеграции, OAuth 2.0, развёртыванию и REST API Identity Provider.' +}; + +const themeScript = `(function(){try{var t=localStorage.getItem('docs-theme');var dark=t==='dark'||(t!=='light'&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',dark);}catch(e){}})();`; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +