308 lines
8.9 KiB
Go
308 lines
8.9 KiB
Go
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
|
|
}
|