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

24
.env.example Normal file
View 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
View 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

View File

@ -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
View File

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

29
backend/Dockerfile Normal file
View File

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

76
backend/README.md Normal file
View File

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

View File

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

24
backend/go.mod Normal file
View File

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

68
backend/go.sum Normal file
View File

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

View File

@ -0,0 +1,106 @@
// Package auth gère le hachage des mots de passe, la génération/validation
// des JWT et des refresh tokens.
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ErrInvalidToken est renvoyé quand un access token est invalide ou expiré.
var ErrInvalidToken = errors.New("token invalide")
// Manager encapsule la logique d'authentification (secret JWT, durées de vie).
type Manager struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
}
// NewManager crée un gestionnaire d'authentification.
func NewManager(secret []byte, accessTTL, refreshTTL time.Duration) *Manager {
return &Manager{secret: secret, accessTTL: accessTTL, refreshTTL: refreshTTL}
}
// HashPassword retourne le hash bcrypt d'un mot de passe en clair.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// CheckPassword compare un mot de passe en clair avec son hash bcrypt.
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// Claims représente le contenu d'un access token.
type Claims struct {
UserID string `json:"uid"`
jwt.RegisteredClaims
}
// GenerateAccessToken crée un access token JWT signé pour l'utilisateur donné.
func (m *Manager) GenerateAccessToken(userID string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
// ParseAccessToken valide un access token et retourne ses claims.
func (m *Manager) ParseAccessToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"])
}
return m.secret, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
// GenerateRefreshToken génère un refresh token opaque (aléatoire) et son hash
// SHA-256 (pour stockage en base), ainsi que sa date d'expiration.
func (m *Manager) GenerateRefreshToken() (token, hash string, expiresAt time.Time, err error) {
raw := make([]byte, 32)
if _, err = rand.Read(raw); err != nil {
return "", "", time.Time{}, err
}
// On préfixe d'un uuid pour garantir l'unicité et faciliter le debug.
token = uuid.NewString() + "." + base64.RawURLEncoding.EncodeToString(raw)
hash = HashToken(token)
expiresAt = time.Now().Add(m.refreshTTL)
return token, hash, expiresAt, nil
}
// HashToken retourne le hash SHA-256 hexadécimal d'un token.
// SHA-256 suffit pour un secret à forte entropie (contrairement aux mots de passe).
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// AccessTTL expose la durée de vie des access tokens.
func (m *Manager) AccessTTL() time.Duration { return m.accessTTL }

View File

@ -0,0 +1,62 @@
// Package config charge la configuration de l'application depuis les variables d'environnement.
package config
import (
"fmt"
"os"
"time"
)
// Config regroupe tous les paramètres de configuration du serveur.
type Config struct {
DatabaseURL string // chaîne de connexion PostgreSQL
JWTSecret []byte // secret de signature des JWT
JWTAccessTTL time.Duration // durée de vie des access tokens
JWTRefreshTTL time.Duration // durée de vie des refresh tokens
StoragePath string // dossier de stockage des PDF sur disque
Port string // port d'écoute HTTP
CORSOrigin string // origine autorisée pour le CORS
}
// Load lit la configuration depuis l'environnement et applique les valeurs par défaut.
// Retourne une erreur si une variable obligatoire est absente ou invalide.
func Load() (*Config, error) {
cfg := &Config{
DatabaseURL: os.Getenv("DATABASE_URL"),
StoragePath: getEnv("STORAGE_PATH", "/data/documents"),
Port: getEnv("PORT", "8080"),
CORSOrigin: os.Getenv("CORS_ORIGIN"),
}
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("la variable d'environnement DATABASE_URL est obligatoire")
}
secret := os.Getenv("JWT_SECRET")
if secret == "" {
return nil, fmt.Errorf("la variable d'environnement JWT_SECRET est obligatoire")
}
cfg.JWTSecret = []byte(secret)
accessTTL, err := time.ParseDuration(getEnv("JWT_ACCESS_TTL", "15m"))
if err != nil {
return nil, fmt.Errorf("JWT_ACCESS_TTL invalide : %w", err)
}
cfg.JWTAccessTTL = accessTTL
refreshTTL, err := time.ParseDuration(getEnv("JWT_REFRESH_TTL", "168h"))
if err != nil {
return nil, fmt.Errorf("JWT_REFRESH_TTL invalide : %w", err)
}
cfg.JWTRefreshTTL = refreshTTL
return cfg, nil
}
// getEnv retourne la valeur de la variable d'environnement clef, ou def si absente.
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}

65
backend/internal/db/db.go Normal file
View File

@ -0,0 +1,65 @@
// Package db gère la connexion à PostgreSQL et l'application des migrations.
package db
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib" // enregistre le driver database/sql "pgx"
"github.com/pressly/goose/v3"
"pdfeditor/migrations"
)
// Connect ouvre un pool de connexions pgx vers la base, en réessayant
// quelques fois pour laisser le temps au service Postgres de démarrer.
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("DATABASE_URL invalide : %w", err)
}
var pool *pgxpool.Pool
// On tente plusieurs fois car la base peut ne pas être encore prête au boot.
for attempt := 1; attempt <= 10; attempt++ {
pool, err = pgxpool.NewWithConfig(ctx, cfg)
if err == nil {
if pingErr := pool.Ping(ctx); pingErr == nil {
return pool, nil
} else {
err = pingErr
pool.Close()
}
}
time.Sleep(2 * time.Second)
}
return nil, fmt.Errorf("connexion à la base impossible après plusieurs tentatives : %w", err)
}
// Migrate applique toutes les migrations embarquées via goose.
// goose s'appuie sur database/sql ; on ouvre une connexion *sql.DB
// à partir de la même configuration pgx le temps de la migration.
func Migrate(databaseURL string) error {
sqlDB, err := openStdlib(databaseURL)
if err != nil {
return fmt.Errorf("ouverture connexion migration : %w", err)
}
defer sqlDB.Close()
goose.SetBaseFS(migrations.FS)
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("dialecte goose : %w", err)
}
if err := goose.Up(sqlDB, "."); err != nil {
return fmt.Errorf("application des migrations : %w", err)
}
return nil
}
// openStdlib ouvre une connexion database/sql basée sur le driver pgx stdlib.
func openStdlib(databaseURL string) (*sql.DB, error) {
return sql.Open("pgx", databaseURL)
}

View File

@ -0,0 +1,257 @@
package handlers
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"pdfeditor/internal/auth"
)
// userResponse représente un utilisateur tel qu'exposé par l'API.
type userResponse struct {
ID string `json:"id"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
// credentials est le corps des requêtes register/login.
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
}
// tokenPair regroupe un couple access/refresh token.
type tokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
// authResponse est la réponse de register/login.
type authResponse struct {
User userResponse `json:"user"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
// validateCredentials applique des règles minimales de validation.
func validateCredentials(c credentials) (string, bool) {
email := strings.TrimSpace(strings.ToLower(c.Email))
if email == "" || !strings.Contains(email, "@") || len(email) > 254 {
return "", false
}
if len(c.Password) < 8 || len(c.Password) > 128 {
return "", false
}
return email, true
}
// Register crée un nouvel utilisateur et renvoie un couple de tokens.
func (a *API) Register(w http.ResponseWriter, r *http.Request) {
var creds credentials
if !decodeJSON(w, r, &creds) {
return
}
email, ok := validateCredentials(creds)
if !ok {
writeError(w, http.StatusBadRequest, "email invalide ou mot de passe trop court (8 caractères minimum)")
return
}
hash, err := auth.HashPassword(creds.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
ctx := r.Context()
var user userResponse
err = a.DB.QueryRow(ctx,
`INSERT INTO users (email, password_hash) VALUES ($1, $2)
RETURNING id, email, created_at`,
email, hash,
).Scan(&user.ID, &user.Email, &user.CreatedAt)
if err != nil {
// Violation de contrainte d'unicité (email déjà pris).
if strings.Contains(err.Error(), "users_email_key") || strings.Contains(err.Error(), "duplicate key") {
writeError(w, http.StatusConflict, "cet email est déjà utilisé")
return
}
writeError(w, http.StatusInternalServerError, "erreur lors de la création du compte")
return
}
pair, err := a.issueTokens(ctx, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de la génération des tokens")
return
}
writeJSON(w, http.StatusCreated, authResponse{User: user, AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken})
}
// Login authentifie un utilisateur et renvoie un couple de tokens.
func (a *API) Login(w http.ResponseWriter, r *http.Request) {
var creds credentials
if !decodeJSON(w, r, &creds) {
return
}
email := strings.TrimSpace(strings.ToLower(creds.Email))
if email == "" || creds.Password == "" {
writeError(w, http.StatusBadRequest, "email et mot de passe requis")
return
}
ctx := r.Context()
var user userResponse
var passwordHash string
err := a.DB.QueryRow(ctx,
`SELECT id, email, created_at, password_hash FROM users WHERE email = $1`,
email,
).Scan(&user.ID, &user.Email, &user.CreatedAt, &passwordHash)
if err != nil {
// On ne distingue pas "email inconnu" de "mauvais mot de passe" (anti-énumération).
writeError(w, http.StatusUnauthorized, "identifiants invalides")
return
}
if !auth.CheckPassword(passwordHash, creds.Password) {
writeError(w, http.StatusUnauthorized, "identifiants invalides")
return
}
pair, err := a.issueTokens(ctx, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de la génération des tokens")
return
}
writeJSON(w, http.StatusOK, authResponse{User: user, AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken})
}
// refreshRequest est le corps de la requête de rafraîchissement.
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// Refresh échange un refresh token valide contre un nouveau couple de tokens.
// L'ancien refresh token est révoqué (rotation).
func (a *API) Refresh(w http.ResponseWriter, r *http.Request) {
var req refreshRequest
if !decodeJSON(w, r, &req) {
return
}
if req.RefreshToken == "" {
writeError(w, http.StatusBadRequest, "refresh_token requis")
return
}
ctx := r.Context()
hash := auth.HashToken(req.RefreshToken)
var tokenID, userID string
var expiresAt time.Time
var revoked bool
err := a.DB.QueryRow(ctx,
`SELECT id, user_id, expires_at, revoked FROM refresh_tokens WHERE token_hash = $1`,
hash,
).Scan(&tokenID, &userID, &expiresAt, &revoked)
if err != nil {
writeError(w, http.StatusUnauthorized, "refresh token invalide")
return
}
if revoked || time.Now().After(expiresAt) {
writeError(w, http.StatusUnauthorized, "refresh token expiré ou révoqué")
return
}
// Rotation : on révoque l'ancien token avant d'en émettre un nouveau.
if _, err := a.DB.Exec(ctx, `UPDATE refresh_tokens SET revoked = TRUE WHERE id = $1`, tokenID); err != nil {
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
pair, err := a.issueTokens(ctx, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de la génération des tokens")
return
}
writeJSON(w, http.StatusOK, pair)
}
// Logout révoque le refresh token fourni. Auth requise.
func (a *API) Logout(w http.ResponseWriter, r *http.Request) {
var req refreshRequest
// Le refresh token peut être fourni dans le corps ; s'il est absent, on
// révoque malgré tout tous les tokens de l'utilisateur courant.
_ = decodeJSON(w, r, &req)
userID := userIDFromContext(r.Context())
ctx := r.Context()
if req.RefreshToken != "" {
hash := auth.HashToken(req.RefreshToken)
_, err := a.DB.Exec(ctx,
`UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = $1 AND user_id = $2`,
hash, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
} else {
// Pas de token précisé : on révoque toutes les sessions de l'utilisateur.
if _, err := a.DB.Exec(ctx,
`UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = $1 AND revoked = FALSE`,
userID); err != nil {
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// Me renvoie le profil de l'utilisateur authentifié.
func (a *API) Me(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
var user userResponse
err := a.DB.QueryRow(r.Context(),
`SELECT id, email, created_at FROM users WHERE id = $1`, userID,
).Scan(&user.ID, &user.Email, &user.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "utilisateur introuvable")
return
}
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
writeJSON(w, http.StatusOK, user)
}
// issueTokens génère un access token et un refresh token, persiste ce dernier
// (hashé) en base, et retourne le couple.
func (a *API) issueTokens(ctx context.Context, userID string) (tokenPair, error) {
accessToken, err := a.Auth.GenerateAccessToken(userID)
if err != nil {
return tokenPair{}, err
}
refreshToken, refreshHash, expiresAt, err := a.Auth.GenerateRefreshToken()
if err != nil {
return tokenPair{}, err
}
_, err = a.DB.Exec(ctx,
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
userID, refreshHash, expiresAt)
if err != nil {
return tokenPair{}, err
}
return tokenPair{AccessToken: accessToken, RefreshToken: refreshToken}, nil
}

View File

@ -0,0 +1,307 @@
package handlers
import (
"bytes"
"errors"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
// pdfMagic est l'en-tête attendu en début de fichier PDF.
var pdfMagic = []byte("%PDF-")
// documentResponse représente les métadonnées d'un document exposées par l'API.
type documentResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListDocuments renvoie la liste des documents de l'utilisateur courant.
func (a *API) ListDocuments(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
rows, err := a.DB.Query(r.Context(),
`SELECT id, name, size, created_at, updated_at FROM documents
WHERE user_id = $1 ORDER BY updated_at DESC`, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de la récupération des documents")
return
}
defer rows.Close()
docs := make([]documentResponse, 0)
for rows.Next() {
var d documentResponse
if err := rows.Scan(&d.ID, &d.Name, &d.Size, &d.CreatedAt, &d.UpdatedAt); err != nil {
writeError(w, http.StatusInternalServerError, "erreur de lecture")
return
}
docs = append(docs, d)
}
if rows.Err() != nil {
writeError(w, http.StatusInternalServerError, "erreur de lecture")
return
}
writeJSON(w, http.StatusOK, map[string][]documentResponse{"documents": docs})
}
// CreateDocument enregistre un nouveau PDF uploadé en multipart/form-data.
func (a *API) CreateDocument(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
file, header, name, ok := a.readUpload(w, r, true)
if !ok {
return
}
defer file.Close()
if name == "" {
name = header.Filename
}
if name == "" {
name = "document.pdf"
}
// On lit l'en-tête pour vérifier le type, puis on reconstitue le flux complet.
reader, ok := validatePDF(w, file)
if !ok {
return
}
fileID, size, err := a.Storage.Save(reader)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de l'enregistrement du fichier")
return
}
var d documentResponse
err = a.DB.QueryRow(r.Context(),
`INSERT INTO documents (user_id, name, file_path, size)
VALUES ($1, $2, $3, $4)
RETURNING id, name, size, created_at, updated_at`,
userID, name, fileID, size,
).Scan(&d.ID, &d.Name, &d.Size, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
_ = a.Storage.Delete(fileID)
writeError(w, http.StatusInternalServerError, "erreur lors de l'enregistrement")
return
}
writeJSON(w, http.StatusCreated, d)
}
// GetDocument renvoie les métadonnées d'un document.
func (a *API) GetDocument(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
id := chi.URLParam(r, "id")
var d documentResponse
err := a.DB.QueryRow(r.Context(),
`SELECT id, name, size, created_at, updated_at FROM documents
WHERE id = $1 AND user_id = $2`, id, userID,
).Scan(&d.ID, &d.Name, &d.Size, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
a.handleDocNotFound(w, err)
return
}
writeJSON(w, http.StatusOK, d)
}
// GetDocumentFile renvoie le binaire PDF d'un document.
func (a *API) GetDocumentFile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
id := chi.URLParam(r, "id")
var name, filePath string
err := a.DB.QueryRow(r.Context(),
`SELECT name, file_path FROM documents WHERE id = $1 AND user_id = $2`, id, userID,
).Scan(&name, &filePath)
if err != nil {
a.handleDocNotFound(w, err)
return
}
f, err := a.Storage.Open(filePath)
if err != nil {
writeError(w, http.StatusInternalServerError, "fichier introuvable sur le disque")
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", "inline; filename=\""+sanitizeFilename(name)+"\"")
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}
// UpdateDocument met à jour le binaire et/ou le nom d'un document.
func (a *API) UpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
id := chi.URLParam(r, "id")
// On vérifie d'abord que le document appartient à l'utilisateur.
var filePath string
err := a.DB.QueryRow(r.Context(),
`SELECT file_path FROM documents WHERE id = $1 AND user_id = $2`, id, userID,
).Scan(&filePath)
if err != nil {
a.handleDocNotFound(w, err)
return
}
file, _, name, ok := a.readUpload(w, r, false)
if !ok {
return
}
hasFile := file != nil
if hasFile {
defer file.Close()
}
// Au moins un des deux champs doit être fourni.
if !hasFile && name == "" {
writeError(w, http.StatusBadRequest, "fournir un nouveau fichier et/ou un nouveau nom")
return
}
if hasFile {
reader, valid := validatePDF(w, file)
if !valid {
return
}
size, werr := a.Storage.Overwrite(filePath, reader)
if werr != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de l'écriture du fichier")
return
}
if name != "" {
_, err = a.DB.Exec(r.Context(),
`UPDATE documents SET size = $1, name = $2, updated_at = now() WHERE id = $3`,
size, name, id)
} else {
_, err = a.DB.Exec(r.Context(),
`UPDATE documents SET size = $1, updated_at = now() WHERE id = $2`,
size, id)
}
} else {
_, err = a.DB.Exec(r.Context(),
`UPDATE documents SET name = $1, updated_at = now() WHERE id = $2`,
name, id)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur lors de la mise à jour")
return
}
var d documentResponse
err = a.DB.QueryRow(r.Context(),
`SELECT id, name, size, created_at, updated_at FROM documents WHERE id = $1`, id,
).Scan(&d.ID, &d.Name, &d.Size, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
writeError(w, http.StatusInternalServerError, "erreur interne")
return
}
writeJSON(w, http.StatusOK, d)
}
// DeleteDocument supprime un document (base + fichier).
func (a *API) DeleteDocument(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
id := chi.URLParam(r, "id")
var filePath string
err := a.DB.QueryRow(r.Context(),
`DELETE FROM documents WHERE id = $1 AND user_id = $2 RETURNING file_path`,
id, userID,
).Scan(&filePath)
if err != nil {
a.handleDocNotFound(w, err)
return
}
// Suppression best-effort du fichier disque (l'entrée en base est déjà supprimée).
_ = a.Storage.Delete(filePath)
w.WriteHeader(http.StatusNoContent)
}
// handleDocNotFound traduit une erreur de requête en 404 ou 500.
func (a *API) handleDocNotFound(w http.ResponseWriter, err error) {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "document introuvable")
return
}
writeError(w, http.StatusInternalServerError, "erreur interne")
}
// readUpload parse un formulaire multipart et retourne le fichier (peut être nil
// si fileRequired est false) ainsi que le champ "name".
// En cas d'erreur, il écrit la réponse et retourne ok=false.
func (a *API) readUpload(w http.ResponseWriter, r *http.Request, fileRequired bool) (multipart.File, *multipart.FileHeader, string, bool) {
// Limite la taille du corps lu pour éviter l'épuisement mémoire/disque.
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize+(1<<20))
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
writeError(w, http.StatusBadRequest, "formulaire multipart invalide ou fichier trop volumineux (max 50 Mo)")
return nil, nil, "", false
}
name := strings.TrimSpace(r.FormValue("name"))
file, header, err := r.FormFile("file")
if err != nil {
if fileRequired {
writeError(w, http.StatusBadRequest, "le champ 'file' est obligatoire")
return nil, nil, "", false
}
// Pas de fichier mais ce n'est pas requis (cas PUT nom seul).
return nil, nil, name, true
}
if header.Size > maxUploadSize {
file.Close()
writeError(w, http.StatusBadRequest, "fichier trop volumineux (max 50 Mo)")
return nil, nil, "", false
}
return file, header, name, true
}
// validatePDF vérifie l'en-tête %PDF- et retourne un lecteur reconstitué
// (en-tête + reste du flux). En cas d'échec, écrit la réponse et retourne ok=false.
func validatePDF(w http.ResponseWriter, file multipart.File) (io.Reader, bool) {
head := make([]byte, len(pdfMagic))
n, err := io.ReadFull(file, head)
if err != nil && err != io.ErrUnexpectedEOF {
writeError(w, http.StatusBadRequest, "fichier illisible")
return nil, false
}
if n < len(pdfMagic) || !bytes.Equal(head[:n], pdfMagic) {
writeError(w, http.StatusBadRequest, "le fichier n'est pas un PDF valide")
return nil, false
}
// On recolle l'en-tête déjà lu au reste du flux.
return io.MultiReader(bytes.NewReader(head[:n]), file), true
}
// sanitizeFilename retire les caractères problématiques d'un nom de fichier
// destiné à l'en-tête Content-Disposition.
func sanitizeFilename(name string) string {
name = strings.ReplaceAll(name, "\"", "")
name = strings.ReplaceAll(name, "\n", "")
name = strings.ReplaceAll(name, "\r", "")
if name == "" {
return "document.pdf"
}
return name
}

View File

@ -0,0 +1,51 @@
// Package handlers contient les handlers HTTP et le routeur de l'API.
package handlers
import (
"encoding/json"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"pdfeditor/internal/auth"
"pdfeditor/internal/storage"
)
// maxUploadSize est la taille maximale autorisée pour un upload de PDF (50 Mo).
const maxUploadSize = 50 << 20
// API regroupe les dépendances partagées par les handlers.
type API struct {
DB *pgxpool.Pool
Auth *auth.Manager
Storage *storage.Store
}
// New crée une instance d'API.
func New(db *pgxpool.Pool, authMgr *auth.Manager, store *storage.Store) *API {
return &API{DB: db, Auth: authMgr, Storage: store}
}
// writeJSON sérialise v en JSON avec le code de statut donné.
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}
// writeError renvoie une erreur au format { "error": "..." }.
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
// decodeJSON décode le corps JSON de la requête dans v.
// Retourne false (et écrit une 400) en cas d'échec.
func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "corps de requête JSON invalide")
return false
}
return true
}

View File

@ -0,0 +1,42 @@
package handlers
import (
"context"
"net/http"
"strings"
)
// ctxKey est un type privé pour les clefs de contexte (évite les collisions).
type ctxKey string
// userIDKey est la clef de contexte sous laquelle on stocke l'ID utilisateur authentifié.
const userIDKey ctxKey = "userID"
// RequireAuth est un middleware qui valide l'access token Bearer et injecte
// l'ID utilisateur dans le contexte de la requête.
func (a *API) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
writeError(w, http.StatusUnauthorized, "en-tête Authorization manquant ou invalide")
return
}
tokenString := strings.TrimSpace(strings.TrimPrefix(header, prefix))
claims, err := a.Auth.ParseAccessToken(tokenString)
if err != nil {
writeError(w, http.StatusUnauthorized, "token invalide ou expiré")
return
}
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// userIDFromContext récupère l'ID utilisateur stocké par RequireAuth.
func userIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(userIDKey).(string)
return id
}

View File

@ -0,0 +1,64 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
// Router construit le routeur HTTP complet de l'API.
// corsOrigin est l'origine autorisée pour les requêtes cross-origin.
func (a *API) Router(corsOrigin string) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Configuration CORS pour l'origine du frontend.
allowedOrigins := []string{"*"}
if corsOrigin != "" {
allowedOrigins = []string{corsOrigin}
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
ExposedHeaders: []string{"Content-Disposition"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Route("/api", func(r chi.Router) {
r.Get("/health", a.Health)
// Authentification (routes publiques).
r.Post("/auth/register", a.Register)
r.Post("/auth/login", a.Login)
r.Post("/auth/refresh", a.Refresh)
// Routes protégées.
r.Group(func(r chi.Router) {
r.Use(a.RequireAuth)
r.Post("/auth/logout", a.Logout)
r.Get("/me", a.Me)
r.Get("/documents", a.ListDocuments)
r.Post("/documents", a.CreateDocument)
r.Get("/documents/{id}", a.GetDocument)
r.Get("/documents/{id}/file", a.GetDocumentFile)
r.Put("/documents/{id}", a.UpdateDocument)
r.Delete("/documents/{id}", a.DeleteDocument)
})
})
return r
}
// Health répond au healthcheck.
func (a *API) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

View File

@ -0,0 +1,71 @@
// Package storage gère la persistance des fichiers PDF sur le disque.
package storage
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/google/uuid"
)
// Store écrit et lit les fichiers PDF sous un dossier racine.
type Store struct {
root string
}
// New crée un Store en s'assurant que le dossier racine existe.
func New(root string) (*Store, error) {
if err := os.MkdirAll(root, 0o750); err != nil {
return nil, fmt.Errorf("création du dossier de stockage : %w", err)
}
return &Store{root: root}, nil
}
// path retourne le chemin absolu d'un fichier à partir de son identifiant.
func (s *Store) path(id string) string {
return filepath.Join(s.root, id)
}
// Save écrit le contenu de r dans un nouveau fichier et retourne son identifiant
// ainsi que le nombre d'octets écrits.
func (s *Store) Save(r io.Reader) (id string, size int64, err error) {
id = uuid.NewString()
f, err := os.OpenFile(s.path(id), os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640)
if err != nil {
return "", 0, err
}
defer f.Close()
size, err = io.Copy(f, r)
if err != nil {
_ = os.Remove(s.path(id))
return "", 0, err
}
return id, size, nil
}
// Overwrite remplace le contenu du fichier identifié par id.
func (s *Store) Overwrite(id string, r io.Reader) (size int64, err error) {
f, err := os.OpenFile(s.path(id), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
return 0, err
}
defer f.Close()
return io.Copy(f, r)
}
// Open ouvre le fichier identifié par id en lecture.
func (s *Store) Open(id string) (*os.File, error) {
return os.Open(s.path(id))
}
// Delete supprime le fichier identifié par id. L'absence du fichier n'est pas une erreur.
func (s *Store) Delete(id string) error {
err := os.Remove(s.path(id))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View File

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

View File

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

53
docker-compose.local.yml Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,9 @@
node_modules
dist
.angular
.git
.gitignore
*.log
Dockerfile
.dockerignore
README.md

12
frontend/.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

52
frontend/nginx.conf Normal file
View 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

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View 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
View File

@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:8081",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
}
}

View File

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

View 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])),
],
};

View 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' },
];

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

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

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

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

View 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[];
}

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

View 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.',
);
},
});
}
}

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

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

View 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' },
];

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

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

View 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 limage.',
);
}
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);
});
}
}

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

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

14
frontend/src/index.html Normal file
View 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
View 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
View 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;
}

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

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

@ -0,0 +1 @@
../playwright/cli.js

1
node_modules/.bin/playwright-core generated vendored Symbolic link
View File

@ -0,0 +1 @@
../playwright-core/cli.js

39
node_modules/.package-lock.json generated vendored Normal file
View 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
View 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
View 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
View 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
View 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.

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

View 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!"

View 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

View 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

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

View 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

View 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

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

View 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

View 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

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

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

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

View 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

View 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

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

File diff suppressed because one or more lines are too long

5
node_modules/playwright-core/lib/entry/cliDaemon.js generated vendored Normal file
View File

@ -0,0 +1,5 @@
"use strict";
var import_coreBundle = require("../coreBundle");
const { program } = require("../utilsBundle");
import_coreBundle.tools.decorateCliDaemonProgram(program);
void program.parseAsync();

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