This commit is contained in:
2026-08-17 19:08:20 +02:00
parent 312890f6e7
commit 8a8f748784
239 changed files with 369374 additions and 0 deletions

10
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Binaires compilés
/server
/out/
# Données locales
/data/
# Divers
*.log
.env

29
backend/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
# ---- Étape de build ----
FROM golang:1.23-alpine AS build
WORKDIR /src
# Téléchargement des dépendances en couche séparée pour profiter du cache.
COPY go.mod go.sum ./
RUN go mod download
# Copie du code source et compilation d'un binaire statique.
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
# ---- Image finale (distroless, légère) ----
FROM gcr.io/distroless/static-debian12:nonroot
# Copie du binaire statique.
COPY --from=build /out/server /server
# Le dossier de stockage est monté via un volume (cf. docker-compose).
ENV STORAGE_PATH=/data/documents
ENV PORT=8080
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]

76
backend/README.md Normal file
View File

@ -0,0 +1,76 @@
# PdfEditor — Backend (Go)
API REST du projet PdfEditor. Implémente le contrat décrit dans `../docs/API.md`.
## Stack
- Go 1.23, routeur [chi](https://github.com/go-chi/chi)
- PostgreSQL via [pgx](https://github.com/jackc/pgx) (pgxpool)
- JWT [golang-jwt](https://github.com/golang-jwt/jwt), bcrypt pour les mots de passe
- Migrations embarquées appliquées au démarrage via [goose](https://github.com/pressly/goose)
- Stockage des PDF sur disque (un fichier par document, nom = UUID)
## Variables d'environnement
| Variable | Obligatoire | Défaut | Description |
|-------------------|-------------|--------------------|-----------------------------------------------|
| `DATABASE_URL` | oui | — | DSN PostgreSQL (`postgres://...`) |
| `JWT_SECRET` | oui | — | Secret de signature des JWT |
| `JWT_ACCESS_TTL` | non | `15m` | Durée de vie des access tokens |
| `JWT_REFRESH_TTL` | non | `168h` | Durée de vie des refresh tokens |
| `STORAGE_PATH` | non | `/data/documents` | Dossier d'écriture des PDF |
| `PORT` | non | `8080` | Port d'écoute HTTP |
| `CORS_ORIGIN` | non | `*` | Origine autorisée pour le CORS |
## Lancer en natif
Prérequis : Go 1.23+ et une base PostgreSQL accessible.
```sh
# Récupérer / verrouiller les dépendances
go mod tidy
# Variables d'environnement (exemple dev)
export DATABASE_URL="postgres://pdfeditor:changeme@localhost:5432/pdfeditor?sslmode=disable"
export JWT_SECRET="dev-secret-not-for-prod"
export STORAGE_PATH="./data/documents"
export PORT="8081"
export CORS_ORIGIN="http://localhost:4200"
# Démarrer (les migrations s'appliquent automatiquement)
go run ./cmd/server
```
## Vérifications
```sh
go build ./...
go vet ./...
```
## Docker
Le `Dockerfile` produit une image distroless avec un binaire statique.
L'orchestration se fait via les fichiers `docker-compose.yml` (prod) et
`docker-compose.local.yml` (dev) à la racine du dépôt.
```sh
# Depuis la racine du dépôt
docker compose -f docker-compose.local.yml up --build
```
## Arborescence
```
backend/
├── cmd/server/main.go # point d'entrée
├── internal/
│ ├── auth/ # JWT, bcrypt, refresh tokens
│ ├── config/ # chargement de la config
│ ├── db/ # pool pgx + migrations
│ ├── handlers/ # routeur + handlers HTTP
│ └── storage/ # stockage des PDF sur disque
├── migrations/ # SQL embarqué (goose)
├── Dockerfile
├── go.mod / go.sum
└── README.md
```

View File

@ -0,0 +1,83 @@
// Commande server : point d'entrée du backend PdfEditor.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"pdfeditor/internal/auth"
"pdfeditor/internal/config"
"pdfeditor/internal/db"
"pdfeditor/internal/handlers"
"pdfeditor/internal/storage"
)
func main() {
// Chargement de la configuration depuis l'environnement.
cfg, err := config.Load()
if err != nil {
log.Fatalf("configuration : %v", err)
}
ctx := context.Background()
// Application des migrations au démarrage.
log.Println("application des migrations...")
if err := db.Migrate(cfg.DatabaseURL); err != nil {
log.Fatalf("migrations : %v", err)
}
// Connexion à la base (pool pgx).
pool, err := db.Connect(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("base de données : %v", err)
}
defer pool.Close()
// Initialisation du stockage des fichiers.
store, err := storage.New(cfg.StoragePath)
if err != nil {
log.Fatalf("stockage : %v", err)
}
// Gestionnaire d'authentification.
authMgr := auth.NewManager(cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL)
// Construction de l'API et du routeur.
api := handlers.New(pool, authMgr, store)
router := api.Router(cfg.CORSOrigin)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
// Pas de WriteTimeout court : upload/download de PDF peuvent être longs.
}
// Démarrage du serveur dans une goroutine pour permettre l'arrêt propre.
go func() {
log.Printf("serveur à l'écoute sur le port %s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("serveur : %v", err)
}
}()
// Attente d'un signal d'arrêt.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("arrêt en cours...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("arrêt forcé : %v", err)
}
log.Println("serveur arrêté")
}

24
backend/go.mod Normal file
View File

@ -0,0 +1,24 @@
module pdfeditor
go 1.23
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.1
github.com/pressly/goose/v3 v3.22.1
golang.org/x/crypto v0.27.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/text v0.18.0 // indirect
)

68
backend/go.sum Normal file
View File

@ -0,0 +1,68 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc=
github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.33.0 h1:WWkA/T2G17okiLGgKAj4/RMIvgyMT19yQ038160IeYk=
modernc.org/sqlite v1.33.0/go.mod h1:9uQ9hF/pCZoYZK73D/ud5Z7cIRIILSZI8NdIemVMTX8=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View 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 }

View 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
View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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"})
}

View 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
}

View File

@ -0,0 +1,42 @@
-- +goose Up
-- Activation de l'extension pour générer des UUID côté base si besoin.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Table des utilisateurs.
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Table des refresh tokens (stockés hashés, révocables).
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
-- Table des documents PDF.
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
file_path TEXT NOT NULL,
size BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_documents_user_id ON documents(user_id);
-- +goose Down
DROP TABLE documents;
DROP TABLE refresh_tokens;
DROP TABLE users;

View File

@ -0,0 +1,9 @@
// Package migrations embarque les fichiers SQL de migration dans le binaire.
package migrations
import "embed"
// FS contient l'ensemble des fichiers de migration SQL embarqués.
//
//go:embed *.sql
var FS embed.FS