feat: v1
This commit is contained in:
106
backend/internal/auth/auth.go
Normal file
106
backend/internal/auth/auth.go
Normal file
@ -0,0 +1,106 @@
|
||||
// Package auth gère le hachage des mots de passe, la génération/validation
|
||||
// des JWT et des refresh tokens.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ErrInvalidToken est renvoyé quand un access token est invalide ou expiré.
|
||||
var ErrInvalidToken = errors.New("token invalide")
|
||||
|
||||
// Manager encapsule la logique d'authentification (secret JWT, durées de vie).
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
// NewManager crée un gestionnaire d'authentification.
|
||||
func NewManager(secret []byte, accessTTL, refreshTTL time.Duration) *Manager {
|
||||
return &Manager{secret: secret, accessTTL: accessTTL, refreshTTL: refreshTTL}
|
||||
}
|
||||
|
||||
// HashPassword retourne le hash bcrypt d'un mot de passe en clair.
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// CheckPassword compare un mot de passe en clair avec son hash bcrypt.
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// Claims représente le contenu d'un access token.
|
||||
type Claims struct {
|
||||
UserID string `json:"uid"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateAccessToken crée un access token JWT signé pour l'utilisateur donné.
|
||||
func (m *Manager) GenerateAccessToken(userID string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(m.secret)
|
||||
}
|
||||
|
||||
// ParseAccessToken valide un access token et retourne ses claims.
|
||||
func (m *Manager) ParseAccessToken(tokenString string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"])
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// GenerateRefreshToken génère un refresh token opaque (aléatoire) et son hash
|
||||
// SHA-256 (pour stockage en base), ainsi que sa date d'expiration.
|
||||
func (m *Manager) GenerateRefreshToken() (token, hash string, expiresAt time.Time, err error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err = rand.Read(raw); err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
// On préfixe d'un uuid pour garantir l'unicité et faciliter le debug.
|
||||
token = uuid.NewString() + "." + base64.RawURLEncoding.EncodeToString(raw)
|
||||
hash = HashToken(token)
|
||||
expiresAt = time.Now().Add(m.refreshTTL)
|
||||
return token, hash, expiresAt, nil
|
||||
}
|
||||
|
||||
// HashToken retourne le hash SHA-256 hexadécimal d'un token.
|
||||
// SHA-256 suffit pour un secret à forte entropie (contrairement aux mots de passe).
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// AccessTTL expose la durée de vie des access tokens.
|
||||
func (m *Manager) AccessTTL() time.Duration { return m.accessTTL }
|
||||
62
backend/internal/config/config.go
Normal file
62
backend/internal/config/config.go
Normal file
@ -0,0 +1,62 @@
|
||||
// Package config charge la configuration de l'application depuis les variables d'environnement.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config regroupe tous les paramètres de configuration du serveur.
|
||||
type Config struct {
|
||||
DatabaseURL string // chaîne de connexion PostgreSQL
|
||||
JWTSecret []byte // secret de signature des JWT
|
||||
JWTAccessTTL time.Duration // durée de vie des access tokens
|
||||
JWTRefreshTTL time.Duration // durée de vie des refresh tokens
|
||||
StoragePath string // dossier de stockage des PDF sur disque
|
||||
Port string // port d'écoute HTTP
|
||||
CORSOrigin string // origine autorisée pour le CORS
|
||||
}
|
||||
|
||||
// Load lit la configuration depuis l'environnement et applique les valeurs par défaut.
|
||||
// Retourne une erreur si une variable obligatoire est absente ou invalide.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
StoragePath: getEnv("STORAGE_PATH", "/data/documents"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
CORSOrigin: os.Getenv("CORS_ORIGIN"),
|
||||
}
|
||||
|
||||
if cfg.DatabaseURL == "" {
|
||||
return nil, fmt.Errorf("la variable d'environnement DATABASE_URL est obligatoire")
|
||||
}
|
||||
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("la variable d'environnement JWT_SECRET est obligatoire")
|
||||
}
|
||||
cfg.JWTSecret = []byte(secret)
|
||||
|
||||
accessTTL, err := time.ParseDuration(getEnv("JWT_ACCESS_TTL", "15m"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JWT_ACCESS_TTL invalide : %w", err)
|
||||
}
|
||||
cfg.JWTAccessTTL = accessTTL
|
||||
|
||||
refreshTTL, err := time.ParseDuration(getEnv("JWT_REFRESH_TTL", "168h"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JWT_REFRESH_TTL invalide : %w", err)
|
||||
}
|
||||
cfg.JWTRefreshTTL = refreshTTL
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// getEnv retourne la valeur de la variable d'environnement clef, ou def si absente.
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
65
backend/internal/db/db.go
Normal file
65
backend/internal/db/db.go
Normal file
@ -0,0 +1,65 @@
|
||||
// Package db gère la connexion à PostgreSQL et l'application des migrations.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // enregistre le driver database/sql "pgx"
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"pdfeditor/migrations"
|
||||
)
|
||||
|
||||
// Connect ouvre un pool de connexions pgx vers la base, en réessayant
|
||||
// quelques fois pour laisser le temps au service Postgres de démarrer.
|
||||
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DATABASE_URL invalide : %w", err)
|
||||
}
|
||||
|
||||
var pool *pgxpool.Pool
|
||||
// On tente plusieurs fois car la base peut ne pas être encore prête au boot.
|
||||
for attempt := 1; attempt <= 10; attempt++ {
|
||||
pool, err = pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err == nil {
|
||||
if pingErr := pool.Ping(ctx); pingErr == nil {
|
||||
return pool, nil
|
||||
} else {
|
||||
err = pingErr
|
||||
pool.Close()
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return nil, fmt.Errorf("connexion à la base impossible après plusieurs tentatives : %w", err)
|
||||
}
|
||||
|
||||
// Migrate applique toutes les migrations embarquées via goose.
|
||||
// goose s'appuie sur database/sql ; on ouvre une connexion *sql.DB
|
||||
// à partir de la même configuration pgx le temps de la migration.
|
||||
func Migrate(databaseURL string) error {
|
||||
sqlDB, err := openStdlib(databaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ouverture connexion migration : %w", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
goose.SetBaseFS(migrations.FS)
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return fmt.Errorf("dialecte goose : %w", err)
|
||||
}
|
||||
if err := goose.Up(sqlDB, "."); err != nil {
|
||||
return fmt.Errorf("application des migrations : %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openStdlib ouvre une connexion database/sql basée sur le driver pgx stdlib.
|
||||
func openStdlib(databaseURL string) (*sql.DB, error) {
|
||||
return sql.Open("pgx", databaseURL)
|
||||
}
|
||||
257
backend/internal/handlers/auth_handlers.go
Normal file
257
backend/internal/handlers/auth_handlers.go
Normal file
@ -0,0 +1,257 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"pdfeditor/internal/auth"
|
||||
)
|
||||
|
||||
// userResponse représente un utilisateur tel qu'exposé par l'API.
|
||||
type userResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// credentials est le corps des requêtes register/login.
|
||||
type credentials struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// tokenPair regroupe un couple access/refresh token.
|
||||
type tokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// authResponse est la réponse de register/login.
|
||||
type authResponse struct {
|
||||
User userResponse `json:"user"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// validateCredentials applique des règles minimales de validation.
|
||||
func validateCredentials(c credentials) (string, bool) {
|
||||
email := strings.TrimSpace(strings.ToLower(c.Email))
|
||||
if email == "" || !strings.Contains(email, "@") || len(email) > 254 {
|
||||
return "", false
|
||||
}
|
||||
if len(c.Password) < 8 || len(c.Password) > 128 {
|
||||
return "", false
|
||||
}
|
||||
return email, true
|
||||
}
|
||||
|
||||
// Register crée un nouvel utilisateur et renvoie un couple de tokens.
|
||||
func (a *API) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var creds credentials
|
||||
if !decodeJSON(w, r, &creds) {
|
||||
return
|
||||
}
|
||||
email, ok := validateCredentials(creds)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "email invalide ou mot de passe trop court (8 caractères minimum)")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(creds.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur interne")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
var user userResponse
|
||||
err = a.DB.QueryRow(ctx,
|
||||
`INSERT INTO users (email, password_hash) VALUES ($1, $2)
|
||||
RETURNING id, email, created_at`,
|
||||
email, hash,
|
||||
).Scan(&user.ID, &user.Email, &user.CreatedAt)
|
||||
if err != nil {
|
||||
// Violation de contrainte d'unicité (email déjà pris).
|
||||
if strings.Contains(err.Error(), "users_email_key") || strings.Contains(err.Error(), "duplicate key") {
|
||||
writeError(w, http.StatusConflict, "cet email est déjà utilisé")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "erreur lors de la création du compte")
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := a.issueTokens(ctx, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur lors de la génération des tokens")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, authResponse{User: user, AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken})
|
||||
}
|
||||
|
||||
// Login authentifie un utilisateur et renvoie un couple de tokens.
|
||||
func (a *API) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var creds credentials
|
||||
if !decodeJSON(w, r, &creds) {
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(strings.ToLower(creds.Email))
|
||||
if email == "" || creds.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "email et mot de passe requis")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
var user userResponse
|
||||
var passwordHash string
|
||||
err := a.DB.QueryRow(ctx,
|
||||
`SELECT id, email, created_at, password_hash FROM users WHERE email = $1`,
|
||||
email,
|
||||
).Scan(&user.ID, &user.Email, &user.CreatedAt, &passwordHash)
|
||||
if err != nil {
|
||||
// On ne distingue pas "email inconnu" de "mauvais mot de passe" (anti-énumération).
|
||||
writeError(w, http.StatusUnauthorized, "identifiants invalides")
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.CheckPassword(passwordHash, creds.Password) {
|
||||
writeError(w, http.StatusUnauthorized, "identifiants invalides")
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := a.issueTokens(ctx, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur lors de la génération des tokens")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, authResponse{User: user, AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken})
|
||||
}
|
||||
|
||||
// refreshRequest est le corps de la requête de rafraîchissement.
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// Refresh échange un refresh token valide contre un nouveau couple de tokens.
|
||||
// L'ancien refresh token est révoqué (rotation).
|
||||
func (a *API) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req refreshRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.RefreshToken == "" {
|
||||
writeError(w, http.StatusBadRequest, "refresh_token requis")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
hash := auth.HashToken(req.RefreshToken)
|
||||
|
||||
var tokenID, userID string
|
||||
var expiresAt time.Time
|
||||
var revoked bool
|
||||
err := a.DB.QueryRow(ctx,
|
||||
`SELECT id, user_id, expires_at, revoked FROM refresh_tokens WHERE token_hash = $1`,
|
||||
hash,
|
||||
).Scan(&tokenID, &userID, &expiresAt, &revoked)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "refresh token invalide")
|
||||
return
|
||||
}
|
||||
if revoked || time.Now().After(expiresAt) {
|
||||
writeError(w, http.StatusUnauthorized, "refresh token expiré ou révoqué")
|
||||
return
|
||||
}
|
||||
|
||||
// Rotation : on révoque l'ancien token avant d'en émettre un nouveau.
|
||||
if _, err := a.DB.Exec(ctx, `UPDATE refresh_tokens SET revoked = TRUE WHERE id = $1`, tokenID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur interne")
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := a.issueTokens(ctx, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur lors de la génération des tokens")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, pair)
|
||||
}
|
||||
|
||||
// Logout révoque le refresh token fourni. Auth requise.
|
||||
func (a *API) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
var req refreshRequest
|
||||
// Le refresh token peut être fourni dans le corps ; s'il est absent, on
|
||||
// révoque malgré tout tous les tokens de l'utilisateur courant.
|
||||
_ = decodeJSON(w, r, &req)
|
||||
userID := userIDFromContext(r.Context())
|
||||
|
||||
ctx := r.Context()
|
||||
if req.RefreshToken != "" {
|
||||
hash := auth.HashToken(req.RefreshToken)
|
||||
_, err := a.DB.Exec(ctx,
|
||||
`UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = $1 AND user_id = $2`,
|
||||
hash, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur interne")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Pas de token précisé : on révoque toutes les sessions de l'utilisateur.
|
||||
if _, err := a.DB.Exec(ctx,
|
||||
`UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = $1 AND revoked = FALSE`,
|
||||
userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur interne")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Me renvoie le profil de l'utilisateur authentifié.
|
||||
func (a *API) Me(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
var user userResponse
|
||||
err := a.DB.QueryRow(r.Context(),
|
||||
`SELECT id, email, created_at FROM users WHERE id = $1`, userID,
|
||||
).Scan(&user.ID, &user.Email, &user.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "utilisateur introuvable")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "erreur interne")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
// issueTokens génère un access token et un refresh token, persiste ce dernier
|
||||
// (hashé) en base, et retourne le couple.
|
||||
func (a *API) issueTokens(ctx context.Context, userID string) (tokenPair, error) {
|
||||
accessToken, err := a.Auth.GenerateAccessToken(userID)
|
||||
if err != nil {
|
||||
return tokenPair{}, err
|
||||
}
|
||||
|
||||
refreshToken, refreshHash, expiresAt, err := a.Auth.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return tokenPair{}, err
|
||||
}
|
||||
|
||||
_, err = a.DB.Exec(ctx,
|
||||
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
||||
userID, refreshHash, expiresAt)
|
||||
if err != nil {
|
||||
return tokenPair{}, err
|
||||
}
|
||||
|
||||
return tokenPair{AccessToken: accessToken, RefreshToken: refreshToken}, nil
|
||||
}
|
||||
307
backend/internal/handlers/document_handlers.go
Normal file
307
backend/internal/handlers/document_handlers.go
Normal file
@ -0,0 +1,307 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// pdfMagic est l'en-tête attendu en début de fichier PDF.
|
||||
var pdfMagic = []byte("%PDF-")
|
||||
|
||||
// documentResponse représente les métadonnées d'un document exposées par l'API.
|
||||
type documentResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListDocuments renvoie la liste des documents de l'utilisateur courant.
|
||||
func (a *API) ListDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
|
||||
rows, err := a.DB.Query(r.Context(),
|
||||
`SELECT id, name, size, created_at, updated_at FROM documents
|
||||
WHERE user_id = $1 ORDER BY updated_at DESC`, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur lors de la récupération des documents")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
docs := make([]documentResponse, 0)
|
||||
for rows.Next() {
|
||||
var d documentResponse
|
||||
if err := rows.Scan(&d.ID, &d.Name, &d.Size, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur de lecture")
|
||||
return
|
||||
}
|
||||
docs = append(docs, d)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur de lecture")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string][]documentResponse{"documents": docs})
|
||||
}
|
||||
|
||||
// CreateDocument enregistre un nouveau PDF uploadé en multipart/form-data.
|
||||
func (a *API) CreateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
|
||||
file, header, name, ok := a.readUpload(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if name == "" {
|
||||
name = header.Filename
|
||||
}
|
||||
if name == "" {
|
||||
name = "document.pdf"
|
||||
}
|
||||
|
||||
// On lit l'en-tête pour vérifier le type, puis on reconstitue le flux complet.
|
||||
reader, ok := validatePDF(w, file)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
fileID, size, err := a.Storage.Save(reader)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur lors de l'enregistrement du fichier")
|
||||
return
|
||||
}
|
||||
|
||||
var d documentResponse
|
||||
err = a.DB.QueryRow(r.Context(),
|
||||
`INSERT INTO documents (user_id, name, file_path, size)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, size, created_at, updated_at`,
|
||||
userID, name, fileID, size,
|
||||
).Scan(&d.ID, &d.Name, &d.Size, &d.CreatedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
_ = a.Storage.Delete(fileID)
|
||||
writeError(w, http.StatusInternalServerError, "erreur lors de l'enregistrement")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, d)
|
||||
}
|
||||
|
||||
// GetDocument renvoie les métadonnées d'un document.
|
||||
func (a *API) GetDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var d documentResponse
|
||||
err := a.DB.QueryRow(r.Context(),
|
||||
`SELECT id, name, size, created_at, updated_at FROM documents
|
||||
WHERE id = $1 AND user_id = $2`, id, userID,
|
||||
).Scan(&d.ID, &d.Name, &d.Size, &d.CreatedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
a.handleDocNotFound(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
// GetDocumentFile renvoie le binaire PDF d'un document.
|
||||
func (a *API) GetDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var name, filePath string
|
||||
err := a.DB.QueryRow(r.Context(),
|
||||
`SELECT name, file_path FROM documents WHERE id = $1 AND user_id = $2`, id, userID,
|
||||
).Scan(&name, &filePath)
|
||||
if err != nil {
|
||||
a.handleDocNotFound(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := a.Storage.Open(filePath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "fichier introuvable sur le disque")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", "inline; filename=\""+sanitizeFilename(name)+"\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
|
||||
// UpdateDocument met à jour le binaire et/ou le nom d'un document.
|
||||
func (a *API) UpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// On vérifie d'abord que le document appartient à l'utilisateur.
|
||||
var filePath string
|
||||
err := a.DB.QueryRow(r.Context(),
|
||||
`SELECT file_path FROM documents WHERE id = $1 AND user_id = $2`, id, userID,
|
||||
).Scan(&filePath)
|
||||
if err != nil {
|
||||
a.handleDocNotFound(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
file, _, name, ok := a.readUpload(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
hasFile := file != nil
|
||||
if hasFile {
|
||||
defer file.Close()
|
||||
}
|
||||
|
||||
// Au moins un des deux champs doit être fourni.
|
||||
if !hasFile && name == "" {
|
||||
writeError(w, http.StatusBadRequest, "fournir un nouveau fichier et/ou un nouveau nom")
|
||||
return
|
||||
}
|
||||
|
||||
if hasFile {
|
||||
reader, valid := validatePDF(w, file)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
size, werr := a.Storage.Overwrite(filePath, reader)
|
||||
if werr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur lors de l'écriture du fichier")
|
||||
return
|
||||
}
|
||||
if name != "" {
|
||||
_, err = a.DB.Exec(r.Context(),
|
||||
`UPDATE documents SET size = $1, name = $2, updated_at = now() WHERE id = $3`,
|
||||
size, name, id)
|
||||
} else {
|
||||
_, err = a.DB.Exec(r.Context(),
|
||||
`UPDATE documents SET size = $1, updated_at = now() WHERE id = $2`,
|
||||
size, id)
|
||||
}
|
||||
} else {
|
||||
_, err = a.DB.Exec(r.Context(),
|
||||
`UPDATE documents SET name = $1, updated_at = now() WHERE id = $2`,
|
||||
name, id)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur lors de la mise à jour")
|
||||
return
|
||||
}
|
||||
|
||||
var d documentResponse
|
||||
err = a.DB.QueryRow(r.Context(),
|
||||
`SELECT id, name, size, created_at, updated_at FROM documents WHERE id = $1`, id,
|
||||
).Scan(&d.ID, &d.Name, &d.Size, &d.CreatedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "erreur interne")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
// DeleteDocument supprime un document (base + fichier).
|
||||
func (a *API) DeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var filePath string
|
||||
err := a.DB.QueryRow(r.Context(),
|
||||
`DELETE FROM documents WHERE id = $1 AND user_id = $2 RETURNING file_path`,
|
||||
id, userID,
|
||||
).Scan(&filePath)
|
||||
if err != nil {
|
||||
a.handleDocNotFound(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Suppression best-effort du fichier disque (l'entrée en base est déjà supprimée).
|
||||
_ = a.Storage.Delete(filePath)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDocNotFound traduit une erreur de requête en 404 ou 500.
|
||||
func (a *API) handleDocNotFound(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "document introuvable")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "erreur interne")
|
||||
}
|
||||
|
||||
// readUpload parse un formulaire multipart et retourne le fichier (peut être nil
|
||||
// si fileRequired est false) ainsi que le champ "name".
|
||||
// En cas d'erreur, il écrit la réponse et retourne ok=false.
|
||||
func (a *API) readUpload(w http.ResponseWriter, r *http.Request, fileRequired bool) (multipart.File, *multipart.FileHeader, string, bool) {
|
||||
// Limite la taille du corps lu pour éviter l'épuisement mémoire/disque.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize+(1<<20))
|
||||
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "formulaire multipart invalide ou fichier trop volumineux (max 50 Mo)")
|
||||
return nil, nil, "", false
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
if fileRequired {
|
||||
writeError(w, http.StatusBadRequest, "le champ 'file' est obligatoire")
|
||||
return nil, nil, "", false
|
||||
}
|
||||
// Pas de fichier mais ce n'est pas requis (cas PUT nom seul).
|
||||
return nil, nil, name, true
|
||||
}
|
||||
|
||||
if header.Size > maxUploadSize {
|
||||
file.Close()
|
||||
writeError(w, http.StatusBadRequest, "fichier trop volumineux (max 50 Mo)")
|
||||
return nil, nil, "", false
|
||||
}
|
||||
|
||||
return file, header, name, true
|
||||
}
|
||||
|
||||
// validatePDF vérifie l'en-tête %PDF- et retourne un lecteur reconstitué
|
||||
// (en-tête + reste du flux). En cas d'échec, écrit la réponse et retourne ok=false.
|
||||
func validatePDF(w http.ResponseWriter, file multipart.File) (io.Reader, bool) {
|
||||
head := make([]byte, len(pdfMagic))
|
||||
n, err := io.ReadFull(file, head)
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
writeError(w, http.StatusBadRequest, "fichier illisible")
|
||||
return nil, false
|
||||
}
|
||||
if n < len(pdfMagic) || !bytes.Equal(head[:n], pdfMagic) {
|
||||
writeError(w, http.StatusBadRequest, "le fichier n'est pas un PDF valide")
|
||||
return nil, false
|
||||
}
|
||||
// On recolle l'en-tête déjà lu au reste du flux.
|
||||
return io.MultiReader(bytes.NewReader(head[:n]), file), true
|
||||
}
|
||||
|
||||
// sanitizeFilename retire les caractères problématiques d'un nom de fichier
|
||||
// destiné à l'en-tête Content-Disposition.
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.ReplaceAll(name, "\"", "")
|
||||
name = strings.ReplaceAll(name, "\n", "")
|
||||
name = strings.ReplaceAll(name, "\r", "")
|
||||
if name == "" {
|
||||
return "document.pdf"
|
||||
}
|
||||
return name
|
||||
}
|
||||
51
backend/internal/handlers/handlers.go
Normal file
51
backend/internal/handlers/handlers.go
Normal file
@ -0,0 +1,51 @@
|
||||
// Package handlers contient les handlers HTTP et le routeur de l'API.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"pdfeditor/internal/auth"
|
||||
"pdfeditor/internal/storage"
|
||||
)
|
||||
|
||||
// maxUploadSize est la taille maximale autorisée pour un upload de PDF (50 Mo).
|
||||
const maxUploadSize = 50 << 20
|
||||
|
||||
// API regroupe les dépendances partagées par les handlers.
|
||||
type API struct {
|
||||
DB *pgxpool.Pool
|
||||
Auth *auth.Manager
|
||||
Storage *storage.Store
|
||||
}
|
||||
|
||||
// New crée une instance d'API.
|
||||
func New(db *pgxpool.Pool, authMgr *auth.Manager, store *storage.Store) *API {
|
||||
return &API{DB: db, Auth: authMgr, Storage: store}
|
||||
}
|
||||
|
||||
// writeJSON sérialise v en JSON avec le code de statut donné.
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
// writeError renvoie une erreur au format { "error": "..." }.
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
// decodeJSON décode le corps JSON de la requête dans v.
|
||||
// Retourne false (et écrit une 400) en cas d'échec.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corps de requête JSON invalide")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
42
backend/internal/handlers/middleware.go
Normal file
42
backend/internal/handlers/middleware.go
Normal file
@ -0,0 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ctxKey est un type privé pour les clefs de contexte (évite les collisions).
|
||||
type ctxKey string
|
||||
|
||||
// userIDKey est la clef de contexte sous laquelle on stocke l'ID utilisateur authentifié.
|
||||
const userIDKey ctxKey = "userID"
|
||||
|
||||
// RequireAuth est un middleware qui valide l'access token Bearer et injecte
|
||||
// l'ID utilisateur dans le contexte de la requête.
|
||||
func (a *API) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
writeError(w, http.StatusUnauthorized, "en-tête Authorization manquant ou invalide")
|
||||
return
|
||||
}
|
||||
tokenString := strings.TrimSpace(strings.TrimPrefix(header, prefix))
|
||||
|
||||
claims, err := a.Auth.ParseAccessToken(tokenString)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "token invalide ou expiré")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// userIDFromContext récupère l'ID utilisateur stocké par RequireAuth.
|
||||
func userIDFromContext(ctx context.Context) string {
|
||||
id, _ := ctx.Value(userIDKey).(string)
|
||||
return id
|
||||
}
|
||||
64
backend/internal/handlers/router.go
Normal file
64
backend/internal/handlers/router.go
Normal file
@ -0,0 +1,64 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
// Router construit le routeur HTTP complet de l'API.
|
||||
// corsOrigin est l'origine autorisée pour les requêtes cross-origin.
|
||||
func (a *API) Router(corsOrigin string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
// Configuration CORS pour l'origine du frontend.
|
||||
allowedOrigins := []string{"*"}
|
||||
if corsOrigin != "" {
|
||||
allowedOrigins = []string{corsOrigin}
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
ExposedHeaders: []string{"Content-Disposition"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", a.Health)
|
||||
|
||||
// Authentification (routes publiques).
|
||||
r.Post("/auth/register", a.Register)
|
||||
r.Post("/auth/login", a.Login)
|
||||
r.Post("/auth/refresh", a.Refresh)
|
||||
|
||||
// Routes protégées.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.RequireAuth)
|
||||
|
||||
r.Post("/auth/logout", a.Logout)
|
||||
r.Get("/me", a.Me)
|
||||
|
||||
r.Get("/documents", a.ListDocuments)
|
||||
r.Post("/documents", a.CreateDocument)
|
||||
r.Get("/documents/{id}", a.GetDocument)
|
||||
r.Get("/documents/{id}/file", a.GetDocumentFile)
|
||||
r.Put("/documents/{id}", a.UpdateDocument)
|
||||
r.Delete("/documents/{id}", a.DeleteDocument)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Health répond au healthcheck.
|
||||
func (a *API) Health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
71
backend/internal/storage/storage.go
Normal file
71
backend/internal/storage/storage.go
Normal file
@ -0,0 +1,71 @@
|
||||
// Package storage gère la persistance des fichiers PDF sur le disque.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store écrit et lit les fichiers PDF sous un dossier racine.
|
||||
type Store struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// New crée un Store en s'assurant que le dossier racine existe.
|
||||
func New(root string) (*Store, error) {
|
||||
if err := os.MkdirAll(root, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("création du dossier de stockage : %w", err)
|
||||
}
|
||||
return &Store{root: root}, nil
|
||||
}
|
||||
|
||||
// path retourne le chemin absolu d'un fichier à partir de son identifiant.
|
||||
func (s *Store) path(id string) string {
|
||||
return filepath.Join(s.root, id)
|
||||
}
|
||||
|
||||
// Save écrit le contenu de r dans un nouveau fichier et retourne son identifiant
|
||||
// ainsi que le nombre d'octets écrits.
|
||||
func (s *Store) Save(r io.Reader) (id string, size int64, err error) {
|
||||
id = uuid.NewString()
|
||||
f, err := os.OpenFile(s.path(id), os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
size, err = io.Copy(f, r)
|
||||
if err != nil {
|
||||
_ = os.Remove(s.path(id))
|
||||
return "", 0, err
|
||||
}
|
||||
return id, size, nil
|
||||
}
|
||||
|
||||
// Overwrite remplace le contenu du fichier identifié par id.
|
||||
func (s *Store) Overwrite(id string, r io.Reader) (size int64, err error) {
|
||||
f, err := os.OpenFile(s.path(id), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.Copy(f, r)
|
||||
}
|
||||
|
||||
// Open ouvre le fichier identifié par id en lecture.
|
||||
func (s *Store) Open(id string) (*os.File, error) {
|
||||
return os.Open(s.path(id))
|
||||
}
|
||||
|
||||
// Delete supprime le fichier identifié par id. L'absence du fichier n'est pas une erreur.
|
||||
func (s *Store) Delete(id string) error {
|
||||
err := os.Remove(s.path(id))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user