160 Commits

Author SHA1 Message Date
Mathis HERRIOT
906f615428 chore: bump version to 1.4.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Valider frontend (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 26s
2026-01-21 15:44:17 +01:00
Mathis HERRIOT
fc4efd1e24 feat(user): add "removeMe" API method
- Introduced a new `removeMe` method in `user.service` for account deletion.
2026-01-21 15:43:58 +01:00
Mathis HERRIOT
6bc6a8f68c feat(profile): add "Share Profile" button and improve page responsiveness
- Added "Share Profile" button with clipboard copy and success notification.
- Enhanced responsiveness by adjusting avatar sizes, typography, and spacing.
- Refined button styling for consistency and usability.
2026-01-21 15:43:48 +01:00
Mathis HERRIOT
e69156407e feat(settings): add account deletion feature and improve UI
- Introduced "Delete Account" functionality with confirmation dialog and success/error notifications.
- Enhanced general settings page UI, including updated card layouts and improved form elements.
- Added support for theme selection with a more user-friendly design.
- Refined typography and button styling for better visual consistency.
2026-01-21 15:43:43 +01:00
Mathis HERRIOT
7dce7ec286 feat(profile): add profile sharing feature and enhance UI responsiveness
- Implemented the "Share Profile" button with clipboard copy functionality and success notification.
- Improved profile page responsiveness by adjusting avatar, text sizes, and spacing.
- Added consistent button styling and updated tabs for better usability and design.
2026-01-21 15:43:34 +01:00
Mathis HERRIOT
029bbe9bb9 feat(users): enhance table responsiveness and replace action buttons with dropdown menu
- Improved table layout by hiding specific columns on smaller screens.
- Replaced action buttons with `DropdownMenu` for a cleaner and more accessible UI.
- Updated skeleton loaders to align with the revised table structure.
2026-01-21 15:43:19 +01:00
Mathis HERRIOT
c3f57db1e5 feat(contents): improve table responsiveness and replace action buttons with dropdown menu
- Enhanced table layout by hiding columns on smaller screens for better responsiveness.
- Replaced action buttons with `DropdownMenu` for improved accessibility and cleaner UI.
- Adjusted skeleton loaders to align with the updated table structure.
2026-01-21 15:43:09 +01:00
Mathis HERRIOT
939448d15c feat(categories): enhance table UI and add dropdown menu for actions
- Improved table responsiveness by hiding columns on smaller screens.
- Replaced action buttons with a `DropdownMenu` for better accessibility.
- Updated skeleton loaders to match the new table layout.
2026-01-21 15:43:00 +01:00
Mathis HERRIOT
4e61b0de9a feat(dashboard): add toaster notifications and update header styling
- Integrated `Toaster` component for notifications in the dashboard layout.
- Updated header typography with better font size and tracking improvements.
2026-01-21 15:42:52 +01:00
Mathis HERRIOT
73556894f8 feat(content-card): improve UI and add tooltips for interaction elements
- Introduced tooltips for like, views, and share buttons for better user guidance.
- Added support for category display and improved tag styling.
- Adjusted `CardContent` aspect ratios for better responsiveness.
- Enhanced share button functionality with copy-to-clipboard feedback.
2026-01-21 15:42:35 +01:00
Mathis HERRIOT
96a9d6e7a7 chore: bump version to 1.4.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m27s
2026-01-21 13:49:15 +01:00
Mathis HERRIOT
058830bb60 refactor(content-card): fix indentation and improve readability
- Fixed inconsistent indentation in `content-card` component.
- Enhanced code readability by restructuring JSX elements properly.
2026-01-21 13:48:29 +01:00
Mathis HERRIOT
02d612e026 feat(contents): add UserContentEditDialog component for editing user content
- Introduced `UserContentEditDialog` to enable inline editing of user-generated content.
- Integrated form handling with `react-hook-form` for validation and form state management.
- Added category selection support and updated content saving functionality.
- Included success and error feedback with loading states for better user experience.
2026-01-21 13:44:10 +01:00
Mathis HERRIOT
498f85d24e feat(contents): add update method with user ownership validation
- Introduced `update` method in contents service to allow partial updates.
- Implemented user ownership validation to ensure secure modifications.
- Added cache clearing logic after successful updates.
2026-01-21 13:42:56 +01:00
Mathis HERRIOT
10cc5a6d8d feat(contents): add update endpoint to contents controller
- Introduced a `PATCH /:id` endpoint to enable partial content updates.
- Integrated AuthGuard for securing the endpoint.
2026-01-21 13:42:24 +01:00
Mathis HERRIOT
7503707ef1 refactor(contents): extract and memoize fetchInitial logic
- Refactored `fetchInitial` function to make it reusable using `useCallback`.
- Updated `ContentCard` to call `fetchInitial` via `onUpdate` prop for better reusability.
- Removed duplicate logic from `useEffect` for improved code readability and maintainability.
2026-01-21 13:42:17 +01:00
Mathis HERRIOT
8778508ced feat(contents): add author actions to content card
- Added dropdown menu for authors to edit or delete their content.
- Integrated `UserContentEditDialog` for inline editing.
- Enabled content deletion with confirmation and success/error feedback.
- Improved UI with `DropdownMenu` for better action accessibility.
2026-01-21 13:42:11 +01:00
Mathis HERRIOT
b968d1e6f8 feat(contents): add update and remove methods to contents service
- Introduced `update` method for partial content updates.
- Added `remove` method to handle content deletion.
2026-01-21 13:41:52 +01:00
Mathis HERRIOT
0382b21a65 chore: bump version to 1.3.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m18s
2026-01-21 13:22:19 +01:00
Mathis HERRIOT
764c4c07c8 feat(users, contents): extend admin update functionality and role management
- Added `moderator` role to `User` type for improved role assignment flexibility.
- Introduced `updateAdmin` method in user and content services to handle partial admin-specific updates.
- Enhanced `Content` type with `categoryId` and `iconUrl` to support richer categorization.
2026-01-21 13:22:07 +01:00
Mathis HERRIOT
68b5071f6d feat(admin): implement dialogs for editing users, categories, and contents
- Added `UserEditDialog`, `CategoryDialog`, and `ContentEditDialog` components.
- Enabled role, status, and other attributes updates for users.
- Implemented create and update functionality for categories.
- Facilitated content management with category assignment and updates.
2026-01-21 13:21:51 +01:00
Mathis HERRIOT
f5c90b0ae4 feat(users): add updateAdmin endpoint and enhance role assignment
- Introduced `PATCH /admin/:uuid` endpoint for admin-specific user updates.
- Updated `update` logic to handle role assignment via `rbacService`.
- Refactored `findAll` method in repository for improved readability.
2026-01-21 13:20:32 +01:00
Mathis HERRIOT
c8820a71b6 feat(categories): add create, update, and delete methods to category service
- Introduced `create`, `update`, and `remove` methods for managing categories via the service.
- Enables API integration for category CRUD functionality.
2026-01-21 13:20:16 +01:00
Mathis HERRIOT
9b714716f6 feat(admin): add edit dialogs for users, contents, and categories
- Implemented edit functionality for users, contents, and categories, including modals for updating records.
- Enhanced table actions with edit buttons alongside delete.
- Improved user, content, and category fetching with `useCallback` to optimize re-renders.
- Added skeleton loaders and UI updates for better user experience.
2026-01-21 13:19:29 +01:00
Mathis HERRIOT
3a5550d6eb feat(contents): add updateAdmin method to contents service
- Introduced `updateAdmin` logic to handle admin-specific content updates.
- Included cache clearing upon successful update for data consistency.
2026-01-21 13:19:08 +01:00
Mathis HERRIOT
07cdb741b3 feat(contents): add updateAdmin endpoint and repository method
- Introduced `PATCH /:id/admin` endpoint to update admin-specific content.
- Added `update` method to `ContentsRepository` for data updates with timestamp.
2026-01-21 13:18:41 +01:00
Mathis HERRIOT
02796e4e1f chore: bump version to 1.2.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 2m7s
CI/CD Pipeline / Valider frontend (push) Successful in 2m4s
CI/CD Pipeline / Déploiement en Production (push) Successful in 17s
2026-01-21 11:38:47 +01:00
Mathis HERRIOT
951b38db67 chore: update lint scripts and improve formatting consistency
- Added `lint:fix` scripts for backend, frontend, and documentation.
- Enabled `biome check --write` for unsafe fixes in backend scripts.
- Fixed imports, formatting, and logging for improved code clarity.
- Adjusted service unit tests for better readability and maintainability.
2026-01-21 11:38:25 +01:00
Mathis HERRIOT
a90aba2748 chore: bump version to 1.2.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 52s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-21 11:07:08 +01:00
Mathis HERRIOT
3f0b1e5119 feat(auth): add bootstrap token flow for initial admin creation
- Introduced `BootstrapService` to handle admin creation when no admins exist.
- Added `/auth/bootstrap-admin` endpoint to consume bootstrap tokens.
- Updated `RbacRepository` to support counting admins and assigning roles.
- Included unit tests for `BootstrapService` to ensure token behavior and admin assignment.
2026-01-21 11:07:02 +01:00
Mathis HERRIOT
aff8acebf8 fix(config): correct transformIgnorePatterns regex in Jest config 2026-01-21 11:06:46 +01:00
Mathis HERRIOT
a721b4041c feat(docs): add /auth/bootstrap-admin endpoint details to API reference
- Documented usage, parameters, and responses for the new endpoint.
- Included constraints and warnings for better API clarity.
2026-01-21 11:06:20 +01:00
Mathis HERRIOT
f4a1a2f4df chore: bump version to 1.1.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 59s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m44s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-21 10:45:30 +01:00
Mathis HERRIOT
0548c418c7 feat(auth): implement role seeding on application bootstrap
- Added `onApplicationBootstrap` to seed default roles if none exist.
- Introduced `seedRoles` method to handle role creation with logging.
- Updated `RbacRepository` with `countRoles` and `createRole` methods.
- Added unit tests to ensure role seeding logic functions correctly.
2026-01-21 10:45:25 +01:00
Mathis HERRIOT
dd0a9e620b feat(docs): enhance API reference documentation with detailed responses and constraints
- Added missing response details and validation constraints for multiple endpoints.
- Improved parameter descriptions and structured examples for better clarity and consistency.
2026-01-21 10:45:06 +01:00
Mathis HERRIOT
7e7b19fe9f chore(ci): add --remove-orphans flag to Docker Compose deployment script
All checks were successful
CI/CD Pipeline / Valider frontend (push) Successful in 1m40s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Valider backend (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m21s
2026-01-21 10:08:25 +01:00
Mathis HERRIOT
57bc51290b feat(docs): update and reorganize API reference structure
- Refactored API endpoint documentation using individual accordions for better clarity.
- Added detailed descriptions for `/contents`, `/categories`, `/favorites`, `/reports`, `/api-keys`, `/tags`, `/media`, and `/admin` endpoints.
- Improved consistency in query parameters and usage examples.
2026-01-21 10:08:09 +01:00
Mathis HERRIOT
d613a89e63 chore: bump version to 1.1.0
All checks were successful
CI/CD Pipeline / Valider frontend (push) Successful in 1m40s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Valider backend (push) Successful in 1m54s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m38s
2026-01-21 09:54:34 +01:00
Mathis HERRIOT
67a10ad7d8 feat(layout): add metadata base URL configuration for dynamic app URL
- Configured `metadataBase` in `RootLayout` using `NEXT_PUBLIC_APP_URL` with a fallback to the default URL.
2026-01-21 09:47:49 +01:00
Mathis HERRIOT
82e98f4fce feat(config): add support for remote image domains in Next.js config
- Enabled `images.remotePatterns` to allow loading images from `memegoat.fr` and `api.memegoat.fr`.
2026-01-21 09:47:19 +01:00
Mathis HERRIOT
70a4249e41 fix(content): update conditional checks to use mimeType for content rendering
- Replaced `type` field checks with `mimeType.startsWith("image/")` for improved accuracy in `content-card` and admin content page components.
- Adjusted `CardContent` background color for better visual consistency.
2026-01-21 09:41:11 +01:00
Mathis HERRIOT
de7d41f4a1 fix(auth): prevent login redirect loop and concurrent refresh calls
- Added check to avoid redirecting to `/login` if already on the login page.
- Prevented multiple simultaneous `refreshUser` calls by adding an `isLoading` guard.
- Improved `useEffect` cleanup in `auth-provider` to handle components unmounting.
2026-01-21 09:40:47 +01:00
Mathis HERRIOT
2da1142866 chore(docker): restrict Postgres port exposure to localhost in production configuration
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m17s
2026-01-20 22:35:42 +01:00
Mathis HERRIOT
4e8e441d98 chore: bump version to 1.0.8
Some checks failed
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Valider backend (push) Successful in 1m19s
CI/CD Pipeline / Déploiement en Production (push) Failing after 1m9s
2026-01-20 22:25:12 +01:00
Mathis HERRIOT
0e83de70e3 chore(build): automate version commit during release process
- Added function to stage and commit version changes automatically for `package.json` files.
- Integrated automated commit step into the release workflow.
2026-01-20 22:25:03 +01:00
Mathis HERRIOT
8169ef719a fix(content): update conditional rendering for type field to use meme instead of image 2026-01-20 22:18:14 +01:00
Mathis HERRIOT
7637499a97 build(release): bump package versions to 1.0.7
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m30s
CI/CD Pipeline / Valider documentation (push) Successful in 1m34s
CI/CD Pipeline / Valider frontend (push) Failing after 1m12s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 22:05:06 +01:00
Mathis HERRIOT
c03ad8c221 fix(content): update type field and conditional rendering for content handling
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m26s
CI/CD Pipeline / Valider frontend (push) Failing after 1m10s
CI/CD Pipeline / Valider documentation (push) Successful in 1m34s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
- Changed `type` field values from `image | video` to `meme | gif`.
- Updated conditional rendering in `content-card` component to match new `type` values.
2026-01-20 22:04:25 +01:00
Mathis HERRIOT
8483927823 fix(tests): replace any with Record<string, unknown> in repository tests
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m32s
- Updated type assertions in repository test files to use `Record<string, unknown>` instead of `any`.
2026-01-20 21:42:16 +01:00
Mathis HERRIOT
e7b79013fd build(release): bump package versions to 1.0.6
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m1s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 21:28:47 +01:00
Mathis HERRIOT
b6b37ebc6b docs(api): update /media endpoint documentation to use path query parameter
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 56s
CI/CD Pipeline / Valider frontend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m41s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 21:28:23 +01:00
Mathis HERRIOT
d647a585c8 fix(media): handle missing path parameter and improve error logging
- Updated `getFile` method to validate `path` query parameter.
- Added improved logging for file retrieval errors.
- Updated test cases to cover scenarios with missing `path`.
2026-01-20 21:28:10 +01:00
Mathis HERRIOT
6a2abf115f fix(s3): update public URL to include path query parameter
- Updated `getPublicUrl` to use `?path=<key>` format in public URLs.
- Adjusted corresponding test cases to reflect the new URL structure.
2026-01-20 21:27:49 +01:00
Mathis HERRIOT
ded2d3220d build(release): bump package versions to 1.0.5
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m46s
CI/CD Pipeline / Valider documentation (push) Successful in 1m50s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m57s
2026-01-20 20:00:36 +01:00
Mathis HERRIOT
162d53630d refactor(theme): organize imports and format components for consistency
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 2m5s
- Reorganized imports in multiple files for better readability.
- Fixed formatting issues in `mode-toggle` and `settings` components to improve maintainability.
2026-01-20 19:59:16 +01:00
Mathis HERRIOT
0e8a2e3986 build(release): bump package versions to 1.0.4
Some checks failed
CI/CD Pipeline / Valider frontend (push) Failing after 58s
CI/CD Pipeline / Valider backend (push) Successful in 1m33s
CI/CD Pipeline / Valider documentation (push) Successful in 1m38s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 16:28:37 +01:00
Mathis HERRIOT
5cc77ae5b0 feat(theme): add appearance settings and theme provider integration
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m26s
CI/CD Pipeline / Valider frontend (push) Failing after 53s
CI/CD Pipeline / Valider documentation (push) Successful in 1m32s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
- Added theme selection in settings page for light, dark, and system modes.
- Integrated ThemeProvider into application layout.
- Updated dashboard layout to include theme toggle in the header.
2026-01-20 16:27:59 +01:00
Mathis HERRIOT
3b9b73bc4b feat(theme): add theme toggle component and integrate into sidebar 2026-01-20 16:27:24 +01:00
Mathis HERRIOT
a6e34c511e build(release): bump package versions to 1.0.3
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Failing after 2m6s
2026-01-20 16:19:18 +01:00
Mathis HERRIOT
13650b6a39 build(docker): add nw_caddy network to production compose file
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Déploiement en Production (push) Failing after 1m49s
2026-01-20 16:18:32 +01:00
Mathis HERRIOT
dbe90ae47b build(release): bump package versions to 1.0.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 1m42s
CI/CD Pipeline / Valider frontend (push) Successful in 1m46s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m57s
2026-01-20 15:46:05 +01:00
Mathis HERRIOT
d0c78cb206 refactor(health): use type import for Cache from cache-manager
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 2m2s
2026-01-20 15:45:40 +01:00
Mathis HERRIOT
1c38434b6e build(tsconfig): enable isolatedModules in TypeScript config 2026-01-20 15:43:34 +01:00
Mathis HERRIOT
1666aaadf2 build(release): bump package versions to 1.0.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m35s
CI/CD Pipeline / Valider frontend (push) Successful in 1m55s
CI/CD Pipeline / Valider documentation (push) Successful in 1m59s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 15:37:43 +01:00
Mathis HERRIOT
6ac429f111 build(tsconfig): disable isolatedModules in TypeScript config
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m30s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 15:37:09 +01:00
Mathis HERRIOT
872087dc44 test(api-keys): improve typings in wrapWithThen mock implementation
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m12s
CI/CD Pipeline / Valider frontend (push) Successful in 1m47s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 15:27:52 +01:00
Mathis HERRIOT
f8eaad3f81 test(api-keys): improve typings in wrapWithThen mock implementation
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m2s
CI/CD Pipeline / Valider documentation (push) Successful in 1m34s
CI/CD Pipeline / Valider frontend (push) Successful in 1m32s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 15:27:25 +01:00
Mathis HERRIOT
5f176def8c test(auth): add comment to clarify DTO usage in register test 2026-01-20 15:27:14 +01:00
Mathis HERRIOT
9ef6bbfd96 test(categories): improve typings in wrapWithThen mock implementation 2026-01-20 15:27:05 +01:00
Mathis HERRIOT
61b25f7b9e test(favorites): improve typings in wrapWithThen mock implementation 2026-01-20 15:26:56 +01:00
Mathis HERRIOT
d0286d51ff test(reports): update wrapWithThen mock to use stricter typings 2026-01-20 15:26:30 +01:00
Mathis HERRIOT
2291cc8afb test(repositories): fix mock implementation of thenable query builders in repository tests
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m13s
CI/CD Pipeline / Valider frontend (push) Successful in 1m40s
CI/CD Pipeline / Valider documentation (push) Successful in 1m43s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 14:04:57 +01:00
Mathis HERRIOT
bad2caef08 docs(roadmap): mark caching with Redis for trends and searches as completed 2026-01-20 13:48:26 +01:00
Mathis HERRIOT
ac4568a0f0 refactor(media): simplify content type assignment logic in media controller 2026-01-20 13:48:16 +01:00
Mathis HERRIOT
a11a332eaa feat(health): enhance health check to include database and Redis status 2026-01-20 13:48:06 +01:00
Mathis HERRIOT
02c00e8aae test(auth): add unit tests for various guards and services with mocked dependencies 2026-01-20 13:47:46 +01:00
Mathis HERRIOT
2886e50a0c test(users): add unit tests for UsersService with mocked dependencies 2026-01-20 13:47:18 +01:00
Mathis HERRIOT
59a5cc941e test(reports): add unit tests for ReportsController and ReportsRepository with mocked dependencies 2026-01-20 13:47:06 +01:00
Mathis HERRIOT
78db4b1c34 test(reports): add unit tests for ReportsController and ReportsRepository with mocked dependencies 2026-01-20 13:46:59 +01:00
Mathis HERRIOT
b177bee75c test(favorites): add unit tests for FavoritesController and FavoritesRepository with mocked dependencies 2026-01-20 13:46:42 +01:00
Mathis HERRIOT
0cd6509273 test(contents): add unit tests for ContentsController and ContentsService with mocked dependencies 2026-01-20 13:46:31 +01:00
Mathis HERRIOT
05a56ff87d test(categories): add unit tests for CategoriesController and CategoriesRepository with mocked dependencies 2026-01-20 13:46:06 +01:00
Mathis HERRIOT
3fa11474c1 test(auth): add unit tests for AuthGuard and AuthController with mocked dependencies 2026-01-20 13:45:50 +01:00
Mathis HERRIOT
4c12c5c5cb test(api-keys): add unit tests for ApiKeysController and ApiKeysRepository with mocked dependencies 2026-01-20 13:45:27 +01:00
Mathis HERRIOT
48dbdbfdcc test(admin): add unit tests for AdminService with mocked repositories 2026-01-20 13:45:07 +01:00
Mathis HERRIOT
002a6b912a test(admin): add unit tests for AdminController with mocked dependencies 2026-01-20 13:44:35 +01:00
Mathis HERRIOT
733ffbff31 chore(ci): update workflow to replace github context with gitea for event and ref conditions 2026-01-20 12:13:14 +01:00
Mathis HERRIOT
4700526dd2 refactor(media): remove redundant metadata fallback for content-type
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m9s
CI/CD Pipeline / Valider frontend (push) Successful in 1m37s
CI/CD Pipeline / Valider documentation (push) Successful in 1m40s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 12:06:20 +01:00
Mathis HERRIOT
2450977e61 chore(versioning): bump package versions to 0.1.0 across all modules
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m12s
CI/CD Pipeline / Valider documentation (push) Successful in 1m41s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 12:00:35 +01:00
Mathis HERRIOT
afc18b555a chore(versioning): improve version script with enhanced validation and refactored command handling 2026-01-20 11:57:24 +01:00
Mathis HERRIOT
9699127739 feat(docs): add details on S3-compatible storage and email notifications system
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m0s
CI/CD Pipeline / Valider documentation (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m33s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-20 11:50:29 +01:00
Mathis HERRIOT
938d8bde7b feat(docs): extend database schema documentation with new fields avatar_url, bio, and slug 2026-01-20 11:50:18 +01:00
Mathis HERRIOT
65c7096f46 feat(docs): update API reference with new endpoints and extended parameter details 2026-01-20 11:49:47 +01:00
Mathis HERRIOT
57c00ad4d1 chore(ci): remove deprecated deploy workflow and update CI pipeline with production deployment steps 2026-01-20 11:21:23 +01:00
Mathis HERRIOT
39618f7708 chore(versioning): bump package versions to 0.0.1 across all modules
Some checks failed
Deploy to Production / Validate Build & Lint (backend) (push) Failing after 1m12s
Deploy to Production / Validate Build & Lint (documentation) (push) Successful in 1m46s
Deploy to Production / Validate Build & Lint (frontend) (push) Successful in 1m46s
Deploy to Production / Deploy to Production (push) Has been skipped
2026-01-20 10:53:11 +01:00
Mathis HERRIOT
e84e4a5a9d chore(ci): add new workflow for linting and testing components 2026-01-20 10:52:58 +01:00
Mathis HERRIOT
e74973a9d0 chore(ci): update deploy workflow to include tag-based triggers and conditional testing steps 2026-01-20 10:52:48 +01:00
Mathis HERRIOT
9233c1bf89 feat(versioning): add support for SemVer increment commands (PATCH, MINOR, MAJOR) in version script 2026-01-20 10:52:41 +01:00
Mathis HERRIOT
88c7f45a2c chore(ci): remove unused backend and lint workflows to clean up repository 2026-01-20 10:52:27 +01:00
Mathis HERRIOT
9af72156f5 chore: remove unused .output.txt file to clean up repository 2026-01-20 10:51:48 +01:00
Mathis HERRIOT
597a4d615e Changement système de branches: passage à main et unification des versions via CMake
All checks were successful
Lint / lint (backend) (push) Successful in 1m18s
Backend Tests / test (push) Successful in 1m18s
Lint / lint (documentation) (push) Successful in 1m18s
Lint / lint (frontend) (push) Successful in 1m15s
2026-01-20 10:39:53 +01:00
Mathis HERRIOT
2df45af305 style(logging): reformat hashed IP computation for improved readability
All checks were successful
Lint / lint (documentation) (push) Successful in 1m18s
Lint / lint (backend) (push) Successful in 1m21s
Backend Tests / test (push) Successful in 1m23s
Lint / lint (frontend) (push) Successful in 1m10s
Lint / lint (backend) (pull_request) Successful in 1m20s
Lint / lint (documentation) (pull_request) Successful in 1m22s
Backend Tests / test (pull_request) Successful in 1m24s
Lint / lint (frontend) (pull_request) Successful in 1m10s
2026-01-20 10:01:40 +01:00
Mathis HERRIOT
863a4bf528 style(app): reformat middleware configuration for improved readability
Some checks failed
Lint / lint (backend) (push) Failing after 52s
Backend Tests / test (push) Successful in 1m15s
Lint / lint (frontend) (push) Successful in 1m10s
Lint / lint (documentation) (push) Successful in 2m39s
2026-01-20 09:58:10 +01:00
Mathis HERRIOT
9a1cdb05a4 fix(auth): adjust 2FA verification log formatting for consistency 2026-01-20 09:57:59 +01:00
Mathis HERRIOT
28caf92f9a fix(media): update S3 file info type casting for stricter type safety
Replace `any` with `BucketItemStat` for `getFileInfo` response in MediaController to ensure accurate type definition.
2026-01-20 09:57:38 +01:00
Mathis HERRIOT
8b2728dc5a test(s3): update mock implementation types for stricter type safety
Refactor mock implementations in S3 service tests to replace `any` with `unknown` for improved type safety and consistency.
2026-01-20 09:57:27 +01:00
Mathis HERRIOT
3bbbbc307f test(media): fix type casting in MediaController unit tests
Update type casting for `Response` object in MediaController tests to use `unknown as Response` for stricter type safety. Remove unused `s3Service` variable for cleanup.
2026-01-20 09:57:11 +01:00
Mathis HERRIOT
f080919563 fix(logging): resolve type issue in hashed IP logging
Ensure `ip` parameter is explicitly cast to string before creating a SHA-256 hash to prevent runtime errors.
2026-01-20 09:56:44 +01:00
Mathis HERRIOT
edc1ab2438 feat(logging): introduce HTTP logging middleware
Some checks failed
Lint / lint (backend) (push) Failing after 2m22s
Backend Tests / test (push) Successful in 2m47s
Lint / lint (documentation) (push) Successful in 1m11s
Lint / lint (frontend) (push) Successful in 1m9s
Add middleware to log HTTP request and response details, including method, URL, status, duration, user agent, and hashed IP address. Logs categorized by severity based on response status code.
2026-01-20 09:45:06 +01:00
Mathis HERRIOT
01b66d6f2f feat(logging): enhance exception filter with user context in logs
Integrate user context (`userId`) into exception filter logging for improved traceability. Adjust log messages to include `[User: <ID>]` when user data is available.
2026-01-20 09:44:57 +01:00
Mathis HERRIOT
9a70dd02bb feat(s3): add detailed logging for upload and delete operations 2026-01-20 09:44:45 +01:00
Mathis HERRIOT
e285a4e634 feat(auth): add detailed logging for login and 2FA operations
Introduce warnings for failed login attempts and invalid 2FA tokens. Add logs for successful logins and 2FA requirements to improve authentication traceability.
2026-01-20 09:44:12 +01:00
Mathis HERRIOT
f247a01ac7 feat(middleware): add HTTP logging middleware to application configuration 2026-01-20 09:43:52 +01:00
Mathis HERRIOT
bb640cd8f9 ci(workflows): remove Next.js build caching from deployment workflow 2026-01-20 09:31:30 +01:00
Mathis HERRIOT
c1118e9f25 test(s3): fix formatting of mock implementation in unit tests
All checks were successful
Backend Tests / test (push) Successful in 1m10s
Lint / lint (backend) (push) Successful in 1m7s
Lint / lint (documentation) (push) Successful in 1m8s
Lint / lint (frontend) (push) Successful in 1m6s
Backend Tests / test (pull_request) Successful in 1m10s
Lint / lint (backend) (pull_request) Successful in 1m7s
Lint / lint (documentation) (pull_request) Successful in 1m6s
Lint / lint (frontend) (pull_request) Successful in 1m7s
2026-01-15 00:44:55 +01:00
Mathis HERRIOT
eae1f84b92 ci(docker): optimize Dockerfiles with pnpm and build cache integration
Switch to `node:22-alpine` for smaller base images. Introduce pnpm cache mounts and utilize `--frozen-lockfile` for faster and more reliable builds. Add Next.js build cache optimizations for `frontend` and `documentation`.
2026-01-15 00:44:44 +01:00
Mathis HERRIOT
8d27532dc0 feat(s3): enhance logging and public URL generation
Some checks failed
Backend Tests / test (push) Successful in 1m11s
Lint / lint (backend) (push) Failing after 46s
Lint / lint (documentation) (push) Successful in 1m7s
Lint / lint (frontend) (push) Has been cancelled
Add detailed logging for S3 uploads in user and content services. Improve public URL generation logic in `S3Service` by providing better handling for `API_URL`, `DOMAIN_NAME`, and `PORT`. Update relevant tests to cover all scenarios.
2026-01-15 00:40:36 +01:00
Mathis HERRIOT
f79507730e ci(workflows): improve caching and optimize dependency installation
Add Next.js build cache to deployment workflow for improved performance. Update all workflows to use `pnpm install --frozen-lockfile --prefer-offline` for faster and more reliable dependency management.
2026-01-15 00:39:56 +01:00
Mathis HERRIOT
7048c2731e fix(media): correct route param handling in media controller
All checks were successful
Backend Tests / test (push) Successful in 1m48s
Lint / lint (backend) (push) Successful in 1m7s
Lint / lint (documentation) (push) Successful in 1m7s
Lint / lint (frontend) (push) Successful in 1m8s
Backend Tests / test (pull_request) Successful in 1m10s
Lint / lint (backend) (pull_request) Successful in 1m8s
Lint / lint (documentation) (pull_request) Successful in 1m7s
Lint / lint (frontend) (pull_request) Successful in 1m9s
Adjust `@Get` decorator route pattern to properly handle file keys with special characters.
2026-01-14 23:51:24 +01:00
Mathis HERRIOT
d74fd15036 ci(workflows): enhance workflows with matrix builds and caching optimizations
Refactor GitHub Actions workflows to introduce matrix builds for `backend`, `frontend`, and `documentation` components. Upgrade actions versions, add pull request triggers, and improve caching with pnpm store integration. Adjust Node.js version to 20 and enforce `--frozen-lockfile` for dependency installation.
2026-01-14 23:51:07 +01:00
Mathis HERRIOT
86a697c392 Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
2026-01-14 23:14:03 +01:00
Mathis HERRIOT
38adbb6e77 feat(media): add public URL generation for media files and improve S3 integration
Introduce `getPublicUrl` in `S3Service` for generating public URLs. Replace custom file URL generation logic across services with the new method. Add media controller for file streaming and update related tests. Adjust frontend to display user roles instead of email in the sidebar. Update environment schema to include optional `API_URL`. Fix help page contact email.
2026-01-14 23:13:28 +01:00
594a387712 Merge branch 'prod' into dev 2026-01-14 22:51:52 +01:00
Mathis HERRIOT
4ca15b578d refactor(modules): mark DatabaseModule and CryptoModule as global and remove redundant imports
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Optimize module imports by marking `DatabaseModule` and `CryptoModule` as global. Remove explicit imports from other modules to reduce duplication and improve maintainability. Update environment variable limits for image and GIF sizes in production.
2026-01-14 22:50:30 +01:00
2912231769 Merge pull request 'UI & Feature update - Alpha' (#9) from dev into prod
Some checks failed
Lint / lint (push) Has been cancelled
Backend Tests / test (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m35s
Reviewed-on: #9
2026-01-14 22:40:06 +01:00
Mathis HERRIOT
db17994bb5 fix(test): update transformIgnorePatterns to include .pnpm and uuid dependencies
All checks were successful
Backend Tests / test (push) Successful in 9m42s
Lint / lint (push) Successful in 9m37s
2026-01-14 22:19:47 +01:00
Mathis HERRIOT
f57e028178 refactor(reports): add as const to test data in reports.service.spec.ts 2026-01-14 22:19:27 +01:00
Mathis HERRIOT
e84aa8a8db feat(ui): integrate dynamic tag handling in MobileFilters
Add support for fetching and displaying dynamic popular tags in `MobileFilters` using `TagService`. Replace static tag list with API-driven content and handle empty states gracefully.
2026-01-14 22:19:18 +01:00
Mathis HERRIOT
c6b23de481 feat(api): add TagService and enhance API error handling
Introduce `TagService` to manage tag-related API interactions. Add SSR cookie interceptor for API requests and implement token refresh logic on 401 errors. Update `FavoriteService` to use `favoritesOnly` filter for exploring content.
2026-01-14 22:19:11 +01:00
Mathis HERRIOT
0611ef715c feat(ui): add ViewCounter component and enhance accessibility annotations
Introduce a new `ViewCounter` component to manage view tracking for content. Add biome-ignore comments for accessibility standards in several UI components, enhance semantic element usage, and improve tag handling in `SearchSidebar`.
2026-01-14 22:18:50 +01:00
Mathis HERRIOT
0a1391674f feat(meme): add ViewCounter component to meme detail pages
Integrate the `ViewCounter` component into meme standard and modal detail pages to track and display content views.
2026-01-14 22:18:10 +01:00
Mathis HERRIOT
2fedaca502 refactor(app): reorder imports in app.module.ts for consistency and readability 2026-01-14 22:00:16 +01:00
Mathis HERRIOT
a6837ff7fb refactor(auth): reorder imports in optional-auth.guard.ts for consistency and readability 2026-01-14 22:00:10 +01:00
Mathis HERRIOT
74b61004e7 refactor(auth): rename id to uuid in AuthStatus and mock uuid in tests 2026-01-14 21:59:51 +01:00
Mathis HERRIOT
760343da76 refactor(app): simplify metadata description formatting in layout component 2026-01-14 21:59:02 +01:00
Mathis HERRIOT
14f8b8b63d refactor(contents): reorder imports and improve code formatting
Standardize import order in `contents.controller.ts` and related files for better code readability. Adjust SQL formatting in repository methods for consistency.
2026-01-14 21:58:52 +01:00
Mathis HERRIOT
50a186da1d refactor(core): standardize and reorder imports across admin services and modules
Optimize the structure and readability of import statements in `admin` services, modules, and controllers. Ensure consistency and logical grouping for improved maintainability.
2026-01-14 21:58:41 +01:00
Mathis HERRIOT
3908989b39 feat(users): enhance user schema and extend service dependencies
Add `email` and `status` fields to user schema for better data handling. Update `UsersService` with new service dependencies (`RbacService`, `MediaService`, `S3Service`, `ConfigService`) for enhanced functionality. Mock dependencies in tests for improved coverage. Adjust user model with optional and extended fields for flexibility. Streamline and update import statements.
2026-01-14 21:58:28 +01:00
Mathis HERRIOT
02d70f27ea refactor: optimize import orders, improve formatting and code readability
Standardize import declarations, resolve misplaced imports, and enhance consistency across components. Update indentation, split multiline JSX props, and enforce consistent function formatting for better maintainability.
2026-01-14 21:58:04 +01:00
Mathis HERRIOT
65f8860cc0 feat(auth): add optional authentication guard and extend AuthModule providers
Some checks failed
Backend Tests / test (push) Failing after 5m0s
Lint / lint (push) Failing after 5m2s
Introduce `OptionalAuthGuard` to allow conditional authentication for routes. Update `AuthModule` to include `AuthGuard`, `OptionalAuthGuard`, and `RolesGuard` in providers and exports for broader reuse.

feat(app): integrate `AdminModule` into app module

Add `AdminModule` to the app's main module to enable administration functionalities.

feat(users): enhance user profiles with bio and avatar fields

Extend `UpdateUserDto` to include optional `bio` and `avatarUrl` fields for better user customization.

feat(categories): add functionality to count all categories

Implement `countAll` method in `CategoriesRepository` to fetch the total number of categories using raw SQL counting.
2026-01-14 21:45:32 +01:00
Mathis HERRIOT
0e9edd4bfc feat(app): enhance metadata and add admin sidebar section
Update app metadata with multilingual support, SEO improvements, and structured OpenGraph and Twitter metadata. Add an "Administration" section in the sidebar for authenticated admin users. Improve role display and enable dynamic sorting in `HomeContent`. Extend UI badges with a success variant.
2026-01-14 21:44:25 +01:00
Mathis HERRIOT
6ce58d1639 feat(admin): implement admin statistics API and service
Add admin statistics endpoint to provide user, content, and category stats. Introduce `AdminModule` with controller, service, and repository integration for data aggregation. Include frontend service to consume the stats API.
2026-01-14 21:44:14 +01:00
Mathis HERRIOT
47d6fcb6a0 feat(media): add image resizing support for processImage
Extend the `processImage` method to support optional resizing with `width` and `height` parameters. Update processing pipeline to handle resizing while maintaining existing format processing for `webp` and `avif`.
2026-01-14 21:44:00 +01:00
Mathis HERRIOT
d7c2a965a0 feat(contents): enhance user-specific data handling and admin content management
Integrate user-specific fields (`isLiked`, `favoritesCount`) in content APIs and improve `ContentCard` through reactive updates. Add admin-only content deletion support. Refactor services and repository to enrich responses with additional data (author details, tags).
2026-01-14 21:43:44 +01:00
Mathis HERRIOT
fb7ddde42e feat(app): add dashboard pages for settings, admin, and public user profiles
Introduce new pages for profile settings, admin dashboard (users, contents, categories), and public user profiles. Enhance profile functionality with avatar uploads and bio updates. Include help and improved content trends/recent pages. Streamline content display using `HomeContent`.
2026-01-14 21:43:27 +01:00
Mathis HERRIOT
026aebaee3 feat(database): add migration snapshot 0006_snapshot.json for schema updates
Capture extensive database schema changes, including new tables and updated relationships for better data management and integrity.
2026-01-14 21:43:10 +01:00
Mathis HERRIOT
a30113e8e2 feat(users): add avatar and bio support, improve user profile handling
Introduce `avatarUrl` and `bio` fields in the user schema. Update repository, service, and controller to handle avatar uploads, processing, and bio updates. Add S3 integration for avatar storage and enhance user data handling for private and public profiles.
2026-01-14 21:42:46 +01:00
f10c444957 Merge pull request 'Fix of backend validation problems.' (#8) from dev into prod
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m41s
Reviewed-on: #8
2026-01-14 21:13:06 +01:00
Mathis HERRIOT
975e29dea1 refactor: format imports, fix indentation, and improve readability across files
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Apply consistent import ordering and indentation in frontend and backend files. Ensure better maintainability and adherence to code style standards.
2026-01-14 21:06:38 +01:00
Mathis HERRIOT
a4ce48a91c feat(database): update passwordHash length and add migration snapshot
Some checks failed
Lint / lint (push) Has been cancelled
Backend Tests / test (push) Has been cancelled
Increase `passwordHash` field length to 100 in the `users` schema to accommodate larger hashes. Add migration snapshot `0005_snapshot.json` to capture database state changes.
2026-01-14 20:51:24 +01:00
Mathis HERRIOT
ff6fc1c6b3 feat(ui): enhance mobile user experience and authentication handling
Add `UserNavMobile` component for improved mobile navigation. Update dashboard and profile pages to include authentication checks with loading states and login prompts. Introduce category-specific content tabs on the profile page. Apply sidebar enhancements, including new sections for user favorites and memes.
2026-01-14 20:50:38 +01:00
Mathis HERRIOT
5671ba60a6 feat(dto): enforce field length constraints across DTOs
Add `@MaxLength` validations to limit string field lengths in multiple DTOs, ensuring consistent data validation and integrity. Integrate `CreateApiKeyDto` in the API keys controller for improved type safety.
2026-01-14 20:41:45 +01:00
Mathis HERRIOT
5f2672021e feat(middleware): add crawler detection middleware for suspicious requests
Introduce `CrawlerDetectionMiddleware` to identify and log potential crawlers or bots accessing suspicious paths or using bot-like user agents. Middleware applied globally to all routes in `AppModule`.
2026-01-14 20:41:25 +01:00
17c2cea366 Merge pull request 'Exclude .migrations from file includes in biome.json configuration' (#7) from dev into prod
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m43s
Reviewed-on: #7
2026-01-14 20:40:54 +01:00
cb6d87eafd Merge pull request 'DEBUG: Erreur de schéma de donnée' (#6) from dev into prod
Some checks failed
Backend Tests / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Failing after 1m1s
Reviewed-on: #6
2026-01-14 20:17:42 +01:00
570576435c Merge pull request 'Updating default api' (#5) from dev into prod
Some checks failed
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m18s
Reviewed-on: #5
2026-01-14 19:21:26 +01:00
c19d86a0cb Merge pull request 'Update Dockerfile for documentation to simplify base image and streamline dependency handling' (#4) from dev into prod
Some checks failed
Deploy to Production / deploy (push) Successful in 6m27s
Lint / lint (push) Has been cancelled
Reviewed-on: #4
2026-01-14 19:11:25 +01:00
6756cf6bc7 Merge pull request 'Fix API URL du frontend, ajout documentation en production' (#3) from dev into prod
Some checks failed
Deploy to Production / deploy (push) Failing after 1m40s
Reviewed-on: #3
2026-01-14 19:01:08 +01:00
de537e5947 Merge pull request 'fix(docker): update API URL for production environment' (#2) from dev into prod
All checks were successful
Deploy to Production / deploy (push) Successful in 6m4s
Reviewed-on: #2
2026-01-14 18:34:57 +01:00
9097a3e9b5 Merge pull request 'Fix on linting' (#1) from dev into prod
Some checks failed
Lint / lint (push) Has been cancelled
Deploy to Production / deploy (push) Successful in 6m37s
Reviewed-on: #1
2026-01-14 18:12:57 +01:00
152 changed files with 10413 additions and 597 deletions

View File

@@ -1,22 +0,0 @@
name: Backend Tests
on:
push:
paths:
- 'backend/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run Backend Tests
run: pnpm -F @memegoat/backend test

111
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,111 @@
# Pipeline CI/CD pour Gitea Actions (Forgejo)
# Compatible avec GitHub Actions pour la portabilité
name: CI/CD Pipeline
on:
push:
branches:
- '**'
tags:
- 'v*'
pull_request:
jobs:
validate:
name: Valider ${{ matrix.component }}
runs-on: ubuntu-latest
strategy:
matrix:
component: [backend, frontend, documentation]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Installer pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Configurer Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Obtenir le chemin du store pnpm
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- name: Configurer le cache pnpm
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Installer les dépendances
run: pnpm install --frozen-lockfile --prefer-offline
- name: Lint ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} lint
- name: Tester ${{ matrix.component }}
if: matrix.component == 'backend' || matrix.component == 'frontend'
run: |
if pnpm -F @memegoat/${{ matrix.component }} run | grep -q "test"; then
pnpm -F @memegoat/${{ matrix.component }} test
else
echo "Pas de script de test trouvé pour ${{ matrix.component }}, passage."
fi
- name: Build ${{ matrix.component }}
run: pnpm -F @memegoat/${{ matrix.component }} build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
deploy:
name: Déploiement en Production
needs: validate
# Déclenchement uniquement sur push sur main ou tag de version
# Gitea supporte le contexte 'github' pour la compatibilité
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Vérifier l'environnement Docker
run: |
docker version
docker compose version
- name: Déployer avec Docker Compose
run: |
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PORT: ${{ secrets.REDIS_PORT }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_PORT: ${{ secrets.S3_PORT }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PASS: ${{ secrets.MAIL_PASS }}
MAIL_USER: ${{ secrets.MAIL_USER }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

View File

@@ -1,87 +0,0 @@
name: Deploy to Production
on:
push:
branches:
- prod
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITEA_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Lint - Backend
run: pnpm run lint:back
- name: Build - Backend
run: pnpm run build:back
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
- name: Lint - Frontend
run: pnpm run lint:front
- name: Build - Frontend
run: pnpm run build:front
- name: Lint - Documentation
run: pnpm run lint:docs
- name: Build - Documentation
run: pnpm run build:docs
- name: Deploy with Docker Compose
run: |
docker compose -f docker-compose.prod.yml up -d --build
env:
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
POSTGRES_HOST: ${{ secrets.POSTGRES_HOST }}
POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PORT: ${{ secrets.REDIS_PORT }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_PORT: ${{ secrets.S3_PORT }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
PGP_ENCRYPTION_KEY: ${{ secrets.PGP_ENCRYPTION_KEY }}
SESSION_PASSWORD: ${{ secrets.SESSION_PASSWORD }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PASS: ${{ secrets.MAIL_PASS }}
MAIL_USER: ${{ secrets.MAIL_USER }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

View File

@@ -1,31 +0,0 @@
name: Lint
on:
push:
paths:
- 'frontend/**'
- 'backend/**'
- 'documentation/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Lint Frontend
if: success() || failure()
run: pnpm -F @memegoat/frontend lint
- name: Lint Backend
if: success() || failure()
run: pnpm -F @memegoat/backend lint
- name: Lint Documentation
if: success() || failure()
run: pnpm -F @bypass/documentation lint

50
ROADMAP.md Normal file
View File

@@ -0,0 +1,50 @@
# 🐐 Memegoat - Roadmap & Critères de Production
Ce document définit les objectifs, les critères techniques et les fonctionnalités à atteindre pour que le projet Memegoat soit considéré comme prêt pour la production et conforme aux normes européennes (RGPD) et françaises.
## 1. 🏗️ Architecture & Infrastructure
- [x] Backend NestJS (TypeScript)
- [x] Base de données PostgreSQL avec Drizzle ORM
- [x] Stockage d'objets compatible S3 (MinIO)
- [x] Service d'Emailing (Nodemailer / SMTPS)
- [x] Documentation Technique & Référence API (`docs.memegoat.fr`)
- [x] Health Checks (`/health`)
- [x] Gestion des variables d'environnement (Validation avec Zod)
- [ ] CI/CD (Build, Lint, Test, Deploy)
## 2. 🔐 Sécurité & Authentification
- [x] Hachage des mots de passe (Argon2id)
- [x] Gestion des sessions robuste (JWT avec Refresh Token et Rotation)
- [x] RBAC (Role Based Access Control) fonctionnel
- [x] Système de Clés API (Hachées en base)
- [x] Double Authentification (2FA / TOTP)
- [x] Limitation de débit (Rate Limiting / Throttler)
- [x] Validation stricte des entrées (DTOs + ValidationPipe)
- [x] Protection contre les vulnérabilités OWASP (Helmet, CORS)
## 3. ⚖️ Conformité RGPD (EU & France)
- [x] Chiffrement natif des données personnelles (PII) via PGP (pgcrypto)
- [x] Hachage aveugle (Blind Indexing) pour l'email (recherche/unicité)
- [x] Journalisation d'audit complète (Audit Logs) pour les actions sensibles
- [x] Gestion du consentement (Versionnage CGU/Politique de Confidentialité)
- [x] Droit à l'effacement : Flux de suppression (Soft Delete -> Purge définitive)
- [x] Droit à la portabilité : Export des données utilisateur (JSON)
- [x] Purge automatique des données obsolètes (Signalements, Sessions expirées)
- [x] Anonymisation des adresses IP (Hachage) dans les logs
## 4. 🖼️ Fonctionnalités Coeur (Media & Galerie)
- [x] Exploration (Trends, Recent, Favoris)
- [x] Recherche par Tags, Catégories, Auteur, Texte
- [x] Gestion des Favoris
- [x] Upload sécurisé via S3 (URLs présignées)
- [x] Scan Antivirus (ClamAV) et traitement des médias (WebP, WebM, AVIF, AV1)
- [x] Limitation de la taille et des formats de fichiers entrants (Configurable)
- [x] Système de Signalement (Reports) et workflow de modération
- [ ] SEO : Metatags dynamiques et slugs sémantiques
## 5. ✅ Qualité & Robustesse
- [ ] Couverture de tests unitaires (Jest) > 80%
- [ ] Tests d'intégration et E2E
- [x] Gestion centralisée des erreurs (Filters NestJS)
- [ ] Monitoring et centralisation des logs (ex: Sentry, ELK/Loki)
- [x] Performance : Cache (Redis) pour les tendances et recherches fréquentes

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(512);--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "bio" varchar(255);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,20 @@
"when": 1768417827439,
"tag": "0004_cheerful_dakota_north",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1768420201679,
"tag": "0005_perpetual_silverclaw",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1768423315172,
"tag": "0006_friendly_adam_warlock",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,5 @@
FROM node:22-slim AS base
# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate
@@ -9,10 +10,17 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/
RUN pnpm install --no-frozen-lockfile
# Utilisation du cache pour pnpm et installation figée
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
RUN pnpm install --no-frozen-lockfile
# Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm run --filter @memegoat/backend build
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
RUN cp -r backend/dist /app/dist

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/backend",
"version": "0.0.1",
"version": "1.4.1",
"description": "",
"author": "",
"private": true,
@@ -13,7 +13,7 @@
"scripts": {
"build": "nest build",
"lint": "biome check",
"lint:write": "biome check --write",
"lint:write": "biome check --write --unsafe",
"format": "biome format --write",
"start": "nest start",
"start:dev": "nest start --watch",
@@ -107,7 +107,7 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"transformIgnorePatterns": [
"node_modules/(?!(jose|@noble)/)"
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid))"
],
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"

View File

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

View File

@@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { Roles } from "../auth/decorators/roles.decorator";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { AdminService } from "./admin.service";
@Controller("admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get("stats")
getStats() {
return this.adminService.getStats();
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CategoriesModule } from "../categories/categories.module";
import { ContentsModule } from "../contents/contents.module";
import { UsersModule } from "../users/users.module";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
@Module({
imports: [AuthModule, UsersModule, ContentsModule, CategoriesModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

View File

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

View File

@@ -0,0 +1,27 @@
import { Injectable } from "@nestjs/common";
import { CategoriesRepository } from "../categories/repositories/categories.repository";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { UsersRepository } from "../users/repositories/users.repository";
@Injectable()
export class AdminService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly contentsRepository: ContentsRepository,
private readonly categoriesRepository: CategoriesRepository,
) {}
async getStats() {
const [userCount, contentCount, categoryCount] = await Promise.all([
this.usersRepository.countAll(),
this.contentsRepository.count({}),
this.categoriesRepository.countAll(),
]);
return {
users: userCount,
contents: contentCount,
categories: categoryCount,
};
}
}

View File

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

View File

@@ -11,6 +11,7 @@ import {
import { AuthGuard } from "../auth/guards/auth.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ApiKeysService } from "./api-keys.service";
import { CreateApiKeyDto } from "./dto/create-api-key.dto";
@Controller("api-keys")
@UseGuards(AuthGuard)
@@ -20,13 +21,12 @@ export class ApiKeysController {
@Post()
create(
@Req() req: AuthenticatedRequest,
@Body("name") name: string,
@Body("expiresAt") expiresAt?: string,
@Body() createApiKeyDto: CreateApiKeyDto,
) {
return this.apiKeysService.create(
req.user.sub,
name,
expiresAt ? new Date(expiresAt) : undefined,
createApiKeyDto.name,
createApiKeyDto.expiresAt ? new Date(createApiKeyDto.expiresAt) : undefined,
);
}

View File

@@ -1,13 +1,11 @@
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { ApiKeysController } from "./api-keys.controller";
import { ApiKeysService } from "./api-keys.service";
import { ApiKeysRepository } from "./repositories/api-keys.repository";
@Module({
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
imports: [forwardRef(() => AuthModule)],
controllers: [ApiKeysController],
providers: [ApiKeysService, ApiKeysRepository],
exports: [ApiKeysService, ApiKeysRepository],

View File

@@ -0,0 +1,18 @@
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
export class CreateApiKeyDto {
@IsString()
@IsNotEmpty()
@MaxLength(128)
name!: string;
@IsOptional()
@IsDateString()
expiresAt?: string;
}

View File

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

View File

@@ -1,15 +1,18 @@
import { CacheModule } from "@nestjs/cache-manager";
import { Module } from "@nestjs/common";
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { ThrottlerModule } from "@nestjs/throttler";
import { redisStore } from "cache-manager-redis-yet";
import { AdminModule } from "./admin/admin.module";
import { ApiKeysModule } from "./api-keys/api-keys.module";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { AuthModule } from "./auth/auth.module";
import { CategoriesModule } from "./categories/categories.module";
import { CommonModule } from "./common/common.module";
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
import { validateEnv } from "./config/env.schema";
import { ContentsModule } from "./contents/contents.module";
import { CryptoModule } from "./crypto/crypto.module";
@@ -41,6 +44,7 @@ import { UsersModule } from "./users/users.module";
SessionsModule,
ReportsModule,
ApiKeysModule,
AdminModule,
ScheduleModule.forRoot(),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
@@ -71,4 +75,10 @@ import { UsersModule } from "./users/users.module";
controllers: [AppController, HealthController],
providers: [AppService],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(HTTPLoggerMiddleware, CrawlerDetectionMiddleware)
.forRoutes("*");
}
}

View File

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

View File

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

View File

@@ -1,22 +1,34 @@
import { forwardRef, Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { SessionsModule } from "../sessions/sessions.module";
import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { BootstrapService } from "./bootstrap.service";
import { AuthGuard } from "./guards/auth.guard";
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
import { RolesGuard } from "./guards/roles.guard";
import { RbacService } from "./rbac.service";
import { RbacRepository } from "./repositories/rbac.repository";
@Module({
imports: [
forwardRef(() => UsersModule),
CryptoModule,
SessionsModule,
DatabaseModule,
],
imports: [forwardRef(() => UsersModule), SessionsModule],
controllers: [AuthController],
providers: [AuthService, RbacService, RbacRepository],
exports: [AuthService, RbacService, RbacRepository],
providers: [
AuthService,
RbacService,
BootstrapService,
RbacRepository,
AuthGuard,
OptionalAuthGuard,
RolesGuard,
],
exports: [
AuthService,
RbacService,
RbacRepository,
AuthGuard,
OptionalAuthGuard,
RolesGuard,
],
})
export class AuthModule {}

View File

@@ -1,3 +1,7 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
import { Test, TestingModule } from "@nestjs/testing";
jest.mock("@noble/post-quantum/ml-kem.js", () => ({

View File

@@ -110,6 +110,7 @@ export class AuthService {
const user = await this.usersService.findByEmailHash(emailHash);
if (!user) {
this.logger.warn(`Login failed: user not found for email hash`);
throw new UnauthorizedException("Invalid credentials");
}
@@ -119,10 +120,12 @@ export class AuthService {
);
if (!isPasswordValid) {
this.logger.warn(`Login failed: invalid password for user ${user.uuid}`);
throw new UnauthorizedException("Invalid credentials");
}
if (user.isTwoFactorEnabled) {
this.logger.log(`2FA required for user ${user.uuid}`);
return {
message: "2FA required",
requires2FA: true,
@@ -141,6 +144,7 @@ export class AuthService {
ip,
);
this.logger.log(`User ${user.uuid} logged in successfully`);
return {
message: "User logged in successfully",
access_token: accessToken,
@@ -165,6 +169,9 @@ export class AuthService {
const isValid = authenticator.verify({ token, secret });
if (!isValid) {
this.logger.warn(
`2FA verification failed for user ${userId}: invalid token`,
);
throw new UnauthorizedException("Invalid 2FA token");
}
@@ -179,6 +186,7 @@ export class AuthService {
ip,
);
this.logger.log(`User ${userId} logged in successfully via 2FA`);
return {
message: "User logged in successfully (2FA)",
access_token: accessToken,

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getIronSession } from "iron-session";
import { JwtService } from "../../crypto/services/jwt.service";
import { getSessionOptions, SessionData } from "../session.config";
@Injectable()
export class OptionalAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const session = await getIronSession<SessionData>(
request,
response,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
const token = session.accessToken;
if (!token) {
return true;
}
try {
const payload = await this.jwtService.verifyJwt(token);
request.user = payload;
} catch {
// Ignore invalid tokens for optional auth
}
return true;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,11 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
import { CategoriesRepository } from "./repositories/categories.repository";
@Module({
imports: [DatabaseModule, AuthModule, CryptoModule],
imports: [AuthModule],
controllers: [CategoriesController],
providers: [CategoriesService, CategoriesRepository],
exports: [CategoriesService, CategoriesRepository],

View File

@@ -1,15 +1,18 @@
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
export class CreateCategoryDto {
@IsString()
@IsNotEmpty()
@MaxLength(64)
name!: string;
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;
@IsOptional()
@IsString()
@MaxLength(512)
iconUrl?: string;
}

View File

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

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { categories } from "../../database/schemas";
import type { CreateCategoryDto } from "../dto/create-category.dto";
@@ -16,6 +16,13 @@ export class CategoriesRepository {
.orderBy(categories.name);
}
async countAll() {
const result = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(categories);
return Number(result[0].count);
}
async findOne(id: string) {
const result = await this.databaseService.db
.select()

View File

@@ -9,6 +9,14 @@ import {
import * as Sentry from "@sentry/nestjs";
import { Request, Response } from "express";
interface RequestWithUser extends Request {
user?: {
sub?: string;
username?: string;
id?: string;
};
}
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger("ExceptionFilter");
@@ -16,7 +24,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const request = ctx.getRequest<RequestWithUser>();
const status =
exception instanceof HttpException
@@ -28,6 +36,9 @@ export class AllExceptionsFilter implements ExceptionFilter {
? exception.getResponse()
: "Internal server error";
const userId = request.user?.sub || request.user?.id;
const userPart = userId ? `[User: ${userId}] ` : "";
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
@@ -42,12 +53,12 @@ export class AllExceptionsFilter implements ExceptionFilter {
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
Sentry.captureException(exception);
this.logger.error(
`${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
`${userPart}${request.method} ${request.url} - Error: ${exception instanceof Error ? exception.message : "Unknown error"}`,
exception instanceof Error ? exception.stack : "",
);
} else {
this.logger.warn(
`${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
`${userPart}${request.method} ${request.url} - Status: ${status} - Message: ${JSON.stringify(message)}`,
);
}

View File

@@ -17,6 +17,7 @@ export interface IMediaService {
processImage(
buffer: Buffer,
format?: "webp" | "avif",
resize?: { width?: number; height?: number },
): Promise<MediaProcessingResult>;
processVideo(
buffer: Buffer,

View File

@@ -33,4 +33,6 @@ export interface IStorageService {
sourceBucketName?: string,
destinationBucketName?: string,
): Promise<string>;
getPublicUrl(storageKey: string): string;
}

View File

@@ -0,0 +1,67 @@
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import type { NextFunction, Request, Response } from "express";
@Injectable()
export class CrawlerDetectionMiddleware implements NestMiddleware {
private readonly logger = new Logger("CrawlerDetection");
private readonly SUSPICIOUS_PATTERNS = [
/\.env/,
/wp-admin/,
/wp-login/,
/\.git/,
/\.php$/,
/xmlrpc/,
/config/,
/setup/,
/wp-config/,
/_next/,
/install/,
/admin/,
/phpmyadmin/,
/sql/,
/backup/,
/db\./,
/backup\./,
/cgi-bin/,
/\.well-known\/security\.txt/, // Bien que légitime, souvent scanné
];
private readonly BOT_USER_AGENTS = [
/bot/i,
/crawler/i,
/spider/i,
/python/i,
/curl/i,
/wget/i,
/nmap/i,
/nikto/i,
/zgrab/i,
/masscan/i,
];
use(req: Request, res: Response, next: NextFunction) {
const { method, url, ip } = req;
const userAgent = req.get("user-agent") || "unknown";
res.on("finish", () => {
if (res.statusCode === 404) {
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
pattern.test(url),
);
const isBotUserAgent = this.BOT_USER_AGENTS.some((pattern) =>
pattern.test(userAgent),
);
if (isSuspiciousPath || isBotUserAgent) {
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
}
}
});
next();
}
}

View File

@@ -0,0 +1,37 @@
import { createHash } from "node:crypto";
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import { NextFunction, Request, Response } from "express";
@Injectable()
export class HTTPLoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(request: Request, response: Response, next: NextFunction): void {
const { method, originalUrl, ip } = request;
const userAgent = request.get("user-agent") || "";
const startTime = Date.now();
response.on("finish", () => {
const { statusCode } = response;
const contentLength = response.get("content-length");
const duration = Date.now() - startTime;
const hashedIp = createHash("sha256")
.update(ip as string)
.digest("hex");
const message = `${method} ${originalUrl} ${statusCode} ${contentLength || 0} - ${userAgent} ${hashedIp} +${duration}ms`;
if (statusCode >= 500) {
return this.logger.error(message);
}
if (statusCode >= 400) {
return this.logger.warn(message);
}
return this.logger.log(message);
});
next();
}
}

View File

@@ -33,6 +33,7 @@ export const envSchema = z.object({
MAIL_FROM: z.string().email(),
DOMAIN_NAME: z.string(),
API_URL: z.string().url().optional(),
// Sentry
SENTRY_DSN: z.string().optional(),

View File

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

View File

@@ -10,6 +10,7 @@ import {
Param,
ParseBoolPipe,
ParseIntPipe,
Patch,
Post,
Query,
Req,
@@ -19,8 +20,11 @@ import {
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import type { Request, Response } from "express";
import type { Response } from "express";
import { Roles } from "../auth/decorators/roles.decorator";
import { AuthGuard } from "../auth/guards/auth.guard";
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ContentsService } from "./contents.service";
import { CreateContentDto } from "./dto/create-content.dto";
@@ -65,10 +69,12 @@ export class ContentsController {
}
@Get("explore")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@Header("Cache-Control", "public, max-age=60")
explore(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("sort") sort?: "trend" | "recent",
@@ -78,7 +84,7 @@ export class ContentsController {
@Query("query") query?: string,
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
favoritesOnly?: boolean,
@Query("userId") userId?: string,
@Query("userId") userIdQuery?: string,
) {
return this.contentsService.findAll({
limit,
@@ -89,42 +95,57 @@ export class ContentsController {
author,
query,
favoritesOnly,
userId,
userId: userIdQuery || req.user?.sub,
});
}
@Get("trends")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor)
@CacheTTL(300)
@Header("Cache-Control", "public, max-age=300")
trends(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return this.contentsService.findAll({ limit, offset, sortBy: "trend" });
return this.contentsService.findAll({
limit,
offset,
sortBy: "trend",
userId: req.user?.sub,
});
}
@Get("recent")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@Header("Cache-Control", "public, max-age=60")
recent(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return this.contentsService.findAll({ limit, offset, sortBy: "recent" });
return this.contentsService.findAll({
limit,
offset,
sortBy: "recent",
userId: req.user?.sub,
});
}
@Get(":idOrSlug")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor)
@CacheTTL(3600)
@Header("Cache-Control", "public, max-age=3600")
async findOne(
@Param("idOrSlug") idOrSlug: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Res() res: Response,
) {
const content = await this.contentsService.findOne(idOrSlug);
const content = await this.contentsService.findOne(idOrSlug, req.user?.sub);
if (!content) {
throw new NotFoundException("Contenu non trouvé");
}
@@ -153,9 +174,33 @@ export class ContentsController {
return this.contentsService.incrementUsage(id);
}
@Patch(":id")
@UseGuards(AuthGuard)
update(
@Param("id") id: string,
@Req() req: AuthenticatedRequest,
@Body() updateContentDto: any,
) {
return this.contentsService.update(id, req.user.sub, updateContentDto);
}
@Delete(":id")
@UseGuards(AuthGuard)
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
return this.contentsService.remove(id, req.user.sub);
}
@Delete(":id/admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
removeAdmin(@Param("id") id: string) {
return this.contentsService.removeAdmin(id);
}
@Patch(":id/admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
updateAdmin(@Param("id") id: string, @Body() updateContentDto: any) {
return this.contentsService.updateAdmin(id, updateContentDto);
}
}

View File

@@ -1,7 +1,5 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { MediaModule } from "../media/media.module";
import { S3Module } from "../s3/s3.module";
import { ContentsController } from "./contents.controller";
@@ -9,7 +7,7 @@ import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository";
@Module({
imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule],
imports: [S3Module, AuthModule, MediaModule],
controllers: [ContentsController],
providers: [ContentsService, ContentsRepository],
exports: [ContentsRepository],

View File

@@ -23,6 +23,7 @@ describe("ContentsService", () => {
incrementViews: jest.fn(),
incrementUsage: jest.fn(),
softDelete: jest.fn(),
softDeleteAdmin: jest.fn(),
findOne: jest.fn(),
findBySlug: jest.fn(),
};
@@ -30,6 +31,7 @@ describe("ContentsService", () => {
const mockS3Service = {
getUploadUrl: jest.fn(),
uploadFile: jest.fn(),
getPublicUrl: jest.fn(),
};
const mockMediaService = {
@@ -146,4 +148,81 @@ describe("ContentsService", () => {
expect(result[0].views).toBe(1);
});
});
describe("incrementUsage", () => {
it("should increment usage", async () => {
mockContentsRepository.incrementUsage.mockResolvedValue([
{ id: "1", usageCount: 1 },
]);
await service.incrementUsage("1");
expect(mockContentsRepository.incrementUsage).toHaveBeenCalledWith("1");
});
});
describe("remove", () => {
it("should soft delete content", async () => {
mockContentsRepository.softDelete.mockResolvedValue({ id: "1" });
await service.remove("1", "u1");
expect(mockContentsRepository.softDelete).toHaveBeenCalledWith("1", "u1");
});
});
describe("removeAdmin", () => {
it("should soft delete content without checking owner", async () => {
mockContentsRepository.softDeleteAdmin.mockResolvedValue({ id: "1" });
await service.removeAdmin("1");
expect(mockContentsRepository.softDeleteAdmin).toHaveBeenCalledWith("1");
});
});
describe("findOne", () => {
it("should return content by id", async () => {
mockContentsRepository.findOne.mockResolvedValue({
id: "1",
storageKey: "k",
author: { avatarUrl: "a" },
});
mockS3Service.getPublicUrl.mockReturnValue("url");
const result = await service.findOne("1");
expect(result.id).toBe("1");
expect(result.url).toBe("url");
});
it("should return content by slug", async () => {
mockContentsRepository.findOne.mockResolvedValue({
id: "1",
slug: "s",
storageKey: "k",
});
const result = await service.findOne("s");
expect(result.slug).toBe("s");
});
});
describe("generateBotHtml", () => {
it("should generate html with og tags", () => {
const content = { title: "Title", storageKey: "k" };
mockS3Service.getPublicUrl.mockReturnValue("url");
const html = service.generateBotHtml(content as any);
expect(html).toContain("<title>Title</title>");
expect(html).toContain('content="Title"');
expect(html).toContain('content="url"');
});
});
describe("ensureUniqueSlug", () => {
it("should return original slug if unique", async () => {
mockContentsRepository.findBySlug.mockResolvedValue(null);
const slug = (service as any).ensureUniqueSlug("My Title");
await expect(slug).resolves.toBe("my-title");
});
it("should append counter if not unique", async () => {
mockContentsRepository.findBySlug
.mockResolvedValueOnce({ id: "1" })
.mockResolvedValueOnce(null);
const slug = await (service as any).ensureUniqueSlug("My Title");
expect(slug).toBe("my-title-1");
});
});
});

View File

@@ -100,6 +100,7 @@ export class ContentsService {
// 3. Upload vers S3
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, {
@@ -126,7 +127,18 @@ export class ContentsService {
this.contentsRepository.count(options),
]);
return { data, totalCount };
const processedData = data.map((content) => ({
...content,
url: this.s3Service.getPublicUrl(content.storageKey),
author: {
...content.author,
avatarUrl: content.author?.avatarUrl
? this.s3Service.getPublicUrl(content.author.avatarUrl)
: null,
},
}));
return { data: processedData, totalCount };
}
async create(userId: string, data: CreateContentDto) {
@@ -162,12 +174,63 @@ export class ContentsService {
return deleted;
}
async findOne(idOrSlug: string) {
return this.contentsRepository.findOne(idOrSlug);
async removeAdmin(id: string) {
this.logger.log(`Removing content ${id} by admin`);
const deleted = await this.contentsRepository.softDeleteAdmin(id);
if (deleted) {
await this.clearContentsCache();
}
return deleted;
}
async updateAdmin(id: string, data: any) {
this.logger.log(`Updating content ${id} by admin`);
const updated = await this.contentsRepository.update(id, data);
if (updated) {
await this.clearContentsCache();
}
return updated;
}
async update(id: string, userId: string, data: any) {
this.logger.log(`Updating content ${id} for user ${userId}`);
// Vérifier que le contenu appartient à l'utilisateur
const existing = await this.contentsRepository.findOne(id, userId);
if (!existing || existing.userId !== userId) {
throw new BadRequestException(
"Contenu non trouvé ou vous n'avez pas la permission de le modifier.",
);
}
const updated = await this.contentsRepository.update(id, data);
if (updated) {
await this.clearContentsCache();
}
return updated;
}
async findOne(idOrSlug: string, userId?: string) {
const content = await this.contentsRepository.findOne(idOrSlug, userId);
if (!content) return null;
return {
...content,
url: this.s3Service.getPublicUrl(content.storageKey),
author: {
...content.author,
avatarUrl: content.author?.avatarUrl
? this.s3Service.getPublicUrl(content.author.avatarUrl)
: null,
},
};
}
generateBotHtml(content: { title: string; storageKey: string }): string {
const imageUrl = this.getFileUrl(content.storageKey);
const imageUrl = this.s3Service.getPublicUrl(content.storageKey);
return `<!DOCTYPE html>
<html>
<head>
@@ -188,19 +251,6 @@ export class ContentsService {
</html>`;
}
getFileUrl(storageKey: string): string {
const endpoint = this.configService.get("S3_ENDPOINT");
const port = this.configService.get("S3_PORT");
const protocol =
this.configService.get("S3_USE_SSL") === true ? "https" : "http";
const bucket = this.configService.get("S3_BUCKET_NAME");
if (endpoint === "localhost" || endpoint === "127.0.0.1") {
return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`;
}
return `${protocol}://${endpoint}/${bucket}/${storageKey}`;
}
private generateSlug(text: string): string {
return text
.toLowerCase()

View File

@@ -6,6 +6,7 @@ import {
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
export enum ContentType {
@@ -19,14 +20,17 @@ export class CreateContentDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
title!: string;
@IsString()
@IsNotEmpty()
@MaxLength(512)
storageKey!: string;
@IsString()
@IsNotEmpty()
@MaxLength(128)
mimeType!: string;
@IsInt()
@@ -39,5 +43,6 @@ export class CreateContentDto {
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(64, { each: true })
tags?: string[];
}

View File

@@ -1,9 +1,11 @@
import {
IsArray,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
import { ContentType } from "./create-content.dto";
@@ -13,6 +15,7 @@ export class UploadContentDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
title!: string;
@IsOptional()
@@ -20,6 +23,8 @@ export class UploadContentDto {
categoryId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(64, { each: true })
tags?: string[];
}

View File

@@ -135,11 +135,20 @@ export class ContentsRepository {
fileSize: contents.fileSize,
views: contents.views,
usageCount: contents.usageCount,
favoritesCount:
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
Number,
),
isLiked: userId
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
: sql<boolean>`false`,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
author: {
id: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
category: {
id: categories.id,
@@ -215,7 +224,7 @@ export class ContentsRepository {
});
}
async findOne(idOrSlug: string) {
async findOne(idOrSlug: string, userId?: string) {
const [result] = await this.databaseService.db
.select({
id: contents.id,
@@ -227,11 +236,31 @@ export class ContentsRepository {
fileSize: contents.fileSize,
views: contents.views,
usageCount: contents.usageCount,
favoritesCount:
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
Number,
),
isLiked: userId
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
: sql<boolean>`false`,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
userId: contents.userId,
author: {
id: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
category: {
id: categories.id,
name: categories.name,
slug: categories.slug,
},
})
.from(contents)
.leftJoin(users, eq(contents.userId, users.uuid))
.leftJoin(categories, eq(contents.categoryId, categories.id))
.where(
and(
isNull(contents.deletedAt),
@@ -240,7 +269,20 @@ export class ContentsRepository {
)
.limit(1);
return result;
if (!result) return null;
const tagsForContent = await this.databaseService.db
.select({
name: tags.name,
})
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(eq(contentsToTags.contentId, result.id));
return {
...result,
tags: tagsForContent.map((t) => t.name),
};
}
async count(options: {
@@ -353,6 +395,24 @@ export class ContentsRepository {
return deleted;
}
async softDeleteAdmin(id: string) {
const [deleted] = await this.databaseService.db
.update(contents)
.set({ deletedAt: new Date() })
.where(eq(contents.id, id))
.returning();
return deleted;
}
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
const [updated] = await this.databaseService.db
.update(contents)
.set({ ...data, updatedAt: new Date() })
.where(eq(contents.id, id))
.returning();
return updated;
}
async findBySlug(slug: string) {
const [result] = await this.databaseService.db
.select()

View File

@@ -1,10 +1,11 @@
import { Module } from "@nestjs/common";
import { Global, Module } from "@nestjs/common";
import { CryptoService } from "./crypto.service";
import { EncryptionService } from "./services/encryption.service";
import { HashingService } from "./services/hashing.service";
import { JwtService } from "./services/jwt.service";
import { PostQuantumService } from "./services/post-quantum.service";
@Global()
@Module({
providers: [
CryptoService,

View File

@@ -1,7 +1,8 @@
import { Module } from "@nestjs/common";
import { Global, Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { DatabaseService } from "./database.service";
@Global()
@Module({
imports: [ConfigModule],
providers: [DatabaseService],

View File

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

View File

@@ -29,7 +29,9 @@ export const users = pgTable(
displayName: varchar("display_name", { length: 32 }),
username: varchar("username", { length: 32 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 95 }).notNull(),
passwordHash: varchar("password_hash", { length: 100 }).notNull(),
avatarUrl: varchar("avatar_url", { length: 512 }),
bio: varchar("bio", { length: 255 }),
// Sécurité
twoFactorSecret: pgpEncrypted("two_factor_secret"),

View File

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

View File

@@ -1,13 +1,11 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { FavoritesController } from "./favorites.controller";
import { FavoritesService } from "./favorites.service";
import { FavoritesRepository } from "./repositories/favorites.repository";
@Module({
imports: [DatabaseModule, AuthModule, CryptoModule],
imports: [AuthModule],
controllers: [FavoritesController],
providers: [FavoritesService, FavoritesRepository],
exports: [FavoritesService, FavoritesRepository],

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
import { Readable } from "node:stream";
import { NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import type { Response } from "express";
import { S3Service } from "../s3/s3.service";
import { MediaController } from "./media.controller";
describe("MediaController", () => {
let controller: MediaController;
const mockS3Service = {
getFileInfo: jest.fn(),
getFile: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MediaController],
providers: [{ provide: S3Service, useValue: mockS3Service }],
}).compile();
controller = module.get<MediaController>(MediaController);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("getFile", () => {
it("should stream the file and set headers with path containing slashes", async () => {
const res = {
setHeader: jest.fn(),
} as unknown as Response;
const stream = new Readable();
stream.pipe = jest.fn();
const key = "contents/user-id/test.webp";
mockS3Service.getFileInfo.mockResolvedValue({
size: 100,
metaData: { "content-type": "image/webp" },
});
mockS3Service.getFile.mockResolvedValue(stream);
await controller.getFile(key, res);
expect(mockS3Service.getFileInfo).toHaveBeenCalledWith(key);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/webp");
expect(res.setHeader).toHaveBeenCalledWith("Content-Length", 100);
expect(stream.pipe).toHaveBeenCalledWith(res);
});
it("should throw NotFoundException if path is missing", async () => {
const res = {} as unknown as Response;
await expect(controller.getFile("", res)).rejects.toThrow(NotFoundException);
});
it("should throw NotFoundException if file is not found", async () => {
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
const res = {} as unknown as Response;
await expect(controller.getFile("invalid", res)).rejects.toThrow(
NotFoundException,
);
});
});
});

View File

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

View File

@@ -1,9 +1,13 @@
import { Module } from "@nestjs/common";
import { S3Module } from "../s3/s3.module";
import { MediaController } from "./media.controller";
import { MediaService } from "./media.service";
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
@Module({
imports: [S3Module],
controllers: [MediaController],
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
exports: [MediaService],
})

View File

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

View File

@@ -83,8 +83,9 @@ export class MediaService implements IMediaService {
async processImage(
buffer: Buffer,
format: "webp" | "avif" = "webp",
resize?: { width?: number; height?: number },
): Promise<MediaProcessingResult> {
return this.imageProcessor.process(buffer, { format });
return this.imageProcessor.process(buffer, { format, resize });
}
async processVideo(

View File

@@ -13,11 +13,22 @@ export class ImageProcessorStrategy implements IMediaProcessorStrategy {
async process(
buffer: Buffer,
options: { format: "webp" | "avif" } = { format: "webp" },
options: {
format: "webp" | "avif";
resize?: { width?: number; height?: number };
} = { format: "webp" },
): Promise<MediaProcessingResult> {
try {
const { format } = options;
const { format, resize } = options;
let pipeline = sharp(buffer);
if (resize) {
pipeline = pipeline.resize(resize.width, resize.height, {
fit: "cover",
position: "center",
});
}
const metadata = await pipeline.metadata();
if (format === "webp") {

View File

@@ -1,4 +1,10 @@
import { IsEnum, IsOptional, IsString, IsUUID } from "class-validator";
import {
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
export enum ReportReason {
INAPPROPRIATE = "inappropriate",
@@ -21,5 +27,6 @@ export class CreateReportDto {
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
}

View File

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

View File

@@ -1,13 +1,11 @@
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
import { ReportsRepository } from "./repositories/reports.repository";
@Module({
imports: [DatabaseModule, forwardRef(() => AuthModule), CryptoModule],
imports: [forwardRef(() => AuthModule)],
controllers: [ReportsController],
providers: [ReportsService, ReportsRepository],
exports: [ReportsRepository, ReportsService],

View File

@@ -33,7 +33,7 @@ describe("ReportsService", () => {
describe("create", () => {
it("should create a report", async () => {
const reporterId = "u1";
const data = { contentId: "c1", reason: "spam" };
const data = { contentId: "c1", reason: "spam" } as const;
mockReportsRepository.create.mockResolvedValue({
id: "r1",
...data,

View File

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

View File

@@ -7,8 +7,7 @@ jest.mock("minio");
describe("S3Service", () => {
let service: S3Service;
let _configService: ConfigService;
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
let configService: ConfigService;
let minioClient: any;
beforeEach(async () => {
@@ -42,7 +41,7 @@ describe("S3Service", () => {
}).compile();
service = module.get<S3Service>(S3Service);
_configService = module.get<ConfigService>(ConfigService);
configService = module.get<ConfigService>(ConfigService);
});
it("should be defined", () => {
@@ -185,35 +184,39 @@ describe("S3Service", () => {
});
});
describe("moveFile", () => {
it("should move file within default bucket", async () => {
const source = "source.txt";
const dest = "dest.txt";
await service.moveFile(source, dest);
expect(minioClient.copyObject).toHaveBeenCalledWith(
"memegoat",
dest,
"/memegoat/source.txt",
expect.any(Minio.CopyConditions),
);
expect(minioClient.removeObject).toHaveBeenCalledWith("memegoat", source);
describe("getPublicUrl", () => {
it("should use API_URL if provided", () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
if (key === "API_URL") return "https://api.test.com";
return null;
});
const url = service.getPublicUrl("test.webp");
expect(url).toBe("https://api.test.com/media?path=test.webp");
});
it("should move file between different buckets", async () => {
const source = "source.txt";
const dest = "dest.txt";
const sBucket = "source-bucket";
const dBucket = "dest-bucket";
await service.moveFile(source, dest, sBucket, dBucket);
expect(minioClient.copyObject).toHaveBeenCalledWith(
dBucket,
dest,
`/${sBucket}/${source}`,
expect.any(Minio.CopyConditions),
it("should use DOMAIN_NAME and PORT for localhost", () => {
(configService.get as jest.Mock).mockImplementation(
(key: string, def: unknown) => {
if (key === "API_URL") return null;
if (key === "DOMAIN_NAME") return "localhost";
if (key === "PORT") return 3000;
return def;
},
);
expect(minioClient.removeObject).toHaveBeenCalledWith(sBucket, source);
const url = service.getPublicUrl("test.webp");
expect(url).toBe("http://localhost:3000/media?path=test.webp");
});
it("should use api.DOMAIN_NAME for production", () => {
(configService.get as jest.Mock).mockImplementation(
(key: string, def: unknown) => {
if (key === "API_URL") return null;
if (key === "DOMAIN_NAME") return "memegoat.fr";
return def;
},
);
const url = service.getPublicUrl("test.webp");
expect(url).toBe("https://api.memegoat.fr/media?path=test.webp");
});
});
});

View File

@@ -54,6 +54,7 @@ export class S3Service implements OnModuleInit, IStorageService {
...metaData,
"Content-Type": mimeType,
});
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
return fileName;
} catch (error) {
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`);
@@ -113,6 +114,7 @@ export class S3Service implements OnModuleInit, IStorageService {
async deleteFile(fileName: string, bucketName: string = this.bucketName) {
try {
await this.minioClient.removeObject(bucketName, fileName);
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
} catch (error) {
this.logger.error(
`Error deleting file from ${bucketName}: ${error.message}`,
@@ -155,4 +157,22 @@ export class S3Service implements OnModuleInit, IStorageService {
throw error;
}
}
getPublicUrl(storageKey: string): string {
const apiUrl = this.configService.get<string>("API_URL");
const domain = this.configService.get<string>("DOMAIN_NAME", "localhost");
const port = this.configService.get<number>("PORT", 3000);
let baseUrl: string;
if (apiUrl) {
baseUrl = apiUrl.replace(/\/$/, "");
} else if (domain === "localhost" || domain === "127.0.0.1") {
baseUrl = `http://${domain}:${port}`;
} else {
baseUrl = `https://api.${domain}`;
}
return `${baseUrl}/media?path=${storageKey}`;
}
}

View File

@@ -1,11 +1,8 @@
import { Module } from "@nestjs/common";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { SessionsRepository } from "./repositories/sessions.repository";
import { SessionsService } from "./sessions.service";
@Module({
imports: [DatabaseModule, CryptoModule],
providers: [SessionsService, SessionsRepository],
exports: [SessionsService, SessionsRepository],
})

View File

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

View File

@@ -1,13 +1,11 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { TagsRepository } from "./repositories/tags.repository";
import { TagsController } from "./tags.controller";
import { TagsService } from "./tags.service";
@Module({
imports: [DatabaseModule, AuthModule, CryptoModule],
imports: [AuthModule],
controllers: [TagsController],
providers: [TagsService, TagsRepository],
exports: [TagsService, TagsRepository],

View File

@@ -1,11 +1,13 @@
import { IsNotEmpty, IsString } from "class-validator";
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
export class UpdateConsentDto {
@IsString()
@IsNotEmpty()
@MaxLength(16)
termsVersion!: string;
@IsString()
@IsNotEmpty()
@MaxLength(16)
privacyVersion!: string;
}

View File

@@ -5,4 +5,21 @@ export class UpdateUserDto {
@IsString()
@MaxLength(32)
displayName?: string;
@IsOptional()
@IsString()
@MaxLength(255)
bio?: string;
@IsOptional()
@IsString()
avatarUrl?: string;
@IsOptional()
@IsString()
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
@IsOptional()
@IsString()
role?: string;
}

View File

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

View File

@@ -43,6 +43,8 @@ export class UsersRepository {
username: users.username,
email: users.email,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
createdAt: users.createdAt,
@@ -62,17 +64,21 @@ export class UsersRepository {
}
async findAll(limit: number, offset: number) {
return await this.databaseService.db
const result = await this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
email: users.email,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
status: users.status,
createdAt: users.createdAt,
})
.from(users)
.limit(limit)
.offset(offset);
return result;
}
async findByUsername(username: string) {
@@ -81,6 +87,8 @@ export class UsersRepository {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
createdAt: users.createdAt,
})
.from(users)

View File

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

View File

@@ -13,9 +13,11 @@ import {
Post,
Query,
Req,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { AuthService } from "../auth/auth.service";
import { Roles } from "../auth/decorators/roles.decorator";
import { AuthGuard } from "../auth/guards/auth.guard";
@@ -74,6 +76,16 @@ export class UsersController {
return this.usersService.update(req.user.sub, updateUserDto);
}
@Post("me/avatar")
@UseGuards(AuthGuard)
@UseInterceptors(FileInterceptor("file"))
updateAvatar(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: Express.Multer.File,
) {
return this.usersService.updateAvatar(req.user.sub, file);
}
@Patch("me/consent")
@UseGuards(AuthGuard)
updateConsent(
@@ -93,6 +105,23 @@ export class UsersController {
return this.usersService.remove(req.user.sub);
}
@Delete(":uuid")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
removeAdmin(@Param("uuid") uuid: string) {
return this.usersService.remove(uuid);
}
@Patch("admin/:uuid")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
updateAdmin(
@Param("uuid") uuid: string,
@Body() updateUserDto: UpdateUserDto,
) {
return this.usersService.update(uuid, updateUserDto);
}
// Double Authentification (2FA)
@Post("me/2fa/setup")
@UseGuards(AuthGuard)

View File

@@ -1,13 +1,13 @@
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { MediaModule } from "../media/media.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: [DatabaseModule, CryptoModule, forwardRef(() => AuthModule)],
imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService, UsersRepository],

View File

@@ -1,3 +1,7 @@
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),
}));
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: {
keygen: jest.fn(),
@@ -12,7 +16,11 @@ jest.mock("jose", () => ({
}));
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { RbacService } from "../auth/rbac.service";
import { MediaService } from "../media/media.service";
import { S3Service } from "../s3/s3.service";
import { UsersRepository } from "./repositories/users.repository";
import { UsersService } from "./users.service";
@@ -39,6 +47,24 @@ describe("UsersService", () => {
del: jest.fn(),
};
const mockRbacService = {
getUserRoles: jest.fn(),
};
const mockMediaService = {
scanFile: jest.fn(),
processImage: jest.fn(),
};
const mockS3Service = {
uploadFile: jest.fn(),
getPublicUrl: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
@@ -47,6 +73,10 @@ describe("UsersService", () => {
UsersService,
{ provide: UsersRepository, useValue: mockUsersRepository },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
{ provide: RbacService, useValue: mockRbacService },
{ provide: MediaService, useValue: mockMediaService },
{ provide: S3Service, useValue: mockS3Service },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
@@ -98,4 +128,112 @@ describe("UsersService", () => {
expect(result[0].displayName).toBe("New");
});
});
describe("clearUserCache", () => {
it("should delete cache", async () => {
await service.clearUserCache("u1");
expect(mockCacheManager.del).toHaveBeenCalledWith("users/profile/u1");
});
});
describe("findByEmailHash", () => {
it("should call repository.findByEmailHash", async () => {
mockUsersRepository.findByEmailHash.mockResolvedValue({ uuid: "u1" });
const result = await service.findByEmailHash("hash");
expect(result.uuid).toBe("u1");
expect(mockUsersRepository.findByEmailHash).toHaveBeenCalledWith("hash");
});
});
describe("findOneWithPrivateData", () => {
it("should return user with roles", async () => {
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
mockRbacService.getUserRoles.mockResolvedValue(["admin"]);
const result = await service.findOneWithPrivateData("u1");
expect(result.roles).toEqual(["admin"]);
});
});
describe("findAll", () => {
it("should return all users", async () => {
mockUsersRepository.findAll.mockResolvedValue([{ uuid: "u1" }]);
mockUsersRepository.countAll.mockResolvedValue(1);
const result = await service.findAll(10, 0);
expect(result.totalCount).toBe(1);
expect(result.data[0].uuid).toBe("u1");
});
});
describe("findPublicProfile", () => {
it("should return public profile", async () => {
mockUsersRepository.findByUsername.mockResolvedValue({
uuid: "u1",
username: "u1",
});
const result = await service.findPublicProfile("u1");
expect(result.username).toBe("u1");
});
});
describe("updateConsent", () => {
it("should update consent", async () => {
await service.updateConsent("u1", "v1", "v2");
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
termsVersion: "v1",
privacyVersion: "v2",
gdprAcceptedAt: expect.any(Date),
});
});
});
describe("setTwoFactorSecret", () => {
it("should set 2fa secret", async () => {
await service.setTwoFactorSecret("u1", "secret");
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
twoFactorSecret: "secret",
});
});
});
describe("toggleTwoFactor", () => {
it("should toggle 2fa", async () => {
await service.toggleTwoFactor("u1", true);
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
isTwoFactorEnabled: true,
});
});
});
describe("getTwoFactorSecret", () => {
it("should return 2fa secret", async () => {
mockUsersRepository.getTwoFactorSecret.mockResolvedValue("secret");
const result = await service.getTwoFactorSecret("u1");
expect(result).toBe("secret");
});
});
describe("exportUserData", () => {
it("should return all user data", async () => {
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
mockUsersRepository.getUserContents.mockResolvedValue([]);
mockUsersRepository.getUserFavorites.mockResolvedValue([]);
const result = await service.exportUserData("u1");
expect(result.profile).toBeDefined();
expect(result.contents).toBeDefined();
expect(result.favorites).toBeDefined();
});
});
describe("remove", () => {
it("should soft delete user", async () => {
await service.remove("u1");
expect(mockUsersRepository.softDeleteUserAndContents).toHaveBeenCalledWith(
"u1",
);
});
});
});

View File

@@ -1,6 +1,18 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable, Logger } from "@nestjs/common";
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
} from "@nestjs/common";
import type { Cache } from "cache-manager";
import { v4 as uuidv4 } from "uuid";
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 { S3Service } from "../s3/s3.service";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersRepository } from "./repositories/users.repository";
@@ -11,6 +23,10 @@ export class UsersService {
constructor(
private readonly usersRepository: UsersRepository,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
@Inject(forwardRef(() => RbacService))
private readonly rbacService: RbacService,
@Inject(MediaService) private readonly mediaService: IMediaService,
@Inject(S3Service) private readonly s3Service: IStorageService,
) {}
private async clearUserCache(username?: string) {
@@ -33,7 +49,21 @@ export class UsersService {
}
async findOneWithPrivateData(uuid: string) {
return await this.usersRepository.findOneWithPrivateData(uuid);
const [user, roles] = await Promise.all([
this.usersRepository.findOneWithPrivateData(uuid),
this.rbacService.getUserRoles(uuid),
]);
if (!user) return null;
return {
...user,
avatarUrl: user.avatarUrl
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
role: roles.includes("admin") ? "admin" : "user",
roles,
};
}
async findAll(limit: number, offset: number) {
@@ -42,11 +72,26 @@ export class UsersService {
this.usersRepository.countAll(),
]);
return { data, totalCount };
const processedData = data.map((user) => ({
...user,
avatarUrl: user.avatarUrl
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
}));
return { data: processedData, totalCount };
}
async findPublicProfile(username: string) {
return await this.usersRepository.findByUsername(username);
const user = await this.usersRepository.findByUsername(username);
if (!user) return null;
return {
...user,
avatarUrl: user.avatarUrl
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
};
}
async findOne(uuid: string) {
@@ -55,7 +100,14 @@ export class UsersService {
async update(uuid: string, data: UpdateUserDto) {
this.logger.log(`Updating user profile for ${uuid}`);
const result = await this.usersRepository.update(uuid, data);
const { role, ...userData } = data;
const result = await this.usersRepository.update(uuid, userData);
if (role) {
await this.rbacService.assignRoleToUser(uuid, role);
}
if (result[0]) {
await this.clearUserCache(result[0].username);
@@ -63,6 +115,48 @@ export class UsersService {
return result;
}
async updateAvatar(uuid: string, file: Express.Multer.File) {
this.logger.log(`Updating avatar for user ${uuid}`);
// Validation du format et de la taille
const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp"];
if (!allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException(
"Format d'image non supporté. Formats acceptés: png, jpeg, webp.",
);
}
if (file.size > 2 * 1024 * 1024) {
throw new BadRequestException("Image trop volumineuse. Limite: 2 Mo.");
}
// 1. Scan Antivirus
const scanResult = await this.mediaService.scanFile(
file.buffer,
file.originalname,
);
if (scanResult.isInfected) {
throw new BadRequestException(
`Le fichier est infecté par ${scanResult.virusName}`,
);
}
// 2. Traitement (WebP + Redimensionnement 512x512)
const processed = await this.mediaService.processImage(file.buffer, "webp", {
width: 512,
height: 512,
});
// 3. Upload vers S3
const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
this.logger.log(`Avatar uploaded successfully to S3: ${key}`);
// 4. Mise à jour de la base de données
const user = await this.update(uuid, { avatarUrl: key });
return user[0];
}
async updateConsent(
uuid: string,
termsVersion: string,

View File

@@ -9,6 +9,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-app}
networks:
- nw_memegoat
ports:
- "127.0.0.1:5432:5432" # not exposed to WAN, LAN only for administration checkup
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -35,6 +37,7 @@ services:
restart: always
networks:
- nw_memegoat
- nw_caddy
#ports:
# - "9000:9000"
# - "9001:9001"
@@ -101,8 +104,8 @@ services:
ENABLE_CORS: ${ENABLE_CORS:-true}
CLAMAV_HOST: memegoat-clamav
CLAMAV_PORT: 3310
MAX_IMAGE_SIZE_KB: 512
MAX_GIF_SIZE_KB: 1024
MAX_IMAGE_SIZE_KB: 1024
MAX_GIF_SIZE_KB: 4096
clamav:
image: clamav/clamav:latest

View File

@@ -1,4 +1,4 @@
# syntax=docker.io/docker/dockerfile:1
# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
@@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/
RUN pnpm install --no-frozen-lockfile
# Montage du cache pnpm
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
RUN pnpm install --no-frozen-lockfile
RUN pnpm run --filter @memegoat/documentation build
# Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
# Build avec cache Next.js
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \
pnpm run --filter @memegoat/documentation build
FROM node:22-alpine AS runner
WORKDIR /app

View File

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

View File

@@ -20,6 +20,13 @@ Le système utilise plusieurs méthodes d'authentification sécurisées pour ré
<Card title="Double Authentification" description="Support TOTP natif avec secret chiffré PGP pour une sécurité maximale." />
</Cards>
### Webhooks / Services Externes
### Stockage & Médias (S3)
Liste des intégrations tierces.
Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Les interactions se font de deux manières :
1. **Proxification Backend** : Pour l'accès public via `/media?path=...`.
2. **URLs Présignées** : Pour l'upload sécurisé direct depuis le client (via `/contents/upload-url`).
### Notifications (Mail)
Le système intègre un service d'envoi d'emails (SMTP) pour les notifications critiques et la gestion des comptes.

View File

@@ -35,10 +35,13 @@ erDiagram
string username
string email
string display_name
string avatar_url
string bio
string status
}
CONTENT {
string title
string slug
string type
string storage_key
}
@@ -82,6 +85,8 @@ erDiagram
bytea email
varchar email_hash
varchar display_name
varchar avatar_url
varchar bio
varchar password_hash
user_status status
bytea two_factor_secret
@@ -100,6 +105,7 @@ erDiagram
uuid category_id FK
content_type type
varchar title
varchar slug
varchar storage_key
varchar mime_type
integer file_size
@@ -233,6 +239,8 @@ erDiagram
varchar email_hash "UNIQUE, INDEXED"
varchar username "UNIQUE, NOT NULL"
varchar password_hash "NOT NULL"
varchar avatar_url "NULLABLE"
varchar bio "NULLABLE"
bytea two_factor_secret "ENCRYPTED"
boolean is_two_factor_enabled "DEFAULT false"
timestamp gdpr_accepted_at "NULLABLE"
@@ -241,6 +249,7 @@ erDiagram
contents {
uuid id "DEFAULT gen_random_uuid()"
uuid user_id "REFERENCES users(uuid)"
varchar slug "UNIQUE, NOT NULL"
varchar storage_key "UNIQUE, NOT NULL"
integer file_size "NOT NULL"
timestamp deleted_at "SOFT DELETE"

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/documentation",
"version": "0.0.1",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",

View File

@@ -1,4 +1,4 @@
# syntax=docker.io/docker/dockerfile:1
# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
@@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/
RUN pnpm install --no-frozen-lockfile
# Montage du cache pnpm
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
# On réinstalle après COPY pour s'assurer que tous les scripts de cycle de vie et les liens sont corrects
RUN pnpm install --no-frozen-lockfile
RUN pnpm run --filter @memegoat/frontend build
# Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
# Build avec cache Next.js
RUN --mount=type=cache,id=next-cache,target=/usr/src/app/frontend/.next/cache \
pnpm run --filter @memegoat/frontend build
FROM node:22-alpine AS runner
WORKDIR /app

View File

@@ -3,6 +3,18 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "memegoat.fr",
},
{
protocol: "https",
hostname: "api.memegoat.fr",
},
],
},
output: "standalone",
};

View File

@@ -1,12 +1,13 @@
{
"name": "@memegoat/frontend",
"version": "0.0.1",
"version": "1.4.1",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome check",
"lint:write": "biome check --write",
"format": "biome format --write"
},
"dependencies": {

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