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 }