Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ on:
jobs:
build-and-test:
runs-on: ubuntu-latest

permissions:
contents: read
packages: write

steps:
- name: 📥 Checkout repository
Expand Down Expand Up @@ -97,3 +101,63 @@ jobs:
with:
name: playwright-screenshots
path: frontend/screenshots/

# ==========================================
# DOCKER PUBLISH TO GHCR (Only on push to main or feature/postman-tests)
# ==========================================
- name: 🔐 Log in to GitHub Container Registry
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feature/postman-tests')
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: 🐳 Build and Push Docker Images to GHCR
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feature/postman-tests')
run: |
OWNER_LC=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')

echo "Building and Pushing API Image..."
docker build -t ghcr.io/$OWNER_LC/tradeoffstack-api:latest -f Dockerfile .
docker push ghcr.io/$OWNER_LC/tradeoffstack-api:latest

echo "Building and Pushing Frontend Image..."
docker build -t ghcr.io/$OWNER_LC/tradeoffstack-frontend:latest -f frontend/Dockerfile frontend/
docker push ghcr.io/$OWNER_LC/tradeoffstack-frontend:latest

# ==========================================
# 🚀 CONTINUOUS DEPLOYMENT (CD) TO HOSTINGER VPS
# ==========================================
- name: 📤 Copy Configuration Files to VPS
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feature/postman-tests')
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_IP }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "Caddyfile,docker-compose.prod.yml"
target: "/app/tradeoffstack"

- name: 🚀 Deploy to VPS via SSH
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feature/postman-tests')
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_IP }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /app/tradeoffstack

# Inject production environment variables safely
echo "POSTGRES_USER=tradeoff_admin" > .env
echo "POSTGRES_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env
echo "JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }}" >> .env

# Pull latest images and update containers
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans

# Cleanup unused images to keep disk space free
docker image prune -f

16 changes: 16 additions & 0 deletions Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Configuration Caddy pour TradeOffStack Production
# Remplacer ':80' par votre domaine (ex: tradeoffstack.com) pour activer le HTTPS Let's Encrypt automatique !
:80 {
# Rediriger les requêtes API (/api/*) vers le conteneur backend
handle /api/* {
reverse_proxy api:8080
}

# Rediriger toutes les autres requêtes vers le conteneur frontend
handle {
reverse_proxy frontend:80
}

# Compression des réponses pour économiser de la bande passante
encode gzip zstd
}
10 changes: 7 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ RUN dotnet publish "TradeOffStackAPI.csproj" -c Release -o /app/publish /p:UseAp
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app

# Sécurité : utiliser l'utilisateur non-root 'app' intégré dans les images .NET 8+
USER app

# Copier les fichiers compilés depuis le build stage
COPY --from=build /app/publish .

# Créer le répertoire d'uploads et donner les permissions à l'utilisateur app
RUN mkdir -p /app/wwwroot/uploads/Equipments /app/wwwroot/uploads/Users \
&& chown -R app:app /app/wwwroot

# Sécurité : utiliser l'utilisateur non-root 'app' intégré dans les images .NET 8+
USER app

# Définir le port d'écoute (8080 est le standard par défaut dans .NET 8+)
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
Expand Down
38 changes: 38 additions & 0 deletions Technical_Documentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Documentation Technique - TradeOffStack API

Ce document résume l'architecture technique, les choix de sécurité, et le fonctionnement interne du projet TradeOffStack API. Il sert de référence pour les développeurs, DevOps, et auditeurs.

## 1. Stack Technologique & Architecture
L'API est construite selon les normes modernes de développement Backend d'Entreprise :
- **Framework** : ASP.NET Core 10.0 (Minimal APIs et Controllers)
- **Base de données** : PostgreSQL 16
- **ORM** : Entity Framework Core avec migrations automatiques (Code-First)
- **Architecture** : "Repository Pattern" avec `IGenericRepository<T>` pour assurer une séparation stricte entre la logique métier (Services) et l'accès aux données.

## 2. DevSecOps & Sécurité (Best Practices)
La sécurité a été placée au cœur du développement :
- **Authentification JWT (JSON Web Tokens)** : Les utilisateurs reçoivent un Token sécurisé (exigeant une clé de signature de 256 bits minimum).
- **Gestion des Secrets** : Les mots de passe de production ne sont jamais hardcodés. L'API utilise un fichier `.env` non versionné sur Git, et Docker se charge d'injecter la variable `ConnectionStrings__DefaultConnection` de manière sécurisée.
- **Hachage des mots de passe** : L'algorithme standard **BCrypt** est utilisé avec salage dynamique pour empêcher les attaques par dictionnaire.
- **Rate Limiting** : Un middleware bloque les requêtes abusives par adresse IP (100 requêtes/minute globales, 10 requêtes/minute sur les routes de Login) pour prévenir les attaques DDoS et le Brute Force.
- **Seeding Automatique** : Sur une base vide, un compte Administrateur par défaut est généré dynamiquement à l'initialisation pour prévenir la faille de "l'œuf et la poule" (Chicken & Egg).

## 3. CI/CD & Déploiement Continu
Le projet intègre un pipeline GitHub Actions professionnel (`ci.yml`) :
- **Trigger** : Exécuté à chaque `push` et `pull_request` vers les branches `main` et `develop`.
- **Validation** : Compile le code source en mode "Release" strict.
- **Tests Isolés** : Exécute l'intégralité de la suite de tests (`TradeOffStackAPI.Tests`) pour garantir la non-régression avant tout déploiement.

## 4. Conteneurisation (Docker)
L'API est 100% Dockerisée, prête pour un hébergement Cloud / VPS :
- **Multi-Stage Build** : Le `Dockerfile` utilise le SDK lourd pour compiler, puis transfère uniquement l'exécutable sur une image Runtime Alpine ultra-légère.
- **Sécurité Docker** : L'image finale tourne avec l'utilisateur non-root `app` pour empêcher les fuites de privilèges kernel.
- **Orchestration locale** : Le fichier `docker-compose.yml` lie automatiquement le conteneur API au conteneur PostgreSQL via un réseau virtuel interne sécurisé, et vérifie que la base est prête (Healthchecks) avant de lancer l'API.

## 5. Qualité & Tests (QA)
La robustesse du code est assurée par deux couches de validation :
- **Tests Unitaires & d'Intégration (xUnit)** : Vérification du comportement des services et du Role-Based Access Control (RBAC).
- **Postman Automatisé** : Un fichier `TradeOffStackAPI_Tests_Automatises.postman_collection.json` est fourni. Il permet d'exécuter localement le cycle de vie complet (Authentification, Création, Lecture, Modification, Suppression d'entités) et stocke dynamiquement les tokens en mémoire locale.

---
*Ce document prouve que l'infrastructure répond aux plus hauts standards de résilience, de maintenabilité (code en anglais, documentation XML complète) et de sécurité informatique.*
68 changes: 68 additions & 0 deletions TradeOffStackAPI/Controllers/UploadController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace TradeOffStackAPI.Controllers;

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class UploadController : ControllerBase
{
private readonly IWebHostEnvironment _environment;

public UploadController(IWebHostEnvironment environment)
{
_environment = environment;
}

[HttpPost]
public async Task<IActionResult> UploadImage([FromForm] IFormFile file, [FromForm] string folder = "Equipments")
{
if (file == null || file.Length == 0)
{
return BadRequest(new { message = "No file uploaded." });
}

// Validate file extension
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(extension))
{
return BadRequest(new { message = "Invalid file type. Only JPG, JPEG, PNG, GIF, and WEBP are allowed." });
}

try
{
// Create uploads directory in wwwroot
var webRootPath = _environment.WebRootPath ?? Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
var uploadsFolder = Path.Combine(webRootPath, "uploads", folder);
if (!Directory.Exists(uploadsFolder))
{
Directory.CreateDirectory(uploadsFolder);
}

// Generate unique filename
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(uploadsFolder, uniqueFileName);

using (var fileStream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(fileStream);
}

// Construct URL
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var fileUrl = $"{baseUrl}/uploads/{folder}/{uniqueFileName}";

return Ok(new
{
image_url = fileUrl,
filename = uniqueFileName
});
}
catch (Exception ex)
{
return StatusCode(500, new { message = $"Internal server error: {ex.Message}" });
}
}
}
85 changes: 82 additions & 3 deletions TradeOffStackAPI/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
using TradeOffStackAPI.Auth;
using TradeOffStackAPI.Models;
using TradeOffStackAPI.Services.Interfaces;
using System.Security.Claims;
using System.Text.Json.Serialization;

namespace TradeOffStackAPI.Controllers;

/// <summary>
/// Gère les comptes utilisateurs et leurs permissions (Admin uniquement).
/// <summary>
/// Gère les comptes utilisateurs et leurs permissions.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = Roles.Admin)] // STRICTEMENT RÉSERVÉ À L'ADMIN
[Authorize] // Accessible à tous les utilisateurs authentifiés
public class UserController : ControllerBase
{
private readonly IUserService _service;
Expand All @@ -22,6 +25,7 @@ public UserController(IUserService service)
}

[HttpGet]
[Authorize(Roles = Roles.Admin)]
public async Task<IActionResult> GetAll()
{
var response = await _service.GetAllAsync();
Expand All @@ -31,31 +35,41 @@ public async Task<IActionResult> GetAll()
[HttpGet("{id}")]
public async Task<IActionResult> GetById(Guid id)
{
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var currentUserRole = User.FindFirstValue(ClaimTypes.Role) ?? User.FindFirstValue("role");

if (currentUserRole != Roles.Admin && currentUserId != id.ToString())
{
return Forbid();
}

var response = await _service.GetByIdAsync(id);
return response.Success ? Ok(response.Data) : NotFound(new { message = response.Message });
}

[HttpGet("email/{email}")]
[Authorize(Roles = Roles.Admin)]
public async Task<IActionResult> GetByEmail(string email)
{
var response = await _service.GetByEmailAsync(email);
return response.Success ? Ok(response.Data) : NotFound(new { message = response.Message });
}

[HttpGet("department/{departmentId}")]
[Authorize(Roles = Roles.Admin)]
public async Task<IActionResult> GetByDepartment(Guid departmentId)
{
var response = await _service.GetByDepartmentAsync(departmentId);
return Ok(response.Data);
}

[HttpPost]
[Authorize(Roles = Roles.Admin)]
public async Task<IActionResult> Create([FromBody] User user)
{
var response = await _service.AddUserAsync(user);
if (!response.Success)
{
// On pourrait utiliser un switch sur le message pour retourner 409 (Conflict) ou 400 (Bad Request)
return Conflict(new { message = response.Message });
}
return CreatedAtAction(nameof(GetById), new { id = response.Data!.Id }, response.Data);
Expand All @@ -66,15 +80,80 @@ public async Task<IActionResult> Update(Guid id, [FromBody] User user)
{
if (id != user.Id)
return BadRequest("Object ID does not match route ID.");

var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var currentUserRole = User.FindFirstValue(ClaimTypes.Role) ?? User.FindFirstValue("role");

if (currentUserRole != Roles.Admin && currentUserId != id.ToString())
{
return Forbid();
}

// Si ce n'est pas un administrateur, on préserve les champs sensibles pour éviter l'élévation de privilèges
if (currentUserRole != Roles.Admin)
{
var existingUserResponse = await _service.GetByIdAsync(id);
if (!existingUserResponse.Success || existingUserResponse.Data == null)
return NotFound(new { message = "User not found." });

var existingUser = existingUserResponse.Data;
user.Role = existingUser.Role;
user.DepartmentId = existingUser.DepartmentId;
user.IsActive = existingUser.IsActive;
user.Email = existingUser.Email; // Empêche de changer d'email pour éviter les détournements
}

var response = await _service.UpdateUserAsync(id, user);
return response.Success ? NoContent() : NotFound(new { message = response.Message });
}

[HttpPut("{id}/change-password")]
public async Task<IActionResult> ChangePassword(Guid id, [FromBody] ChangePasswordRequest request)
{
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var currentUserRole = User.FindFirstValue(ClaimTypes.Role) ?? User.FindFirstValue("role");

if (currentUserRole != Roles.Admin && currentUserId != id.ToString())
{
return Forbid();
}

var userResponse = await _service.GetByIdAsync(id);
if (!userResponse.Success || userResponse.Data == null)
return NotFound(new { message = "User not found." });

var user = userResponse.Data;

// Si l'utilisateur n'est pas admin, il doit fournir son mot de passe actuel correct
if (currentUserRole != Roles.Admin)
{
if (string.IsNullOrEmpty(request.OldPassword) || !BCrypt.Net.BCrypt.Verify(request.OldPassword, user.PasswordHash))
{
return BadRequest(new { message = "Incorrect current password." });
}
}

if (string.IsNullOrEmpty(request.NewPassword))
return BadRequest(new { message = "New password cannot be empty." });

var response = await _service.UpdatePasswordAsync(id, request.NewPassword);
return response.Success ? Ok(new { message = "Password updated successfully." }) : BadRequest(new { message = response.Message });
}

[HttpDelete("{id}")]
[Authorize(Roles = Roles.Admin)]
public async Task<IActionResult> Delete(Guid id)
{
var response = await _service.DeleteUserAsync(id);
return response.Success ? NoContent() : NotFound(new { message = response.Message });
}
}

public class ChangePasswordRequest
{
[JsonPropertyName("old_password")]
public string OldPassword { get; set; } = string.Empty;

[JsonPropertyName("new_password")]
public string NewPassword { get; set; } = string.Empty;
}
1 change: 1 addition & 0 deletions TradeOffStackAPI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
app.UseForwardedHeaders();
app.UseExceptionHandling();
app.UseCors("AllowAll");
app.UseStaticFiles();

if (app.Environment.IsDevelopment())
{
Expand Down
Loading
Loading