feat: v1
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Copier ce fichier en .env et adapter les valeurs.
|
||||||
|
# (.env est ignoré par git)
|
||||||
|
|
||||||
|
# === PostgreSQL ===
|
||||||
|
POSTGRES_USER=pdfeditor
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
|
POSTGRES_DB=pdfeditor
|
||||||
|
|
||||||
|
# === Backend ===
|
||||||
|
# URL utilisée par le backend pour joindre la base (hôte "db" = nom du service docker)
|
||||||
|
DATABASE_URL=postgres://pdfeditor:changeme@db:5432/pdfeditor?sslmode=disable
|
||||||
|
JWT_SECRET=remplace-moi-par-une-longue-chaine-aleatoire
|
||||||
|
JWT_ACCESS_TTL=15m
|
||||||
|
JWT_REFRESH_TTL=168h
|
||||||
|
STORAGE_PATH=/data/documents
|
||||||
|
PORT=8080
|
||||||
|
# Origine autorisée pour le CORS (frontend). En prod, mettre l'URL publique.
|
||||||
|
CORS_ORIGIN=http://localhost:8080
|
||||||
|
|
||||||
|
# === Déploiement / images ===
|
||||||
|
REGISTRY=gitea.anthonybouteiller.ovh/blomios
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
# Port HTTP exposé sur l'hôte par le frontend (nginx)
|
||||||
|
HTTP_PORT=8080
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# === Secrets / env ===
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# === Script de publication (contient la logique de push vers le registre Gitea) ===
|
||||||
|
scripts/push-images.sh
|
||||||
|
|
||||||
|
# === Stockage local (volume monté en dev) ===
|
||||||
|
/data/
|
||||||
|
documents/
|
||||||
|
|
||||||
|
# === Backend Go ===
|
||||||
|
backend/bin/
|
||||||
|
backend/tmp/
|
||||||
|
*.exe
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
__debug_bin*
|
||||||
|
|
||||||
|
# === Frontend Angular ===
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.angular/
|
||||||
|
frontend/coverage/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# === Docker ===
|
||||||
|
*.tar
|
||||||
|
|
||||||
|
# === OS / éditeurs ===
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
84
README.md
84
README.md
@ -0,0 +1,84 @@
|
|||||||
|
# PdfEditor
|
||||||
|
|
||||||
|
Éditeur de PDF en ligne, auto-hébergé. L'utilisateur dépose un PDF (glisser-déposer),
|
||||||
|
l'édite directement dans le navigateur — **ajout de texte, d'images, de signatures,
|
||||||
|
de formes basiques et de liens** — puis le télécharge ou le sauvegarde sur son compte.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Couche | Techno | Rôle |
|
||||||
|
|--------|--------|------|
|
||||||
|
| **Frontend** | Angular + [PDF.js](https://mozilla.github.io/pdf.js/) (rendu) + [pdf-lib](https://pdf-lib.js.org/) (édition/export) | Toute l'édition se fait **côté client**, dans le navigateur. |
|
||||||
|
| **Backend** | Go (`net/http` + `chi`) | Authentification (JWT) et persistance des PDF/projets de l'utilisateur. |
|
||||||
|
| **Base de données** | PostgreSQL | Comptes utilisateurs et métadonnées des documents. |
|
||||||
|
| **Stockage fichiers** | Volume disque | Les PDF sauvegardés sont écrits sur un volume monté. |
|
||||||
|
|
||||||
|
Le PDF n'est envoyé au serveur **que** si l'utilisateur choisit de le sauvegarder.
|
||||||
|
L'édition elle-même ne quitte jamais le navigateur.
|
||||||
|
|
||||||
|
Le contrat d'API partagé est décrit dans [`docs/API.md`](docs/API.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
PdfEditor/
|
||||||
|
├── docker-compose.yml # Production : tire les images du registre Gitea
|
||||||
|
├── docker-compose.local.yml # Dev/debug : build à partir des sources locales
|
||||||
|
├── .env.example # Variables d'environnement (copier en .env)
|
||||||
|
├── scripts/push-images.sh # Build + push des images vers Gitea (gitignoré)
|
||||||
|
├── docs/API.md # Contrat d'API REST
|
||||||
|
├── backend/ # Service Go
|
||||||
|
└── frontend/ # Application Angular (servie par nginx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Développement local
|
||||||
|
|
||||||
|
Prérequis : Docker + Docker Compose.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # adapter si besoin
|
||||||
|
docker compose -f docker-compose.local.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Services exposés :
|
||||||
|
- Frontend : http://localhost:8080
|
||||||
|
- Backend (debug) : http://localhost:8081
|
||||||
|
- PostgreSQL : localhost:5432
|
||||||
|
|
||||||
|
> Pour un cycle de dev plus rapide sur une seule couche, voir les README de
|
||||||
|
> `backend/` et `frontend/` (lancement natif `go run` / `ng serve`).
|
||||||
|
|
||||||
|
## Déploiement (production)
|
||||||
|
|
||||||
|
Les deux images (`pdfeditor-backend`, `pdfeditor-frontend`) sont publiées sur le
|
||||||
|
registre OCI intégré de Gitea, puis tirées par `docker-compose.yml`.
|
||||||
|
|
||||||
|
### 1. Publier les images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/push-images.sh # tag = git short SHA + latest
|
||||||
|
# ou
|
||||||
|
IMAGE_TAG=v1.0.0 ./scripts/push-images.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script construit `backend/` et `frontend/`, les tague
|
||||||
|
(`<short-sha>` + `latest`) et les pousse vers
|
||||||
|
`gitea.anthonybouteiller.ovh/blomios/pdfeditor-{backend,frontend}`.
|
||||||
|
|
||||||
|
### 2. Lancer sur le serveur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # renseigner les secrets (JWT_SECRET, mots de passe…)
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Le frontend est exposé sur le port défini par `HTTP_PORT` (défaut `8080`) ; il
|
||||||
|
proxifie les appels `/api` vers le backend.
|
||||||
|
|
||||||
|
## Images Docker
|
||||||
|
|
||||||
|
| Image | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `…/pdfeditor-backend` | Binaire Go (build multi-stage). |
|
||||||
|
| `…/pdfeditor-frontend`| Build Angular servi par nginx, proxy `/api` → backend. |
|
||||||
|
|
||||||
|
Tags : `latest` (dernier build) et le **git short SHA** de chaque build.
|
||||||
|
|||||||
10
backend/.gitignore
vendored
Normal file
10
backend/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Binaires compilés
|
||||||
|
/server
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# Données locales
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# Divers
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
29
backend/Dockerfile
Normal file
29
backend/Dockerfile
Normal 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
76
backend/README.md
Normal 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
|
||||||
|
```
|
||||||
83
backend/cmd/server/main.go
Normal file
83
backend/cmd/server/main.go
Normal 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
24
backend/go.mod
Normal 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
68
backend/go.sum
Normal 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=
|
||||||
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
|
||||||
|
}
|
||||||
42
backend/migrations/00001_init.sql
Normal file
42
backend/migrations/00001_init.sql
Normal 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;
|
||||||
9
backend/migrations/embed.go
Normal file
9
backend/migrations/embed.go
Normal 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
|
||||||
53
docker-compose.local.yml
Normal file
53
docker-compose.local.yml
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Développement / debug local — construit les images à partir des fichiers locaux.
|
||||||
|
# Usage : docker compose -f docker-compose.local.yml up --build
|
||||||
|
# Les ports backend et db sont exposés pour faciliter le debug.
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-pdfeditor}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-pdfeditor}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- db-data-local:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-pdfeditor} -d $${POSTGRES_DB:-pdfeditor}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgres://pdfeditor:changeme@db:5432/pdfeditor?sslmode=disable}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-dev-secret-not-for-prod}
|
||||||
|
JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-15m}
|
||||||
|
JWT_REFRESH_TTL: ${JWT_REFRESH_TTL:-168h}
|
||||||
|
STORAGE_PATH: /data/documents
|
||||||
|
PORT: "8080"
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:8080}
|
||||||
|
ports:
|
||||||
|
- "8085:8080"
|
||||||
|
volumes:
|
||||||
|
- documents-local:/data/documents
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-8090}:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data-local:
|
||||||
|
documents-local:
|
||||||
48
docker-compose.yml
Normal file
48
docker-compose.yml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Déploiement (production) — utilise les images publiées sur le registre Gitea.
|
||||||
|
# Usage : docker compose up -d
|
||||||
|
# Nécessite un fichier .env (cf. .env.example).
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: ${REGISTRY:-gitea.anthonybouteiller.ovh/blomios}/pdfeditor-backend:${IMAGE_TAG:-latest}
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-15m}
|
||||||
|
JWT_REFRESH_TTL: ${JWT_REFRESH_TTL:-168h}
|
||||||
|
STORAGE_PATH: ${STORAGE_PATH:-/data/documents}
|
||||||
|
PORT: "8080"
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN}
|
||||||
|
volumes:
|
||||||
|
- documents:/data/documents
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: ${REGISTRY:-gitea.anthonybouteiller.ovh/blomios}/pdfeditor-frontend:${IMAGE_TAG:-latest}
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-8080}:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
documents:
|
||||||
82
docs/API.md
Normal file
82
docs/API.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# Contrat d'API — PdfEditor
|
||||||
|
|
||||||
|
Document de référence **partagé** entre l'agent backend (Go) et l'agent frontend (Angular).
|
||||||
|
Toute évolution doit être répercutée des deux côtés.
|
||||||
|
|
||||||
|
Base URL : `/api`
|
||||||
|
Auth : JWT Bearer dans l'en-tête `Authorization: Bearer <access_token>`.
|
||||||
|
Format : JSON (sauf upload/download de fichier).
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- Codes d'erreur : `400` (validation), `401` (non authentifié), `403` (interdit),
|
||||||
|
`404` (introuvable), `409` (conflit, ex. email déjà pris), `500`.
|
||||||
|
- Corps d'erreur : `{ "error": "message lisible" }`.
|
||||||
|
- Dates : ISO 8601 (RFC 3339).
|
||||||
|
- Les `access_token` sont de courte durée (15 min). Les `refresh_token` permettent
|
||||||
|
d'obtenir un nouveau couple de tokens.
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
### POST /api/auth/register
|
||||||
|
Req: `{ "email": "a@b.c", "password": "..." }`
|
||||||
|
Res `201`: `{ "user": { "id", "email", "created_at" }, "access_token", "refresh_token" }`
|
||||||
|
|
||||||
|
### POST /api/auth/login
|
||||||
|
Req: `{ "email", "password" }`
|
||||||
|
Res `200`: `{ "user": {...}, "access_token", "refresh_token" }`
|
||||||
|
|
||||||
|
### POST /api/auth/refresh
|
||||||
|
Req: `{ "refresh_token": "..." }`
|
||||||
|
Res `200`: `{ "access_token", "refresh_token" }`
|
||||||
|
|
||||||
|
### POST /api/auth/logout
|
||||||
|
Auth requise. Invalide le refresh token courant. Res `204`.
|
||||||
|
|
||||||
|
### GET /api/me
|
||||||
|
Auth requise. Res `200`: `{ "id", "email", "created_at" }`
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
Toutes ces routes nécessitent l'authentification. Un utilisateur ne voit que ses
|
||||||
|
propres documents.
|
||||||
|
|
||||||
|
### GET /api/documents
|
||||||
|
Res `200`: `{ "documents": [ { "id", "name", "size", "created_at", "updated_at" } ] }`
|
||||||
|
|
||||||
|
### POST /api/documents
|
||||||
|
Upload d'un nouveau PDF. `multipart/form-data` :
|
||||||
|
- `file` : le binaire PDF (obligatoire)
|
||||||
|
- `name` : nom affiché (optionnel, défaut = nom du fichier)
|
||||||
|
Res `201`: `{ "id", "name", "size", "created_at", "updated_at" }`
|
||||||
|
|
||||||
|
### GET /api/documents/:id
|
||||||
|
Métadonnées. Res `200`: `{ "id", "name", "size", "created_at", "updated_at" }`
|
||||||
|
|
||||||
|
### GET /api/documents/:id/file
|
||||||
|
Renvoie le binaire PDF. `Content-Type: application/pdf`.
|
||||||
|
|
||||||
|
### PUT /api/documents/:id
|
||||||
|
Met à jour le PDF (après édition) et/ou le nom. `multipart/form-data` :
|
||||||
|
- `file` : nouveau binaire PDF (optionnel)
|
||||||
|
- `name` : nouveau nom (optionnel)
|
||||||
|
Res `200`: métadonnées à jour.
|
||||||
|
|
||||||
|
### DELETE /api/documents/:id
|
||||||
|
Res `204`.
|
||||||
|
|
||||||
|
## Santé
|
||||||
|
### GET /api/health
|
||||||
|
Res `200`: `{ "status": "ok" }` — utilisé par les healthchecks/monitoring.
|
||||||
|
|
||||||
|
## Modèle de données (backend)
|
||||||
|
- `users` : `id` (uuid), `email` (unique), `password_hash`, `created_at`
|
||||||
|
- `refresh_tokens` : `id`, `user_id`, `token_hash`, `expires_at`, `revoked`
|
||||||
|
- `documents` : `id` (uuid), `user_id` (fk), `name`, `file_path`, `size`,
|
||||||
|
`created_at`, `updated_at`
|
||||||
|
|
||||||
|
## Notes d'intégration frontend
|
||||||
|
- Le frontend stocke `access_token` en mémoire et `refresh_token` de façon
|
||||||
|
persistante ; un intercepteur HTTP ajoute le Bearer et tente un `/auth/refresh`
|
||||||
|
sur `401`.
|
||||||
|
- En prod, nginx (image frontend) proxifie `/api/*` vers le service `backend:8080`.
|
||||||
|
En dev (`docker-compose.local.yml`), même comportement via nginx, ou proxy
|
||||||
|
Angular vers `http://localhost:8081`.
|
||||||
9
frontend/.dockerignore
Normal file
9
frontend/.dockerignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.angular
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
12
frontend/.editorconfig
Normal file
12
frontend/.editorconfig
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
17
frontend/.gitignore
vendored
Normal file
17
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Dépendances
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Build Angular
|
||||||
|
/dist
|
||||||
|
/out-tsc
|
||||||
|
/.angular/cache
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
/.idea
|
||||||
|
/.vscode/*
|
||||||
|
!/.vscode/extensions.json
|
||||||
|
|
||||||
|
# Logs et divers
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
27
frontend/Dockerfile
Normal file
27
frontend/Dockerfile
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# --- Étape 1 : build de l'application Angular ---
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Installation des dépendances (couche mise en cache tant que les manifestes
|
||||||
|
# ne changent pas). On utilise `npm ci` si un lockfile est présent, sinon
|
||||||
|
# `npm install` (utile tant que le lockfile n'a pas encore été généré/commité).
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
|
# Copie du code source et build de production.
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Étape 2 : service par nginx ---
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
# Configuration nginx (SPA + proxy /api -> backend:8080).
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copie du build Angular dans la racine servie par nginx.
|
||||||
|
COPY --from=build /app/dist/pdfeditor-frontend/browser /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
99
frontend/README.md
Normal file
99
frontend/README.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# PdfEditor — Frontend (Angular)
|
||||||
|
|
||||||
|
Application web d'édition de PDF auto-hébergée. Frontend Angular (standalone
|
||||||
|
components, routing, lazy-loading) consommant l'API REST décrite dans
|
||||||
|
`../docs/API.md`. L'API est toujours appelée en chemin **relatif** `/api/...`,
|
||||||
|
afin que le proxy nginx (prod) comme le proxy Angular (dev) fonctionnent sans
|
||||||
|
configuration d'URL.
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Node.js 20+ (testé avec Node 22/24) et npm.
|
||||||
|
- Angular CLI (`npm install -g @angular/cli`) ou via `npx ng`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
> Le fichier `package-lock.json` n'est pas fourni : il sera généré au premier
|
||||||
|
> `npm install`. Pensez à le commiter ensuite (le Dockerfile l'utilise via
|
||||||
|
> `npm ci` s'il est présent).
|
||||||
|
|
||||||
|
## Développement local (`ng serve` + proxy)
|
||||||
|
|
||||||
|
Le backend est exposé en local sur `http://localhost:8081` par
|
||||||
|
`../docker-compose.local.yml`. Le fichier `proxy.conf.json` redirige `/api`
|
||||||
|
vers ce port.
|
||||||
|
|
||||||
|
1. Démarrer la base + le backend :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# à la racine du dépôt
|
||||||
|
docker compose -f docker-compose.local.yml up --build db backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Lancer le serveur de dev Angular (utilise automatiquement le proxy) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
# équivaut à : ng serve --proxy-config proxy.conf.json
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Ouvrir http://localhost:4200.
|
||||||
|
|
||||||
|
## Build de production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# Sortie : dist/pdfeditor-frontend/browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
`Dockerfile` (multi-stage : build Angular puis service nginx) et `nginx.conf`
|
||||||
|
(SPA + proxy `/api` -> `http://backend:8080`) sont prévus pour
|
||||||
|
`../docker-compose.yml` et `../docker-compose.local.yml` (service `frontend`,
|
||||||
|
exposé sur le port 80 de l'image).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build local de l'image (depuis le dossier frontend)
|
||||||
|
docker build -t pdfeditor-frontend .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
├── core/ # services transverses
|
||||||
|
│ ├── auth.service.ts # login/register/refresh/logout + état utilisateur
|
||||||
|
│ ├── auth.interceptor.ts # Bearer + refresh automatique sur 401
|
||||||
|
│ ├── auth.guard.ts # garde des routes protégées
|
||||||
|
│ ├── documents.service.ts # CRUD /api/documents
|
||||||
|
│ └── models.ts # types alignés sur le contrat d'API
|
||||||
|
└── features/
|
||||||
|
├── auth/ # pages login + register
|
||||||
|
├── dashboard/ # liste des documents sauvegardés
|
||||||
|
└── editor/ # éditeur PDF (cœur du produit)
|
||||||
|
├── editor.component.* # orchestration (drag&drop, toolbar, export)
|
||||||
|
├── page-layer.component.ts # rendu d'une page + couche d'annotations
|
||||||
|
├── pdf.service.ts # rendu pdf.js + export pdf-lib
|
||||||
|
├── signature-dialog.component.ts
|
||||||
|
└── annotation.model.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Librairies clés
|
||||||
|
|
||||||
|
- **pdfjs-dist** : rendu des pages PDF dans un `<canvas>` (navigation, zoom).
|
||||||
|
- **pdf-lib** : aplatissement des annotations dans le PDF à l'export.
|
||||||
|
- **signature_pad** : capture de la signature à main levée.
|
||||||
|
- Couche d'annotations : **DOM/CSS maison** (pas de fabric/konva) — éléments
|
||||||
|
simples, édition de texte et rendu d'images natifs, dépendance évitée.
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
- `access_token` conservé **en mémoire** (perdu au rechargement, restauré par
|
||||||
|
un refresh) ; `refresh_token` persisté dans `localStorage`.
|
||||||
|
- L'intercepteur ajoute le Bearer et, sur `401`, tente un `/api/auth/refresh`
|
||||||
|
unique (les requêtes concurrentes attendent puis sont rejouées).
|
||||||
100
frontend/angular.json
Normal file
100
frontend/angular.json
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"pdfeditor-frontend": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"standalone": true,
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/pdfeditor-frontend",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "pdf.worker.min.mjs",
|
||||||
|
"input": "node_modules/pdfjs-dist/build",
|
||||||
|
"output": "assets"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1mb",
|
||||||
|
"maximumError": "4mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kb",
|
||||||
|
"maximumError": "16kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "pdfeditor-frontend:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "pdfeditor-frontend:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": ["zone.js", "zone.js/testing"],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/irm-60.png
Normal file
BIN
frontend/irm-60.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
52
frontend/nginx.conf
Normal file
52
frontend/nginx.conf
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Configuration nginx servant l'application Angular et proxifiant l'API backend.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Taille max des uploads (PDF édités). Ajuster si besoin.
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
# Compression des assets statiques.
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json
|
||||||
|
image/svg+xml application/wasm;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# Proxy de toutes les requêtes /api/* vers le service backend (Docker).
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# Téléchargements/uploads de PDF potentiellement longs.
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Les modules ES (.mjs, ex. le worker pdf.js) ne sont pas dans la table MIME
|
||||||
|
# par défaut de nginx et sortiraient en application/octet-stream, ce que le
|
||||||
|
# navigateur refuse de charger comme module. On force le bon type.
|
||||||
|
location ~* \.mjs$ {
|
||||||
|
default_type application/javascript;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache long pour les assets versionnés (hash dans le nom de fichier).
|
||||||
|
location /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback SPA : toute autre route renvoie index.html (routing Angular).
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
16346
frontend/package-lock.json
generated
Normal file
16346
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "pdfeditor-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Frontend Angular de PdfEditor — éditeur de PDF web auto-hébergé.",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^19.2.0",
|
||||||
|
"@angular/common": "^19.2.0",
|
||||||
|
"@angular/compiler": "^19.2.0",
|
||||||
|
"@angular/core": "^19.2.0",
|
||||||
|
"@angular/forms": "^19.2.0",
|
||||||
|
"@angular/platform-browser": "^19.2.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||||
|
"@angular/router": "^19.2.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfjs-dist": "^4.10.38",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"signature_pad": "^5.0.4",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^19.2.0",
|
||||||
|
"@angular/cli": "^19.2.0",
|
||||||
|
"@angular/compiler-cli": "^19.2.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"jasmine-core": "~5.4.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"typescript": "~5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/proxy.conf.json
Normal file
8
frontend/proxy.conf.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:8081",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"logLevel": "debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
frontend/public/favicon.ico
Normal file
0
frontend/public/favicon.ico
Normal file
82
frontend/src/app/app.component.ts
Normal file
82
frontend/src/app/app.component.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { AsyncPipe } from '@angular/common';
|
||||||
|
import { AuthService } from './core/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composant racine : barre de navigation commune + zone de routage.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet, RouterLink, RouterLinkActive, AsyncPipe],
|
||||||
|
template: `
|
||||||
|
<header class="barre-nav">
|
||||||
|
<a class="logo" routerLink="/editor">📄 PdfEditor</a>
|
||||||
|
<nav>
|
||||||
|
<a routerLink="/editor" routerLinkActive="actif">Éditeur</a>
|
||||||
|
@if (auth.estConnecte$ | async) {
|
||||||
|
<a routerLink="/dashboard" routerLinkActive="actif">Mes documents</a>
|
||||||
|
<span class="email">{{ (auth.utilisateur$ | async)?.email }}</span>
|
||||||
|
<button class="btn" (click)="deconnexion()">Déconnexion</button>
|
||||||
|
} @else {
|
||||||
|
<a routerLink="/login" routerLinkActive="actif">Connexion</a>
|
||||||
|
<a routerLink="/register" routerLinkActive="actif">Inscription</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.barre-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 56px;
|
||||||
|
background: var(--couleur-surface);
|
||||||
|
border-bottom: 1px solid var(--couleur-bordure);
|
||||||
|
box-shadow: var(--ombre);
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--couleur-texte);
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
color: var(--couleur-texte-doux);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
nav a.actif {
|
||||||
|
color: var(--couleur-primaire);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.email {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--couleur-texte-doux);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppComponent {
|
||||||
|
protected readonly auth = inject(AuthService);
|
||||||
|
|
||||||
|
deconnexion(): void {
|
||||||
|
this.auth.logout().subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
frontend/src/app/app.config.ts
Normal file
19
frontend/src/app/app.config.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { authInterceptor } from './core/auth.interceptor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration racine de l'application standalone.
|
||||||
|
* - Router avec les routes déclarées dans app.routes.ts
|
||||||
|
* - HttpClient muni de l'intercepteur d'authentification (Bearer + refresh sur 401)
|
||||||
|
*/
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
],
|
||||||
|
};
|
||||||
53
frontend/src/app/app.routes.ts
Normal file
53
frontend/src/app/app.routes.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { authGuard } from './core/auth.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Définition des routes de l'application.
|
||||||
|
* Les pages protégées (dashboard, éditeur) sont gardées par authGuard.
|
||||||
|
* Tout est chargé en lazy-loading pour alléger le bundle initial.
|
||||||
|
*/
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/login.component').then((m) => m.LoginComponent),
|
||||||
|
title: 'Connexion — PdfEditor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/register.component').then(
|
||||||
|
(m) => m.RegisterComponent,
|
||||||
|
),
|
||||||
|
title: 'Inscription — PdfEditor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/dashboard/dashboard.component').then(
|
||||||
|
(m) => m.DashboardComponent,
|
||||||
|
),
|
||||||
|
title: 'Mes documents — PdfEditor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'editor',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/editor/editor.component').then(
|
||||||
|
(m) => m.EditorComponent,
|
||||||
|
),
|
||||||
|
title: 'Éditeur — PdfEditor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Ouverture d'un document existant : /editor/:id
|
||||||
|
path: 'editor/:id',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/editor/editor.component').then(
|
||||||
|
(m) => m.EditorComponent,
|
||||||
|
),
|
||||||
|
title: 'Éditeur — PdfEditor',
|
||||||
|
},
|
||||||
|
{ path: '', redirectTo: 'editor', pathMatch: 'full' },
|
||||||
|
{ path: '**', redirectTo: 'editor' },
|
||||||
|
];
|
||||||
53
frontend/src/app/core/auth.guard.ts
Normal file
53
frontend/src/app/core/auth.guard.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { Observable, map, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde de route pour les pages protégées.
|
||||||
|
*
|
||||||
|
* - Si un utilisateur est déjà chargé en mémoire -> accès autorisé.
|
||||||
|
* - Sinon, s'il existe un refresh_token persistant, on tente de restaurer la
|
||||||
|
* session (refresh + chargement du profil) : utile après un rechargement de page
|
||||||
|
* où l'access_token en mémoire a été perdu.
|
||||||
|
* - En cas d'échec, redirection vers /login en conservant l'URL demandée.
|
||||||
|
*/
|
||||||
|
export const authGuard: CanActivateFn = (_route, state): Observable<boolean> => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
const versLogin = () =>
|
||||||
|
router.createUrlTree(['/login'], {
|
||||||
|
queryParams: { redirige: state.url },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utilisateur déjà en mémoire.
|
||||||
|
if (auth.getAccessToken()) {
|
||||||
|
return of(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentative de restauration via le refresh_token persistant.
|
||||||
|
if (auth.aUneSessionPotentielle()) {
|
||||||
|
return auth.refresh().pipe(
|
||||||
|
// Après refresh on récupère le profil pour peupler le BehaviorSubject.
|
||||||
|
map(() => true),
|
||||||
|
// chargerProfil est déclenché en arrière-plan ; on autorise dès le refresh OK.
|
||||||
|
catchError(() => {
|
||||||
|
auth.viderSession();
|
||||||
|
router.navigateByUrl('');
|
||||||
|
return of(false);
|
||||||
|
}),
|
||||||
|
map((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
auth.chargerProfil().subscribe({ error: () => {} });
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aucune session : redirection.
|
||||||
|
router.navigateByUrl(versLogin().toString());
|
||||||
|
return of(false);
|
||||||
|
};
|
||||||
107
frontend/src/app/core/auth.interceptor.ts
Normal file
107
frontend/src/app/core/auth.interceptor.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
HttpErrorResponse,
|
||||||
|
HttpEvent,
|
||||||
|
HttpHandlerFn,
|
||||||
|
HttpInterceptorFn,
|
||||||
|
HttpRequest,
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
Observable,
|
||||||
|
catchError,
|
||||||
|
filter,
|
||||||
|
switchMap,
|
||||||
|
take,
|
||||||
|
throwError,
|
||||||
|
} from 'rxjs';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepteur HTTP :
|
||||||
|
* 1. Ajoute l'en-tête `Authorization: Bearer <access_token>` aux requêtes /api
|
||||||
|
* (sauf aux endpoints d'authentification eux-mêmes).
|
||||||
|
* 2. Sur une réponse 401, tente UN refresh puis rejoue la requête. Les requêtes
|
||||||
|
* concurrentes pendant le refresh sont mises en file et rejouées une fois le
|
||||||
|
* nouveau token disponible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// État partagé du rafraîchissement (au niveau module : un seul refresh à la fois).
|
||||||
|
let refreshEnCours = false;
|
||||||
|
const tokenRafraichi$ = new BehaviorSubject<string | null>(null);
|
||||||
|
|
||||||
|
/** Endpoints qui ne doivent pas porter de Bearer ni déclencher de refresh. */
|
||||||
|
const ENDPOINTS_AUTH = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh'];
|
||||||
|
|
||||||
|
function estEndpointAuth(url: string): boolean {
|
||||||
|
return ENDPOINTS_AUTH.some((e) => url.includes(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ajouterToken(
|
||||||
|
req: HttpRequest<unknown>,
|
||||||
|
token: string,
|
||||||
|
): HttpRequest<unknown> {
|
||||||
|
return req.clone({
|
||||||
|
setHeaders: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
|
||||||
|
// Les endpoints d'auth passent tels quels.
|
||||||
|
if (estEndpointAuth(req.url)) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = auth.getAccessToken();
|
||||||
|
const requete = accessToken ? ajouterToken(req, accessToken) : req;
|
||||||
|
|
||||||
|
return next(requete).pipe(
|
||||||
|
catchError((erreur: unknown) => {
|
||||||
|
if (
|
||||||
|
erreur instanceof HttpErrorResponse &&
|
||||||
|
erreur.status === 401 &&
|
||||||
|
auth.aUneSessionPotentielle()
|
||||||
|
) {
|
||||||
|
return gererErreur401(req, next, auth);
|
||||||
|
}
|
||||||
|
return throwError(() => erreur);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gère un 401 : déclenche (ou attend) un refresh, puis rejoue la requête.
|
||||||
|
*/
|
||||||
|
function gererErreur401(
|
||||||
|
req: HttpRequest<unknown>,
|
||||||
|
next: HttpHandlerFn,
|
||||||
|
auth: AuthService,
|
||||||
|
): Observable<HttpEvent<unknown>> {
|
||||||
|
if (refreshEnCours) {
|
||||||
|
// Un refresh est déjà en cours : on attend le nouveau token puis on rejoue.
|
||||||
|
return tokenRafraichi$.pipe(
|
||||||
|
filter((token): token is string => token !== null),
|
||||||
|
take(1),
|
||||||
|
switchMap((token) => next(ajouterToken(req, token))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshEnCours = true;
|
||||||
|
tokenRafraichi$.next(null);
|
||||||
|
|
||||||
|
return auth.refresh().pipe(
|
||||||
|
switchMap((res) => {
|
||||||
|
refreshEnCours = false;
|
||||||
|
tokenRafraichi$.next(res.access_token);
|
||||||
|
return next(ajouterToken(req, res.access_token));
|
||||||
|
}),
|
||||||
|
catchError((erreur: unknown) => {
|
||||||
|
// Le refresh a échoué : session invalide, on purge et on propage l'erreur.
|
||||||
|
refreshEnCours = false;
|
||||||
|
auth.viderSession();
|
||||||
|
return throwError(() => erreur);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
123
frontend/src/app/core/auth.service.ts
Normal file
123
frontend/src/app/core/auth.service.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, Observable, map, tap } from 'rxjs';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { ReponseAuth, ReponseRefresh, Utilisateur } from './models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service central d'authentification.
|
||||||
|
*
|
||||||
|
* Stratégie de stockage (cf. contrat d'API) :
|
||||||
|
* - access_token : conservé EN MÉMOIRE uniquement (variable privée), jamais persisté
|
||||||
|
* pour limiter l'exposition en cas de XSS.
|
||||||
|
* - refresh_token : persisté dans localStorage afin de rétablir la session au
|
||||||
|
* rechargement de la page.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AuthService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private static readonly CLE_REFRESH = 'pdfeditor_refresh_token';
|
||||||
|
|
||||||
|
/** access_token en mémoire (perdu au rechargement, restauré via refresh) */
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
|
||||||
|
private readonly utilisateurSubject =
|
||||||
|
new BehaviorSubject<Utilisateur | null>(null);
|
||||||
|
readonly utilisateur$ = this.utilisateurSubject.asObservable();
|
||||||
|
readonly estConnecte$ = this.utilisateur$.pipe(map((u) => u !== null));
|
||||||
|
|
||||||
|
/** Indique si l'on possède un refresh_token persistant (session potentielle). */
|
||||||
|
aUneSessionPotentielle(): boolean {
|
||||||
|
return this.getRefreshToken() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccessToken(): string | null {
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRefreshToken(): string | null {
|
||||||
|
return localStorage.getItem(AuthService.CLE_REFRESH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Endpoints d'authentification --------------------------------------
|
||||||
|
|
||||||
|
register(email: string, password: string): Observable<ReponseAuth> {
|
||||||
|
return this.http
|
||||||
|
.post<ReponseAuth>('/api/auth/register', { email, password })
|
||||||
|
.pipe(tap((res) => this.appliquerSession(res)));
|
||||||
|
}
|
||||||
|
|
||||||
|
login(email: string, password: string): Observable<ReponseAuth> {
|
||||||
|
return this.http
|
||||||
|
.post<ReponseAuth>('/api/auth/login', { email, password })
|
||||||
|
.pipe(tap((res) => this.appliquerSession(res)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rafraîchit le couple de tokens à partir du refresh_token persistant.
|
||||||
|
* Utilisé par l'intercepteur sur 401 et au démarrage de l'app.
|
||||||
|
*/
|
||||||
|
refresh(): Observable<ReponseRefresh> {
|
||||||
|
const refreshToken = this.getRefreshToken();
|
||||||
|
return this.http
|
||||||
|
.post<ReponseRefresh>('/api/auth/refresh', {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
tap((res) => {
|
||||||
|
this.accessToken = res.access_token;
|
||||||
|
this.enregistrerRefreshToken(res.refresh_token);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Récupère le profil courant (utilisé après un refresh au démarrage). */
|
||||||
|
chargerProfil(): Observable<Utilisateur> {
|
||||||
|
return this.http
|
||||||
|
.get<Utilisateur>('/api/me')
|
||||||
|
.pipe(tap((u) => this.utilisateurSubject.next(u)));
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): Observable<void> {
|
||||||
|
// On tente d'invalider le refresh côté serveur ; quoi qu'il arrive on purge en local.
|
||||||
|
const finaliser = () => {
|
||||||
|
this.viderSession();
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
};
|
||||||
|
return new Observable<void>((observer) => {
|
||||||
|
this.http.post<void>('/api/auth/logout', {}).subscribe({
|
||||||
|
next: () => {
|
||||||
|
finaliser();
|
||||||
|
observer.next();
|
||||||
|
observer.complete();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
finaliser();
|
||||||
|
observer.next();
|
||||||
|
observer.complete();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers internes ---------------------------------------------------
|
||||||
|
|
||||||
|
private appliquerSession(res: ReponseAuth): void {
|
||||||
|
this.accessToken = res.access_token;
|
||||||
|
this.enregistrerRefreshToken(res.refresh_token);
|
||||||
|
this.utilisateurSubject.next(res.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enregistrerRefreshToken(token: string): void {
|
||||||
|
localStorage.setItem(AuthService.CLE_REFRESH, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Efface toute trace de session (utilisé au logout et sur refresh échoué). */
|
||||||
|
viderSession(): void {
|
||||||
|
this.accessToken = null;
|
||||||
|
localStorage.removeItem(AuthService.CLE_REFRESH);
|
||||||
|
this.utilisateurSubject.next(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
frontend/src/app/core/documents.service.ts
Normal file
60
frontend/src/app/core/documents.service.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
import { DocumentPdf, ReponseDocuments } from './models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'accès aux documents PDF de l'utilisateur (CRUD via /api/documents).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class DocumentsService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
/** Liste les documents de l'utilisateur connecté. */
|
||||||
|
lister(): Observable<DocumentPdf[]> {
|
||||||
|
return this.http
|
||||||
|
.get<ReponseDocuments>('/api/documents')
|
||||||
|
.pipe(map((r) => r.documents));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Métadonnées d'un document. */
|
||||||
|
obtenir(id: string): Observable<DocumentPdf> {
|
||||||
|
return this.http.get<DocumentPdf>(`/api/documents/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Télécharge le binaire PDF d'un document. */
|
||||||
|
telechargerFichier(id: string): Observable<ArrayBuffer> {
|
||||||
|
return this.http.get(`/api/documents/${id}/file`, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload d'un nouveau PDF. */
|
||||||
|
creer(fichier: Blob, nom: string): Observable<DocumentPdf> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', fichier, nom);
|
||||||
|
form.append('name', nom);
|
||||||
|
return this.http.post<DocumentPdf>('/api/documents', form);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met à jour le binaire et/ou le nom d'un document existant. */
|
||||||
|
mettreAJour(
|
||||||
|
id: string,
|
||||||
|
fichier: Blob | null,
|
||||||
|
nom: string | null,
|
||||||
|
): Observable<DocumentPdf> {
|
||||||
|
const form = new FormData();
|
||||||
|
if (fichier) {
|
||||||
|
form.append('file', fichier, nom ?? 'document.pdf');
|
||||||
|
}
|
||||||
|
if (nom) {
|
||||||
|
form.append('name', nom);
|
||||||
|
}
|
||||||
|
return this.http.put<DocumentPdf>(`/api/documents/${id}`, form);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supprime un document. */
|
||||||
|
supprimer(id: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`/api/documents/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/src/app/core/models.ts
Normal file
35
frontend/src/app/core/models.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Modèles de données alignés sur le contrat d'API (docs/API.md).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Utilisateur {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse des endpoints /auth/login et /auth/register */
|
||||||
|
export interface ReponseAuth {
|
||||||
|
user: Utilisateur;
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse de /auth/refresh */
|
||||||
|
export interface ReponseRefresh {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Métadonnées d'un document PDF */
|
||||||
|
export interface DocumentPdf {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReponseDocuments {
|
||||||
|
documents: DocumentPdf[];
|
||||||
|
}
|
||||||
36
frontend/src/app/features/auth/auth.scss
Normal file
36
frontend/src/app/features/auth/auth.scss
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/* Styles partagés des pages login / register */
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 48px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-auth {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
background: var(--couleur-surface);
|
||||||
|
border: 1px solid var(--couleur-bordure);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
box-shadow: var(--ombre);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-auth h1 {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.bloc {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lien-secondaire {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--couleur-texte-doux);
|
||||||
|
}
|
||||||
90
frontend/src/app/features/auth/login.component.ts
Normal file
90
frontend/src/app/features/auth/login.component.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { AuthService } from '../../core/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page de connexion. Consomme /api/auth/login via AuthService.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule, RouterLink],
|
||||||
|
template: `
|
||||||
|
<div class="carte-auth">
|
||||||
|
<h1>Connexion</h1>
|
||||||
|
<form (ngSubmit)="soumettre()">
|
||||||
|
<div class="champ">
|
||||||
|
<label for="email">Adresse e-mail</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
[(ngModel)]="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="champ">
|
||||||
|
<label for="password">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (erreur()) {
|
||||||
|
<p class="message-erreur">{{ erreur() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primaire bloc"
|
||||||
|
type="submit"
|
||||||
|
[disabled]="chargement()"
|
||||||
|
>
|
||||||
|
{{ chargement() ? 'Connexion…' : 'Se connecter' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="lien-secondaire">
|
||||||
|
Pas de compte ? <a routerLink="/register">Créer un compte</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrl: './auth.scss',
|
||||||
|
})
|
||||||
|
export class LoginComponent {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
email = '';
|
||||||
|
password = '';
|
||||||
|
readonly chargement = signal(false);
|
||||||
|
readonly erreur = signal<string | null>(null);
|
||||||
|
|
||||||
|
soumettre(): void {
|
||||||
|
if (!this.email || !this.password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.chargement.set(true);
|
||||||
|
this.erreur.set(null);
|
||||||
|
this.auth.login(this.email, this.password).subscribe({
|
||||||
|
next: () => {
|
||||||
|
const redirige =
|
||||||
|
this.route.snapshot.queryParamMap.get('redirige') ?? '/dashboard';
|
||||||
|
this.router.navigateByUrl(redirige);
|
||||||
|
},
|
||||||
|
error: (e: HttpErrorResponse) => {
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.erreur.set(
|
||||||
|
e.error?.error ?? 'Connexion impossible. Vérifiez vos identifiants.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
109
frontend/src/app/features/auth/register.component.ts
Normal file
109
frontend/src/app/features/auth/register.component.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { AuthService } from '../../core/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page d'inscription. Consomme /api/auth/register via AuthService.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-register',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule, RouterLink],
|
||||||
|
template: `
|
||||||
|
<div class="carte-auth">
|
||||||
|
<h1>Créer un compte</h1>
|
||||||
|
<form (ngSubmit)="soumettre()">
|
||||||
|
<div class="champ">
|
||||||
|
<label for="email">Adresse e-mail</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
[(ngModel)]="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="champ">
|
||||||
|
<label for="password">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="champ">
|
||||||
|
<label for="confirmation">Confirmer le mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="confirmation"
|
||||||
|
name="confirmation"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="confirmation"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (erreur()) {
|
||||||
|
<p class="message-erreur">{{ erreur() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primaire bloc"
|
||||||
|
type="submit"
|
||||||
|
[disabled]="chargement()"
|
||||||
|
>
|
||||||
|
{{ chargement() ? 'Création…' : "S'inscrire" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="lien-secondaire">
|
||||||
|
Déjà inscrit ? <a routerLink="/login">Se connecter</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrl: './auth.scss',
|
||||||
|
})
|
||||||
|
export class RegisterComponent {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
email = '';
|
||||||
|
password = '';
|
||||||
|
confirmation = '';
|
||||||
|
readonly chargement = signal(false);
|
||||||
|
readonly erreur = signal<string | null>(null);
|
||||||
|
|
||||||
|
soumettre(): void {
|
||||||
|
if (!this.email || !this.password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.password.length < 8) {
|
||||||
|
this.erreur.set('Le mot de passe doit contenir au moins 8 caractères.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.password !== this.confirmation) {
|
||||||
|
this.erreur.set('Les mots de passe ne correspondent pas.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.chargement.set(true);
|
||||||
|
this.erreur.set(null);
|
||||||
|
this.auth.register(this.email, this.password).subscribe({
|
||||||
|
next: () => this.router.navigateByUrl('/dashboard'),
|
||||||
|
error: (e: HttpErrorResponse) => {
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.erreur.set(
|
||||||
|
e.error?.error ??
|
||||||
|
(e.status === 409
|
||||||
|
? 'Cette adresse e-mail est déjà utilisée.'
|
||||||
|
: "Inscription impossible. Réessayez."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
187
frontend/src/app/features/dashboard/dashboard.component.ts
Normal file
187
frontend/src/app/features/dashboard/dashboard.component.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { DocumentsService } from '../../core/documents.service';
|
||||||
|
import { DocumentPdf } from '../../core/models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tableau de bord : liste les PDF sauvegardés de l'utilisateur.
|
||||||
|
* Permet d'ouvrir un document dans l'éditeur ou de le supprimer.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DatePipe, DecimalPipe],
|
||||||
|
template: `
|
||||||
|
<div class="conteneur">
|
||||||
|
<header class="entete">
|
||||||
|
<h1>Mes documents</h1>
|
||||||
|
<button class="btn btn-primaire" (click)="nouveau()">
|
||||||
|
+ Nouveau document
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (chargement()) {
|
||||||
|
<p class="info">Chargement…</p>
|
||||||
|
} @else if (erreur()) {
|
||||||
|
<p class="message-erreur">{{ erreur() }}</p>
|
||||||
|
} @else if (documents().length === 0) {
|
||||||
|
<div class="vide">
|
||||||
|
<p>Aucun document sauvegardé pour le moment.</p>
|
||||||
|
<button class="btn btn-primaire" (click)="nouveau()">
|
||||||
|
Charger un PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<table class="tableau">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Taille</th>
|
||||||
|
<th>Modifié le</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (doc of documents(); track doc.id) {
|
||||||
|
<tr>
|
||||||
|
<td class="nom" (click)="ouvrir(doc)">📄 {{ doc.name }}</td>
|
||||||
|
<td>{{ doc.size / 1024 | number: '1.0-0' }} Ko</td>
|
||||||
|
<td>{{ doc.updated_at | date: 'dd/MM/yyyy HH:mm' }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn" (click)="ouvrir(doc)">Ouvrir</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
(click)="supprimer(doc)"
|
||||||
|
[disabled]="suppressionEnCours() === doc.id"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.conteneur {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
|
.entete {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.entete h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
color: var(--couleur-texte-doux);
|
||||||
|
}
|
||||||
|
.vide {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
background: var(--couleur-surface);
|
||||||
|
border: 1px dashed var(--couleur-bordure);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
}
|
||||||
|
.vide p {
|
||||||
|
color: var(--couleur-texte-doux);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.tableau {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--couleur-surface);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--ombre);
|
||||||
|
}
|
||||||
|
.tableau th,
|
||||||
|
.tableau td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--couleur-bordure);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.tableau th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--couleur-texte-doux);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.nom {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.nom:hover {
|
||||||
|
color: var(--couleur-primaire);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DashboardComponent {
|
||||||
|
private readonly documentsService = inject(DocumentsService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
readonly documents = signal<DocumentPdf[]>([]);
|
||||||
|
readonly chargement = signal(true);
|
||||||
|
readonly erreur = signal<string | null>(null);
|
||||||
|
readonly suppressionEnCours = signal<string | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.recharger();
|
||||||
|
}
|
||||||
|
|
||||||
|
recharger(): void {
|
||||||
|
this.chargement.set(true);
|
||||||
|
this.documentsService.lister().subscribe({
|
||||||
|
next: (docs) => {
|
||||||
|
this.documents.set(docs);
|
||||||
|
this.chargement.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.erreur.set('Impossible de charger vos documents.');
|
||||||
|
this.chargement.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nouveau(): void {
|
||||||
|
this.router.navigate(['/editor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
ouvrir(doc: DocumentPdf): void {
|
||||||
|
this.router.navigate(['/editor', doc.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
supprimer(doc: DocumentPdf): void {
|
||||||
|
if (!confirm(`Supprimer définitivement « ${doc.name} » ?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.suppressionEnCours.set(doc.id);
|
||||||
|
this.documentsService.supprimer(doc.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.documents.update((liste) =>
|
||||||
|
liste.filter((d) => d.id !== doc.id),
|
||||||
|
);
|
||||||
|
this.suppressionEnCours.set(null);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.erreur.set('La suppression a échoué.');
|
||||||
|
this.suppressionEnCours.set(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
82
frontend/src/app/features/editor/annotation.model.ts
Normal file
82
frontend/src/app/features/editor/annotation.model.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Modèles des annotations posées sur la couche d'édition.
|
||||||
|
*
|
||||||
|
* Toutes les coordonnées (x, y, w, h) sont exprimées en POURCENTAGE [0..1]
|
||||||
|
* de la taille de la page rendue. Cela rend les annotations indépendantes du
|
||||||
|
* zoom : on les stocke en relatif et on les convertit en pixels (pour
|
||||||
|
* l'affichage) ou en points PDF (pour l'export pdf-lib) au moment voulu.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TypeAnnotation =
|
||||||
|
| 'texte'
|
||||||
|
| 'image'
|
||||||
|
| 'signature'
|
||||||
|
| 'rectangle'
|
||||||
|
| 'cercle'
|
||||||
|
| 'ligne'
|
||||||
|
| 'fleche'
|
||||||
|
| 'croix'
|
||||||
|
| 'lien';
|
||||||
|
|
||||||
|
export interface AnnotationBase {
|
||||||
|
id: string;
|
||||||
|
type: TypeAnnotation;
|
||||||
|
page: number; // index de page (0-based)
|
||||||
|
x: number; // coin haut-gauche, en fraction de la largeur
|
||||||
|
y: number; // coin haut-gauche, en fraction de la hauteur
|
||||||
|
w: number; // largeur en fraction
|
||||||
|
h: number; // hauteur en fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationTexte extends AnnotationBase {
|
||||||
|
type: 'texte';
|
||||||
|
texte: string;
|
||||||
|
taillePolice: number; // en pixels à 100% de zoom
|
||||||
|
couleur: string; // #rrggbb
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationImage extends AnnotationBase {
|
||||||
|
type: 'image' | 'signature';
|
||||||
|
/** Image encodée en data URL (png/jpeg). */
|
||||||
|
dataUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationForme extends AnnotationBase {
|
||||||
|
type: 'rectangle' | 'cercle' | 'ligne' | 'fleche' | 'croix';
|
||||||
|
couleur: string; // trait
|
||||||
|
epaisseur: number; // en pixels à 100%
|
||||||
|
remplissage?: string | null; // couleur de fond (rect/cercle), sinon null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationLien extends AnnotationBase {
|
||||||
|
type: 'lien';
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Annotation =
|
||||||
|
| AnnotationTexte
|
||||||
|
| AnnotationImage
|
||||||
|
| AnnotationForme
|
||||||
|
| AnnotationLien;
|
||||||
|
|
||||||
|
/** Outils sélectionnables dans la barre d'outils de l'éditeur. */
|
||||||
|
export type Outil =
|
||||||
|
| 'selection'
|
||||||
|
| 'texte'
|
||||||
|
| 'image'
|
||||||
|
| 'signature'
|
||||||
|
| 'rectangle'
|
||||||
|
| 'cercle'
|
||||||
|
| 'ligne'
|
||||||
|
| 'fleche'
|
||||||
|
| 'croix'
|
||||||
|
| 'lien';
|
||||||
|
|
||||||
|
/** Liste des outils « forme » regroupés dans le menu déroulant de la barre d'outils. */
|
||||||
|
export const FORMES: { outil: Outil; icone: string; libelle: string }[] = [
|
||||||
|
{ outil: 'rectangle', icone: '▭', libelle: 'Rectangle' },
|
||||||
|
{ outil: 'cercle', icone: '◯', libelle: 'Cercle' },
|
||||||
|
{ outil: 'ligne', icone: '/', libelle: 'Ligne' },
|
||||||
|
{ outil: 'fleche', icone: '➚', libelle: 'Flèche' },
|
||||||
|
{ outil: 'croix', icone: '✕', libelle: 'Croix' },
|
||||||
|
];
|
||||||
114
frontend/src/app/features/editor/editor.component.html
Normal file
114
frontend/src/app/features/editor/editor.component.html
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<div class="editeur">
|
||||||
|
@if (!pdf()) {
|
||||||
|
<!-- Zone de glisser-déposer initiale -->
|
||||||
|
<div
|
||||||
|
class="depot"
|
||||||
|
[class.survol]="survolDepot()"
|
||||||
|
(dragover)="empecherDefaut($event)"
|
||||||
|
(dragleave)="finSurvol($event)"
|
||||||
|
(drop)="surFichierDepose($event)"
|
||||||
|
>
|
||||||
|
<div class="depot-contenu">
|
||||||
|
<div class="icone">📄</div>
|
||||||
|
<h2>Glissez-déposez un PDF ici</h2>
|
||||||
|
<p>ou</p>
|
||||||
|
<label class="btn btn-primaire">
|
||||||
|
Parcourir un fichier
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
hidden
|
||||||
|
(change)="surFichierChoisi($event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
@if (message()) {
|
||||||
|
<p class="message-erreur">{{ message() }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- Barre d'outils -->
|
||||||
|
<div class="barre-outils">
|
||||||
|
<div class="groupe">
|
||||||
|
<button class="btn" [class.actif]="outil() === 'selection'" (click)="choisirOutil('selection')" title="Sélection">↖</button>
|
||||||
|
<button class="btn" [class.actif]="outil() === 'texte'" (click)="choisirOutil('texte')" title="Texte">T</button>
|
||||||
|
<label class="btn" title="Image">
|
||||||
|
🖼
|
||||||
|
<input type="file" accept="image/*" hidden (change)="surImageChoisie($event)" />
|
||||||
|
</label>
|
||||||
|
<button class="btn" [class.actif]="outil() === 'signature'" (click)="choisirOutil('signature')" title="Signature">✍</button>
|
||||||
|
|
||||||
|
<!-- Menu déroulant des formes -->
|
||||||
|
<div class="menu-formes">
|
||||||
|
<button class="btn" [class.actif]="estForme(outil())" (click)="basculerMenuFormes()" title="Formes">
|
||||||
|
{{ iconeForme(formeActive()) }} ▾
|
||||||
|
</button>
|
||||||
|
@if (menuFormesOuvert()) {
|
||||||
|
<div class="menu-deroulant">
|
||||||
|
@for (f of formes; track f.outil) {
|
||||||
|
<button class="item" [class.actif]="outil() === f.outil" (click)="choisirForme(f.outil)">
|
||||||
|
<span class="ic">{{ f.icone }}</span> {{ f.libelle }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" [class.actif]="outil() === 'lien'" (click)="choisirOutil('lien')" title="Lien">🔗</button>
|
||||||
|
<input
|
||||||
|
class="selecteur-couleur"
|
||||||
|
type="color"
|
||||||
|
[value]="couleur()"
|
||||||
|
(input)="couleur.set($any($event.target).value)"
|
||||||
|
title="Couleur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="groupe">
|
||||||
|
<button class="btn" (click)="zoomMoins()" title="Dézoomer">−</button>
|
||||||
|
<span class="zoom-valeur">{{ (zoom() * 100).toFixed(0) }} %</span>
|
||||||
|
<button class="btn" (click)="zoomPlus()" title="Zoomer">+</button>
|
||||||
|
<span class="zoom-valeur" title="version">v16</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="groupe">
|
||||||
|
<button class="btn" (click)="telecharger()">⬇ Télécharger</button>
|
||||||
|
@if (estConnecte$ | async) {
|
||||||
|
<button class="btn btn-primaire" (click)="sauvegarder()" [disabled]="chargement()">
|
||||||
|
💾 Sauvegarder
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button class="btn" (click)="allerConnexion()">Se connecter pour sauvegarder</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (message()) {
|
||||||
|
<p class="bandeau">{{ message() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Pages -->
|
||||||
|
<div class="zone-pages">
|
||||||
|
@for (i of pages(); track i) {
|
||||||
|
<app-page-layer
|
||||||
|
[pdf]="pdf()!"
|
||||||
|
[pageIndex]="i"
|
||||||
|
[zoom]="zoom()"
|
||||||
|
[outil]="outil()"
|
||||||
|
[couleur]="couleur()"
|
||||||
|
[chargeUtile]="chargeUtile()"
|
||||||
|
[annotations]="annotations()"
|
||||||
|
(annotationsChange)="majAnnotations($event)"
|
||||||
|
(outilConsomme)="outilConsomme()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (afficheSignature()) {
|
||||||
|
<app-signature-dialog
|
||||||
|
(valide)="signatureValidee($event)"
|
||||||
|
(annule)="signatureAnnulee()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
138
frontend/src/app/features/editor/editor.component.scss
Normal file
138
frontend/src/app/features/editor/editor.component.scss
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
.editeur {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Zone de dépôt initiale --- */
|
||||||
|
.depot {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 24px;
|
||||||
|
border: 2px dashed var(--couleur-bordure);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--couleur-surface);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.depot.survol {
|
||||||
|
border-color: var(--couleur-primaire);
|
||||||
|
background: #eef3ff;
|
||||||
|
}
|
||||||
|
.depot-contenu {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.depot-contenu .icone {
|
||||||
|
font-size: 56px;
|
||||||
|
}
|
||||||
|
.depot-contenu h2 {
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
.depot-contenu p {
|
||||||
|
color: var(--couleur-texte-doux);
|
||||||
|
margin: 6px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Barre d'outils --- */
|
||||||
|
.barre-outils {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--couleur-surface);
|
||||||
|
border-bottom: 1px solid var(--couleur-bordure);
|
||||||
|
}
|
||||||
|
.groupe {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.barre-outils .btn {
|
||||||
|
padding: 7px 10px;
|
||||||
|
min-width: 36px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.barre-outils .btn.actif {
|
||||||
|
background: var(--couleur-primaire);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--couleur-primaire);
|
||||||
|
}
|
||||||
|
.menu-formes {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.menu-deroulant {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 150px;
|
||||||
|
background: var(--couleur-surface);
|
||||||
|
border: 1px solid var(--couleur-bordure);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
box-shadow: var(--ombre);
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.menu-deroulant .item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.menu-deroulant .item:hover {
|
||||||
|
background: var(--couleur-fond, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
.menu-deroulant .item.actif {
|
||||||
|
background: var(--couleur-primaire);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.menu-deroulant .item .ic {
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.selecteur-couleur {
|
||||||
|
width: 36px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--couleur-bordure);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.zoom-valeur {
|
||||||
|
min-width: 52px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--couleur-texte-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #eef3ff;
|
||||||
|
color: var(--couleur-primaire);
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--couleur-bordure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Zone des pages --- */
|
||||||
|
.zone-pages {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px;
|
||||||
|
background: #e9edf5;
|
||||||
|
}
|
||||||
292
frontend/src/app/features/editor/editor.component.ts
Normal file
292
frontend/src/app/features/editor/editor.component.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { AsyncPipe } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||||
|
import { PdfService } from './pdf.service';
|
||||||
|
import { DocumentsService } from '../../core/documents.service';
|
||||||
|
import { AuthService } from '../../core/auth.service';
|
||||||
|
import { Annotation, Outil, FORMES } from './annotation.model';
|
||||||
|
import { PageLayerComponent } from './page-layer.component';
|
||||||
|
import { SignatureDialogComponent } from './signature-dialog.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composant principal de l'éditeur : chargement (drag & drop ou document
|
||||||
|
* existant), barre d'outils, navigation/zoom, couches d'édition par page,
|
||||||
|
* puis export (téléchargement) et sauvegarde (API).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-editor',
|
||||||
|
standalone: true,
|
||||||
|
imports: [AsyncPipe, PageLayerComponent, SignatureDialogComponent],
|
||||||
|
templateUrl: './editor.component.html',
|
||||||
|
styleUrl: './editor.component.scss',
|
||||||
|
})
|
||||||
|
export class EditorComponent {
|
||||||
|
private readonly pdfService = inject(PdfService);
|
||||||
|
private readonly documentsService = inject(DocumentsService);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
/** Binaire du PDF source (conservé pour l'export pdf-lib). */
|
||||||
|
private sourcePdf: ArrayBuffer | null = null;
|
||||||
|
/** Document pdf.js pour le rendu. */
|
||||||
|
readonly pdf = signal<PDFDocumentProxy | null>(null);
|
||||||
|
readonly nbPages = signal(0);
|
||||||
|
readonly nomDocument = signal('document.pdf');
|
||||||
|
/** Identifiant du document si ouvert depuis le dashboard (mode mise à jour). */
|
||||||
|
private documentId: string | null = null;
|
||||||
|
|
||||||
|
readonly annotations = signal<Annotation[]>([]);
|
||||||
|
readonly outil = signal<Outil>('selection');
|
||||||
|
readonly couleur = signal('#000000');
|
||||||
|
readonly zoom = signal(1);
|
||||||
|
|
||||||
|
/** Outils « forme » du menu déroulant. */
|
||||||
|
readonly formes = FORMES;
|
||||||
|
/** Dernière forme choisie (affichée sur le bouton du menu). */
|
||||||
|
readonly formeActive = signal<Outil>('rectangle');
|
||||||
|
readonly menuFormesOuvert = signal(false);
|
||||||
|
|
||||||
|
/** Charge utile pour les outils image/signature (data URL à poser). */
|
||||||
|
readonly chargeUtile = signal<string | null>(null);
|
||||||
|
readonly afficheSignature = signal(false);
|
||||||
|
|
||||||
|
readonly chargement = signal(false);
|
||||||
|
readonly message = signal<string | null>(null);
|
||||||
|
readonly survolDepot = signal(false);
|
||||||
|
|
||||||
|
readonly estConnecte$ = this.auth.estConnecte$;
|
||||||
|
|
||||||
|
/** Pages itérables pour le template. */
|
||||||
|
pages = (): number[] =>
|
||||||
|
Array.from({ length: this.nbPages() }, (_, i) => i);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
|
if (id) {
|
||||||
|
this.documentId = id;
|
||||||
|
this.ouvrirDocumentExistant(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chargement ---------------------------------------------------------
|
||||||
|
|
||||||
|
private async ouvrirDocumentExistant(id: string): Promise<void> {
|
||||||
|
this.chargement.set(true);
|
||||||
|
this.documentsService.obtenir(id).subscribe({
|
||||||
|
next: (meta) => this.nomDocument.set(meta.name),
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
this.documentsService.telechargerFichier(id).subscribe({
|
||||||
|
next: async (buffer) => {
|
||||||
|
await this.chargerBuffer(buffer);
|
||||||
|
this.chargement.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.message.set("Impossible d'ouvrir ce document.");
|
||||||
|
this.chargement.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async surFichierDepose(ev: DragEvent): Promise<void> {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.survolDepot.set(false);
|
||||||
|
const fichier = ev.dataTransfer?.files?.[0];
|
||||||
|
if (fichier) {
|
||||||
|
await this.chargerFichier(fichier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async surFichierChoisi(ev: Event): Promise<void> {
|
||||||
|
const fichier = (ev.target as HTMLInputElement).files?.[0];
|
||||||
|
if (fichier) {
|
||||||
|
await this.chargerFichier(fichier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async chargerFichier(fichier: File): Promise<void> {
|
||||||
|
if (fichier.type !== 'application/pdf') {
|
||||||
|
this.message.set('Veuillez déposer un fichier PDF.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.nomDocument.set(fichier.name);
|
||||||
|
this.documentId = null; // nouveau fichier => création à la sauvegarde
|
||||||
|
const buffer = await fichier.arrayBuffer();
|
||||||
|
await this.chargerBuffer(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async chargerBuffer(buffer: ArrayBuffer): Promise<void> {
|
||||||
|
this.message.set(null);
|
||||||
|
this.sourcePdf = buffer.slice(0);
|
||||||
|
const pdf = await this.pdfService.charger(buffer);
|
||||||
|
this.pdf.set(pdf);
|
||||||
|
this.nbPages.set(pdf.numPages);
|
||||||
|
this.annotations.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
empecherDefaut(ev: DragEvent): void {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.survolDepot.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
finSurvol(ev: DragEvent): void {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.survolDepot.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Barre d'outils -----------------------------------------------------
|
||||||
|
|
||||||
|
choisirOutil(o: Outil): void {
|
||||||
|
this.menuFormesOuvert.set(false);
|
||||||
|
this.outil.set(o);
|
||||||
|
if (o === 'signature') {
|
||||||
|
this.afficheSignature.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Menu des formes ----------------------------------------------------
|
||||||
|
|
||||||
|
/** Vrai si l'outil donné est une forme (pour l'état actif du bouton menu). */
|
||||||
|
estForme(o: Outil): boolean {
|
||||||
|
return this.formes.some((f) => f.outil === o);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icône associée à un outil forme. */
|
||||||
|
iconeForme(o: Outil): string {
|
||||||
|
return this.formes.find((f) => f.outil === o)?.icone ?? '▭';
|
||||||
|
}
|
||||||
|
|
||||||
|
basculerMenuFormes(): void {
|
||||||
|
this.menuFormesOuvert.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choix d'une forme dans le menu déroulant. */
|
||||||
|
choisirForme(o: Outil): void {
|
||||||
|
this.formeActive.set(o);
|
||||||
|
this.menuFormesOuvert.set(false);
|
||||||
|
this.outil.set(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déclenché par le sélecteur de fichier image caché. */
|
||||||
|
async surImageChoisie(ev: Event): Promise<void> {
|
||||||
|
const fichier = (ev.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!fichier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dataUrl = await this.lireDataUrl(fichier);
|
||||||
|
this.chargeUtile.set(dataUrl);
|
||||||
|
this.outil.set('image');
|
||||||
|
this.message.set(
|
||||||
|
'Cliquez sur la page pour poser l’image.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureValidee(dataUrl: string): void {
|
||||||
|
this.chargeUtile.set(dataUrl);
|
||||||
|
this.outil.set('signature');
|
||||||
|
this.afficheSignature.set(false);
|
||||||
|
this.message.set('Cliquez sur la page pour poser la signature.');
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureAnnulee(): void {
|
||||||
|
this.afficheSignature.set(false);
|
||||||
|
this.outil.set('selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
outilConsomme(): void {
|
||||||
|
// Après pose d'un élément ponctuel, on revient en sélection.
|
||||||
|
this.outil.set('selection');
|
||||||
|
this.chargeUtile.set(null);
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
majAnnotations(liste: Annotation[]): void {
|
||||||
|
this.annotations.set(liste);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zoom / navigation --------------------------------------------------
|
||||||
|
|
||||||
|
zoomPlus(): void {
|
||||||
|
this.zoom.update((z) => Math.min(3, +(z + 0.2).toFixed(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomMoins(): void {
|
||||||
|
this.zoom.update((z) => Math.max(0.4, +(z - 0.2).toFixed(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Export / sauvegarde ------------------------------------------------
|
||||||
|
|
||||||
|
private async genererPdf(): Promise<Uint8Array> {
|
||||||
|
if (!this.sourcePdf) {
|
||||||
|
throw new Error('Aucun PDF chargé');
|
||||||
|
}
|
||||||
|
return this.pdfService.exporter(this.sourcePdf, this.annotations());
|
||||||
|
}
|
||||||
|
|
||||||
|
async telecharger(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const octets = await this.genererPdf();
|
||||||
|
const blob = new Blob([octets as BlobPart], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = this.nomDocument().endsWith('.pdf')
|
||||||
|
? this.nomDocument()
|
||||||
|
: `${this.nomDocument()}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
this.message.set("L'export a échoué.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sauvegarder(): Promise<void> {
|
||||||
|
this.chargement.set(true);
|
||||||
|
this.message.set(null);
|
||||||
|
try {
|
||||||
|
const octets = await this.genererPdf();
|
||||||
|
const blob = new Blob([octets as BlobPart], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
const nom = this.nomDocument();
|
||||||
|
const requete = this.documentId
|
||||||
|
? this.documentsService.mettreAJour(this.documentId, blob, nom)
|
||||||
|
: this.documentsService.creer(blob, nom);
|
||||||
|
requete.subscribe({
|
||||||
|
next: (doc) => {
|
||||||
|
this.documentId = doc.id;
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.message.set('Document sauvegardé.');
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.message.set('La sauvegarde a échoué.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.message.set("Impossible de générer le PDF.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allerConnexion(): void {
|
||||||
|
this.router.navigate(['/login'], {
|
||||||
|
queryParams: { redirige: this.router.url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
private lireDataUrl(fichier: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(fichier);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
743
frontend/src/app/features/editor/page-layer.component.ts
Normal file
743
frontend/src/app/features/editor/page-layer.component.ts
Normal file
@ -0,0 +1,743 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
Output,
|
||||||
|
SimpleChanges,
|
||||||
|
ViewChild,
|
||||||
|
inject,
|
||||||
|
} from '@angular/core';
|
||||||
|
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
|
||||||
|
import { PdfService } from './pdf.service';
|
||||||
|
import {
|
||||||
|
Annotation,
|
||||||
|
AnnotationForme,
|
||||||
|
AnnotationImage,
|
||||||
|
AnnotationLien,
|
||||||
|
AnnotationTexte,
|
||||||
|
Outil,
|
||||||
|
} from './annotation.model';
|
||||||
|
|
||||||
|
/** Identifiant unique simple pour les annotations. */
|
||||||
|
function nouvelId(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche UNE page de PDF (canvas pdf.js) surmontée d'une couche d'édition
|
||||||
|
* interactive (DOM positionné en absolu).
|
||||||
|
*
|
||||||
|
* Choix d'implémentation : couche DOM/CSS maison plutôt qu'une lib type
|
||||||
|
* fabric/konva. Les éléments à manipuler restent simples (texte, image, formes,
|
||||||
|
* liens) et le DOM offre nativement l'édition de texte, le rendu d'images et la
|
||||||
|
* sélection ; cela évite une dépendance lourde et garde le code lisible.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-page-layer',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="page"
|
||||||
|
#conteneur
|
||||||
|
[style.width.px]="largeur"
|
||||||
|
[style.height.px]="hauteur"
|
||||||
|
(mousedown)="debutInteraction($event)"
|
||||||
|
>
|
||||||
|
<canvas #canvas class="rendu"></canvas>
|
||||||
|
|
||||||
|
<!-- Couche d'édition -->
|
||||||
|
<div class="couche">
|
||||||
|
<!-- DEBUG TEMP : repère BLEU (DOM) 30%-70% -->
|
||||||
|
<div style="position:absolute; left:30%; top:30%; width:40%; height:40%; border:2px solid blue; pointer-events:none; box-sizing:border-box;"></div>
|
||||||
|
@if (pageIndex === 0) {
|
||||||
|
<div style="position:fixed; top:96px; left:8px; z-index:9999; background:#ff0; color:#000; font:11px monospace; padding:4px 6px; border:1px solid #000; pointer-events:none; white-space:pre;">{{ dbg }}</div>
|
||||||
|
}
|
||||||
|
@for (a of annotationsPage(); track a.id) {
|
||||||
|
<div
|
||||||
|
class="annotation"
|
||||||
|
[class.selectionnee]="a.id === selectionId"
|
||||||
|
[style.left.px]="a.x * largeur"
|
||||||
|
[style.top.px]="a.y * hauteur"
|
||||||
|
[style.width.px]="a.w * largeur"
|
||||||
|
[style.height.px]="a.h * hauteur"
|
||||||
|
(mousedown)="debutDeplacement($event, a)"
|
||||||
|
>
|
||||||
|
@switch (a.type) {
|
||||||
|
@case ('texte') {
|
||||||
|
<div
|
||||||
|
class="contenu-texte"
|
||||||
|
[class.edition]="a.id === editionId"
|
||||||
|
[attr.contenteditable]="a.id === editionId"
|
||||||
|
[style.color]="texte(a).couleur"
|
||||||
|
[style.fontSize.px]="texte(a).taillePolice * zoom"
|
||||||
|
(dblclick)="entrerEdition(a, $event)"
|
||||||
|
(blur)="finEdition(a, $event)"
|
||||||
|
(mousedown)="a.id === editionId && $event.stopPropagation()"
|
||||||
|
>{{ texte(a).texte }}</div>
|
||||||
|
}
|
||||||
|
@case ('image') {
|
||||||
|
<img class="contenu-image" [src]="image(a).dataUrl" alt="" />
|
||||||
|
}
|
||||||
|
@case ('signature') {
|
||||||
|
<img class="contenu-image" [src]="image(a).dataUrl" alt="signature" />
|
||||||
|
}
|
||||||
|
@case ('lien') {
|
||||||
|
<div class="contenu-lien" [title]="lien(a).url">🔗 {{ lien(a).url }}</div>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<!-- formes : rectangle / cercle / ligne / flèche dessinées en SVG -->
|
||||||
|
<svg class="contenu-forme" [attr.viewBox]="'0 0 ' + (a.w * largeur) + ' ' + (a.h * hauteur)" preserveAspectRatio="none">
|
||||||
|
@if (a.type === 'rectangle') {
|
||||||
|
<!-- Trait rentré dans la boîte (box-sizing: border-box) pour
|
||||||
|
que le rendu colle exactement au curseur / à la sélection. -->
|
||||||
|
<rect [attr.x]="rectInset(a).x" [attr.y]="rectInset(a).y"
|
||||||
|
[attr.width]="rectInset(a).width" [attr.height]="rectInset(a).height"
|
||||||
|
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="strokeW(a)"
|
||||||
|
[attr.fill]="forme(a).remplissage || 'none'" />
|
||||||
|
} @else if (a.type === 'cercle') {
|
||||||
|
<ellipse [attr.cx]="ellipseInset(a).cx" [attr.cy]="ellipseInset(a).cy"
|
||||||
|
[attr.rx]="ellipseInset(a).rx" [attr.ry]="ellipseInset(a).ry"
|
||||||
|
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="strokeW(a)"
|
||||||
|
[attr.fill]="forme(a).remplissage || 'none'" />
|
||||||
|
} @else if (a.type === 'ligne') {
|
||||||
|
<line x1="0" y1="0" [attr.x2]="a.w*largeur" [attr.y2]="a.h*hauteur"
|
||||||
|
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="forme(a).epaisseur * zoom" />
|
||||||
|
} @else if (a.type === 'fleche') {
|
||||||
|
<line x1="0" y1="0" [attr.x2]="a.w*largeur" [attr.y2]="a.h*hauteur"
|
||||||
|
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="forme(a).epaisseur * zoom"
|
||||||
|
[attr.marker-end]="'url(#fleche-' + a.id + ')'" />
|
||||||
|
<defs>
|
||||||
|
<marker [attr.id]="'fleche-' + a.id" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L8,3 L0,6 Z" [attr.fill]="forme(a).couleur" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
} @else if (a.type === 'croix') {
|
||||||
|
<line x1="0" y1="0" [attr.x2]="a.w*largeur" [attr.y2]="a.h*hauteur"
|
||||||
|
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="forme(a).epaisseur * zoom" />
|
||||||
|
<line [attr.x1]="a.w*largeur" y1="0" x2="0" [attr.y2]="a.h*hauteur"
|
||||||
|
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="forme(a).epaisseur * zoom" />
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (a.id === selectionId) {
|
||||||
|
@if (a.type === 'texte') {
|
||||||
|
<div class="barre-texte" (mousedown)="$event.stopPropagation()">
|
||||||
|
<button class="mini" (click)="changerTaille(a, -2)" title="Réduire">A−</button>
|
||||||
|
<input
|
||||||
|
class="champ-taille"
|
||||||
|
type="number"
|
||||||
|
min="6"
|
||||||
|
max="300"
|
||||||
|
[value]="texte(a).taillePolice"
|
||||||
|
(input)="majTaille(a, $any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<button class="mini" (click)="changerTaille(a, 2)" title="Agrandir">A+</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button class="poignee-suppr" (mousedown)="$event.stopPropagation()" (click)="supprimer(a)">×</button>
|
||||||
|
<span class="poignee-resize" (mousedown)="debutRedim($event, a)"></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.page {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--ombre);
|
||||||
|
}
|
||||||
|
.rendu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.couche {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
.annotation {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
.annotation.selectionnee {
|
||||||
|
outline: 1px dashed var(--couleur-primaire);
|
||||||
|
}
|
||||||
|
.contenu-texte {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
outline: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
.contenu-texte.edition {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.contenu-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.contenu-forme {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.contenu-lien {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--couleur-primaire);
|
||||||
|
border: 1px solid var(--couleur-primaire);
|
||||||
|
background: rgba(59, 108, 255, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.barre-texte {
|
||||||
|
position: absolute;
|
||||||
|
top: -34px;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--couleur-bordure, #d0d0d0);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
|
cursor: default;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.barre-texte .mini {
|
||||||
|
border: none;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 26px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.barre-texte .mini:hover {
|
||||||
|
background: #e2e2e2;
|
||||||
|
}
|
||||||
|
.barre-texte .champ-taille {
|
||||||
|
width: 48px;
|
||||||
|
height: 24px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid var(--couleur-bordure, #d0d0d0);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.poignee-suppr {
|
||||||
|
position: absolute;
|
||||||
|
top: -12px;
|
||||||
|
right: -12px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--couleur-danger);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.poignee-resize {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--couleur-primaire);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class PageLayerComponent implements AfterViewInit, OnChanges {
|
||||||
|
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
@ViewChild('conteneur') conteneurRef!: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
/** Document pdf.js partagé. */
|
||||||
|
@Input({ required: true }) pdf!: PDFDocumentProxy;
|
||||||
|
/** Index de page 0-based. */
|
||||||
|
@Input({ required: true }) pageIndex!: number;
|
||||||
|
/** Niveau de zoom (1 = 100%). */
|
||||||
|
@Input({ required: true }) zoom = 1;
|
||||||
|
/** Outil actif sélectionné dans la barre d'outils. */
|
||||||
|
@Input({ required: true }) outil: Outil = 'selection';
|
||||||
|
/** Toutes les annotations du document. */
|
||||||
|
@Input({ required: true }) annotations: Annotation[] = [];
|
||||||
|
/** Couleur courante de la barre d'outils. */
|
||||||
|
@Input() couleur = '#000000';
|
||||||
|
/** Data URL d'une image/signature à poser au prochain clic (image/signature). */
|
||||||
|
@Input() chargeUtile: string | null = null;
|
||||||
|
|
||||||
|
@Output() annotationsChange = new EventEmitter<Annotation[]>();
|
||||||
|
/** Demande au parent de réinitialiser l'outil (revenir en sélection) après pose. */
|
||||||
|
@Output() outilConsomme = new EventEmitter<void>();
|
||||||
|
|
||||||
|
private readonly pdfService = inject(PdfService);
|
||||||
|
|
||||||
|
largeur = 0;
|
||||||
|
hauteur = 0;
|
||||||
|
dbg = 'v14 (en attente)';
|
||||||
|
/** Page pdf.js et ses dimensions de base (échelle 1), mises en cache. */
|
||||||
|
private pageProxy?: PDFPageProxy;
|
||||||
|
private baseLargeur = 0;
|
||||||
|
private baseHauteur = 0;
|
||||||
|
/** Rendu pdf.js en cours (pour pouvoir l'annuler au changement de zoom). */
|
||||||
|
private renderTask?: ReturnType<PDFPageProxy['render']>;
|
||||||
|
selectionId: string | null = null;
|
||||||
|
/** Annotation texte actuellement en cours d'édition (double-clic). */
|
||||||
|
editionId: string | null = null;
|
||||||
|
|
||||||
|
// État d'interaction en cours.
|
||||||
|
private mode: 'aucun' | 'creation' | 'deplacement' | 'redim' = 'aucun';
|
||||||
|
private brouillon: Annotation | null = null;
|
||||||
|
private depart = { x: 0, y: 0, ax: 0, ay: 0, aw: 0, ah: 0 };
|
||||||
|
|
||||||
|
// --- Helpers de typage pour le template --------------------------------
|
||||||
|
texte = (a: Annotation) => a as AnnotationTexte;
|
||||||
|
image = (a: Annotation) => a as AnnotationImage;
|
||||||
|
forme = (a: Annotation) => a as AnnotationForme;
|
||||||
|
lien = (a: Annotation) => a as AnnotationLien;
|
||||||
|
|
||||||
|
/** Épaisseur du trait en pixels écran (points * zoom). */
|
||||||
|
strokeW = (a: Annotation) => this.forme(a).epaisseur * this.zoom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Géométrie d'une forme remplie (rectangle/cercle) en RENTRANT le trait à
|
||||||
|
* l'intérieur de la boîte (façon box-sizing: border-box). Sans cet inset, la
|
||||||
|
* moitié de l'épaisseur déborderait de la boîte (overflow:visible), ce qui
|
||||||
|
* décale visiblement l'anchor sur une petite forme.
|
||||||
|
*/
|
||||||
|
rectInset = (a: Annotation) => {
|
||||||
|
const sw = this.strokeW(a);
|
||||||
|
return {
|
||||||
|
x: sw / 2,
|
||||||
|
y: sw / 2,
|
||||||
|
width: Math.max(0, a.w * this.largeur - sw),
|
||||||
|
height: Math.max(0, a.h * this.hauteur - sw),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
ellipseInset = (a: Annotation) => {
|
||||||
|
const sw = this.strokeW(a);
|
||||||
|
const rx = (a.w * this.largeur) / 2;
|
||||||
|
const ry = (a.h * this.hauteur) / 2;
|
||||||
|
return {
|
||||||
|
cx: rx,
|
||||||
|
cy: ry,
|
||||||
|
rx: Math.max(0, rx - sw / 2),
|
||||||
|
ry: Math.max(0, ry - sw / 2),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
annotationsPage(): Annotation[] {
|
||||||
|
return this.annotations.filter((a) => a.page === this.pageIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngAfterViewInit(): Promise<void> {
|
||||||
|
const r = await this.pdfService.chargerPage(this.pdf, this.pageIndex + 1);
|
||||||
|
this.pageProxy = r.page;
|
||||||
|
this.baseLargeur = r.baseLargeur;
|
||||||
|
this.baseHauteur = r.baseHauteur;
|
||||||
|
this.redimensionner();
|
||||||
|
await this.repeindre();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngOnChanges(changes: SimpleChanges): Promise<void> {
|
||||||
|
// Au changement de zoom : on redimensionne la page de façon SYNCHRONE (donc
|
||||||
|
// immédiate), puis on repeint. La page est toujours à la bonne taille quand
|
||||||
|
// l'utilisateur dessine, même si le rendu pixel est lent (PDF lourd).
|
||||||
|
if (changes['zoom'] && this.pageProxy) {
|
||||||
|
this.redimensionner();
|
||||||
|
await this.repeindre();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dimensionnement synchrone du canvas + de la couche d'annotations. */
|
||||||
|
private redimensionner(): void {
|
||||||
|
const dims = this.pdfService.dimensionner(
|
||||||
|
this.canvasRef.nativeElement,
|
||||||
|
this.baseLargeur,
|
||||||
|
this.baseHauteur,
|
||||||
|
this.zoom,
|
||||||
|
);
|
||||||
|
this.largeur = dims.largeur;
|
||||||
|
this.hauteur = dims.hauteur;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DEBUG TEMP : mesure repère vert (canvas) vs repère bleu (DOM). */
|
||||||
|
private mesurerDebug(): void {
|
||||||
|
if (this.pageIndex !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const cr = canvas.getBoundingClientRect();
|
||||||
|
const blue = this.conteneurRef.nativeElement.querySelector(
|
||||||
|
'.couche > div',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const br = blue?.getBoundingClientRect();
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let gx = -1,
|
||||||
|
gy = -1;
|
||||||
|
if (ctx) {
|
||||||
|
const w = canvas.width,
|
||||||
|
h = canvas.height;
|
||||||
|
const data = ctx.getImageData(0, 0, w, h).data;
|
||||||
|
for (let y = 0; y < h && gy < 0; y += 1) {
|
||||||
|
for (let x = 0; x < w; x += 1) {
|
||||||
|
const i = (y * w + x) * 4;
|
||||||
|
if (data[i] < 80 && data[i + 1] > 150 && data[i + 2] < 80) {
|
||||||
|
gx = x;
|
||||||
|
gy = y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ech = cr.width / canvas.width; // bitmap -> CSS px
|
||||||
|
const gL = cr.left + gx * ech;
|
||||||
|
const gT = cr.top + gy * ech;
|
||||||
|
this.dbg =
|
||||||
|
`v16 dpr=${window.devicePixelRatio} zoom=${this.zoom} bmp=${canvas.width}x${canvas.height}\n` +
|
||||||
|
`canvas L=${cr.left.toFixed(1)} T=${cr.top.toFixed(1)} W=${cr.width.toFixed(1)}\n` +
|
||||||
|
`VERT bmp x=${gx} y=${gy} -> ecran L=${gL.toFixed(1)} T=${gT.toFixed(1)}\n` +
|
||||||
|
`BLEU ecran L=${br?.left.toFixed(1)} T=${br?.top.toFixed(1)}\n` +
|
||||||
|
`=> ECART vert-bleu X=${br ? (gL - br.left).toFixed(1) : '?'} Y=${br ? (gT - br.top).toFixed(1) : '?'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rendu pixel (asynchrone) de la page dans le canvas déjà dimensionné. */
|
||||||
|
private async repeindre(): Promise<void> {
|
||||||
|
if (!this.pageProxy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Annule un éventuel rendu en cours (évite les rendus concurrents au zoom
|
||||||
|
// rapide, qui peignaient un contenu décalé par rapport au canvas final).
|
||||||
|
if (this.renderTask) {
|
||||||
|
try {
|
||||||
|
this.renderTask.cancel();
|
||||||
|
} catch {
|
||||||
|
/* déjà terminé */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
this.renderTask = this.pdfService.peindre(this.pageProxy, canvas, this.zoom);
|
||||||
|
try {
|
||||||
|
await this.renderTask.promise;
|
||||||
|
} catch {
|
||||||
|
return; // rendu annulé : on laisse le rendu suivant peindre
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEBUG TEMP : repère VERT plein (contenu canvas) à la fraction 30%-70%.
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.strokeStyle = 'rgb(0,200,0)';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeRect(
|
||||||
|
0.3 * canvas.width,
|
||||||
|
0.3 * canvas.height,
|
||||||
|
0.4 * canvas.width,
|
||||||
|
0.4 * canvas.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.mesurerDebug();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Création d'une annotation par cliquer-glisser ---------------------
|
||||||
|
|
||||||
|
debutInteraction(ev: MouseEvent): void {
|
||||||
|
// Clic sur le fond : désélection + sortie d'édition + éventuelle création.
|
||||||
|
this.selectionId = null;
|
||||||
|
this.editionId = null;
|
||||||
|
|
||||||
|
if (this.outil === 'selection') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On convertit la position souris en fraction [0..1] à partir de la taille
|
||||||
|
// RÉELLE du conteneur (getBoundingClientRect), et non de this.largeur/
|
||||||
|
// this.hauteur : ces variables sont mises à jour par rendre() qui est async,
|
||||||
|
// elles peuvent donc être périmées pendant un zoom et provoquer un décalage
|
||||||
|
// proportionnel au zoom. rect.width/rect.height reflètent toujours l'état
|
||||||
|
// affiché à l'écran.
|
||||||
|
const rect = this.conteneurRef.nativeElement.getBoundingClientRect();
|
||||||
|
const x = (ev.clientX - rect.left) / rect.width;
|
||||||
|
const y = (ev.clientY - rect.top) / rect.height;
|
||||||
|
|
||||||
|
// Outils « ponctuels » : pose immédiate puis retour en sélection.
|
||||||
|
if (this.outil === 'texte') {
|
||||||
|
const t = this.creerTexte(x, y);
|
||||||
|
this.poser(t);
|
||||||
|
this.editionId = t.id; // édition immédiate du nouveau texte
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.outil === 'image' || this.outil === 'signature') {
|
||||||
|
if (this.chargeUtile) {
|
||||||
|
this.creerImage(x, y, this.outil, this.chargeUtile).then((a) =>
|
||||||
|
this.poser(a),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.outil === 'lien') {
|
||||||
|
const url = prompt('URL du lien (https://…)');
|
||||||
|
if (url) {
|
||||||
|
this.poser(this.creerLien(x, y, url));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formes : création par glissement.
|
||||||
|
this.mode = 'creation';
|
||||||
|
this.brouillon = this.creerForme(this.outil, x, y, this.couleur);
|
||||||
|
this.depart = { x, y, ax: x, ay: y, aw: 0, ah: 0 };
|
||||||
|
this.poser(this.brouillon, false);
|
||||||
|
this.attacherSuivi();
|
||||||
|
}
|
||||||
|
|
||||||
|
debutDeplacement(ev: MouseEvent, a: Annotation): void {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.selectionId = a.id;
|
||||||
|
if (this.outil !== 'selection') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mode = 'deplacement';
|
||||||
|
this.brouillon = a;
|
||||||
|
const rect = this.conteneurRef.nativeElement.getBoundingClientRect();
|
||||||
|
this.depart = {
|
||||||
|
x: (ev.clientX - rect.left) / rect.width,
|
||||||
|
y: (ev.clientY - rect.top) / rect.height,
|
||||||
|
ax: a.x,
|
||||||
|
ay: a.y,
|
||||||
|
aw: a.w,
|
||||||
|
ah: a.h,
|
||||||
|
};
|
||||||
|
this.attacherSuivi();
|
||||||
|
}
|
||||||
|
|
||||||
|
debutRedim(ev: MouseEvent, a: Annotation): void {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.mode = 'redim';
|
||||||
|
this.brouillon = a;
|
||||||
|
const rect = this.conteneurRef.nativeElement.getBoundingClientRect();
|
||||||
|
this.depart = {
|
||||||
|
x: (ev.clientX - rect.left) / rect.width,
|
||||||
|
y: (ev.clientY - rect.top) / rect.height,
|
||||||
|
ax: a.x,
|
||||||
|
ay: a.y,
|
||||||
|
aw: a.w,
|
||||||
|
ah: a.h,
|
||||||
|
};
|
||||||
|
this.attacherSuivi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private attacherSuivi(): void {
|
||||||
|
const onMove = (ev: MouseEvent) => this.surMouvement(ev);
|
||||||
|
const onUp = () => {
|
||||||
|
this.mode = 'aucun';
|
||||||
|
this.brouillon = null;
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
this.emettre();
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private surMouvement(ev: MouseEvent): void {
|
||||||
|
if (this.mode === 'aucun' || !this.brouillon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = this.conteneurRef.nativeElement.getBoundingClientRect();
|
||||||
|
const x = (ev.clientX - rect.left) / rect.width;
|
||||||
|
const y = (ev.clientY - rect.top) / rect.height;
|
||||||
|
const dx = x - this.depart.x;
|
||||||
|
const dy = y - this.depart.y;
|
||||||
|
|
||||||
|
if (this.mode === 'creation') {
|
||||||
|
this.brouillon.x = Math.min(this.depart.ax, x);
|
||||||
|
this.brouillon.y = Math.min(this.depart.ay, y);
|
||||||
|
this.brouillon.w = Math.abs(x - this.depart.ax);
|
||||||
|
this.brouillon.h = Math.abs(y - this.depart.ay);
|
||||||
|
} else if (this.mode === 'deplacement') {
|
||||||
|
this.brouillon.x = this.depart.ax + dx;
|
||||||
|
this.brouillon.y = this.depart.ay + dy;
|
||||||
|
} else if (this.mode === 'redim') {
|
||||||
|
this.brouillon.w = Math.max(0.01, this.depart.aw + dx);
|
||||||
|
this.brouillon.h = Math.max(0.01, this.depart.ah + dy);
|
||||||
|
}
|
||||||
|
// Mutation in place : on déclenche un nouveau tableau pour la détection.
|
||||||
|
this.annotations = [...this.annotations];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mise à jour / suppression -----------------------------------------
|
||||||
|
|
||||||
|
/** Double-clic sur un texte : passe en mode édition et place le curseur. */
|
||||||
|
entrerEdition(a: Annotation, ev: MouseEvent): void {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.selectionId = a.id;
|
||||||
|
this.editionId = a.id;
|
||||||
|
const el = ev.target as HTMLElement;
|
||||||
|
// Le focus doit attendre que l'attribut contenteditable soit appliqué.
|
||||||
|
setTimeout(() => {
|
||||||
|
el.focus();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
range.collapse(false); // curseur en fin de texte
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fin d'édition (blur) : on enregistre le texte saisi. */
|
||||||
|
finEdition(a: Annotation, ev: Event): void {
|
||||||
|
const el = ev.target as HTMLElement;
|
||||||
|
(a as AnnotationTexte).texte = el.innerText;
|
||||||
|
this.editionId = null;
|
||||||
|
this.emettre();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ajuste la taille de police d'un texte par incrément (boutons A− / A+). */
|
||||||
|
changerTaille(a: Annotation, delta: number): void {
|
||||||
|
this.appliquerTaille(a, (a as AnnotationTexte).taillePolice + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Définit la taille de police d'un texte depuis le champ numérique. */
|
||||||
|
majTaille(a: Annotation, valeur: string): void {
|
||||||
|
const n = parseInt(valeur, 10);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
this.appliquerTaille(a, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private appliquerTaille(a: Annotation, taille: number): void {
|
||||||
|
(a as AnnotationTexte).taillePolice = Math.max(6, Math.min(300, taille));
|
||||||
|
this.annotations = [...this.annotations];
|
||||||
|
this.emettre();
|
||||||
|
}
|
||||||
|
|
||||||
|
supprimer(a: Annotation): void {
|
||||||
|
this.annotations = this.annotations.filter((x) => x.id !== a.id);
|
||||||
|
this.selectionId = null;
|
||||||
|
this.emettre();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fabriques d'annotations -------------------------------------------
|
||||||
|
|
||||||
|
private poser(a: Annotation, consomme = true): void {
|
||||||
|
this.annotations = [...this.annotations, a];
|
||||||
|
this.selectionId = a.id;
|
||||||
|
if (consomme) {
|
||||||
|
this.emettre();
|
||||||
|
this.outilConsomme.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emettre(): void {
|
||||||
|
this.annotationsChange.emit([...this.annotations]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private creerTexte(x: number, y: number): AnnotationTexte {
|
||||||
|
return {
|
||||||
|
id: nouvelId(),
|
||||||
|
type: 'texte',
|
||||||
|
page: this.pageIndex,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w: 0.25,
|
||||||
|
h: 0.05,
|
||||||
|
texte: 'Votre texte',
|
||||||
|
taillePolice: 16,
|
||||||
|
couleur: this.couleur,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async creerImage(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
type: 'image' | 'signature',
|
||||||
|
dataUrl: string,
|
||||||
|
): Promise<AnnotationImage> {
|
||||||
|
const w = type === 'signature' ? 0.3 : 0.3;
|
||||||
|
let h = type === 'signature' ? 0.1 : 0.2;
|
||||||
|
try {
|
||||||
|
const dim = await this.dimensionsImage(dataUrl);
|
||||||
|
// Préserve le ratio visuel : (w*largeur)/(h*hauteur) = dim.w/dim.h
|
||||||
|
h = (w * this.largeur * dim.h) / (dim.w * this.hauteur);
|
||||||
|
} catch {
|
||||||
|
// En cas d'échec de lecture, on garde les dimensions par défaut.
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: nouvelId(),
|
||||||
|
type,
|
||||||
|
page: this.pageIndex,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
dataUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lit les dimensions naturelles (px) d'une image encodée en data URL. */
|
||||||
|
private dimensionsImage(dataUrl: string): Promise<{ w: number; h: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private creerLien(x: number, y: number, url: string): AnnotationLien {
|
||||||
|
return {
|
||||||
|
id: nouvelId(),
|
||||||
|
type: 'lien',
|
||||||
|
page: this.pageIndex,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w: 0.2,
|
||||||
|
h: 0.04,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private creerForme(
|
||||||
|
type: Outil,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
couleur: string,
|
||||||
|
): AnnotationForme {
|
||||||
|
return {
|
||||||
|
id: nouvelId(),
|
||||||
|
type: type as AnnotationForme['type'],
|
||||||
|
page: this.pageIndex,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w: 0,
|
||||||
|
h: 0,
|
||||||
|
couleur,
|
||||||
|
epaisseur: 2,
|
||||||
|
remplissage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
353
frontend/src/app/features/editor/pdf.service.ts
Normal file
353
frontend/src/app/features/editor/pdf.service.ts
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
PDFDocument,
|
||||||
|
PDFName,
|
||||||
|
PDFArray,
|
||||||
|
rgb,
|
||||||
|
StandardFonts,
|
||||||
|
} from 'pdf-lib';
|
||||||
|
import * as pdfjs from 'pdfjs-dist';
|
||||||
|
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
|
||||||
|
import {
|
||||||
|
Annotation,
|
||||||
|
AnnotationForme,
|
||||||
|
AnnotationImage,
|
||||||
|
AnnotationLien,
|
||||||
|
AnnotationTexte,
|
||||||
|
} from './annotation.model';
|
||||||
|
|
||||||
|
// Le worker pdf.js est copié dans /assets au build (cf. angular.json).
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = 'assets/pdf.worker.min.mjs';
|
||||||
|
|
||||||
|
/** Convertit un #rrggbb en composante rgb() de pdf-lib (0..1). */
|
||||||
|
function couleurVersRgb(hex: string) {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
const r = parseInt(h.substring(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(h.substring(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(h.substring(4, 6), 16) / 255;
|
||||||
|
return rgb(r || 0, g || 0, b || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de manipulation des PDF :
|
||||||
|
* - rendu page par page avec pdf.js (pour l'affichage interactif) ;
|
||||||
|
* - aplatissement des annotations dans le PDF avec pdf-lib (pour l'export).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PdfService {
|
||||||
|
/** Charge un document pdf.js depuis un ArrayBuffer. */
|
||||||
|
async charger(data: ArrayBuffer): Promise<PDFDocumentProxy> {
|
||||||
|
// On clone le buffer car pdf.js le « détache » (transférable).
|
||||||
|
const copie = data.slice(0);
|
||||||
|
return pdfjs.getDocument({ data: copie }).promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge une page pdf.js et retourne ses dimensions de base (échelle 1). */
|
||||||
|
async chargerPage(
|
||||||
|
pdf: PDFDocumentProxy,
|
||||||
|
numeroPage: number,
|
||||||
|
): Promise<{ page: PDFPageProxy; baseLargeur: number; baseHauteur: number }> {
|
||||||
|
const page = await pdf.getPage(numeroPage);
|
||||||
|
const vp = page.getViewport({ scale: 1 });
|
||||||
|
return { page, baseLargeur: vp.width, baseHauteur: vp.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dimensionne le canvas de façon SYNCHRONE à partir des dimensions de base et
|
||||||
|
* du zoom (aucun await), et retourne la taille d'affichage (CSS px) à utiliser
|
||||||
|
* pour la couche d'annotations. Bitmap en pixels physiques entiers, affichage
|
||||||
|
* = bitmap/ratio (1:1, sans étirement) -> alignement canvas <-> overlay exact.
|
||||||
|
*
|
||||||
|
* Étant synchrone, la page prend sa bonne taille immédiatement au changement de
|
||||||
|
* zoom : l'utilisateur ne peut pas dessiner sur une page encore à l'ancienne
|
||||||
|
* taille (ce qui décalait les annotations sur les PDF lourds à rendu lent).
|
||||||
|
*/
|
||||||
|
dimensionner(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
baseLargeur: number,
|
||||||
|
baseHauteur: number,
|
||||||
|
echelle: number,
|
||||||
|
): { largeur: number; hauteur: number } {
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
const bitmapW = Math.round(baseLargeur * echelle * ratio);
|
||||||
|
const bitmapH = Math.round(baseHauteur * echelle * ratio);
|
||||||
|
canvas.width = bitmapW;
|
||||||
|
canvas.height = bitmapH;
|
||||||
|
const cssW = bitmapW / ratio;
|
||||||
|
const cssH = bitmapH / ratio;
|
||||||
|
canvas.style.width = `${cssW}px`;
|
||||||
|
canvas.style.height = `${cssH}px`;
|
||||||
|
return { largeur: cssW, hauteur: cssH };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre le rendu (rasterisation) de la page dans le canvas déjà dimensionné
|
||||||
|
* et retourne la RenderTask pdf.js (qui expose `.promise` et `.cancel()`).
|
||||||
|
* L'appelant doit annuler la tâche précédente avant d'en démarrer une nouvelle,
|
||||||
|
* sinon des rendus concurrents (zoom rapide) se résolvent dans le désordre et
|
||||||
|
* peignent un contenu incohérent avec la taille finale du canvas.
|
||||||
|
*/
|
||||||
|
peindre(
|
||||||
|
page: PDFPageProxy,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
echelle: number,
|
||||||
|
): ReturnType<PDFPageProxy['render']> {
|
||||||
|
const contexte = canvas.getContext('2d');
|
||||||
|
if (!contexte) {
|
||||||
|
throw new Error('Contexte 2D indisponible');
|
||||||
|
}
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
const viewport = page.getViewport({ scale: echelle });
|
||||||
|
// Le bitmap est en pixels ENTIERS (cf. dimensionner) alors que viewport est
|
||||||
|
// fractionnaire : on étire très légèrement le rendu pour qu'il remplisse
|
||||||
|
// EXACTEMENT le canvas -> alignement pixel-parfait contenu/couche.
|
||||||
|
const sx = canvas.width / (viewport.width * ratio);
|
||||||
|
const sy = canvas.height / (viewport.height * ratio);
|
||||||
|
contexte.setTransform(ratio * sx, 0, 0, ratio * sy, 0, 0);
|
||||||
|
return page.render({ canvasContext: contexte, viewport });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplatit toutes les annotations dans le PDF source et retourne le binaire
|
||||||
|
* résultant (Uint8Array), prêt au téléchargement ou à l'upload.
|
||||||
|
*
|
||||||
|
* @param sourcePdf binaire du PDF original
|
||||||
|
* @param annotations annotations à appliquer (coordonnées en fraction de page)
|
||||||
|
*/
|
||||||
|
async exporter(
|
||||||
|
sourcePdf: ArrayBuffer,
|
||||||
|
annotations: Annotation[],
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const doc = await PDFDocument.load(sourcePdf);
|
||||||
|
const police = await doc.embedFont(StandardFonts.Helvetica);
|
||||||
|
const pages = doc.getPages();
|
||||||
|
|
||||||
|
// Pré-chargement des images embarquées (mutualisation par dataUrl).
|
||||||
|
const cacheImages = new Map<string, Awaited<ReturnType<PDFDocument['embedPng']>>>();
|
||||||
|
const embarquerImage = async (dataUrl: string) => {
|
||||||
|
if (cacheImages.has(dataUrl)) {
|
||||||
|
return cacheImages.get(dataUrl)!;
|
||||||
|
}
|
||||||
|
const octets = this.dataUrlVersOctets(dataUrl);
|
||||||
|
const img = dataUrl.startsWith('data:image/png')
|
||||||
|
? await doc.embedPng(octets)
|
||||||
|
: await doc.embedJpg(octets);
|
||||||
|
cacheImages.set(dataUrl, img);
|
||||||
|
return img;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const a of annotations) {
|
||||||
|
const page = pages[a.page];
|
||||||
|
if (!page) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { width: pw, height: ph } = page.getSize();
|
||||||
|
|
||||||
|
// Conversion fraction -> points PDF. En PDF l'origine est en bas-gauche,
|
||||||
|
// alors que nos annotations sont en haut-gauche : on inverse l'axe Y.
|
||||||
|
const x = a.x * pw;
|
||||||
|
const largeur = a.w * pw;
|
||||||
|
const hauteur = a.h * ph;
|
||||||
|
const yHaut = ph - a.y * ph; // bord supérieur de l'annotation
|
||||||
|
const yBas = yHaut - hauteur;
|
||||||
|
|
||||||
|
switch (a.type) {
|
||||||
|
case 'texte':
|
||||||
|
this.dessinerTexte(page, a, police, x, yHaut);
|
||||||
|
break;
|
||||||
|
case 'image':
|
||||||
|
case 'signature': {
|
||||||
|
const img = await embarquerImage((a as AnnotationImage).dataUrl);
|
||||||
|
page.drawImage(img, { x, y: yBas, width: largeur, height: hauteur });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rectangle':
|
||||||
|
case 'cercle':
|
||||||
|
case 'ligne':
|
||||||
|
case 'fleche':
|
||||||
|
case 'croix':
|
||||||
|
this.dessinerForme(page, a, x, yBas, yHaut, largeur, hauteur);
|
||||||
|
break;
|
||||||
|
case 'lien':
|
||||||
|
this.dessinerLien(doc, page, a, x, yBas, largeur, hauteur);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rendu des différents types pour l'export --------------------------
|
||||||
|
|
||||||
|
private dessinerTexte(
|
||||||
|
page: ReturnType<PDFDocument['getPages']>[number],
|
||||||
|
a: AnnotationTexte,
|
||||||
|
police: Awaited<ReturnType<PDFDocument['embedFont']>>,
|
||||||
|
x: number,
|
||||||
|
yHaut: number,
|
||||||
|
): void {
|
||||||
|
// taillePolice est exprimée en px à 100% : on l'exprime en points relatifs
|
||||||
|
// à la hauteur de page (le rendu pdf.js à scale=1 ~ 1px = 1pt).
|
||||||
|
const taille = a.taillePolice;
|
||||||
|
const lignes = a.texte.split('\n');
|
||||||
|
let y = yHaut - taille; // ligne de base de la première ligne
|
||||||
|
for (const ligne of lignes) {
|
||||||
|
page.drawText(ligne, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
size: taille,
|
||||||
|
font: police,
|
||||||
|
color: couleurVersRgb(a.couleur),
|
||||||
|
});
|
||||||
|
y -= taille * 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dessinerForme(
|
||||||
|
page: ReturnType<PDFDocument['getPages']>[number],
|
||||||
|
a: AnnotationForme,
|
||||||
|
x: number,
|
||||||
|
yBas: number,
|
||||||
|
yHaut: number,
|
||||||
|
largeur: number,
|
||||||
|
hauteur: number,
|
||||||
|
): void {
|
||||||
|
const trait = couleurVersRgb(a.couleur);
|
||||||
|
const remplissage =
|
||||||
|
a.remplissage && a.remplissage !== 'none'
|
||||||
|
? couleurVersRgb(a.remplissage)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
switch (a.type) {
|
||||||
|
case 'rectangle':
|
||||||
|
// Trait rentré dans la boîte (border-box) pour rester WYSIWYG avec
|
||||||
|
// l'écran. À l'échelle 1, a.epaisseur est en points (pas de zoom).
|
||||||
|
page.drawRectangle({
|
||||||
|
x: x + a.epaisseur / 2,
|
||||||
|
y: yBas + a.epaisseur / 2,
|
||||||
|
width: Math.max(0, largeur - a.epaisseur),
|
||||||
|
height: Math.max(0, hauteur - a.epaisseur),
|
||||||
|
borderColor: trait,
|
||||||
|
borderWidth: a.epaisseur,
|
||||||
|
color: remplissage,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'cercle':
|
||||||
|
page.drawEllipse({
|
||||||
|
x: x + largeur / 2,
|
||||||
|
y: yBas + hauteur / 2,
|
||||||
|
xScale: Math.max(0, largeur / 2 - a.epaisseur / 2),
|
||||||
|
yScale: Math.max(0, hauteur / 2 - a.epaisseur / 2),
|
||||||
|
borderColor: trait,
|
||||||
|
borderWidth: a.epaisseur,
|
||||||
|
color: remplissage,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'ligne':
|
||||||
|
page.drawLine({
|
||||||
|
start: { x, y: yHaut },
|
||||||
|
end: { x: x + largeur, y: yBas },
|
||||||
|
thickness: a.epaisseur,
|
||||||
|
color: trait,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'croix':
|
||||||
|
// Deux diagonales formant un X dans la boîte englobante.
|
||||||
|
page.drawLine({
|
||||||
|
start: { x, y: yHaut },
|
||||||
|
end: { x: x + largeur, y: yBas },
|
||||||
|
thickness: a.epaisseur,
|
||||||
|
color: trait,
|
||||||
|
});
|
||||||
|
page.drawLine({
|
||||||
|
start: { x, y: yBas },
|
||||||
|
end: { x: x + largeur, y: yHaut },
|
||||||
|
thickness: a.epaisseur,
|
||||||
|
color: trait,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'fleche': {
|
||||||
|
const finX = x + largeur;
|
||||||
|
const finY = yBas;
|
||||||
|
page.drawLine({
|
||||||
|
start: { x, y: yHaut },
|
||||||
|
end: { x: finX, y: finY },
|
||||||
|
thickness: a.epaisseur,
|
||||||
|
color: trait,
|
||||||
|
});
|
||||||
|
// Pointe de flèche : deux petits segments.
|
||||||
|
const angle = Math.atan2(finY - yHaut, finX - x);
|
||||||
|
const taillePointe = 10 + a.epaisseur * 2;
|
||||||
|
for (const delta of [Math.PI - 0.4, Math.PI + 0.4]) {
|
||||||
|
page.drawLine({
|
||||||
|
start: { x: finX, y: finY },
|
||||||
|
end: {
|
||||||
|
x: finX + taillePointe * Math.cos(angle + delta),
|
||||||
|
y: finY + taillePointe * Math.sin(angle + delta),
|
||||||
|
},
|
||||||
|
thickness: a.epaisseur,
|
||||||
|
color: trait,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dessinerLien(
|
||||||
|
doc: PDFDocument,
|
||||||
|
page: ReturnType<PDFDocument['getPages']>[number],
|
||||||
|
a: AnnotationLien,
|
||||||
|
x: number,
|
||||||
|
yBas: number,
|
||||||
|
largeur: number,
|
||||||
|
hauteur: number,
|
||||||
|
): void {
|
||||||
|
// Rectangle visible (encadré bleu léger) pour matérialiser la zone cliquable.
|
||||||
|
page.drawRectangle({
|
||||||
|
x,
|
||||||
|
y: yBas,
|
||||||
|
width: largeur,
|
||||||
|
height: hauteur,
|
||||||
|
borderColor: couleurVersRgb('#3b6cff'),
|
||||||
|
borderWidth: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Annotation de lien interactive (API bas niveau de pdf-lib).
|
||||||
|
// ctx.obj convertit automatiquement une chaîne JS en PDFString, ce qui
|
||||||
|
// produit une URI correctement encodée.
|
||||||
|
const ctx = doc.context;
|
||||||
|
const annot = ctx.obj({
|
||||||
|
Type: 'Annot',
|
||||||
|
Subtype: 'Link',
|
||||||
|
Rect: [x, yBas, x + largeur, yBas + hauteur],
|
||||||
|
Border: [0, 0, 0],
|
||||||
|
A: ctx.obj({
|
||||||
|
Type: 'Action',
|
||||||
|
S: 'URI',
|
||||||
|
URI: a.url,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const ref = ctx.register(annot);
|
||||||
|
|
||||||
|
// Récupère le tableau Annots existant de la page ou en crée un.
|
||||||
|
const cleAnnots = PDFName.of('Annots');
|
||||||
|
let annots = page.node.lookupMaybe(cleAnnots, PDFArray);
|
||||||
|
if (!annots) {
|
||||||
|
annots = ctx.obj([]) as PDFArray;
|
||||||
|
page.node.set(cleAnnots, annots);
|
||||||
|
}
|
||||||
|
annots.push(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Décode une data URL base64 en Uint8Array. */
|
||||||
|
private dataUrlVersOctets(dataUrl: string): Uint8Array {
|
||||||
|
const base64 = dataUrl.split(',')[1] ?? '';
|
||||||
|
const binaire = atob(base64);
|
||||||
|
const octets = new Uint8Array(binaire.length);
|
||||||
|
for (let i = 0; i < binaire.length; i++) {
|
||||||
|
octets[i] = binaire.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return octets;
|
||||||
|
}
|
||||||
|
}
|
||||||
297
frontend/src/app/features/editor/signature-dialog.component.ts
Normal file
297
frontend/src/app/features/editor/signature-dialog.component.ts
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import SignaturePad from 'signature_pad';
|
||||||
|
|
||||||
|
/** Police manuscrite proposée pour la signature dactylographiée. */
|
||||||
|
interface PoliceSignature {
|
||||||
|
nom: string;
|
||||||
|
/** Famille seule (avec guillemets), ex. "'Dancing Script'" — pour la Font Loading API. */
|
||||||
|
famille: string;
|
||||||
|
/** Pile complète avec repli, ex. "'Dancing Script', cursive" — pour le rendu CSS/canvas. */
|
||||||
|
css: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boîte de dialogue de signature, avec deux modes :
|
||||||
|
* - « Dessiner » : tracé à main levée (librairie signature_pad) ;
|
||||||
|
* - « Écrire » : saisie d'un texte rendu dans une police manuscrite,
|
||||||
|
* converti en image PNG transparente.
|
||||||
|
*
|
||||||
|
* Dans les deux cas, la signature est émise en data URL PNG via `valide`
|
||||||
|
* (l'éditeur la pose ensuite comme une image), ou `annule` à la fermeture.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-signature-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="overlay" (click)="annuler()">
|
||||||
|
<div class="modale" (click)="$event.stopPropagation()">
|
||||||
|
<div class="onglets">
|
||||||
|
<button
|
||||||
|
class="onglet"
|
||||||
|
[class.actif]="mode === 'dessin'"
|
||||||
|
(click)="mode = 'dessin'"
|
||||||
|
>
|
||||||
|
✍ Dessiner
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="onglet"
|
||||||
|
[class.actif]="mode === 'texte'"
|
||||||
|
(click)="mode = 'texte'"
|
||||||
|
>
|
||||||
|
⌨ Écrire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode dessin (le canvas reste dans le DOM pour conserver le tracé) -->
|
||||||
|
<div class="panneau" [style.display]="mode === 'dessin' ? 'block' : 'none'">
|
||||||
|
<div class="zone-signature">
|
||||||
|
<canvas #canvas width="500" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" (click)="effacer()">Effacer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode texte stylisé -->
|
||||||
|
<div class="panneau" [style.display]="mode === 'texte' ? 'block' : 'none'">
|
||||||
|
<input
|
||||||
|
class="champ-texte"
|
||||||
|
type="text"
|
||||||
|
placeholder="Tapez votre nom…"
|
||||||
|
[(ngModel)]="texteSignature"
|
||||||
|
name="signature"
|
||||||
|
/>
|
||||||
|
<div class="polices">
|
||||||
|
@for (p of polices; track p.nom) {
|
||||||
|
<button
|
||||||
|
class="choix-police"
|
||||||
|
[class.actif]="police === p"
|
||||||
|
[style.fontFamily]="p.css"
|
||||||
|
(click)="police = p"
|
||||||
|
>
|
||||||
|
{{ texteSignature || 'Signature' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="apercu">
|
||||||
|
<span [style.fontFamily]="police.css">{{ texteSignature || 'Aperçu de la signature' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions pied">
|
||||||
|
<span class="espace"></span>
|
||||||
|
<button class="btn" (click)="annuler()">Annuler</button>
|
||||||
|
<button class="btn btn-primaire" (click)="valider()">
|
||||||
|
Insérer la signature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modale {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 22px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 540px;
|
||||||
|
max-width: 92vw;
|
||||||
|
}
|
||||||
|
.onglets {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.onglet {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--couleur-bordure, #d0d0d0);
|
||||||
|
background: #f7f7f7;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.onglet.actif {
|
||||||
|
background: var(--couleur-primaire);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--couleur-primaire);
|
||||||
|
}
|
||||||
|
.zone-signature {
|
||||||
|
border: 2px dashed var(--couleur-bordure);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.champ-texte {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid var(--couleur-bordure, #d0d0d0);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.polices {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.choix-police {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--couleur-bordure, #d0d0d0);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #0a2540;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.choix-police.actif {
|
||||||
|
border-color: var(--couleur-primaire);
|
||||||
|
box-shadow: 0 0 0 2px var(--couleur-primaire) inset;
|
||||||
|
}
|
||||||
|
.apercu {
|
||||||
|
margin-top: 14px;
|
||||||
|
min-height: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--couleur-bordure, #eee);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
font-size: 44px;
|
||||||
|
color: #0a2540;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.actions.pied {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.espace {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class SignatureDialogComponent implements OnDestroy {
|
||||||
|
@Output() valide = new EventEmitter<string>();
|
||||||
|
@Output() annule = new EventEmitter<void>();
|
||||||
|
|
||||||
|
mode: 'dessin' | 'texte' = 'dessin';
|
||||||
|
|
||||||
|
// --- Mode texte ---
|
||||||
|
texteSignature = '';
|
||||||
|
readonly polices: PoliceSignature[] = [
|
||||||
|
{ nom: 'Dancing Script', famille: "'Dancing Script'", css: "'Dancing Script', cursive" },
|
||||||
|
{ nom: 'Great Vibes', famille: "'Great Vibes'", css: "'Great Vibes', cursive" },
|
||||||
|
{ nom: 'Sacramento', famille: "'Sacramento'", css: "'Sacramento', cursive" },
|
||||||
|
{ nom: 'Satisfy', famille: "'Satisfy'", css: "'Satisfy', cursive" },
|
||||||
|
{ nom: 'Caveat', famille: "'Caveat'", css: "'Caveat', cursive" },
|
||||||
|
{ nom: 'Allura', famille: "'Allura'", css: "'Allura', cursive" },
|
||||||
|
];
|
||||||
|
police: PoliceSignature = this.polices[0];
|
||||||
|
|
||||||
|
// --- Mode dessin ---
|
||||||
|
private pad?: SignaturePad;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewChild via setter : le canvas n'apparaît qu'en mode dessin (display),
|
||||||
|
* mais reste dans le DOM, donc le setter est appelé une fois à l'init.
|
||||||
|
*/
|
||||||
|
@ViewChild('canvas')
|
||||||
|
set canvasRef(ref: ElementRef<HTMLCanvasElement> | undefined) {
|
||||||
|
if (ref && !this.pad) {
|
||||||
|
this.pad = new SignaturePad(ref.nativeElement, {
|
||||||
|
penColor: '#0a2540',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0)', // fond transparent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.pad?.off();
|
||||||
|
}
|
||||||
|
|
||||||
|
effacer(): void {
|
||||||
|
this.pad?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
annuler(): void {
|
||||||
|
this.annule.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async valider(): Promise<void> {
|
||||||
|
if (this.mode === 'dessin') {
|
||||||
|
if (!this.pad || this.pad.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.valide.emit(this.pad.toDataURL('image/png'));
|
||||||
|
} else {
|
||||||
|
const dataUrl = await this.genererTexteSignature();
|
||||||
|
if (dataUrl) {
|
||||||
|
this.valide.emit(dataUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rend le texte saisi dans la police choisie sur un canvas PNG transparent. */
|
||||||
|
private async genererTexteSignature(): Promise<string | null> {
|
||||||
|
const texte = this.texteSignature.trim();
|
||||||
|
if (!texte) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const taille = 96;
|
||||||
|
const marge = 40;
|
||||||
|
// Le canvas n'utilise une police que si elle est déjà chargée par le navigateur :
|
||||||
|
// on l'attend explicitement via la Font Loading API avant de mesurer/dessiner.
|
||||||
|
await document.fonts.load(`${taille}px ${this.police.famille}`);
|
||||||
|
const mesure = document.createElement('canvas').getContext('2d')!;
|
||||||
|
const fontCss = `${taille}px ${this.police.css}`;
|
||||||
|
mesure.font = fontCss;
|
||||||
|
const largeur = Math.ceil(mesure.measureText(texte).width + marge * 2);
|
||||||
|
const hauteur = Math.ceil(taille * 1.6);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = largeur;
|
||||||
|
canvas.height = hauteur;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.clearRect(0, 0, largeur, hauteur);
|
||||||
|
ctx.font = fontCss;
|
||||||
|
ctx.fillStyle = '#0a2540';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(texte, largeur / 2, hauteur / 2);
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/src/assets/fonts/allura.woff2
Normal file
BIN
frontend/src/assets/fonts/allura.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/caveat.woff2
Normal file
BIN
frontend/src/assets/fonts/caveat.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/dancing-script.woff2
Normal file
BIN
frontend/src/assets/fonts/dancing-script.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/great-vibes.woff2
Normal file
BIN
frontend/src/assets/fonts/great-vibes.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/sacramento.woff2
Normal file
BIN
frontend/src/assets/fonts/sacramento.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/satisfy.woff2
Normal file
BIN
frontend/src/assets/fonts/satisfy.woff2
Normal file
Binary file not shown.
14
frontend/src/index.html
Normal file
14
frontend/src/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>PdfEditor</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="Éditeur de PDF web auto-hébergé" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
frontend/src/main.ts
Normal file
7
frontend/src/main.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig).catch((err) =>
|
||||||
|
console.error(err),
|
||||||
|
);
|
||||||
153
frontend/src/styles.scss
Normal file
153
frontend/src/styles.scss
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/* Styles globaux de PdfEditor */
|
||||||
|
|
||||||
|
/* Polices manuscrites (licence OFL) pour la signature dactylographiée.
|
||||||
|
esbuild empaquette automatiquement les woff2 référencés ici. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Dancing Script';
|
||||||
|
src: url('./assets/fonts/dancing-script.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Great Vibes';
|
||||||
|
src: url('./assets/fonts/great-vibes.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Sacramento';
|
||||||
|
src: url('./assets/fonts/sacramento.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Satisfy';
|
||||||
|
src: url('./assets/fonts/satisfy.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Caveat';
|
||||||
|
src: url('./assets/fonts/caveat.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Allura';
|
||||||
|
src: url('./assets/fonts/allura.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--couleur-primaire: #3b6cff;
|
||||||
|
--couleur-primaire-sombre: #2d54cc;
|
||||||
|
--couleur-danger: #e0413b;
|
||||||
|
--couleur-fond: #f4f6fb;
|
||||||
|
--couleur-surface: #ffffff;
|
||||||
|
--couleur-bordure: #d9dee8;
|
||||||
|
--couleur-texte: #1d2433;
|
||||||
|
--couleur-texte-doux: #6b7280;
|
||||||
|
--rayon: 8px;
|
||||||
|
--ombre: 0 2px 8px rgba(20, 30, 60, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--couleur-fond);
|
||||||
|
color: var(--couleur-texte);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--couleur-primaire);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Boutons utilitaires réutilisés dans toute l'app */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: 1px solid var(--couleur-bordure);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
background: var(--couleur-surface);
|
||||||
|
color: var(--couleur-texte);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: #eef2fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primaire {
|
||||||
|
background: var(--couleur-primaire);
|
||||||
|
border-color: var(--couleur-primaire);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primaire:hover:not(:disabled) {
|
||||||
|
background: var(--couleur-primaire-sombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--couleur-danger);
|
||||||
|
border-color: #f0c1bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #fdeceb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--couleur-texte-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ input,
|
||||||
|
.champ select {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--couleur-bordure);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--couleur-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ input:focus {
|
||||||
|
outline: 2px solid var(--couleur-primaire);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-erreur {
|
||||||
|
color: var(--couleur-danger);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
9
frontend/tsconfig.app.json
Normal file
9
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": ["src/main.ts"],
|
||||||
|
"include": ["src/**/*.d.ts"]
|
||||||
|
}
|
||||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/tsconfig.spec.json
Normal file
8
frontend/tsconfig.spec.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": ["jasmine"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
1
node_modules/.bin/playwright
generated
vendored
Symbolic link
1
node_modules/.bin/playwright
generated
vendored
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../playwright/cli.js
|
||||||
1
node_modules/.bin/playwright-core
generated
vendored
Symbolic link
1
node_modules/.bin/playwright-core
generated
vendored
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../playwright-core/cli.js
|
||||||
39
node_modules/.package-lock.json
generated
vendored
Normal file
39
node_modules/.package-lock.json
generated
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "PdfEditor",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
202
node_modules/playwright-core/LICENSE
generated
vendored
Normal file
202
node_modules/playwright-core/LICENSE
generated
vendored
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Portions Copyright (c) Microsoft Corporation.
|
||||||
|
Portions Copyright 2017 Google Inc.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
5
node_modules/playwright-core/NOTICE
generated
vendored
Normal file
5
node_modules/playwright-core/NOTICE
generated
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
Playwright
|
||||||
|
Copyright (c) Microsoft Corporation
|
||||||
|
|
||||||
|
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||||
|
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||||
3
node_modules/playwright-core/README.md
generated
vendored
Normal file
3
node_modules/playwright-core/README.md
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# playwright-core
|
||||||
|
|
||||||
|
This package contains the no-browser flavor of [Playwright](http://github.com/microsoft/playwright).
|
||||||
13
node_modules/playwright-core/ThirdPartyNotices.txt
generated
vendored
Normal file
13
node_modules/playwright-core/ThirdPartyNotices.txt
generated
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
microsoft/playwright-core
|
||||||
|
|
||||||
|
THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
|
||||||
|
|
||||||
|
This package bundles third-party software inside individual files under
|
||||||
|
`lib/`. Each bundled output has a sidecar `<bundle>.js.LICENSE` file next
|
||||||
|
to it listing every npm package whose source was inlined into that
|
||||||
|
bundle, together with the full license text for each.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
- lib/utilsBundle.js.LICENSE
|
||||||
|
|
||||||
|
This project incorporates components from the projects listed below. The original copyright notices and the licenses under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted herein, whether by implication, estoppel or otherwise.
|
||||||
5
node_modules/playwright-core/bin/install_media_pack.ps1
generated
vendored
Normal file
5
node_modules/playwright-core/bin/install_media_pack.ps1
generated
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
|
||||||
|
# check if running on Windows Server
|
||||||
|
if ($osInfo.ProductType -eq 3) {
|
||||||
|
Install-WindowsFeature Server-Media-Foundation
|
||||||
|
}
|
||||||
33
node_modules/playwright-core/bin/install_webkit_wsl.ps1
generated
vendored
Normal file
33
node_modules/playwright-core/bin/install_webkit_wsl.ps1
generated
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# This script sets up a WSL distribution that will be used to run WebKit.
|
||||||
|
|
||||||
|
$Distribution = "playwright"
|
||||||
|
$Username = "pwuser"
|
||||||
|
|
||||||
|
$distributions = (wsl --list --quiet) -split "\r?\n"
|
||||||
|
if ($distributions -contains $Distribution) {
|
||||||
|
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
|
||||||
|
} else {
|
||||||
|
Write-Host "Installing new WSL distribution '$Distribution'..."
|
||||||
|
$VhdSize = "10GB"
|
||||||
|
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
|
||||||
|
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
|
||||||
|
}
|
||||||
|
|
||||||
|
$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
|
||||||
|
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")
|
||||||
|
|
||||||
|
$initScript = @"
|
||||||
|
if [ ! -f "/home/$Username/node/bin/node" ]; then
|
||||||
|
mkdir -p /home/$Username/node
|
||||||
|
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
|
||||||
|
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
|
||||||
|
sudo -u $Username echo 'export PATH=/home/$Username/node/bin:\`$PATH' >> /home/$Username/.profile
|
||||||
|
fi
|
||||||
|
/home/$Username/node/bin/node cli.js install-deps webkit
|
||||||
|
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
|
||||||
|
"@ -replace "\r\n", "`n"
|
||||||
|
|
||||||
|
wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
|
||||||
|
Write-Host "Done!"
|
||||||
42
node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh
generated
vendored
Executable file
42
node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh
generated
vendored
Executable file
@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old beta if any.
|
||||||
|
if dpkg --get-selections | grep -q "^google-chrome-beta[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y google-chrome-beta
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
# 3. Install curl to download chrome
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. download chrome beta from dl.google.com and install it.
|
||||||
|
cd /tmp
|
||||||
|
curl -O https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb
|
||||||
|
apt-get install -y ./google-chrome-beta_current_amd64.deb
|
||||||
|
rm -rf ./google-chrome-beta_current_amd64.deb
|
||||||
|
cd -
|
||||||
|
google-chrome-beta --version
|
||||||
13
node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh
generated
vendored
Executable file
13
node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh
generated
vendored
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
rm -rf "/Applications/Google Chrome Beta.app"
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./googlechromebeta.dmg https://dl.google.com/chrome/mac/universal/beta/googlechromebeta.dmg
|
||||||
|
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechromebeta.dmg ./googlechromebeta.dmg
|
||||||
|
cp -pR "/Volumes/googlechromebeta.dmg/Google Chrome Beta.app" /Applications
|
||||||
|
hdiutil detach /Volumes/googlechromebeta.dmg
|
||||||
|
rm -rf /tmp/googlechromebeta.dmg
|
||||||
|
|
||||||
|
/Applications/Google\ Chrome\ Beta.app/Contents/MacOS/Google\ Chrome\ Beta --version
|
||||||
24
node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1
generated
vendored
Normal file
24
node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1
generated
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$url = 'https://dl.google.com/tag/s/dl/chrome/install/beta/googlechromebetastandaloneenterprise64.msi'
|
||||||
|
|
||||||
|
Write-Host "Downloading Google Chrome Beta"
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\google-chrome-beta.msi"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Google Chrome Beta"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
$suffix = "\\Google\\Chrome Beta\\Application\\chrome.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Google Chrome Beta."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
42
node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh
generated
vendored
Executable file
42
node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh
generated
vendored
Executable file
@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old stable if any.
|
||||||
|
if dpkg --get-selections | grep -q "^google-chrome[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y google-chrome
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
# 3. Install curl to download chrome
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. download chrome stable from dl.google.com and install it.
|
||||||
|
cd /tmp
|
||||||
|
curl -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
apt-get install -y ./google-chrome-stable_current_amd64.deb
|
||||||
|
rm -rf ./google-chrome-stable_current_amd64.deb
|
||||||
|
cd -
|
||||||
|
google-chrome --version
|
||||||
12
node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh
generated
vendored
Executable file
12
node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh
generated
vendored
Executable file
@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
rm -rf "/Applications/Google Chrome.app"
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./googlechrome.dmg https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg
|
||||||
|
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechrome.dmg ./googlechrome.dmg
|
||||||
|
cp -pR "/Volumes/googlechrome.dmg/Google Chrome.app" /Applications
|
||||||
|
hdiutil detach /Volumes/googlechrome.dmg
|
||||||
|
rm -rf /tmp/googlechrome.dmg
|
||||||
|
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
|
||||||
24
node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1
generated
vendored
Normal file
24
node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1
generated
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$url = 'https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi'
|
||||||
|
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\google-chrome.msi"
|
||||||
|
Write-Host "Downloading Google Chrome"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Google Chrome"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
|
||||||
|
$suffix = "\\Google\\Chrome\\Application\\chrome.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Google Chrome."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
48
node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh
generated
vendored
Executable file
48
node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh
generated
vendored
Executable file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old beta if any.
|
||||||
|
if dpkg --get-selections | grep -q "^microsoft-edge-beta[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y microsoft-edge-beta
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Install curl to download Microsoft gpg key
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GnuPG is not preinstalled in slim images
|
||||||
|
if ! command -v gpg >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gpg
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||||
|
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||||
|
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||||
|
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||||
|
rm /tmp/microsoft.gpg
|
||||||
|
apt-get update && apt-get install -y microsoft-edge-beta
|
||||||
|
|
||||||
|
microsoft-edge-beta --version
|
||||||
11
node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh
generated
vendored
Executable file
11
node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh
generated
vendored
Executable file
@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./msedge_beta.pkg "$1"
|
||||||
|
# Note: there's no way to uninstall previously installed MSEdge.
|
||||||
|
# However, running PKG again seems to update installation.
|
||||||
|
sudo installer -pkg /tmp/msedge_beta.pkg -target /
|
||||||
|
rm -rf /tmp/msedge_beta.pkg
|
||||||
|
/Applications/Microsoft\ Edge\ Beta.app/Contents/MacOS/Microsoft\ Edge\ Beta --version
|
||||||
23
node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1
generated
vendored
Normal file
23
node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1
generated
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$url = $args[0]
|
||||||
|
|
||||||
|
Write-Host "Downloading Microsoft Edge Beta"
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\microsoft-edge-beta.msi"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Microsoft Edge Beta"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
$suffix = "\\Microsoft\\Edge Beta\\Application\\msedge.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Microsoft Edge Beta."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
48
node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh
generated
vendored
Executable file
48
node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh
generated
vendored
Executable file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old dev if any.
|
||||||
|
if dpkg --get-selections | grep -q "^microsoft-edge-dev[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y microsoft-edge-dev
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Install curl to download Microsoft gpg key
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GnuPG is not preinstalled in slim images
|
||||||
|
if ! command -v gpg >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gpg
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||||
|
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||||
|
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||||
|
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||||
|
rm /tmp/microsoft.gpg
|
||||||
|
apt-get update && apt-get install -y microsoft-edge-dev
|
||||||
|
|
||||||
|
microsoft-edge-dev --version
|
||||||
11
node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh
generated
vendored
Executable file
11
node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh
generated
vendored
Executable file
@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./msedge_dev.pkg "$1"
|
||||||
|
# Note: there's no way to uninstall previously installed MSEdge.
|
||||||
|
# However, running PKG again seems to update installation.
|
||||||
|
sudo installer -pkg /tmp/msedge_dev.pkg -target /
|
||||||
|
rm -rf /tmp/msedge_dev.pkg
|
||||||
|
/Applications/Microsoft\ Edge\ Dev.app/Contents/MacOS/Microsoft\ Edge\ Dev --version
|
||||||
23
node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1
generated
vendored
Normal file
23
node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1
generated
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$url = $args[0]
|
||||||
|
|
||||||
|
Write-Host "Downloading Microsoft Edge Dev"
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\microsoft-edge-dev.msi"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Microsoft Edge Dev"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
$suffix = "\\Microsoft\\Edge Dev\\Application\\msedge.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Microsoft Edge Dev."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
48
node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh
generated
vendored
Executable file
48
node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh
generated
vendored
Executable file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
if [[ $(arch) == "aarch64" ]]; then
|
||||||
|
echo "ERROR: not supported on Linux Arm64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||||
|
if [[ ! -f "/etc/os-release" ]]; then
|
||||||
|
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||||
|
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||||
|
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. make sure to remove old stable if any.
|
||||||
|
if dpkg --get-selections | grep -q "^microsoft-edge-stable[[:space:]]*install$" >/dev/null; then
|
||||||
|
apt-get remove -y microsoft-edge-stable
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Install curl to download Microsoft gpg key
|
||||||
|
if ! command -v curl >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GnuPG is not preinstalled in slim images
|
||||||
|
if ! command -v gpg >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gpg
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||||
|
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||||
|
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||||
|
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-stable.list'
|
||||||
|
rm /tmp/microsoft.gpg
|
||||||
|
apt-get update && apt-get install -y microsoft-edge-stable
|
||||||
|
|
||||||
|
microsoft-edge-stable --version
|
||||||
11
node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh
generated
vendored
Executable file
11
node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh
generated
vendored
Executable file
@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
curl --retry 3 -o ./msedge_stable.pkg "$1"
|
||||||
|
# Note: there's no way to uninstall previously installed MSEdge.
|
||||||
|
# However, running PKG again seems to update installation.
|
||||||
|
sudo installer -pkg /tmp/msedge_stable.pkg -target /
|
||||||
|
rm -rf /tmp/msedge_stable.pkg
|
||||||
|
/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --version
|
||||||
24
node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1
generated
vendored
Normal file
24
node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1
generated
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$url = $args[0]
|
||||||
|
|
||||||
|
Write-Host "Downloading Microsoft Edge"
|
||||||
|
$wc = New-Object net.webclient
|
||||||
|
$msiInstaller = "$env:temp\microsoft-edge-stable.msi"
|
||||||
|
$wc.Downloadfile($url, $msiInstaller)
|
||||||
|
|
||||||
|
Write-Host "Installing Microsoft Edge"
|
||||||
|
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||||
|
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||||
|
Remove-Item $msiInstaller
|
||||||
|
|
||||||
|
$suffix = "\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||||
|
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||||
|
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||||
|
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Failed to install Microsoft Edge."
|
||||||
|
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
81
node_modules/playwright-core/browsers.json
generated
vendored
Normal file
81
node_modules/playwright-core/browsers.json
generated
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"comment": "Do not edit this file, use utils/roll_browser.js",
|
||||||
|
"browsers": [
|
||||||
|
{
|
||||||
|
"name": "chromium",
|
||||||
|
"revision": "1223",
|
||||||
|
"installByDefault": true,
|
||||||
|
"browserVersion": "148.0.7778.96",
|
||||||
|
"title": "Chrome for Testing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chromium-headless-shell",
|
||||||
|
"revision": "1223",
|
||||||
|
"installByDefault": true,
|
||||||
|
"browserVersion": "148.0.7778.96",
|
||||||
|
"title": "Chrome Headless Shell"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chromium-tip-of-tree",
|
||||||
|
"revision": "1427",
|
||||||
|
"installByDefault": false,
|
||||||
|
"browserVersion": "149.0.7827.0",
|
||||||
|
"title": "Chrome Canary for Testing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chromium-tip-of-tree-headless-shell",
|
||||||
|
"revision": "1427",
|
||||||
|
"installByDefault": false,
|
||||||
|
"browserVersion": "149.0.7827.0",
|
||||||
|
"title": "Chrome Canary Headless Shell"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "firefox",
|
||||||
|
"revision": "1522",
|
||||||
|
"installByDefault": true,
|
||||||
|
"browserVersion": "150.0.2",
|
||||||
|
"title": "Firefox"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "firefox-beta",
|
||||||
|
"revision": "1512",
|
||||||
|
"installByDefault": false,
|
||||||
|
"browserVersion": "151.0b5",
|
||||||
|
"title": "Firefox Beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "webkit",
|
||||||
|
"revision": "2287",
|
||||||
|
"installByDefault": true,
|
||||||
|
"revisionOverrides": {
|
||||||
|
"mac14": "2251",
|
||||||
|
"mac14-arm64": "2251",
|
||||||
|
"debian11-x64": "2105",
|
||||||
|
"debian11-arm64": "2105",
|
||||||
|
"ubuntu20.04-x64": "2092",
|
||||||
|
"ubuntu20.04-arm64": "2092"
|
||||||
|
},
|
||||||
|
"browserVersion": "26.4",
|
||||||
|
"title": "WebKit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ffmpeg",
|
||||||
|
"revision": "1011",
|
||||||
|
"installByDefault": true,
|
||||||
|
"revisionOverrides": {
|
||||||
|
"mac12": "1010",
|
||||||
|
"mac12-arm64": "1010"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "winldd",
|
||||||
|
"revision": "1007",
|
||||||
|
"installByDefault": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "android",
|
||||||
|
"revision": "1001",
|
||||||
|
"installByDefault": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
21
node_modules/playwright-core/cli.js
generated
vendored
Executable file
21
node_modules/playwright-core/cli.js
generated
vendored
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
const { libCli, libCliTestStub } = require('./lib/coreBundle');
|
||||||
|
const { program } = require('./lib/utilsBundle');
|
||||||
|
libCli.decorateProgram(program);
|
||||||
|
libCliTestStub.decorateProgram(program);
|
||||||
|
program.parse(process.argv);
|
||||||
17
node_modules/playwright-core/index.d.ts
generated
vendored
Normal file
17
node_modules/playwright-core/index.d.ts
generated
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types/types';
|
||||||
32
node_modules/playwright-core/index.js
generated
vendored
Normal file
32
node_modules/playwright-core/index.js
generated
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
const minimumMajorNodeVersion = 18;
|
||||||
|
const currentNodeVersion = process.versions.node;
|
||||||
|
const semver = currentNodeVersion.split('.');
|
||||||
|
const [major] = [+semver[0]];
|
||||||
|
|
||||||
|
if (major < minimumMajorNodeVersion) {
|
||||||
|
console.error(
|
||||||
|
'You are running Node.js ' +
|
||||||
|
currentNodeVersion +
|
||||||
|
'.\n' +
|
||||||
|
`Playwright requires Node.js ${minimumMajorNodeVersion} or higher. \n` +
|
||||||
|
'Please update your version of Node.js.'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = require('./lib/coreBundle').inprocess.playwright;
|
||||||
28
node_modules/playwright-core/index.mjs
generated
vendored
Normal file
28
node_modules/playwright-core/index.mjs
generated
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import playwright from './index.js';
|
||||||
|
|
||||||
|
export const chromium = playwright.chromium;
|
||||||
|
export const firefox = playwright.firefox;
|
||||||
|
export const webkit = playwright.webkit;
|
||||||
|
export const selectors = playwright.selectors;
|
||||||
|
export const devices = playwright.devices;
|
||||||
|
export const errors = playwright.errors;
|
||||||
|
export const request = playwright.request;
|
||||||
|
export const _electron = playwright._electron;
|
||||||
|
export const _android = playwright._android;
|
||||||
|
export default playwright;
|
||||||
77
node_modules/playwright-core/lib/bootstrap.js
generated
vendored
Normal file
77
node_modules/playwright-core/lib/bootstrap.js
generated
vendored
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"use strict";
|
||||||
|
if (process.env.PW_INSTRUMENT_MODULES) {
|
||||||
|
const Module = require("module");
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
const root = { name: "<root>", selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
|
||||||
|
let current = root;
|
||||||
|
const stack = [];
|
||||||
|
Module._load = function(request, _parent, _isMain) {
|
||||||
|
const node = { name: request, selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
|
||||||
|
current.children.push(node);
|
||||||
|
stack.push(current);
|
||||||
|
current = node;
|
||||||
|
const start = performance.now();
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = originalLoad.apply(this, arguments);
|
||||||
|
} catch (e) {
|
||||||
|
current = stack.pop();
|
||||||
|
current.children.pop();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const duration = performance.now() - start;
|
||||||
|
node.totalMs = duration;
|
||||||
|
node.selfMs = Math.max(0, duration - node.childrenMs);
|
||||||
|
current = stack.pop();
|
||||||
|
current.childrenMs += duration;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
process.on("exit", () => {
|
||||||
|
function printTree(node, prefix, isLast, lines2, depth) {
|
||||||
|
if (node.totalMs < 1 && depth > 0)
|
||||||
|
return;
|
||||||
|
const connector = depth === 0 ? "" : isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
||||||
|
const time = `${node.totalMs.toFixed(1).padStart(8)}ms`;
|
||||||
|
const self = node.children.length ? ` (self: ${node.selfMs.toFixed(1)}ms)` : "";
|
||||||
|
lines2.push(`${time} ${prefix}${connector}${node.name}${self}`);
|
||||||
|
const childPrefix = prefix + (depth === 0 ? "" : isLast ? " " : "\u2502 ");
|
||||||
|
const sorted2 = node.children.slice().sort((a, b) => b.totalMs - a.totalMs);
|
||||||
|
for (let i = 0; i < sorted2.length; i++)
|
||||||
|
printTree(sorted2[i], childPrefix, i === sorted2.length - 1, lines2, depth + 1);
|
||||||
|
}
|
||||||
|
let totalModules = 0;
|
||||||
|
function count(n) {
|
||||||
|
totalModules++;
|
||||||
|
n.children.forEach(count);
|
||||||
|
}
|
||||||
|
root.children.forEach(count);
|
||||||
|
const lines = [];
|
||||||
|
const sorted = root.children.slice().sort((a, b) => b.totalMs - a.totalMs);
|
||||||
|
for (let i = 0; i < sorted.length; i++)
|
||||||
|
printTree(sorted[i], "", i === sorted.length - 1, lines, 0);
|
||||||
|
const totalMs = root.children.reduce((s, c) => s + c.totalMs, 0);
|
||||||
|
process.stderr.write(`
|
||||||
|
--- Module load tree: ${totalModules} modules, ${totalMs.toFixed(0)}ms total ---
|
||||||
|
` + lines.join("\n") + "\n");
|
||||||
|
const flat = /* @__PURE__ */ new Map();
|
||||||
|
function gather(n) {
|
||||||
|
const existing = flat.get(n.name);
|
||||||
|
if (existing) {
|
||||||
|
existing.selfMs += n.selfMs;
|
||||||
|
existing.totalMs += n.totalMs;
|
||||||
|
existing.count++;
|
||||||
|
} else {
|
||||||
|
flat.set(n.name, { selfMs: n.selfMs, totalMs: n.totalMs, count: 1 });
|
||||||
|
}
|
||||||
|
n.children.forEach(gather);
|
||||||
|
}
|
||||||
|
root.children.forEach(gather);
|
||||||
|
const top50 = [...flat.entries()].sort((a, b) => b[1].selfMs - a[1].selfMs).slice(0, 50);
|
||||||
|
const flatLines = top50.map(
|
||||||
|
([mod, { selfMs, totalMs: totalMs2, count: count2 }]) => `${selfMs.toFixed(1).padStart(8)}ms self ${totalMs2.toFixed(1).padStart(8)}ms total (x${String(count2).padStart(3)}) ${mod}`
|
||||||
|
);
|
||||||
|
process.stderr.write(`
|
||||||
|
--- Top 50 modules by self time ---
|
||||||
|
` + flatLines.join("\n") + "\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
69799
node_modules/playwright-core/lib/coreBundle.js
generated
vendored
Normal file
69799
node_modules/playwright-core/lib/coreBundle.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
5
node_modules/playwright-core/lib/entry/cliDaemon.js
generated
vendored
Normal file
5
node_modules/playwright-core/lib/entry/cliDaemon.js
generated
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"use strict";
|
||||||
|
var import_coreBundle = require("../coreBundle");
|
||||||
|
const { program } = require("../utilsBundle");
|
||||||
|
import_coreBundle.tools.decorateCliDaemonProgram(program);
|
||||||
|
void program.parseAsync();
|
||||||
3
node_modules/playwright-core/lib/entry/dashboardApp.js
generated
vendored
Normal file
3
node_modules/playwright-core/lib/entry/dashboardApp.js
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
var import_coreBundle = require("../coreBundle");
|
||||||
|
import_coreBundle.tools.openDashboardApp();
|
||||||
10
node_modules/playwright-core/lib/entry/mcp.js
generated
vendored
Normal file
10
node_modules/playwright-core/lib/entry/mcp.js
generated
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"use strict";
|
||||||
|
var import_coreBundle = require("../coreBundle");
|
||||||
|
var import_package = require("../package");
|
||||||
|
const { program } = require("../utilsBundle");
|
||||||
|
const p = program.version("Version " + import_package.packageJSON.version).name("Playwright MCP");
|
||||||
|
import_coreBundle.tools.decorateMCPCommand(p);
|
||||||
|
program.parseAsync(process.argv).catch((e) => {
|
||||||
|
console.error(e.message);
|
||||||
|
import_coreBundle.utils.gracefullyProcessExitDoNotHang(1);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user