95 Commits

Author SHA1 Message Date
Mathis HERRIOT
22c753d1e7 chore: bump version to 1.9.6
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m43s
CI/CD Pipeline / Valider frontend (push) Successful in 1m50s
CI/CD Pipeline / Valider documentation (push) Successful in 1m51s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m26s
2026-02-01 20:27:58 +01:00
Mathis HERRIOT
1f7bd51a7b feat(docs): add detailed features and business flow diagrams
- Introduced new interaction and community features, including comments and private messaging.
- Added technical diagrams for critical workflows: authentication, content publication, and messaging.
- Enhanced data model documentation with support for comments and messaging tables.
- Updated API references with endpoints for comments, messaging, and user search.
- Integrated post-quantum cryptography for improved data protection.
2026-02-01 20:27:46 +01:00
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
104 changed files with 11846 additions and 359 deletions

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

@@ -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

View File

@@ -57,6 +57,27 @@
"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

@@ -15,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

@@ -1,6 +1,6 @@
{
"name": "@memegoat/backend",
"version": "1.5.3",
"version": "1.9.6",
"description": "",
"author": "",
"private": true,
@@ -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": [

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

@@ -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

@@ -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,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

@@ -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";
@@ -49,6 +50,10 @@ describe("ContentsService", () => {
del: jest.fn(),
};
const mockEventsGateway = {
sendToUser: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
@@ -60,6 +65,7 @@ describe("ContentsService", () => {
{ provide: MediaService, useValue: mockMediaService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
{ provide: EventsGateway, useValue: mockEventsGateway },
],
}).compile();

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",
@@ -60,13 +67,25 @@ export class ContentsService {
];
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, mp4, mov, gif.",
);
}
const isGif = file.mimetype === "image/gif";
const isVideo = file.mimetype.startsWith("video/");
// 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) {
@@ -78,23 +97,39 @@ export class ContentsService {
}
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" : 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/") && file.mimetype !== "image/gif") {
// Image -> WebP (format moderne, bien supporté)
@@ -110,17 +145,34 @@ export class ContentsService {
}
// 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: {

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

@@ -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

@@ -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;

View File

@@ -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

@@ -1,4 +1,4 @@
import { IsOptional, IsString, MaxLength } from "class-validator";
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
export class UpdateUserDto {
@IsOptional()
@@ -22,4 +22,12 @@ export class UpdateUserDto {
@IsOptional()
@IsString()
role?: string;
@IsOptional()
@IsBoolean()
showOnlineStatus?: boolean;
@IsOptional()
@IsBoolean()
showReadReceipts?: boolean;
}

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,
})
@@ -97,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

@@ -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)

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");
});
@@ -139,6 +148,7 @@ describe("UsersService", () => {
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");

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,8 +109,30 @@ 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) {
@@ -103,6 +140,9 @@ export class UsersService {
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) {
@@ -111,6 +151,21 @@ export class UsersService {
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

@@ -131,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

@@ -7,6 +7,7 @@
"features": "Fonctionnalités",
"stack": "Stack Technologique",
"database": "Modèle de Données",
"flows": "Flux Métiers",
"---security---": {
"type": "separator",
"label": "Sécurité & Conformité"

View File

@@ -216,6 +216,16 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
- `200 OK` : 2FA désactivée.
</Accordion>
<Accordion title="GET /users/search">
Recherche des utilisateurs par leur nom d'utilisateur ou nom d'affichage. Requiert l'authentification.
**Query Params :**
- `q` (string) : Terme de recherche.
**Réponses :**
- `200 OK` : Liste des utilisateurs correspondants.
</Accordion>
<Accordion title="GET /users/admin">
Liste tous les utilisateurs. **Réservé aux administrateurs.**
@@ -406,6 +416,92 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
</Accordion>
</Accordions>
### 💬 Commentaires (`/comments` & `/contents/:id/comments`)
<Accordions>
<Accordion title="GET /contents/:contentId/comments">
Liste les commentaires d'un contenu.
**Réponses :**
- `200 OK` : Liste des commentaires, incluant l'auteur et si l'utilisateur actuel a aimé le commentaire.
</Accordion>
<Accordion title="POST /contents/:contentId/comments">
Ajoute un commentaire à un contenu. Requiert l'authentification.
**Corps de la requête :**
- `text` (string) : Contenu du commentaire.
- `parentId` (uuid, optional) : ID du commentaire parent pour les réponses.
**Réponses :**
- `201 Created` : Commentaire ajouté.
</Accordion>
<Accordion title="DELETE /comments/:id">
Supprime un commentaire. L'utilisateur doit être l'auteur ou un modérateur/admin.
**Réponses :**
- `200 OK` : Commentaire supprimé.
</Accordion>
<Accordion title="POST /comments/:id/like">
Ajoute un "like" à un commentaire. Requiert l'authentification.
**Réponses :**
- `201 Created` : Like ajouté.
</Accordion>
<Accordion title="DELETE /comments/:id/like">
Retire un "like" d'un commentaire. Requiert l'authentification.
**Réponses :**
- `200 OK` : Like retiré.
</Accordion>
</Accordions>
### ✉️ Messagerie (`/messages`)
<Accordions>
<Accordion title="GET /messages/conversations">
Liste les conversations de l'utilisateur connecté. Requiert l'authentification.
**Réponses :**
- `200 OK` : Liste des conversations avec le dernier message et le nombre de messages non lus.
</Accordion>
<Accordion title="GET /messages/unread-count">
Récupère le nombre total de messages non lus pour l'utilisateur. Requiert l'authentification.
**Réponses :**
- `200 OK` : `{ "count": number }`.
</Accordion>
<Accordion title="GET /messages/conversations/with/:userId">
Récupère ou crée une conversation avec un utilisateur spécifique. Requiert l'authentification.
**Réponses :**
- `200 OK` : Objet conversation.
</Accordion>
<Accordion title="GET /messages/conversations/:id">
Récupère les messages d'une conversation. Marque les messages comme lus. Requiert l'authentification.
**Réponses :**
- `200 OK` : Liste des messages.
</Accordion>
<Accordion title="POST /messages">
Envoie un message. Requiert l'authentification.
**Corps de la requête :**
- `recipientId` (uuid) : ID du destinataire.
- `text` (string) : Contenu du message.
**Réponses :**
- `201 Created` : Message envoyé.
</Accordion>
</Accordions>
### ⭐ Favoris (`/favorites`)
<Accordions>

View File

@@ -29,4 +29,4 @@ Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Le
### Notifications (Mail)
Le système intègre un service d'envoi d'emails (SMTP) pour les notifications critiques et la gestion des comptes.
Le système intègre un service d'envoi d'emails (SMTP) via `@nestjs-modules/mailer` pour les notifications critiques, la validation des comptes et la réinitialisation de mots de passe.

View File

@@ -19,7 +19,8 @@ Le projet Memegoat s'inscrit dans une démarche de respect de la vie privée et
Conformément à la section [Sécurité](/docs/security), les mesures suivantes sont appliquées :
- **Chiffrement au repos** : Utilisation de **PGP (pgcrypto)** pour les données identifiantes.
- **Hachage aveugle** : Pour permettre les opérations sur données chiffrées sans compromettre la confidentialité.
- **Cryptographie Post-Quantique** : Mise en œuvre de `@noble/post-quantum` pour protéger les données contre les futures capacités de calcul quantique.
- **Hachage aveugle (Blind Indexing)** : Pour permettre les opérations d'unicité et de recherche sur données chiffrées sans compromettre la confidentialité.
- **Hachage des mots de passe** : Utilisation de l'algorithme **Argon2id**.
- **Communications sécurisées** : Utilisation de **TLS 1.3** via Caddy.
- **Suivi des Erreurs (Sentry)** : Configuration conforme avec désactivation de l'envoi des PII (Personally Identifiable Information) et masquage des données sensibles.

View File

@@ -18,13 +18,24 @@ erDiagram
USER ||--o{ API_KEY : "genere"
USER ||--o{ AUDIT_LOG : "genere"
USER ||--o{ FAVORITE : "ajoute"
USER ||--o{ COMMENT : "rédige"
USER ||--o{ COMMENT_LIKE : "aime"
USER ||--o{ CONVERSATION_PARTICIPANT : "participe"
USER ||--o{ MESSAGE : "envoie"
CONTENT ||--o{ CONTENT_TAG : "possede"
TAG ||--o{ CONTENT_TAG : "est_lie_a"
CONTENT ||--o{ REPORT : "est_signale"
CONTENT ||--o{ FAVORITE : "est_mis_en"
CONTENT ||--o{ COMMENT : "reçoit"
TAG ||--o{ REPORT : "est_signale"
COMMENT ||--o{ COMMENT : "possède des réponses"
COMMENT ||--o{ COMMENT_LIKE : "est aimé par"
CONVERSATION ||--o{ CONVERSATION_PARTICIPANT : "regroupe"
CONVERSATION ||--o{ MESSAGE : "contient"
CATEGORY ||--o{ CONTENT : "catégorise"
ROLE ||--o{ USER_ROLE : "attribue_a"
@@ -45,6 +56,15 @@ erDiagram
string type
string storage_key
}
COMMENT {
string text
}
CONVERSATION {
timestamp created_at
}
MESSAGE {
string text
}
TAG {
string name
string slug
@@ -140,6 +160,39 @@ erDiagram
uuid content_id PK, FK
uuid tag_id PK, FK
}
comments {
uuid id PK
uuid content_id FK
uuid user_id FK
uuid parent_id FK
text text
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
comment_likes {
uuid comment_id PK, FK
uuid user_id PK, FK
timestamp created_at
}
conversations {
uuid id PK
timestamp created_at
timestamp updated_at
}
conversation_participants {
uuid conversation_id PK, FK
uuid user_id PK, FK
timestamp joined_at
}
messages {
uuid id PK
uuid conversation_id FK
uuid sender_id FK
text text
timestamp created_at
timestamp read_at
}
roles {
uuid id PK
varchar name
@@ -225,6 +278,15 @@ erDiagram
users ||--o{ sessions : "user_id"
users ||--o{ api_keys : "user_id"
users ||--o{ audit_logs : "user_id"
contents ||--o{ comments : "content_id"
users ||--o{ comments : "user_id"
comments ||--o{ comments : "parent_id"
comments ||--o{ comment_likes : "comment_id"
users ||--o{ comment_likes : "user_id"
conversations ||--o{ conversation_participants : "conversation_id"
users ||--o{ conversation_participants : "user_id"
conversations ||--o{ messages : "conversation_id"
users ||--o{ messages : "sender_id"
```
### Physique (MPD)
@@ -278,6 +340,7 @@ erDiagram
#### Sécurité et Chiffrement
- **Chiffrement PGP (Native)** : Les colonnes `email` et `two_factor_secret` sont stockées au format `bytea` et chiffrées/déchiffrées via les fonctions `pgp_sym_encrypt` et `pgp_sym_decrypt` de PostgreSQL (via l'extension `pgcrypto`).
- **Cryptographie Post-Quantique** : Utilisation de la bibliothèque `@noble/post-quantum` pour anticiper les futures menaces cryptographiques.
- **Hachage aveugle (Blind Indexing)** : La colonne `email_hash` stocke un hash (SHA-256) de l'email pour permettre les recherches d'unicité et les recherches rapides sans déchiffrer la donnée.
#### Index et Optimisations

View File

@@ -12,10 +12,10 @@ Un conteneur **Caddy** est utilisé en tant que reverse proxy pour fournir le TL
### Pré-requis Système
<Cards>
<Card title="Environnement" description="Node.js >= 20, pnpm >= 10." />
<Card title="Base de données" description="PostgreSQL >= 15 + pgcrypto et Redis." />
<Card title="Environnement" description="Node.js >= 22 (recommandé pour NestJS 11), pnpm >= 10." />
<Card title="Base de données" description="PostgreSQL >= 16 + pgcrypto et Redis 7+." />
<Card title="Stockage" description="MinIO ou S3 Compatible." />
<Card title="Services" description="ClamAV (clamd) et FFmpeg." />
<Card title="Services" description="ClamAV (clamd), FFmpeg 6+ et Serveur SMTP." />
</Cards>
### Procédure de Déploiement

View File

@@ -10,7 +10,7 @@ Le projet Memegoat intègre un ensemble de fonctionnalités avancées pour garan
## 🏗️ Infrastructure & Médias
### 📤 Publication & Traitement
Le coeur de la plateforme permet la publication sécurisée de mèmes et de GIFs avec un pipeline de traitement complet :
Le coeur de la plateforme permet la publication sécurisée de mèmes et de GIFs avec un pipeline de traitement complet (voir le [Flux de Publication](/docs/flows#-publication-de-contenu-pipeline-médía)) :
<Cards>
<Card icon="🛡️" title="Sécurité (Antivirus)" description="Chaque fichier uploadé est scanné en temps réel par ClamAV." />
@@ -64,6 +64,11 @@ Un système complet de gestion de profil permet aux utilisateurs de :
- Configurer la **Double Authentification (2FA)**.
- Consulter leurs sessions actives et révoquer des accès.
### 💬 Interaction & Communauté
Memegoat favorise l'interaction entre les utilisateurs via plusieurs fonctionnalités sociales :
- **Système de Commentaires** : Les utilisateurs peuvent commenter les mèmes, répondre à d'autres commentaires et aimer les contributions.
- **Messagerie Privée** : Un système de messagerie sécurisé permettant des conversations directes entre utilisateurs, avec gestion des conversations et compteurs de messages non lus.
<Callout type="info">
Toutes les données sensibles du profil sont protégées par **chiffrement PGP** au repos.
</Callout>

View File

@@ -0,0 +1,177 @@
---
title: Flux Métiers
description: Diagrammes de séquence et explications des flux critiques de Memegoat.
---
# 🔄 Flux Métiers
Cette section détaille les processus critiques de la plateforme Memegoat à travers des diagrammes de séquence et des explications techniques étape par étape.
## 🔐 Authentification & Sécurité
### Inscription & Double Authentification (2FA)
Le processus d'inscription intègre immédiatement les mesures de sécurité fortes (Argon2id, PGP). L'activation de la 2FA est optionnelle mais fortement recommandée.
```mermaid
sequenceDiagram
participant U as Utilisateur
participant F as Frontend
participant B as Backend
participant DB as PostgreSQL
participant M as Serveur SMTP
Note over U, DB: Flux d'Inscription
U->>F: Remplir formulaire (email, password)
F->>B: POST /auth/register
B->>B: Hash password (Argon2id)
B->>B: Chiffrement Email (PGP)
B->>B: Génération Email Hash (Blind Indexing)
B->>DB: INSERT INTO users
B->>M: Envoi email de validation
B-->>F: 201 Created
F-->>U: Succès (Redirection Login)
Note over U, DB: Activation 2FA
U->>F: Activer 2FA
F->>B: POST /users/me/2fa/setup
B->>B: Générer Secret TOTP
B->>B: Chiffrer Secret (PGP)
B->>DB: UPDATE users SET two_factor_secret
B-->>F: Secret + QR Code URL
F-->>U: Affiche QR Code
U->>F: Saisir code TOTP
F->>B: POST /users/me/2fa/enable (token)
B->>B: Déchiffrer Secret (PGP)
B->>B: Vérifier TOTP (otplib)
B->>DB: UPDATE users SET is_two_factor_enabled = true
B-->>F: 200 OK
```
---
## 📤 Publication de Contenu (Pipeline Média)
La publication d'un mème ou d'un GIF suit un pipeline rigoureux garantissant la sécurité (Antivirus) et l'optimisation (Transcodage).
```mermaid
sequenceDiagram
participant U as Utilisateur
participant F as Frontend
participant B as Backend
participant AV as ClamAV
participant S3 as MinIO (S3)
participant DB as PostgreSQL
U->>F: Sélectionner image/vidéo
F->>B: POST /contents/upload (multipart)
B->>B: Validation (Taille, MIME-Type)
B->>AV: Scan Antivirus (Stream)
AV-->>B: Verdict (Clean/Infected)
alt Infecté
B-->>F: 400 Bad Request (Virus detected)
else Sain
B->>B: Transcodage (Sharp/FFmpeg)
Note right of B: WebP pour images, WebM pour vidéos
B->>S3: Upload fichier optimisé
S3-->>B: Storage Key
B->>DB: INSERT INTO contents
B->>DB: INSERT INTO audit_logs (Upload action)
B-->>F: 201 Created
end
```
---
## 💬 Messagerie & Temps Réel
Memegoat utilise **Socket.io** pour les interactions en temps réel, avec une validation de session robuste via `iron-session`.
```mermaid
sequenceDiagram
participant U1 as Utilisateur A
participant F1 as Frontend A
participant WS as WebSocket Gateway
participant B as Backend (API)
participant F2 as Frontend B
participant U2 as Utilisateur B
U1->>F1: Ouvre le chat
F1->>WS: Connexion (transports: websocket)
Note over WS: Authentification via iron-session cookie
WS->>WS: Vérifie Access Token (JWT)
WS->>WS: Rejoindre room "user:A"
WS-->>F1: Connected
U1->>F1: Tape un message
F1->>WS: Event "typing" { recipientId: B, isTyping: true }
WS->>F2: Event "user_typing" { userId: A, isTyping: true }
F2-->>U2: Affiche "A est en train d'écrire..."
U1->>F1: Envoyer message
F1->>B: POST /messages { recipientId: B, text: "Salut !" }
B->>DB: INSERT INTO messages
B-->>F1: 201 Created
B->>WS: Trigger Notify(B)
WS->>F2: Event "new_message" { senderId: A, text: "Salut !" }
F2-->>U2: Affiche message + Notification
```
---
## ⚖️ Cycle de Vie & Conformité (RGPD)
La gestion des données respecte le droit à l'oubli à travers un processus de suppression en deux étapes et une purge automatique.
```mermaid
sequenceDiagram
participant U as Utilisateur
participant B as Backend
participant DB as PostgreSQL
participant S3 as MinIO (S3)
participant C as Cron Job (PurgeService)
Note over U, DB: Droit à l'oubli (Phase 1)
U->>B: DELETE /users/me
B->>DB: UPDATE users SET deleted_at = NOW()
B->>DB: UPDATE contents SET deleted_at = NOW() WHERE user_id = U
B-->>U: 200 OK (Compte désactivé)
Note over C, S3: Purge Automatique (Phase 2 - après 30 jours)
C->>B: Execute purgeExpiredData()
B->>DB: SELECT users WHERE deleted_at < 30 days
B->>DB: DELETE FROM users (Hard Delete)
Note right of B: Cascade delete sur API keys, Sessions, etc.
B->>DB: DELETE FROM contents (Hard Delete)
B->>S3: DELETE objects (Storage Keys)
B->>DB: Purge Audit Logs / Reports expirés
```
---
## 🚩 Modération
Le flux de modération permet aux utilisateurs de signaler des abus, traités ensuite par les administrateurs.
```mermaid
sequenceDiagram
participant U as Utilisateur
participant B as Backend
participant DB as PostgreSQL
participant A as Administrateur
U->>B: POST /reports { contentId, reason, description }
B->>DB: INSERT INTO reports (status: pending)
B-->>U: 201 Created
A->>B: GET /reports (Admin Panel)
B->>DB: SELECT * FROM reports WHERE status = pending
B-->>A: Liste des signalements
A->>B: PATCH /reports/:id/status { status: resolved }
B->>DB: UPDATE reports SET status = resolved
Note right of B: Si contenu illicite, l'admin peut supprimer le contenu
B->>B: DELETE /contents/:id/admin (Hard Delete)
B-->>A: 200 OK
```

View File

@@ -18,10 +18,11 @@ graph TD
User([Utilisateur])
Caddy[Reverse Proxy: Caddy]
Frontend[Frontend: Next.js]
Backend[Backend: NestJS]
Backend[Backend: NestJS 11]
DB[(Database: PostgreSQL)]
Storage[Storage: S3/MinIO]
Cache[(Cache: Redis)]
AV[Antivirus: ClamAV]
Monitoring[Monitoring: Sentry]
User <--> Caddy
@@ -30,6 +31,7 @@ graph TD
Backend <--> DB
Backend <--> Storage
Backend <--> Cache
Backend <--> AV
Backend --> Monitoring
```
@@ -43,6 +45,11 @@ Explorez les sections clés pour approfondir vos connaissances techniques :
href="/docs/features"
description="Détails des capacités techniques et du pipeline média haute performance."
/>
<Card
title="🔄 Flux Métiers"
href="/docs/flows"
description="Diagrammes de séquence des processus critiques (Publication, 2FA, Chat)."
/>
<Card
title="🔐 Sécurité"
href="/docs/security"

View File

@@ -7,6 +7,7 @@ description: Mesures de sécurité implémentées
### Protection des Données (At Rest)
- **Cryptographie Post-Quantique** : Utilisation de la bibliothèque `@noble/post-quantum` pour anticiper les futures menaces cryptographiques et protéger les données sensibles contre les attaques "Harvest Now, Decrypt Later".
- **Chiffrement PGP Natif** : Les données identifiantes (PII) comme l'email, le nom d'affichage et le **secret 2FA** sont chiffrées dans PostgreSQL via `pgcrypto` (`pgp_sym_encrypt`).
<Callout type="warn" title="Sécurité des Clés">

View File

@@ -17,9 +17,9 @@ description: Technologies utilisées dans le projet Memegoat
### Backend
<Cards>
<Card title="NestJS" description="Framework Node.js modulaire et robuste." />
<Card title="NestJS 11" description="Framework Node.js modulaire et robuste (dernière version majeure)." />
<Card title="PostgreSQL" description="Base de données relationnelle puissante." />
<Card title="Redis" description="Store clé-valeur pour le cache haute performance." />
<Card title="Redis" description="Store clé-valeur pour le cache haute performance (Cache Manager v5+)." />
<Card title="Drizzle ORM" description="ORM TypeScript-first avec support des migrations." />
<Card title="Sharp & FFmpeg" description="Traitement haute performance des images et vidéos." />
</Cards>
@@ -28,8 +28,9 @@ description: Technologies utilisées dans le projet Memegoat
<Cards>
<Card title="ClamAV" description="Protection antivirus en temps réel." />
<Card title="Sentry" description="Reporting d'erreurs et profiling de performance." />
<Card title="Argon2id" description="Hachage de mots de passe de grade militaire." />
<Card title="Sentry" description="Reporting d'erreurs et profiling de performance (SDK v8+)." />
<Card title="Argon2id" description="Hachage de mots de passe de grade militaire via @node-rs/argon2." />
<Card title="Post-Quantum Crypto" description="Algorithmes résistants aux futurs ordinateurs quantiques via @noble/post-quantum." />
<Card title="PGP (pgcrypto)" description="Chiffrement natif des données sensibles." />
<Card title="otplib" description="Implémentation TOTP pour la 2FA." />
<Card title="iron-session" description="Gestion sécurisée des sessions via cookies chiffrés." />

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-cache,target=/usr/src/app/frontend/.next/cache \

View File

@@ -1,5 +1,16 @@
import type { NextConfig } from "next";
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.fr";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.memegoat.fr";
const getHostname = (url: string) => {
try {
return new URL(url).hostname;
} catch {
return url;
}
};
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
@@ -7,11 +18,11 @@ const nextConfig: NextConfig = {
remotePatterns: [
{
protocol: "https",
hostname: "memegoat.fr",
hostname: getHostname(appUrl),
},
{
protocol: "https",
hostname: "api.memegoat.fr",
hostname: getHostname(apiUrl),
},
],
},

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/frontend",
"version": "1.5.3",
"version": "1.9.6",
"private": true,
"scripts": {
"dev": "next dev",
@@ -54,6 +54,7 @@
"react-hook-form": "^7.71.1",
"react-resizable-panels": "^4.4.1",
"recharts": "2.15.4",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",

View File

@@ -24,20 +24,29 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { useAuth } from "@/providers/auth-provider";
const loginSchema = z.object({
email: z.string().email({ message: "Email invalide" }),
password: z
.string()
.min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
.min(8, { message: "Le mot de passe doit faire au moins 8 caractères" }),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export default function LoginPage() {
const { login } = useAuth();
const { login, verify2fa } = useAuth();
const [loading, setLoading] = React.useState(false);
const [show2fa, setShow2fa] = React.useState(false);
const [userId, setUserId] = React.useState<string | null>(null);
const [otpValue, setOtpValue] = React.useState("");
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
@@ -50,7 +59,11 @@ export default function LoginPage() {
async function onSubmit(values: LoginFormValues) {
setLoading(true);
try {
await login(values.email, values.password);
const res = await login(values.email, values.password);
if (res.userId && res.message === "Please provide 2FA token") {
setUserId(res.userId);
setShow2fa(true);
}
} catch (_error) {
// Error is handled in useAuth via toast
} finally {
@@ -58,6 +71,20 @@ export default function LoginPage() {
}
}
async function onOtpSubmit(e: React.FormEvent) {
e.preventDefault();
if (!userId || otpValue.length !== 6) return;
setLoading(true);
try {
await verify2fa(userId, otpValue);
} catch (_error) {
// Error handled in useAuth
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
<div className="w-full max-w-md space-y-4">
@@ -70,45 +97,89 @@ export default function LoginPage() {
</Link>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Connexion</CardTitle>
<CardTitle className="text-2xl">
{show2fa ? "Double Authentification" : "Connexion"}
</CardTitle>
<CardDescription>
Entrez vos identifiants pour accéder à votre compte MemeGoat.
{show2fa
? "Entrez le code à 6 chiffres de votre application d'authentification."
: "Entrez vos identifiants pour accéder à votre compte MemeGoat."}
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="goat@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Mot de passe</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Connexion en cours..." : "Se connecter"}
{show2fa ? (
<form
onSubmit={onOtpSubmit}
className="space-y-6 flex flex-col items-center"
>
<InputOTP
maxLength={6}
value={otpValue}
onChange={(value) => setOtpValue(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<Button
type="submit"
className="w-full"
disabled={loading || otpValue.length !== 6}
>
{loading ? "Vérification..." : "Vérifier le code"}
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => setShow2fa(false)}
disabled={loading}
>
Retour
</Button>
</form>
</Form>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="goat@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Mot de passe</FormLabel>
<FormControl>
<Input type="password" placeholder="" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Connexion en cours..." : "Se connecter"}
</Button>
</form>
</Form>
)}
</CardContent>
<CardFooter className="flex flex-col space-y-2">
<p className="text-sm text-center text-muted-foreground">

View File

@@ -29,11 +29,27 @@ import { useAuth } from "@/providers/auth-provider";
const registerSchema = z.object({
username: z
.string()
.min(3, { message: "Le pseudo doit faire au moins 3 caractères" }),
.min(3, { message: "Le pseudo doit faire au moins 3 caractères" })
.regex(/^[a-z0-9_]+$/, {
message:
"Le pseudo ne doit contenir que des minuscules, chiffres et underscores",
}),
email: z.string().email({ message: "Email invalide" }),
password: z
.string()
.min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
.min(8, { message: "Le mot de passe doit faire au moins 8 caractères" })
.regex(/[A-Z]/, {
message: "Le mot de passe doit contenir au moins une majuscule",
})
.regex(/[a-z]/, {
message: "Le mot de passe doit contenir au moins une minuscule",
})
.regex(/[0-9]/, {
message: "Le mot de passe doit contenir au moins un chiffre",
})
.regex(/[^A-Za-z0-9]/, {
message: "Le mot de passe doit contenir au moins un caractère spécial",
}),
displayName: z.string().optional(),
});
@@ -84,12 +100,25 @@ export default function RegisterPage() {
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Nom d'affichage (Optionnel)</FormLabel>
<FormControl>
<Input placeholder="Le Roi des Chèvres" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Pseudo</FormLabel>
<FormLabel>Pseudo (minuscule)</FormLabel>
<FormControl>
<Input placeholder="supergoat" {...field} />
</FormControl>
@@ -110,19 +139,6 @@ export default function RegisterPage() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Nom d'affichage (Optionnel)</FormLabel>
<FormControl>
<Input placeholder="Le Roi des Chèvres" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"

View File

@@ -10,7 +10,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { ViewCounter } from "@/components/view-counter";
import { ContentService } from "@/services/content.service";
import type { Content } from "@/types/content";
@@ -46,7 +45,6 @@ export default function MemeModal({
</div>
) : content ? (
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
<ViewCounter contentId={content.id} />
<ContentCard content={content} />
</div>
) : (

View File

@@ -1,6 +1,6 @@
"use client";
import { AlertCircle, FileText, LayoutGrid, Users } from "lucide-react";
import { AlertCircle, FileText, Flag, LayoutGrid, Users } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -54,6 +54,13 @@ export default function AdminDashboardPage() {
href: "/admin/categories",
color: "text-purple-500",
},
{
title: "Signalements",
value: "Voir",
icon: Flag,
href: "/admin/reports",
color: "text-red-500",
},
];
return (

View File

@@ -0,0 +1,204 @@
"use client";
import {
AlertCircle,
ArrowLeft,
CheckCircle,
MoreHorizontal,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { adminService } from "@/services/admin.service";
import { type Report, ReportStatus } from "@/services/report.service";
export default function AdminReportsPage() {
const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true);
const fetchReports = useCallback(async () => {
setLoading(true);
try {
const data = await adminService.getReports();
setReports(data);
} catch (_error) {
toast.error("Erreur lors du chargement des signalements.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchReports();
}, [fetchReports]);
const handleUpdateStatus = async (reportId: string, status: ReportStatus) => {
try {
await adminService.updateReportStatus(reportId, status);
toast.success("Statut mis à jour.");
fetchReports();
} catch (_error) {
toast.error("Erreur lors de la mise à jour du statut.");
}
};
const getStatusBadge = (status: ReportStatus) => {
switch (status) {
case ReportStatus.PENDING:
return <Badge variant="outline">En attente</Badge>;
case ReportStatus.REVIEWED:
return <Badge variant="secondary">Examiné</Badge>;
case ReportStatus.RESOLVED:
return <Badge variant="success">Résolu</Badge>;
case ReportStatus.DISMISSED:
return <Badge variant="destructive">Rejeté</Badge>;
default:
return <Badge variant="default">{status}</Badge>;
}
};
return (
<div className="flex-1 space-y-8 p-4 pt-6 md:p-8">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h2 className="text-3xl font-bold tracking-tight">Signalements</h2>
</div>
<Card>
<CardHeader>
<CardTitle>Liste des signalements</CardTitle>
<CardDescription>
Gérez les signalements de contenu inapproprié.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Signalé par</TableHead>
<TableHead>Cible</TableHead>
<TableHead>Raison</TableHead>
<TableHead>Description</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
Chargement...
</TableCell>
</TableRow>
) : reports.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
Aucun signalement trouvé.
</TableCell>
</TableRow>
) : (
reports.map((report) => (
<TableRow key={report.uuid}>
<TableCell>{report.reporterId.substring(0, 8)}...</TableCell>
<TableCell>
{report.contentId ? (
<Link
href={`/meme/${report.contentId}`}
className="text-primary hover:underline"
>
Contenu
</Link>
) : (
"Tag"
)}
</TableCell>
<TableCell className="font-medium capitalize">
{report.reason}
</TableCell>
<TableCell className="max-w-xs truncate">
{report.description || "-"}
</TableCell>
<TableCell>{getStatusBadge(report.status)}</TableCell>
<TableCell>
{new Date(report.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
handleUpdateStatus(report.uuid, ReportStatus.REVIEWED)
}
>
<AlertCircle className="h-4 w-4 mr-2" />
Marquer comme examiné
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleUpdateStatus(report.uuid, ReportStatus.RESOLVED)
}
>
<CheckCircle className="h-4 w-4 mr-2" />
Marquer comme résolu
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleUpdateStatus(report.uuid, ReportStatus.DISMISSED)
}
className="text-destructive"
>
<XCircle className="h-4 w-4 mr-2" />
Rejeter
</DropdownMenuItem>
{report.contentId && (
<DropdownMenuItem asChild>
<Link href={`/meme/${report.contentId}`}>Voir le contenu</Link>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -63,7 +63,9 @@ export default function HelpPage() {
<p className="text-muted-foreground">
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
</p>
<p className="font-semibold text-primary">contact@memegoat.fr</p>
<p className="font-semibold text-primary">
{process.env.NEXT_PUBLIC_CONTACT_EMAIL || "contact@memegoat.fr"}
</p>
</div>
</div>
);

View File

@@ -2,9 +2,9 @@ import { ChevronLeft } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CommentSection } from "@/components/comment-section";
import { ContentCard } from "@/components/content-card";
import { Button } from "@/components/ui/button";
import { ViewCounter } from "@/components/view-counter";
import { ContentService } from "@/services/content.service";
export const revalidate = 3600; // ISR: Revalider toutes les heures
@@ -41,7 +41,6 @@ export default async function MemePage({
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<ViewCounter contentId={content.id} />
<Link
href="/"
className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors"
@@ -50,9 +49,10 @@ export default async function MemePage({
Retour au flux
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
<div className="lg:col-span-2">
<ContentCard content={content} />
<CommentSection contentId={content.id} />
</div>
<div className="space-y-6">

View File

@@ -0,0 +1,562 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale";
import {
ArrowLeft,
Check,
CheckCheck,
Search,
Send,
UserPlus,
X,
} from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import * as React from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAuth } from "@/providers/auth-provider";
import { useSocket } from "@/providers/socket-provider";
import {
type Conversation,
type Message,
MessageService,
} from "@/services/message.service";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
export default function MessagesPage() {
const { user } = useAuth();
const { socket } = useSocket();
const _router = useRouter();
const searchParams = useSearchParams();
const targetUserId = searchParams.get("user");
const [conversations, setConversations] = React.useState<Conversation[]>([]);
const [activeConv, setActiveConv] = React.useState<Conversation | null>(null);
const [messages, setMessages] = React.useState<Message[]>([]);
const [newMessage, setNewMessage] = React.useState("");
const typingTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleTyping = () => {
if (!socket || !activeConv) return;
socket.emit("typing", {
recipientId: activeConv.recipient.uuid,
isTyping: true,
});
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
socket.emit("typing", {
recipientId: activeConv.recipient.uuid,
isTyping: false,
});
}, 3000);
};
const [isLoadingConvs, setIsLoadingConvs] = React.useState(true);
const [isLoadingMsgs, setIsLoadingMsgs] = React.useState(false);
const [isOtherTyping, setIsOtherTyping] = React.useState(false);
const [onlineUsers, setOnlineUsers] = React.useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = React.useState("");
const [searchResults, setSearchResults] = React.useState<User[]>([]);
const [isSearching, setIsSearching] = React.useState(false);
const scrollRef = React.useRef<HTMLDivElement>(null);
// Charger les conversations initiales
React.useEffect(() => {
const fetchConvs = async () => {
try {
const data = await MessageService.getConversations();
setConversations(data);
// Si un utilisateur est spécifié dans l'URL, essayer de trouver la conversation
if (targetUserId) {
const existing = data.find((c) => c.recipient.uuid === targetUserId);
if (existing) {
setActiveConv(existing);
} else {
// Chercher les infos de l'utilisateur pour afficher une interface de chat vide
try {
const conv = await MessageService.getConversationWith(targetUserId);
if (conv) {
setConversations((prev) => [conv, ...prev]);
setActiveConv(conv);
}
} catch (_e) {
// Peut-être que l'utilisateur n'existe pas ou erreur
}
}
}
} catch (_error) {
toast.error("Erreur lors du chargement des conversations");
} finally {
setIsLoadingConvs(false);
}
};
fetchConvs();
}, [targetUserId]);
// Recherche d'utilisateurs
React.useEffect(() => {
const delayDebounceFn = setTimeout(async () => {
if (searchQuery.length > 1) {
setIsSearching(true);
try {
const results = await UserService.search(searchQuery);
setSearchResults(results.filter((u) => u.uuid !== user?.uuid));
} catch (_error) {
console.error("Search failed");
} finally {
setIsSearching(false);
}
} else {
setSearchResults([]);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [searchQuery, user?.uuid]);
React.useEffect(() => {
if (activeConv) {
const fetchMsgs = async () => {
setIsLoadingMsgs(true);
try {
const data = await MessageService.getMessages(activeConv.id);
setMessages(data.reverse()); // Plus ancien au plus récent
} catch (_error) {
toast.error("Erreur lors du chargement des messages");
} finally {
setIsLoadingMsgs(false);
}
};
fetchMsgs();
}
}, [activeConv]);
React.useEffect(() => {
if (socket) {
socket.on(
"new_message",
(data: { conversationId: string; message: Message }) => {
if (activeConv?.id === data.conversationId) {
setMessages((prev) => [...prev, data.message]);
setIsOtherTyping(false); // S'il a envoyé un message, il ne tape plus
// Marquer comme lu immédiatement si on est sur la conversation
MessageService.markAsRead(data.conversationId).catch(console.error);
}
// Mettre à jour la liste des conversations
setConversations((prev) => {
const index = prev.findIndex((c) => c.id === data.conversationId);
if (index !== -1) {
const updated = [...prev];
updated[index] = {
...updated[index],
lastMessage: {
text: data.message.text,
createdAt: data.message.createdAt,
},
updatedAt: data.message.createdAt,
};
return updated.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
}
return prev;
});
},
);
socket.on("user_status", (data: { userId: string; status: string }) => {
setOnlineUsers((prev) => {
const next = new Set(prev);
if (data.status === "online") {
next.add(data.userId);
} else {
next.delete(data.userId);
}
return next;
});
});
socket.on("user_typing", (data: { userId: string; isTyping: boolean }) => {
if (activeConv?.recipient.uuid === data.userId) {
setIsOtherTyping(data.isTyping);
}
});
socket.on(
"messages_read",
(data: { conversationId: string; readerId: string }) => {
if (activeConv?.id === data.conversationId) {
setMessages((prev) =>
prev.map((msg) =>
msg.senderId !== data.readerId && !msg.readAt
? { ...msg, readAt: new Date().toISOString() }
: msg,
),
);
}
},
);
return () => {
socket.off("new_message");
socket.off("user_status");
socket.off("user_typing");
socket.off("messages_read");
};
}
}, [socket, activeConv]);
React.useEffect(() => {
if (scrollRef.current) {
const scrollContainer = scrollRef.current.querySelector(
"[data-slot='scroll-area-viewport']",
);
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
}, []);
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !activeConv) return;
const text = newMessage.trim();
setNewMessage("");
try {
const msg = await MessageService.sendMessage(
activeConv.recipient.uuid,
text,
);
// Si c'était une conv temporaire, on la remplace par la vraie
if (activeConv.id.startsWith("temp-")) {
const fetchConvs = async () => {
const data = await MessageService.getConversations();
setConversations(data);
const realConv = data.find(
(c) => c.recipient.uuid === activeConv.recipient.uuid,
);
if (realConv) setActiveConv(realConv);
};
fetchConvs();
} else {
setMessages((prev) => [...prev, msg]);
}
} catch (_error) {
toast.error("Erreur lors de l'envoi");
}
};
return (
<div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950">
{/* Sidebar - Liste des conversations */}
<div
className={`w-full md:w-80 border-r flex flex-col ${
activeConv ? "hidden md:flex" : "flex"
}`}
>
<div className="p-4 border-b">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Messages</h2>
<Button variant="ghost" size="icon" className="rounded-full">
<UserPlus className="h-5 w-5" />
</Button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un membre..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-full"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{searchQuery.length > 0 ? (
<>
<p className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Membres
</p>
{isSearching ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Recherche...
</div>
) : searchResults.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Aucun membre trouvé.
</div>
) : (
searchResults.map((result) => (
<button
key={result.uuid}
type="button"
onClick={async () => {
setSearchQuery("");
// Chercher si une conv existe déjà
const existing = conversations.find(
(c) => c.recipient.uuid === result.uuid,
);
if (existing) {
setActiveConv(existing);
} else {
// Créer une interface de conv temporaire
const newConv: Conversation = {
id: `temp-${result.uuid}`,
updatedAt: new Date().toISOString(),
recipient: {
uuid: result.uuid,
username: result.username,
displayName: result.displayName,
avatarUrl: result.avatarUrl,
},
};
setConversations((prev) => [newConv, ...prev]);
setActiveConv(newConv);
}
}}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors"
>
<Avatar className="h-10 w-10">
<AvatarImage src={result.avatarUrl} />
<AvatarFallback>{result.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 text-left overflow-hidden">
<span className="font-bold block truncate">
{result.displayName || result.username}
</span>
<span className="text-xs text-muted-foreground block truncate">
@{result.username}
</span>
</div>
</button>
))
)}
</>
) : isLoadingConvs ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Chargement...
</div>
) : conversations.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Aucune conversation.
</div>
) : (
conversations.map((conv) => (
<button
key={conv.id}
type="button"
onClick={() => setActiveConv(conv)}
className={`w-full flex items-center gap-3 p-3 rounded-xl transition-colors ${
activeConv?.id === conv.id
? "bg-primary/10 text-primary"
: "hover:bg-zinc-100 dark:hover:bg-zinc-900"
}`}
>
<Avatar isOnline={onlineUsers.has(conv.recipient.uuid)}>
<AvatarImage src={conv.recipient.avatarUrl} />
<AvatarFallback>
{conv.recipient.username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 text-left overflow-hidden">
<div className="flex justify-between items-baseline">
<span className="font-bold truncate">
{conv.recipient.displayName || conv.recipient.username}
</span>
{conv.lastMessage && (
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(conv.lastMessage.createdAt), {
locale: fr,
})}
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{conv.lastMessage?.text || "Démarrer une conversation"}
</p>
</div>
</button>
))
)}
</div>
</ScrollArea>
</div>
{/* Zone de chat */}
<div
className={`flex-1 flex flex-col ${
!activeConv ? "hidden md:flex" : "flex"
}`}
>
{activeConv ? (
<>
{/* Header */}
<div className="p-4 border-b flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="md:hidden rounded-full"
onClick={() => setActiveConv(null)}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<Link
href={`/user/${activeConv.recipient.username}`}
className="flex-1 flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<Avatar
className="h-8 w-8"
isOnline={onlineUsers.has(activeConv.recipient.uuid)}
>
<AvatarImage src={activeConv.recipient.avatarUrl} />
<AvatarFallback>
{activeConv.recipient.username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-bold leading-none">
{activeConv.recipient.displayName || activeConv.recipient.username}
</h3>
<span
className={`text-xs font-medium ${
onlineUsers.has(activeConv.recipient.uuid)
? "text-green-500"
: "text-muted-foreground"
}`}
>
{onlineUsers.has(activeConv.recipient.uuid)
? "En ligne"
: "Hors ligne"}
</span>
</div>
</Link>
</div>
{/* Messages */}
<ScrollArea className="flex-1 p-4" viewportRef={scrollRef}>
<div className="space-y-4">
{isLoadingMsgs ? (
<div className="text-center py-4 text-sm text-muted-foreground">
Chargement...
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${
msg.senderId === user?.uuid ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[70%] p-3 rounded-2xl text-sm ${
msg.senderId === user?.uuid
? "bg-primary text-primary-foreground rounded-br-none"
: "bg-zinc-100 dark:bg-zinc-800 rounded-bl-none"
}`}
>
<p className="whitespace-pre-wrap">{msg.text}</p>
<div
className={`flex items-center gap-1 text-[10px] mt-1 ${
msg.senderId === user?.uuid
? "text-primary-foreground/70 justify-end"
: "text-muted-foreground"
}`}
>
<span>
{new Date(msg.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
{msg.senderId === user?.uuid && (
<span className="flex items-center">
{msg.readAt ? (
<CheckCheck className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</span>
)}
</div>
</div>
</div>
))
)}
{isOtherTyping && (
<div className="flex justify-start">
<div className="bg-zinc-100 dark:bg-zinc-800 p-3 rounded-2xl rounded-bl-none">
<div className="flex gap-1">
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full animate-bounce" />
</div>
</div>
</div>
)}
</div>
</ScrollArea>
{/* Input */}
<div className="p-4 border-t">
<form onSubmit={handleSendMessage} className="flex gap-2">
<Input
placeholder="Écrivez un message..."
value={newMessage}
onChange={(e) => {
setNewMessage(e.target.value);
handleTyping();
}}
className="rounded-full px-4"
/>
<Button
type="submit"
size="icon"
className="rounded-full shrink-0"
disabled={!newMessage.trim()}
>
<Send className="h-4 w-4" />
</Button>
</form>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
<div className="bg-primary/10 p-6 rounded-full mb-4">
<Send className="h-12 w-12 text-primary" />
</div>
<h2 className="text-2xl font-bold mb-2">Vos messages</h2>
<p className="text-muted-foreground max-w-sm">
Sélectionnez une conversation ou démarrez-en une nouvelle pour commencer
à discuter.
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -3,12 +3,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
AlertTriangle,
Download,
Laptop,
Loader2,
Moon,
Palette,
Save,
Settings,
Shield,
Sun,
Trash2,
User as UserIcon,
@@ -19,6 +21,7 @@ import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { TwoFactorSetup } from "@/components/two-factor-setup";
import {
AlertDialog,
AlertDialogAction,
@@ -51,6 +54,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider";
import { UserService } from "@/services/user.service";
@@ -58,6 +62,8 @@ import { UserService } from "@/services/user.service";
const settingsSchema = z.object({
displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(),
bio: z.string().max(255, "La bio est trop longue").optional(),
showOnlineStatus: z.boolean(),
showReadReceipts: z.boolean(),
});
type SettingsFormValues = z.infer<typeof settingsSchema>;
@@ -68,6 +74,7 @@ export default function SettingsPage() {
const router = useRouter();
const [isSaving, setIsSaving] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const [isExporting, setIsExporting] = React.useState(false);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
@@ -79,6 +86,8 @@ export default function SettingsPage() {
defaultValues: {
displayName: "",
bio: "",
showOnlineStatus: true,
showReadReceipts: true,
},
});
@@ -87,6 +96,8 @@ export default function SettingsPage() {
form.reset({
displayName: user.displayName || "",
bio: user.bio || "",
showOnlineStatus: user.showOnlineStatus ?? true,
showReadReceipts: user.showReadReceipts ?? true,
});
}
}, [user, form]);
@@ -143,6 +154,29 @@ export default function SettingsPage() {
}
};
const handleExportData = async () => {
setIsExporting(true);
try {
const data = await UserService.exportData();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `memegoat-data-${user?.username}.json`);
document.body.appendChild(link);
link.click();
link.remove();
toast.success("Vos données ont été exportées avec succès.");
} catch (error) {
console.error(error);
toast.error("Erreur lors de l'exportation des données.");
} finally {
setIsExporting(false);
}
};
return (
<div className="max-w-2xl mx-auto py-12 px-4">
<div className="flex items-center gap-3 mb-8">
@@ -239,6 +273,75 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Confidentialité */}
<Card className="border-none shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<CardTitle>Confidentialité</CardTitle>
<CardDescription>Gérez la visibilité de vos activités.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="showOnlineStatus"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Statut en ligne</FormLabel>
<FormDescription>
Affiche quand vous êtes actif sur le site.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="showReadReceipts"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Confirmations de lecture
</FormLabel>
<FormDescription>
Permet aux autres de voir quand vous avez lu leurs messages.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSaving} className="min-w-[150px]">
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Enregistrer
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
<TwoFactorSetup />
<Card className="border-none shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center gap-2 mb-1">
@@ -291,6 +394,49 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card className="border-none shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center gap-2 mb-1">
<Download className="h-5 w-5 text-primary" />
<CardTitle>Portabilité des données</CardTitle>
</div>
<CardDescription>
Conformément au RGPD, vous pouvez exporter l'intégralité de vos données
rattachées à votre compte.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 rounded-lg bg-white dark:bg-zinc-900 border">
<div className="space-y-1">
<p className="font-bold">Exporter mes données</p>
<p className="text-sm text-muted-foreground">
Téléchargez un fichier JSON contenant votre profil, vos mèmes et vos
favoris.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleExportData}
disabled={isExporting}
className="font-semibold"
>
{isExporting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Exportation...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Exporter mes données
</>
)}
</Button>
</div>
</CardContent>
</Card>
<Card className="border-destructive/20 shadow-sm bg-destructive/5">
<CardHeader className="pb-4">
<div className="flex items-center gap-2 mb-1">

View File

@@ -36,6 +36,7 @@ import {
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { useAuth } from "@/providers/auth-provider";
import { useSocket } from "@/providers/socket-provider";
import { CategoryService } from "@/services/category.service";
import { ContentService } from "@/services/content.service";
import type { Category } from "@/types/content";
@@ -52,10 +53,32 @@ type UploadFormValues = z.infer<typeof uploadSchema>;
export default function UploadPage() {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
const { socket } = useSocket();
const [categories, setCategories] = React.useState<Category[]>([]);
const [file, setFile] = React.useState<File | null>(null);
const [preview, setPreview] = React.useState<string | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const [uploadStatus, setUploadStatus] = React.useState<string>("");
const [uploadProgress, setUploadProgress] = React.useState<number>(0);
React.useEffect(() => {
if (socket) {
socket.on(
"upload_progress",
(data: { status: string; progress: number; message?: string }) => {
setUploadStatus(data.status);
setUploadProgress(data.progress);
if (data.status === "error" && data.message) {
toast.error(data.message);
}
},
);
return () => {
socket.off("upload_progress");
};
}
}, [socket]);
const form = useForm<UploadFormValues>({
resolver: zodResolver(uploadSchema),
@@ -327,10 +350,20 @@ export default function UploadPage() {
<Button type="submit" className="w-full" disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Upload en cours...
</>
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{uploadProgress}%</span>
</div>
<span className="text-[10px] uppercase tracking-wider opacity-70">
{uploadStatus === "starting" && "Initialisation..."}
{uploadStatus === "scanning" && "Scan Antivirus..."}
{uploadStatus === "processing" && "Optimisation..."}
{uploadStatus === "uploading_s3" && "Envoi au cloud..."}
{uploadStatus === "saving" && "Finalisation..."}
{uploadStatus === "completed" && "Terminé !"}
</span>
</div>
) : (
"Publier le mème"
)}

View File

@@ -1,12 +1,19 @@
"use client";
import { Calendar, Share2, User as UserIcon } from "lucide-react";
import {
Calendar,
MessageCircle,
Share2,
User as UserIcon,
} from "lucide-react";
import Link from "next/link";
import * as React from "react";
import { toast } from "sonner";
import { ContentList } from "@/components/content-list";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
@@ -17,9 +24,12 @@ export default function PublicProfilePage({
params: Promise<{ username: string }>;
}) {
const { username } = React.use(params);
const { user: currentUser, isAuthenticated } = useAuth();
const [user, setUser] = React.useState<User | null>(null);
const [loading, setLoading] = React.useState(true);
const isOwnProfile = currentUser?.username === username;
React.useEffect(() => {
UserService.getProfile(username)
.then(setUser)
@@ -93,7 +103,15 @@ export default function PublicProfilePage({
})}
</span>
</div>
<div className="flex justify-center md:justify-start pt-2">
<div className="flex flex-wrap justify-center md:justify-start gap-2 pt-2">
{!isOwnProfile && isAuthenticated && (
<Button size="sm" className="h-9 px-4" asChild>
<Link href={`/messages?user=${user.uuid}`}>
<MessageCircle className="h-4 w-4 mr-2" />
Message
</Link>
</Button>
)}
<Button
variant="outline"
size="sm"

View File

@@ -74,6 +74,16 @@
--color-destructive-foreground: var(--destructive-foreground);
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
:root {
--radius: 0.5rem;
--background: oklch(0.9821 0 0);

View File

@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
import { NotificationHandler } from "@/components/notification-handler";
import { Toaster } from "@/components/ui/sonner";
import { AudioProvider } from "@/providers/audio-provider";
import { AuthProvider } from "@/providers/auth-provider";
import { SocketProvider } from "@/providers/socket-provider";
import { ThemeProvider } from "@/providers/theme-provider";
import "./globals.css";
@@ -29,7 +32,7 @@ export const metadata: Metadata = {
openGraph: {
type: "website",
locale: "fr_FR",
url: "https://memegoat.local",
url: "/",
siteName: "MemeGoat",
title: "MemeGoat | Partagez vos meilleurs mèmes",
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
@@ -71,8 +74,13 @@ export default function RootLayout({
disableTransitionOnChange
>
<AuthProvider>
{children}
<Toaster />
<SocketProvider>
<AudioProvider>
{children}
<NotificationHandler />
<Toaster />
</AudioProvider>
</SocketProvider>
</AuthProvider>
</ThemeProvider>
</body>

View File

@@ -10,14 +10,17 @@ import {
LayoutGrid,
LogIn,
LogOut,
MessageCircle,
PlusCircle,
Settings,
ShieldCheck,
TrendingUp,
User as UserIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import * as React from "react";
import { ModeToggle } from "@/components/mode-toggle";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -42,14 +45,19 @@ import {
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useAuth } from "@/providers/auth-provider";
import { useSocket } from "@/providers/socket-provider";
import { CategoryService } from "@/services/category.service";
import { MessageService } from "@/services/message.service";
import type { Category } from "@/types/content";
const mainNav = [
@@ -74,19 +82,74 @@ export function AppSidebar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const { user, logout, isAuthenticated } = useAuth();
const { socket } = useSocket();
const { resolvedTheme } = useTheme();
const [categories, setCategories] = React.useState<Category[]>([]);
const [mounted, setMounted] = React.useState(false);
const [unreadMessages, setUnreadMessages] = React.useState(0);
React.useEffect(() => {
setMounted(true);
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
// Gérer le compteur de messages non-lus
React.useEffect(() => {
if (isAuthenticated) {
MessageService.getUnreadCount().then(setUnreadMessages).catch(console.error);
}
}, [isAuthenticated]);
React.useEffect(() => {
if (socket && isAuthenticated) {
socket.on("new_message", () => {
// Incrémenter si on n'est pas sur la page messages
if (pathname !== "/messages") {
setUnreadMessages((prev) => prev + 1);
}
});
return () => {
socket.off("new_message");
};
}
}, [socket, isAuthenticated, pathname]);
// Remettre à zéro si on arrive sur la page messages
React.useEffect(() => {
if (pathname === "/messages") {
setUnreadMessages(0);
}
}, [pathname]);
const logoSrc = React.useMemo(() => {
if (!mounted) return "/memegoat-color.svg";
return resolvedTheme === "dark"
? "/memegoat-white.svg"
: "/memegoat-black.svg";
}, [resolvedTheme, mounted]);
return (
<Sidebar collapsible="icon">
<SidebarHeader className="flex items-center justify-center py-4">
<Link href="/" className="flex items-center gap-2 font-bold text-xl">
<div className="bg-primary text-primary-foreground p-1 rounded">🐐</div>
<span className="group-data-[collapsible=icon]:hidden">MemeGoat</span>
<SidebarHeader className="flex flex-row items-center justify-between py-4 group-data-[collapsible=icon]:justify-center">
<Link
href="/"
className="flex items-center gap-2 font-bold text-xl overflow-hidden"
>
<div className="p-1 rounded shrink-0">
<Image
src={logoSrc}
alt="MemeGoat Logo"
width={32}
height={32}
className="w-8 h-8"
/>
</div>
<span className="group-data-[collapsible=icon]:hidden whitespace-nowrap">
MemeGoat
</span>
</Link>
<SidebarTrigger className="hidden md:flex group-data-[collapsible=icon]:hidden" />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
@@ -152,6 +215,25 @@ export function AppSidebar() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{isAuthenticated && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/messages"}
tooltip="Messages"
>
<Link href="/messages">
<MessageCircle />
<span>Messages</span>
</Link>
</SidebarMenuButton>
{unreadMessages > 0 && (
<SidebarMenuBadge className="bg-red-500 text-white border-none h-5 min-w-5 flex items-center justify-center p-1 text-[10px]">
{unreadMessages > 9 ? "9+" : unreadMessages}
</SidebarMenuBadge>
)}
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
@@ -305,6 +387,7 @@ export function AppSidebar() {
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View File

@@ -0,0 +1,314 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale";
import { Heart, MoreHorizontal, Send, Trash2 } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useAuth } from "@/providers/auth-provider";
import { useSocket } from "@/providers/socket-provider";
import { type Comment, CommentService } from "@/services/comment.service";
interface CommentSectionProps {
contentId: string;
}
export function CommentSection({ contentId }: CommentSectionProps) {
const { user, isAuthenticated } = useAuth();
const { socket } = useSocket();
const [comments, setComments] = React.useState<Comment[]>([]);
const [newComment, setNewComment] = React.useState("");
const [replyingTo, setReplyingTo] = React.useState<Comment | null>(null);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const fetchComments = React.useCallback(async () => {
try {
const data = await CommentService.getByContentId(contentId);
setComments(data);
} catch (_error) {
toast.error("Impossible de charger les commentaires");
} finally {
setIsLoading(false);
}
}, [contentId]);
React.useEffect(() => {
fetchComments();
}, [fetchComments]);
// Gestion du WebSocket
React.useEffect(() => {
if (socket) {
socket.emit("join_content", contentId);
socket.on("new_comment", (comment: Comment) => {
setComments((prev) => {
// Éviter les doublons si l'auteur reçoit son propre commentaire via WS
if (prev.some((c) => c.id === comment.id)) return prev;
return [comment, ...prev];
});
});
return () => {
socket.emit("leave_content", contentId);
socket.off("new_comment");
};
}
}, [socket, contentId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
const comment = await CommentService.create(
contentId,
newComment.trim(),
replyingTo?.id,
);
setComments((prev) => [comment, ...prev]);
setNewComment("");
setReplyingTo(null);
toast.success("Commentaire publié !");
} catch (_error) {
toast.error("Erreur lors de la publication du commentaire");
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async (commentId: string) => {
try {
await CommentService.remove(commentId);
setComments((prev) => prev.filter((c) => c.id !== commentId));
toast.success("Commentaire supprimé");
} catch (_error) {
toast.error("Erreur lors de la suppression");
}
};
const handleLike = async (comment: Comment) => {
if (!isAuthenticated) {
toast.error("Vous devez être connecté pour liker");
return;
}
try {
if (comment.isLiked) {
await CommentService.unlike(comment.id);
setComments((prev) =>
prev.map((c) =>
c.id === comment.id
? { ...c, isLiked: false, likesCount: c.likesCount - 1 }
: c,
),
);
} else {
await CommentService.like(comment.id);
setComments((prev) =>
prev.map((c) =>
c.id === comment.id
? { ...c, isLiked: true, likesCount: c.likesCount + 1 }
: c,
),
);
}
} catch (_error) {
toast.error("Une erreur est survenue");
}
};
// Organiser les commentaires : Parents d'abord
const rootComments = comments.filter((c) => !c.parentId);
const renderComment = (comment: Comment, depth = 0) => {
const replies = comments.filter((c) => c.parentId === comment.id);
return (
<div key={comment.id} className={cn("space-y-4", depth > 0 && "ml-10")}>
<div className="flex gap-3">
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={comment.user.avatarUrl} />
<AvatarFallback>{comment.user.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-bold">
{comment.user.displayName || comment.user.username}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
locale: fr,
})}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
comment.isLiked && "text-red-500 hover:text-red-600",
)}
onClick={() => handleLike(comment)}
>
<Heart className={cn("h-4 w-4", comment.isLiked && "fill-current")} />
</Button>
{(user?.uuid === comment.user.uuid ||
user?.role === "admin" ||
user?.role === "moderator") && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDelete(comment.id)}
className="text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{comment.text}
</p>
<div className="flex items-center gap-4 pt-1">
{comment.likesCount > 0 && (
<span className="text-xs font-semibold text-muted-foreground">
{comment.likesCount} like{comment.likesCount > 1 ? "s" : ""}
</span>
)}
{isAuthenticated && depth < 1 && (
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs font-semibold text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={() => {
setReplyingTo(comment);
setNewComment(`@${comment.user.username} `);
document.querySelector("textarea")?.focus();
}}
>
Répondre
</Button>
)}
</div>
</div>
</div>
{replies.length > 0 && (
<div className="space-y-4 pt-2">
{replies.map((reply) => renderComment(reply, depth + 1))}
</div>
)}
</div>
);
};
return (
<div className="space-y-6 mt-8">
<div className="flex items-center justify-between">
<h3 className="font-bold text-lg">Commentaires ({comments.length})</h3>
</div>
{isAuthenticated ? (
<div className="space-y-2">
{replyingTo && (
<div className="flex items-center justify-between bg-zinc-100 dark:bg-zinc-800 px-3 py-1.5 rounded-lg text-xs">
<span className="text-muted-foreground">
En réponse à{" "}
<span className="font-bold">@{replyingTo.user.username}</span>
</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4"
onClick={() => {
setReplyingTo(null);
setNewComment("");
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-3">
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={user?.avatarUrl} />
<AvatarFallback>{user?.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<Textarea
placeholder={
replyingTo ? "Ajouter une réponse..." : "Ajouter un commentaire..."
}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[80px] resize-none"
/>
<div className="flex justify-end gap-2">
{replyingTo && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setReplyingTo(null);
setNewComment("");
}}
>
Annuler
</Button>
)}
<Button
type="submit"
size="sm"
disabled={!newComment.trim() || isSubmitting}
>
{isSubmitting ? "Envoi..." : replyingTo ? "Répondre" : "Publier"}
<Send className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</form>
</div>
) : (
<div className="bg-zinc-100 dark:bg-zinc-800 p-4 rounded-xl text-center text-sm">
Connectez-vous pour laisser un commentaire.
</div>
)}
<div className="space-y-6">
{isLoading ? (
<div className="text-center text-muted-foreground py-4">Chargement...</div>
) : rootComments.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
Aucun commentaire pour le moment. Soyez le premier !
</div>
) : (
rootComments.map((comment) => renderComment(comment))
)}
</div>
</div>
);
}

View File

@@ -1,13 +1,22 @@
"use client";
import { Edit, Eye, Heart, MoreHorizontal, Share2, Trash2 } from "lucide-react";
import {
Edit,
Eye,
Flag,
Heart,
MoreHorizontal,
Share2,
Trash2,
Volume2,
VolumeX,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import * as React from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -21,17 +30,14 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAudio } from "@/providers/audio-provider";
import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service";
import type { Content } from "@/types/content";
import { ShareDialog } from "./share-dialog";
import { UserContentEditDialog } from "./user-content-edit-dialog";
import { ViewCounter } from "./view-counter";
interface ContentCardProps {
content: Content;
@@ -40,12 +46,36 @@ interface ContentCardProps {
export function ContentCard({ content, onUpdate }: ContentCardProps) {
const { isAuthenticated, user } = useAuth();
const { isGlobalMuted, activeVideoId, toggleGlobalMute, setActiveVideo } =
useAudio();
const router = useRouter();
const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
const [editDialogOpen, setEditDialogOpen] = React.useState(false);
const [shareDialogOpen, setShareDialogOpen] = React.useState(false);
const [_reportDialogOpen, setReportDialogOpen] = React.useState(false);
const isAuthor = user?.uuid === content.authorId;
const isVideo = !content.mimeType.startsWith("image/");
const isThisVideoActive = activeVideoId === content.id;
const isMuted = isGlobalMuted || (isVideo && !isThisVideoActive);
const videoRef = React.useRef<HTMLVideoElement>(null);
React.useEffect(() => {
if (videoRef.current) {
if (isThisVideoActive) {
const playPromise = videoRef.current.play();
if (playPromise !== undefined) {
playPromise.catch((_error) => {
// L'auto-lecture peut échouer si l'utilisateur n'a pas interagi avec la page
// On peut tenter de mettre en sourdine pour forcer la lecture si nécessaire
});
}
} else {
videoRef.current.pause();
}
}
}, [isThisVideoActive]);
React.useEffect(() => {
setIsLiked(content.isLiked || false);
@@ -71,12 +101,26 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
await FavoriteService.add(content.id);
setIsLiked(true);
setLikesCount((prev) => prev + 1);
// Considérer un like comme une vue
ContentService.incrementViews(content.id).catch(() => {});
}
} catch (_error) {
toast.error("Une erreur est survenue");
}
};
const handleToggleMute = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isGlobalMuted) {
setActiveVideo(content.id);
} else if (isThisVideoActive) {
toggleGlobalMute();
} else {
setActiveVideo(content.id);
}
};
const handleUse = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -107,9 +151,10 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
return (
<>
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8">
<ViewCounter contentId={content.id} videoRef={videoRef} />
<Card className="overflow-hidden border-none gap-0 shadow-none bg-transparent">
<CardHeader className="p-3 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8 border">
<AvatarImage src={content.author.avatarUrl} />
<AvatarFallback>
{content.author.username[0].toUpperCase()}
@@ -118,13 +163,10 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
<div className="flex flex-col">
<Link
href={`/user/${content.author.username}`}
className="text-sm font-semibold hover:underline"
className="text-sm font-bold hover:underline"
>
{content.author.displayName || content.author.username}
{content.author.username}
</Link>
<span className="text-xs text-muted-foreground">
{new Date(content.createdAt).toLocaleDateString("fr-FR")}
</span>
</div>
<div className="ml-auto flex items-center gap-1">
@@ -150,123 +192,138 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => toast.success("Lien copié !")}>
<DropdownMenuItem
onClick={() => {
if (!isAuthenticated) {
toast.error("Connectez-vous pour partager");
return;
}
setShareDialogOpen(true);
}}
>
<Share2 className="h-4 w-4 mr-2" />
Partager
</DropdownMenuItem>
{!isAuthor && (
<DropdownMenuItem onClick={() => setReportDialogOpen(true)}>
<Flag className="h-4 w-4 mr-2" />
Signaler
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 aspect-square sm:aspect-video md:aspect-square flex items-center justify-center">
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 flex items-center justify-center overflow-hidden aspect-square w-full">
<Link
href={`/meme/${content.slug}`}
className="w-full h-full block relative"
>
{content.mimeType.startsWith("image/") ? (
<Image
src={content.url}
alt={content.title}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
width={content.width || 1000}
height={content.height || 1000}
className="w-full h-full object-contain"
priority={false}
/>
) : (
<video
ref={videoRef}
src={content.url}
controls={false}
autoPlay
muted
autoPlay={isThisVideoActive}
muted={isMuted}
loop
playsInline
className="w-full h-full object-contain"
/>
)}
</Link>
{isVideo && (
<Button
variant="ghost"
size="icon"
className="absolute bottom-2 right-2 h-8 w-8 rounded-full bg-black/50 text-white hover:bg-black/70 hover:text-white"
onClick={handleToggleMute}
>
{isMuted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
)}
</CardContent>
<CardFooter className="p-4 flex flex-col gap-4">
<CardFooter className="p-3 flex flex-col items-start gap-2">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={`gap-1.5 h-8 px-2 ${isLiked ? "text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20" : ""}`}
onClick={handleLike}
>
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} />
<span className="text-xs font-medium">{likesCount}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Liker</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2 cursor-default"
>
<Eye className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">{content.views}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Vues</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/meme/${content.slug}`,
);
toast.success("Lien copié !");
}}
>
<Share2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Partager</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-4">
<button
type="button"
onClick={handleLike}
className={`transition-transform active:scale-125 ${isLiked ? "text-red-500" : "hover:text-muted-foreground"}`}
>
<Heart className={`h-6 w-6 ${isLiked ? "fill-current" : ""}`} />
</button>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Eye className="h-6 w-6" />
<span className="text-sm font-medium">{content.views}</span>
</div>
<button
type="button"
onClick={() => {
if (!isAuthenticated) {
toast.error("Connectez-vous pour partager");
return;
}
setShareDialogOpen(true);
}}
className="hover:text-muted-foreground"
>
<Share2 className="h-6 w-6" />
</button>
</div>
<Button
size="sm"
variant="secondary"
className="text-xs h-8 font-semibold"
className="text-xs h-8 font-semibold rounded-full px-4"
onClick={handleUse}
>
Utiliser
</Button>
</div>
<div className="w-full space-y-2">
<Link href={`/meme/${content.slug}`}>
<h3 className="font-semibold text-base line-clamp-2 hover:text-primary transition-colors">
<div className="space-y-1">
<p className="text-sm font-bold">{likesCount} J'aime</p>
<div className="text-sm leading-snug">
<Link
href={`/user/${content.author.username}`}
className="font-bold mr-2 hover:underline"
>
{content.author.username}
</Link>
<Link href={`/meme/${content.slug}`} className="break-words">
{content.title}
</h3>
</Link>
<div className="flex flex-wrap gap-1.5">
{content.category && (
<Badge variant="outline" className="text-[10px] py-0 px-2 bg-muted/50">
{content.category.name}
</Badge>
)}
{content.tags.slice(0, 3).map((tag, _i) => (
<Badge
</Link>
</div>
<div className="flex flex-wrap gap-1 mt-1">
{content.tags.slice(0, 5).map((tag, _i) => (
<Link
key={typeof tag === "string" ? tag : tag.id}
variant="secondary"
className="text-[10px] py-0 px-2"
href={`/?tag=${typeof tag === "string" ? tag : tag.slug}`}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
#{typeof tag === "string" ? tag : tag.name}
</Badge>
</Link>
))}
</div>
<p className="text-[10px] text-muted-foreground uppercase mt-1">
{new Date(content.createdAt).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
})}
</p>
</div>
</CardFooter>
</Card>
@@ -276,6 +333,13 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
onOpenChange={setEditDialogOpen}
onSuccess={() => onUpdate?.()}
/>
<ShareDialog
contentId={content.id}
contentTitle={content.title}
contentUrl={`${typeof window !== "undefined" ? window.location.origin : ""}/meme/${content.slug}`}
open={shareDialogOpen}
onOpenChange={setShareDialogOpen}
/>
</>
);
}

View File

@@ -4,6 +4,7 @@ import * as React from "react";
import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll";
import { ContentCard } from "@/components/content-card";
import { Spinner } from "@/components/ui/spinner";
import { useAudio } from "@/providers/audio-provider";
import type { Content, PaginatedResponse } from "@/types/content";
interface ContentListProps {
@@ -15,10 +16,48 @@ interface ContentListProps {
}
export function ContentList({ fetchFn, title }: ContentListProps) {
const { setActiveVideo } = useAudio();
const [contents, setContents] = React.useState<Content[]>([]);
const [loading, setLoading] = React.useState(true);
const [offset, setOffset] = React.useState(0);
const [hasMore, setHasMore] = React.useState(true);
const containerRef = React.useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: On a besoin de contents pour ré-attacher l'observer quand la liste change
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
// On cherche l'entrée avec le plus grand ratio d'intersection parmi celles qui dépassent le seuil
let bestEntry: IntersectionObserverEntry | null = null;
let maxRatio = 0;
for (const entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
maxRatio = entry.intersectionRatio;
bestEntry = entry;
}
}
if (bestEntry && maxRatio >= 0.6) {
const contentId = bestEntry.target.getAttribute("data-content-id");
if (contentId) {
setActiveVideo(contentId);
}
}
},
{
threshold: [0, 0.2, 0.4, 0.6, 0.8, 1.0], // Plusieurs seuils pour une détection plus fine du "meilleur"
root: containerRef.current,
},
);
const elements = containerRef.current?.querySelectorAll("[data-content-id]");
elements?.forEach((el) => {
observer.observe(el);
});
return () => observer.disconnect();
}, [setActiveVideo, contents]);
const fetchInitial = React.useCallback(async () => {
setLoading(true);
@@ -51,7 +90,11 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
offset: offset + 10,
});
setContents((prev) => [...prev, ...response.data]);
setContents((prev) => {
const newIds = new Set(response.data.map((item) => item.id));
const filteredPrev = prev.filter((item) => !newIds.has(item.id));
return [...filteredPrev, ...response.data];
});
setOffset((prev) => prev + 10);
setHasMore(response.data.length === 10);
} catch (error) {
@@ -68,11 +111,20 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
});
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
{title && <h1 className="text-2xl font-bold">{title}</h1>}
<div className="flex flex-col gap-6">
<div
ref={containerRef}
className="mx-auto px-4 h-screen flex flex-col justify-start items-center overflow-y-auto snap-y snap-mandatory no-scrollbar"
>
{title && <h1 className="text-2xl font-bold py-8">{title}</h1>}
<div className="max-w-xl flex flex-col justify-start items-center">
{contents.map((content) => (
<ContentCard key={content.id} content={content} onUpdate={fetchInitial} />
<div
key={content.id}
data-content-id={content.id}
className="w-full snap-start snap-always py-4"
>
<ContentCard content={content} onUpdate={fetchInitial} />
</div>
))}
</div>

View File

@@ -0,0 +1,108 @@
"use client";
import { Bell, Heart, MessageCircle, Reply } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { toast } from "sonner";
import { useSocket } from "@/providers/socket-provider";
interface NotificationData {
type: "comment" | "reply" | "like_comment" | "message";
userId: string;
username: string;
contentId?: string;
commentId?: string;
text: string;
}
export function NotificationHandler() {
const { socket } = useSocket();
const router = useRouter();
React.useEffect(() => {
if (!socket) return;
const handleNotification = (data: NotificationData) => {
// Ne pas afficher de toast si on est déjà sur la page des messages pour un nouveau message
if (data.type === "message" && window.location.pathname === "/messages") {
return;
}
toast.custom(
(t) => (
<button
type="button"
className="flex items-start gap-3 bg-white dark:bg-zinc-900 p-4 rounded-xl shadow-lg border border-zinc-200 dark:border-zinc-800 w-full max-w-sm cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors text-left"
onClick={() => {
toast.dismiss(t);
if (data.type === "message") {
router.push("/messages");
} else if (data.contentId) {
router.push(`/meme/${data.contentId}`);
}
}}
>
<div className="bg-primary/10 p-2 rounded-full shrink-0">
{data.type === "comment" && (
<MessageCircle className="h-4 w-4 text-primary" />
)}
{data.type === "reply" && <Reply className="h-4 w-4 text-primary" />}
{data.type === "like_comment" && (
<Heart className="h-4 w-4 text-red-500" />
)}
{data.type === "message" && (
<MessageCircle className="h-4 w-4 text-primary" />
)}
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-bold">@{data.username}</p>
<p className="text-xs text-muted-foreground truncate">{data.text}</p>
</div>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={(e) => {
e.stopPropagation();
toast.dismiss(t);
}}
>
<Bell className="h-3 w-3" />
</button>
</button>
),
{
duration: 5000,
position: "top-right",
},
);
};
socket.on("notification", handleNotification);
// Aussi pour les nouveaux messages (si on veut un toast global)
socket.on(
"new_message",
(data: { message: { text: string; sender?: { username: string } } }) => {
if (window.location.pathname !== "/messages") {
toast(
`Nouveau message de @${data.message.sender?.username || "un membre"}`,
{
description: data.message.text.substring(0, 50),
action: {
label: "Voir",
onClick: () => router.push("/messages"),
},
},
);
}
},
);
return () => {
socket.off("notification");
socket.off("new_message");
};
}, [socket, router]);
return null;
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ReportReason, ReportService } from "@/services/report.service";
interface ReportDialogProps {
contentId?: string;
tagId?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ReportDialog({
contentId,
tagId,
open,
onOpenChange,
}: ReportDialogProps) {
const [reason, setReason] = useState<ReportReason>(ReportReason.INAPPROPRIATE);
const [description, setDescription] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await ReportService.create({
contentId,
tagId,
reason,
description,
});
toast.success(
"Signalement envoyé avec succès. Merci de nous aider à maintenir la communauté sûre.",
);
onOpenChange(false);
setDescription("");
} catch (_error) {
toast.error("Erreur lors de l'envoi du signalement.");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Signaler le contenu</DialogTitle>
<DialogDescription>
Pourquoi signalez-vous ce contenu ? Un modérateur examinera votre demande.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="reason">Raison</Label>
<Select
value={reason}
onValueChange={(value) => setReason(value as ReportReason)}
>
<SelectTrigger id="reason">
<SelectValue placeholder="Sélectionnez une raison" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ReportReason.INAPPROPRIATE}>Inapproprié</SelectItem>
<SelectItem value={ReportReason.SPAM}>Spam</SelectItem>
<SelectItem value={ReportReason.COPYRIGHT}>Droit d'auteur</SelectItem>
<SelectItem value={ReportReason.OTHER}>Autre</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description (optionnelle)</Label>
<Textarea
id="description"
placeholder="Détaillez votre signalement..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Annuler
</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? "Envoi..." : "Signaler"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,12 +1,18 @@
"use client";
import { Filter, Search } from "lucide-react";
import { ChevronLeft, ChevronRight, Filter, Search } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CategoryService } from "@/services/category.service";
import { TagService } from "@/services/tag.service";
import type { Category, Tag } from "@/types/content";
@@ -16,10 +22,24 @@ export function SearchSidebar() {
const searchParams = useSearchParams();
const pathname = usePathname();
const [isCollapsed, setIsCollapsed] = React.useState(true);
const [categories, setCategories] = React.useState<Category[]>([]);
const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
const [query, setQuery] = React.useState(searchParams.get("query") || "");
// Ouvrir automatiquement si des filtres sont actifs
React.useEffect(() => {
const hasFilters =
searchParams.has("query") ||
searchParams.has("category") ||
searchParams.has("tag") ||
searchParams.get("sort") !== "trend";
if (hasFilters) {
setIsCollapsed(false);
}
}, [searchParams]);
React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
TagService.getAll({ limit: 10, sort: "popular" })
@@ -51,98 +71,149 @@ export function SearchSidebar() {
const currentCategory = searchParams.get("category");
return (
<aside className="hidden lg:flex flex-col w-80 border-l bg-background">
<div className="p-4 border-b">
<h2 className="font-semibold mb-4">Rechercher</h2>
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des mèmes..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<Filter className="h-4 w-4" />
Filtres
</h3>
<div className="space-y-4">
<aside
className={`hidden lg:flex flex-col border-l bg-background transition-all duration-300 relative ${
isCollapsed ? "w-12" : "w-80"
}`}
>
<Button
variant="outline"
size="icon"
className="absolute -left-4 top-20 h-8 w-8 rounded-full bg-background shadow-md z-50 hover:bg-accent"
onClick={() => setIsCollapsed(!isCollapsed)}
>
{isCollapsed ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
{isCollapsed ? (
<div className="flex flex-col items-center py-4 gap-4 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(false)}
>
<Search className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Rechercher</TooltipContent>
</Tooltip>
<Separator />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(false)}
>
<Filter className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Filtres</TooltipContent>
</Tooltip>
</div>
) : (
<>
<div className="p-4 border-b">
<h2 className="font-semibold mb-4">Rechercher</h2>
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des mèmes..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
<div>
<p className="text-xs text-muted-foreground mb-2">Trier par</p>
<div className="flex flex-wrap gap-2">
<Badge
variant={currentSort === "trend" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "trend")}
>
Tendances
</Badge>
<Badge
variant={currentSort === "recent" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "recent")}
>
Récent
</Badge>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<Filter className="h-4 w-4" />
Filtres
</h3>
<div className="space-y-4">
<div>
<p className="text-xs text-muted-foreground mb-2">Trier par</p>
<div className="flex flex-wrap gap-2">
<Badge
variant={currentSort === "trend" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "trend")}
>
Tendances
</Badge>
<Badge
variant={currentSort === "recent" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "recent")}
>
Récent
</Badge>
</div>
</div>
<Separator />
<div>
<p className="text-xs text-muted-foreground mb-2">Catégories</p>
<div className="flex flex-wrap gap-2">
<Badge
variant={!currentCategory ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", null)}
>
Tout
</Badge>
{categories.map((cat) => (
<Badge
key={cat.id}
variant={currentCategory === cat.slug ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", cat.slug)}
>
{cat.name}
</Badge>
))}
</div>
</div>
</div>
</div>
<Separator />
<div>
<p className="text-xs text-muted-foreground mb-2">Catégories</p>
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2">
<Badge
variant={!currentCategory ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", null)}
>
Tout
</Badge>
{categories.map((cat) => (
{popularTags.map((tag) => (
<Badge
key={cat.id}
variant={currentCategory === cat.slug ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", cat.slug)}
key={tag.id}
variant={
searchParams.get("tag") === tag.name ? "default" : "outline"
}
className="cursor-pointer hover:bg-secondary"
onClick={() =>
updateSearch(
"tag",
searchParams.get("tag") === tag.name ? null : tag.name,
)
}
>
{cat.name}
#{tag.name}
</Badge>
))}
{popularTags.length === 0 && (
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
)}
</div>
</div>
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2">
{popularTags.map((tag) => (
<Badge
key={tag.id}
variant={searchParams.get("tag") === tag.name ? "default" : "outline"}
className="cursor-pointer hover:bg-secondary"
onClick={() =>
updateSearch(
"tag",
searchParams.get("tag") === tag.name ? null : tag.name,
)
}
>
#{tag.name}
</Badge>
))}
{popularTags.length === 0 && (
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
)}
</div>
</div>
</div>
</ScrollArea>
</ScrollArea>
</>
)}
</aside>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { Search, Send, X } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageService } from "@/services/message.service";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
interface ShareDialogProps {
contentId: string;
contentTitle: string;
contentUrl: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ShareDialog({
contentId,
contentTitle,
contentUrl: _unused, // Support legacy prop
open,
onOpenChange,
}: ShareDialogProps) {
const [searchQuery, setSearchQuery] = React.useState("");
const [results, setResults] = React.useState<User[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [sendingTo, setSendingTo] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) {
setSearchQuery("");
setResults([]);
return;
}
const fetchInitial = async () => {
setIsLoading(true);
try {
// Par défaut, montrer les conversations récentes ou suggérer des gens
const recent = await UserService.search("");
setResults(recent);
} catch (error) {
console.error("Failed to fetch users", error);
} finally {
setIsLoading(false);
}
};
fetchInitial();
}, [open]);
React.useEffect(() => {
if (searchQuery.length < 2) return;
const timeout = setTimeout(async () => {
setIsLoading(true);
try {
const data = await UserService.search(searchQuery);
setResults(data);
} catch (error) {
console.error("Search failed", error);
} finally {
setIsLoading(false);
}
}, 300);
return () => clearTimeout(timeout);
}, [searchQuery]);
const handleShare = async (user: User) => {
setSendingTo(user.uuid);
try {
const shareUrl = `${window.location.origin}/meme/${contentId}`;
await MessageService.sendMessage(
user.uuid,
`Regarde ce mème : ${contentTitle}\n${shareUrl}`,
);
toast.success(`Partagé avec @${user.username}`);
onOpenChange(false);
} catch (_error) {
toast.error("Échec du partage");
} finally {
setSendingTo(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px] p-0 gap-0 overflow-hidden">
<DialogHeader className="p-4 border-b">
<DialogTitle>Partager avec</DialogTitle>
</DialogHeader>
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un membre..."
className="pl-9 h-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-full"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
</div>
<ScrollArea className="h-[300px]">
<div className="p-2 space-y-1">
{isLoading && results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Chargement...
</div>
) : results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Aucun membre trouvé.
</div>
) : (
results.map((user) => (
<div
key={user.uuid}
className="flex items-center justify-between p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-900"
>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback>{user.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-bold leading-none">
{user.displayName || user.username}
</span>
<span className="text-xs text-muted-foreground">
@{user.username}
</span>
</div>
</div>
<Button
size="sm"
variant={sendingTo === user.uuid ? "outline" : "default"}
disabled={sendingTo !== null}
onClick={() => handleShare(user)}
className="h-8 px-4 rounded-full"
>
{sendingTo === user.uuid ? "Envoi..." : "Envoyer"}
</Button>
</div>
))
)}
</div>
</ScrollArea>
<div className="p-4 border-t bg-zinc-50 dark:bg-zinc-900/50">
<Button
variant="outline"
className="w-full justify-start gap-2 h-10 rounded-xl"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/meme/${contentId}`,
);
toast.success("Lien copié !");
onOpenChange(false);
}}
>
<Send className="h-4 w-4" />
Copier le lien
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,294 @@
"use client";
import { Loader2, Shield, ShieldAlert, ShieldCheck } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { useAuth } from "@/providers/auth-provider";
import { AuthService } from "@/services/auth.service";
export function TwoFactorSetup() {
const { user, refreshUser } = useAuth();
const [step, setStep] = useState<"idle" | "setup" | "verify">("idle");
const [qrCode, setQrCode] = useState<string | null>(null);
const [secret, setSecret] = useState<string | null>(null);
const [otpValue, setOtpValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isRevealed, setIsRevealed] = useState(false);
const handleSetup = async () => {
setIsLoading(true);
try {
const data = await AuthService.setup2fa();
setQrCode(data.qrCodeUrl);
setSecret(data.secret);
setStep("setup");
} catch (_error) {
toast.error("Erreur lors de la configuration de la 2FA.");
} finally {
setIsLoading(false);
}
};
const handleEnable = async () => {
if (otpValue.length !== 6) return;
setIsLoading(true);
try {
await AuthService.enable2fa(otpValue);
toast.success("Double authentification activée !");
await refreshUser();
setStep("idle");
setOtpValue("");
} catch (_error) {
toast.error("Code invalide. Veuillez réessayer.");
} finally {
setIsLoading(false);
}
};
const handleDisable = async () => {
if (otpValue.length !== 6) return;
setIsLoading(true);
try {
await AuthService.disable2fa(otpValue);
toast.success("Double authentification désactivée.");
await refreshUser();
setStep("idle");
setOtpValue("");
} catch (_error) {
toast.error("Code invalide. Veuillez réessayer.");
} finally {
setIsLoading(false);
}
};
// Note: We need a way to know if 2FA is enabled.
const isEnabled = user?.twoFactorEnabled;
if (step === "idle") {
return (
<Card className="border-none shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center gap-2 mb-1">
<Shield className="h-5 w-5 text-primary" />
<CardTitle>Double Authentification (2FA)</CardTitle>
</div>
<CardDescription>
Ajoutez une couche de sécurité supplémentaire à votre compte en utilisant
une application d'authentification.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 p-4 rounded-lg bg-zinc-50 dark:bg-zinc-900 border">
{isEnabled ? (
<>
<div className="bg-green-100 dark:bg-green-900/30 p-2 rounded-full">
<ShieldCheck className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<p className="font-bold">La 2FA est activée</p>
<p className="text-sm text-muted-foreground">
Votre compte est protégé par un code temporaire.
</p>
</div>
<Button variant="outline" size="sm" onClick={() => setStep("verify")}>
Désactiver
</Button>
</>
) : (
<>
<div className="bg-zinc-200 dark:bg-zinc-800 p-2 rounded-full">
<ShieldAlert className="h-6 w-6 text-zinc-500" />
</div>
<div className="flex-1">
<p className="font-bold">La 2FA n'est pas activée</p>
<p className="text-sm text-muted-foreground">
Activez la 2FA pour mieux protéger votre compte.
</p>
</div>
<Button
variant="default"
size="sm"
onClick={handleSetup}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Configurer"
)}
</Button>
</>
)}
</div>
</CardContent>
</Card>
);
}
if (step === "setup") {
return (
<Card className="border-none shadow-sm">
<CardHeader>
<CardTitle>Configurer la 2FA</CardTitle>
<CardDescription>
Scannez le QR Code ci-dessous avec votre application d'authentification
(Google Authenticator, Authy, etc.).
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-6">
{qrCode && (
<div className="relative group">
<div
className={`bg-white p-4 rounded-xl border-4 border-zinc-100 transition-all duration-300 ${
!isRevealed ? "blur-md select-none" : ""
}`}
>
<Image
src={qrCode}
alt="QR Code 2FA"
width={192}
height={192}
className="w-48 h-48"
unoptimized
/>
</div>
{!isRevealed && (
<div className="absolute inset-0 flex items-center justify-center">
<Button
variant="secondary"
size="sm"
onClick={() => setIsRevealed(true)}
className="shadow-lg"
>
Afficher le QR Code
</Button>
</div>
)}
</div>
)}
<div className="w-full space-y-2">
<p className="text-sm font-medium text-center">
Ou entrez ce code manuellement :
</p>
<div className="relative group">
<code
className={`block p-2 bg-muted text-center rounded text-xs font-mono break-all transition-all duration-300 ${
!isRevealed ? "blur-[3px] select-none" : ""
}`}
>
{secret}
</code>
{!isRevealed && (
<div className="absolute inset-0 flex items-center justify-center">
<button
type="button"
onClick={() => setIsRevealed(true)}
className="text-[10px] font-bold uppercase tracking-wider text-primary hover:underline"
>
Afficher le code
</button>
</div>
)}
</div>
</div>
<div className="flex flex-col items-center gap-4 w-full border-t pt-6">
<p className="text-sm font-medium">
Entrez le code à 6 chiffres pour confirmer :
</p>
<InputOTP maxLength={6} value={otpValue} onChange={setOtpValue}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="ghost" onClick={() => setStep("idle")}>
Annuler
</Button>
<Button
onClick={handleEnable}
disabled={otpValue.length !== 6 || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Activer la 2FA"
)}
</Button>
</CardFooter>
</Card>
);
}
if (step === "verify") {
return (
<Card className="border-none shadow-sm">
<CardHeader>
<CardTitle>Désactiver la 2FA</CardTitle>
<CardDescription>
Veuillez entrer le code de votre application pour désactiver la double
authentification.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-6">
<InputOTP maxLength={6} value={otpValue} onChange={setOtpValue}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="ghost" onClick={() => setStep("idle")}>
Annuler
</Button>
<Button
variant="destructive"
onClick={handleDisable}
disabled={otpValue.length !== 6 || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Confirmer la désactivation"
)}
</Button>
</CardFooter>
</Card>
);
}
return null;
}

View File

@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
function Avatar({
className,
isOnline,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
}: React.ComponentProps<typeof AvatarPrimitive.Root> & { isOnline?: boolean }) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
<div className="relative inline-block">
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
{isOnline && (
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-white dark:ring-zinc-900" />
)}
{...props}
/>
</div>
);
}

View File

@@ -8,8 +8,11 @@ import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
viewportRef,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
viewportRef?: React.Ref<HTMLDivElement>;
}) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
@@ -18,6 +21,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
ref={viewportRef}
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}

View File

@@ -1,23 +1,74 @@
"use client";
import { useEffect, useRef } from "react";
import { type RefObject, useEffect, useRef } from "react";
import { ContentService } from "@/services/content.service";
interface ViewCounterProps {
contentId: string;
videoRef?: RefObject<HTMLVideoElement | null>;
}
export function ViewCounter({ contentId }: ViewCounterProps) {
export function ViewCounter({ contentId, videoRef }: ViewCounterProps) {
const hasIncremented = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!hasIncremented.current) {
ContentService.incrementViews(contentId).catch((err) => {
console.error("Failed to increment views:", err);
});
hasIncremented.current = true;
}
}, [contentId]);
const increment = () => {
if (!hasIncremented.current) {
ContentService.incrementViews(contentId).catch((err) => {
console.error("Failed to increment views:", err);
});
hasIncremented.current = true;
}
};
return null;
// 1. Observer pour la visibilité (IntersectionObserver)
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
// Si c'est une image (pas de videoRef), on attend 3 secondes
if (!videoRef) {
const timer = setTimeout(() => {
increment();
}, 3000);
return () => clearTimeout(timer);
}
}
},
{ threshold: 0.5 },
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
// 2. Logique pour la vidéo (> 50%)
let videoElement: HTMLVideoElement | null = null;
const handleTimeUpdate = () => {
if (videoElement && videoElement.duration > 0) {
const progress = videoElement.currentTime / videoElement.duration;
if (progress >= 0.5) {
increment();
videoElement.removeEventListener("timeupdate", handleTimeUpdate);
}
}
};
if (videoRef?.current) {
videoElement = videoRef.current;
videoElement.addEventListener("timeupdate", handleTimeUpdate);
}
return () => {
observer.disconnect();
if (videoElement) {
videoElement.removeEventListener("timeupdate", handleTimeUpdate);
}
};
}, [contentId, videoRef]);
return (
<div ref={containerRef} className="absolute inset-0 pointer-events-none" />
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import type React from "react";
import { createContext, useCallback, useContext, useState } from "react";
interface AudioContextType {
isGlobalMuted: boolean;
activeVideoId: string | null;
toggleGlobalMute: () => void;
setActiveVideo: (id: string | null) => void;
}
const AudioContext = createContext<AudioContextType | undefined>(undefined);
export function AudioProvider({ children }: { children: React.ReactNode }) {
const [isGlobalMuted, setIsGlobalMuted] = useState(true);
const [activeVideoId, setActiveVideoId] = useState<string | null>(null);
const toggleGlobalMute = useCallback(() => {
setIsGlobalMuted((prev) => !prev);
}, []);
const setActiveVideo = useCallback((id: string | null) => {
setActiveVideoId(id);
if (id !== null) {
setIsGlobalMuted(false);
}
}, []);
return (
<AudioContext.Provider
value={{ isGlobalMuted, activeVideoId, toggleGlobalMute, setActiveVideo }}
>
{children}
</AudioContext.Provider>
);
}
export function useAudio() {
const context = useContext(AudioContext);
if (context === undefined) {
throw new Error("useAudio must be used within an AudioProvider");
}
return context;
}

View File

@@ -5,14 +5,15 @@ import * as React from "react";
import { toast } from "sonner";
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
import type { RegisterPayload } from "@/types/auth";
import type { LoginResponse, RegisterPayload } from "@/types/auth";
import type { User } from "@/types/user";
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
login: (email: string, password: string) => Promise<LoginResponse>;
verify2fa: (userId: string, token: string) => Promise<void>;
register: (payload: RegisterPayload) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
@@ -59,12 +60,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = async (email: string, password: string) => {
try {
await AuthService.login(email, password);
const response = await AuthService.login(email, password);
if (response.userId && response.message === "Please provide 2FA token") {
return response;
}
await refreshUser();
toast.success("Connexion réussie !");
router.push("/");
return response;
} catch (error: unknown) {
let errorMessage = "Erreur de connexion";
if (
error &&
typeof error === "object" &&
"response" in error &&
error.response &&
typeof error.response === "object" &&
"data" in error.response &&
error.response.data &&
typeof error.response.data === "object" &&
"message" in error.response.data &&
typeof error.response.data.message === "string"
) {
errorMessage = error.response.data.message;
}
toast.error(errorMessage);
throw error;
}
};
const verify2fa = async (userId: string, token: string) => {
try {
await AuthService.verify2fa(userId, token);
await refreshUser();
toast.success("Connexion réussie !");
router.push("/");
} catch (error: unknown) {
let errorMessage = "Erreur de connexion";
let errorMessage = "Code 2FA invalide";
if (
error &&
typeof error === "object" &&
@@ -130,6 +162,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
isLoading,
isAuthenticated: !!user,
login,
verify2fa,
register,
logout,
refreshUser,

View File

@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import { io, type Socket } from "socket.io-client";
import { useAuth } from "./auth-provider";
interface SocketContextType {
socket: Socket | null;
isConnected: boolean;
}
const SocketContext = React.createContext<SocketContextType>({
socket: null,
isConnected: false,
});
export const useSocket = () => React.useContext(SocketContext);
export function SocketProvider({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const [socket, setSocket] = React.useState<Socket | null>(null);
const [isConnected, setIsConnected] = React.useState(false);
React.useEffect(() => {
if (isAuthenticated) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
// Initialisation du socket avec configuration optimisée pour la production
const socketInstance = io(apiUrl, {
withCredentials: true,
transports: ["websocket"], // Recommandé pour éviter les problèmes de sticky sessions
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
socketInstance.on("connect", () => {
console.log("WebSocket connected to:", apiUrl);
setIsConnected(true);
});
socketInstance.on("connect_error", (error) => {
console.error("WebSocket connection error:", error);
// Si le websocket pur échoue, on peut tenter le polling en fallback (optionnel)
});
socketInstance.on("disconnect", () => {
setIsConnected(false);
});
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
} else {
setSocket(null);
setIsConnected(false);
}
}, [isAuthenticated]);
return (
<SocketContext.Provider value={{ socket, isConnected }}>
{children}
</SocketContext.Provider>
);
}

View File

@@ -1,4 +1,6 @@
import api from "@/lib/api";
import type { User } from "@/types/user";
import type { Report, ReportStatus } from "./report.service";
export interface AdminStats {
users: number;
@@ -11,4 +13,24 @@ export const adminService = {
const response = await api.get("/admin/stats");
return response.data;
},
getReports: async (limit = 10, offset = 0): Promise<Report[]> => {
const response = await api.get("/reports", { params: { limit, offset } });
return response.data;
},
updateReportStatus: async (
reportId: string,
status: ReportStatus,
): Promise<void> => {
await api.patch(`/reports/${reportId}/status`, { status });
},
deleteUser: async (userId: string): Promise<void> => {
await api.delete(`/users/${userId}`);
},
updateUser: async (userId: string, data: Partial<User>): Promise<void> => {
await api.patch(`/users/admin/${userId}`, data);
},
};

View File

@@ -1,5 +1,9 @@
import api from "@/lib/api";
import type { LoginResponse, RegisterPayload } from "@/types/auth";
import type {
LoginResponse,
RegisterPayload,
TwoFactorSetupResponse,
} from "@/types/auth";
export const AuthService = {
async login(email: string, password: string): Promise<LoginResponse> {
@@ -10,6 +14,14 @@ export const AuthService = {
return data;
},
async verify2fa(userId: string, token: string): Promise<LoginResponse> {
const { data } = await api.post<LoginResponse>("/auth/verify-2fa", {
userId,
token,
});
return data;
},
async register(payload: RegisterPayload): Promise<void> {
await api.post("/auth/register", payload);
},
@@ -21,4 +33,19 @@ export const AuthService = {
async refresh(): Promise<void> {
await api.post("/auth/refresh");
},
async setup2fa(): Promise<TwoFactorSetupResponse> {
const { data } = await api.post<TwoFactorSetupResponse>(
"/users/me/2fa/setup",
);
return data;
},
async enable2fa(token: string): Promise<void> {
await api.post("/users/me/2fa/enable", { token });
},
async disable2fa(token: string): Promise<void> {
await api.post("/users/me/2fa/disable", { token });
},
};

View File

@@ -0,0 +1,48 @@
import api from "@/lib/api";
export interface Comment {
id: string;
text: string;
parentId?: string;
likesCount: number;
isLiked: boolean;
createdAt: string;
updatedAt: string;
user: {
uuid: string;
username: string;
displayName?: string;
avatarUrl?: string;
};
}
export const CommentService = {
async getByContentId(contentId: string): Promise<Comment[]> {
const { data } = await api.get<Comment[]>(`/contents/${contentId}/comments`);
return data;
},
async create(
contentId: string,
text: string,
parentId?: string,
): Promise<Comment> {
const { data } = await api.post<Comment>(`/contents/${contentId}/comments`, {
text,
parentId,
});
return data;
},
async remove(commentId: string): Promise<void> {
await api.delete(`/comments/${commentId}`);
},
async like(commentId: string): Promise<void> {
await api.post(`/comments/${commentId}/like`);
},
async unlike(commentId: string): Promise<void> {
await api.delete(`/comments/${commentId}/like`);
},
};

View File

@@ -0,0 +1,62 @@
import api from "@/lib/api";
export interface Conversation {
id: string;
updatedAt: string;
lastMessage?: {
text: string;
createdAt: string;
};
recipient: {
uuid: string;
username: string;
displayName?: string;
avatarUrl?: string;
};
}
export interface Message {
id: string;
text: string;
createdAt: string;
senderId: string;
readAt?: string;
}
export const MessageService = {
async getConversations(): Promise<Conversation[]> {
const { data } = await api.get<Conversation[]>("/messages/conversations");
return data;
},
async getUnreadCount(): Promise<number> {
const { data } = await api.get<number>("/messages/unread-count");
return data;
},
async getMessages(conversationId: string): Promise<Message[]> {
const { data } = await api.get<Message[]>(
`/messages/conversations/${conversationId}`,
);
return data;
},
async getConversationWith(userId: string): Promise<Conversation | null> {
const { data } = await api.get<Conversation | null>(
`/messages/conversations/with/${userId}`,
);
return data;
},
async sendMessage(recipientId: string, text: string): Promise<Message> {
const { data } = await api.post<Message>("/messages", {
recipientId,
text,
});
return data;
},
async markAsRead(conversationId: string): Promise<void> {
await api.patch(`/messages/conversations/${conversationId}/read`);
},
};

View File

@@ -0,0 +1,40 @@
import api from "@/lib/api";
export enum ReportReason {
INAPPROPRIATE = "inappropriate",
SPAM = "spam",
COPYRIGHT = "copyright",
OTHER = "other",
}
export enum ReportStatus {
PENDING = "pending",
REVIEWED = "reviewed",
RESOLVED = "resolved",
DISMISSED = "dismissed",
}
export interface CreateReportPayload {
contentId?: string;
tagId?: string;
reason: ReportReason;
description?: string;
}
export interface Report {
uuid: string;
reporterId: string;
contentId?: string;
tagId?: string;
reason: ReportReason;
description?: string;
status: ReportStatus;
createdAt: string;
updatedAt: string;
}
export const ReportService = {
async create(payload: CreateReportPayload): Promise<void> {
await api.post("/reports", payload);
},
};

View File

@@ -12,6 +12,13 @@ export const UserService = {
return data;
},
async search(query: string): Promise<User[]> {
const { data } = await api.get<User[]>("/users/search", {
params: { q: query },
});
return data;
},
async updateMe(update: Partial<User>): Promise<User> {
const { data } = await api.patch<User>("/users/me", update);
return data;
@@ -53,4 +60,9 @@ export const UserService = {
});
return data;
},
async exportData(): Promise<Record<string, unknown>> {
const { data } = await api.get<Record<string, unknown>>("/users/me/export");
return data;
},
};

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