Compare commits

...

60 Commits

Author SHA1 Message Date
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
109 changed files with 5976 additions and 282 deletions

View File

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

View File

@@ -1,61 +1,63 @@
name: Deploy to Production name: Deploy to Production
on: on:
push: push:
branches: branches:
- prod - main
jobs: jobs:
deploy: validate:
name: Validate Build & Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
component: [backend, frontend, documentation]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Get pnpm store directory - name: Get pnpm store directory
id: pnpm-cache
shell: bash shell: bash
run: | run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITEA_ENV echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ${{ env.STORE_PATH }} path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install --frozen-lockfile --prefer-offline
- name: Lint - Backend - name: Lint ${{ matrix.component }}
run: pnpm run lint:back run: pnpm -F @memegoat/${{ matrix.component }} lint
- name: Build - Backend - name: Build ${{ matrix.component }}
run: pnpm run build:back run: pnpm -F @memegoat/${{ matrix.component }} build
env: env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
- name: Lint - Frontend deploy:
run: pnpm run lint:front name: Deploy to Production
needs: validate
- name: Build - Frontend runs-on: ubuntu-latest
run: pnpm run build:front steps:
- name: Checkout code
- name: Lint - Documentation uses: actions/checkout@v4
run: pnpm run lint:docs
- name: Build - Documentation
run: pnpm run build:docs
- name: Deploy with Docker Compose - name: Deploy with Docker Compose
run: | run: |

View File

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

225
.output.txt Normal file
View File

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

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)
- [ ] 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, "when": 1768417827439,
"tag": "0004_cheerful_dakota_north", "tag": "0004_cheerful_dakota_north",
"breakpoints": true "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 PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate 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 backend/package.json ./backend/
COPY frontend/package.json ./frontend/ COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/ 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 . . 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 run --filter @memegoat/backend build
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
RUN cp -r backend/dist /app/dist RUN cp -r backend/dist /app/dist

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/backend", "name": "@memegoat/backend",
"version": "0.0.1", "version": "0.0.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -107,7 +107,7 @@
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node", "testEnvironment": "node",
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(jose|@noble)/)" "node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
], ],
"transform": { "transform": {
"^.+\\.(t|j)sx?$": "ts-jest" "^.+\\.(t|j)sx?$": "ts-jest"

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

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

View File

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

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

View File

@@ -1,22 +1,32 @@
import { forwardRef, Module } from "@nestjs/common"; 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 { SessionsModule } from "../sessions/sessions.module";
import { UsersModule } from "../users/users.module"; import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.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 { RbacService } from "./rbac.service";
import { RbacRepository } from "./repositories/rbac.repository"; import { RbacRepository } from "./repositories/rbac.repository";
@Module({ @Module({
imports: [ imports: [forwardRef(() => UsersModule), SessionsModule],
forwardRef(() => UsersModule),
CryptoModule,
SessionsModule,
DatabaseModule,
],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, RbacService, RbacRepository], providers: [
exports: [AuthService, RbacService, RbacRepository], AuthService,
RbacService,
RbacRepository,
AuthGuard,
OptionalAuthGuard,
RolesGuard,
],
exports: [
AuthService,
RbacService,
RbacRepository,
AuthGuard,
OptionalAuthGuard,
RolesGuard,
],
}) })
export class AuthModule {} export class AuthModule {}

View File

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

View File

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

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

@@ -1,13 +1,11 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { CategoriesController } from "./categories.controller"; import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service"; import { CategoriesService } from "./categories.service";
import { CategoriesRepository } from "./repositories/categories.repository"; import { CategoriesRepository } from "./repositories/categories.repository";
@Module({ @Module({
imports: [DatabaseModule, AuthModule, CryptoModule], imports: [AuthModule],
controllers: [CategoriesController], controllers: [CategoriesController],
providers: [CategoriesService, CategoriesRepository], providers: [CategoriesService, CategoriesRepository],
exports: [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 { export class CreateCategoryDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(64)
name!: string; name!: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(255)
description?: string; description?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(512)
iconUrl?: string; iconUrl?: string;
} }

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service"; import { DatabaseService } from "../../database/database.service";
import { categories } from "../../database/schemas"; import { categories } from "../../database/schemas";
import type { CreateCategoryDto } from "../dto/create-category.dto"; import type { CreateCategoryDto } from "../dto/create-category.dto";
@@ -16,6 +16,13 @@ export class CategoriesRepository {
.orderBy(categories.name); .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) { async findOne(id: string) {
const result = await this.databaseService.db const result = await this.databaseService.db
.select() .select()

View File

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

View File

@@ -33,4 +33,6 @@ export interface IStorageService {
sourceBucketName?: string, sourceBucketName?: string,
destinationBucketName?: string, destinationBucketName?: string,
): Promise<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(), MAIL_FROM: z.string().email(),
DOMAIN_NAME: z.string(), DOMAIN_NAME: z.string(),
API_URL: z.string().url().optional(),
// Sentry // Sentry
SENTRY_DSN: z.string().optional(), SENTRY_DSN: z.string().optional(),

View File

@@ -19,8 +19,11 @@ import {
UseInterceptors, UseInterceptors,
} from "@nestjs/common"; } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express"; 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 { 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 type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ContentsService } from "./contents.service"; import { ContentsService } from "./contents.service";
import { CreateContentDto } from "./dto/create-content.dto"; import { CreateContentDto } from "./dto/create-content.dto";
@@ -65,10 +68,12 @@ export class ContentsController {
} }
@Get("explore") @Get("explore")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@CacheTTL(60) @CacheTTL(60)
@Header("Cache-Control", "public, max-age=60") @Header("Cache-Control", "public, max-age=60")
explore( explore(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("sort") sort?: "trend" | "recent", @Query("sort") sort?: "trend" | "recent",
@@ -78,7 +83,7 @@ export class ContentsController {
@Query("query") query?: string, @Query("query") query?: string,
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe) @Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
favoritesOnly?: boolean, favoritesOnly?: boolean,
@Query("userId") userId?: string, @Query("userId") userIdQuery?: string,
) { ) {
return this.contentsService.findAll({ return this.contentsService.findAll({
limit, limit,
@@ -89,42 +94,57 @@ export class ContentsController {
author, author,
query, query,
favoritesOnly, favoritesOnly,
userId, userId: userIdQuery || req.user?.sub,
}); });
} }
@Get("trends") @Get("trends")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@CacheTTL(300) @CacheTTL(300)
@Header("Cache-Control", "public, max-age=300") @Header("Cache-Control", "public, max-age=300")
trends( trends(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: 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") @Get("recent")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@CacheTTL(60) @CacheTTL(60)
@Header("Cache-Control", "public, max-age=60") @Header("Cache-Control", "public, max-age=60")
recent( recent(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: 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") @Get(":idOrSlug")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@CacheTTL(3600) @CacheTTL(3600)
@Header("Cache-Control", "public, max-age=3600") @Header("Cache-Control", "public, max-age=3600")
async findOne( async findOne(
@Param("idOrSlug") idOrSlug: string, @Param("idOrSlug") idOrSlug: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
@Res() res: Response, @Res() res: Response,
) { ) {
const content = await this.contentsService.findOne(idOrSlug); const content = await this.contentsService.findOne(idOrSlug, req.user?.sub);
if (!content) { if (!content) {
throw new NotFoundException("Contenu non trouvé"); throw new NotFoundException("Contenu non trouvé");
} }
@@ -158,4 +178,11 @@ export class ContentsController {
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) { remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
return this.contentsService.remove(id, req.user.sub); 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);
}
} }

View File

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

View File

@@ -30,6 +30,7 @@ describe("ContentsService", () => {
const mockS3Service = { const mockS3Service = {
getUploadUrl: jest.fn(), getUploadUrl: jest.fn(),
uploadFile: jest.fn(), uploadFile: jest.fn(),
getPublicUrl: jest.fn(),
}; };
const mockMediaService = { const mockMediaService = {

View File

@@ -100,6 +100,7 @@ export class ContentsService {
// 3. Upload vers S3 // 3. Upload vers S3
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`; const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); 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 // 4. Création en base de données
return await this.create(userId, { return await this.create(userId, {
@@ -126,7 +127,18 @@ export class ContentsService {
this.contentsRepository.count(options), 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) { async create(userId: string, data: CreateContentDto) {
@@ -162,12 +174,34 @@ export class ContentsService {
return deleted; return deleted;
} }
async findOne(idOrSlug: string) { async removeAdmin(id: string) {
return this.contentsRepository.findOne(idOrSlug); this.logger.log(`Removing content ${id} by admin`);
const deleted = await this.contentsRepository.softDeleteAdmin(id);
if (deleted) {
await this.clearContentsCache();
}
return deleted;
}
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 { generateBotHtml(content: { title: string; storageKey: string }): string {
const imageUrl = this.getFileUrl(content.storageKey); const imageUrl = this.s3Service.getPublicUrl(content.storageKey);
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -188,19 +222,6 @@ export class ContentsService {
</html>`; </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 { private generateSlug(text: string): string {
return text return text
.toLowerCase() .toLowerCase()

View File

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

View File

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

View File

@@ -135,11 +135,20 @@ export class ContentsRepository {
fileSize: contents.fileSize, fileSize: contents.fileSize,
views: contents.views, views: contents.views,
usageCount: contents.usageCount, 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, createdAt: contents.createdAt,
updatedAt: contents.updatedAt, updatedAt: contents.updatedAt,
author: { author: {
id: users.uuid, id: users.uuid,
username: users.username, username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
}, },
category: { category: {
id: categories.id, 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 const [result] = await this.databaseService.db
.select({ .select({
id: contents.id, id: contents.id,
@@ -227,11 +236,31 @@ export class ContentsRepository {
fileSize: contents.fileSize, fileSize: contents.fileSize,
views: contents.views, views: contents.views,
usageCount: contents.usageCount, 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, createdAt: contents.createdAt,
updatedAt: contents.updatedAt, updatedAt: contents.updatedAt,
userId: contents.userId, 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) .from(contents)
.leftJoin(users, eq(contents.userId, users.uuid))
.leftJoin(categories, eq(contents.categoryId, categories.id))
.where( .where(
and( and(
isNull(contents.deletedAt), isNull(contents.deletedAt),
@@ -240,7 +269,20 @@ export class ContentsRepository {
) )
.limit(1); .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: { async count(options: {
@@ -353,6 +395,15 @@ export class ContentsRepository {
return deleted; 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 findBySlug(slug: string) { async findBySlug(slug: string) {
const [result] = await this.databaseService.db const [result] = await this.databaseService.db
.select() .select()

View File

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

View File

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

View File

@@ -29,7 +29,9 @@ export const users = pgTable(
displayName: varchar("display_name", { length: 32 }), displayName: varchar("display_name", { length: 32 }),
username: varchar("username", { length: 32 }).notNull().unique(), 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é // Sécurité
twoFactorSecret: pgpEncrypted("two_factor_secret"), twoFactorSecret: pgpEncrypted("two_factor_secret"),

View File

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

View File

@@ -0,0 +1,61 @@
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 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,30 @@
import { Controller, Get, NotFoundException, Param, 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 {
constructor(private readonly s3Service: S3Service) {}
@Get("*key")
async getFile(@Param("key") key: string, @Res() res: Response) {
try {
const stats = (await this.s3Service.getFileInfo(key)) as BucketItemStat;
const stream = await this.s3Service.getFile(key);
const contentType =
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) {
throw new NotFoundException("Fichier non trouvé");
}
}
}

View File

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

View File

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

View File

@@ -13,11 +13,22 @@ export class ImageProcessorStrategy implements IMediaProcessorStrategy {
async process( async process(
buffer: Buffer, buffer: Buffer,
options: { format: "webp" | "avif" } = { format: "webp" }, options: {
format: "webp" | "avif";
resize?: { width?: number; height?: number };
} = { format: "webp" },
): Promise<MediaProcessingResult> { ): Promise<MediaProcessingResult> {
try { try {
const { format } = options; const { format, resize } = options;
let pipeline = sharp(buffer); let pipeline = sharp(buffer);
if (resize) {
pipeline = pipeline.resize(resize.width, resize.height, {
fit: "cover",
position: "center",
});
}
const metadata = await pipeline.metadata(); const metadata = await pipeline.metadata();
if (format === "webp") { 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 { export enum ReportReason {
INAPPROPRIATE = "inappropriate", INAPPROPRIATE = "inappropriate",
@@ -21,5 +27,6 @@ export class CreateReportDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(1000)
description?: string; description?: string;
} }

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,7 @@ export class S3Service implements OnModuleInit, IStorageService {
...metaData, ...metaData,
"Content-Type": mimeType, "Content-Type": mimeType,
}); });
this.logger.log(`File uploaded successfully: ${fileName} to ${bucketName}`);
return fileName; return fileName;
} catch (error) { } catch (error) {
this.logger.error(`Error uploading file to ${bucketName}: ${error.message}`); 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) { async deleteFile(fileName: string, bucketName: string = this.bucketName) {
try { try {
await this.minioClient.removeObject(bucketName, fileName); await this.minioClient.removeObject(bucketName, fileName);
this.logger.log(`File deleted successfully: ${fileName} from ${bucketName}`);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Error deleting file from ${bucketName}: ${error.message}`, `Error deleting file from ${bucketName}: ${error.message}`,
@@ -155,4 +157,22 @@ export class S3Service implements OnModuleInit, IStorageService {
throw error; 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/${storageKey}`;
}
} }

View File

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

View File

@@ -1,13 +1,11 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; 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 { TagsRepository } from "./repositories/tags.repository";
import { TagsController } from "./tags.controller"; import { TagsController } from "./tags.controller";
import { TagsService } from "./tags.service"; import { TagsService } from "./tags.service";
@Module({ @Module({
imports: [DatabaseModule, AuthModule, CryptoModule], imports: [AuthModule],
controllers: [TagsController], controllers: [TagsController],
providers: [TagsService, TagsRepository], providers: [TagsService, TagsRepository],
exports: [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 { export class UpdateConsentDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(16)
termsVersion!: string; termsVersion!: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(16)
privacyVersion!: string; privacyVersion!: string;
} }

View File

@@ -5,4 +5,13 @@ export class UpdateUserDto {
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
displayName?: string; displayName?: string;
@IsOptional()
@IsString()
@MaxLength(255)
bio?: string;
@IsOptional()
@IsString()
avatarUrl?: string;
} }

View File

@@ -43,6 +43,8 @@ export class UsersRepository {
username: users.username, username: users.username,
email: users.email, email: users.email,
displayName: users.displayName, displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
status: users.status, status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled, isTwoFactorEnabled: users.isTwoFactorEnabled,
createdAt: users.createdAt, createdAt: users.createdAt,
@@ -66,7 +68,9 @@ export class UsersRepository {
.select({ .select({
uuid: users.uuid, uuid: users.uuid,
username: users.username, username: users.username,
email: users.email,
displayName: users.displayName, displayName: users.displayName,
avatarUrl: users.avatarUrl,
status: users.status, status: users.status,
createdAt: users.createdAt, createdAt: users.createdAt,
}) })
@@ -81,6 +85,8 @@ export class UsersRepository {
uuid: users.uuid, uuid: users.uuid,
username: users.username, username: users.username,
displayName: users.displayName, displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
createdAt: users.createdAt, createdAt: users.createdAt,
}) })
.from(users) .from(users)

View File

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

View File

@@ -1,13 +1,13 @@
import { forwardRef, Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module"; import { MediaModule } from "../media/media.module";
import { DatabaseModule } from "../database/database.module"; import { S3Module } from "../s3/s3.module";
import { UsersRepository } from "./repositories/users.repository"; import { UsersRepository } from "./repositories/users.repository";
import { UsersController } from "./users.controller"; import { UsersController } from "./users.controller";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
@Module({ @Module({
imports: [DatabaseModule, CryptoModule, forwardRef(() => AuthModule)], imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService, UsersRepository], providers: [UsersService, UsersRepository],
exports: [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", () => ({ jest.mock("@noble/post-quantum/ml-kem.js", () => ({
ml_kem768: { ml_kem768: {
keygen: jest.fn(), keygen: jest.fn(),
@@ -12,7 +16,11 @@ jest.mock("jose", () => ({
})); }));
import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing"; 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 { UsersRepository } from "./repositories/users.repository";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
@@ -39,6 +47,24 @@ describe("UsersService", () => {
del: jest.fn(), 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 () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -47,6 +73,10 @@ describe("UsersService", () => {
UsersService, UsersService,
{ provide: UsersRepository, useValue: mockUsersRepository }, { provide: UsersRepository, useValue: mockUsersRepository },
{ provide: CACHE_MANAGER, useValue: mockCacheManager }, { provide: CACHE_MANAGER, useValue: mockCacheManager },
{ provide: RbacService, useValue: mockRbacService },
{ provide: MediaService, useValue: mockMediaService },
{ provide: S3Service, useValue: mockS3Service },
{ provide: ConfigService, useValue: mockConfigService },
], ],
}).compile(); }).compile();

View File

@@ -1,6 +1,18 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager"; 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 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 { UpdateUserDto } from "./dto/update-user.dto";
import { UsersRepository } from "./repositories/users.repository"; import { UsersRepository } from "./repositories/users.repository";
@@ -11,6 +23,10 @@ export class UsersService {
constructor( constructor(
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
@Inject(CACHE_MANAGER) private cacheManager: Cache, @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) { private async clearUserCache(username?: string) {
@@ -33,7 +49,21 @@ export class UsersService {
} }
async findOneWithPrivateData(uuid: string) { 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) { async findAll(limit: number, offset: number) {
@@ -42,11 +72,26 @@ export class UsersService {
this.usersRepository.countAll(), 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) { 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) { async findOne(uuid: string) {
@@ -63,6 +108,48 @@ export class UsersService {
return result; 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( async updateConsent(
uuid: string, uuid: string,
termsVersion: string, termsVersion: string,

View File

@@ -101,8 +101,8 @@ services:
ENABLE_CORS: ${ENABLE_CORS:-true} ENABLE_CORS: ${ENABLE_CORS:-true}
CLAMAV_HOST: memegoat-clamav CLAMAV_HOST: memegoat-clamav
CLAMAV_PORT: 3310 CLAMAV_PORT: 3310
MAX_IMAGE_SIZE_KB: 512 MAX_IMAGE_SIZE_KB: 1024
MAX_GIF_SIZE_KB: 1024 MAX_GIF_SIZE_KB: 4096
clamav: clamav:
image: clamav/clamav:latest 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 FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
@@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/ COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/ COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/ 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 . . 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 pnpm run --filter @memegoat/documentation build 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 FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app

View File

@@ -1,4 +1,4 @@
# syntax=docker.io/docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:22-alpine AS base FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
@@ -11,11 +11,20 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY backend/package.json ./backend/ COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/ COPY frontend/package.json ./frontend/
COPY documentation/package.json ./documentation/ 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 . . 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 pnpm run --filter @memegoat/frontend build 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 FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "0.0.1", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

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

View File

@@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
export default function AdminCategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
CategoryService.getAll()
.then(setCategories)
.catch((err) => console.error(err))
.finally(() => setLoading(false));
}, []);
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">
Catégories ({categories.length})
</h2>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-[150px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[150px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[250px]" />
</TableCell>
</TableRow>
))
) : categories.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center h-24">
Aucune catégorie trouvée.
</TableCell>
</TableRow>
) : (
categories.map((category) => (
<TableRow key={category.id}>
<TableCell className="font-medium whitespace-nowrap">
{category.name}
</TableCell>
<TableCell className="whitespace-nowrap">{category.slug}</TableCell>
<TableCell className="text-muted-foreground">
{category.description || "Aucune description"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Download, Eye, Image as ImageIcon, Trash2, Video } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ContentService } from "@/services/content.service";
import type { Content } from "@/types/content";
export default function AdminContentsPage() {
const [contents, setContents] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
ContentService.getExplore({ limit: 20 })
.then((res) => {
setContents(res.data);
setTotalCount(res.totalCount);
})
.catch((err) => console.error(err))
.finally(() => setLoading(false));
}, []);
const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return;
try {
await ContentService.removeAdmin(id);
setContents(contents.filter((c) => c.id !== id));
setTotalCount((prev) => prev - 1);
} catch (error) {
console.error(error);
}
};
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">
Contenus ({totalCount})
</h2>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Contenu</TableHead>
<TableHead>Catégorie</TableHead>
<TableHead>Auteur</TableHead>
<TableHead>Stats</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
<TableRow key={i}>
<TableCell>
<Skeleton className="h-10 w-[200px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[80px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
</TableRow>
))
) : contents.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center h-24">
Aucun contenu trouvé.
</TableCell>
</TableRow>
) : (
contents.map((content) => (
<TableRow key={content.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
{content.type === "image" ? (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
) : (
<Video className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div>
<div className="font-semibold">{content.title}</div>
<div className="text-xs text-muted-foreground">
{content.type} {content.mimeType}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{content.category?.name || "Sans catégorie"}
</Badge>
</TableCell>
<TableCell>@{content.author.username}</TableCell>
<TableCell>
<div className="flex flex-col gap-1 text-xs">
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" /> {content.views}
</div>
<div className="flex items-center gap-1">
<Download className="h-3 w-3" /> {content.usageCount}
</div>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(content.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { AlertCircle, FileText, LayoutGrid, Users } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { type AdminStats, adminService } from "@/services/admin.service";
export default function AdminDashboardPage() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
adminService
.getStats()
.then(setStats)
.catch((err) => {
console.error(err);
setError("Impossible de charger les statistiques.");
})
.finally(() => setLoading(false));
}, []);
if (error) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center gap-4 text-center">
<AlertCircle className="h-12 w-12 text-destructive" />
<p className="text-xl font-semibold">{error}</p>
</div>
);
}
const statCards = [
{
title: "Utilisateurs",
value: stats?.users,
icon: Users,
href: "/admin/users",
color: "text-blue-500",
},
{
title: "Contenus",
value: stats?.contents,
icon: FileText,
href: "/admin/contents",
color: "text-green-500",
},
{
title: "Catégories",
value: stats?.categories,
icon: LayoutGrid,
href: "/admin/categories",
color: "text-purple-500",
},
];
return (
<div className="flex-1 space-y-8 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Dashboard Admin</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{statCards.map((card) => (
<Link key={card.title} href={card.href}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<card.icon className={`h-4 w-4 ${card.color}`} />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<div className="text-2xl font-bold">{card.value}</div>
)}
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
UserService.getUsersAdmin()
.then((res) => {
setUsers(res.data);
setTotalCount(res.totalCount);
})
.catch((err) => {
console.error(err);
})
.finally(() => setLoading(false));
}, []);
const handleDelete = async (uuid: string) => {
if (
!confirm(
"Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.",
)
)
return;
try {
await UserService.removeUserAdmin(uuid);
setUsers(users.filter((u) => u.uuid !== uuid));
setTotalCount((prev) => prev - 1);
} catch (error) {
console.error(error);
}
};
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">
Utilisateurs ({totalCount})
</h2>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Utilisateur</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date d'inscription</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-[150px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[200px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[50px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[80px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
</TableRow>
))
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center h-24">
Aucun utilisateur trouvé.
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.uuid}>
<TableCell className="font-medium whitespace-nowrap">
{user.displayName || user.username}
<div className="text-xs text-muted-foreground">@{user.username}</div>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
{user.role}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "success" : "destructive"}>
{user.status}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
{format(new Date(user.createdAt), "PPP", { locale: fr })}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.uuid)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { HelpCircle } from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export default function HelpPage() {
const faqs = [
{
question: "Comment puis-je publier un mème ?",
answer:
"Pour publier un mème, vous devez être connecté à votre compte. Cliquez sur le bouton 'Publier' dans la barre latérale, choisissez votre fichier (image ou GIF), donnez-lui un titre et une catégorie, puis validez.",
},
{
question: "Quels formats de fichiers sont acceptés ?",
answer:
"Nous acceptons les images au format PNG, JPEG, WebP et les GIF animés. La taille maximale recommandée est de 2 Mo.",
},
{
question: "Comment fonctionnent les favoris ?",
answer:
"En cliquant sur l'icône de cœur sur un mème, vous l'ajoutez à vos favoris. Vous pouvez retrouver tous vos mèmes favoris dans l'onglet 'Mes Favoris' de votre profil.",
},
{
question: "Puis-je supprimer un mème que j'ai publié ?",
answer:
"Oui, vous pouvez supprimer vos propres mèmes en vous rendant sur votre profil, en sélectionnant le mème et en cliquant sur l'option de suppression.",
},
{
question: "Comment fonctionne le système de recherche ?",
answer:
"Vous pouvez rechercher des mèmes par titre en utilisant la barre de recherche dans la colonne de droite. Vous pouvez également filtrer par catégories ou par tags populaires.",
},
];
return (
<div className="max-w-3xl mx-auto py-12 px-4">
<div className="flex items-center gap-3 mb-8">
<div className="bg-primary/10 p-3 rounded-xl">
<HelpCircle className="h-6 w-6 text-primary" />
</div>
<h1 className="text-3xl font-bold">Centre d'aide</h1>
</div>
<div className="bg-white dark:bg-zinc-900 border rounded-2xl p-6 shadow-sm mb-12">
<h2 className="text-xl font-semibold mb-6">Foire Aux Questions</h2>
<Accordion type="single" collapsible className="w-full">
{faqs.map((faq, index) => (
<AccordionItem key={faq.question} value={`item-${index}`}>
<AccordionTrigger className="text-left">{faq.question}</AccordionTrigger>
<AccordionContent className="text-muted-foreground leading-relaxed">
{faq.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<div className="text-center space-y-4">
<h2 className="text-lg font-medium">Vous ne trouvez pas de réponse ?</h2>
<p className="text-muted-foreground">
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
</p>
<p className="font-semibold text-primary">contact@memegoat.fr</p>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { UserNavMobile } from "@/components/user-nav-mobile";
export default function DashboardLayout({ export default function DashboardLayout({
children, children,
@@ -16,26 +17,31 @@ export default function DashboardLayout({
modal: React.ReactNode; modal: React.ReactNode;
}) { }) {
return ( return (
<SidebarProvider> <React.Suspense fallback={null}>
<AppSidebar /> <SidebarProvider>
<SidebarInset className="flex flex-row overflow-hidden"> <AppSidebar />
<div className="flex-1 flex flex-col min-w-0"> <SidebarInset className="flex flex-row overflow-hidden">
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden"> <div className="flex-1 flex flex-col min-w-0">
<SidebarTrigger /> <header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden sticky top-0 bg-background z-40">
<div className="flex-1" /> <SidebarTrigger />
</header> <div className="flex-1 flex justify-center">
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950"> <span className="font-bold text-primary text-lg">MemeGoat</span>
{children} </div>
{modal} <UserNavMobile />
</main> </header>
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
{children}
{modal}
</main>
<React.Suspense fallback={null}>
<MobileFilters />
</React.Suspense>
</div>
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<MobileFilters /> <SearchSidebar />
</React.Suspense> </React.Suspense>
</div> </SidebarInset>
<React.Suspense fallback={null}> </SidebarProvider>
<SearchSidebar /> </React.Suspense>
</React.Suspense>
</SidebarInset>
</SidebarProvider>
); );
} }

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContentCard } from "@/components/content-card"; import { ContentCard } from "@/components/content-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ViewCounter } from "@/components/view-counter";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
export const revalidate = 3600; // ISR: Revalider toutes les heures export const revalidate = 3600; // ISR: Revalider toutes les heures
@@ -40,6 +41,7 @@ export default async function MemePage({
return ( return (
<div className="max-w-4xl mx-auto py-8 px-4"> <div className="max-w-4xl mx-auto py-8 px-4">
<ViewCounter contentId={content.id} />
<Link <Link
href="/" href="/"
className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors" className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors"

View File

@@ -1,19 +1,52 @@
"use client"; "use client";
import { Calendar, LogOut, Settings } from "lucide-react"; import { Calendar, Camera, LogIn, LogOut, Settings } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner";
import { ContentList } from "@/components/content-list"; import { ContentList } from "@/components/content-list";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service"; import { FavoriteService } from "@/services/favorite.service";
import { UserService } from "@/services/user.service";
export default function ProfilePage() { export default function ProfilePage() {
const { user, isAuthenticated, isLoading, logout } = useAuth(); const { user, isAuthenticated, isLoading, logout, refreshUser } = useAuth();
const searchParams = useSearchParams();
const tab = searchParams.get("tab") || "memes";
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleAvatarClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
try {
await UserService.updateAvatar(file);
toast.success("Avatar mis à jour avec succès !");
await refreshUser?.();
} catch (error) {
console.error(error);
toast.error("Erreur lors de la mise à jour de l'avatar.");
}
};
const fetchMyMemes = React.useCallback( const fetchMyMemes = React.useCallback(
(params: { limit: number; offset: number }) => (params: { limit: number; offset: number }) =>
@@ -26,21 +59,64 @@ export default function ProfilePage() {
[], [],
); );
if (isLoading) return null; if (isLoading) {
return (
<div className="flex h-[400px] items-center justify-center">
<Spinner className="h-8 w-8 text-primary" />
</div>
);
}
if (!isAuthenticated || !user) { if (!isAuthenticated || !user) {
redirect("/login"); return (
<div className="max-w-2xl mx-auto py-8 px-4 text-center">
<Card className="p-12">
<CardHeader>
<div className="mx-auto bg-primary/10 p-4 rounded-full w-fit mb-4">
<LogIn className="h-8 w-8 text-primary" />
</div>
<CardTitle>Profil inaccessible</CardTitle>
<CardDescription>
Vous devez être connecté pour voir votre profil, vos mèmes et vos
favoris.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full sm:w-auto">
<Link href="/login">Se connecter</Link>
</Button>
</CardContent>
</Card>
</div>
);
} }
return ( return (
<div className="max-w-4xl mx-auto py-8 px-4"> <div className="max-w-4xl mx-auto py-8 px-4">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
<div className="flex flex-col md:flex-row items-center gap-8"> <div className="flex flex-col md:flex-row items-center gap-8">
<Avatar className="h-32 w-32 border-4 border-primary/10"> <div className="relative group">
<AvatarImage src={user.avatarUrl} alt={user.username} /> <Avatar className="h-32 w-32 border-4 border-primary/10">
<AvatarFallback className="text-4xl"> <AvatarImage src={user.avatarUrl} alt={user.username} />
{user.username.slice(0, 2).toUpperCase()} <AvatarFallback className="text-4xl">
</AvatarFallback> {user.username.slice(0, 2).toUpperCase()}
</Avatar> </AvatarFallback>
</Avatar>
<button
type="button"
onClick={handleAvatarClick}
className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<Camera className="h-8 w-8" />
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
</div>
<div className="flex-1 text-center md:text-left space-y-4"> <div className="flex-1 text-center md:text-left space-y-4">
<div> <div>
<h1 className="text-3xl font-bold"> <h1 className="text-3xl font-bold">
@@ -48,6 +124,9 @@ export default function ProfilePage() {
</h1> </h1>
<p className="text-muted-foreground">@{user.username}</p> <p className="text-muted-foreground">@{user.username}</p>
</div> </div>
{user.bio && (
<p className="max-w-md text-sm leading-relaxed">{user.bio}</p>
)}
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground"> <div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
@@ -79,10 +158,14 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
<Tabs defaultValue="memes" className="w-full"> <Tabs value={tab} className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-8"> <TabsList className="grid w-full grid-cols-2 mb-8">
<TabsTrigger value="memes">Mes Mèmes</TabsTrigger> <TabsTrigger value="memes" asChild>
<TabsTrigger value="favorites">Mes Favoris</TabsTrigger> <Link href="/profile?tab=memes">Mes Mèmes</Link>
</TabsTrigger>
<TabsTrigger value="favorites" asChild>
<Link href="/profile?tab=favorites">Mes Favoris</Link>
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="memes"> <TabsContent value="memes">
<ContentList fetchFn={fetchMyMemes} /> <ContentList fetchFn={fetchMyMemes} />

View File

@@ -1,15 +1,20 @@
"use client"; import type { Metadata } from "next";
import * as React from "react"; import * as React from "react";
import { ContentList } from "@/components/content-list"; import { HomeContent } from "@/components/home-content";
import { ContentService } from "@/services/content.service";
export const metadata: Metadata = {
title: "Nouveautés",
description: "Les tout derniers mèmes fraîchement débarqués sur MemeGoat.",
};
export default function RecentPage() { export default function RecentPage() {
const fetchFn = React.useCallback( return (
(params: { limit: number; offset: number }) => <React.Suspense
ContentService.getRecent(params.limit, params.offset), fallback={
[], <div className="p-8 text-center">Chargement des nouveautés...</div>
}
>
<HomeContent defaultSort="recent" />
</React.Suspense>
); );
return <ContentList fetchFn={fetchFn} title="Nouveaux Mèmes" />;
} }

View File

@@ -0,0 +1,190 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Save, User as UserIcon } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider";
import { UserService } from "@/services/user.service";
const settingsSchema = z.object({
displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(),
bio: z.string().max(255, "La bio est trop longue").optional(),
});
type SettingsFormValues = z.infer<typeof settingsSchema>;
export default function SettingsPage() {
const { user, isLoading, refreshUser } = useAuth();
const [isSaving, setIsSaving] = React.useState(false);
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
defaultValues: {
displayName: "",
bio: "",
},
});
React.useEffect(() => {
if (user) {
form.reset({
displayName: user.displayName || "",
bio: user.bio || "",
});
}
}, [user, form]);
if (isLoading) {
return (
<div className="flex h-[400px] items-center justify-center">
<Spinner className="h-8 w-8 text-primary" />
</div>
);
}
if (!user) {
return (
<div className="max-w-2xl mx-auto py-8 px-4 text-center">
<Card>
<CardHeader>
<CardTitle>Accès refusé</CardTitle>
<CardDescription>
Vous devez être connecté pour accéder aux paramètres.
</CardDescription>
</CardHeader>
</Card>
</div>
);
}
const onSubmit = async (values: SettingsFormValues) => {
setIsSaving(true);
try {
await UserService.updateMe(values);
toast.success("Paramètres mis à jour !");
await refreshUser();
} catch (error) {
console.error(error);
toast.error("Erreur lors de la mise à jour des paramètres.");
} finally {
setIsSaving(false);
}
};
return (
<div className="max-w-2xl mx-auto py-12 px-4">
<div className="flex items-center gap-3 mb-8">
<div className="bg-primary/10 p-3 rounded-xl">
<UserIcon className="h-6 w-6 text-primary" />
</div>
<h1 className="text-3xl font-bold">Paramètres du profil</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Informations personnelles</CardTitle>
<CardDescription>
Mettez à jour vos informations publiques. Ces données seront visibles par
les autres utilisateurs.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-4">
<FormItem>
<FormLabel>Nom d'utilisateur</FormLabel>
<FormControl>
<Input
value={user.username}
disabled
className="bg-zinc-50 dark:bg-zinc-900"
/>
</FormControl>
<FormDescription>
Le nom d'utilisateur ne peut pas être modifié.
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Nom d'affichage</FormLabel>
<FormControl>
<Input placeholder="Votre nom" {...field} />
</FormControl>
<FormDescription>
Le nom qui sera affiché sur votre profil et vos mèmes.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Racontez-nous quelque chose sur vous..."
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
Une courte description de vous (max 255 caractères).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les modifications
</>
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,15 +1,18 @@
"use client"; import type { Metadata } from "next";
import * as React from "react"; import * as React from "react";
import { ContentList } from "@/components/content-list"; import { HomeContent } from "@/components/home-content";
import { ContentService } from "@/services/content.service";
export const metadata: Metadata = {
title: "Tendances",
description: "Découvrez les mèmes les plus populaires du moment sur MemeGoat.",
};
export default function TrendsPage() { export default function TrendsPage() {
const fetchFn = React.useCallback( return (
(params: { limit: number; offset: number }) => <React.Suspense
ContentService.getTrends(params.limit, params.offset), fallback={<div className="p-8 text-center">Chargement des tendances...</div>}
[], >
<HomeContent defaultSort="trend" />
</React.Suspense>
); );
return <ContentList fetchFn={fetchFn} title="Top Tendances" />;
} }

View File

@@ -1,15 +1,22 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Image as ImageIcon, Loader2, Upload, X } from "lucide-react"; import { Image as ImageIcon, Loader2, LogIn, Upload, X } from "lucide-react";
import NextImage from "next/image"; import NextImage from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import * as z from "zod"; import * as z from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { import {
Form, Form,
FormControl, FormControl,
@@ -27,6 +34,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { useAuth } from "@/providers/auth-provider";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import type { Category } from "@/types/content"; import type { Category } from "@/types/content";
@@ -42,6 +51,7 @@ type UploadFormValues = z.infer<typeof uploadSchema>;
export default function UploadPage() { export default function UploadPage() {
const router = useRouter(); const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
const [file, setFile] = React.useState<File | null>(null); const [file, setFile] = React.useState<File | null>(null);
const [preview, setPreview] = React.useState<string | null>(null); const [preview, setPreview] = React.useState<string | null>(null);
@@ -57,8 +67,42 @@ export default function UploadPage() {
}); });
React.useEffect(() => { React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error); if (isAuthenticated) {
}, []); CategoryService.getAll().then(setCategories).catch(console.error);
}
}, [isAuthenticated]);
if (isLoading) {
return (
<div className="flex h-[400px] items-center justify-center">
<Spinner className="h-8 w-8 text-primary" />
</div>
);
}
if (!isAuthenticated) {
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<Card className="text-center p-12">
<CardHeader>
<div className="mx-auto bg-primary/10 p-4 rounded-full w-fit mb-4">
<LogIn className="h-8 w-8 text-primary" />
</div>
<CardTitle>Connexion requise</CardTitle>
<CardDescription>
Vous devez être connecté pour partager vos meilleurs mèmes avec la
communauté.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full sm:w-auto">
<Link href="/login">Se connecter</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]; const selectedFile = e.target.files?.[0];

View File

@@ -0,0 +1,98 @@
"use client";
import { Calendar, User as UserIcon } from "lucide-react";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Spinner } from "@/components/ui/spinner";
import { ContentService } from "@/services/content.service";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
export default function PublicProfilePage({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = React.use(params);
const [user, setUser] = React.useState<User | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
UserService.getProfile(username)
.then(setUser)
.catch(console.error)
.finally(() => setLoading(false));
}, [username]);
const fetchUserMemes = React.useCallback(
(params: { limit: number; offset: number }) =>
ContentService.getExplore({ ...params, author: username }),
[username],
);
if (loading) {
return (
<div className="flex h-[400px] items-center justify-center">
<Spinner className="h-8 w-8 text-primary" />
</div>
);
}
if (!user) {
return (
<div className="max-w-2xl mx-auto py-12 px-4 text-center">
<div className="bg-primary/10 p-4 rounded-full w-fit mx-auto mb-4">
<UserIcon className="h-8 w-8 text-primary" />
</div>
<h1 className="text-2xl font-bold">Utilisateur non trouvé</h1>
<p className="text-muted-foreground mt-2">
Le membre @{username} n'existe pas ou a quitté le troupeau.
</p>
</div>
);
}
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
<div className="flex flex-col md:flex-row items-center gap-8">
<Avatar className="h-32 w-32 border-4 border-primary/10">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="text-4xl">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 text-center md:text-left space-y-4">
<div>
<h1 className="text-3xl font-bold">
{user.displayName || user.username}
</h1>
<p className="text-muted-foreground">@{user.username}</p>
</div>
{user.bio && (
<p className="max-w-md text-sm leading-relaxed mx-auto md:mx-0">
{user.bio}
</p>
)}
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Membre depuis{" "}
{new Date(user.createdAt).toLocaleDateString("fr-FR", {
month: "long",
year: "numeric",
})}
</span>
</div>
</div>
</div>
</div>
<div className="space-y-8">
<h2 className="text-xl font-bold border-b pb-4">Ses mèmes</h2>
<ContentList fetchFn={fetchUserMemes} />
</div>
</div>
);
}

View File

@@ -16,7 +16,37 @@ const ubuntuMono = Ubuntu_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "MemeGoat", title: {
default: "MemeGoat | Partagez vos meilleurs mèmes",
template: "%s | MemeGoat",
},
description:
"MemeGoat est la plateforme ultime pour découvrir, créer et partager les mèmes les plus drôles de la communauté des chèvres.",
keywords: ["meme", "drôle", "goat", "chèvre", "humour", "partage", "gif"],
authors: [{ name: "MemeGoat Team" }],
creator: "MemeGoat Team",
openGraph: {
type: "website",
locale: "fr_FR",
url: "https://memegoat.local",
siteName: "MemeGoat",
title: "MemeGoat | Partagez vos meilleurs mèmes",
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
images: [
{
url: "/memegoat-og.png",
width: 1200,
height: 630,
alt: "MemeGoat",
},
],
},
twitter: {
card: "summary_large_image",
title: "MemeGoat | Partagez vos meilleurs mèmes",
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
images: ["/memegoat-og.png"],
},
icons: "/memegoat-color.svg", icons: "/memegoat-color.svg",
}; };

View File

@@ -3,18 +3,21 @@
import { import {
ChevronRight, ChevronRight,
Clock, Clock,
Heart,
HelpCircle, HelpCircle,
History,
Home, Home,
LayoutGrid, LayoutGrid,
LogIn, LogIn,
LogOut, LogOut,
PlusCircle, PlusCircle,
Settings, Settings,
ShieldCheck,
TrendingUp, TrendingUp,
User as UserIcon, User as UserIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
@@ -68,6 +71,7 @@ const mainNav = [
export function AppSidebar() { export function AppSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams();
const { user, logout, isAuthenticated } = useAuth(); const { user, logout, isAuthenticated } = useAuth();
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
@@ -149,6 +153,60 @@ export function AppSidebar() {
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Ma Bibliothèque</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={
pathname === "/profile" && searchParams.get("tab") === "favorites"
}
tooltip="Mes Favoris"
>
<Link href="/profile?tab=favorites">
<Heart />
<span>Mes Favoris</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={
pathname === "/profile" && searchParams.get("tab") === "memes"
}
tooltip="Mes Mèmes"
>
<Link href="/profile?tab=memes">
<History />
<span>Mes Mèmes</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
{isAuthenticated && user?.role === "admin" && (
<SidebarGroup>
<SidebarGroupLabel>Administration</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname.startsWith("/admin")}
tooltip="Dashboard Admin"
>
<Link href="/admin">
<ShieldCheck />
<span>Admin</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)}
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<SidebarMenu> <SidebarMenu>
@@ -170,7 +228,7 @@ export function AppSidebar() {
<span className="truncate font-semibold"> <span className="truncate font-semibold">
{user.displayName || user.username} {user.displayName || user.username}
</span> </span>
<span className="truncate text-xs">{user.email}</span> <span className="truncate text-xs">{user.role}</span>
</div> </div>
<ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" /> <ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton> </SidebarMenuButton>
@@ -193,7 +251,7 @@ export function AppSidebar() {
<span className="truncate font-semibold"> <span className="truncate font-semibold">
{user.displayName || user.username} {user.displayName || user.username}
</span> </span>
<span className="truncate text-xs">{user.email}</span> <span className="truncate text-xs">{user.role}</span>
</div> </div>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>

View File

@@ -16,6 +16,7 @@ import {
CardHeader, CardHeader,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service"; import { FavoriteService } from "@/services/favorite.service";
import type { Content } from "@/types/content"; import type { Content } from "@/types/content";
@@ -26,9 +27,14 @@ interface ContentCardProps {
export function ContentCard({ content }: ContentCardProps) { export function ContentCard({ content }: ContentCardProps) {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const router = useRouter(); const router = useRouter();
const [isLiked, setIsLiked] = React.useState(false); const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount); const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
React.useEffect(() => {
setIsLiked(content.isLiked || false);
setLikesCount(content.favoritesCount);
}, [content.isLiked, content.favoritesCount]);
const handleLike = async (e: React.MouseEvent) => { const handleLike = async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -54,6 +60,17 @@ export function ContentCard({ content }: ContentCardProps) {
} }
}; };
const handleUse = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
await ContentService.incrementUsage(content.id);
toast.success("Mème prêt à être utilisé !");
} catch (_error) {
toast.error("Une erreur est survenue");
}
};
return ( return (
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow"> <Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3"> <CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
@@ -118,7 +135,12 @@ export function ContentCard({ content }: ContentCardProps) {
<Share2 className="h-4 w-4" /> <Share2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
<Button size="sm" variant="secondary" className="text-xs h-8"> <Button
size="sm"
variant="secondary"
className="text-xs h-8"
onClick={handleUse}
>
Utiliser Utiliser
</Button> </Button>
</div> </div>

View File

@@ -5,10 +5,14 @@ import * as React from "react";
import { ContentList } from "@/components/content-list"; import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
export function HomeContent() { export function HomeContent({
defaultSort = "trend",
}: {
defaultSort?: "trend" | "recent";
}) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const sort = (searchParams.get("sort") as "trend" | "recent") || "trend"; const sort = (searchParams.get("sort") as "trend" | "recent") || defaultSort;
const category = searchParams.get("category") || undefined; const category = searchParams.get("category") || undefined;
const tag = searchParams.get("tag") || undefined; const tag = searchParams.get("tag") || undefined;
const query = searchParams.get("query") || undefined; const query = searchParams.get("query") || undefined;

View File

@@ -16,7 +16,8 @@ import {
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content"; import { TagService } from "@/services/tag.service";
import type { Category, Tag } from "@/types/content";
export function MobileFilters() { export function MobileFilters() {
const router = useRouter(); const router = useRouter();
@@ -24,12 +25,16 @@ export function MobileFilters() {
const pathname = usePathname(); const pathname = usePathname();
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
const [query, setQuery] = React.useState(searchParams.get("query") || ""); const [query, setQuery] = React.useState(searchParams.get("query") || "");
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (open) { if (open) {
CategoryService.getAll().then(setCategories).catch(console.error); CategoryService.getAll().then(setCategories).catch(console.error);
TagService.getAll({ limit: 10, sort: "popular" })
.then(setPopularTags)
.catch(console.error);
} }
}, [open]); }, [open]);
@@ -127,19 +132,25 @@ export function MobileFilters() {
<div> <div>
<h3 className="text-sm font-medium mb-3">Tags populaires</h3> <h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map( {popularTags.map((tag) => (
(tag) => ( <Badge
<Badge key={tag.id}
key={tag} variant={
variant={searchParams.get("tag") === tag ? "default" : "outline"} searchParams.get("tag") === tag.name ? "default" : "outline"
className="cursor-pointer" }
onClick={() => className="cursor-pointer"
updateSearch("tag", searchParams.get("tag") === tag ? null : tag) onClick={() =>
} updateSearch(
> "tag",
#{tag} searchParams.get("tag") === tag.name ? null : tag.name,
</Badge> )
), }
>
#{tag.name}
</Badge>
))}
{popularTags.length === 0 && (
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -8,7 +8,8 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content"; import { TagService } from "@/services/tag.service";
import type { Category, Tag } from "@/types/content";
export function SearchSidebar() { export function SearchSidebar() {
const router = useRouter(); const router = useRouter();
@@ -16,10 +17,14 @@ export function SearchSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
const [query, setQuery] = React.useState(searchParams.get("query") || ""); const [query, setQuery] = React.useState(searchParams.get("query") || "");
React.useEffect(() => { React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error); CategoryService.getAll().then(setCategories).catch(console.error);
TagService.getAll({ limit: 10, sort: "popular" })
.then(setPopularTags)
.catch(console.error);
}, []); }, []);
const updateSearch = React.useCallback( const updateSearch = React.useCallback(
@@ -116,19 +121,23 @@ export function SearchSidebar() {
<div> <div>
<h3 className="text-sm font-medium mb-3">Tags populaires</h3> <h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map( {popularTags.map((tag) => (
(tag) => ( <Badge
<Badge key={tag.id}
key={tag} variant={searchParams.get("tag") === tag.name ? "default" : "outline"}
variant={searchParams.get("tag") === tag ? "default" : "outline"} className="cursor-pointer hover:bg-secondary"
className="cursor-pointer hover:bg-secondary" onClick={() =>
onClick={() => updateSearch(
updateSearch("tag", searchParams.get("tag") === tag ? null : tag) "tag",
} searchParams.get("tag") === tag.name ? null : tag.name,
> )
#{tag} }
</Badge> >
), #{tag.name}
</Badge>
))}
{popularTags.length === 0 && (
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -15,6 +15,8 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
success:
"border-transparent bg-emerald-500 text-white [a&]:hover:bg-emerald-500/90",
outline: outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
}, },

View File

@@ -53,8 +53,6 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="breadcrumb-page" data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page" aria-current="page"
className={cn("text-foreground font-normal", className)} className={cn("text-foreground font-normal", className)}
{...props} {...props}

View File

@@ -26,6 +26,7 @@ function ButtonGroup({
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return ( return (
// biome-ignore lint/a11y/useSemanticElements: standard pattern for button groups
<div <div
role="group" role="group"
data-slot="button-group" data-slot="button-group"

View File

@@ -117,6 +117,7 @@ function Carousel({
canScrollNext, canScrollNext,
}} }}
> >
{/* biome-ignore lint/a11y/useSemanticElements: standard pattern for carousels */}
<div <div
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
className={cn("relative", className)} className={cn("relative", className)}
@@ -156,6 +157,7 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel(); const { orientation } = useCarousel();
return ( return (
// biome-ignore lint/a11y/useSemanticElements: standard pattern for carousel items
<div <div
role="group" role="group"
aria-roledescription="slide" aria-roledescription="slide"

View File

@@ -83,6 +83,7 @@ function Field({
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return ( return (
// biome-ignore lint/a11y/useSemanticElements: standard pattern for field components
<div <div
role="group" role="group"
data-slot="field" data-slot="field"

View File

@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) { function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
// biome-ignore lint/a11y/useSemanticElements: standard pattern for input groups
<div <div
data-slot="input-group" data-slot="input-group"
role="group" role="group"
@@ -62,6 +63,7 @@ function InputGroupAddon({
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return ( return (
// biome-ignore lint/a11y/useSemanticElements: standard pattern for input groups
<div <div
role="group" role="group"
data-slot="input-group-addon" data-slot="input-group-addon"

View File

@@ -68,7 +68,7 @@ function InputOTPSlot({
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return ( return (
<div data-slot="input-otp-separator" role="separator" {...props}> <div data-slot="input-otp-separator" aria-hidden="true" {...props}>
<MinusIcon /> <MinusIcon />
</div> </div>
); );

View File

@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
// biome-ignore lint/a11y/useSemanticElements: standard pattern for item groups
<div <div
role="list" role="list"
data-slot="item-group" data-slot="item-group"

View File

@@ -82,6 +82,7 @@ function SidebarProvider({
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
// biome-ignore lint/suspicious/noDocumentCookie: persistence of sidebar state
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open], [setOpenProp, open],

View File

@@ -0,0 +1,36 @@
"use client";
import { LogIn } from "lucide-react";
import Link from "next/link";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/providers/auth-provider";
export function UserNavMobile() {
const { user, isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <div className="h-8 w-8 rounded-full bg-zinc-200 animate-pulse" />;
}
if (!isAuthenticated || !user) {
return (
<Button variant="ghost" size="icon" asChild className="h-9 w-9">
<Link href="/login">
<LogIn className="h-5 w-5" />
</Link>
</Button>
);
}
return (
<Button variant="ghost" size="icon" asChild className="h-9 w-9 p-0">
<Link href="/profile">
<Avatar className="h-8 w-8 border">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback>{user.username.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</Button>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect, useRef } from "react";
import { ContentService } from "@/services/content.service";
interface ViewCounterProps {
contentId: string;
}
export function ViewCounter({ contentId }: ViewCounterProps) {
const hasIncremented = useRef(false);
useEffect(() => {
if (!hasIncremented.current) {
ContentService.incrementViews(contentId).catch((err) => {
console.error("Failed to increment views:", err);
});
hasIncremented.current = true;
}
}, [contentId]);
return null;
}

View File

@@ -8,6 +8,23 @@ const api = axios.create({
}, },
}); });
// Interceptor for Server-Side Rendering to pass cookies
api.interceptors.request.use(async (config) => {
if (typeof window === "undefined") {
try {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
if (cookieHeader) {
config.headers.Cookie = cookieHeader;
}
} catch (_error) {
// Fail silently if cookies() is not available (e.g. during build)
}
}
return config;
});
// Système anti-spam rudimentaire pour les erreurs répétitives // Système anti-spam rudimentaire pour les erreurs répétitives
const errorCache = new Map<string, number>(); const errorCache = new Map<string, number>();
const SPAM_THRESHOLD_MS = 2000; // 2 secondes de silence après une erreur sur le même endpoint const SPAM_THRESHOLD_MS = 2000; // 2 secondes de silence après une erreur sur le même endpoint
@@ -19,14 +36,35 @@ api.interceptors.response.use(
errorCache.delete(url); errorCache.delete(url);
return response; return response;
}, },
(error) => { async (error) => {
const originalRequest = error.config;
// Handle Token Refresh (401 Unauthorized)
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes("/auth/refresh") &&
!originalRequest.url?.includes("/auth/login")
) {
originalRequest._retry = true;
try {
await api.post("/auth/refresh");
return api(originalRequest);
} catch (refreshError) {
// If refresh fails, we might want to redirect to login on the client
if (typeof window !== "undefined") {
window.location.href = "/login";
}
return Promise.reject(refreshError);
}
}
const url = error.config?.url || "unknown"; const url = error.config?.url || "unknown";
const now = Date.now(); const now = Date.now();
const lastErrorTime = errorCache.get(url); const lastErrorTime = errorCache.get(url);
if (lastErrorTime && now - lastErrorTime < SPAM_THRESHOLD_MS) { if (lastErrorTime && now - lastErrorTime < SPAM_THRESHOLD_MS) {
// Ignorer l'erreur si elle se produit trop rapidement (déjà signalée) // Ignorer l'erreur si elle se produit trop rapidement (déjà signalée)
// On retourne une promesse qui ne se résout jamais ou on rejette avec une marque spéciale
return new Promise(() => {}); return new Promise(() => {});
} }

View File

@@ -0,0 +1,14 @@
import api from "@/lib/api";
export interface AdminStats {
users: number;
contents: number;
categories: number;
}
export const adminService = {
getStats: async (): Promise<AdminStats> => {
const response = await api.get("/admin/stats");
return response.data;
},
};

View File

@@ -61,4 +61,8 @@ export const ContentService = {
}); });
return data; return data;
}, },
async removeAdmin(id: string): Promise<void> {
await api.delete(`/contents/${id}/admin`);
},
}; };

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