213 Commits

Author SHA1 Message Date
Mathis HERRIOT
f34fd644b8 chore: bump version to 1.9.5
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 17s
2026-01-29 21:42:34 +01:00
Mathis HERRIOT
c827c2e58d feat(database): increase passwordHash length and add migration snapshot
- Extended `passwordHash` field length in `users` schema from 100 to 255.
- Added migration snapshot for schema updates.
2026-01-29 21:42:05 +01:00
Mathis HERRIOT
30bcfdb436 chore: bump version to 1.9.4
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m30s
CI/CD Pipeline / Valider documentation (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m26s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m19s
2026-01-29 20:49:07 +01:00
Mathis HERRIOT
0b4753c47b style(messages): reformat import statements in MessagesService 2026-01-29 20:48:57 +01:00
Mathis HERRIOT
69b90849fd feat(messages): integrate UsersModule into MessagesModule with forward-ref
- Added `UsersModule` to `MessagesModule` imports using `forwardRef`.
- Injected `UsersService` into `MessagesService` to support user-related operations.
2026-01-29 20:44:35 +01:00
Mathis HERRIOT
f2950ecf86 chore: bump version to 1.9.3
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m16s
2026-01-29 20:33:19 +01:00
Mathis HERRIOT
1e17308aab feat(realtime): add ConfigModule and UsersModule to RealtimeModule
- Integrated `ConfigModule` for configuration management.
- Added `UsersModule` to enable forward-ref dependencies in realtime services.
2026-01-29 20:32:34 +01:00
Mathis HERRIOT
ca4b594828 chore: bump version to 1.9.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Valider frontend (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m36s
2026-01-29 18:22:00 +01:00
Mathis HERRIOT
2ea16773c8 feat(users): add boolean fields for online status and read receipts
- Added `showOnlineStatus` and `showReadReceipts` fields to `UpdateUserDto` with validation.
2026-01-29 18:21:54 +01:00
Mathis HERRIOT
616d7f76d7 feat: add support for online status and read receipt preferences
- Added `showOnlineStatus` and `showReadReceipts` fields to settings form.
- Introduced real-time synchronization for read receipts in message threads.
- Enhanced avatars to display online status indicators.
- Automatically mark messages as read when viewing active conversations.
2026-01-29 18:20:58 +01:00
Mathis HERRIOT
f882a70343 feat: add read receipt handling based on user preferences
- Integrated `UsersService` into `MessagesService` for retrieving user preferences.
- Updated `markAsRead` functionality to respect `showReadReceipts` preference.
- Enhanced real-time read receipt notifications via `EventsGateway`.
- Added `markAsRead` method to the frontend message service.
2026-01-29 18:20:18 +01:00
Mathis HERRIOT
779bb5c112 feat: integrate user preferences for online status in WebSocket gateway
- Added `UsersService` to manage user preferences in `EventsGateway`.
- Enhanced online/offline broadcasting to respect user `showOnlineStatus` preference.
- Updated `handleTyping` and `check_status` to verify user preferences before emitting events.
- Abstracted status broadcasting logic into `broadcastStatus`.
2026-01-29 18:20:04 +01:00
Mathis HERRIOT
5753477717 feat: add user preferences for online status and read receipts with real-time updates
- Introduced `showOnlineStatus` and `showReadReceipts` fields in the user schema and API.
- Integrated real-time status broadcasting in `UsersService` via `EventsGateway`.
- Updated repository and frontend user types to align with new fields.
- Enhanced user update handling to support dynamic preference changes for online status.
2026-01-29 18:18:52 +01:00
Mathis HERRIOT
7615ec670e chore: bump version to 1.9.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m58s
2026-01-29 17:44:55 +01:00
Mathis HERRIOT
40cfff683d fix: ensure decrypted PGP values are cast to text in SQL queries
- Added `::text` cast to `pgp_sym_decrypt` function calls for consistent data type handling.
2026-01-29 17:44:50 +01:00
Mathis HERRIOT
bb52782226 feat: enhance environment configuration and CORS handling
- Added `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_CONTACT_EMAIL` to environment variables for frontend configuration.
- Updated CORS logic to support domain-based restrictions with dynamic origin validation.
- Improved frontend image hostname resolution using environment-driven URLs.
- Standardized contact email usage across the application.
2026-01-29 17:34:53 +01:00
Mathis HERRIOT
6a70274623 fix: handle null enriched comment in comments service
- Added a null check for `enrichedComment` to prevent processing invalid data and potential runtime errors.
2026-01-29 17:22:00 +01:00
Mathis HERRIOT
aabc615b89 feat: enhance CORS and user connection handling in WebSocket gateway
- Improved CORS configuration to allow specific origins for development and mobile use cases.
- Added validation for token payload to ensure `sub` property is present.
- Enhanced user connection management by using `userId` consistently for online status tracking and room joining.
2026-01-29 17:21:53 +01:00
Mathis HERRIOT
f9b202375f feat: improve accessibility, security & user interaction in notifications and setup
- Replaced `div` with `button` elements in `NotificationHandler` for better semantics and accessibility.
- Added conditional QR Code reveal in 2FA setup with `blur` effect for enhanced security and user control.
- Enhanced messages layout for responsiveness on smaller screens with dynamic chat/sidebar toggling.
- Simplified legacy prop handling in `ShareDialog`.
2026-01-29 17:21:19 +01:00
Mathis HERRIOT
6398965f16 chore: bump version to 1.9.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m13s
CI/CD Pipeline / Valider frontend (push) Failing after 1m18s
CI/CD Pipeline / Valider documentation (push) Successful in 1m44s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-29 17:06:14 +01:00
Mathis HERRIOT
9e9b1db012 feat: manage user online status and typing indicator in socket gateway
- Added tracking of online users with real-time status updates (online/offline).
- Implemented `handleTyping` to broadcast user typing events to recipients.
- Added `check_status` handler to query user online status.
- Enhanced CORS configuration to support multi-domain deployments with credentials.
2026-01-29 16:56:36 +01:00
Mathis HERRIOT
62bf03d07a feat: implement NotificationHandler component for real-time notifications
- Added `NotificationHandler` component for managing real-time notifications using sockets.
- Display notifications for comments, replies, likes, and messages with interactive toasts.
- Integrated click handling for navigation to relevant pages based on notification type.
2026-01-29 16:56:19 +01:00
Mathis HERRIOT
c83ba6eb7d feat: add NotificationHandler component to layout
- Integrated `NotificationHandler` into the app layout for centralized notification management.
- Ensured compatibility with existing `Toaster` component for consistent user feedback.
2026-01-29 16:51:20 +01:00
Mathis HERRIOT
05a05a1940 feat: add share dialog and typing indicator in messages
- Implemented `ShareDialog` component for sharing content directly with other users.
- Added typing indicator when a user is composing a message in an active conversation.
- Updated `SocketProvider` to handle improved connection management and user status updates.
- Enhanced the messages UI with real-time online status and typing indicators for better feedback.
2026-01-29 16:50:53 +01:00
Mathis HERRIOT
7c065a2fb9 feat: inject ContentsRepository into CommentsService for better integration
- Added `ContentsRepository` as a dependency to `CommentsService` and updated tests for mock setup.
- Adjusted import order in relevant files to align with project standards.
2026-01-29 16:49:13 +01:00
Mathis HERRIOT
001cdaff8f feat: add notification system for comments and likes
- Notify post authors when their content receives a new comment.
- Notify parent comment authors when their comment receives a reply.
- Send notifications to comment authors when their comments are liked.
- Handle notification errors gracefully with error
2026-01-29 16:41:13 +01:00
Mathis HERRIOT
0eb940c5ce feat: add ContentsModule to CommentsModule with forward reference
- Updated imports in `comments.module.ts` to include `ContentsModule` using `forwardRef` for dependency resolution.
2026-01-29 16:22:55 +01:00
Mathis HERRIOT
f0617c8ba5 chore: bump version to 1.8.3
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m53s
2026-01-29 16:10:08 +01:00
Mathis HERRIOT
27ea6fa413 feat: add twoFactorEnabled field to User type definition 2026-01-29 16:09:13 +01:00
Mathis HERRIOT
e2146f4502 feat: update exportData method with improved type annotations
- Refined `exportData` method to use `Record<string, unknown>` for more precise type safety.
2026-01-29 16:09:00 +01:00
Mathis HERRIOT
484b775923 feat: update updateUser method to use Partial<User> for improved type safety
- Refactored `updateUser` method in `admin.service.ts` to accept `Partial<User>` instead of `any`.
- Added `User` type import for more precise typing.
2026-01-29 16:06:45 +01:00
Mathis HERRIOT
5b05a14932 feat: update 2FA QR code rendering with Next.js Image
- Replaced `<img>` with Next.js `<Image>` for optimized 2FA QR code rendering.
- Refined `twoFactorEnabled` check for improved readability.
2026-01-29 16:04:58 +01:00
Mathis HERRIOT
2704f7d5c5 feat: add Link import for navigation in messages page
- Introduced `Link` from Next.js for improved inter-page navigation.
2026-01-29 16:01:11 +01:00
Mathis HERRIOT
d271cc215b feat: improve message scrolling and enhance conversation header UX
- Fixed auto-scrolling to the latest message by targeting the correct scroll container.
- Updated the conversation header to include a clickable link to the recipient's profile.
2026-01-29 15:57:25 +01:00
Mathis HERRIOT
9eb5a60fb2 feat: add unread messages badge and live updates in sidebar
- Display unread message count badge in the sidebar.
- Integrate `useSocket` for real-time updates on unread messages.
- Reset unread message count when navigating to the messages page.
- Increment badge count on receiving `new_message` WebSocket events.
2026-01-29 15:56:16 +01:00
Mathis HERRIOT
950646a426 feat: add WebSocket integration for live comment updates
- Introduced `useSocket` to manage WebSocket connections in comment sections.
- Implemented real-time comment updates via `new_comment` WebSocket events.
- Added auto-join and leave for content-specific rooms using WebSocket upon mounting/unmounting.
2026-01-29 15:55:39 +01:00
Mathis HERRIOT
a9b80e66cd feat: enhance user search query with additional filter
- Updated `UsersRepository` to support `lte` condition in user search queries.
- Improved search flexibility by refining query logic with enhanced filters.
2026-01-29 15:55:10 +01:00
Mathis HERRIOT
307655371d feat: add content room subscription and messaging support
- Added `join_content` and `leave_content` WebSocket events for subscribing and unsubscribing to content rooms.
- Implemented `sendToContent` utility method for broadcasting messages to specific content rooms.
- Enhanced connection handling with logging and session validation updates.
2026-01-29 15:54:39 +01:00
Mathis HERRIOT
8eb0cba050 test: improve unit tests with new mocks and WebSocket validation
- Added `markAsRead` and `countUnreadMessages` mocks to `MessagesService` tests.
- Included enriched comment retrieval and WebSocket notification validation in `CommentsService` tests.
- Updated dependency injection to include `EventsGateway` in `CommentsService` tests.
2026-01-29 15:54:16 +01:00
Mathis HERRIOT
50787c9357 feat: enhance messaging system with user search and direct conversations
- Added user-to-user messaging via profile pages.
- Implemented user search functionality with instant result display in the messaging sidebar.
- Introduced support for temporary chat interfaces when messaging new users without prior conversations.
- Included "Message read status" updates with improved UX for message timestamps.
2026-01-29 15:53:53 +01:00
Mathis HERRIOT
0972ed951f feat: add unread message count API
- Added `GET /messages/unread-count` endpoint to retrieve the count of unread messages for a user.
- Implemented `getUnreadCount` method in `MessagesService` and `MessagesRepository`.
- Updated frontend service to support fetching unread message count via API.
2026-01-29 15:47:43 +01:00
Mathis HERRIOT
f852835c59 feat: add user search functionality
- Implemented `GET /users/search` endpoint in the backend to enable user search by username or display name.
- Added `search` method in `UsersService` and `UsersRepository`.
- Updated frontend `UserService` to support the new search API.
2026-01-29 15:47:03 +01:00
Mathis HERRIOT
2c18fd1c1a feat: add API for fetching direct conversation with a user
- Added `GET /messages/conversations/with/:userId` endpoint in the backend to retrieve direct conversation data.
- Implemented corresponding method in `MessagesService` and `MessagesRepository`.
- Updated the frontend service to support fetching direct conversations via API.
2026-01-29 15:46:38 +01:00
Mathis HERRIOT
6d80795e44 feat: add WebSocket notifications for new comments
- Introduced enriched comment retrieval with user information and like statistics.
- Implemented WebSocket notifications to notify users of new comments on content.
- Updated dependency injection to include `EventsGateway` and `RealtimeModule`.
2026-01-29 15:46:00 +01:00
Mathis HERRIOT
ace438be6b chore: bump version to 1.8.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m33s
2026-01-29 15:33:51 +01:00
Mathis HERRIOT
ea1afa7688 test: add unit tests for comment liking and enriched comment data
- Added tests for comment liking (`like` and `unlike` methods).
- Improved `findAllByContentId` tests to cover enriched comment data (likes count, isLiked, and user avatar URL resolution).
- Mocked new dependencies (`CommentLikesRepository` and `S3Service`) in `CommentsService` unit tests.
2026-01-29 15:30:20 +01:00
Mathis HERRIOT
0976850c0c feat: add comment replies and liking functionality
- Introduced support for nested comment replies in both frontend and backend.
- Added comment liking and unliking features, including like count and "isLiked" state tracking.
- Updated database schema with `parentId` and new `comment_likes` table.
- Enhanced UI for threaded comments and implemented display of like counts and reply actions.
- Refactored APIs and repositories to support replies, likes, and enriched comment data.
2026-01-29 15:26:54 +01:00
Mathis HERRIOT
ed3ed66cab feat: add database snapshot for schema changes
- Created a snapshot to reflect the updated database schema, including new tables `api_keys`, `audit_logs`, `categories`, `comments`, `contents`, and related relationships.
- Includes indexes, unique constraints, and foreign key definitions.
2026-01-29 15:26:09 +01:00
Mathis HERRIOT
46ffdd809c chore: bump version to 1.8.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m30s
2026-01-29 15:11:01 +01:00
Mathis HERRIOT
2dcd277347 test: rename variables and format multiline assertions in messages service spec
- Renamed `repository` and `eventsGateway` to `_repository` and `_eventsGateway` to follow conventions for unused test variables.
- Reformatted multiline assertions for better readability.
2026-01-29 15:10:56 +01:00
Mathis HERRIOT
9486803aeb test: rename variables and format multiline assertion in events gateway spec
- Renamed `jwtService` to `_jwtService` to align with conventions for unused test variables.
- Adjusted multiline assertion formatting for improved readability.
2026-01-29 15:10:43 +01:00
Mathis HERRIOT
1e0bb03182 test: format multiline assertion in comments service spec
- Adjusted `remove` test to improve readability of multiline assertion.
2026-01-29 15:10:22 +01:00
Mathis HERRIOT
f1d1359dcb chore: bump version to 1.8.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 57s
CI/CD Pipeline / Valider frontend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 1m39s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-29 15:06:20 +01:00
Mathis HERRIOT
7b76942795 test: add unit tests for messaging, comments, events, and user services
- Added comprehensive unit tests for `MessagesService`, `CommentsService`, `EventsGateway`, and enhancements in `UsersService`.
- Ensured proper mocking and test coverage for newly introduced dependencies like `EventsGateway` and `RBACService`.
2026-01-29 15:06:12 +01:00
Mathis HERRIOT
1be8571f26 chore: bump version to 1.7.5
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m8s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-29 14:58:09 +01:00
Mathis HERRIOT
29b1db4aed feat: add ViewCounter enhancements and file upload progress tracking
- Improved `ViewCounter` with visibility-based view increment using `IntersectionObserver` and 50% video progress tracking.
- Added real-time file upload progress updates via Socket.io, including status and percentage feedback.
- Integrated `ViewCounter` dynamically into `ContentCard` and removed redundant instances from static pages.
- Updated backend upload logic to emit progress updates at different stages via the `EventsGateway`.
2026-01-29 14:57:44 +01:00
Mathis HERRIOT
9db3067721 refactor: improve import order and code formatting
- Reordered and grouped imports consistently in backend and frontend files for better readability.
- Applied indentation and formatting fixes across frontend components, services, and backend modules.
- Adjusted multiline method calls and type definitions for improved clarity.
2026-01-29 14:44:34 +01:00
Mathis HERRIOT
27f8c7148a feat: enhance user service with role assignment and frontend scroll-area ref support
- Updated `users.service.ts` to assign user roles dynamically based on RBAC.
- Enhanced JWT generation to include the user's role in `auth.service.ts`.
- Added `viewportRef` prop support to `ScrollArea` component in the frontend for improved flexibility.
2026-01-29 14:43:01 +01:00
Mathis HERRIOT
209711195b feat: include user role in JWT payload
- Updated `request.interface.ts` to add `role` to the user object.
- Modified `auth.service.ts` to include `role` in the JWT payload.
2026-01-29 14:37:45 +01:00
Mathis HERRIOT
fafdaee668 feat: implement messaging functionality with real-time updates
- Introduced a messaging module on the backend using NestJS, including repository, service, controller, DTOs, and WebSocket Gateway.
- Developed a frontend messaging page with conversation management, real-time message handling, and chat UI.
- Implemented `MessageService` for API integrations and `SocketProvider` for real-time WebSocket updates.
- Enhanced database schema to support conversations, participants, and messages with Drizzle ORM.
2026-01-29 14:34:22 +01:00
Mathis HERRIOT
01117aad6d feat: add comments functionality and integrate Socket.io for real-time updates
- Implemented a full comments module in the backend with repository, service, controller, and DTOs using NestJS.
- Added frontend support for comments with a `CommentSection` component and integration into content pages.
- Introduced `SocketProvider` on the frontend and integrated Socket.io for real-time communication.
- Updated dependencies and configurations for Socket.io and WebSockets support.
2026-01-29 14:33:34 +01:00
Mathis HERRIOT
e73ae80fc5 chore: bump version to 1.7.4
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m26s
2026-01-29 14:11:38 +01:00
Mathis HERRIOT
9ccbd2ceb1 refactor: improve formatting, type safety, and component organization
- Adjusted inconsistent formatting for better readability across components and services.
- Enhanced type safety by adding placeholders for ignored error parameters and improving types across services.
- Improved component organization by reordering imports consistently and applying formatting updates in UI components.
2026-01-29 14:11:28 +01:00
Mathis HERRIOT
3edf5cc070 Merge remote-tracking branch 'origin/main' 2026-01-29 14:05:09 +01:00
Mathis HERRIOT
2d670ad9cf chore: bump version to 1.7.3
Some checks failed
CI/CD Pipeline / Valider frontend (push) Failing after 59s
CI/CD Pipeline / Valider backend (push) Successful in 1m33s
CI/CD Pipeline / Valider documentation (push) Successful in 1m38s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-29 14:03:10 +01:00
Mathis HERRIOT
fc2f5214b1 feat: implement IP banning in crawler-detection middleware using cache manager
- Added Redis-based temporary IP banning for suspicious activity detected by the middleware.
- Injected `CACHE_MANAGER` into the middleware to manage banned IPs.
- Enhanced logging to track banned IP attempts.
- Adjusted middleware logic to handle asynchronous IP checks and updates.
2026-01-29 14:02:49 +01:00
Mathis HERRIOT
aa17c57e26 feat: add data export functionality to settings page and update admin reports table
- Introduced "Export Data" card in settings for exporting user data as a JSON file.
- Added `exportData` method to `UserService` for handling data export requests.
- Updated admin reports table with a new "Cible" column to display target information.
2026-01-29 13:57:07 +01:00
Mathis HERRIOT
004021ff84 feat: display reporter and content details in admin reports table
- Added "Signalé par" column to show reporter ID.
- Displayed content links or "Tag" for reported items.
2026-01-29 13:55:34 +01:00
Mathis HERRIOT
586d827552 feat: add admin reports page for managing user reports
- Introduced a new admin reports page at `/admin/reports`.
- Added functionality to fetch, display, and update the status of user reports.
- Integrated status management with options to review, resolve, and dismiss reports.
2026-01-29 13:52:55 +01:00
Mathis HERRIOT
17fc8d4b68 feat: add REAC_CDA_V04_24052023.pdf file 2026-01-29 13:52:29 +01:00
Mathis HERRIOT
4a66676fcb feat: add reports section to admin dashboard
- Introduced a new "Signalements" card with navigation to `/admin/reports`.
- Added `Flag` icon for the reports section.
2026-01-29 13:52:16 +01:00
Mathis HERRIOT
48db40b3d4 feat: integrate TwoFactorSetup component into settings page
- Added `TwoFactorSetup` to settings for 2FA configuration.
- Enhanced security options in user settings.
2026-01-29 13:51:46 +01:00
Mathis HERRIOT
c32d4e7203 feat: add 2FA verification to auth provider
- Introduced `verify2fa` method for handling two-factor authentication.
- Updated `login` to support 2FA response handling.
- Enhanced `AuthContext` with new `verify2fa` method and types.
2026-01-29 13:51:32 +01:00
Mathis HERRIOT
9b7c2c8e5b feat: add 2FA verification to auth provider
- Introduced `verify2fa` method for handling two-factor authentication.
- Updated `login` to support 2FA response handling.
- Enhanced `AuthContext` with new `verify2fa` method and types.
2026-01-29 13:51:20 +01:00
Mathis HERRIOT
0584c46190 feat: add 2FA prompt and OTP input to login flow
- Integrated 2FA verification into the login process.
- Added conditional rendering for OTP input.
- Updated UI to support dynamic switching between login and 2FA views.
- Introduced new state variables for managing 2FA logic.
2026-01-29 13:49:54 +01:00
Mathis HERRIOT
13ccdbc2ab feat: introduce reporting system and two-factor authentication (2FA)
- Added `ReportDialog` component for user-generated content reporting.
- Integrated `ReportService` with create, update, and fetch report functionalities.
- Enhanced `AuthService` with 2FA setup, enable, disable, and verification methods.
- Updated types to include 2FA responses and reporting-related data.
- Enhanced `ContentCard` UI to support reporting functionality.
- Improved admin services to manage user reports and statuses.
2026-01-29 13:48:59 +01:00
a4d0c6aa8c feat(auth): enhance validation rules for username and password
- Updated username validation to allow only lowercase letters, numbers, and underscores.
- Strengthened password requirements to include at least 8 characters, one uppercase letter, one lowercase letter, one number, and one special character.
- Adjusted frontend forms and backend DTOs to reflect new validation rules.
2026-01-28 21:48:23 +01:00
Mathis HERRIOT
ba0234fd13 chore: bump version to 1.7.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m30s
2026-01-28 20:56:56 +01:00
Mathis HERRIOT
81461d04e9 chore: update pnpm-lock.yaml to reflect dependency changes
- Upgraded lockfile version to 9.0.
- Updated dependencies and devDependencies to align with recent changes.
2026-01-28 20:56:44 +01:00
c4e6be4452 chore: bump version to 1.7.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Failing after 5s
2026-01-28 20:49:22 +01:00
18288cf8f3 chore(docker): enforce --force flag for pnpm install across all Dockerfiles 2026-01-28 20:49:16 +01:00
3ffc5b6fde chore: bump version to 1.7.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m40s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m49s
CI/CD Pipeline / Déploiement en Production (push) Failing after 6s
2026-01-28 20:40:45 +01:00
5413774cf4 chore: update pnpm-lock.yaml to reflect lockfile v6, update dependencies versions, and remove redundant nested dependency details 2026-01-28 20:37:39 +01:00
e342eacc69 style: add utility class for scrollbar hiding and align content correctly in meme page layout 2026-01-28 20:35:55 +01:00
Mathis HERRIOT
60643f6aa8 chore: bump version to 1.6.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m32s
2026-01-28 16:30:34 +01:00
Mathis HERRIOT
929dd74ec1 feat: add AudioProvider for global audio state management
- Introduced `AudioProvider` with context for managing global mute state and active video.
- Added `useAudio` hook to access AudioContext conveniently.
2026-01-28 16:30:28 +01:00
Mathis HERRIOT
87534c0596 refactor: enhance content card and layout with video handling and audio controls
- Added mute toggle for video content in content card.
- Integrated `AudioProvider` for global audio state management.
- Improved content card layout with dynamic aspect ratio support.
- Updated content list to use a masonry-style layout for better visual presentation.
2026-01-28 16:29:56 +01:00
Mathis HERRIOT
fa673d0f80 chore: bump version to 1.5.6
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m32s
2026-01-28 15:39:56 +01:00
Mathis HERRIOT
8df6d15b19 refactor: update content card and list layout for better responsiveness
- Removed unused aspect-video class from content card layout.
- Improved content list layout by switching to a responsive grid system.
2026-01-28 15:39:44 +01:00
Mathis HERRIOT
0144421f03 chore: bump version to 1.5.5
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m36s
2026-01-28 15:00:02 +01:00
Mathis HERRIOT
df9a6c6f36 refactor: update test mocks to use addOutputOptions for consistency
- Replaced `outputOptions` with `addOutputOptions` in media service spec test to align with updated FFmpeg strategy.
2026-01-28 14:59:40 +01:00
Mathis HERRIOT
15426a9e18 chore: bump version to 1.5.4
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m10s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-28 14:45:48 +01:00
Mathis HERRIOT
a28844e9b7 refactor: replace outputOptions with addOutputOptions in video processor strategy
- Updated FFmpeg command to use `addOutputOptions` for improved readability and consistency.
2026-01-28 14:45:41 +01:00
Mathis HERRIOT
ae916931f6 chore: bump version to 1.5.3
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m33s
2026-01-28 14:39:43 +01:00
Mathis HERRIOT
e4dc5dd10b chore: simplify FFmpeg installation in Dockerfile
- Replaced custom FFmpeg build process with `apk add --no-cache ffmpeg` for reduced complexity and faster builds.
2026-01-28 14:39:33 +01:00
Mathis HERRIOT
878c35cbcd chore: bump version to 1.5.2
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 1m42s
CI/CD Pipeline / Valider frontend (push) Successful in 1m49s
CI/CD Pipeline / Déploiement en Production (push) Failing after 18s
2026-01-28 14:32:57 +01:00
Mathis HERRIOT
8cf0036248 chore: update Dockerfile to fix FFmpeg download URL
- Replaced the FFmpeg URL with a version that supports redirection handling using `-L` flag.
2026-01-28 14:32:52 +01:00
Mathis HERRIOT
c389024f59 chore: bump version to 1.5.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Valider frontend (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Failing after 13s
2026-01-28 14:27:43 +01:00
Mathis HERRIOT
bbdbe58af5 feat: add FFmpeg installation to Dockerfile for media processing
- Configured Dockerfile to install FFmpeg with support for various codecs and libraries.
- Optimized build process by cleaning up temporary files post-installation.
2026-01-28 14:27:29 +01:00
Mathis HERRIOT
5951e41eb5 chore: bump version to 1.5.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m35s
2026-01-28 14:14:42 +01:00
Mathis HERRIOT
7442236e8d feat: add 'video' as a new value to content_type enum
- Updated backend migrations to include 'video' in the `content_type` enum.
- Synced migration metadata files to reflect the schema changes.
2026-01-28 14:14:05 +01:00
Mathis HERRIOT
3ef7292287 feat: add captions support for video uploads
- Enhanced video previews with `<track>` element for captions.
2026-01-28 14:10:59 +01:00
Mathis HERRIOT
f1a571196d feat: add video upload feature with support for validation and processing
- Introduced "video" as a new content type across backend and frontend.
- Updated validation schemas and MIME-type handling for video files.
- Implemented file size limits for videos (10 MB max) in configuration.
- Enhanced upload flow with auto-detection of file types (image, GIF, video).
- Expanded media processing to handle video files and convert them to WebM format.
2026-01-28 14:07:00 +01:00
Mathis HERRIOT
f4cd20a010 docs: add PlantUML backend architecture diagram
- Introduced a detailed PlantUML diagram illustrating backend modules, services, controllers, and key relationships.
- Enhanced documentation with visual representation of system architecture for improved understanding.
2026-01-28 14:00:46 +01:00
Mathis HERRIOT
988eacc281 docs: remove detailed table of contents from project dossier
- Simplified the document by removing the exhaustive table of contents to enhance readability.
- Maintained key subsections and updated in-text references for smooth navigation.
2026-01-28 13:19:49 +01:00
Mathis HERRIOT
329a150ff8 docs: refine and expand project dossier content
- Improved structure with updated headings and enhanced indentation for clarity.
- Added new sections: "Analyse et Conception," personas, user stories, and advanced diagrams (use cases, sequence flows).
- Expanded content on backend architecture, security features, and compliance (RGPD, ANSSI).
- Updated annexes with additional resources and technical glossary entries.
2026-01-28 13:01:10 +01:00
Mathis HERRIOT
4372f75025 docs: remove POO class diagram from annex in project dossier
- Deleted the class diagram representing backend entities to simplify and declutter the annex section.
2026-01-28 12:36:43 +01:00
Mathis HERRIOT
4fa163b542 docs: improve and expand project dossier structure and content
- Refined document structure with clearer heading hierarchy and indentation.
- Enhanced functional and non-functional specifications with additional subsections and technical details.
- Added comprehensive descriptions for security (PGP, ML-KEM), accessibility (A11Y), and observability improvements.
- Introduced new annexes for backend/frontend technical dossiers, live demonstrations, and advanced workflows.
2026-01-27 14:27:42 +01:00
Mathis HERRIOT
7f0749808e docs: expand project dossier with advanced CRUD flows and architecture details
- Added comprehensive sections on business workflows, CRUD operations, and security features.
- Enhanced documentation with detailed data validation, media handling, and lifecycle management.
- Incorporated diagrams for authentication, media upload, content moderation, and caching strategies.
- Reorganized and updated sections to align with newly introduced content and flow improvements.
2026-01-27 13:31:08 +01:00
Mathis HERRIOT
bcbc93d6a3 docs: restructure and enhance project dossier
- Consolidated and reordered headings for clarity and coherence.
- Updated content under functional and non-functional specifications for better readability.
- Improved sections on security, observability, and cryptography.
- Added new subsections on Green IT, accessibility, and regulatory compliance (RGPD).
- Optimized technical glossary for precision and expanded explanations.
2026-01-27 13:22:20 +01:00
Mathis HERRIOT
89587d6abc docs: update project dossier with additional sections and enhancements
- Added detailed objectives, technical overview, and regulatory compliance for "Memegoat".
- Expanded functional and non-functional specifications with rich examples and new subsections.
- Enhanced documentation with accessibility, security, UX, and Green IT strategies.
- Revised and restructured content for coherence and clarity.
2026-01-27 12:50:36 +01:00
Mathis HERRIOT
3347d693ce docs: add project dossier with detailed specifications and technical overview
- Introduced `dossier-de-projet-cda.md` outlining project scope, objectives, and covered competencies.
- Included functional and non-functional specifications, technical stack, and infrastructure details.
- Added sections on backend architecture, frontend implementation, security measures, and deployments.
2026-01-26 14:19:05 +01:00
Mathis HERRIOT
5048b4813c chore(ci): update workflow trigger to only act on tags 2026-01-21 16:36:34 +01:00
Mathis HERRIOT
906f615428 chore: bump version to 1.4.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Valider frontend (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 26s
2026-01-21 15:44:17 +01:00
Mathis HERRIOT
fc4efd1e24 feat(user): add "removeMe" API method
- Introduced a new `removeMe` method in `user.service` for account deletion.
2026-01-21 15:43:58 +01:00
Mathis HERRIOT
6bc6a8f68c feat(profile): add "Share Profile" button and improve page responsiveness
- Added "Share Profile" button with clipboard copy and success notification.
- Enhanced responsiveness by adjusting avatar sizes, typography, and spacing.
- Refined button styling for consistency and usability.
2026-01-21 15:43:48 +01:00
Mathis HERRIOT
e69156407e feat(settings): add account deletion feature and improve UI
- Introduced "Delete Account" functionality with confirmation dialog and success/error notifications.
- Enhanced general settings page UI, including updated card layouts and improved form elements.
- Added support for theme selection with a more user-friendly design.
- Refined typography and button styling for better visual consistency.
2026-01-21 15:43:43 +01:00
Mathis HERRIOT
7dce7ec286 feat(profile): add profile sharing feature and enhance UI responsiveness
- Implemented the "Share Profile" button with clipboard copy functionality and success notification.
- Improved profile page responsiveness by adjusting avatar, text sizes, and spacing.
- Added consistent button styling and updated tabs for better usability and design.
2026-01-21 15:43:34 +01:00
Mathis HERRIOT
029bbe9bb9 feat(users): enhance table responsiveness and replace action buttons with dropdown menu
- Improved table layout by hiding specific columns on smaller screens.
- Replaced action buttons with `DropdownMenu` for a cleaner and more accessible UI.
- Updated skeleton loaders to align with the revised table structure.
2026-01-21 15:43:19 +01:00
Mathis HERRIOT
c3f57db1e5 feat(contents): improve table responsiveness and replace action buttons with dropdown menu
- Enhanced table layout by hiding columns on smaller screens for better responsiveness.
- Replaced action buttons with `DropdownMenu` for improved accessibility and cleaner UI.
- Adjusted skeleton loaders to align with the updated table structure.
2026-01-21 15:43:09 +01:00
Mathis HERRIOT
939448d15c feat(categories): enhance table UI and add dropdown menu for actions
- Improved table responsiveness by hiding columns on smaller screens.
- Replaced action buttons with a `DropdownMenu` for better accessibility.
- Updated skeleton loaders to match the new table layout.
2026-01-21 15:43:00 +01:00
Mathis HERRIOT
4e61b0de9a feat(dashboard): add toaster notifications and update header styling
- Integrated `Toaster` component for notifications in the dashboard layout.
- Updated header typography with better font size and tracking improvements.
2026-01-21 15:42:52 +01:00
Mathis HERRIOT
73556894f8 feat(content-card): improve UI and add tooltips for interaction elements
- Introduced tooltips for like, views, and share buttons for better user guidance.
- Added support for category display and improved tag styling.
- Adjusted `CardContent` aspect ratios for better responsiveness.
- Enhanced share button functionality with copy-to-clipboard feedback.
2026-01-21 15:42:35 +01:00
Mathis HERRIOT
96a9d6e7a7 chore: bump version to 1.4.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m27s
2026-01-21 13:49:15 +01:00
Mathis HERRIOT
058830bb60 refactor(content-card): fix indentation and improve readability
- Fixed inconsistent indentation in `content-card` component.
- Enhanced code readability by restructuring JSX elements properly.
2026-01-21 13:48:29 +01:00
Mathis HERRIOT
02d612e026 feat(contents): add UserContentEditDialog component for editing user content
- Introduced `UserContentEditDialog` to enable inline editing of user-generated content.
- Integrated form handling with `react-hook-form` for validation and form state management.
- Added category selection support and updated content saving functionality.
- Included success and error feedback with loading states for better user experience.
2026-01-21 13:44:10 +01:00
Mathis HERRIOT
498f85d24e feat(contents): add update method with user ownership validation
- Introduced `update` method in contents service to allow partial updates.
- Implemented user ownership validation to ensure secure modifications.
- Added cache clearing logic after successful updates.
2026-01-21 13:42:56 +01:00
Mathis HERRIOT
10cc5a6d8d feat(contents): add update endpoint to contents controller
- Introduced a `PATCH /:id` endpoint to enable partial content updates.
- Integrated AuthGuard for securing the endpoint.
2026-01-21 13:42:24 +01:00
Mathis HERRIOT
7503707ef1 refactor(contents): extract and memoize fetchInitial logic
- Refactored `fetchInitial` function to make it reusable using `useCallback`.
- Updated `ContentCard` to call `fetchInitial` via `onUpdate` prop for better reusability.
- Removed duplicate logic from `useEffect` for improved code readability and maintainability.
2026-01-21 13:42:17 +01:00
Mathis HERRIOT
8778508ced feat(contents): add author actions to content card
- Added dropdown menu for authors to edit or delete their content.
- Integrated `UserContentEditDialog` for inline editing.
- Enabled content deletion with confirmation and success/error feedback.
- Improved UI with `DropdownMenu` for better action accessibility.
2026-01-21 13:42:11 +01:00
Mathis HERRIOT
b968d1e6f8 feat(contents): add update and remove methods to contents service
- Introduced `update` method for partial content updates.
- Added `remove` method to handle content deletion.
2026-01-21 13:41:52 +01:00
Mathis HERRIOT
0382b21a65 chore: bump version to 1.3.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m18s
2026-01-21 13:22:19 +01:00
Mathis HERRIOT
764c4c07c8 feat(users, contents): extend admin update functionality and role management
- Added `moderator` role to `User` type for improved role assignment flexibility.
- Introduced `updateAdmin` method in user and content services to handle partial admin-specific updates.
- Enhanced `Content` type with `categoryId` and `iconUrl` to support richer categorization.
2026-01-21 13:22:07 +01:00
Mathis HERRIOT
68b5071f6d feat(admin): implement dialogs for editing users, categories, and contents
- Added `UserEditDialog`, `CategoryDialog`, and `ContentEditDialog` components.
- Enabled role, status, and other attributes updates for users.
- Implemented create and update functionality for categories.
- Facilitated content management with category assignment and updates.
2026-01-21 13:21:51 +01:00
Mathis HERRIOT
f5c90b0ae4 feat(users): add updateAdmin endpoint and enhance role assignment
- Introduced `PATCH /admin/:uuid` endpoint for admin-specific user updates.
- Updated `update` logic to handle role assignment via `rbacService`.
- Refactored `findAll` method in repository for improved readability.
2026-01-21 13:20:32 +01:00
Mathis HERRIOT
c8820a71b6 feat(categories): add create, update, and delete methods to category service
- Introduced `create`, `update`, and `remove` methods for managing categories via the service.
- Enables API integration for category CRUD functionality.
2026-01-21 13:20:16 +01:00
Mathis HERRIOT
9b714716f6 feat(admin): add edit dialogs for users, contents, and categories
- Implemented edit functionality for users, contents, and categories, including modals for updating records.
- Enhanced table actions with edit buttons alongside delete.
- Improved user, content, and category fetching with `useCallback` to optimize re-renders.
- Added skeleton loaders and UI updates for better user experience.
2026-01-21 13:19:29 +01:00
Mathis HERRIOT
3a5550d6eb feat(contents): add updateAdmin method to contents service
- Introduced `updateAdmin` logic to handle admin-specific content updates.
- Included cache clearing upon successful update for data consistency.
2026-01-21 13:19:08 +01:00
Mathis HERRIOT
07cdb741b3 feat(contents): add updateAdmin endpoint and repository method
- Introduced `PATCH /:id/admin` endpoint to update admin-specific content.
- Added `update` method to `ContentsRepository` for data updates with timestamp.
2026-01-21 13:18:41 +01:00
Mathis HERRIOT
02796e4e1f chore: bump version to 1.2.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 2m7s
CI/CD Pipeline / Valider frontend (push) Successful in 2m4s
CI/CD Pipeline / Déploiement en Production (push) Successful in 17s
2026-01-21 11:38:47 +01:00
Mathis HERRIOT
951b38db67 chore: update lint scripts and improve formatting consistency
- Added `lint:fix` scripts for backend, frontend, and documentation.
- Enabled `biome check --write` for unsafe fixes in backend scripts.
- Fixed imports, formatting, and logging for improved code clarity.
- Adjusted service unit tests for better readability and maintainability.
2026-01-21 11:38:25 +01:00
Mathis HERRIOT
a90aba2748 chore: bump version to 1.2.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 52s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-21 11:07:08 +01:00
Mathis HERRIOT
3f0b1e5119 feat(auth): add bootstrap token flow for initial admin creation
- Introduced `BootstrapService` to handle admin creation when no admins exist.
- Added `/auth/bootstrap-admin` endpoint to consume bootstrap tokens.
- Updated `RbacRepository` to support counting admins and assigning roles.
- Included unit tests for `BootstrapService` to ensure token behavior and admin assignment.
2026-01-21 11:07:02 +01:00
Mathis HERRIOT
aff8acebf8 fix(config): correct transformIgnorePatterns regex in Jest config 2026-01-21 11:06:46 +01:00
Mathis HERRIOT
a721b4041c feat(docs): add /auth/bootstrap-admin endpoint details to API reference
- Documented usage, parameters, and responses for the new endpoint.
- Included constraints and warnings for better API clarity.
2026-01-21 11:06:20 +01:00
Mathis HERRIOT
f4a1a2f4df chore: bump version to 1.1.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 59s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m44s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-21 10:45:30 +01:00
Mathis HERRIOT
0548c418c7 feat(auth): implement role seeding on application bootstrap
- Added `onApplicationBootstrap` to seed default roles if none exist.
- Introduced `seedRoles` method to handle role creation with logging.
- Updated `RbacRepository` with `countRoles` and `createRole` methods.
- Added unit tests to ensure role seeding logic functions correctly.
2026-01-21 10:45:25 +01:00
Mathis HERRIOT
dd0a9e620b feat(docs): enhance API reference documentation with detailed responses and constraints
- Added missing response details and validation constraints for multiple endpoints.
- Improved parameter descriptions and structured examples for better clarity and consistency.
2026-01-21 10:45:06 +01:00
Mathis HERRIOT
7e7b19fe9f chore(ci): add --remove-orphans flag to Docker Compose deployment script
All checks were successful
CI/CD Pipeline / Valider frontend (push) Successful in 1m40s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Valider backend (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m21s
2026-01-21 10:08:25 +01:00
Mathis HERRIOT
57bc51290b feat(docs): update and reorganize API reference structure
- Refactored API endpoint documentation using individual accordions for better clarity.
- Added detailed descriptions for `/contents`, `/categories`, `/favorites`, `/reports`, `/api-keys`, `/tags`, `/media`, and `/admin` endpoints.
- Improved consistency in query parameters and usage examples.
2026-01-21 10:08:09 +01:00
Mathis HERRIOT
d613a89e63 chore: bump version to 1.1.0
All checks were successful
CI/CD Pipeline / Valider frontend (push) Successful in 1m40s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Valider backend (push) Successful in 1m54s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m38s
2026-01-21 09:54:34 +01:00
Mathis HERRIOT
67a10ad7d8 feat(layout): add metadata base URL configuration for dynamic app URL
- Configured `metadataBase` in `RootLayout` using `NEXT_PUBLIC_APP_URL` with a fallback to the default URL.
2026-01-21 09:47:49 +01:00
Mathis HERRIOT
82e98f4fce feat(config): add support for remote image domains in Next.js config
- Enabled `images.remotePatterns` to allow loading images from `memegoat.fr` and `api.memegoat.fr`.
2026-01-21 09:47:19 +01:00
Mathis HERRIOT
70a4249e41 fix(content): update conditional checks to use mimeType for content rendering
- Replaced `type` field checks with `mimeType.startsWith("image/")` for improved accuracy in `content-card` and admin content page components.
- Adjusted `CardContent` background color for better visual consistency.
2026-01-21 09:41:11 +01:00
Mathis HERRIOT
de7d41f4a1 fix(auth): prevent login redirect loop and concurrent refresh calls
- Added check to avoid redirecting to `/login` if already on the login page.
- Prevented multiple simultaneous `refreshUser` calls by adding an `isLoading` guard.
- Improved `useEffect` cleanup in `auth-provider` to handle components unmounting.
2026-01-21 09:40:47 +01:00
Mathis HERRIOT
2da1142866 chore(docker): restrict Postgres port exposure to localhost in production configuration
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m17s
2026-01-20 22:35:42 +01:00
Mathis HERRIOT
4e8e441d98 chore: bump version to 1.0.8
Some checks failed
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Valider backend (push) Successful in 1m19s
CI/CD Pipeline / Déploiement en Production (push) Failing after 1m9s
2026-01-20 22:25:12 +01:00
Mathis HERRIOT
0e83de70e3 chore(build): automate version commit during release process
- Added function to stage and commit version changes automatically for `package.json` files.
- Integrated automated commit step into the release workflow.
2026-01-20 22:25:03 +01:00
Mathis HERRIOT
8169ef719a fix(content): update conditional rendering for type field to use meme instead of image 2026-01-20 22:18:14 +01:00
Mathis HERRIOT
7637499a97 build(release): bump package versions to 1.0.7
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m30s
CI/CD Pipeline / Valider documentation (push) Successful in 1m34s
CI/CD Pipeline / Valider frontend (push) Failing after 1m12s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 22:05:06 +01:00
Mathis HERRIOT
c03ad8c221 fix(content): update type field and conditional rendering for content handling
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m26s
CI/CD Pipeline / Valider frontend (push) Failing after 1m10s
CI/CD Pipeline / Valider documentation (push) Successful in 1m34s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
- Changed `type` field values from `image | video` to `meme | gif`.
- Updated conditional rendering in `content-card` component to match new `type` values.
2026-01-20 22:04:25 +01:00
Mathis HERRIOT
8483927823 fix(tests): replace any with Record<string, unknown> in repository tests
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m32s
- Updated type assertions in repository test files to use `Record<string, unknown>` instead of `any`.
2026-01-20 21:42:16 +01:00
Mathis HERRIOT
e7b79013fd build(release): bump package versions to 1.0.6
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m1s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 21:28:47 +01:00
Mathis HERRIOT
b6b37ebc6b docs(api): update /media endpoint documentation to use path query parameter
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 56s
CI/CD Pipeline / Valider frontend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m41s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 21:28:23 +01:00
Mathis HERRIOT
d647a585c8 fix(media): handle missing path parameter and improve error logging
- Updated `getFile` method to validate `path` query parameter.
- Added improved logging for file retrieval errors.
- Updated test cases to cover scenarios with missing `path`.
2026-01-20 21:28:10 +01:00
Mathis HERRIOT
6a2abf115f fix(s3): update public URL to include path query parameter
- Updated `getPublicUrl` to use `?path=<key>` format in public URLs.
- Adjusted corresponding test cases to reflect the new URL structure.
2026-01-20 21:27:49 +01:00
Mathis HERRIOT
ded2d3220d build(release): bump package versions to 1.0.5
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m46s
CI/CD Pipeline / Valider documentation (push) Successful in 1m50s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m57s
2026-01-20 20:00:36 +01:00
Mathis HERRIOT
162d53630d refactor(theme): organize imports and format components for consistency
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 2m5s
- Reorganized imports in multiple files for better readability.
- Fixed formatting issues in `mode-toggle` and `settings` components to improve maintainability.
2026-01-20 19:59:16 +01:00
Mathis HERRIOT
0e8a2e3986 build(release): bump package versions to 1.0.4
Some checks failed
CI/CD Pipeline / Valider frontend (push) Failing after 58s
CI/CD Pipeline / Valider backend (push) Successful in 1m33s
CI/CD Pipeline / Valider documentation (push) Successful in 1m38s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 16:28:37 +01:00
Mathis HERRIOT
5cc77ae5b0 feat(theme): add appearance settings and theme provider integration
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m26s
CI/CD Pipeline / Valider frontend (push) Failing after 53s
CI/CD Pipeline / Valider documentation (push) Successful in 1m32s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
- Added theme selection in settings page for light, dark, and system modes.
- Integrated ThemeProvider into application layout.
- Updated dashboard layout to include theme toggle in the header.
2026-01-20 16:27:59 +01:00
Mathis HERRIOT
3b9b73bc4b feat(theme): add theme toggle component and integrate into sidebar 2026-01-20 16:27:24 +01:00
Mathis HERRIOT
a6e34c511e build(release): bump package versions to 1.0.3
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Failing after 2m6s
2026-01-20 16:19:18 +01:00
Mathis HERRIOT
13650b6a39 build(docker): add nw_caddy network to production compose file
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Déploiement en Production (push) Failing after 1m49s
2026-01-20 16:18:32 +01:00
Mathis HERRIOT
dbe90ae47b build(release): bump package versions to 1.0.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 1m42s
CI/CD Pipeline / Valider frontend (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m57s
2026-01-20 15:46:05 +01:00
Mathis HERRIOT
d0c78cb206 refactor(health): use type import for Cache from cache-manager
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 2m2s
2026-01-20 15:45:40 +01:00
Mathis HERRIOT
1c38434b6e build(tsconfig): enable isolatedModules in TypeScript config 2026-01-20 15:43:34 +01:00
Mathis HERRIOT
1666aaadf2 build(release): bump package versions to 1.0.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m35s
CI/CD Pipeline / Valider frontend (push) Successful in 1m55s
CI/CD Pipeline / Valider documentation (push) Successful in 1m59s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 15:37:43 +01:00
Mathis HERRIOT
6ac429f111 build(tsconfig): disable isolatedModules in TypeScript config
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m30s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 15:37:09 +01:00
Mathis HERRIOT
872087dc44 test(api-keys): improve typings in wrapWithThen mock implementation
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m12s
CI/CD Pipeline / Valider frontend (push) Successful in 1m47s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 15:27:52 +01:00
Mathis HERRIOT
f8eaad3f81 test(api-keys): improve typings in wrapWithThen mock implementation
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m2s
CI/CD Pipeline / Valider documentation (push) Successful in 1m34s
CI/CD Pipeline / Valider frontend (push) Successful in 1m32s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 15:27:25 +01:00
Mathis HERRIOT
5f176def8c test(auth): add comment to clarify DTO usage in register test 2026-01-20 15:27:14 +01:00
Mathis HERRIOT
9ef6bbfd96 test(categories): improve typings in wrapWithThen mock implementation 2026-01-20 15:27:05 +01:00
Mathis HERRIOT
61b25f7b9e test(favorites): improve typings in wrapWithThen mock implementation 2026-01-20 15:26:56 +01:00
Mathis HERRIOT
d0286d51ff test(reports): update wrapWithThen mock to use stricter typings 2026-01-20 15:26:30 +01:00
Mathis HERRIOT
2291cc8afb test(repositories): fix mock implementation of thenable query builders in repository tests
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m13s
CI/CD Pipeline / Valider frontend (push) Successful in 1m40s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 14:04:57 +01:00
Mathis HERRIOT
bad2caef08 docs(roadmap): mark caching with Redis for trends and searches as completed 2026-01-20 13:48:26 +01:00
Mathis HERRIOT
ac4568a0f0 refactor(media): simplify content type assignment logic in media controller 2026-01-20 13:48:16 +01:00
Mathis HERRIOT
a11a332eaa feat(health): enhance health check to include database and Redis status 2026-01-20 13:48:06 +01:00
Mathis HERRIOT
02c00e8aae test(auth): add unit tests for various guards and services with mocked dependencies 2026-01-20 13:47:46 +01:00
Mathis HERRIOT
2886e50a0c test(users): add unit tests for UsersService with mocked dependencies 2026-01-20 13:47:18 +01:00
Mathis HERRIOT
59a5cc941e test(reports): add unit tests for ReportsController and ReportsRepository with mocked dependencies 2026-01-20 13:47:06 +01:00
Mathis HERRIOT
78db4b1c34 test(reports): add unit tests for ReportsController and ReportsRepository with mocked dependencies 2026-01-20 13:46:59 +01:00
Mathis HERRIOT
b177bee75c test(favorites): add unit tests for FavoritesController and FavoritesRepository with mocked dependencies 2026-01-20 13:46:42 +01:00
Mathis HERRIOT
0cd6509273 test(contents): add unit tests for ContentsController and ContentsService with mocked dependencies 2026-01-20 13:46:31 +01:00
Mathis HERRIOT
05a56ff87d test(categories): add unit tests for CategoriesController and CategoriesRepository with mocked dependencies 2026-01-20 13:46:06 +01:00
Mathis HERRIOT
3fa11474c1 test(auth): add unit tests for AuthGuard and AuthController with mocked dependencies 2026-01-20 13:45:50 +01:00
Mathis HERRIOT
4c12c5c5cb test(api-keys): add unit tests for ApiKeysController and ApiKeysRepository with mocked dependencies 2026-01-20 13:45:27 +01:00
Mathis HERRIOT
48dbdbfdcc test(admin): add unit tests for AdminService with mocked repositories 2026-01-20 13:45:07 +01:00
Mathis HERRIOT
002a6b912a test(admin): add unit tests for AdminController with mocked dependencies 2026-01-20 13:44:35 +01:00
Mathis HERRIOT
733ffbff31 chore(ci): update workflow to replace github context with gitea for event and ref conditions 2026-01-20 12:13:14 +01:00
Mathis HERRIOT
4700526dd2 refactor(media): remove redundant metadata fallback for content-type
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m9s
CI/CD Pipeline / Valider frontend (push) Successful in 1m37s
CI/CD Pipeline / Valider documentation (push) Successful in 1m40s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 12:06:20 +01:00
Mathis HERRIOT
2450977e61 chore(versioning): bump package versions to 0.1.0 across all modules
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m12s
CI/CD Pipeline / Valider documentation (push) Successful in 1m41s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 12:00:35 +01:00
Mathis HERRIOT
afc18b555a chore(versioning): improve version script with enhanced validation and refactored command handling 2026-01-20 11:57:24 +01:00
Mathis HERRIOT
9699127739 feat(docs): add details on S3-compatible storage and email notifications system
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m0s
CI/CD Pipeline / Valider documentation (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m33s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 11:50:29 +01:00
Mathis HERRIOT
938d8bde7b feat(docs): extend database schema documentation with new fields avatar_url, bio, and slug 2026-01-20 11:50:18 +01:00
Mathis HERRIOT
65c7096f46 feat(docs): update API reference with new endpoints and extended parameter details 2026-01-20 11:49:47 +01:00
Mathis HERRIOT
57c00ad4d1 chore(ci): remove deprecated deploy workflow and update CI pipeline with production deployment steps 2026-01-20 11:21:23 +01:00
Mathis HERRIOT
39618f7708 chore(versioning): bump package versions to 0.0.1 across all modules
Some checks failed
Deploy to Production / Validate Build & Lint (backend) (push) Failing after 1m12s
Deploy to Production / Validate Build & Lint (documentation) (push) Successful in 1m46s
Deploy to Production / Validate Build & Lint (frontend) (push) Successful in 1m46s
Deploy to Production / Deploy to Production (push) Has been skipped
2026-01-20 10:53:11 +01:00
Mathis HERRIOT
e84e4a5a9d chore(ci): add new workflow for linting and testing components 2026-01-20 10:52:58 +01:00
Mathis HERRIOT
e74973a9d0 chore(ci): update deploy workflow to include tag-based triggers and conditional testing steps 2026-01-20 10:52:48 +01:00
Mathis HERRIOT
9233c1bf89 feat(versioning): add support for SemVer increment commands (PATCH, MINOR, MAJOR) in version script 2026-01-20 10:52:41 +01:00
Mathis HERRIOT
88c7f45a2c chore(ci): remove unused backend and lint workflows to clean up repository 2026-01-20 10:52:27 +01:00
Mathis HERRIOT
9af72156f5 chore: remove unused .output.txt file to clean up repository 2026-01-20 10:51:48 +01:00
161 changed files with 19555 additions and 1000 deletions

View File

@@ -1,36 +0,0 @@
name: Backend Tests
on:
push:
paths:
- 'backend/**'
pull_request:
paths:
- 'backend/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Run Backend Tests
run: pnpm -F @memegoat/backend test

View File

@@ -1,13 +1,15 @@
name: Deploy to Production
# Pipeline CI/CD pour Gitea Actions (Forgejo)
# Compatible avec GitHub Actions pour la portabilité
name: CI/CD Pipeline
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
validate:
name: Validate Build & Lint
name: Valider ${{ matrix.component }}
runs-on: ubuntu-latest
strategy:
matrix:
@@ -16,23 +18,23 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
- name: Installer pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
- name: Configurer Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Get pnpm store directory
- name: Obtenir le chemin du store pnpm
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- name: Setup pnpm cache
- name: Configurer le cache pnpm
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
@@ -40,28 +42,45 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
- name: Installer les dépendances
run: pnpm install --frozen-lockfile --prefer-offline
- name: Lint ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} lint
- name: Tester ${{ matrix.component }}
if: matrix.component == 'backend' || matrix.component == 'frontend'
run: |
if pnpm -F @memegoat/${{ matrix.component }} run | grep -q "test"; then
pnpm -F @memegoat/${{ matrix.component }} test
else
echo "Pas de script de test trouvé pour ${{ matrix.component }}, passage."
fi
- name: Build ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
deploy:
name: Deploy to Production
name: Déploiement en Production
needs: validate
# Déclenchement uniquement sur push sur main ou tag de version
# Gitea supporte le contexte 'github' pour la compatibilité
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy with Docker Compose
- name: Vérifier l'environnement Docker
run: |
docker compose -f docker-compose.prod.yml up -d --build
docker version
docker compose version
- name: Déployer avec Docker Compose
run: |
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}

View File

@@ -1,43 +0,0 @@
name: Lint
on:
push:
paths:
- 'frontend/**'
- 'backend/**'
- 'documentation/**'
pull_request:
paths:
- 'frontend/**'
- 'backend/**'
- 'documentation/**'
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
component: [backend, frontend, documentation]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Lint ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} lint

View File

@@ -1,225 +0,0 @@
{
"name": "@memegoat/source",
"version": "0.0.1",
"description": "",
"scripts": {
"build": "pnpm run build:back && pnpm run build:front && pnpm run build:docs",
"build:front": "pnpm run -F @memegoat/frontend build",
"build:back": "pnpm run -F @memegoat/backend build",
"build:docs": "pnpm run -F @memegoat/documentation build",
"lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs",
"lint:back": "pnpm run -F @memegoat/backend lint",
"lint:front": "pnpm run -F @memegoat/frontend lint",
"lint:docs": "pnpm run -F @memegoat/documentation lint",
"test": "pnpm run test:back && pnpm run test:front",
"test:back": "pnpm run -F @memegoat/backend test",
"test:front": "pnpm run -F @memegoat/frontend test",
"format": "pnpm run format:back && pnpm run format:front && pnpm run format:docs",
"format:back": "pnpm run -F @memegoat/backend format",
"format:front": "pnpm run -F @memegoat/frontend format",
"format:docs": "pnpm run -F @memegoat/documentation format",
"upgrade": "pnpm dlx taze minor"
},
"keywords": [],
"author": {
"name": "Mathis HERRIOT",
"email": "mherriot.pro@proton.me",
"role": "Author"
},
"license": "AGPL-3.0-only",
"devDependencies": {
"@biomejs/biome": "2.3.11"
}
}
{
"name": "@memegoat/backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"files": [
"dist",
".migrations",
"drizzle.config.ts"
],
"scripts": {
"build": "nest build",
"lint": "biome check",
"lint:write": "biome check --write",
"format": "biome format --write",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.0",
"@nestjs/throttler": "^6.5.0",
"@noble/post-quantum": "^0.5.4",
"@node-rs/argon2": "^2.0.2",
"@sentry/nestjs": "^10.32.1",
"@sentry/profiling-node": "^10.32.1",
"cache-manager": "^7.2.7",
"cache-manager-redis-yet": "^5.1.5",
"clamscan": "^2.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.1.0",
"iron-session": "^8.0.4",
"jose": "^6.1.3",
"minio": "^8.0.6",
"nodemailer": "^7.0.12",
"otplib": "^12.0.1",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"uuid": "^13.0.0",
"zod": "^4.3.5",
"drizzle-kit": "^0.31.8"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"globals": "^16.0.0",
"jest": "^30.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.6",
"@types/sharp": "^0.32.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0",
"drizzle-kit": "^0.31.8"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"transformIgnorePatterns": [
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
],
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"moduleNameMapper": {
"^@noble/post-quantum/(.*)$": "<rootDir>/../node_modules/@noble/post-quantum/$1",
"^@noble/hashes/(.*)$": "<rootDir>/../node_modules/@noble/hashes/$1"
}
}
}
{
"name": "@memegoat/frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome check",
"format": "biome format --write"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"react-resizable-panels": "^4.4.1",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

BIN
REAC_CDA_V04_24052023.pdf Normal file

Binary file not shown.

View File

@@ -59,12 +59,28 @@ Pour approfondir vos connaissances techniques sur le projet :
## Comment l'utiliser ?
### Installation locale
### Déploiement en Production
1. Clonez le dépôt.
2. Installez les dépendances avec `pnpm install`.
3. Configurez les variables d'environnement (voir `.env.example`).
4. Lancez les services via Docker ou manuellement.
Le projet est prêt pour la production via Docker Compose.
1. **Prérequis** : Docker et Docker Compose installés.
2. **Variables d'environnement** : Copiez `.env.example` en `.env.prod` et ajustez les valeurs (clés secrètes, hosts, Sentry DSN, etc.).
3. **Lancement** :
```bash
docker-compose -f docker-compose.prod.yml up -d
```
4. **Services inclus** :
- **Frontend** : Next.js en mode standalone optimisé.
- **Backend** : NestJS avec clustering et monitoring Sentry.
- **Caddy** : Gestion automatique du SSL/TLS.
- **ClamAV** : Scan antivirus en temps réel des médias.
- **Redis** : Cache, sessions et limitation de débit (Throttling/Bot detection).
- **MinIO** : Stockage compatible S3.
### Sécurité et Performance
- **Transcodage Auto** : Toutes les images sont converties en WebP et les vidéos en WebM pour minimiser la bande passante.
- **Bot Detection** : Système intégré de détection et de bannissement automatique des crawlers malveillants via Redis.
- **Monitoring** : Tracking d'erreurs et profilage de performance via Sentry (Node.js et Next.js).
### Clés API

View File

@@ -47,4 +47,4 @@ Ce document définit les objectifs, les critères techniques et les fonctionnali
- [ ] Tests d'intégration et E2E
- [x] Gestion centralisée des erreurs (Filters NestJS)
- [ ] Monitoring et centralisation des logs (ex: Sentry, ELK/Loki)
- [ ] Performance : Cache (Redis) pour les tendances et recherches fréquentes
- [x] Performance : Cache (Redis) pour les tendances et recherches fréquentes

756
backend.plantuml Normal file
View File

@@ -0,0 +1,756 @@
@startuml
!theme plain
top to bottom direction
skinparam linetype ortho
class AdminController {
constructor(adminService: AdminService):
getStats(): Promise<{users: number, contents: numbe…
}
class AdminModule
class AdminService {
constructor(usersRepository: UsersRepository, contentsRepository: ContentsRepository, categoriesRepository: CategoriesRepository):
getStats(): Promise<{users: number, contents: numbe…
}
class AllExceptionsFilter {
logger: Logger
catch(exception: unknown, host: ArgumentsHost): void
}
class ApiKeysController {
constructor(apiKeysService: ApiKeysService):
create(req: AuthenticatedRequest, createApiKeyDto: CreateApiKeyDto): Promise<{name: string, key: string, exp…
findAll(req: AuthenticatedRequest): Promise<any>
revoke(req: AuthenticatedRequest, id: string): Promise<any>
}
class ApiKeysModule
class ApiKeysRepository {
constructor(databaseService: DatabaseService):
create(data: {userId: string; name: string; prefix: string; keyHash: string; expiresAt?: Date}): Promise<any>
findAll(userId: string): Promise<any>
revoke(userId: string, keyId: string): Promise<any>
findActiveByKeyHash(keyHash: string): Promise<any>
updateLastUsed(id: string): Promise<any>
}
class ApiKeysService {
constructor(apiKeysRepository: ApiKeysRepository, hashingService: HashingService):
logger: Logger
create(userId: string, name: string, expiresAt?: Date): Promise<{name: string, key: string, exp…
findAll(userId: string): Promise<any>
revoke(userId: string, keyId: string): Promise<any>
validateKey(key: string): Promise<any>
}
class AppController {
constructor(appService: AppService):
getHello(): string
}
class AppModule {
configure(consumer: MiddlewareConsumer): void
}
class AppService {
getHello(): string
}
class AuditLogInDb
class AuthController {
constructor(authService: AuthService, bootstrapService: BootstrapService, configService: ConfigService):
register(registerDto: RegisterDto): Promise<{message: string, userId: any}>
login(loginDto: LoginDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
verifyTwoFactor(verify2faDto: Verify2faDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
refresh(req: Request, res: Response): Promise<Response<any, Record<string, an…
logout(req: Request, res: Response): Promise<Response<any, Record<string, an…
bootstrapAdmin(token: string, username: string): Promise<{message: string}>
}
class AuthGuard {
constructor(jwtService: JwtService, configService: ConfigService):
canActivate(context: ExecutionContext): Promise<boolean>
}
class AuthModule
class AuthService {
constructor(usersService: UsersService, hashingService: HashingService, jwtService: JwtService, sessionsService: SessionsService, configService: ConfigService):
logger: Logger
generateTwoFactorSecret(userId: string): Promise<{secret: string, qrCodeDataUrl:…
enableTwoFactor(userId: string, token: string): Promise<{message: string}>
disableTwoFactor(userId: string, token: string): Promise<{message: string}>
register(dto: RegisterDto): Promise<{message: string, userId: any}>
login(dto: LoginDto, userAgent?: string, ip?: string): Promise<{message: string, requires2FA: …
verifyTwoFactorLogin(userId: string, token: string, userAgent?: string, ip?: string): Promise<{message: string, access_token:…
refresh(refreshToken: string): Promise<{access_token: string, refresh_…
logout(): Promise<{message: string}>
}
class AuthenticatedRequest {
user: {sub: string, username: string}
}
class BootstrapService {
constructor(rbacService: RbacService, usersService: UsersService, configService: ConfigService):
logger: Logger
bootstrapToken: string | null
onApplicationBootstrap(): Promise<void>
generateBootstrapToken(): void
consumeToken(token: string, username: string): Promise<{message: string}>
}
class CategoriesController {
constructor(categoriesService: CategoriesService):
findAll(): Promise<any>
findOne(id: string): Promise<any>
create(createCategoryDto: CreateCategoryDto): Promise<any>
update(id: string, updateCategoryDto: UpdateCategoryDto): Promise<any>
remove(id: string): Promise<any>
}
class CategoriesModule
class CategoriesRepository {
constructor(databaseService: DatabaseService):
findAll(): Promise<any>
countAll(): Promise<number>
findOne(id: string): Promise<any>
create(data: CreateCategoryDto & {slug: string}): Promise<any>
update(id: string, data: UpdateCategoryDto & {slug?: string; updatedAt: Date}): Promise<any>
remove(id: string): Promise<any>
}
class CategoriesService {
constructor(categoriesRepository: CategoriesRepository, cacheManager: Cache):
logger: Logger
clearCategoriesCache(): Promise<void>
findAll(): Promise<any>
findOne(id: string): Promise<any>
create(data: CreateCategoryDto): Promise<any>
update(id: string, data: UpdateCategoryDto): Promise<any>
remove(id: string): Promise<any>
}
class CategoryInDb
class ClamScanner {
scanStream(stream: Readable): Promise<{isInfected: boolean, viruses: …
}
class CommonModule
class ContentInDb
class ContentType {
MEME:
GIF:
}
class ContentsController {
constructor(contentsService: ContentsService):
create(req: AuthenticatedRequest, createContentDto: CreateContentDto): Promise<any>
getUploadUrl(req: AuthenticatedRequest, fileName: string): Promise<{url: string, key: string}>
upload(req: AuthenticatedRequest, file: Express.Multer.File, uploadContentDto: UploadContentDto): Promise<any>
explore(req: AuthenticatedRequest, limit: number, offset: number, sort?: "trend" | "recent", tag?: string, category?: string, author?: string): Promise<{data: any, totalCount: any}>
trends(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
recent(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
findOne(idOrSlug: string, req: AuthenticatedRequest, res: Response): Promise<Response<any, Record<string, an…
incrementViews(id: string): Promise<void>
incrementUsage(id: string): Promise<void>
update(id: string, req: AuthenticatedRequest, updateContentDto: any): Promise<any>
remove(id: string, req: AuthenticatedRequest): Promise<any>
removeAdmin(id: string): Promise<any>
updateAdmin(id: string, updateContentDto: any): Promise<any>
}
class ContentsModule
class ContentsRepository {
constructor(databaseService: DatabaseService):
findAll(options: FindAllOptions): Promise<any>
create(data: NewContentInDb & {userId: string}, tagNames?: string[]): Promise<any>
findOne(idOrSlug: string, userId?: string): Promise<any>
count(options: {tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<number>
incrementViews(id: string): Promise<void>
incrementUsage(id: string): Promise<void>
softDelete(id: string, userId: string): Promise<any>
softDeleteAdmin(id: string): Promise<any>
update(id: string, data: Partial<typeof contents.$inferInsert>): Promise<any>
findBySlug(slug: string): Promise<any>
purgeSoftDeleted(before: Date): Promise<any>
}
class ContentsService {
constructor(contentsRepository: ContentsRepository, s3Service: IStorageService, mediaService: IMediaService, configService: ConfigService, cacheManager: Cache):
logger: Logger
clearContentsCache(): Promise<void>
getUploadUrl(userId: string, fileName: string): Promise<{url: string, key: string}>
uploadAndProcess(userId: string, file: Express.Multer.File, data: UploadContentDto): Promise<any>
findAll(options: {limit: number; offset: number; sortBy?: "trend" | "recent"; tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<{data: any, totalCount: any}>
create(userId: string, data: CreateContentDto): Promise<any>
incrementViews(id: string): Promise<void>
incrementUsage(id: string): Promise<void>
remove(id: string, userId: string): Promise<any>
removeAdmin(id: string): Promise<any>
updateAdmin(id: string, data: any): Promise<any>
update(id: string, userId: string, data: any): Promise<any>
findOne(idOrSlug: string, userId?: string): Promise<any>
generateBotHtml(content: {title: string; storageKey: string}): string
generateSlug(text: string): string
ensureUniqueSlug(title: string): Promise<string>
}
class CrawlerDetectionMiddleware {
logger: Logger
SUSPICIOUS_PATTERNS: RegExp[]
BOT_USER_AGENTS: RegExp[]
use(req: Request, res: Response, next: NextFunction): void
}
class CreateApiKeyDto {
name: string
expiresAt: string
}
class CreateCategoryDto {
name: string
description: string
iconUrl: string
}
class CreateContentDto {
type: "meme" | "gif"
title: string
storageKey: string
mimeType: string
fileSize: number
categoryId: string
tags: string[]
}
class CreateReportDto {
contentId: string
tagId: string
reason: "inappropriate" | "spam" | "copyright" …
description: string
}
class CryptoModule
class CryptoService {
constructor(hashingService: HashingService, jwtService: JwtService, encryptionService: EncryptionService, postQuantumService: PostQuantumService):
hashEmail(email: string): Promise<string>
hashIp(ip: string): Promise<string>
getPgpEncryptionKey(): string
hashPassword(password: string): Promise<string>
verifyPassword(password: string, hash: string): Promise<boolean>
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
verifyJwt(token: string): Promise<T>
encryptContent(content: string): Promise<string>
decryptContent(jwe: string): Promise<string>
signContent(content: string): Promise<string>
verifyContentSignature(jws: string): Promise<string>
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
}
class DatabaseModule
class DatabaseService {
constructor(configService: ConfigService):
logger: Logger
pool: Pool
db: ReturnType<typeof drizzle>
onModuleInit(): Promise<void>
onModuleDestroy(): Promise<void>
getDatabaseConnectionString(): string
}
class EncryptionService {
constructor(configService: ConfigService):
logger: Logger
jwtSecret: Uint8Array
encryptionKey: Uint8Array
encryptContent(content: string): Promise<string>
decryptContent(jwe: string): Promise<string>
signContent(content: string): Promise<string>
verifyContentSignature(jws: string): Promise<string>
getPgpEncryptionKey(): string
}
class Env
class FavoriteInDb
class FavoritesController {
constructor(favoritesService: FavoritesService):
add(req: AuthenticatedRequest, contentId: string): Promise<any>
remove(req: AuthenticatedRequest, contentId: string): Promise<any>
list(req: AuthenticatedRequest, limit: number, offset: number): Promise<any>
}
class FavoritesModule
class FavoritesRepository {
constructor(databaseService: DatabaseService):
findContentById(contentId: string): Promise<any>
add(userId: string, contentId: string): Promise<any>
remove(userId: string, contentId: string): Promise<any>
findByUserId(userId: string, limit: number, offset: number): Promise<any>
}
class FavoritesService {
constructor(favoritesRepository: FavoritesRepository):
logger: Logger
addFavorite(userId: string, contentId: string): Promise<any>
removeFavorite(userId: string, contentId: string): Promise<any>
getUserFavorites(userId: string, limit: number, offset: number): Promise<any>
}
class FindAllOptions {
limit: number
offset: number
sortBy: "trend" | "recent"
tag: string
category: string
author: string
query: string
favoritesOnly: boolean
userId: string
}
class HTTPLoggerMiddleware {
logger: Logger
use(request: Request, response: Response, next: NextFunction): void
}
class HashingService {
hashEmail(email: string): Promise<string>
hashIp(ip: string): Promise<string>
hashSha256(text: string): Promise<string>
hashPassword(password: string): Promise<string>
verifyPassword(password: string, hash: string): Promise<boolean>
}
class HealthController {
constructor(databaseService: DatabaseService, cacheManager: Cache):
check(): Promise<any>
}
class IMailService {
sendEmailValidation(email: string, token: string): Promise<void>
sendPasswordReset(email: string, token: string): Promise<void>
}
class IMediaProcessorStrategy {
canHandle(mimeType: string): boolean
process(buffer: Buffer, options?: Record<string, unknown>): Promise<MediaProcessingResult>
}
class IMediaService {
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
}
class IStorageService {
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Record<string, string>, bucketName?: string): Promise<string>
getFile(fileName: string, bucketName?: string): Promise<Readable>
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
deleteFile(fileName: string, bucketName?: string): Promise<void>
getFileInfo(fileName: string, bucketName?: string): Promise<unknown>
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
getPublicUrl(storageKey: string): string
}
class ImageProcessorStrategy {
logger: Logger
canHandle(mimeType: string): boolean
process(buffer: Buffer, options?: {format: "webp" | "avif"; resize?: {width?: number; height?: number}}): Promise<MediaProcessingResult>
}
class JwtService {
constructor(configService: ConfigService):
logger: Logger
jwtSecret: Uint8Array
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
verifyJwt(token: string): Promise<T>
}
class LoginDto {
email: string
password: string
}
class MailModule
class MailService {
constructor(mailerService: MailerService, configService: ConfigService):
logger: Logger
domain: string
sendEmailValidation(email: string, token: string): Promise<void>
sendPasswordReset(email: string, token: string): Promise<void>
}
class MediaController {
constructor(s3Service: S3Service):
logger: Logger
getFile(path: string, res: Response): Promise<void>
}
class MediaModule
class MediaProcessingResult {
buffer: Buffer
mimeType: string
extension: string
width: number
height: number
size: number
}
class MediaProcessingResult {
buffer: Buffer
mimeType: string
extension: string
width: number
height: number
size: number
}
class MediaService {
constructor(configService: ConfigService, imageProcessor: ImageProcessorStrategy, videoProcessor: VideoProcessorStrategy):
logger: Logger
clamscan: ClamScanner | null
isClamAvInitialized: boolean
initClamScan(): Promise<void>
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
}
class NewAuditLogInDb
class NewCategoryInDb
class NewContentInDb
class NewFavoriteInDb
class NewReportInDb
class NewTagInDb
class NewUserInDb
class OptionalAuthGuard {
constructor(jwtService: JwtService, configService: ConfigService):
canActivate(context: ExecutionContext): Promise<boolean>
}
class PostQuantumService {
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
}
class PurgeService {
constructor(sessionsRepository: SessionsRepository, reportsRepository: ReportsRepository, usersRepository: UsersRepository, contentsRepository: ContentsRepository):
logger: Logger
purgeExpiredData(): Promise<void>
}
class RbacRepository {
constructor(databaseService: DatabaseService):
findRolesByUserId(userId: string): Promise<any>
findPermissionsByUserId(userId: string): Promise<any[]>
countRoles(): Promise<number>
countAdmins(): Promise<number>
createRole(name: string, slug: string, description?: string): Promise<any>
assignRole(userId: string, roleSlug: string): Promise<any>
}
class RbacService {
constructor(rbacRepository: RbacRepository):
logger: Logger
onApplicationBootstrap(): Promise<void>
seedRoles(): Promise<void>
getUserRoles(userId: string): Promise<any>
getUserPermissions(userId: string): Promise<any[]>
countAdmins(): Promise<number>
assignRoleToUser(userId: string, roleSlug: string): Promise<any>
}
class RefreshDto {
refresh_token: string
}
class RegisterDto {
username: string
displayName: string
email: string
password: string
}
class ReportInDb
class ReportReason {
INAPPROPRIATE:
SPAM:
COPYRIGHT:
OTHER:
}
class ReportStatus {
PENDING:
REVIEWED:
RESOLVED:
DISMISSED:
}
class ReportsController {
constructor(reportsService: ReportsService):
create(req: AuthenticatedRequest, createReportDto: CreateReportDto): Promise<any>
findAll(limit: number, offset: number): Promise<any>
updateStatus(id: string, updateReportStatusDto: UpdateReportStatusDto): Promise<any>
}
class ReportsModule
class ReportsRepository {
constructor(databaseService: DatabaseService):
create(data: {reporterId: string; contentId?: string; tagId?: string; reason: "inappropriate" | "spam" | "copyright" | "other"; description?: string}): Promise<any>
findAll(limit: number, offset: number): Promise<any>
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
purgeObsolete(now: Date): Promise<any>
}
class ReportsService {
constructor(reportsRepository: ReportsRepository):
logger: Logger
create(reporterId: string, data: CreateReportDto): Promise<any>
findAll(limit: number, offset: number): Promise<any>
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
}
class RequestWithUser {
user: {sub?: string, username?: string, id?: …
}
class RolesGuard {
constructor(reflector: Reflector, rbacService: RbacService):
canActivate(context: ExecutionContext): Promise<boolean>
}
class S3Module
class S3Service {
constructor(configService: ConfigService):
logger: Logger
minioClient: Minio.Client
bucketName: string
onModuleInit(): Promise<void>
ensureBucketExists(bucketName: string): Promise<void>
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Minio.ItemBucketMetadata, bucketName?: string): Promise<string>
getFile(fileName: string, bucketName?: string): Promise<stream.Readable>
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
deleteFile(fileName: string, bucketName?: string): Promise<void>
getFileInfo(fileName: string, bucketName?: string): Promise<BucketItemStat>
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
getPublicUrl(storageKey: string): string
}
class ScanResult {
isInfected: boolean
virusName: string
}
class ScanResult {
isInfected: boolean
virusName: string
}
class SessionData {
accessToken: string
refreshToken: string
userId: string
}
class SessionsModule
class SessionsRepository {
constructor(databaseService: DatabaseService):
create(data: {userId: string; refreshToken: string; userAgent?: string; ipHash?: string | null; expiresAt: Date}): Promise<any>
findValidByRefreshToken(refreshToken: string): Promise<any>
update(sessionId: string, data: Record<string, unknown>): Promise<any>
revoke(sessionId: string): Promise<void>
revokeAllByUserId(userId: string): Promise<void>
purgeExpired(now: Date): Promise<any>
}
class SessionsService {
constructor(sessionsRepository: SessionsRepository, hashingService: HashingService, jwtService: JwtService):
createSession(userId: string, userAgent?: string, ip?: string): Promise<any>
refreshSession(oldRefreshToken: string): Promise<any>
revokeSession(sessionId: string): Promise<void>
revokeAllUserSessions(userId: string): Promise<void>
}
class TagInDb
class TagsController {
constructor(tagsService: TagsService):
findAll(limit: number, offset: number, query?: string, sort?: "popular" | "recent"): Promise<any>
}
class TagsModule
class TagsRepository {
constructor(databaseService: DatabaseService):
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
}
class TagsService {
constructor(tagsRepository: TagsRepository):
logger: Logger
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
}
class UpdateCategoryDto
class UpdateConsentDto {
termsVersion: string
privacyVersion: string
}
class UpdateReportStatusDto {
status: "pending" | "reviewed" | "resolved" | "…
}
class UpdateUserDto {
displayName: string
bio: string
avatarUrl: string
status: "active" | "verification" | "suspended"…
role: string
}
class UploadContentDto {
type: "meme" | "gif"
title: string
categoryId: string
tags: string[]
}
class UserInDb
class UsersController {
constructor(usersService: UsersService, authService: AuthService):
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
findPublicProfile(username: string): Promise<any>
findMe(req: AuthenticatedRequest): Promise<any>
exportMe(req: AuthenticatedRequest): Promise<null | {profile: any, contents:…
updateMe(req: AuthenticatedRequest, updateUserDto: UpdateUserDto): Promise<any>
updateAvatar(req: AuthenticatedRequest, file: Express.Multer.File): Promise<any>
updateConsent(req: AuthenticatedRequest, consentDto: UpdateConsentDto): Promise<any>
removeMe(req: AuthenticatedRequest): Promise<any>
removeAdmin(uuid: string): Promise<any>
updateAdmin(uuid: string, updateUserDto: UpdateUserDto): Promise<any>
setup2fa(req: AuthenticatedRequest): Promise<{secret: string, qrCodeDataUrl:…
enable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
disable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
}
class UsersModule
class UsersRepository {
constructor(databaseService: DatabaseService):
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
findByEmailHash(emailHash: string): Promise<any>
findOneWithPrivateData(uuid: string): Promise<any>
countAll(): Promise<number>
findAll(limit: number, offset: number): Promise<any>
findByUsername(username: string): Promise<any>
findOne(uuid: string): Promise<any>
update(uuid: string, data: Partial<typeof users.$inferInsert>): Promise<any>
getTwoFactorSecret(uuid: string): Promise<any>
getUserContents(uuid: string): Promise<any>
getUserFavorites(uuid: string): Promise<any>
softDeleteUserAndContents(uuid: string): Promise<any>
purgeDeleted(before: Date): Promise<any>
}
class UsersService {
constructor(usersRepository: UsersRepository, cacheManager: Cache, rbacService: RbacService, mediaService: IMediaService, s3Service: IStorageService):
logger: Logger
clearUserCache(username?: string): Promise<void>
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
findByEmailHash(emailHash: string): Promise<any>
findOneWithPrivateData(uuid: string): Promise<any>
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
findPublicProfile(username: string): Promise<any>
findOne(uuid: string): Promise<any>
update(uuid: string, data: UpdateUserDto): Promise<any>
updateAvatar(uuid: string, file: Express.Multer.File): Promise<any>
updateConsent(uuid: string, termsVersion: string, privacyVersion: string): Promise<any>
setTwoFactorSecret(uuid: string, secret: string): Promise<any>
toggleTwoFactor(uuid: string, enabled: boolean): Promise<any>
getTwoFactorSecret(uuid: string): Promise<string | null>
exportUserData(uuid: string): Promise<null | {profile: any, contents:…
remove(uuid: string): Promise<any>
}
class Verify2faDto {
userId: string
token: string
}
class VideoProcessorStrategy {
logger: Logger
canHandle(mimeType: string): boolean
process(buffer: Buffer, options?: {format: "webm" | "av1"}): Promise<MediaProcessingResult>
}
AdminController -[#595959,dashed]-> AdminService
AdminService -[#595959,dashed]-> CategoriesRepository
AdminService -[#595959,dashed]-> ContentsRepository
AdminService -[#595959,dashed]-> UsersRepository
AllExceptionsFilter -[#595959,dashed]-> RequestWithUser
ApiKeysController -[#595959,dashed]-> ApiKeysService
ApiKeysController -[#595959,dashed]-> AuthenticatedRequest
ApiKeysController -[#595959,dashed]-> CreateApiKeyDto
ApiKeysRepository -[#595959,dashed]-> DatabaseService
ApiKeysService -[#595959,dashed]-> ApiKeysRepository
ApiKeysService -[#595959,dashed]-> ApiKeysService
ApiKeysService -[#595959,dashed]-> HashingService
AppController -[#595959,dashed]-> AppService
AppModule -[#595959,dashed]-> CrawlerDetectionMiddleware
AppModule -[#595959,dashed]-> HTTPLoggerMiddleware
AuthController -[#595959,dashed]-> AuthService
AuthController -[#595959,dashed]-> BootstrapService
AuthController -[#595959,dashed]-> LoginDto
AuthController -[#595959,dashed]-> RegisterDto
AuthController -[#595959,dashed]-> SessionData
AuthController -[#595959,dashed]-> Verify2faDto
AuthGuard -[#595959,dashed]-> JwtService
AuthGuard -[#595959,dashed]-> SessionData
AuthService -[#595959,dashed]-> AuthService
AuthService -[#595959,dashed]-> HashingService
AuthService -[#595959,dashed]-> JwtService
AuthService -[#595959,dashed]-> LoginDto
AuthService -[#595959,dashed]-> RegisterDto
AuthService -[#595959,dashed]-> SessionsService
AuthService -[#595959,dashed]-> UsersService
BootstrapService -[#595959,dashed]-> BootstrapService
BootstrapService -[#595959,dashed]-> RbacService
BootstrapService -[#595959,dashed]-> UsersService
CategoriesController -[#595959,dashed]-> AuthGuard
CategoriesController -[#595959,dashed]-> CategoriesService
CategoriesController -[#595959,dashed]-> CreateCategoryDto
CategoriesController -[#595959,dashed]-> RolesGuard
CategoriesController -[#595959,dashed]-> UpdateCategoryDto
CategoriesRepository -[#595959,dashed]-> CreateCategoryDto
CategoriesRepository -[#595959,dashed]-> DatabaseService
CategoriesRepository -[#595959,dashed]-> UpdateCategoryDto
CategoriesService -[#595959,dashed]-> CategoriesRepository
CategoriesService -[#595959,dashed]-> CategoriesService
CategoriesService -[#595959,dashed]-> CreateCategoryDto
CategoriesService -[#595959,dashed]-> UpdateCategoryDto
ContentsController -[#595959,dashed]-> AuthGuard
ContentsController -[#595959,dashed]-> AuthenticatedRequest
ContentsController -[#595959,dashed]-> ContentsService
ContentsController -[#595959,dashed]-> CreateContentDto
ContentsController -[#595959,dashed]-> OptionalAuthGuard
ContentsController -[#595959,dashed]-> RolesGuard
ContentsController -[#595959,dashed]-> UploadContentDto
ContentsRepository -[#595959,dashed]-> DatabaseService
ContentsRepository -[#595959,dashed]-> FindAllOptions
ContentsRepository -[#595959,dashed]-> NewContentInDb
ContentsService -[#595959,dashed]-> ContentsRepository
ContentsService -[#595959,dashed]-> ContentsService
ContentsService -[#595959,dashed]-> CreateContentDto
ContentsService -[#595959,dashed]-> IMediaService
ContentsService -[#595959,dashed]-> IStorageService
ContentsService -[#595959,dashed]-> MediaProcessingResult
ContentsService -[#595959,dashed]-> MediaService
ContentsService -[#595959,dashed]-> S3Service
ContentsService -[#595959,dashed]-> UploadContentDto
CryptoService -[#595959,dashed]-> EncryptionService
CryptoService -[#595959,dashed]-> HashingService
CryptoService -[#595959,dashed]-> JwtService
CryptoService -[#595959,dashed]-> PostQuantumService
DatabaseService -[#595959,dashed]-> DatabaseService
EncryptionService -[#595959,dashed]-> EncryptionService
FavoritesController -[#595959,dashed]-> AuthenticatedRequest
FavoritesController -[#595959,dashed]-> FavoritesService
FavoritesRepository -[#595959,dashed]-> DatabaseService
FavoritesService -[#595959,dashed]-> FavoritesRepository
FavoritesService -[#595959,dashed]-> FavoritesService
HealthController -[#595959,dashed]-> DatabaseService
IMediaProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
IMediaService -[#595959,dashed]-> MediaProcessingResult
IMediaService -[#595959,dashed]-> ScanResult
ImageProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
ImageProcessorStrategy -[#595959,dashed]-> ImageProcessorStrategy
ImageProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
JwtService -[#595959,dashed]-> JwtService
MailService -[#008200,dashed]-^ IMailService
MailService -[#595959,dashed]-> MailService
MediaController -[#595959,dashed]-> MediaController
MediaController -[#595959,dashed]-> S3Service
MediaService -[#595959,dashed]-> ClamScanner
MediaService -[#008200,dashed]-^ IMediaService
MediaService -[#595959,dashed]-> ImageProcessorStrategy
MediaService -[#595959,dashed]-> MediaProcessingResult
MediaService -[#595959,dashed]-> MediaService
MediaService -[#595959,dashed]-> ScanResult
MediaService -[#595959,dashed]-> VideoProcessorStrategy
OptionalAuthGuard -[#595959,dashed]-> JwtService
OptionalAuthGuard -[#595959,dashed]-> SessionData
PurgeService -[#595959,dashed]-> ContentsRepository
PurgeService -[#595959,dashed]-> PurgeService
PurgeService -[#595959,dashed]-> ReportsRepository
PurgeService -[#595959,dashed]-> SessionsRepository
PurgeService -[#595959,dashed]-> UsersRepository
RbacRepository -[#595959,dashed]-> DatabaseService
RbacService -[#595959,dashed]-> RbacRepository
RbacService -[#595959,dashed]-> RbacService
ReportsController -[#595959,dashed]-> AuthGuard
ReportsController -[#595959,dashed]-> AuthenticatedRequest
ReportsController -[#595959,dashed]-> CreateReportDto
ReportsController -[#595959,dashed]-> ReportsService
ReportsController -[#595959,dashed]-> RolesGuard
ReportsController -[#595959,dashed]-> UpdateReportStatusDto
ReportsRepository -[#595959,dashed]-> DatabaseService
ReportsService -[#595959,dashed]-> CreateReportDto
ReportsService -[#595959,dashed]-> ReportsRepository
ReportsService -[#595959,dashed]-> ReportsService
RolesGuard -[#595959,dashed]-> RbacService
S3Service -[#008200,dashed]-^ IStorageService
S3Service -[#595959,dashed]-> S3Service
SessionsRepository -[#595959,dashed]-> DatabaseService
SessionsService -[#595959,dashed]-> HashingService
SessionsService -[#595959,dashed]-> JwtService
SessionsService -[#595959,dashed]-> SessionsRepository
TagsController -[#595959,dashed]-> TagsService
TagsRepository -[#595959,dashed]-> DatabaseService
TagsService -[#595959,dashed]-> TagsRepository
TagsService -[#595959,dashed]-> TagsService
UsersController -[#595959,dashed]-> AuthGuard
UsersController -[#595959,dashed]-> AuthService
UsersController -[#595959,dashed]-> AuthenticatedRequest
UsersController -[#595959,dashed]-> RolesGuard
UsersController -[#595959,dashed]-> UpdateConsentDto
UsersController -[#595959,dashed]-> UpdateUserDto
UsersController -[#595959,dashed]-> UsersService
UsersRepository -[#595959,dashed]-> DatabaseService
UsersService -[#595959,dashed]-> IMediaService
UsersService -[#595959,dashed]-> IStorageService
UsersService -[#595959,dashed]-> MediaService
UsersService -[#595959,dashed]-> RbacService
UsersService -[#595959,dashed]-> S3Service
UsersService -[#595959,dashed]-> UpdateUserDto
UsersService -[#595959,dashed]-> UsersRepository
UsersService -[#595959,dashed]-> UsersService
VideoProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
VideoProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
VideoProcessorStrategy -[#595959,dashed]-> VideoProcessorStrategy
@enduml

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."content_type" ADD VALUE 'video';

View File

@@ -0,0 +1,54 @@
CREATE TABLE "comment_likes" (
"comment_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "comment_likes_comment_id_user_id_pk" PRIMARY KEY("comment_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"content_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"parent_id" uuid,
"text" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "conversation_participants" (
"conversation_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "conversation_participants_conversation_id_user_id_pk" PRIMARY KEY("conversation_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"conversation_id" uuid NOT NULL,
"sender_id" uuid NOT NULL,
"text" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"read_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_uuid_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "comments_content_id_idx" ON "comments" USING btree ("content_id");--> statement-breakpoint
CREATE INDEX "comments_user_id_idx" ON "comments" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "comments_parent_id_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "messages_conversation_id_idx" ON "messages" USING btree ("conversation_id");--> statement-breakpoint
CREATE INDEX "messages_sender_id_idx" ON "messages" USING btree ("sender_id");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "show_online_status" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "show_read_receipts" boolean DEFAULT true NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ALTER COLUMN "password_hash" SET DATA TYPE varchar(255);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,34 @@
"when": 1768423315172,
"tag": "0006_friendly_adam_warlock",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1769605995410,
"tag": "0007_melodic_synch",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1769696731978,
"tag": "0008_bitter_darwin",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1769717126917,
"tag": "0009_add_privacy_settings",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769718997591,
"tag": "0010_update_password_hash_length",
"breakpoints": true
}
]
}

View File

@@ -4,6 +4,8 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN apk add --no-cache ffmpeg
FROM base AS build
WORKDIR /usr/src/app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
@@ -13,13 +15,13 @@ COPY documentation/package.json ./documentation/
# Utilisation du cache pour pnpm et installation figée
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
pnpm install --frozen-lockfile --force
COPY . .
# Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
pnpm install --frozen-lockfile --force
RUN pnpm run --filter @memegoat/backend build
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app

View File

@@ -24,7 +24,8 @@
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off"
"noUnknownAtRules": "off",
"noExplicitAny": "off"
},
"style": {
"useImportType": "off"

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/backend",
"version": "0.0.0",
"version": "1.9.5",
"description": "",
"author": "",
"private": true,
@@ -13,7 +13,7 @@
"scripts": {
"build": "nest build",
"lint": "biome check",
"lint:write": "biome check --write",
"lint:write": "biome check --write --unsafe",
"format": "biome format --write",
"start": "nest start",
"start:dev": "nest start --watch",
@@ -36,8 +36,10 @@
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/schedule": "^6.1.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.12",
"@noble/post-quantum": "^0.5.4",
"@node-rs/argon2": "^2.0.2",
"@sentry/nestjs": "^10.32.1",
@@ -48,6 +50,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.1.0",
@@ -61,23 +64,12 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"uuid": "^13.0.0",
"zod": "^4.3.5",
"drizzle-kit": "^0.31.8"
"zod": "^4.3.5"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"globals": "^16.0.0",
"jest": "^30.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
@@ -89,9 +81,21 @@
"@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.6",
"@types/sharp": "^0.32.0",
"@types/socket.io": "^3.0.2",
"@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0",
"drizzle-kit": "^0.31.8"
"drizzle-kit": "^0.31.8",
"globals": "^16.0.0",
"jest": "^30.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
@@ -107,7 +111,7 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"transformIgnorePatterns": [
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid))"
],
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"

View File

@@ -0,0 +1,62 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
describe("AdminController", () => {
let controller: AdminController;
let service: AdminService;
const mockAdminService = {
getStats: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
providers: [{ provide: AdminService, useValue: mockAdminService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<AdminController>(AdminController);
service = module.get<AdminService>(AdminService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("getStats", () => {
it("should call service.getStats", async () => {
await controller.getStats();
expect(service.getStats).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,58 @@
import { Test, TestingModule } from "@nestjs/testing";
import { CategoriesRepository } from "../categories/repositories/categories.repository";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { UsersRepository } from "../users/repositories/users.repository";
import { AdminService } from "./admin.service";
describe("AdminService", () => {
let service: AdminService;
let _usersRepository: UsersRepository;
let _contentsRepository: ContentsRepository;
let _categoriesRepository: CategoriesRepository;
const mockUsersRepository = {
countAll: jest.fn(),
};
const mockContentsRepository = {
count: jest.fn(),
};
const mockCategoriesRepository = {
countAll: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: UsersRepository, useValue: mockUsersRepository },
{ provide: ContentsRepository, useValue: mockContentsRepository },
{ provide: CategoriesRepository, useValue: mockCategoriesRepository },
],
}).compile();
service = module.get<AdminService>(AdminService);
_usersRepository = module.get<UsersRepository>(UsersRepository);
_contentsRepository = module.get<ContentsRepository>(ContentsRepository);
_categoriesRepository =
module.get<CategoriesRepository>(CategoriesRepository);
});
it("should return stats", async () => {
mockUsersRepository.countAll.mockResolvedValue(10);
mockContentsRepository.count.mockResolvedValue(20);
mockCategoriesRepository.countAll.mockResolvedValue(5);
const result = await service.getStats();
expect(result).toEqual({
users: 10,
contents: 20,
categories: 5,
});
expect(mockUsersRepository.countAll).toHaveBeenCalled();
expect(mockContentsRepository.count).toHaveBeenCalledWith({});
expect(mockCategoriesRepository.countAll).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,95 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ApiKeysController } from "./api-keys.controller";
import { ApiKeysService } from "./api-keys.service";
describe("ApiKeysController", () => {
let controller: ApiKeysController;
let service: ApiKeysService;
const mockApiKeysService = {
create: jest.fn(),
findAll: jest.fn(),
revoke: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ApiKeysController],
providers: [{ provide: ApiKeysService, useValue: mockApiKeysService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ApiKeysController>(ApiKeysController);
service = module.get<ApiKeysService>(ApiKeysService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should call service.create", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { name: "Key Name", expiresAt: "2026-01-20T12:00:00Z" };
await controller.create(req, dto);
expect(service.create).toHaveBeenCalledWith(
"user-uuid",
"Key Name",
new Date(dto.expiresAt),
);
});
it("should call service.create without expiresAt", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { name: "Key Name" };
await controller.create(req, dto);
expect(service.create).toHaveBeenCalledWith(
"user-uuid",
"Key Name",
undefined,
);
});
});
describe("findAll", () => {
it("should call service.findAll", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.findAll(req);
expect(service.findAll).toHaveBeenCalledWith("user-uuid");
});
});
describe("revoke", () => {
it("should call service.revoke", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.revoke(req, "key-id");
expect(service.revoke).toHaveBeenCalledWith("user-uuid", "key-id");
});
});
});

View File

@@ -0,0 +1,83 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { ApiKeysRepository } from "./api-keys.repository";
describe("ApiKeysRepository", () => {
let repository: ApiKeysRepository;
let _databaseService: DatabaseService;
const mockDb = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeysRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<ApiKeysRepository>(ApiKeysRepository);
_databaseService = module.get<DatabaseService>(DatabaseService);
});
it("should create an api key", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.create({
userId: "u1",
name: "n",
prefix: "p",
keyHash: "h",
});
expect(mockDb.insert).toHaveBeenCalled();
});
it("should find all keys for user", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findAll("u1");
expect(result).toHaveLength(1);
});
it("should revoke a key", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([
{ id: "1", isActive: false },
]);
const result = await repository.revoke("u1", "k1");
expect(result[0].isActive).toBe(false);
});
it("should find active by hash", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findActiveByKeyHash("h");
expect(result.id).toBe("1");
});
it("should update last used", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.updateLastUsed("1");
expect(mockDb.update).toHaveBeenCalled();
});
});

View File

@@ -10,6 +10,7 @@ import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { AuthModule } from "./auth/auth.module";
import { CategoriesModule } from "./categories/categories.module";
import { CommentsModule } from "./comments/comments.module";
import { CommonModule } from "./common/common.module";
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
@@ -21,6 +22,8 @@ import { FavoritesModule } from "./favorites/favorites.module";
import { HealthController } from "./health.controller";
import { MailModule } from "./mail/mail.module";
import { MediaModule } from "./media/media.module";
import { MessagesModule } from "./messages/messages.module";
import { RealtimeModule } from "./realtime/realtime.module";
import { ReportsModule } from "./reports/reports.module";
import { S3Module } from "./s3/s3.module";
import { SessionsModule } from "./sessions/sessions.module";
@@ -37,12 +40,15 @@ import { UsersModule } from "./users/users.module";
UsersModule,
AuthModule,
CategoriesModule,
CommentsModule,
ContentsModule,
FavoritesModule,
TagsModule,
MediaModule,
MessagesModule,
SessionsModule,
ReportsModule,
RealtimeModule,
ApiKeysModule,
AdminModule,
ScheduleModule.forRoot(),

View File

@@ -0,0 +1,190 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
jest.mock("iron-session", () => ({
getIronSession: jest.fn().mockResolvedValue({
save: jest.fn(),
destroy: jest.fn(),
}),
}));
describe("AuthController", () => {
let controller: AuthController;
let authService: AuthService;
let _configService: ConfigService;
const mockAuthService = {
register: jest.fn(),
login: jest.fn(),
verifyTwoFactorLogin: jest.fn(),
refresh: jest.fn(),
};
const mockBootstrapService = {
consumeToken: jest.fn(),
};
const mockConfigService = {
get: jest
.fn()
.mockReturnValue("complex_password_at_least_32_characters_long"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: BootstrapService, useValue: mockBootstrapService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
controller = module.get<AuthController>(AuthController);
authService = module.get<AuthService>(AuthService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("register", () => {
it("should call authService.register", async () => {
const dto = {
email: "test@example.com",
password: "password",
username: "test",
};
await controller.register(dto as any);
expect(authService.register).toHaveBeenCalledWith(dto);
});
});
describe("login", () => {
it("should call authService.login and setup session if success", async () => {
const dto = { email: "test@example.com", password: "password" };
const req = { ip: "127.0.0.1" } as any;
const res = { json: jest.fn() } as any;
const loginResult = {
access_token: "at",
refresh_token: "rt",
userId: "1",
message: "ok",
};
mockAuthService.login.mockResolvedValue(loginResult);
await controller.login(dto as any, "ua", req, res);
expect(authService.login).toHaveBeenCalledWith(dto, "ua", "127.0.0.1");
expect(res.json).toHaveBeenCalledWith({ message: "ok", userId: "1" });
});
it("should return result if no access_token", async () => {
const dto = { email: "test@example.com", password: "password" };
const req = { ip: "127.0.0.1" } as any;
const res = { json: jest.fn() } as any;
const loginResult = { message: "2fa_required", userId: "1" };
mockAuthService.login.mockResolvedValue(loginResult);
await controller.login(dto as any, "ua", req, res);
expect(res.json).toHaveBeenCalledWith(loginResult);
});
});
describe("verifyTwoFactor", () => {
it("should call authService.verifyTwoFactorLogin and setup session", async () => {
const dto = { userId: "1", token: "123456" };
const req = { ip: "127.0.0.1" } as any;
const res = { json: jest.fn() } as any;
const verifyResult = {
access_token: "at",
refresh_token: "rt",
message: "ok",
};
mockAuthService.verifyTwoFactorLogin.mockResolvedValue(verifyResult);
await controller.verifyTwoFactor(dto, "ua", req, res);
expect(authService.verifyTwoFactorLogin).toHaveBeenCalledWith(
"1",
"123456",
"ua",
"127.0.0.1",
);
expect(res.json).toHaveBeenCalledWith({ message: "ok" });
});
});
describe("refresh", () => {
it("should refresh token if session has refresh token", async () => {
const { getIronSession } = require("iron-session");
const session = { refreshToken: "rt", save: jest.fn() };
getIronSession.mockResolvedValue(session);
const req = {} as any;
const res = { json: jest.fn() } as any;
mockAuthService.refresh.mockResolvedValue({
access_token: "at2",
refresh_token: "rt2",
});
await controller.refresh(req, res);
expect(authService.refresh).toHaveBeenCalledWith("rt");
expect(res.json).toHaveBeenCalledWith({ message: "Token refreshed" });
});
it("should return 401 if no refresh token", async () => {
const { getIronSession } = require("iron-session");
const session = { save: jest.fn() };
getIronSession.mockResolvedValue(session);
const req = {} as any;
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() } as any;
await controller.refresh(req, res);
expect(res.status).toHaveBeenCalledWith(401);
});
});
describe("logout", () => {
it("should destroy session", async () => {
const { getIronSession } = require("iron-session");
const session = { destroy: jest.fn() };
getIronSession.mockResolvedValue(session);
const req = {} as any;
const res = { json: jest.fn() } as any;
await controller.logout(req, res);
expect(session.destroy).toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({ message: "User logged out" });
});
});
});

View File

@@ -1,9 +1,19 @@
import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common";
import {
Body,
Controller,
Get,
Headers,
Post,
Query,
Req,
Res,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler";
import type { Request, Response } from "express";
import { getIronSession } from "iron-session";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { Verify2faDto } from "./dto/verify-2fa.dto";
@@ -13,6 +23,7 @@ import { getSessionOptions, SessionData } from "./session.config";
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly bootstrapService: BootstrapService,
private readonly configService: ConfigService,
) {}
@@ -120,4 +131,12 @@ export class AuthController {
session.destroy();
return res.json({ message: "User logged out" });
}
@Get("bootstrap-admin")
async bootstrapAdmin(
@Query("token") token: string,
@Query("username") username: string,
) {
return this.bootstrapService.consumeToken(token, username);
}
}

View File

@@ -3,6 +3,7 @@ import { SessionsModule } from "../sessions/sessions.module";
import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { AuthGuard } from "./guards/auth.guard";
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
import { RolesGuard } from "./guards/roles.guard";
@@ -15,6 +16,7 @@ import { RbacRepository } from "./repositories/rbac.repository";
providers: [
AuthService,
RbacService,
BootstrapService,
RbacRepository,
AuthGuard,
OptionalAuthGuard,

View File

@@ -148,7 +148,7 @@ describe("AuthService", () => {
const dto = {
username: "test",
email: "test@example.com",
password: "password",
password: "Password1!",
};
mockHashingService.hashPassword.mockResolvedValue("hashed-password");
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
@@ -165,7 +165,7 @@ describe("AuthService", () => {
describe("login", () => {
it("should login a user", async () => {
const dto = { email: "test@example.com", password: "password" };
const dto = { email: "test@example.com", password: "Password1!" };
const user = {
uuid: "user-id",
username: "test",

View File

@@ -136,6 +136,7 @@ export class AuthService {
const accessToken = await this.jwtService.generateJwt({
sub: user.uuid,
username: user.username,
role: user.role,
});
const session = await this.sessionsService.createSession(
@@ -178,6 +179,7 @@ export class AuthService {
const accessToken = await this.jwtService.generateJwt({
sub: user.uuid,
username: user.username,
role: user.role,
});
const session = await this.sessionsService.createSession(
@@ -205,6 +207,7 @@ export class AuthService {
const accessToken = await this.jwtService.generateJwt({
sub: user.uuid,
username: user.username,
role: user.role,
});
return {

View File

@@ -0,0 +1,114 @@
import { UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { UsersService } from "../users/users.service";
import { BootstrapService } from "./bootstrap.service";
import { RbacService } from "./rbac.service";
describe("BootstrapService", () => {
let service: BootstrapService;
let rbacService: RbacService;
let _usersService: UsersService;
const mockRbacService = {
countAdmins: jest.fn(),
assignRoleToUser: jest.fn(),
};
const mockUsersService = {
findPublicProfile: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
BootstrapService,
{ provide: RbacService, useValue: mockRbacService },
{ provide: UsersService, useValue: mockUsersService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<BootstrapService>(BootstrapService);
rbacService = module.get<RbacService>(RbacService);
_usersService = module.get<UsersService>(UsersService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("onApplicationBootstrap", () => {
it("should generate a token if no admin exists", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
const generateTokenSpy = jest.spyOn(
service as any,
"generateBootstrapToken",
);
await service.onApplicationBootstrap();
expect(rbacService.countAdmins).toHaveBeenCalled();
expect(generateTokenSpy).toHaveBeenCalled();
});
it("should not generate a token if admin exists", async () => {
mockRbacService.countAdmins.mockResolvedValue(1);
const generateTokenSpy = jest.spyOn(
service as any,
"generateBootstrapToken",
);
await service.onApplicationBootstrap();
expect(rbacService.countAdmins).toHaveBeenCalled();
expect(generateTokenSpy).not.toHaveBeenCalled();
});
});
describe("consumeToken", () => {
it("should throw UnauthorizedException if token is invalid", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
await service.onApplicationBootstrap();
await expect(service.consumeToken("wrong-token", "user1")).rejects.toThrow(
UnauthorizedException,
);
});
it("should throw UnauthorizedException if user not found", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
await service.onApplicationBootstrap();
const token = (service as any).bootstrapToken;
mockUsersService.findPublicProfile.mockResolvedValue(null);
await expect(service.consumeToken(token, "user1")).rejects.toThrow(
UnauthorizedException,
);
});
it("should assign admin role and invalidate token on success", async () => {
mockRbacService.countAdmins.mockResolvedValue(0);
await service.onApplicationBootstrap();
const token = (service as any).bootstrapToken;
const mockUser = { uuid: "user-uuid", username: "user1" };
mockUsersService.findPublicProfile.mockResolvedValue(mockUser);
const result = await service.consumeToken(token, "user1");
expect(rbacService.assignRoleToUser).toHaveBeenCalledWith(
"user-uuid",
"admin",
);
expect((service as any).bootstrapToken).toBeNull();
expect(result.message).toContain("user1 is now an administrator");
});
});
});

View File

@@ -0,0 +1,67 @@
import * as crypto from "node:crypto";
import {
Injectable,
Logger,
OnApplicationBootstrap,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { UsersService } from "../users/users.service";
import { RbacService } from "./rbac.service";
@Injectable()
export class BootstrapService implements OnApplicationBootstrap {
private readonly logger = new Logger(BootstrapService.name);
private bootstrapToken: string | null = null;
constructor(
private readonly rbacService: RbacService,
private readonly usersService: UsersService,
private readonly configService: ConfigService,
) {}
async onApplicationBootstrap() {
const adminCount = await this.rbacService.countAdmins();
if (adminCount === 0) {
this.generateBootstrapToken();
}
}
private generateBootstrapToken() {
this.bootstrapToken = crypto.randomBytes(32).toString("hex");
const domain = this.configService.get("DOMAIN_NAME") || "localhost";
const protocol = domain.includes("localhost") ? "http" : "https";
const url = `${protocol}://${domain}/auth/bootstrap-admin`;
this.logger.warn("SECURITY ALERT: No administrator found in database.");
this.logger.warn(
"To create the first administrator, use the following endpoint:",
);
this.logger.warn(
`Endpoint: GET ${url}?token=${this.bootstrapToken}&username=votre_nom_utilisateur`,
);
this.logger.warn(
'Exemple: curl -X GET "http://localhost/auth/bootstrap-admin?token=...&username=..."',
);
this.logger.warn("This token is one-time use only.");
}
async consumeToken(token: string, username: string) {
if (!this.bootstrapToken || token !== this.bootstrapToken) {
throw new UnauthorizedException("Invalid or expired bootstrap token");
}
const user = await this.usersService.findPublicProfile(username);
if (!user) {
throw new UnauthorizedException(`User ${username} not found`);
}
await this.rbacService.assignRoleToUser(user.uuid, "admin");
this.bootstrapToken = null; // One-time use
this.logger.log(
`User ${username} has been promoted to administrator via bootstrap token.`,
);
return { message: `User ${username} is now an administrator` };
}
}

View File

@@ -2,6 +2,7 @@ import {
IsEmail,
IsNotEmpty,
IsString,
Matches,
MaxLength,
MinLength,
} from "class-validator";
@@ -10,6 +11,10 @@ export class RegisterDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
@Matches(/^[a-z0-9_]+$/, {
message:
"username must contain only lowercase letters, numbers, and underscores",
})
username!: string;
@IsString()
@@ -21,5 +26,15 @@ export class RegisterDto {
@IsString()
@MinLength(8)
@Matches(/[A-Z]/, {
message: "password must contain at least one uppercase letter",
})
@Matches(/[a-z]/, {
message: "password must contain at least one lowercase letter",
})
@Matches(/[0-9]/, { message: "password must contain at least one number" })
@Matches(/[^A-Za-z0-9]/, {
message: "password must contain at least one special character",
})
password!: string;
}

View File

@@ -0,0 +1,89 @@
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { getIronSession } from "iron-session";
import { JwtService } from "../../crypto/services/jwt.service";
import { AuthGuard } from "./auth.guard";
jest.mock("jose", () => ({}));
jest.mock("iron-session", () => ({
getIronSession: jest.fn(),
}));
describe("AuthGuard", () => {
let guard: AuthGuard;
let _jwtService: JwtService;
let _configService: ConfigService;
const mockJwtService = {
verifyJwt: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockReturnValue("session-password"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthGuard,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
guard = module.get<AuthGuard>(AuthGuard);
_jwtService = module.get<JwtService>(JwtService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should return true for valid token", async () => {
const request = { user: null };
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => ({}),
}),
} as unknown as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({
accessToken: "valid-token",
});
mockJwtService.verifyJwt.mockResolvedValue({ sub: "user1" });
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(request.user).toEqual({ sub: "user1" });
});
it("should throw UnauthorizedException if no token", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({});
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
it("should throw UnauthorizedException if token invalid", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
});

View File

@@ -0,0 +1,84 @@
import { ExecutionContext } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { getIronSession } from "iron-session";
import { JwtService } from "../../crypto/services/jwt.service";
import { OptionalAuthGuard } from "./optional-auth.guard";
jest.mock("jose", () => ({}));
jest.mock("iron-session", () => ({
getIronSession: jest.fn(),
}));
describe("OptionalAuthGuard", () => {
let guard: OptionalAuthGuard;
let _jwtService: JwtService;
const mockJwtService = {
verifyJwt: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockReturnValue("session-password"),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OptionalAuthGuard,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
guard = module.get<OptionalAuthGuard>(OptionalAuthGuard);
_jwtService = module.get<JwtService>(JwtService);
});
it("should return true and set user for valid token", async () => {
const request = { user: null };
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => ({}),
}),
} as unknown as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "valid" });
mockJwtService.verifyJwt.mockResolvedValue({ sub: "u1" });
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(request.user).toEqual({ sub: "u1" });
});
it("should return true if no token", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({});
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it("should return true even if token invalid", async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({ user: null }),
getResponse: () => ({}),
}),
} as ExecutionContext;
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(context.switchToHttp().getRequest().user).toBeNull();
});
});

View File

@@ -0,0 +1,90 @@
import { ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Test, TestingModule } from "@nestjs/testing";
import { RbacService } from "../rbac.service";
import { RolesGuard } from "./roles.guard";
describe("RolesGuard", () => {
let guard: RolesGuard;
let _reflector: Reflector;
let _rbacService: RbacService;
const mockReflector = {
getAllAndOverride: jest.fn(),
};
const mockRbacService = {
getUserRoles: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RolesGuard,
{ provide: Reflector, useValue: mockReflector },
{ provide: RbacService, useValue: mockRbacService },
],
}).compile();
guard = module.get<RolesGuard>(RolesGuard);
_reflector = module.get<Reflector>(Reflector);
_rbacService = module.get<RbacService>(RbacService);
});
it("should return true if no roles required", async () => {
mockReflector.getAllAndOverride.mockReturnValue(null);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it("should return false if no user in request", async () => {
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: null }),
}),
} as ExecutionContext;
const result = await guard.canActivate(context);
expect(result).toBe(false);
});
it("should return true if user has required role", async () => {
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: { sub: "u1" } }),
}),
} as ExecutionContext;
mockRbacService.getUserRoles.mockResolvedValue(["admin", "user"]);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it("should return false if user doesn't have required role", async () => {
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
const context = {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user: { sub: "u1" } }),
}),
} as ExecutionContext;
mockRbacService.getUserRoles.mockResolvedValue(["user"]);
const result = await guard.canActivate(context);
expect(result).toBe(false);
});
});

View File

@@ -9,6 +9,8 @@ describe("RbacService", () => {
const mockRbacRepository = {
findRolesByUserId: jest.fn(),
findPermissionsByUserId: jest.fn(),
countRoles: jest.fn(),
createRole: jest.fn(),
};
beforeEach(async () => {
@@ -58,4 +60,35 @@ describe("RbacService", () => {
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
});
});
describe("seedRoles", () => {
it("should be called on application bootstrap", async () => {
const seedRolesSpy = jest.spyOn(service, "seedRoles");
await service.onApplicationBootstrap();
expect(seedRolesSpy).toHaveBeenCalled();
});
it("should seed roles if none exist", async () => {
mockRbacRepository.countRoles.mockResolvedValue(0);
await service.seedRoles();
expect(repository.countRoles).toHaveBeenCalled();
expect(repository.createRole).toHaveBeenCalledTimes(3);
expect(repository.createRole).toHaveBeenCalledWith(
"Administrator",
"admin",
"Full system access",
);
});
it("should not seed roles if some already exist", async () => {
mockRbacRepository.countRoles.mockResolvedValue(3);
await service.seedRoles();
expect(repository.countRoles).toHaveBeenCalled();
expect(repository.createRole).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,10 +1,53 @@
import { Injectable } from "@nestjs/common";
import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common";
import { RbacRepository } from "./repositories/rbac.repository";
@Injectable()
export class RbacService {
export class RbacService implements OnApplicationBootstrap {
private readonly logger = new Logger(RbacService.name);
constructor(private readonly rbacRepository: RbacRepository) {}
async onApplicationBootstrap() {
this.logger.log("RbacService initialized, checking roles...");
await this.seedRoles();
}
async seedRoles() {
try {
const count = await this.rbacRepository.countRoles();
if (count === 0) {
this.logger.log("No roles found, seeding default roles...");
const defaultRoles = [
{
name: "Administrator",
slug: "admin",
description: "Full system access",
},
{
name: "Moderator",
slug: "moderator",
description: "Access to moderation tools",
},
{ name: "User", slug: "user", description: "Standard user access" },
];
for (const role of defaultRoles) {
await this.rbacRepository.createRole(
role.name,
role.slug,
role.description,
);
this.logger.log(`Created role: ${role.slug}`);
}
this.logger.log("Default roles seeded successfully.");
} else {
this.logger.log(`${count} roles already exist, skipping seeding.`);
}
} catch (error) {
this.logger.error("Error during roles seeding:", error);
}
}
async getUserRoles(userId: string) {
return this.rbacRepository.findRolesByUserId(userId);
}
@@ -12,4 +55,12 @@ export class RbacService {
async getUserPermissions(userId: string) {
return this.rbacRepository.findPermissionsByUserId(userId);
}
async countAdmins() {
return this.rbacRepository.countAdmins();
}
async assignRoleToUser(userId: string, roleSlug: string) {
return this.rbacRepository.assignRole(userId, roleSlug);
}
}

View File

@@ -39,4 +39,52 @@ export class RbacRepository {
return Array.from(new Set(result.map((p) => p.slug)));
}
async countRoles(): Promise<number> {
const result = await this.databaseService.db
.select({ count: roles.id })
.from(roles);
return result.length;
}
async countAdmins(): Promise<number> {
const result = await this.databaseService.db
.select({ count: usersToRoles.userId })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(roles.slug, "admin"));
return result.length;
}
async createRole(name: string, slug: string, description?: string) {
return this.databaseService.db
.insert(roles)
.values({
name,
slug,
description,
})
.returning();
}
async assignRole(userId: string, roleSlug: string) {
const role = await this.databaseService.db
.select()
.from(roles)
.where(eq(roles.slug, roleSlug))
.limit(1);
if (!role[0]) {
throw new Error(`Role with slug ${roleSlug} not found`);
}
return this.databaseService.db
.insert(usersToRoles)
.values({
userId,
roleId: role[0].id,
})
.onConflictDoNothing()
.returning();
}
}

View File

@@ -0,0 +1,105 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
describe("CategoriesController", () => {
let controller: CategoriesController;
let service: CategoriesService;
const mockCategoriesService = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CategoriesController],
providers: [
{ provide: CategoriesService, useValue: mockCategoriesService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<CategoriesController>(CategoriesController);
service = module.get<CategoriesService>(CategoriesService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should call service.findAll", async () => {
await controller.findAll();
expect(service.findAll).toHaveBeenCalled();
});
});
describe("findOne", () => {
it("should call service.findOne", async () => {
await controller.findOne("1");
expect(service.findOne).toHaveBeenCalledWith("1");
});
});
describe("create", () => {
it("should call service.create", async () => {
const dto = { name: "Cat", slug: "cat" };
await controller.create(dto);
expect(service.create).toHaveBeenCalledWith(dto);
});
});
describe("update", () => {
it("should call service.update", async () => {
const dto = { name: "New Name" };
await controller.update("1", dto);
expect(service.update).toHaveBeenCalledWith("1", dto);
});
});
describe("remove", () => {
it("should call service.remove", async () => {
await controller.remove("1");
expect(service.remove).toHaveBeenCalledWith("1");
});
});
});

View File

@@ -0,0 +1,82 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { CategoriesRepository } from "./categories.repository";
describe("CategoriesRepository", () => {
let repository: CategoriesRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CategoriesRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<CategoriesRepository>(CategoriesRepository);
});
it("should find all", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findAll();
expect(result).toHaveLength(1);
});
it("should count all", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ count: 5 }]);
const result = await repository.countAll();
expect(result).toBe(5);
});
it("should find one", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findOne("1");
expect(result.id).toBe("1");
});
it("should create", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.create({ name: "C", slug: "s" });
expect(mockDb.insert).toHaveBeenCalled();
});
it("should update", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.update("1", { name: "N", updatedAt: new Date() });
expect(mockDb.update).toHaveBeenCalled();
});
it("should remove", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.remove("1");
expect(mockDb.delete).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getIronSession } from "iron-session";
import { AuthGuard } from "../auth/guards/auth.guard";
import { getSessionOptions } from "../auth/session.config";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { JwtService } from "../crypto/services/jwt.service";
import { CommentsService } from "./comments.service";
import { CreateCommentDto } from "./dto/create-comment.dto";
@Controller()
export class CommentsController {
constructor(
private readonly commentsService: CommentsService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
@Get("contents/:contentId/comments")
async findAllByContentId(
@Param("contentId") contentId: string,
@Req() req: any,
) {
// Tentative de récupération de l'utilisateur pour isLiked (optionnel)
let userId: string | undefined;
try {
const session = await getIronSession<any>(
req,
req.res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
if (session.accessToken) {
const payload = await this.jwtService.verifyJwt(session.accessToken);
userId = payload.sub;
}
} catch (_e) {
// Ignorer les erreurs de session
}
return this.commentsService.findAllByContentId(contentId, userId);
}
@Post("contents/:contentId/comments")
@UseGuards(AuthGuard)
create(
@Req() req: AuthenticatedRequest,
@Param("contentId") contentId: string,
@Body() dto: CreateCommentDto,
) {
return this.commentsService.create(req.user.sub, contentId, dto);
}
@Delete("comments/:id")
@UseGuards(AuthGuard)
remove(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
const isAdmin = req.user.role === "admin" || req.user.role === "moderator";
return this.commentsService.remove(req.user.sub, id, isAdmin);
}
@Post("comments/:id/like")
@UseGuards(AuthGuard)
like(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
return this.commentsService.like(req.user.sub, id);
}
@Delete("comments/:id/like")
@UseGuards(AuthGuard)
unlike(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
return this.commentsService.unlike(req.user.sub, id);
}
}

View File

@@ -0,0 +1,22 @@
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { ContentsModule } from "../contents/contents.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module";
import { CommentsController } from "./comments.controller";
import { CommentsService } from "./comments.service";
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
import { CommentsRepository } from "./repositories/comments.repository";
@Module({
imports: [
AuthModule,
S3Module,
RealtimeModule,
forwardRef(() => ContentsModule),
],
controllers: [CommentsController],
providers: [CommentsService, CommentsRepository, CommentLikesRepository],
exports: [CommentsService],
})
export class CommentsModule {}

View File

@@ -0,0 +1,151 @@
import { ForbiddenException, NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { CommentsService } from "./comments.service";
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
import { CommentsRepository } from "./repositories/comments.repository";
describe("CommentsService", () => {
let service: CommentsService;
let repository: CommentsRepository;
const mockCommentsRepository = {
create: jest.fn(),
findAllByContentId: jest.fn(),
findOne: jest.fn(),
findOneEnriched: jest.fn(),
delete: jest.fn(),
};
const mockCommentLikesRepository = {
addLike: jest.fn(),
removeLike: jest.fn(),
countByCommentId: jest.fn(),
isLikedByUser: jest.fn(),
};
const mockContentsRepository = {
findOne: jest.fn(),
};
const mockS3Service = {
getPublicUrl: jest.fn(),
};
const mockEventsGateway = {
sendToContent: jest.fn(),
sendToUser: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
CommentsService,
{ provide: CommentsRepository, useValue: mockCommentsRepository },
{ provide: CommentLikesRepository, useValue: mockCommentLikesRepository },
{ provide: ContentsRepository, useValue: mockContentsRepository },
{ provide: S3Service, useValue: mockS3Service },
{ provide: EventsGateway, useValue: mockEventsGateway },
],
}).compile();
service = module.get<CommentsService>(CommentsService);
repository = module.get<CommentsRepository>(CommentsRepository);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("create", () => {
it("should create a comment", async () => {
const userId = "user1";
const contentId = "content1";
const dto = { text: "Nice meme", parentId: undefined };
const createdComment = { id: "c1", ...dto, user: { username: "u1" } };
mockCommentsRepository.create.mockResolvedValue(createdComment);
mockCommentsRepository.findOneEnriched.mockResolvedValue(createdComment);
mockCommentLikesRepository.countByCommentId.mockResolvedValue(0);
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(false);
const result = await service.create(userId, contentId, dto);
expect(result.id).toBe("c1");
expect(repository.create).toHaveBeenCalledWith({
userId,
contentId,
text: dto.text,
parentId: undefined,
});
expect(mockEventsGateway.sendToContent).toHaveBeenCalledWith(
contentId,
"new_comment",
expect.any(Object),
);
});
});
describe("findAllByContentId", () => {
it("should return comments for a content", async () => {
mockCommentsRepository.findAllByContentId.mockResolvedValue([
{ id: "c1", user: { avatarUrl: "path" } },
]);
mockCommentLikesRepository.countByCommentId.mockResolvedValue(5);
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(true);
mockS3Service.getPublicUrl.mockReturnValue("url");
const result = await service.findAllByContentId("content1", "u1");
expect(result).toHaveLength(1);
expect(result[0].likesCount).toBe(5);
expect(result[0].isLiked).toBe(true);
expect(result[0].user.avatarUrl).toBe("url");
});
});
describe("remove", () => {
it("should remove comment if owner", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
await service.remove("u1", "c1");
expect(repository.delete).toHaveBeenCalledWith("c1");
});
it("should remove comment if admin", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
await service.remove("other", "c1", true);
expect(repository.delete).toHaveBeenCalledWith("c1");
});
it("should throw NotFoundException if comment does not exist", async () => {
mockCommentsRepository.findOne.mockResolvedValue(null);
await expect(service.remove("u1", "c1")).rejects.toThrow(NotFoundException);
});
it("should throw ForbiddenException if not owner and not admin", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
await expect(service.remove("other", "c1")).rejects.toThrow(
ForbiddenException,
);
});
});
describe("like", () => {
it("should add like", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
await service.like("u1", "c1");
expect(mockCommentLikesRepository.addLike).toHaveBeenCalledWith("c1", "u1");
});
});
describe("unlike", () => {
it("should remove like", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
await service.unlike("u1", "c1");
expect(mockCommentLikesRepository.removeLike).toHaveBeenCalledWith(
"c1",
"u1",
);
});
});
});

View File

@@ -0,0 +1,177 @@
import {
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import type { CreateCommentDto } from "./dto/create-comment.dto";
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
import { CommentsRepository } from "./repositories/comments.repository";
@Injectable()
export class CommentsService {
constructor(
private readonly commentsRepository: CommentsRepository,
private readonly commentLikesRepository: CommentLikesRepository,
@Inject(forwardRef(() => ContentsRepository))
private readonly contentsRepository: ContentsRepository,
private readonly s3Service: S3Service,
private readonly eventsGateway: EventsGateway,
) {}
async create(userId: string, contentId: string, dto: CreateCommentDto) {
const comment = await this.commentsRepository.create({
userId,
contentId,
text: dto.text,
parentId: dto.parentId,
});
// Récupérer le commentaire avec les infos utilisateur pour le WebSocket
const enrichedComment = await this.findOneEnriched(comment.id, userId);
if (!enrichedComment) return null;
// Notifier les autres utilisateurs sur ce contenu (room de contenu)
this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment);
// Notifications ciblées
try {
// 1. Notifier l'auteur du post
const content = await this.contentsRepository.findOne(contentId);
if (content && content.userId !== userId) {
this.eventsGateway.sendToUser(content.userId, "notification", {
type: "comment",
userId: userId,
username: enrichedComment.user.username,
contentId: contentId,
commentId: comment.id,
text: `a commenté votre post : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
});
}
// 2. Si c'est une réponse, notifier l'auteur du commentaire parent
if (dto.parentId) {
const parentComment = await this.commentsRepository.findOne(dto.parentId);
if (
parentComment &&
parentComment.userId !== userId &&
(!content || parentComment.userId !== content.userId)
) {
this.eventsGateway.sendToUser(parentComment.userId, "notification", {
type: "reply",
userId: userId,
username: enrichedComment.user.username,
contentId: contentId,
commentId: comment.id,
text: `a répondu à votre commentaire : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
});
}
}
} catch (error) {
console.error("Failed to send notification:", error);
}
return enrichedComment;
}
async findOneEnriched(commentId: string, currentUserId?: string) {
const comment = await this.commentsRepository.findOneEnriched(commentId);
if (!comment) return null;
const [likesCount, isLiked] = await Promise.all([
this.commentLikesRepository.countByCommentId(comment.id),
currentUserId
? this.commentLikesRepository.isLikedByUser(comment.id, currentUserId)
: Promise.resolve(false),
]);
return {
...comment,
likesCount,
isLiked,
user: {
...comment.user,
avatarUrl: comment.user.avatarUrl
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
: null,
},
};
}
async findAllByContentId(contentId: string, userId?: string) {
const comments = await this.commentsRepository.findAllByContentId(contentId);
return Promise.all(
comments.map(async (comment) => {
const [likesCount, isLiked] = await Promise.all([
this.commentLikesRepository.countByCommentId(comment.id),
userId
? this.commentLikesRepository.isLikedByUser(comment.id, userId)
: Promise.resolve(false),
]);
return {
...comment,
likesCount,
isLiked,
user: {
...comment.user,
avatarUrl: comment.user.avatarUrl
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
: null,
},
};
}),
);
}
async remove(userId: string, commentId: string, isAdmin = false) {
const comment = await this.commentsRepository.findOne(commentId);
if (!comment) {
throw new NotFoundException("Comment not found");
}
if (!isAdmin && comment.userId !== userId) {
throw new ForbiddenException("You cannot delete this comment");
}
await this.commentsRepository.delete(commentId);
}
async like(userId: string, commentId: string) {
const comment = await this.commentsRepository.findOne(commentId);
if (!comment) {
throw new NotFoundException("Comment not found");
}
await this.commentLikesRepository.addLike(commentId, userId);
// Notifier l'auteur du commentaire
if (comment.userId !== userId) {
try {
const liker = await this.findOneEnriched(commentId, userId);
this.eventsGateway.sendToUser(comment.userId, "notification", {
type: "like_comment",
userId: userId,
username: liker?.user.username,
contentId: comment.contentId,
commentId: commentId,
text: "a aimé votre commentaire",
});
} catch (error) {
console.error("Failed to send like notification:", error);
}
}
}
async unlike(userId: string, commentId: string) {
const comment = await this.commentsRepository.findOne(commentId);
if (!comment) {
throw new NotFoundException("Comment not found");
}
await this.commentLikesRepository.removeLike(commentId, userId);
}
}

View File

@@ -0,0 +1,18 @@
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
@MaxLength(1000)
text!: string;
@IsOptional()
@IsUUID()
parentId?: string;
}

View File

@@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import { and, eq, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { commentLikes } from "../../database/schemas/comment_likes";
@Injectable()
export class CommentLikesRepository {
constructor(private readonly databaseService: DatabaseService) {}
async addLike(commentId: string, userId: string) {
await this.databaseService.db
.insert(commentLikes)
.values({ commentId, userId })
.onConflictDoNothing();
}
async removeLike(commentId: string, userId: string) {
await this.databaseService.db
.delete(commentLikes)
.where(
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
);
}
async countByCommentId(commentId: string) {
const results = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(commentLikes)
.where(eq(commentLikes.commentId, commentId));
return Number(results[0]?.count || 0);
}
async isLikedByUser(commentId: string, userId: string) {
const results = await this.databaseService.db
.select()
.from(commentLikes)
.where(
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
);
return !!results[0];
}
}

View File

@@ -0,0 +1,75 @@
import { Injectable } from "@nestjs/common";
import { and, desc, eq, isNull } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { comments, users } from "../../database/schemas";
import type { NewCommentInDb } from "../../database/schemas/comments";
@Injectable()
export class CommentsRepository {
constructor(private readonly databaseService: DatabaseService) {}
async create(data: NewCommentInDb) {
const results = await this.databaseService.db
.insert(comments)
.values(data)
.returning();
return results[0];
}
async findAllByContentId(contentId: string) {
return this.databaseService.db
.select({
id: comments.id,
text: comments.text,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
user: {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.uuid))
.where(and(eq(comments.contentId, contentId), isNull(comments.deletedAt)))
.orderBy(desc(comments.createdAt));
}
async findOne(id: string) {
const results = await this.databaseService.db
.select()
.from(comments)
.where(and(eq(comments.id, id), isNull(comments.deletedAt)));
return results[0];
}
async findOneEnriched(id: string) {
const results = await this.databaseService.db
.select({
id: comments.id,
text: comments.text,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
user: {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.uuid))
.where(and(eq(comments.id, id), isNull(comments.deletedAt)));
return results[0];
}
async delete(id: string) {
await this.databaseService.db
.update(comments)
.set({ deletedAt: new Date() })
.where(eq(comments.id, id));
}
}

View File

@@ -4,5 +4,6 @@ export interface AuthenticatedRequest extends Request {
user: {
sub: string;
username: string;
role: string;
};
}

View File

@@ -1,10 +1,14 @@
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable, Logger, NestMiddleware } from "@nestjs/common";
import type { Cache } from "cache-manager";
import type { NextFunction, Request, Response } from "express";
@Injectable()
export class CrawlerDetectionMiddleware implements NestMiddleware {
private readonly logger = new Logger("CrawlerDetection");
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
private readonly SUSPICIOUS_PATTERNS = [
/\.env/,
/wp-admin/,
@@ -24,7 +28,7 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
/db\./,
/backup\./,
/cgi-bin/,
/\.well-known\/security\.txt/, // Bien que légitime, souvent scanné
/\.well-known\/security\.txt/,
];
private readonly BOT_USER_AGENTS = [
@@ -40,11 +44,21 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
/masscan/i,
];
use(req: Request, res: Response, next: NextFunction) {
async use(req: Request, res: Response, next: NextFunction) {
const { method, url, ip } = req;
const userAgent = req.get("user-agent") || "unknown";
res.on("finish", () => {
// Vérifier si l'IP est bannie
const isBanned = await this.cacheManager.get(`banned_ip:${ip}`);
if (isBanned) {
this.logger.warn(`Banned IP attempt: ${ip} -> ${method} ${url}`);
res.status(403).json({
message: "Access denied: Your IP has been temporarily banned.",
});
return;
}
res.on("finish", async () => {
if (res.statusCode === 404) {
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
pattern.test(url),
@@ -57,7 +71,9 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
this.logger.warn(
`Potential crawler detected: [${ip}] ${method} ${url} - User-Agent: ${userAgent}`,
);
// Ici, on pourrait ajouter une logique pour bannir l'IP temporairement via Redis
// Bannir l'IP pour 24h via Redis
await this.cacheManager.set(`banned_ip:${ip}`, true, 86400000);
}
}
});

View File

@@ -48,6 +48,7 @@ export const envSchema = z.object({
// Media Limits
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
MAX_VIDEO_SIZE_KB: z.coerce.number().default(10240),
});
export type Env = z.infer<typeof envSchema>;

View File

@@ -0,0 +1,230 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ContentsController } from "./contents.controller";
import { ContentsService } from "./contents.service";
describe("ContentsController", () => {
let controller: ContentsController;
let service: ContentsService;
const mockContentsService = {
create: jest.fn(),
getUploadUrl: jest.fn(),
uploadAndProcess: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
incrementViews: jest.fn(),
incrementUsage: jest.fn(),
remove: jest.fn(),
removeAdmin: jest.fn(),
generateBotHtml: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ContentsController],
providers: [
{ provide: ContentsService, useValue: mockContentsService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.overrideGuard(OptionalAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ContentsController>(ContentsController);
service = module.get<ContentsService>(ContentsService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should call service.create", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { title: "Title", type: "image" as any };
await controller.create(req, dto as any);
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
});
});
describe("getUploadUrl", () => {
it("should call service.getUploadUrl", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.getUploadUrl(req, "test.jpg");
expect(service.getUploadUrl).toHaveBeenCalledWith("user-uuid", "test.jpg");
});
});
describe("upload", () => {
it("should call service.uploadAndProcess", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const file = {} as Express.Multer.File;
const dto = { title: "Title" };
await controller.upload(req, file, dto as any);
expect(service.uploadAndProcess).toHaveBeenCalledWith(
"user-uuid",
file,
dto,
);
});
});
describe("explore", () => {
it("should call service.findAll", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.explore(
req,
10,
0,
"trend",
"tag",
"cat",
"auth",
"query",
false,
undefined,
);
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
sortBy: "trend",
tag: "tag",
category: "cat",
author: "auth",
query: "query",
favoritesOnly: false,
userId: "user-uuid",
});
});
});
describe("trends", () => {
it("should call service.findAll with trend sort", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.trends(req, 10, 0);
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
sortBy: "trend",
userId: "user-uuid",
});
});
});
describe("recent", () => {
it("should call service.findAll with recent sort", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.recent(req, 10, 0);
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
sortBy: "recent",
userId: "user-uuid",
});
});
});
describe("findOne", () => {
it("should return json for normal user", async () => {
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
const res = { json: jest.fn(), send: jest.fn() } as any;
const content = { id: "1" };
mockContentsService.findOne.mockResolvedValue(content);
await controller.findOne("1", req, res);
expect(res.json).toHaveBeenCalledWith(content);
});
it("should return html for bot", async () => {
const req = {
user: { sub: "user-uuid" },
headers: { "user-agent": "Googlebot" },
} as any;
const res = { json: jest.fn(), send: jest.fn() } as any;
const content = { id: "1" };
mockContentsService.findOne.mockResolvedValue(content);
mockContentsService.generateBotHtml.mockReturnValue("<html></html>");
await controller.findOne("1", req, res);
expect(res.send).toHaveBeenCalledWith("<html></html>");
});
it("should throw NotFoundException if not found", async () => {
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
const res = { json: jest.fn(), send: jest.fn() } as any;
mockContentsService.findOne.mockResolvedValue(null);
await expect(controller.findOne("1", req, res)).rejects.toThrow(
"Contenu non trouvé",
);
});
});
describe("incrementViews", () => {
it("should call service.incrementViews", async () => {
await controller.incrementViews("1");
expect(service.incrementViews).toHaveBeenCalledWith("1");
});
});
describe("incrementUsage", () => {
it("should call service.incrementUsage", async () => {
await controller.incrementUsage("1");
expect(service.incrementUsage).toHaveBeenCalledWith("1");
});
});
describe("remove", () => {
it("should call service.remove", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.remove("1", req);
expect(service.remove).toHaveBeenCalledWith("1", "user-uuid");
});
});
describe("removeAdmin", () => {
it("should call service.removeAdmin", async () => {
await controller.removeAdmin("1");
expect(service.removeAdmin).toHaveBeenCalledWith("1");
});
});
});

View File

@@ -10,6 +10,7 @@ import {
Param,
ParseBoolPipe,
ParseIntPipe,
Patch,
Post,
Query,
Req,
@@ -173,6 +174,16 @@ export class ContentsController {
return this.contentsService.incrementUsage(id);
}
@Patch(":id")
@UseGuards(AuthGuard)
update(
@Param("id") id: string,
@Req() req: AuthenticatedRequest,
@Body() updateContentDto: any,
) {
return this.contentsService.update(id, req.user.sub, updateContentDto);
}
@Delete(":id")
@UseGuards(AuthGuard)
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
@@ -185,4 +196,11 @@ export class ContentsController {
removeAdmin(@Param("id") id: string) {
return this.contentsService.removeAdmin(id);
}
@Patch(":id/admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
updateAdmin(@Param("id") id: string, @Body() updateContentDto: any) {
return this.contentsService.updateAdmin(id, updateContentDto);
}
}

View File

@@ -1,13 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { MediaModule } from "../media/media.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module";
import { ContentsController } from "./contents.controller";
import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository";
@Module({
imports: [S3Module, AuthModule, MediaModule],
imports: [S3Module, AuthModule, MediaModule, RealtimeModule],
controllers: [ContentsController],
providers: [ContentsService, ContentsRepository],
exports: [ContentsRepository],

View File

@@ -7,6 +7,7 @@ import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository";
@@ -23,6 +24,7 @@ describe("ContentsService", () => {
incrementViews: jest.fn(),
incrementUsage: jest.fn(),
softDelete: jest.fn(),
softDeleteAdmin: jest.fn(),
findOne: jest.fn(),
findBySlug: jest.fn(),
};
@@ -48,6 +50,10 @@ describe("ContentsService", () => {
del: jest.fn(),
};
const mockEventsGateway = {
sendToUser: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
@@ -59,6 +65,7 @@ describe("ContentsService", () => {
{ provide: MediaService, useValue: mockMediaService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
{ provide: EventsGateway, useValue: mockEventsGateway },
],
}).compile();
@@ -147,4 +154,81 @@ describe("ContentsService", () => {
expect(result[0].views).toBe(1);
});
});
describe("incrementUsage", () => {
it("should increment usage", async () => {
mockContentsRepository.incrementUsage.mockResolvedValue([
{ id: "1", usageCount: 1 },
]);
await service.incrementUsage("1");
expect(mockContentsRepository.incrementUsage).toHaveBeenCalledWith("1");
});
});
describe("remove", () => {
it("should soft delete content", async () => {
mockContentsRepository.softDelete.mockResolvedValue({ id: "1" });
await service.remove("1", "u1");
expect(mockContentsRepository.softDelete).toHaveBeenCalledWith("1", "u1");
});
});
describe("removeAdmin", () => {
it("should soft delete content without checking owner", async () => {
mockContentsRepository.softDeleteAdmin.mockResolvedValue({ id: "1" });
await service.removeAdmin("1");
expect(mockContentsRepository.softDeleteAdmin).toHaveBeenCalledWith("1");
});
});
describe("findOne", () => {
it("should return content by id", async () => {
mockContentsRepository.findOne.mockResolvedValue({
id: "1",
storageKey: "k",
author: { avatarUrl: "a" },
});
mockS3Service.getPublicUrl.mockReturnValue("url");
const result = await service.findOne("1");
expect(result.id).toBe("1");
expect(result.url).toBe("url");
});
it("should return content by slug", async () => {
mockContentsRepository.findOne.mockResolvedValue({
id: "1",
slug: "s",
storageKey: "k",
});
const result = await service.findOne("s");
expect(result.slug).toBe("s");
});
});
describe("generateBotHtml", () => {
it("should generate html with og tags", () => {
const content = { title: "Title", storageKey: "k" };
mockS3Service.getPublicUrl.mockReturnValue("url");
const html = service.generateBotHtml(content as any);
expect(html).toContain("<title>Title</title>");
expect(html).toContain('content="Title"');
expect(html).toContain('content="url"');
});
});
describe("ensureUniqueSlug", () => {
it("should return original slug if unique", async () => {
mockContentsRepository.findBySlug.mockResolvedValue(null);
const slug = (service as any).ensureUniqueSlug("My Title");
await expect(slug).resolves.toBe("my-title");
});
it("should append counter if not unique", async () => {
mockContentsRepository.findBySlug
.mockResolvedValueOnce({ id: "1" })
.mockResolvedValueOnce(null);
const slug = await (service as any).ensureUniqueSlug("My Title");
expect(slug).toBe("my-title-1");
});
});
});

View File

@@ -14,6 +14,7 @@ import type {
} from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { CreateContentDto } from "./dto/create-content.dto";
import { UploadContentDto } from "./dto/upload-content.dto";
@@ -29,6 +30,7 @@ export class ContentsService {
@Inject(MediaService) private readonly mediaService: IMediaService,
private readonly configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly eventsGateway: EventsGateway,
) {}
private async clearContentsCache() {
@@ -48,6 +50,11 @@ export class ContentsService {
data: UploadContentDto,
) {
this.logger.log(`Uploading and processing file for user ${userId}`);
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "starting",
progress: 0,
});
// 0. Validation du format et de la taille
const allowedMimeTypes = [
"image/png",
@@ -55,60 +62,117 @@ export class ContentsService {
"image/webp",
"image/gif",
"video/webm",
"video/mp4",
"video/quicktime",
];
if (!allowedMimeTypes.includes(file.mimetype)) {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "error",
message: "Format de fichier non supporté",
});
throw new BadRequestException(
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, gif.",
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.",
);
}
const isGif = file.mimetype === "image/gif";
const maxSizeKb = isGif
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024)
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
// Autodétermination du type si non fourni ou pour valider
let contentType: "meme" | "gif" | "video" = "meme";
if (file.mimetype === "image/gif") {
contentType = "gif";
} else if (file.mimetype.startsWith("video/")) {
contentType = "video";
}
const isGif = contentType === "gif";
const isVideo = contentType === "video";
let maxSizeKb: number;
if (isGif) {
maxSizeKb = this.configService.get<number>("MAX_GIF_SIZE_KB", 1024);
} else if (isVideo) {
maxSizeKb = this.configService.get<number>("MAX_VIDEO_SIZE_KB", 10240);
} else {
maxSizeKb = this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
}
if (file.size > maxSizeKb * 1024) {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "error",
message: "Fichier trop volumineux",
});
throw new BadRequestException(
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : "image"}: ${maxSizeKb} Ko.`,
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`,
);
}
// 1. Scan Antivirus
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "scanning",
progress: 20,
});
const scanResult = await this.mediaService.scanFile(
file.buffer,
file.originalname,
);
if (scanResult.isInfected) {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "error",
message: "Fichier infecté",
});
throw new BadRequestException(
`Le fichier est infecté par ${scanResult.virusName}`,
);
}
// 2. Transcodage
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "processing",
progress: 40,
});
let processed: MediaProcessingResult;
if (file.mimetype.startsWith("image/")) {
// Image ou GIF -> WebP (format moderne, bien supporté)
if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
// Image -> WebP (format moderne, bien supporté)
processed = await this.mediaService.processImage(file.buffer, "webp");
} else if (file.mimetype.startsWith("video/")) {
// Vidéo -> WebM
} else if (
file.mimetype.startsWith("video/") ||
file.mimetype === "image/gif"
) {
// Vidéo ou GIF -> WebM
processed = await this.mediaService.processVideo(file.buffer, "webm");
} else {
throw new BadRequestException("Format de fichier non supporté");
}
// 3. Upload vers S3
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "uploading_s3",
progress: 70,
});
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
this.logger.log(`File uploaded successfully to S3: ${key}`);
// 4. Création en base de données
return await this.create(userId, {
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "saving",
progress: 90,
});
const content = await this.create(userId, {
...data,
type: contentType, // Utiliser le type autodéterminé
storageKey: key,
mimeType: processed.mimeType,
fileSize: processed.size,
});
this.eventsGateway.sendToUser(userId, "upload_progress", {
status: "completed",
progress: 100,
contentId: content.id,
});
return content;
}
async findAll(options: {
@@ -184,6 +248,35 @@ export class ContentsService {
return deleted;
}
async updateAdmin(id: string, data: any) {
this.logger.log(`Updating content ${id} by admin`);
const updated = await this.contentsRepository.update(id, data);
if (updated) {
await this.clearContentsCache();
}
return updated;
}
async update(id: string, userId: string, data: any) {
this.logger.log(`Updating content ${id} for user ${userId}`);
// Vérifier que le contenu appartient à l'utilisateur
const existing = await this.contentsRepository.findOne(id, userId);
if (!existing || existing.userId !== userId) {
throw new BadRequestException(
"Contenu non trouvé ou vous n'avez pas la permission de le modifier.",
);
}
const updated = await this.contentsRepository.update(id, data);
if (updated) {
await this.clearContentsCache();
}
return updated;
}
async findOne(idOrSlug: string, userId?: string) {
const content = await this.contentsRepository.findOne(idOrSlug, userId);
if (!content) return null;

View File

@@ -12,11 +12,12 @@ import {
export enum ContentType {
MEME = "meme",
GIF = "gif",
VIDEO = "video",
}
export class CreateContentDto {
@IsEnum(ContentType)
type!: "meme" | "gif";
type!: "meme" | "gif" | "video";
@IsString()
@IsNotEmpty()

View File

@@ -11,7 +11,7 @@ import { ContentType } from "./create-content.dto";
export class UploadContentDto {
@IsEnum(ContentType)
type!: "meme" | "gif";
type!: "meme" | "gif" | "video";
@IsString()
@IsNotEmpty()

View File

@@ -404,6 +404,15 @@ export class ContentsRepository {
return deleted;
}
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
const [updated] = await this.databaseService.db
.update(contents)
.set({ ...data, updatedAt: new Date() })
.where(eq(contents.id, id))
.returning();
return updated;
}
async findBySlug(slug: string) {
const [result] = await this.databaseService.db
.select()

View File

@@ -0,0 +1,67 @@
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "./database.service";
jest.mock("pg", () => {
const mPool = {
connect: jest.fn(),
query: jest.fn(),
end: jest.fn(),
on: jest.fn(),
};
return { Pool: jest.fn(() => mPool) };
});
jest.mock("drizzle-orm/node-postgres", () => ({
drizzle: jest.fn().mockReturnValue({}),
}));
describe("DatabaseService", () => {
let service: DatabaseService;
let _configService: ConfigService;
const mockConfigService = {
get: jest.fn((key) => {
const config = {
POSTGRES_PASSWORD: "p",
POSTGRES_USER: "u",
POSTGRES_HOST: "h",
POSTGRES_PORT: "5432",
POSTGRES_DB: "db",
NODE_ENV: "development",
};
return config[key];
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseService,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<DatabaseService>(DatabaseService);
_configService = module.get<ConfigService>(ConfigService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("onModuleInit", () => {
it("should skip migrations in development", async () => {
await service.onModuleInit();
expect(mockConfigService.get).toHaveBeenCalledWith("NODE_ENV");
});
});
describe("onModuleDestroy", () => {
it("should close pool", async () => {
const pool = (service as any).pool;
await service.onModuleDestroy();
expect(pool.end).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,21 @@
import { pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core";
import { comments } from "./comments";
import { users } from "./users";
export const commentLikes = pgTable(
"comment_likes",
{
commentId: uuid("comment_id")
.notNull()
.references(() => comments.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.commentId, t.userId] }),
}),
);

View File

@@ -0,0 +1,35 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { contents } from "./content";
import { users } from "./users";
export const comments = pgTable(
"comments",
{
id: uuid("id").primaryKey().defaultRandom(),
contentId: uuid("content_id")
.notNull()
.references(() => contents.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
parentId: uuid("parent_id").references(() => comments.id, {
onDelete: "cascade",
}),
text: text("text").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
},
(table) => ({
contentIdIdx: index("comments_content_id_idx").on(table.contentId),
userIdIdx: index("comments_user_id_idx").on(table.userId),
parentIdIdx: index("comments_parent_id_idx").on(table.parentId),
}),
);
export type CommentInDb = typeof comments.$inferSelect;
export type NewCommentInDb = typeof comments.$inferInsert;

View File

@@ -12,7 +12,7 @@ import { categories } from "./categories";
import { tags } from "./tags";
import { users } from "./users";
export const contentType = pgEnum("content_type", ["meme", "gif"]);
export const contentType = pgEnum("content_type", ["meme", "gif", "video"]);
export const contents = pgTable(
"contents",

View File

@@ -1,8 +1,11 @@
export * from "./api_keys";
export * from "./audit_logs";
export * from "./categories";
export * from "./comment_likes";
export * from "./comments";
export * from "./content";
export * from "./favorites";
export * from "./messages";
export * from "./pgp";
export * from "./rbac";
export * from "./reports";

View File

@@ -0,0 +1,66 @@
import {
index,
pgTable,
primaryKey,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { users } from "./users";
export const conversations = pgTable("conversations", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const conversationParticipants = pgTable(
"conversation_participants",
{
conversationId: uuid("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
joinedAt: timestamp("joined_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.conversationId, t.userId] }),
}),
);
export const messages = pgTable(
"messages",
{
id: uuid("id").primaryKey().defaultRandom(),
conversationId: uuid("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
senderId: uuid("sender_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
text: text("text").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
readAt: timestamp("read_at", { withTimezone: true }),
},
(table) => ({
conversationIdIdx: index("messages_conversation_id_idx").on(
table.conversationId,
),
senderIdIdx: index("messages_sender_id_idx").on(table.senderId),
}),
);
export type ConversationInDb = typeof conversations.$inferSelect;
export type NewConversationInDb = typeof conversations.$inferInsert;
export type MessageInDb = typeof messages.$inferSelect;
export type NewMessageInDb = typeof messages.$inferInsert;

View File

@@ -21,14 +21,19 @@ const getPgpKey = () => process.env.PGP_ENCRYPTION_KEY || "default-pgp-key";
* withAutomaticPgpDecrypt(users.email);
* ```
*/
export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
export const pgpEncrypted = customType<{
data: string | null;
driverData: Buffer | string | null | SQL;
}>({
dataType() {
return "bytea";
},
toDriver(value: string): SQL {
toDriver(value: string | null): SQL | null {
if (value === null) return null;
return sql`pgp_sym_encrypt(${value}, ${getPgpKey()})`;
},
fromDriver(value: Buffer | string): string {
fromDriver(value: Buffer | string | null | any): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "string") return value;
return value.toString();
},
@@ -41,7 +46,9 @@ export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
const originalGetSQL = column.getSQL.bind(column);
column.getSQL = () =>
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})`.mapWith(column);
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})::text`.mapWith(
column,
);
return column;
}
@@ -59,5 +66,7 @@ export function pgpSymDecrypt(
column: AnyPgColumn,
key: string | SQL,
): SQL<string> {
return sql`pgp_sym_decrypt(${column}, ${key})`.mapWith(column) as SQL<string>;
return sql`pgp_sym_decrypt(${column}, ${key})::text`.mapWith(
column,
) as SQL<string>;
}

View File

@@ -29,13 +29,15 @@ export const users = pgTable(
displayName: varchar("display_name", { length: 32 }),
username: varchar("username", { length: 32 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 100 }).notNull(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
avatarUrl: varchar("avatar_url", { length: 512 }),
bio: varchar("bio", { length: 255 }),
// Sécurité
twoFactorSecret: pgpEncrypted("two_factor_secret"),
isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false),
showOnlineStatus: boolean("show_online_status").notNull().default(true),
showReadReceipts: boolean("show_read_receipts").notNull().default(true),
// RGPD & Conformité
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées

View File

@@ -0,0 +1,82 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { FavoritesController } from "./favorites.controller";
import { FavoritesService } from "./favorites.service";
describe("FavoritesController", () => {
let controller: FavoritesController;
let service: FavoritesService;
const mockFavoritesService = {
addFavorite: jest.fn(),
removeFavorite: jest.fn(),
getUserFavorites: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FavoritesController],
providers: [{ provide: FavoritesService, useValue: mockFavoritesService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FavoritesController>(FavoritesController);
service = module.get<FavoritesService>(FavoritesService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("add", () => {
it("should call service.addFavorite", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.add(req, "content-1");
expect(service.addFavorite).toHaveBeenCalledWith("user-uuid", "content-1");
});
});
describe("remove", () => {
it("should call service.removeFavorite", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.remove(req, "content-1");
expect(service.removeFavorite).toHaveBeenCalledWith(
"user-uuid",
"content-1",
);
});
});
describe("list", () => {
it("should call service.getUserFavorites", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.list(req, 10, 0);
expect(service.getUserFavorites).toHaveBeenCalledWith("user-uuid", 10, 0);
});
});
});

View File

@@ -0,0 +1,72 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { FavoritesRepository } from "./favorites.repository";
describe("FavoritesRepository", () => {
let repository: FavoritesRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FavoritesRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<FavoritesRepository>(FavoritesRepository);
});
it("should find content by id", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findContentById("1");
expect(result.id).toBe("1");
});
it("should add favorite", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([
{ userId: "u", contentId: "c" },
]);
await repository.add("u", "c");
expect(mockDb.insert).toHaveBeenCalled();
});
it("should remove favorite", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.remove("u", "c");
expect(mockDb.delete).toHaveBeenCalled();
});
it("should find by user id", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ content: { id: "c1" } }]);
const result = await repository.findByUserId("u1", 10, 0);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("c1");
});
});

View File

@@ -1,3 +1,4 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "./database/database.service";
import { HealthController } from "./health.controller";
@@ -9,6 +10,10 @@ describe("HealthController", () => {
execute: jest.fn().mockResolvedValue([]),
};
const mockCacheManager = {
set: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
@@ -19,24 +24,42 @@ describe("HealthController", () => {
db: mockDb,
},
},
{
provide: CACHE_MANAGER,
useValue: mockCacheManager,
},
],
}).compile();
controller = module.get<HealthController>(HealthController);
});
it("should return ok if database is connected", async () => {
it("should return ok if database and redis are connected", async () => {
mockDb.execute.mockResolvedValue([]);
mockCacheManager.set.mockResolvedValue(undefined);
const result = await controller.check();
expect(result.status).toBe("ok");
expect(result.database).toBe("connected");
expect(result.redis).toBe("connected");
});
it("should return error if database is disconnected", async () => {
mockDb.execute.mockRejectedValue(new Error("DB Error"));
mockCacheManager.set.mockResolvedValue(undefined);
const result = await controller.check();
expect(result.status).toBe("error");
expect(result.database).toBe("disconnected");
expect(result.message).toBe("DB Error");
expect(result.databaseError).toBe("DB Error");
expect(result.redis).toBe("connected");
});
it("should return error if redis is disconnected", async () => {
mockDb.execute.mockResolvedValue([]);
mockCacheManager.set.mockRejectedValue(new Error("Redis Error"));
const result = await controller.check();
expect(result.status).toBe("error");
expect(result.database).toBe("connected");
expect(result.redis).toBe("disconnected");
expect(result.redisError).toBe("Redis Error");
});
});

View File

@@ -1,28 +1,44 @@
import { Controller, Get } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Controller, Get, Inject } from "@nestjs/common";
import type { Cache } from "cache-manager";
import { sql } from "drizzle-orm";
import { DatabaseService } from "./database/database.service";
@Controller("health")
export class HealthController {
constructor(private readonly databaseService: DatabaseService) {}
constructor(
private readonly databaseService: DatabaseService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
@Get()
async check() {
const health: any = {
status: "ok",
timestamp: new Date().toISOString(),
};
try {
// Check database connection
await this.databaseService.db.execute(sql`SELECT 1`);
return {
status: "ok",
database: "connected",
timestamp: new Date().toISOString(),
};
health.database = "connected";
} catch (error) {
return {
status: "error",
database: "disconnected",
message: error.message,
timestamp: new Date().toISOString(),
};
health.status = "error";
health.database = "disconnected";
health.databaseError = error.message;
}
try {
// Check Redis connection via cache-manager
// We try to set a temporary key to verify the connection
await this.cacheManager.set("health-check", "ok", 1000);
health.redis = "connected";
} catch (error) {
health.status = "error";
health.redis = "disconnected";
health.redisError = error.message;
}
return health;
}
}

View File

@@ -49,6 +49,11 @@ describe("MediaController", () => {
expect(stream.pipe).toHaveBeenCalledWith(res);
});
it("should throw NotFoundException if path is missing", async () => {
const res = {} as unknown as Response;
await expect(controller.getFile("", res)).rejects.toThrow(NotFoundException);
});
it("should throw NotFoundException if file is not found", async () => {
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
const res = {} as unknown as Response;

View File

@@ -1,21 +1,36 @@
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
import {
Controller,
Get,
Logger,
NotFoundException,
Query,
Res,
} from "@nestjs/common";
import type { Response } from "express";
import type { BucketItemStat } from "minio";
import { S3Service } from "../s3/s3.service";
@Controller("media")
export class MediaController {
private readonly logger = new Logger(MediaController.name);
constructor(private readonly s3Service: S3Service) {}
@Get("*key")
async getFile(@Param("key") key: string, @Res() res: Response) {
try {
const stats = (await this.s3Service.getFileInfo(key)) as BucketItemStat;
const stream = await this.s3Service.getFile(key);
@Get()
async getFile(@Query("path") path: string, @Res() res: Response) {
if (!path) {
this.logger.warn("Tentative d'accès à un média sans paramètre 'path'");
throw new NotFoundException("Paramètre 'path' manquant");
}
const contentType =
try {
this.logger.log(`Récupération du fichier : ${path}`);
const stats = (await this.s3Service.getFileInfo(path)) as BucketItemStat;
const stream = await this.s3Service.getFile(path);
const contentType: string =
stats.metaData?.["content-type"] ||
stats.metadata?.["content-type"] ||
stats.metaData?.["Content-Type"] ||
"application/octet-stream";
res.setHeader("Content-Type", contentType);
@@ -23,7 +38,10 @@ export class MediaController {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
stream.pipe(res);
} catch (_error) {
} catch (error) {
this.logger.error(
`Erreur lors de la récupération du fichier ${path} : ${error.message}`,
);
throw new NotFoundException("Fichier non trouvé");
}
}

View File

@@ -75,7 +75,7 @@ describe("MediaService", () => {
toFormat: jest.fn().mockReturnThis(),
videoCodec: jest.fn().mockReturnThis(),
audioCodec: jest.fn().mockReturnThis(),
outputOptions: jest.fn().mockReturnThis(),
addOutputOptions: jest.fn().mockReturnThis(),
on: jest.fn().mockImplementation(function (event, cb) {
if (event === "end") setTimeout(cb, 0);
return this;
@@ -96,4 +96,37 @@ describe("MediaService", () => {
expect(result.buffer).toEqual(Buffer.from("processed-video"));
});
});
describe("scanFile", () => {
it("should return false if clamav not initialized", async () => {
const result = await service.scanFile(Buffer.from(""), "test.txt");
expect(result.isInfected).toBe(false);
});
it("should handle virus detection", async () => {
// Mock private property to simulate initialized clamscan
(service as any).isClamAvInitialized = true;
(service as any).clamscan = {
scanStream: jest.fn().mockResolvedValue({
isInfected: true,
viruses: ["Eicar-Test-Signature"],
}),
};
const result = await service.scanFile(Buffer.from(""), "test.txt");
expect(result.isInfected).toBe(true);
expect(result.virusName).toBe("Eicar-Test-Signature");
});
it("should handle scan error", async () => {
(service as any).isClamAvInitialized = true;
(service as any).clamscan = {
scanStream: jest.fn().mockRejectedValue(new Error("Scan failed")),
};
await expect(
service.scanFile(Buffer.from(""), "test.txt"),
).rejects.toThrow();
});
});
});

View File

@@ -12,7 +12,7 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
private readonly logger = new Logger(VideoProcessorStrategy.name);
canHandle(mimeType: string): boolean {
return mimeType.startsWith("video/");
return mimeType.startsWith("video/") || mimeType === "image/gif";
}
async process(
@@ -37,13 +37,13 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
.toFormat("webm")
.videoCodec("libvpx-vp9")
.audioCodec("libopus")
.outputOptions("-crf 30", "-b:v 0");
.addOutputOptions("-crf", "30", "-b:v", "0");
} else {
command = command
.toFormat("mp4")
.videoCodec("libaom-av1")
.audioCodec("libopus")
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
.addOutputOptions("-crf", "34", "-b:v", "0", "-strict", "experimental");
}
command

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator";
export class CreateMessageDto {
@IsUUID()
recipientId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(2000)
text!: string;
}

View File

@@ -0,0 +1,53 @@
import {
Body,
Controller,
Get,
Param,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { CreateMessageDto } from "./dto/create-message.dto";
import { MessagesService } from "./messages.service";
@Controller("messages")
@UseGuards(AuthGuard)
export class MessagesController {
constructor(private readonly messagesService: MessagesService) {}
@Get("conversations")
getConversations(@Req() req: AuthenticatedRequest) {
return this.messagesService.getConversations(req.user.sub);
}
@Get("unread-count")
getUnreadCount(@Req() req: AuthenticatedRequest) {
return this.messagesService.getUnreadCount(req.user.sub);
}
@Get("conversations/with/:userId")
getConversationWithUser(
@Req() req: AuthenticatedRequest,
@Param("userId") targetUserId: string,
) {
return this.messagesService.getConversationWithUser(
req.user.sub,
targetUserId,
);
}
@Get("conversations/:id")
getMessages(
@Req() req: AuthenticatedRequest,
@Param("id") conversationId: string,
) {
return this.messagesService.getMessages(req.user.sub, conversationId);
}
@Post()
sendMessage(@Req() req: AuthenticatedRequest, @Body() dto: CreateMessageDto) {
return this.messagesService.sendMessage(req.user.sub, dto);
}
}

View File

@@ -0,0 +1,15 @@
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { UsersModule } from "../users/users.module";
import { MessagesController } from "./messages.controller";
import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository";
@Module({
imports: [AuthModule, RealtimeModule, forwardRef(() => UsersModule)],
controllers: [MessagesController],
providers: [MessagesService, MessagesRepository],
exports: [MessagesService],
})
export class MessagesModule {}

View File

@@ -0,0 +1,105 @@
import { ForbiddenException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository";
describe("MessagesService", () => {
let service: MessagesService;
let _repository: MessagesRepository;
let _eventsGateway: EventsGateway;
const mockMessagesRepository = {
findConversationBetweenUsers: jest.fn(),
createConversation: jest.fn(),
addParticipant: jest.fn(),
createMessage: jest.fn(),
findAllConversations: jest.fn(),
isParticipant: jest.fn(),
getParticipants: jest.fn(),
findMessagesByConversationId: jest.fn(),
markAsRead: jest.fn(),
countUnreadMessages: jest.fn(),
};
const mockEventsGateway = {
sendToUser: jest.fn(),
};
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagesService,
{ provide: MessagesRepository, useValue: mockMessagesRepository },
{ provide: EventsGateway, useValue: mockEventsGateway },
{ provide: UsersService, useValue: mockUsersService },
],
}).compile();
service = module.get<MessagesService>(MessagesService);
_repository = module.get<MessagesRepository>(MessagesRepository);
_eventsGateway = module.get<EventsGateway>(EventsGateway);
});
describe("sendMessage", () => {
it("should send message to existing conversation", async () => {
const senderId = "s1";
const dto = { recipientId: "r1", text: "hello" };
mockMessagesRepository.findConversationBetweenUsers.mockResolvedValue({
id: "conv1",
});
mockMessagesRepository.createMessage.mockResolvedValue({
id: "m1",
text: "hello",
});
const result = await service.sendMessage(senderId, dto);
expect(result.id).toBe("m1");
expect(mockEventsGateway.sendToUser).toHaveBeenCalledWith(
"r1",
"new_message",
expect.anything(),
);
});
it("should create new conversation if not exists", async () => {
const senderId = "s1";
const dto = { recipientId: "r1", text: "hello" };
mockMessagesRepository.findConversationBetweenUsers.mockResolvedValue(null);
mockMessagesRepository.createConversation.mockResolvedValue({
id: "new_conv",
});
mockMessagesRepository.createMessage.mockResolvedValue({ id: "m1" });
await service.sendMessage(senderId, dto);
expect(mockMessagesRepository.createConversation).toHaveBeenCalled();
expect(mockMessagesRepository.addParticipant).toHaveBeenCalledTimes(2);
});
});
describe("getMessages", () => {
it("should return messages if user is participant", async () => {
mockMessagesRepository.isParticipant.mockResolvedValue(true);
mockMessagesRepository.findMessagesByConversationId.mockResolvedValue([
{ id: "m1" },
]);
const result = await service.getMessages("u1", "conv1");
expect(result).toHaveLength(1);
});
it("should throw ForbiddenException if user is not participant", async () => {
mockMessagesRepository.isParticipant.mockResolvedValue(false);
await expect(service.getMessages("u1", "conv1")).rejects.toThrow(
ForbiddenException,
);
});
});
});

View File

@@ -0,0 +1,125 @@
import {
ForbiddenException,
forwardRef,
Inject,
Injectable,
} from "@nestjs/common";
import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import type { CreateMessageDto } from "./dto/create-message.dto";
import { MessagesRepository } from "./repositories/messages.repository";
@Injectable()
export class MessagesService {
constructor(
private readonly messagesRepository: MessagesRepository,
private readonly eventsGateway: EventsGateway,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {}
async sendMessage(senderId: string, dto: CreateMessageDto) {
let conversation = await this.messagesRepository.findConversationBetweenUsers(
senderId,
dto.recipientId,
);
if (!conversation) {
const newConv = await this.messagesRepository.createConversation();
await this.messagesRepository.addParticipant(newConv.id, senderId);
await this.messagesRepository.addParticipant(newConv.id, dto.recipientId);
conversation = newConv;
}
const message = await this.messagesRepository.createMessage({
conversationId: conversation.id,
senderId,
text: dto.text,
});
// Notify recipient via WebSocket
this.eventsGateway.sendToUser(dto.recipientId, "new_message", {
conversationId: conversation.id,
message,
});
return message;
}
async getConversations(userId: string) {
return this.messagesRepository.findAllConversations(userId);
}
async getUnreadCount(userId: string) {
return this.messagesRepository.countUnreadMessages(userId);
}
async getConversationWithUser(userId: string, targetUserId: string) {
return this.messagesRepository.findConversationBetweenUsers(
userId,
targetUserId,
);
}
async getMessages(userId: string, conversationId: string) {
const isParticipant = await this.messagesRepository.isParticipant(
conversationId,
userId,
);
if (!isParticipant) {
throw new ForbiddenException("You are not part of this conversation");
}
// Récupérer les préférences de l'utilisateur actuel
const user = await this.usersService.findOne(userId);
// Marquer comme lus seulement si l'utilisateur l'autorise
if (user?.showReadReceipts) {
await this.messagesRepository.markAsRead(conversationId, userId);
// Notifier l'expéditeur que les messages ont été lus
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
}
return this.messagesRepository.findMessagesByConversationId(conversationId);
}
async markAsRead(userId: string, conversationId: string) {
const isParticipant = await this.messagesRepository.isParticipant(
conversationId,
userId,
);
if (!isParticipant) {
throw new ForbiddenException("You are not part of this conversation");
}
const user = await this.usersService.findOne(userId);
if (!user?.showReadReceipts) return;
const result = await this.messagesRepository.markAsRead(
conversationId,
userId,
);
// Notifier l'autre participant
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
return result;
}
}

View File

@@ -0,0 +1,167 @@
import { Injectable } from "@nestjs/common";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import {
conversationParticipants,
conversations,
messages,
users,
} from "../../database/schemas";
@Injectable()
export class MessagesRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findConversationBetweenUsers(userId1: string, userId2: string) {
const results = await this.databaseService.db
.select({ id: conversations.id })
.from(conversations)
.innerJoin(
conversationParticipants,
eq(conversations.id, conversationParticipants.conversationId),
)
.where(inArray(conversationParticipants.userId, [userId1, userId2]))
.groupBy(conversations.id)
.having(sql`count(${conversations.id}) = 2`);
return results[0];
}
async createConversation() {
const [conv] = await this.databaseService.db
.insert(conversations)
.values({})
.returning();
return conv;
}
async addParticipant(conversationId: string, userId: string) {
await this.databaseService.db
.insert(conversationParticipants)
.values({ conversationId, userId });
}
async createMessage(data: {
conversationId: string;
senderId: string;
text: string;
}) {
const [msg] = await this.databaseService.db
.insert(messages)
.values(data)
.returning();
// Update conversation updatedAt
await this.databaseService.db
.update(conversations)
.set({ updatedAt: new Date() })
.where(eq(conversations.id, data.conversationId));
return msg;
}
async findAllConversations(userId: string) {
// Sous-requête pour trouver les IDs des conversations de l'utilisateur
const userConvs = this.databaseService.db
.select({ id: conversationParticipants.conversationId })
.from(conversationParticipants)
.where(eq(conversationParticipants.userId, userId));
return this.databaseService.db
.select({
id: conversations.id,
updatedAt: conversations.updatedAt,
lastMessage: {
text: messages.text,
createdAt: messages.createdAt,
},
recipient: {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(conversations)
.innerJoin(
conversationParticipants,
eq(conversations.id, conversationParticipants.conversationId),
)
.innerJoin(users, eq(conversationParticipants.userId, users.uuid))
.leftJoin(messages, eq(conversations.id, messages.conversationId))
.where(
and(
inArray(conversations.id, userConvs),
eq(conversationParticipants.userId, users.uuid),
sql`${users.uuid} != ${userId}`,
),
)
.orderBy(desc(conversations.updatedAt));
}
async findMessagesByConversationId(conversationId: string, limit = 50) {
return this.databaseService.db
.select({
id: messages.id,
text: messages.text,
createdAt: messages.createdAt,
senderId: messages.senderId,
readAt: messages.readAt,
})
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(desc(messages.createdAt))
.limit(limit);
}
async isParticipant(conversationId: string, userId: string) {
const [participant] = await this.databaseService.db
.select()
.from(conversationParticipants)
.where(
and(
eq(conversationParticipants.conversationId, conversationId),
eq(conversationParticipants.userId, userId),
),
);
return !!participant;
}
async getParticipants(conversationId: string) {
return this.databaseService.db
.select({ userId: conversationParticipants.userId })
.from(conversationParticipants)
.where(eq(conversationParticipants.conversationId, conversationId));
}
async markAsRead(conversationId: string, userId: string) {
await this.databaseService.db
.update(messages)
.set({ readAt: new Date() })
.where(
and(
eq(messages.conversationId, conversationId),
sql`${messages.senderId} != ${userId}`,
sql`${messages.readAt} IS NULL`,
),
);
}
async countUnreadMessages(userId: string) {
const result = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(messages)
.innerJoin(
conversationParticipants,
eq(messages.conversationId, conversationParticipants.conversationId),
)
.where(
and(
eq(conversationParticipants.userId, userId),
sql`${messages.senderId} != ${userId}`,
sql`${messages.readAt} IS NULL`,
),
);
return Number(result[0]?.count || 0);
}
}

View File

@@ -0,0 +1,60 @@
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
import { EventsGateway } from "./events.gateway";
describe("EventsGateway", () => {
let gateway: EventsGateway;
let _jwtService: JwtService;
const mockJwtService = {
verifyJwt: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockReturnValue("secret-password-32-chars-long-!!!"),
};
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EventsGateway,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: UsersService, useValue: mockUsersService },
],
}).compile();
gateway = module.get<EventsGateway>(EventsGateway);
_jwtService = module.get<JwtService>(JwtService);
gateway.server = {
to: jest.fn().mockReturnThis(),
emit: jest.fn(),
} as any;
});
it("should be defined", () => {
expect(gateway).toBeDefined();
});
describe("sendToUser", () => {
it("should emit event to user room", () => {
const userId = "user123";
const event = "test_event";
const data = { foo: "bar" };
gateway.sendToUser(userId, event, data);
expect(gateway.server.to).toHaveBeenCalledWith(`user:${userId}`);
expect(gateway.server.to(`user:${userId}`).emit).toHaveBeenCalledWith(
event,
data,
);
});
});
});

View File

@@ -0,0 +1,220 @@
import { forwardRef, Inject, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from "@nestjs/websockets";
import { getIronSession } from "iron-session";
import { Server, Socket } from "socket.io";
import { getSessionOptions, SessionData } from "../auth/session.config";
import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
@WebSocketGateway({
transports: ["websocket"],
cors: {
origin: (
origin: string,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// Autoriser si pas d'origine (ex: app mobile ou serveur à serveur)
// ou si on est en développement local
if (
!origin ||
origin.includes("localhost") ||
origin.includes("127.0.0.1")
) {
callback(null, true);
return;
}
// En production, on peut restreindre via une variable d'environnement
const domainName = process.env.CORS_DOMAIN_NAME;
if (!domainName || domainName === "*") {
callback(null, true);
return;
}
const allowedOrigins = domainName.split(",").map((o) => o.trim());
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true,
methods: ["GET", "POST"],
},
})
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(EventsGateway.name);
private readonly onlineUsers = new Map<string, Set<string>>(); // userId -> Set of socketIds
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {}
afterInit(_server: Server) {
this.logger.log("WebSocket Gateway initialized");
}
async handleConnection(client: Socket) {
try {
// Simuler un objet Request/Response pour iron-session
const req: any = {
headers: client.handshake.headers,
};
const res: any = {
setHeader: () => {},
getHeader: () => {},
};
const session = await getIronSession<SessionData>(
req,
res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
if (!session.accessToken) {
this.logger.warn(`Client ${client.id} unauthorized connection`);
// Permettre les connexions anonymes pour voir les commentaires en temps réel ?
// Pour l'instant on déconnecte car le système actuel semble exiger l'auth
client.disconnect();
return;
}
const payload = await this.jwtService.verifyJwt(session.accessToken);
if (!payload.sub) {
throw new Error("Invalid token payload: missing sub");
}
client.data.user = payload;
// Rejoindre une room personnelle pour les notifications
client.join(`user:${payload.sub}`);
// Gérer le statut en ligne
const userId = payload.sub as string;
if (!this.onlineUsers.has(userId)) {
this.onlineUsers.set(userId, new Set());
// Vérifier les préférences de l'utilisateur
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "online");
}
}
this.onlineUsers.get(userId)?.add(client.id);
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
} catch (error) {
this.logger.error(`Connection error for client ${client.id}: ${error}`);
client.disconnect();
}
}
async handleDisconnect(client: Socket) {
const userId = client.data.user?.sub;
if (userId && this.onlineUsers.has(userId)) {
const sockets = this.onlineUsers.get(userId);
sockets?.delete(client.id);
if (sockets?.size === 0) {
this.onlineUsers.delete(userId);
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "offline");
}
}
}
this.logger.log(`Client disconnected: ${client.id}`);
}
broadcastStatus(userId: string, status: "online" | "offline") {
this.server.emit("user_status", { userId, status });
}
isUserOnline(userId: string): boolean {
return this.onlineUsers.has(userId);
}
@SubscribeMessage("join_content")
handleJoinContent(
@ConnectedSocket() client: Socket,
@MessageBody() contentId: string,
) {
client.join(`content:${contentId}`);
this.logger.log(`Client ${client.id} joined content room: ${contentId}`);
}
@SubscribeMessage("leave_content")
handleLeaveContent(
@ConnectedSocket() client: Socket,
@MessageBody() contentId: string,
) {
client.leave(`content:${contentId}`);
this.logger.log(`Client ${client.id} left content room: ${contentId}`);
}
@SubscribeMessage("typing")
async handleTyping(
@ConnectedSocket() client: Socket,
@MessageBody() data: { recipientId: string; isTyping: boolean },
) {
const userId = client.data.user?.sub;
if (!userId) return;
// Optionnel: vérifier si l'utilisateur autorise le statut en ligne avant d'émettre "typing"
// ou si on considère que typing est une interaction directe qui outrepasse le statut.
// Instagram affiche "Typing..." même si le statut en ligne est désactivé si on est dans le chat.
// Mais par souci de cohérence avec "showOnlineStatus", on peut le vérifier.
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return;
this.server.to(`user:${data.recipientId}`).emit("user_typing", {
userId,
isTyping: data.isTyping,
});
}
@SubscribeMessage("check_status")
async handleCheckStatus(
@ConnectedSocket() _client: Socket,
@MessageBody() userId: string,
) {
const isOnline = this.onlineUsers.has(userId);
if (!isOnline) return { userId, status: "offline" };
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return { userId, status: "offline" };
return {
userId,
status: "online",
};
}
// Méthode utilitaire pour envoyer des messages à un utilisateur spécifique
sendToUser(userId: string, event: string, data: any) {
this.server.to(`user:${userId}`).emit(event, data);
}
sendToContent(contentId: string, event: string, data: any) {
this.server.to(`content:${contentId}`).emit(event, data);
}
}

View File

@@ -0,0 +1,12 @@
import { forwardRef, Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { CryptoModule } from "../crypto/crypto.module";
import { UsersModule } from "../users/users.module";
import { EventsGateway } from "./events.gateway";
@Module({
imports: [CryptoModule, ConfigModule, forwardRef(() => UsersModule)],
providers: [EventsGateway],
exports: [EventsGateway],
})
export class RealtimeModule {}

View File

@@ -0,0 +1,82 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { Test, TestingModule } from "@nestjs/testing";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
describe("ReportsController", () => {
let controller: ReportsController;
let service: ReportsService;
const mockReportsService = {
create: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ReportsController],
providers: [{ provide: ReportsService, useValue: mockReportsService }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ReportsController>(ReportsController);
service = module.get<ReportsService>(ReportsService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should call service.create", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { contentId: "1", reason: "spam" };
await controller.create(req, dto as any);
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
});
});
describe("findAll", () => {
it("should call service.findAll", async () => {
await controller.findAll(10, 0);
expect(service.findAll).toHaveBeenCalledWith(10, 0);
});
});
describe("updateStatus", () => {
it("should call service.updateStatus", async () => {
const dto = { status: "resolved" as any };
await controller.updateStatus("1", dto);
expect(service.updateStatus).toHaveBeenCalledWith("1", "resolved");
});
});
});

View File

@@ -0,0 +1,73 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { ReportsRepository } from "./reports.repository";
describe("ReportsRepository", () => {
let repository: ReportsRepository;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const wrapWithThen = (obj: unknown) => {
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
Object.defineProperty(obj, "then", {
value: function (onFulfilled: (arg0: unknown) => void) {
const result = (this as Record<string, unknown>).execute();
return Promise.resolve(result).then(onFulfilled);
},
configurable: true,
});
return obj;
};
wrapWithThen(mockDb);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ReportsRepository,
{ provide: DatabaseService, useValue: { db: mockDb } },
],
}).compile();
repository = module.get<ReportsRepository>(ReportsRepository);
});
it("should create report", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.create({ reporterId: "u", reason: "spam" });
expect(result.id).toBe("1");
});
it("should find all", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
const result = await repository.findAll(10, 0);
expect(result).toHaveLength(1);
});
it("should update status", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([
{ id: "1", status: "resolved" },
]);
const result = await repository.updateStatus("1", "resolved");
expect(result[0].status).toBe("resolved");
});
it("should purge obsolete", async () => {
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
await repository.purgeObsolete(new Date());
expect(mockDb.delete).toHaveBeenCalled();
});
});

View File

@@ -8,7 +8,6 @@ jest.mock("minio");
describe("S3Service", () => {
let service: S3Service;
let configService: ConfigService;
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
let minioClient: any;
beforeEach(async () => {
@@ -192,7 +191,7 @@ describe("S3Service", () => {
return null;
});
const url = service.getPublicUrl("test.webp");
expect(url).toBe("https://api.test.com/media/test.webp");
expect(url).toBe("https://api.test.com/media?path=test.webp");
});
it("should use DOMAIN_NAME and PORT for localhost", () => {
@@ -205,7 +204,7 @@ describe("S3Service", () => {
},
);
const url = service.getPublicUrl("test.webp");
expect(url).toBe("http://localhost:3000/media/test.webp");
expect(url).toBe("http://localhost:3000/media?path=test.webp");
});
it("should use api.DOMAIN_NAME for production", () => {
@@ -217,7 +216,7 @@ describe("S3Service", () => {
},
);
const url = service.getPublicUrl("test.webp");
expect(url).toBe("https://api.memegoat.fr/media/test.webp");
expect(url).toBe("https://api.memegoat.fr/media?path=test.webp");
});
});
});

View File

@@ -173,6 +173,6 @@ export class S3Service implements OnModuleInit, IStorageService {
baseUrl = `https://api.${domain}`;
}
return `${baseUrl}/media/${storageKey}`;
return `${baseUrl}/media?path=${storageKey}`;
}
}

View File

@@ -0,0 +1,69 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { TagsController } from "./tags.controller";
import { TagsService } from "./tags.service";
describe("TagsController", () => {
let controller: TagsController;
let service: TagsService;
const mockTagsService = {
findAll: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TagsController],
providers: [
{ provide: TagsService, useValue: mockTagsService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
}).compile();
controller = module.get<TagsController>(TagsController);
service = module.get<TagsService>(TagsService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should call service.findAll", async () => {
await controller.findAll(10, 0, "test", "popular");
expect(service.findAll).toHaveBeenCalledWith({
limit: 10,
offset: 0,
query: "test",
sortBy: "popular",
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { IsOptional, IsString, MaxLength } from "class-validator";
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
export class UpdateUserDto {
@IsOptional()
@@ -14,4 +14,20 @@ export class UpdateUserDto {
@IsOptional()
@IsString()
avatarUrl?: string;
@IsOptional()
@IsString()
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
@IsOptional()
@IsString()
role?: string;
@IsOptional()
@IsBoolean()
showOnlineStatus?: boolean;
@IsOptional()
@IsBoolean()
showReadReceipts?: boolean;
}

View File

@@ -0,0 +1,150 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../../database/database.service";
import { UsersRepository } from "./users.repository";
describe("UsersRepository", () => {
let repository: UsersRepository;
let _databaseService: DatabaseService;
const mockDb = {
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
transaction: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersRepository,
{
provide: DatabaseService,
useValue: { db: mockDb },
},
],
}).compile();
repository = module.get<UsersRepository>(UsersRepository);
_databaseService = module.get<DatabaseService>(DatabaseService);
jest.clearAllMocks();
});
it("should be defined", () => {
expect(repository).toBeDefined();
});
describe("create", () => {
it("should insert a user", async () => {
const data = {
username: "u",
email: "e",
passwordHash: "p",
emailHash: "eh",
};
await repository.create(data);
expect(mockDb.insert).toHaveBeenCalled();
expect(mockDb.values).toHaveBeenCalledWith(data);
});
});
describe("findByEmailHash", () => {
it("should select user by email hash", async () => {
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findByEmailHash("hash");
expect(result.uuid).toBe("u1");
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.where).toHaveBeenCalled();
});
});
describe("findOneWithPrivateData", () => {
it("should select user with private data", async () => {
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findOneWithPrivateData("u1");
expect(result.uuid).toBe("u1");
});
});
describe("countAll", () => {
it("should return count", async () => {
mockDb.from.mockResolvedValueOnce([{ count: 5 }]);
const result = await repository.countAll();
expect(result).toBe(5);
});
});
describe("findAll", () => {
it("should select users with limit and offset", async () => {
mockDb.offset.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findAll(10, 0);
expect(result[0].uuid).toBe("u1");
expect(mockDb.limit).toHaveBeenCalledWith(10);
expect(mockDb.offset).toHaveBeenCalledWith(0);
});
});
describe("findByUsername", () => {
it("should find by username", async () => {
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
const result = await repository.findByUsername("u");
expect(result.uuid).toBe("u1");
});
});
describe("update", () => {
it("should update user", async () => {
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
await repository.update("u1", { displayName: "New" });
expect(mockDb.update).toHaveBeenCalled();
expect(mockDb.set).toHaveBeenCalled();
});
});
describe("getTwoFactorSecret", () => {
it("should return secret", async () => {
mockDb.limit.mockResolvedValueOnce([{ secret: "s" }]);
const result = await repository.getTwoFactorSecret("u1");
expect(result).toBe("s");
});
});
describe("getUserContents", () => {
it("should return contents", async () => {
mockDb.where.mockResolvedValueOnce([{ id: "c1" }]);
const result = await repository.getUserContents("u1");
expect(result[0].id).toBe("c1");
});
});
describe("softDeleteUserAndContents", () => {
it("should run transaction", async () => {
const mockTx = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
};
mockDb.transaction.mockImplementation(async (cb) => cb(mockTx));
const result = await repository.softDeleteUserAndContents("u1");
expect(result[0].uuid).toBe("u1");
expect(mockTx.update).toHaveBeenCalledTimes(2);
});
});
describe("purgeDeleted", () => {
it("should delete old deleted users", async () => {
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
const _result = await repository.purgeDeleted(new Date());
expect(mockDb.delete).toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common";
import { and, eq, lte, sql } from "drizzle-orm";
import { and, eq, ilike, lte, or, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { contents, favorites, users } from "../../database/schemas";
@@ -47,6 +47,8 @@ export class UsersRepository {
bio: users.bio,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
showOnlineStatus: users.showOnlineStatus,
showReadReceipts: users.showReadReceipts,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
@@ -64,7 +66,7 @@ export class UsersRepository {
}
async findAll(limit: number, offset: number) {
return await this.databaseService.db
const result = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
@@ -77,6 +79,8 @@ export class UsersRepository {
.from(users)
.limit(limit)
.offset(offset);
return result;
}
async findByUsername(username: string) {
@@ -95,6 +99,24 @@ export class UsersRepository {
return result[0] || null;
}
async search(query: string) {
return this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
})
.from(users)
.where(
or(
ilike(users.username, `%${query}%`),
ilike(users.displayName, `%${query}%`),
),
)
.limit(10);
}
async findOne(uuid: string) {
const result = await this.databaseService.db
.select()

View File

@@ -0,0 +1,192 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
encapsulate: jest.fn(),
decapsulate: jest.fn(),
},
}));
jest.mock("jose", () => ({
SignJWT: jest.fn().mockReturnValue({
setProtectedHeader: jest.fn().mockReturnThis(),
setIssuedAt: jest.fn().mockReturnThis(),
setExpirationTime: jest.fn().mockReturnThis(),
sign: jest.fn().mockResolvedValue("mocked-jwt"),
}),
jwtVerify: jest.fn(),
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthService } from "../auth/auth.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
describe("UsersController", () => {
let controller: UsersController;
let usersService: UsersService;
let authService: AuthService;
const mockUsersService = {
findAll: jest.fn(),
findPublicProfile: jest.fn(),
findOneWithPrivateData: jest.fn(),
exportUserData: jest.fn(),
update: jest.fn(),
updateAvatar: jest.fn(),
updateConsent: jest.fn(),
remove: jest.fn(),
};
const mockAuthService = {
generateTwoFactorSecret: jest.fn(),
enableTwoFactor: jest.fn(),
disableTwoFactor: jest.fn(),
};
const mockCacheManager = {
get: jest.fn(),
set: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{ provide: UsersService, useValue: mockUsersService },
{ provide: AuthService, useValue: mockAuthService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<UsersController>(UsersController);
usersService = module.get<UsersService>(UsersService);
authService = module.get<AuthService>(AuthService);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should call usersService.findAll", async () => {
await controller.findAll(10, 0);
expect(usersService.findAll).toHaveBeenCalledWith(10, 0);
});
});
describe("findPublicProfile", () => {
it("should call usersService.findPublicProfile", async () => {
await controller.findPublicProfile("testuser");
expect(usersService.findPublicProfile).toHaveBeenCalledWith("testuser");
});
});
describe("findMe", () => {
it("should call usersService.findOneWithPrivateData", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.findMe(req);
expect(usersService.findOneWithPrivateData).toHaveBeenCalledWith(
"user-uuid",
);
});
});
describe("exportMe", () => {
it("should call usersService.exportUserData", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.exportMe(req);
expect(usersService.exportUserData).toHaveBeenCalledWith("user-uuid");
});
});
describe("updateMe", () => {
it("should call usersService.update", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { displayName: "New Name" };
await controller.updateMe(req, dto);
expect(usersService.update).toHaveBeenCalledWith("user-uuid", dto);
});
});
describe("updateAvatar", () => {
it("should call usersService.updateAvatar", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const file = {} as Express.Multer.File;
await controller.updateAvatar(req, file);
expect(usersService.updateAvatar).toHaveBeenCalledWith("user-uuid", file);
});
});
describe("updateConsent", () => {
it("should call usersService.updateConsent", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
const dto = { termsVersion: "1.0", privacyVersion: "1.0" };
await controller.updateConsent(req, dto);
expect(usersService.updateConsent).toHaveBeenCalledWith(
"user-uuid",
"1.0",
"1.0",
);
});
});
describe("removeMe", () => {
it("should call usersService.remove", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.removeMe(req);
expect(usersService.remove).toHaveBeenCalledWith("user-uuid");
});
});
describe("removeAdmin", () => {
it("should call usersService.remove", async () => {
await controller.removeAdmin("target-uuid");
expect(usersService.remove).toHaveBeenCalledWith("target-uuid");
});
});
describe("setup2fa", () => {
it("should call authService.generateTwoFactorSecret", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.setup2fa(req);
expect(authService.generateTwoFactorSecret).toHaveBeenCalledWith(
"user-uuid",
);
});
});
describe("enable2fa", () => {
it("should call authService.enableTwoFactor", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.enable2fa(req, "token123");
expect(authService.enableTwoFactor).toHaveBeenCalledWith(
"user-uuid",
"token123",
);
});
});
describe("disable2fa", () => {
it("should call authService.disableTwoFactor", async () => {
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
await controller.disable2fa(req, "token123");
expect(authService.disableTwoFactor).toHaveBeenCalledWith(
"user-uuid",
"token123",
);
});
});
});

View File

@@ -54,6 +54,12 @@ export class UsersController {
return this.usersService.findPublicProfile(username);
}
@Get("search")
@UseGuards(AuthGuard)
search(@Query("q") query: string) {
return this.usersService.search(query);
}
// Gestion de son propre compte
@Get("me")
@UseGuards(AuthGuard)
@@ -112,6 +118,16 @@ export class UsersController {
return this.usersService.remove(uuid);
}
@Patch("admin/:uuid")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
updateAdmin(
@Param("uuid") uuid: string,
@Body() updateUserDto: UpdateUserDto,
) {
return this.usersService.update(uuid, updateUserDto);
}
// Double Authentification (2FA)
@Post("me/2fa/setup")
@UseGuards(AuthGuard)

View File

@@ -1,13 +1,19 @@
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { MediaModule } from "../media/media.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module";
import { UsersRepository } from "./repositories/users.repository";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
imports: [
forwardRef(() => AuthModule),
MediaModule,
S3Module,
forwardRef(() => RealtimeModule),
],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService, UsersRepository],

View File

@@ -20,6 +20,7 @@ import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { RbacService } from "../auth/rbac.service";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { UsersRepository } from "./repositories/users.repository";
import { UsersService } from "./users.service";
@@ -49,6 +50,7 @@ describe("UsersService", () => {
const mockRbacService = {
getUserRoles: jest.fn(),
assignRoleToUser: jest.fn(),
};
const mockMediaService = {
@@ -65,6 +67,11 @@ describe("UsersService", () => {
get: jest.fn(),
};
const mockEventsGateway = {
isUserOnline: jest.fn(),
broadcastStatus: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
@@ -77,6 +84,7 @@ describe("UsersService", () => {
{ provide: MediaService, useValue: mockMediaService },
{ provide: S3Service, useValue: mockS3Service },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: EventsGateway, useValue: mockEventsGateway },
],
}).compile();
@@ -108,6 +116,7 @@ describe("UsersService", () => {
describe("findOne", () => {
it("should find a user", async () => {
mockUsersRepository.findOne.mockResolvedValue({ uuid: "uuid1" });
mockRbacService.getUserRoles.mockResolvedValue([]);
const result = await service.findOne("uuid1");
expect(result.uuid).toBe("uuid1");
});
@@ -128,4 +137,113 @@ describe("UsersService", () => {
expect(result[0].displayName).toBe("New");
});
});
describe("clearUserCache", () => {
it("should delete cache", async () => {
await service.clearUserCache("u1");
expect(mockCacheManager.del).toHaveBeenCalledWith("users/profile/u1");
});
});
describe("findByEmailHash", () => {
it("should call repository.findByEmailHash", async () => {
mockUsersRepository.findByEmailHash.mockResolvedValue({ uuid: "u1" });
mockRbacService.getUserRoles.mockResolvedValue([]);
const result = await service.findByEmailHash("hash");
expect(result.uuid).toBe("u1");
expect(mockUsersRepository.findByEmailHash).toHaveBeenCalledWith("hash");
});
});
describe("findOneWithPrivateData", () => {
it("should return user with roles", async () => {
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
mockRbacService.getUserRoles.mockResolvedValue(["admin"]);
const result = await service.findOneWithPrivateData("u1");
expect(result.roles).toEqual(["admin"]);
});
});
describe("findAll", () => {
it("should return all users", async () => {
mockUsersRepository.findAll.mockResolvedValue([{ uuid: "u1" }]);
mockUsersRepository.countAll.mockResolvedValue(1);
const result = await service.findAll(10, 0);
expect(result.totalCount).toBe(1);
expect(result.data[0].uuid).toBe("u1");
});
});
describe("findPublicProfile", () => {
it("should return public profile", async () => {
mockUsersRepository.findByUsername.mockResolvedValue({
uuid: "u1",
username: "u1",
});
const result = await service.findPublicProfile("u1");
expect(result.username).toBe("u1");
});
});
describe("updateConsent", () => {
it("should update consent", async () => {
await service.updateConsent("u1", "v1", "v2");
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
termsVersion: "v1",
privacyVersion: "v2",
gdprAcceptedAt: expect.any(Date),
});
});
});
describe("setTwoFactorSecret", () => {
it("should set 2fa secret", async () => {
await service.setTwoFactorSecret("u1", "secret");
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
twoFactorSecret: "secret",
});
});
});
describe("toggleTwoFactor", () => {
it("should toggle 2fa", async () => {
await service.toggleTwoFactor("u1", true);
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
isTwoFactorEnabled: true,
});
});
});
describe("getTwoFactorSecret", () => {
it("should return 2fa secret", async () => {
mockUsersRepository.getTwoFactorSecret.mockResolvedValue("secret");
const result = await service.getTwoFactorSecret("u1");
expect(result).toBe("secret");
});
});
describe("exportUserData", () => {
it("should return all user data", async () => {
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
mockUsersRepository.getUserContents.mockResolvedValue([]);
mockUsersRepository.getUserFavorites.mockResolvedValue([]);
const result = await service.exportUserData("u1");
expect(result.profile).toBeDefined();
expect(result.contents).toBeDefined();
expect(result.favorites).toBeDefined();
});
});
describe("remove", () => {
it("should soft delete user", async () => {
await service.remove("u1");
expect(mockUsersRepository.softDeleteUserAndContents).toHaveBeenCalledWith(
"u1",
);
});
});
});

View File

@@ -12,6 +12,7 @@ import { RbacService } from "../auth/rbac.service";
import type { IMediaService } from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersRepository } from "./repositories/users.repository";
@@ -27,6 +28,8 @@ export class UsersService {
private readonly rbacService: RbacService,
@Inject(MediaService) private readonly mediaService: IMediaService,
@Inject(S3Service) private readonly s3Service: IStorageService,
@Inject(forwardRef(() => EventsGateway))
private readonly eventsGateway: EventsGateway,
) {}
private async clearUserCache(username?: string) {
@@ -45,7 +48,19 @@ export class UsersService {
}
async findByEmailHash(emailHash: string) {
return await this.usersRepository.findByEmailHash(emailHash);
const user = await this.usersRepository.findByEmailHash(emailHash);
if (!user) return null;
const roles = await this.rbacService.getUserRoles(user.uuid);
return {
...user,
role: roles.includes("admin")
? "admin"
: roles.includes("moderator")
? "moderator"
: "user",
roles,
};
}
async findOneWithPrivateData(uuid: string) {
@@ -94,16 +109,63 @@ export class UsersService {
};
}
async search(query: string) {
const users = await this.usersRepository.search(query);
return users.map((user) => ({
...user,
avatarUrl: user.avatarUrl
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
}));
}
async findOne(uuid: string) {
return await this.usersRepository.findOne(uuid);
const user = await this.usersRepository.findOne(uuid);
if (!user) return null;
const roles = await this.rbacService.getUserRoles(user.uuid);
return {
...user,
role: roles.includes("admin")
? "admin"
: roles.includes("moderator")
? "moderator"
: "user",
roles,
};
}
async update(uuid: string, data: UpdateUserDto) {
this.logger.log(`Updating user profile for ${uuid}`);
const result = await this.usersRepository.update(uuid, data);
const { role, ...userData } = data;
// On récupère l'utilisateur actuel avant mise à jour pour comparer les préférences
const oldUser = await this.usersRepository.findOne(uuid);
const result = await this.usersRepository.update(uuid, userData);
if (role) {
await this.rbacService.assignRoleToUser(uuid, role);
}
if (result[0]) {
await this.clearUserCache(result[0].username);
// Gérer le changement de préférence de statut en ligne
if (
data.showOnlineStatus !== undefined &&
data.showOnlineStatus !== oldUser?.showOnlineStatus
) {
const isOnline = this.eventsGateway.isUserOnline(uuid);
if (isOnline) {
if (data.showOnlineStatus) {
this.eventsGateway.broadcastStatus(uuid, "online");
} else {
this.eventsGateway.broadcastStatus(uuid, "offline");
}
}
}
}
return result;
}

View File

@@ -9,6 +9,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-app}
networks:
- nw_memegoat
ports:
- "127.0.0.1:5432:5432" # not exposed to WAN, LAN only for administration checkup
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -35,6 +37,7 @@ services:
restart: always
networks:
- nw_memegoat
- nw_caddy
#ports:
# - "9000:9000"
# - "9001:9001"
@@ -128,6 +131,8 @@ services:
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://memegoat.fr}
NEXT_PUBLIC_CONTACT_EMAIL: ${MAIL_FROM:-noreply@memegoat.fr}
depends_on:
- backend

View File

@@ -14,13 +14,13 @@ COPY documentation/package.json ./documentation/
# Montage du cache pnpm
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
pnpm install --frozen-lockfile --force
COPY . .
# Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
pnpm install --frozen-lockfile --force
# Build avec cache Next.js
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \

View File

@@ -18,15 +18,21 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
Inscrit un nouvel utilisateur.
**Corps de la requête (JSON) :**
- `username` (string) : Nom d'utilisateur unique.
- `username` (string, max: 32) : Nom d'utilisateur unique.
- `email` (string) : Adresse email valide.
- `password` (string) : Mot de passe (min. 8 caractères).
- `password` (string, min: 8) : Mot de passe.
- `displayName` (string, optional, max: 32) : Nom d'affichage.
**Réponses :**
- `201 Created` : Utilisateur créé.
- `400 Bad Request` : Validation échouée ou utilisateur déjà existant.
```json
{
"username": "goat_user",
"email": "user@memegoat.fr",
"password": "strong-password"
"password": "strong-password",
"displayName": "Le Bouc"
}
```
</Accordion>
@@ -38,16 +44,15 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
- `email` (string)
- `password` (string)
**Réponse (Succès) :**
**Réponses :**
- `200 OK` : Connexion réussie.
```json
{
"message": "User logged in successfully",
"userId": "uuid-v4"
}
```
*Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.*
**Réponse (2FA requise) :**
- `200 OK` (2FA requise) :
```json
{
"message": "2FA required",
@@ -55,6 +60,9 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
"userId": "uuid-v4"
}
```
- `401 Unauthorized` : Identifiants invalides.
*Note: L'access_token et le refresh_token sont stockés dans un cookie HttpOnly chiffré.*
</Accordion>
<Accordion title="POST /auth/verify-2fa">
@@ -63,15 +71,41 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
**Corps de la requête :**
- `userId` (uuid) : ID de l'utilisateur.
- `token` (string) : Code TOTP à 6 chiffres.
**Réponses :**
- `200 OK` : Vérification réussie, session établie.
- `401 Unauthorized` : Token invalide ou utilisateur non autorisé.
</Accordion>
<Accordion title="POST /auth/refresh">
Obtient un nouvel `access_token` à partir du `refresh_token` stocké dans la session.
Met à jour automatiquement le cookie de session.
**Réponses :**
- `200 OK` : Token rafraîchi.
- `401 Unauthorized` : Refresh token absent ou invalide.
</Accordion>
<Accordion title="POST /auth/logout">
Invalide la session actuelle.
Invalide la session actuelle en détruisant le cookie de session.
**Réponses :**
- `200 OK` : Déconnexion réussie.
</Accordion>
<Accordion title="GET /auth/bootstrap-admin">
Élève les privilèges d'un utilisateur au rang d'administrateur.
<Callout type="warn">
Cette route n'est active que si aucun administrateur n'existe en base de données. Le token est affiché dans les logs de la console au démarrage.
</Callout>
**Query Params :**
- `token` (string) : Token à usage unique généré par le système.
- `username` (string) : Nom de l'utilisateur à promouvoir.
**Réponses :**
- `200 OK` : Utilisateur promu.
- `401 Unauthorized` : Token invalide ou utilisateur non trouvé.
</Accordion>
</Accordions>
@@ -80,16 +114,62 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
<Accordions>
<Accordion title="GET /users/me">
Récupère les informations détaillées de l'utilisateur connecté. Requiert l'authentification.
**Réponses :**
- `200 OK` : Retourne l'objet utilisateur complet (incluant données privées).
- `401 Unauthorized` : Session invalide.
</Accordion>
<Accordion title="GET /users/public/:username">
Récupère le profil public d'un utilisateur par son nom d'utilisateur. Mise en cache pendant 1 minute.
**Réponses :**
- `200 OK` : Profil public (id, username, displayName, bio, avatarUrl, createdAt).
- `404 Not Found` : Utilisateur non trouvé.
</Accordion>
<Accordion title="GET /users/me/export">
Extrait l'intégralité des données de l'utilisateur au format JSON (Conformité RGPD).
Contient le profil, les contenus et les favoris.
Contient le profil, les contenus créés et les favoris.
**Réponses :**
- `200 OK` : Archive JSON des données.
- `401 Unauthorized` : Non authentifié.
</Accordion>
<Accordion title="PATCH /users/me">
Met à jour les informations du profil.
- `displayName` (string)
**Corps de la requête :**
- `displayName` (string, optional, max: 32)
- `bio` (string, optional, max: 255)
- `avatarUrl` (string, optional) : URL directe de l'avatar.
**Réponses :**
- `200 OK` : Profil mis à jour.
- `400 Bad Request` : Validation échouée.
</Accordion>
<Accordion title="POST /users/me/avatar">
Met à jour l'avatar de l'utilisateur via upload de fichier.
**Type :** `multipart/form-data`
**Champ :** `file` (Image: png, jpeg, webp)
**Réponses :**
- `201 Created` : Avatar téléchargé et mis à jour.
- `400 Bad Request` : Fichier invalide ou trop volumineux.
</Accordion>
<Accordion title="PATCH /users/me/consent">
Met à jour les consentements légaux de l'utilisateur (CGU/RGPD).
**Corps de la requête :**
- `termsVersion` (string, max: 16)
- `privacyVersion` (string, max: 16)
**Réponses :**
- `200 OK` : Consentements enregistrés.
</Accordion>
<Accordion title="DELETE /users/me">
@@ -97,95 +177,388 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
<Callout type="warn">
Les données sont définitivement purgées après un délai légal de 30 jours.
</Callout>
**Réponses :**
- `200 OK` : Suppression planifiée.
</Accordion>
<Accordion title="Gestion 2FA">
- `POST /users/me/2fa/setup` : Génère un secret et QR Code.
- `POST /users/me/2fa/enable` : Active après vérification du jeton.
- `POST /users/me/2fa/disable` : Désactive avec jeton.
<Accordion title="POST /users/me/2fa/setup">
Génère un secret et un QR Code pour la configuration de la 2FA.
**Réponses :**
- `201 Created` :
```json
{
"secret": "JBSWY3DPEHPK3PXP",
"qrCodeDataUrl": "data:image/png;base64,..."
}
```
</Accordion>
<Accordion title="Administration (GET /users/admin)">
Liste tous les utilisateurs. Réservé aux administrateurs.
**Params :** `limit`, `offset`.
<Accordion title="POST /users/me/2fa/enable">
Active la 2FA après vérification du jeton TOTP.
**Corps de la requête :**
- `token` (string) : Code TOTP généré par l'app.
**Réponses :**
- `200 OK` : 2FA activée.
- `400 Bad Request` : Token invalide ou 2FA non initiée.
</Accordion>
<Accordion title="POST /users/me/2fa/disable">
Désactive la 2FA en utilisant un jeton TOTP valide.
**Corps de la requête :**
- `token` (string) : Code TOTP.
**Réponses :**
- `200 OK` : 2FA désactivée.
</Accordion>
<Accordion title="GET /users/admin">
Liste tous les utilisateurs. **Réservé aux administrateurs.**
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste paginée des utilisateurs.
- `403 Forbidden` : Droits insuffisants.
</Accordion>
<Accordion title="DELETE /users/:uuid">
Supprime définitivement un utilisateur par son UUID. **Réservé aux administrateurs.**
**Réponses :**
- `200 OK` : Utilisateur supprimé.
</Accordion>
</Accordions>
### 🖼️ Contenus (`/contents`)
<Accordions>
<Accordion title="GET /contents/explore | /trends | /recent">
Recherche et filtre les contenus. Ces endpoints sont mis en cache (Redis + Navigateur).
<Accordion title="GET /contents/explore">
Recherche et filtre les contenus. Cet endpoint est mis en cache pendant 1 minute.
**Query Params :**
- `sort` : `trend` | `recent` (uniquement sur `/explore`)
- `tag` (string)
- `category` (slug ou id)
- `author` (username)
- `query` (titre)
- `favoritesOnly` (bool)
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
- `sort` : `trend` | `recent`
- `tag` (string) : Filtrer par tag (nom).
- `category` (slug ou uuid) : Filtrer par catégorie.
- `author` (username) : Filtrer par auteur.
- `query` (string) : Recherche textuelle dans le titre.
- `favoritesOnly` (boolean) : Ne montrer que les favoris de l'utilisateur (nécessite auth).
- `userId` (uuid) : Filtrer par ID utilisateur.
**Réponses :**
- `200 OK` : Liste paginée des contenus.
</Accordion>
<Accordion title="GET /contents/trends">
Récupère les contenus les plus populaires du moment. Cache de 5 minutes.
**Query Params :** `limit`, `offset`.
**Réponses :**
- `200 OK` : Liste des tendances.
</Accordion>
<Accordion title="GET /contents/recent">
Récupère les contenus les plus récents. Cache de 1 minute.
**Query Params :** `limit`, `offset`.
**Réponses :**
- `200 OK` : Liste des contenus récents.
</Accordion>
<Accordion title="GET /contents/:idOrSlug">
Récupère un contenu par son ID ou son Slug.
Récupère un contenu par son ID ou son Slug. Cache de 1 heure.
**Détection de Bots (SEO) :**
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards** pour un partage optimal. Pour les autres clients, les données sont retournées en JSON.
Si l'User-Agent correspond à un robot d'indexation (Googlebot, Twitterbot, etc.), l'API retourne un rendu HTML minimal contenant les méta-tags **OpenGraph** et **Twitter Cards**.
**Réponses :**
- `200 OK` : Objet Contenu ou Rendu HTML (Bots).
- `404 Not Found` : Contenu inexistant.
</Accordion>
<Accordion title="POST /contents">
Crée une entrée de contenu à partir d'une ressource déjà uploadée ou externe.
**Corps de la requête :**
- `type` : `meme` | `gif`
- `title` (string, max: 255)
- `storageKey` (string, max: 512) : Clé du fichier sur S3.
- `mimeType` (string, max: 128)
- `fileSize` (number)
- `categoryId` (uuid, optional)
- `tags` (string[], optional)
**Réponses :**
- `201 Created` : Contenu référencé.
- `401 Unauthorized` : Non authentifié.
</Accordion>
<Accordion title="POST /contents/upload">
Upload un fichier avec traitement automatique.
**Type :** `multipart/form-data`
Upload un fichier et crée le contenu associé en une seule étape.
**Type :** `multipart/form-data`
**Champs :**
- `file` (binary) : png, jpeg, webp, webm, gif.
- `type` : `meme` | `gif`
- `title` : string
- `categoryId`? : uuid
- `tags`? : string[]
- `title` (string)
- `categoryId` (uuid, optional)
- `tags` (string[], optional)
**Réponses :**
- `201 Created` : Upload réussi et contenu créé.
- `400 Bad Request` : Fichier non supporté ou données invalides.
</Accordion>
<Accordion title="POST /contents/:id/view | /use">
Incrémente les statistiques de vue ou d'utilisation.
<Accordion title="POST /contents/upload-url">
Génère une URL présignée pour un upload direct vers S3.
**Query Param :**
- `fileName` (string) : Nom du fichier avec extension.
**Réponses :**
- `201 Created` : Retourne l'URL présignée et les champs requis.
</Accordion>
<Accordion title="POST /contents/:id/view">
Incrémente le compteur de vues d'un contenu.
**Réponses :**
- `201 Created` : Compteur incrémenté.
</Accordion>
<Accordion title="POST /contents/:id/use">
Incrémente le compteur d'utilisation (clic sur "Utiliser").
**Réponses :**
- `201 Created` : Compteur incrémenté.
</Accordion>
<Accordion title="DELETE /contents/:id">
Supprime un contenu (Soft Delete). Doit être l'auteur.
**Réponses :**
- `200 OK` : Contenu supprimé.
- `403 Forbidden` : Tentative de supprimer le contenu d'autrui.
</Accordion>
<Accordion title="DELETE /contents/:id/admin">
Supprime définitivement un contenu. **Réservé aux administrateurs.**
**Réponses :**
- `200 OK` : Contenu supprimé définitivement.
</Accordion>
</Accordions>
### 📂 Catégories, ⭐ Favoris, 🚩 Signalements
### 📂 Catégories (`/categories`)
<Accordions>
<Accordion title="Catégories (/categories)">
- `GET /categories` : Liste toutes les catégories.
- `POST /categories` : Création (Admin uniquement).
<Accordion title="GET /categories">
Liste toutes les catégories de mèmes disponibles. Cache de 1 heure.
**Réponses :**
- `200 OK` : Liste d'objets catégorie.
</Accordion>
<Accordion title="Favoris (/favorites)">
- `GET /favorites` : Liste les favoris de l'utilisateur.
- `POST /favorites/:contentId` : Ajoute un favori.
- `DELETE /favorites/:contentId` : Retire un favori.
<Accordion title="GET /categories/:id">
Récupère les détails d'une catégorie spécifique.
**Réponses :**
- `200 OK` : Objet catégorie.
- `404 Not Found` : Catégorie non trouvée.
</Accordion>
<Accordion title="Signalements (/reports)">
- `POST /reports` : Signale un contenu ou un tag.
- `GET /reports` : Liste (Modérateurs).
- `PATCH /reports/:id/status` : Gère le workflow.
<Accordion title="POST /categories">
Crée une nouvelle catégorie. **Admin uniquement.**
**Corps de la requête :**
- `name` (string, max: 64)
- `description` (string, optional, max: 255)
- `iconUrl` (string, optional, max: 512)
**Réponses :**
- `201 Created` : Catégorie créée.
</Accordion>
<Accordion title="PATCH /categories/:id">
Met à jour une catégorie existante. **Admin uniquement.**
**Corps de la requête :** (Tous optionnels) `name`, `description`, `iconUrl`.
**Réponses :**
- `200 OK` : Catégorie mise à jour.
</Accordion>
<Accordion title="DELETE /categories/:id">
Supprime une catégorie. **Admin uniquement.**
**Réponses :**
- `200 OK` : Catégorie supprimée.
</Accordion>
</Accordions>
### 🔑 Clés API & 🏷️ Tags
### ⭐ Favoris (`/favorites`)
<Accordions>
<Accordion title="Clés API (/api-keys)">
- `POST /api-keys` : Génère une clé `{ name, expiresAt? }`.
- `GET /api-keys` : Liste les clés actives.
- `DELETE /api-keys/:id` : Révoque une clé.
<Accordion title="GET /favorites">
Liste les favoris de l'utilisateur connecté.
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste paginée des favoris.
- `401 Unauthorized` : Non authentifié.
</Accordion>
<Accordion title="Tags (/tags)">
- `GET /tags` : Recherche de tags populaires ou récents.
**Params :** `query`, `sort`, `limit`.
<Accordion title="POST /favorites/:contentId">
Ajoute un contenu aux favoris de l'utilisateur.
**Réponses :**
- `201 Created` : Favori ajouté.
</Accordion>
<Accordion title="DELETE /favorites/:contentId">
Retire un contenu des favoris de l'utilisateur.
**Réponses :**
- `200 OK` : Favori supprimé.
</Accordion>
</Accordions>
### 🚩 Signalements (`/reports`)
<Accordions>
<Accordion title="POST /reports">
Signale un contenu ou un tag pour modération.
**Corps de la requête :**
- `contentId` (uuid, optional) : ID du contenu à signaler.
- `tagId` (uuid, optional) : ID du tag à signaler.
- `reason` : `inappropriate` | `spam` | `copyright` | `other`
- `description` (string, optional, max: 1000)
**Réponses :**
- `201 Created` : Signalement enregistré.
</Accordion>
<Accordion title="GET /reports">
Liste les signalements. **Réservé aux administrateurs et modérateurs.**
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
**Réponses :**
- `200 OK` : Liste des signalements.
</Accordion>
<Accordion title="PATCH /reports/:id/status">
Met à jour le statut d'un signalement. **Réservé aux administrateurs et modérateurs.**
**Corps de la requête :**
- `status` : `pending` | `reviewed` | `resolved` | `dismissed`
**Réponses :**
- `200 OK` : Statut mis à jour.
</Accordion>
</Accordions>
### 🔑 Clés API (`/api-keys`)
<Accordions>
<Accordion title="POST /api-keys">
Génère une nouvelle clé API pour l'utilisateur.
**Corps de la requête :**
- `name` (string, max: 128) : Nom descriptif de la clé.
- `expiresAt` (date-string, optional) : Date d'expiration.
**Réponses :**
- `201 Created` : Clé générée. Retourne le token (à conserver précieusement, ne sera plus affiché).
</Accordion>
<Accordion title="GET /api-keys">
Liste toutes les clés API actives de l'utilisateur.
**Réponses :**
- `200 OK` : Liste des métadonnées des clés (nom, date de création, expiration).
</Accordion>
<Accordion title="DELETE /api-keys/:id">
Révoque une clé API spécifique.
**Réponses :**
- `200 OK` : Clé révoquée.
</Accordion>
</Accordions>
### 🏷️ Tags (`/tags`)
<Accordions>
<Accordion title="GET /tags">
Liste les tags populaires ou recherchés. Cache de 5 minutes.
**Query Params :**
- `limit` (number) : Défaut 10.
- `offset` (number) : Défaut 0.
- `query` (string, optional) : Recherche par nom.
- `sort` : `popular` | `recent`
**Réponses :**
- `200 OK` : Liste paginée des tags.
</Accordion>
</Accordions>
### 🛠️ Système (`/health`)
<Accordions>
<Accordion title="GET /health">
Vérifie l'état de santé de l'API et de ses dépendances (DB, Redis).
**Réponses :**
- `200 OK` : Système opérationnel.
```json
{
"status": "ok",
"timestamp": "2024-01-21T10:00:00.000Z",
"database": "connected",
"redis": "connected"
}
```
- `503 Service Unavailable` : Problème sur l'un des composants.
</Accordion>
</Accordions>
### 📁 Médias (`/media`)
<Accordions>
<Accordion title="GET /media">
Sert un fichier média stocké sur S3 avec une gestion optimisée du cache.
**Query Params :**
- `path` (string) : Chemin relatif du fichier sur le bucket.
**Réponses :**
- `200 OK` : Flux binaire du fichier. Headers `Content-Type` et `Cache-Control` inclus.
- `404 Not Found` : Fichier introuvable.
</Accordion>
</Accordions>
### 📊 Administration (`/admin`)
<Accordions>
<Accordion title="GET /admin/stats">
Récupère les statistiques globales d'utilisation de la plateforme (**Admin uniquement**).
</Accordion>
</Accordions>

Some files were not shown because too many files have changed in this diff Show More