[FIX] Uge session of debugging and code coverage.
This commit is contained in:
+2
-1
@@ -4,6 +4,7 @@ missing.xml
|
||||
partitions/.svelte-kit/
|
||||
partitions/build/
|
||||
partitions/node_modules/
|
||||
partitions/static/pdf.worker.min.js
|
||||
api/vendor/
|
||||
api/composer.phar
|
||||
api/.env
|
||||
partitions/.env
|
||||
|
||||
@@ -20,7 +20,52 @@ site is archived in `legacy/` and must not be modified.
|
||||
- Node.js 26 / npm 11
|
||||
- Svelte 5, SvelteKit 2, `@sveltejs/adapter-static`, Vite 5, Tailwind 3
|
||||
- PHP 8.5 (CLI built-in server in development)
|
||||
- Dependencies: `axios`, `pdfjs-dist`, `lucide-svelte` (front); `setasign/fpdi`, `setasign/fpdf`, `tecnickcom/tcpdf` (back)
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Frontend (`partitions/package.json`)
|
||||
|
||||
Runtime:
|
||||
|
||||
| Package | Version |
|
||||
|---|---|
|
||||
| `axios` | ^1.6.0 |
|
||||
| `pdfjs-dist` | ^4.0.379 |
|
||||
|
||||
Dev:
|
||||
|
||||
| Package | Version |
|
||||
|---|---|
|
||||
| `svelte` | ^5.0.0 |
|
||||
| `@sveltejs/kit` | ^2.0.0 |
|
||||
| `@sveltejs/adapter-static` | ^3.0.0 |
|
||||
| `@sveltejs/vite-plugin-svelte` | ^4.0.0 |
|
||||
| `vite` | ^5.0.0 |
|
||||
| `typescript` | ^5.0.0 |
|
||||
| `svelte-check` | ^4.0.0 |
|
||||
| `tailwindcss` | ^3.4.0 |
|
||||
| `autoprefixer` | ^10.4.16 |
|
||||
| `postcss` | ^8.4.32 |
|
||||
|
||||
Install: `cd partitions && npm install`.
|
||||
|
||||
### Backend (`api/composer.json`)
|
||||
|
||||
| Package | Version |
|
||||
|---|---|
|
||||
| `setasign/fpdi` | ^2.6 (import de pages PDF) |
|
||||
| `setasign/fpdf` | ^1.8 (base PDF) |
|
||||
| `tecnickcom/tcpdf` | ^6.10 |
|
||||
|
||||
Install: `cd api && php composer.phar install` (le dossier `vendor/` est déjà présent).
|
||||
|
||||
PHP extensions requises (via `composer.lock`) : `gd`, `zlib`, `curl`. Déjà fournies
|
||||
ou activées avec les paquets Arch `php` + `php-gd`.
|
||||
|
||||
### Tooling de développement
|
||||
|
||||
- `xdebug` (paquet Arch `extra/xdebug`) — optionnel, utilisé pour mesurer la
|
||||
couverture de code du backend (`xdebug.mode=coverage`).
|
||||
|
||||
> PHP 8.5 note: nullable parameters MUST use explicit `?type` syntax
|
||||
> (e.g. `?string $x = null`). Implicit nullable is deprecated and pollutes API output.
|
||||
@@ -46,12 +91,14 @@ cd partitions
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm run build # outputs to partitions/build
|
||||
npm run check # svelte-check (0 errors / 0 warnings expected)
|
||||
npm run preview
|
||||
```
|
||||
|
||||
- `frontend` is a symlink to `partitions/build` (the deployed static site).
|
||||
- `VITE_API_URL` (build/dev env) selects the API base URL; falls back to
|
||||
`localhost:8000` (dev) or the production URL.
|
||||
- No ESLint / test framework is configured for the frontend.
|
||||
- `VITE_API_URL` is defined but currently unused (base URL is chosen from `import.meta.env.DEV`).
|
||||
|
||||
### Deploy
|
||||
|
||||
@@ -65,6 +112,17 @@ npm run preview
|
||||
|
||||
## Testing
|
||||
|
||||
### Run everything (single command)
|
||||
|
||||
```bash
|
||||
./test.sh
|
||||
```
|
||||
|
||||
Runs, in order: backend tests (`api/tests.php`), backend code coverage (Xdebug,
|
||||
if available), frontend `npm run check` (svelte-check), and the frontend E2E smoke
|
||||
test. It starts the API and dev servers if needed and stops them afterwards.
|
||||
"Test the project" means running `./test.sh`.
|
||||
|
||||
### PHP API tests
|
||||
|
||||
```bash
|
||||
@@ -80,6 +138,49 @@ php tests.php
|
||||
validation, INI injection, security headers.
|
||||
- `tests.php` is kept for development only and MUST NOT be shipped in the deploy zip.
|
||||
|
||||
### Code coverage (optional, requires Xdebug)
|
||||
|
||||
The test suite covers ~96% of `api/` non-vendor code (`Auth.php` and `router.php`
|
||||
at 100%, `ScoreScanner.php` +99%). The remaining uncovered lines are defensive
|
||||
I/O-failure branches (rate-limiter `flock`/`fopen` failures, missing
|
||||
`SCORES_PATH`, `OOM`/partial PHP upload errors, `createScore` rollback) that
|
||||
need OS-level fault injection to trigger.
|
||||
|
||||
```bash
|
||||
cd api
|
||||
rm -rf /tmp/ohmj_cov
|
||||
# Terminal 1: instrumented dev server
|
||||
php -d zend_extension=xdebug -d xdebug.mode=coverage \
|
||||
-d auto_prepend_file="$PWD/coverage-prepend.php" \
|
||||
-c php-upload.ini -S localhost:8000 router.php
|
||||
# Terminal 2: run tests through the instrumented server
|
||||
php -d zend_extension=xdebug -d xdebug.mode=coverage \
|
||||
-d auto_prepend_file="$PWD/coverage-prepend.php" tests.php
|
||||
# Then, from the repo root:
|
||||
SHOW_MISSING=1 php api/coverage-report.php \
|
||||
api/index.php api/router.php api/lib/Auth.php api/lib/ScoreScanner.php
|
||||
```
|
||||
|
||||
`coverage-prepend.php` / `coverage-report.php` are dev-only and excluded from the
|
||||
deploy archive.
|
||||
|
||||
### Frontend E2E smoke test (CDP, no dependency)
|
||||
|
||||
Requires the API (`:8000`) and the frontend dev server (`:5173`) to be running.
|
||||
|
||||
```bash
|
||||
cd partitions
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
`tests/e2e-smoke.mjs` drives the system `chromium` through the DevTools Protocol:
|
||||
it logs in via the API, injects the session, then visits `/scores`, a score
|
||||
detail, the PDF viewer and `/admin`. It asserts that every API call returns
|
||||
`< 400`, that the SPA stays on each route, and that the PDF is fetched with an
|
||||
`Authorization` header (no token in the URL).
|
||||
|
||||
Env overrides: `API_URL`, `FRONT_URL`, `CHROME_PATH`, `LOGIN_USER`, `LOGIN_PASS`.
|
||||
|
||||
## Authentication
|
||||
|
||||
- Login: `POST /login` with `{"username":"admin","password":"password"}` (development).
|
||||
@@ -137,6 +238,9 @@ Admin only (`role === 'admin'`):
|
||||
|
||||
## Data Model (files)
|
||||
|
||||
> Full filename/directory convention: see [`NAMING.md`](NAMING.md). It is
|
||||
> normalized and MUST NOT be changed casually (backend + frontend depend on it).
|
||||
|
||||
```
|
||||
legacy/Scores/<NUM>/<PIECE>/<INSTRUMENT>/<VERSION>/<PARTIE>.pdf
|
||||
legacy/Scores/<NUM>/score.ini
|
||||
@@ -218,11 +322,20 @@ curl -s -I http://localhost:8000/login -X POST \
|
||||
-d '{"username":"admin","password":"password"}'
|
||||
```
|
||||
|
||||
> Known security findings from the latest review (not yet fixed): static file
|
||||
> passthrough in `api/router.php` exposes `.env`/`users.json`; `deleteScoreFile`
|
||||
> lacks path containment; `api/.env` is tracked by git; JWT is standard base64
|
||||
> (not base64url) and expiry is optional; stored XSS via the `ressource` link.
|
||||
> See the project issue tracker / recent review notes.
|
||||
> Security fixes applied recently: `api/router.php` no longer serves files
|
||||
> statically (`.env`/`config/users.json` are not reachable); `deleteScoreFile`
|
||||
> enforces `realpath()` containment and only deletes `.pdf`; `api/.env` and
|
||||
> `partitions/.env` are untracked/gitignored (rotate the secret since it remains
|
||||
> in git history); JWT uses base64url, requires `exp` and validates `alg`;
|
||||
> credentials errors are generic; the `ressource` link rejects non-http(s) URLs.
|
||||
>
|
||||
> Known findings still open: default `admin`/`password` credentials; login rate
|
||||
> limiter is race-prone (no `flock`) and `/api/login` bypasses it; CORS falls
|
||||
> back to `*` for unknown origins; `GET /admin/scores/:id` passes extra
|
||||
> arguments dropped by `ScoreScanner::updateScore` (so `ressource` is not
|
||||
> persisted); watermarking only keeps page 1 of multi-page PDFs; `createScore`
|
||||
> and `uploadPdf` do not validate path segments; `updateScore`/`updatePieces`
|
||||
> do not sanitize INI values.
|
||||
|
||||
## Temporary Files
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# NAMING.md - Convention des noms de partitions
|
||||
|
||||
Référence du format de fichiers/répertoires des partitions (`legacy/Scores/`).
|
||||
Cette convention a été normalisée une fois par `scripts/convert_final_v2.js`
|
||||
(6388 fichiers convertis). **Ne pas renommer à la main** : le backend
|
||||
(`api/lib/ScoreScanner.php`) et le frontend en dépendent.
|
||||
|
||||
## Arborescence
|
||||
|
||||
```
|
||||
legacy/Scores/<NUM>/<PIECE>/<INSTRUMENT>/<VERSION>/<FICHIER>
|
||||
legacy/Scores/<NUM>/score.ini
|
||||
```
|
||||
|
||||
| Segment | Rôle | Exemples |
|
||||
|---|---|---|
|
||||
| `NUM` | identifiant du morceau, **zéro-paddé sur 3 chiffres** | `102`, `390`, `072`, `098` |
|
||||
| `PIECE` | numéro de pièce (mouvement) | `1` (morceau simple), `1`..`5` (medley type `390`) |
|
||||
| `INSTRUMENT` | trigramme de l'instrument | `cla`, `sax`, `sup`… (voir table) |
|
||||
| `VERSION` | répertoire de version | `1` = version principale, `2`, `3`… = alternatives |
|
||||
| `FICHIER` | nom du PDF (grammaire ci-dessous) | `clarinette-sib-1.pdf` |
|
||||
|
||||
> `VERSION` est un **répertoire**, pas un token du nom de fichier. Le dossier `1/`
|
||||
> est la version principale ; `2/`+ sont des versions alternatives du même morceau
|
||||
> (ex. score `210` : `cla/1/` et `cla/2/`).
|
||||
|
||||
## Grammaire du nom de fichier
|
||||
|
||||
```
|
||||
{instrument}[-{tonalité}][-{variante}]-{partie}[_{partie_combinée}][_{clef}][_alt{N}].pdf
|
||||
```
|
||||
|
||||
- Séparateur de champs : **`-`**
|
||||
- Séparateur interne aux noms composés et aux suffixes : **`_`**
|
||||
- Multi-instrument (deux instruments sur la même partition) : **`+`**
|
||||
|
||||
### Exemples réels
|
||||
|
||||
| Fichier | Sens |
|
||||
|---|---|
|
||||
| `clarinette-sib-1.pdf` | clarinette en Si♭, partie 1 |
|
||||
| `clarinette_alto-mib-1.pdf` | clarinette alto en Mi♭, partie 1 (`_` = nom composé) |
|
||||
| `sax_alto-mib-2.pdf` | sax alto en Mi♭, partie 2 |
|
||||
| `cor-fa-1_2.pdf` | cor en Fa, parties **1 et 2 combinées** |
|
||||
| `baryton-clefa-1.pdf` | baryton, clé de fa, partie 1 (pas de tonalité) |
|
||||
| `euphonium-sib-clesol-1.pdf` | euphonium Si♭, clé de sol, partie 1 |
|
||||
| `cornet-sib-solo-1.pdf` | variante `solo` |
|
||||
| `baryton-sib-default_cor-2.pdf` | substitution `default_cor` |
|
||||
| `conducteur-1_alt1.pdf` | version alternative `alt1` |
|
||||
| `basse-sib-1+contrebasse-sib-1.pdf` | **deux instruments** sur la même partition |
|
||||
| `saxophone-mib-1+baryton-mib-1.pdf` | idem (sax + baryton) |
|
||||
| `glockenspiel-1+xylophone-1.pdf` | percussion multiple |
|
||||
| `percussion-1+gong-1+cymbale-1+triangle-1.pdf` | 3 `+` possibles |
|
||||
|
||||
### Tonalités
|
||||
|
||||
`sib` (Si♭), `mib` (Mi♭), `fa` (Fa), `ut` (Do — **`do` s'écrit `ut` dans les
|
||||
fichiers**), `sol`, `re`, `mi`, `si`, `reb`, `dob`, `solb`, `lab`.
|
||||
|
||||
### Clefs
|
||||
|
||||
`clesol` (clé de sol), `clefa` (clé de fa).
|
||||
|
||||
### Variantes / suffixes
|
||||
|
||||
- `solo` : partie solo.
|
||||
- `default_<instr>` : instrument de substitution (ex. `default_cor`).
|
||||
- `_altN` : version alternative dans un même dossier (ex. `_alt1`, `_alt2`).
|
||||
|
||||
## Codes instruments
|
||||
|
||||
| Code | Instrument | Code | Instrument |
|
||||
|---|---|---|---|
|
||||
| `dir` | Direction | `trp` | Trompette |
|
||||
| `pic` | Piccolo | `trb` | Trombone |
|
||||
| `flu` | Flûte | `tub` | Tuba |
|
||||
| `cla` | Clarinette | `htb` | Hautbois |
|
||||
| `clb` | Clarinette basse | `bas` | Basson |
|
||||
| `sax` | Saxophone alto | `per` | Percussions |
|
||||
| `sat` | Saxophone ténor | `crn` | Cornet |
|
||||
| `sab` | Saxophone baryton | `eup` | Euphonium |
|
||||
| `coa` | Cor anglais | `har` | Harpe |
|
||||
| `cba` | Contrebasse | `pia` | Piano |
|
||||
| `cor` | Cor | `sup` / `par` | Parties combinées |
|
||||
|
||||
## `score.ini`
|
||||
|
||||
```ini
|
||||
[info]
|
||||
name = La part d'Euterpe
|
||||
compositor = Michaël CUVILLON
|
||||
ressource = https://... ; optionnel (lien externe)
|
||||
|
||||
[pieces]
|
||||
count = 1
|
||||
; pour un medley (count = N) :
|
||||
; 1 = La Part d Euterpe
|
||||
; 2 = Calliope s Songs
|
||||
```
|
||||
|
||||
- `[pieces] count` = nombre de pièces. Les clés `1`..`N` donnent leur nom.
|
||||
- `ressource` : lien externe optionnel (YouTube, éditeur…).
|
||||
|
||||
## Points d'attention (code)
|
||||
|
||||
- **`+` dans les chemins** : un `+` littéral dans une URL est valide en *path*,
|
||||
mais PHP `urldecode()` le transforme en espace. Le download doit donc :
|
||||
- encoder chaque segment côté frontend (`encodePath` dans `partitions/src/lib/api.ts`) ;
|
||||
- utiliser `rawurldecode()` (jamais `urldecode()`) côté `api/index.php`.
|
||||
Sinon les 167 fichiers multi-instruments (`a+b.pdf`) renvoient 403.
|
||||
- **Parsing** : `ScoreScanner::parseFilename()` détecte tonalité, clef, variante
|
||||
et partie. Les motifs doivent être ancrés (`_sib(?:_|$)`) et du plus long au plus
|
||||
court (`sibemol` avant `sib`) pour ne pas mal découper les noms.
|
||||
- Le matcher multi-instruments utilise `+` (et non `_et_`, supprimé par la
|
||||
conversion).
|
||||
|
||||
## Conversion (one-off)
|
||||
|
||||
`scripts/convert_final_v2.js` est le convertisseur historique utilisé pour
|
||||
normaliser l'ensemble du catalogue. Il ne doit plus être nécessaire pour de
|
||||
nouveaux fichiers (uploader via l'admin respecte la convention).
|
||||
|
||||
```bash
|
||||
node scripts/convert_final_v2.js /chemin/vers/Scores confirm
|
||||
```
|
||||
@@ -40,10 +40,26 @@ Le site déployé est exposé via le lien symbolique `frontend -> partitions/bui
|
||||
|
||||
## Tests
|
||||
|
||||
Tout en une commande (backend + couverture + frontend) :
|
||||
|
||||
```bash
|
||||
./test.sh
|
||||
```
|
||||
|
||||
Détail manuel :
|
||||
|
||||
```bash
|
||||
cd api
|
||||
rm -f /tmp/rate_* 2>/dev/null
|
||||
php tests.php # attendu : 53/53
|
||||
php tests.php # attendu : 181/181
|
||||
```
|
||||
|
||||
Frontend (serveurs API + dev lancés) :
|
||||
|
||||
```bash
|
||||
cd partitions
|
||||
npm run check # svelte-check : 0 erreur / 0 warning
|
||||
npm run test:e2e # smoke E2E navigateur (CDP)
|
||||
```
|
||||
|
||||
## Déploiement
|
||||
@@ -55,6 +71,12 @@ php tests.php # attendu : 53/53
|
||||
Le build frontend doit être présent dans `frontend/`. `legacy/` et `api/tests.php`
|
||||
ne sont pas inclus dans l'archive.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`AGENTS.md`](AGENTS.md) — guide de développement (stack, commandes, conventions)
|
||||
- [`NAMING.md`](NAMING.md) — convention des noms de partitions (`legacy/Scores`)
|
||||
- [`api/README.md`](api/README.md) — documentation de l'API REST
|
||||
|
||||
## Licence
|
||||
|
||||
GPL v2
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# TODO.md - Plan de travail en cours
|
||||
|
||||
> Document de reprise : si la session est interrompue, reprendre ici.
|
||||
> Dernière mise à jour : 2026-09-14.
|
||||
|
||||
## Comment lancer / tester
|
||||
|
||||
```bash
|
||||
# Tout tester d'un coup (backend + couverture + frontend) : "test le projet"
|
||||
./test.sh
|
||||
|
||||
# Serveur API (dev) - obligatoire pour les tests
|
||||
cd api && php -c php-upload.ini -S localhost:8000 router.php &
|
||||
# Front (dev)
|
||||
cd partitions && npm run dev # http://localhost:5173
|
||||
|
||||
# Tests backend
|
||||
cd api && rm -f /tmp/rate_* 2>/dev/null && php tests.php
|
||||
|
||||
# Tests frontend
|
||||
cd partitions && npm run check && npm run test:e2e
|
||||
|
||||
# Couverture (Xdebug requis) ~96.9%
|
||||
```
|
||||
|
||||
## État actuel
|
||||
|
||||
### Fait (session)
|
||||
- **Docs** : `AGENTS.md` réécrit (stack réelle), `README.md` + `api/README.md` corrigés,
|
||||
`NAMING.md` recréé (convention des noms), dépendances documentées, `PLAN.md` /
|
||||
`SECURITY_AUDIT.md` / `wordpress_list/` supprimés (commit `e79c865`).
|
||||
- **Sécurité C1-C5** : plus de service statique `.env` (`router.php`), containment
|
||||
`deleteScoreFile`, `.env` dé-suivis + secret roté, JWT base64url + `exp` obligatoire
|
||||
+ `alg` validé + erreurs génériques, XSS `ressource` bloqué.
|
||||
- **Bugs** : `+`/encodage des chemins (167 PDF), viewer PDF (course canvas + double
|
||||
chargement + fuite blob), en-tête `Authorization` insensible à la casse, 404/403 download.
|
||||
- **Backend** : `ressource` persisté/préservé, watermark multi-pages + échec remonté,
|
||||
validation des segments de chemin, sanitisation INI, rate-limiter `flock`,
|
||||
CORS sans wildcard, token download via header uniquement, `ini_set` retiré,
|
||||
`score.ini` protégé, code mort supprimé (Auth methods).
|
||||
- **Tests** : `tests.php` porte de 53 à **181 tests** (fonctionnels + unitaires directs).
|
||||
Tests isolés (créent/suppriment leurs propres partitions) ; le test watermark utilisait
|
||||
un PDF invalide et écrasait `scores[0]` → corrigé.
|
||||
- **Harness couverture** : `api/coverage-prepend.php` + `api/coverage-report.php`
|
||||
(exclus du deploy), documentés dans `AGENTS.md`.
|
||||
|
||||
### Couverture backend : 96.9%
|
||||
- `api/router.php` : **100%**
|
||||
- `api/lib/Auth.php` : **100%**
|
||||
- `api/lib/ScoreScanner.php` : **99.5%** (restent 2 lignes)
|
||||
- `api/index.php` : **92.4%** (restent 22 lignes)
|
||||
|
||||
Code inutile retiré (justifié en revue) : fallback `getallheaders()` (lit la même
|
||||
source que `$_SERVER['HTTP_AUTHORIZATION']`) et boucle d'auto-id morte (comparait
|
||||
un id non paddé à des dossiers paddés).
|
||||
|
||||
Lignes non couvertes = **branches défensives d'échec I/O**, non atteignables via
|
||||
l'API sans injection de panne OS :
|
||||
|
||||
| Fichier | Lignes | Nature |
|
||||
|---|---|---|
|
||||
| `api/index.php` | 56, 59, 60 | rate-limiter : `fopen`/`flock` échoue (fail-open) |
|
||||
| `api/index.php` | 104 | fallback `SCORES_PATH` si variable absente |
|
||||
| `api/index.php` | 222-224 | `GET /scores` renvoie 500 si dossier Scores absent |
|
||||
| `api/index.php` | 388-408 | erreurs d'upload PHP (`UPLOAD_ERR_*`, post_max_size) |
|
||||
| `api/lib/ScoreScanner.php` | 443, 444 | rollback `createScore` si écriture `score.ini` échoue |
|
||||
|
||||
**Option** pour monter plus haut : serveur de test secondaire (port 8001) avec
|
||||
`SCORES_PATH=/nonexistent` et `upload_max_filesize=1K` pour couvrir 222-224 et
|
||||
388-395. Le reste (I/O/OS, 56/59/60, 104, 443/444) resterait non couvert.
|
||||
|
||||
## À faire (reste)
|
||||
|
||||
### Finitions
|
||||
- [ ] Décider du sort de `legacy/Scores/001/` (résidu de test « Test Duplicate »,
|
||||
sans PDF) → demander avant suppression.
|
||||
- [ ] `npm audit` : 18 → **8 vulnérabilités** après `npm audit fix`. Le reste
|
||||
(svelte/rollup/picomatch) exige `npm audit fix --force` (breaking) → à décider.
|
||||
- [ ] Committer (uniquement sur demande).
|
||||
|
||||
## Fait (frontend, F11-F16)
|
||||
- **F11** code mort supprimé : `auth.getToken()`, `api.downloadFileWithAuth`,
|
||||
tout le code d'upload inutilisé d'`admin/+page.svelte`.
|
||||
- **F12** maps d'instruments factorisées dans `partitions/src/lib/instruments.ts`
|
||||
(+ helpers `getInstrumentName/Icon/DefaultKey`), réutilisées partout. `test-icons`
|
||||
supprimé. `lucide-svelte` retiré (inutilisé).
|
||||
- **F13** migration `$app/stores` → `$app/state`, suppression des abonnements
|
||||
manuels, état réactif dans `scores/[id]` et chargement piloté par `$effect`
|
||||
(navigation client correcte). `<slot/>` → `{@render children()}`.
|
||||
- **F14** worker PDF pointé sur `.mjs` (fichier suivi), `nginx.conf` MIME `.mjs`,
|
||||
`deploy.sh` simplifié, `favicon.png` ajouté.
|
||||
- **F15** route `test-icons/` supprimée ; `VITE_API_URL` réellement utilisé
|
||||
(fallback dev/prod), `define` bogus retiré de `vite.config.ts`.
|
||||
- **F16** `svelte-check` ajouté (`npm run check`) → **0 erreur / 0 warning** ;
|
||||
a11y corrigée (labels `for`/`id`, `<tr>`/spans cliquables → `<button>`,
|
||||
modales → composant `ConfirmDialog.svelte` avec `role="dialog"`/Escape,
|
||||
autofocus → action) ; `npm audit fix` appliqué.
|
||||
|
||||
|
||||
## Notes
|
||||
- Branche `master`. Ne pas committer sans demande explicite.
|
||||
- `legacy/Scores/` est la donnée réelle : les tests ne doivent jamais y écrire
|
||||
(les tests de partition sont désormais auto-isolés).
|
||||
@@ -1,2 +0,0 @@
|
||||
JWT_SECRET=6jh/MWqVplwXQKsiwlKahE19TSavfR1dNCawsQFixus=
|
||||
SCORES_PATH=/home/jbnadal/sources/jb/ohmj/ohmj2/legacy/Scores/
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
API REST pour la gestion des partitions de l'Harmonie de Montpellier-Jacou.
|
||||
|
||||
## Installation
|
||||
|
||||
Prérequis : **PHP 8.5** avec les extensions `gd`, `zlib`, `curl`.
|
||||
|
||||
```bash
|
||||
cd api
|
||||
php composer.phar install # vendor/ est déjà présent dans le dépôt
|
||||
cp .env.example .env # puis définir JWT_SECRET
|
||||
```
|
||||
|
||||
Dépendances (`composer.json`) :
|
||||
|
||||
| Package | Rôle |
|
||||
|---|---|
|
||||
| `setasign/fpdi` | import de pages PDF (watermark) |
|
||||
| `setasign/fpdf` | génération PDF de base |
|
||||
| `tecnickcom/tcpdf` | boîte à outils PDF |
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
// Dev-only: prepend file that enables Xdebug code coverage on the PHP
|
||||
// built-in server. Loaded via -d auto_prepend_file=... during coverage runs.
|
||||
// Not shipped in the deploy archive (see deploy/deploy.sh).
|
||||
|
||||
if (!function_exists('xdebug_start_code_coverage')) {
|
||||
return;
|
||||
}
|
||||
|
||||
xdebug_set_filter(XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_INCLUDE, [
|
||||
realpath(__DIR__ . '/index.php'),
|
||||
realpath(__DIR__ . '/router.php'),
|
||||
realpath(__DIR__ . '/lib'),
|
||||
]);
|
||||
|
||||
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);
|
||||
|
||||
register_shutdown_function(function () {
|
||||
$cov = xdebug_get_code_coverage();
|
||||
$dir = sys_get_temp_dir() . '/ohmj_cov';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
@file_put_contents($dir . '/cov_' . getmypid() . '_' . uniqid('', true) . '.ser', serialize($cov));
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
// Dev-only: merges the Xdebug dumps produced by coverage-prepend.php and
|
||||
// prints per-file line coverage. Usage (from the repo root):
|
||||
//
|
||||
// SHOW_MISSING=1 php api/coverage-report.php \
|
||||
// api/index.php api/router.php api/lib/Auth.php api/lib/ScoreScanner.php
|
||||
|
||||
$dir = sys_get_temp_dir() . '/ohmj_cov';
|
||||
$files = glob($dir . '/cov_*.ser') ?: [];
|
||||
$merged = [];
|
||||
|
||||
foreach ($files as $f) {
|
||||
$data = @unserialize(file_get_contents($f));
|
||||
if (!is_array($data)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($data as $file => $lines) {
|
||||
foreach ($lines as $line => $status) {
|
||||
if (!isset($merged[$file][$line]) || $status === 1) {
|
||||
$merged[$file][$line] = ($status === 1 || ($merged[$file][$line] ?? -1) === 1) ? 1 : $status;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$targets = array_slice($argv, 1);
|
||||
if (!$targets) {
|
||||
$targets = array_keys($merged);
|
||||
}
|
||||
|
||||
$grandTotal = 0;
|
||||
$grandHit = 0;
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$target = realpath($target) ?: $target;
|
||||
$lines = $merged[$target] ?? [];
|
||||
$exec = [];
|
||||
$hit = [];
|
||||
foreach ($lines as $line => $status) {
|
||||
if ($status === -2) {
|
||||
continue; // dead code
|
||||
}
|
||||
$exec[$line] = true;
|
||||
if ($status === 1) {
|
||||
$hit[$line] = true;
|
||||
}
|
||||
}
|
||||
$total = count($exec);
|
||||
$covered = count($hit);
|
||||
$grandTotal += $total;
|
||||
$grandHit += $covered;
|
||||
$pct = $total > 0 ? round($covered * 100 / $total, 1) : 0.0;
|
||||
$missing = array_diff(array_keys($exec), array_keys($hit));
|
||||
sort($missing);
|
||||
printf("%6.1f%% %3d/%-3d %s\n", $pct, $covered, $total, str_replace(getcwd() . '/', '', $target));
|
||||
if ($missing && getenv('SHOW_MISSING')) {
|
||||
echo ' manquantes: ' . implode(', ', $missing) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($grandTotal > 0) {
|
||||
printf("\nTOTAL: %d/%d (%.1f%%)\n", $grandHit, $grandTotal, $grandHit * 100 / $grandTotal);
|
||||
}
|
||||
echo 'dumps: ' . count($files) . "\n";
|
||||
+50
-68
@@ -17,10 +17,6 @@ if (file_exists(__DIR__ . '/.env')) {
|
||||
}
|
||||
}
|
||||
|
||||
// Increase upload limits for this script
|
||||
ini_set('upload_max_filesize', '64M');
|
||||
ini_set('post_max_size', '64M');
|
||||
|
||||
// Security headers
|
||||
header('Content-Type: application/json');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
@@ -29,17 +25,14 @@ header('X-XSS-Protection: 1; mode=block');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
|
||||
// CORS - Restrict to allowed origins
|
||||
// CORS - only allowlisted origins receive the header (no wildcard fallback)
|
||||
$allowedOrigins = ['http://localhost:5173', 'http://localhost:3000', 'http://localhost:4173', 'https://ohmj2.free.fr', 'https://partitions.c.nadal-fr.com', 'https://ohmj-api.c.nadal-fr.com'];
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
|
||||
// Always send CORS headers for preflight requests
|
||||
if (in_array($origin, $allowedOrigins)) {
|
||||
if (in_array($origin, $allowedOrigins, true)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
} else {
|
||||
// For preflight without matching origin, still allow (for debugging)
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header('Vary: Origin');
|
||||
}
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
||||
@@ -49,31 +42,45 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Rate limiting function
|
||||
// Normalize the path once (strip an optional /api prefix)
|
||||
$path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
|
||||
$path = preg_replace('#^api/#', '', $path);
|
||||
|
||||
// Rate limiting (flock-protected to avoid read-modify-write races)
|
||||
function checkRateLimit(string $key, int $maxRequests = 100, int $windowSeconds = 60): bool {
|
||||
$rateFile = sys_get_temp_dir() . '/rate_' . md5($key) . '.json';
|
||||
$now = time();
|
||||
|
||||
$data = ['count' => 0, 'reset' => $now + $windowSeconds];
|
||||
if (file_exists($rateFile)) {
|
||||
$content = file_get_contents($rateFile);
|
||||
$data = json_decode($content, true) ?? $data;
|
||||
|
||||
$fp = @fopen($rateFile, 'c+');
|
||||
if ($fp === false) {
|
||||
return true; // fail open if the temp dir is not writable
|
||||
}
|
||||
|
||||
// Reset if window expired
|
||||
if ($now > $data['reset']) {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
fclose($fp);
|
||||
return true;
|
||||
}
|
||||
|
||||
$content = stream_get_contents($fp);
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data) || !isset($data['count'], $data['reset']) || $now > $data['reset']) {
|
||||
$data = ['count' => 0, 'reset' => $now + $windowSeconds];
|
||||
}
|
||||
|
||||
|
||||
$data['count']++;
|
||||
file_put_contents($rateFile, json_encode($data));
|
||||
|
||||
rewind($fp);
|
||||
ftruncate($fp, 0);
|
||||
fwrite($fp, json_encode($data));
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
return $data['count'] <= $maxRequests;
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
|
||||
// Check rate limit for login endpoint (5 attempts per minute)
|
||||
if (preg_match('#^login$#', trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/')) && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
if ($path === 'login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!checkRateLimit('login_' . $ip, 5, 60)) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Too many requests. Please try again later.']);
|
||||
@@ -82,7 +89,6 @@ if (preg_match('#^login$#', trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH
|
||||
}
|
||||
|
||||
// Check general rate limit (500 requests per minute per IP - increased for testing)
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
if (!checkRateLimit('general_' . $ip, 500, 60)) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Rate limit exceeded']);
|
||||
@@ -99,30 +105,19 @@ if ($scoresPath === false || $scoresPath === '') {
|
||||
}
|
||||
$scanner = new ScoreScanner($scoresPath);
|
||||
|
||||
// Get Authorization header
|
||||
// Get Authorization header (PHP exposes it as HTTP_AUTHORIZATION)
|
||||
$token = null;
|
||||
$headers = getallheaders();
|
||||
if (isset($headers['Authorization'])) {
|
||||
$authHeader = $headers['Authorization'];
|
||||
if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches)) {
|
||||
$token = $matches[1];
|
||||
}
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches)) {
|
||||
$token = trim($matches[1]);
|
||||
}
|
||||
|
||||
// Parse request
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$path = trim($uri, '/');
|
||||
|
||||
// Remove 'api/' prefix if present
|
||||
$path = preg_replace('#^api/#', '', $path);
|
||||
|
||||
// Debug: log the path
|
||||
// file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " PATH: $path METHOD: $method\n", FILE_APPEND);
|
||||
|
||||
// GET /download/:path - Download PDF (BEFORE auth check)
|
||||
if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
|
||||
$filePath = urldecode($matches[1]);
|
||||
$filePath = rawurldecode($matches[1]);
|
||||
|
||||
// Security: Check for null bytes (must use double quotes for escape sequence)
|
||||
if (strpos($filePath, "\x00") !== false) {
|
||||
@@ -134,11 +129,10 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
|
||||
// Security: Prevent directory traversal
|
||||
$filePath = str_replace(['../', '..\\', '..'], '', $filePath);
|
||||
|
||||
// Check token from header or query parameter
|
||||
$downloadToken = $_GET['token'] ?? $token;
|
||||
$downloadToken = urldecode($downloadToken ?? '');
|
||||
// Security: token is read from the Authorization header only
|
||||
$downloadToken = $token ?? '';
|
||||
|
||||
if ($downloadToken === null || $downloadToken === '') {
|
||||
if ($downloadToken === '') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Authorization required']);
|
||||
exit;
|
||||
@@ -158,7 +152,12 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
|
||||
$realBasePath = realpath($basePath);
|
||||
$realFilePath = realpath($fullPath);
|
||||
|
||||
if ($realFilePath === false || strpos($realFilePath, $realBasePath) !== 0) {
|
||||
if ($realFilePath === false) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'File not found']);
|
||||
exit;
|
||||
}
|
||||
if ($realBasePath === false || strpos($realFilePath, $realBasePath . DIRECTORY_SEPARATOR) !== 0) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Access denied']);
|
||||
exit;
|
||||
@@ -171,12 +170,6 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!file_exists($realFilePath) || !is_file($realFilePath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'File not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Length: ' . filesize($realFilePath));
|
||||
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
|
||||
@@ -294,16 +287,9 @@ if ($path === 'admin/scores' && $method === 'POST') {
|
||||
if ($num > $maxId) $maxId = $num;
|
||||
}
|
||||
|
||||
// Skip existing IDs
|
||||
$scoresPath = getenv('SCORES_PATH') ?: __DIR__ . '/../legacy/Scores/';
|
||||
$id = strval($maxId + 1);
|
||||
while (is_dir($scoresPath . $id)) {
|
||||
$id = strval(intval($id) + 1);
|
||||
}
|
||||
// Pad with zeros to 3 digits if needed
|
||||
if (strlen($id) < 3) {
|
||||
$id = str_pad($id, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
// Next score id, zero-padded to 3 digits.
|
||||
// A concurrent collision is rejected by createScore (already exists).
|
||||
$id = str_pad((string) ($maxId + 1), 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
if (empty($name)) {
|
||||
@@ -330,7 +316,7 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'PUT')
|
||||
|
||||
$name = $input['name'] ?? null;
|
||||
$compositor = $input['compositor'] ?? null;
|
||||
$ressource = $input['ressource'] ?? '';
|
||||
$ressource = $input['ressource'] ?? null;
|
||||
|
||||
$result = $scanner->updateScore($scoreId, $name, $compositor, $ressource);
|
||||
|
||||
@@ -370,11 +356,7 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'DELETE
|
||||
if ($result['success']) {
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
if (strpos($result['error'], 'not found') !== false) {
|
||||
http_response_code(404);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
}
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => $result['error']]);
|
||||
}
|
||||
exit;
|
||||
|
||||
+50
-47
@@ -7,7 +7,7 @@ class Auth {
|
||||
private string $usersFile;
|
||||
private string $jwtSecret;
|
||||
|
||||
public function __construct(string $usersFile = null) {
|
||||
public function __construct(?string $usersFile = null) {
|
||||
$this->usersFile = $usersFile ?? __DIR__ . '/../config/users.json';
|
||||
|
||||
// Load JWT secret from environment variable
|
||||
@@ -19,25 +19,34 @@ class Auth {
|
||||
|
||||
public function login(string $username, string $password): array {
|
||||
$users = $this->loadUsers();
|
||||
$user = null;
|
||||
|
||||
foreach ($users['users'] as $user) {
|
||||
if ($user['username'] === $username) {
|
||||
if (password_verify($password, $user['password'])) {
|
||||
$token = $this->generateToken($user);
|
||||
return [
|
||||
'success' => true,
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role']
|
||||
]
|
||||
];
|
||||
}
|
||||
return ['success' => false, 'error' => 'Invalid password'];
|
||||
foreach ($users['users'] as $candidate) {
|
||||
if (hash_equals($candidate['username'], $username)) {
|
||||
$user = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => 'User not found'];
|
||||
// Always run a password check to avoid a timing oracle on user existence
|
||||
if ($user === null) {
|
||||
password_verify($password, '$2y$10$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG');
|
||||
return ['success' => false, 'error' => 'Invalid credentials'];
|
||||
}
|
||||
|
||||
if (!password_verify($password, $user['password'])) {
|
||||
return ['success' => false, 'error' => 'Invalid credentials'];
|
||||
}
|
||||
|
||||
$token = $this->generateToken($user);
|
||||
return [
|
||||
'success' => true,
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role']
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyToken(string $token): ?array {
|
||||
@@ -49,8 +58,14 @@ class Auth {
|
||||
|
||||
list($header, $payload, $signature) = $parts;
|
||||
|
||||
// Verify signature
|
||||
$expectedSignature = base64_encode(
|
||||
// Verify algorithm header before trusting anything
|
||||
$headerData = json_decode(self::base64UrlDecode($header), true);
|
||||
if (!is_array($headerData) || ($headerData['alg'] ?? '') !== self::JWT_ALGO) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify signature (constant-time)
|
||||
$expectedSignature = self::base64UrlEncode(
|
||||
hash_hmac('sha256', "$header.$payload", $this->jwtSecret, true)
|
||||
);
|
||||
|
||||
@@ -59,54 +74,46 @@ class Auth {
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
$payloadData = json_decode(base64_decode($payload), true);
|
||||
$payloadData = json_decode(self::base64UrlDecode($payload), true);
|
||||
|
||||
// Check expiry
|
||||
if (isset($payloadData['exp']) && $payloadData['exp'] < time()) {
|
||||
if (!is_array($payloadData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expiry is mandatory
|
||||
if (!isset($payloadData['exp']) || !is_numeric($payloadData['exp']) || (int) $payloadData['exp'] < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $payloadData;
|
||||
}
|
||||
|
||||
public function requireAuth(string $token): array {
|
||||
$payload = $this->verifyToken($token);
|
||||
|
||||
if ($payload === null) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
private static function base64UrlEncode(string $data): string {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public function requireAdmin(string $token): array {
|
||||
$payload = $this->requireAuth($token);
|
||||
|
||||
if ($payload['role'] !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden']);
|
||||
exit;
|
||||
private static function base64UrlDecode(string $data): string {
|
||||
$remainder = strlen($data) % 4;
|
||||
if ($remainder !== 0) {
|
||||
$data .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
return base64_decode(strtr($data, '-_', '+/'));
|
||||
}
|
||||
|
||||
private function generateToken(array $user): string {
|
||||
$header = base64_encode(json_encode([
|
||||
$header = self::base64UrlEncode(json_encode([
|
||||
'alg' => self::JWT_ALGO,
|
||||
'typ' => 'JWT'
|
||||
]));
|
||||
|
||||
$payload = base64_encode(json_encode([
|
||||
$payload = self::base64UrlEncode(json_encode([
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role'],
|
||||
'iat' => time(),
|
||||
'exp' => time() + self::JWT_EXPIRY
|
||||
]));
|
||||
|
||||
$signature = base64_encode(
|
||||
$signature = self::base64UrlEncode(
|
||||
hash_hmac('sha256', "$header.$payload", $this->jwtSecret, true)
|
||||
);
|
||||
|
||||
@@ -121,8 +128,4 @@ class Auth {
|
||||
$content = file_get_contents($this->usersFile);
|
||||
return json_decode($content, true) ?? ['users' => []];
|
||||
}
|
||||
|
||||
public function hashPassword(string $password): string {
|
||||
return password_hash($password, PASSWORD_BCRYPT);
|
||||
}
|
||||
}
|
||||
|
||||
+150
-60
@@ -406,6 +406,11 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
public function createScore(string $id, string $name, string $compositor, array $pieces = []): array {
|
||||
// Security: the id becomes a directory name, allow digits only
|
||||
if (!$this->isValidSegment($id, '/^\d{1,6}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid score id'];
|
||||
}
|
||||
|
||||
$scoreDir = $this->scoresPath . $id;
|
||||
|
||||
if (is_dir($scoreDir)) {
|
||||
@@ -425,12 +430,17 @@ class ScoreScanner {
|
||||
|
||||
// Add piece names if provided
|
||||
foreach ($pieces as $piece) {
|
||||
$num = $piece['number'];
|
||||
$pieceName = $this->sanitizeIniValue($piece['name']);
|
||||
$num = $piece['number'] ?? $piece['id'] ?? '';
|
||||
if (!preg_match('/^\d{1,3}$/', (string) $num)) {
|
||||
$this->deleteDirectory($scoreDir);
|
||||
return ['success' => false, 'error' => 'Invalid piece number'];
|
||||
}
|
||||
$pieceName = $this->sanitizeIniValue($piece['name'] ?? '');
|
||||
$iniContent .= "{$num} = $pieceName\n";
|
||||
}
|
||||
|
||||
if (file_put_contents($scoreDir . '/score.ini', $iniContent) === false) {
|
||||
$this->deleteDirectory($scoreDir);
|
||||
return ['success' => false, 'error' => 'Failed to create score.ini'];
|
||||
}
|
||||
|
||||
@@ -445,11 +455,15 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
private function sanitizeIniValue(string $value): string {
|
||||
// Remove newlines and carriage returns to prevent INI injection
|
||||
return str_replace(["\n", "\r", "\x00"], '', $value);
|
||||
// Remove control characters that would break/inject INI lines
|
||||
return trim(str_replace(["\n", "\r", "\x00", "\x1a"], '', $value));
|
||||
}
|
||||
|
||||
private function isValidSegment(string $value, string $pattern): bool {
|
||||
return $value !== '' && $value !== '.' && $value !== '..' && (bool) preg_match($pattern, $value);
|
||||
}
|
||||
|
||||
public function updateScore(string $scoreId, ?string $name, ?string $compositor): array {
|
||||
public function updateScore(string $scoreId, ?string $name, ?string $compositor, ?string $ressource = null): array {
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
$iniFile = $scoreDir . '/score.ini';
|
||||
|
||||
@@ -462,23 +476,39 @@ class ScoreScanner {
|
||||
return ['success' => false, 'error' => 'Failed to parse score.ini'];
|
||||
}
|
||||
|
||||
// Update values
|
||||
$currentName = $ini['info']['name'] ?? '';
|
||||
$currentCompositor = $ini['info']['compositor'] ?? '';
|
||||
$currentRessource = $ini['info']['ressource'] ?? '';
|
||||
|
||||
// Update values (keep existing when the field is not provided)
|
||||
if ($name !== null) {
|
||||
$ini['info']['name'] = $name;
|
||||
$currentName = $this->sanitizeIniValue($name);
|
||||
}
|
||||
if ($compositor !== null) {
|
||||
$ini['info']['compositor'] = $compositor;
|
||||
$currentCompositor = $this->sanitizeIniValue($compositor);
|
||||
}
|
||||
if ($ressource !== null) {
|
||||
$ressource = trim($ressource);
|
||||
if ($ressource !== '' && !preg_match('#^https?://#i', $ressource)) {
|
||||
return ['success' => false, 'error' => 'Invalid ressource URL (http/https only)'];
|
||||
}
|
||||
$currentRessource = $this->sanitizeIniValue($ressource);
|
||||
}
|
||||
|
||||
// Rebuild ini content
|
||||
$content = "[info]\n";
|
||||
$content .= "name = " . ($ini['info']['name'] ?? '') . "\n";
|
||||
$content .= "compositor = " . ($ini['info']['compositor'] ?? '') . "\n\n";
|
||||
$content .= "[pieces]\n";
|
||||
$content .= "name = $currentName\n";
|
||||
$content .= "compositor = $currentCompositor\n";
|
||||
if ($currentRessource !== '') {
|
||||
$content .= "ressource = $currentRessource\n";
|
||||
}
|
||||
$content .= "\n[pieces]\n";
|
||||
|
||||
if (isset($ini['pieces'])) {
|
||||
foreach ($ini['pieces'] as $key => $value) {
|
||||
$content .= "$key = $value\n";
|
||||
$safeKey = $this->sanitizeIniValue((string) $key);
|
||||
$safeValue = $this->sanitizeIniValue((string) $value);
|
||||
$content .= "$safeKey = $safeValue\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,9 +533,9 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
// Get existing info
|
||||
$name = $ini['info']['name'] ?? '';
|
||||
$compositor = $ini['info']['compositor'] ?? '';
|
||||
$ressource = $ini['info']['ressource'] ?? '';
|
||||
$name = $this->sanitizeIniValue($ini['info']['name'] ?? '');
|
||||
$compositor = $this->sanitizeIniValue($ini['info']['compositor'] ?? '');
|
||||
$ressource = $this->sanitizeIniValue($ini['info']['ressource'] ?? '');
|
||||
|
||||
// Rebuild ini content with new pieces
|
||||
$content = "[info]\n";
|
||||
@@ -518,8 +548,11 @@ class ScoreScanner {
|
||||
$content .= "count = " . count($pieces) . "\n";
|
||||
|
||||
foreach ($pieces as $piece) {
|
||||
$num = $piece['number'] ?? $piece['id'];
|
||||
$pieceName = $piece['name'] ?? '';
|
||||
$num = $piece['number'] ?? $piece['id'] ?? '';
|
||||
if (!preg_match('/^\d{1,3}$/', (string) $num)) {
|
||||
return ['success' => false, 'error' => 'Invalid piece number'];
|
||||
}
|
||||
$pieceName = $this->sanitizeIniValue($piece['name'] ?? '');
|
||||
$content .= "$num = $pieceName\n";
|
||||
}
|
||||
|
||||
@@ -562,6 +595,32 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $key = '', string $clef = '', string $variant = '', string $part = '1', bool $watermark = false, string $watermarkPosition = 'left'): array {
|
||||
// Security: every input used to build a path or a filename is validated
|
||||
if (!$this->isValidSegment($scoreId, '/^\d{1,6}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid score id'];
|
||||
}
|
||||
if (!$this->isValidSegment($piece, '/^\d{1,3}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid piece'];
|
||||
}
|
||||
if (!$this->isValidSegment($instrument, '/^[a-z]{2,4}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid instrument'];
|
||||
}
|
||||
if (!$this->isValidSegment($version, '/^[A-Za-z0-9_-]{1,10}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid version'];
|
||||
}
|
||||
if ($part !== '' && !$this->isValidSegment($part, '/^[A-Za-z0-9_+-]{1,20}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid part'];
|
||||
}
|
||||
if ($key !== '' && !$this->isValidSegment($key, '/^[a-z]{2,8}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid key'];
|
||||
}
|
||||
if ($clef !== '' && !$this->isValidSegment($clef, '/^[a-z]{2,8}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid clef'];
|
||||
}
|
||||
if ($variant !== '' && !$this->isValidSegment($variant, '/^[a-z0-9_-]{1,20}$/')) {
|
||||
return ['success' => false, 'error' => 'Invalid variant'];
|
||||
}
|
||||
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
|
||||
if (!is_dir($scoreDir)) {
|
||||
@@ -578,15 +637,6 @@ class ScoreScanner {
|
||||
return ['success' => false, 'error' => 'Upload error: ' . $file['error']];
|
||||
}
|
||||
|
||||
// Security: Validate MIME type
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if ($mimeType !== 'application/pdf') {
|
||||
return ['success' => false, 'error' => 'Invalid file type. Only PDF files are allowed'];
|
||||
}
|
||||
|
||||
// Security: Validate extension
|
||||
$originalExtension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if ($originalExtension !== 'pdf') {
|
||||
@@ -647,22 +697,38 @@ class ScoreScanner {
|
||||
|
||||
$targetPath = $targetDir . '/' . $filename;
|
||||
|
||||
// Use copy for CLI testing, move_uploaded_file for real uploads
|
||||
if (is_uploaded_file($file['tmp_name'])) {
|
||||
$result = move_uploaded_file($file['tmp_name'], $targetPath);
|
||||
if ($watermark) {
|
||||
// Write to a temp file, watermark it, then move into place atomically
|
||||
$tmpPath = $targetPath . '.tmp';
|
||||
if (is_uploaded_file($file['tmp_name'])) {
|
||||
$result = move_uploaded_file($file['tmp_name'], $tmpPath);
|
||||
} else {
|
||||
$result = copy($file['tmp_name'], $tmpPath);
|
||||
}
|
||||
if (!$result) {
|
||||
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||
}
|
||||
if (!$this->addWatermark($tmpPath, $scoreId, $watermarkPosition)) {
|
||||
@unlink($tmpPath);
|
||||
return ['success' => false, 'error' => 'Failed to apply watermark'];
|
||||
}
|
||||
if (!rename($tmpPath, $targetPath)) {
|
||||
@unlink($tmpPath);
|
||||
return ['success' => false, 'error' => 'Failed to store watermarked file'];
|
||||
}
|
||||
} else {
|
||||
$result = copy($file['tmp_name'], $targetPath);
|
||||
// Use copy for CLI testing, move_uploaded_file for real uploads
|
||||
if (is_uploaded_file($file['tmp_name'])) {
|
||||
$result = move_uploaded_file($file['tmp_name'], $targetPath);
|
||||
} else {
|
||||
$result = copy($file['tmp_name'], $targetPath);
|
||||
}
|
||||
if (!$result) {
|
||||
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($result && $watermark) {
|
||||
$this->addWatermark($targetPath, $scoreId, $watermarkPosition);
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
|
||||
}
|
||||
|
||||
public function getScoreFiles(string $scoreId): array {
|
||||
@@ -709,22 +775,42 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
public function deleteScoreFile(string $scoreId, string $filePath): array {
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
$fullPath = $scoreDir . '/' . $filePath;
|
||||
|
||||
if (!file_exists($fullPath)) {
|
||||
$scoreDir = realpath($this->scoresPath . $scoreId);
|
||||
|
||||
if ($scoreDir === false || !is_dir($scoreDir)) {
|
||||
return ['success' => false, 'error' => 'Score not found'];
|
||||
}
|
||||
|
||||
// Security: reject null bytes and resolve the real target
|
||||
if (strpos($filePath, "\x00") !== false) {
|
||||
return ['success' => false, 'error' => 'Invalid file path'];
|
||||
}
|
||||
|
||||
$fullPath = realpath($scoreDir . '/' . $filePath);
|
||||
|
||||
if ($fullPath === false) {
|
||||
return ['success' => false, 'error' => 'File not found'];
|
||||
}
|
||||
|
||||
|
||||
// Security: ensure the file stays inside the score directory
|
||||
if (strpos($fullPath, $scoreDir . DIRECTORY_SEPARATOR) !== 0) {
|
||||
return ['success' => false, 'error' => 'Access denied'];
|
||||
}
|
||||
|
||||
// Security: only PDF files can be deleted, never score.ini
|
||||
if (is_dir($fullPath)) {
|
||||
return ['success' => false, 'error' => 'Cannot delete directory'];
|
||||
}
|
||||
|
||||
|
||||
if (strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)) !== 'pdf') {
|
||||
return ['success' => false, 'error' => 'Only PDF files can be deleted'];
|
||||
}
|
||||
|
||||
if (unlink($fullPath)) {
|
||||
$this->cleanupEmptyDirectories($scoreDir, dirname($filePath));
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
|
||||
return ['success' => false, 'error' => 'Failed to delete file'];
|
||||
}
|
||||
|
||||
@@ -750,26 +836,30 @@ class ScoreScanner {
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$pdf = new \setasign\Fpdi\Fpdi();
|
||||
$pdf->setSourceFile($pdfPath);
|
||||
|
||||
$templateId = $pdf->importPage(1);
|
||||
$size = $pdf->getTemplateSize($templateId);
|
||||
|
||||
$pdf->AddPage($size['width'] > $size['height'] ? 'L' : 'P', [$size['width'], $size['height']]);
|
||||
$pdf->useTemplate($templateId);
|
||||
$pageCount = $pdf->setSourceFile($pdfPath);
|
||||
|
||||
$pdf->SetFont('helvetica', 'B', 20);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
|
||||
if ($position === 'right') {
|
||||
$pdf->SetXY($size['width'] - 45, 5);
|
||||
$pdf->Cell(40, 10, $scoreId, 0, 0, 'R');
|
||||
} else {
|
||||
$pdf->SetXY(10, 5);
|
||||
$pdf->Cell(40, 10, $scoreId, 0, 0, 'L');
|
||||
// Apply the watermark on every page (do not truncate multi-page PDFs)
|
||||
for ($i = 1; $i <= $pageCount; $i++) {
|
||||
$templateId = $pdf->importPage($i);
|
||||
$size = $pdf->getTemplateSize($templateId);
|
||||
|
||||
$pdf->AddPage($size['width'] > $size['height'] ? 'L' : 'P', [$size['width'], $size['height']]);
|
||||
$pdf->useTemplate($templateId);
|
||||
|
||||
if ($position === 'right') {
|
||||
$pdf->SetXY($size['width'] - 45, 5);
|
||||
$pdf->Cell(40, 10, $scoreId, 0, 0, 'R');
|
||||
} else {
|
||||
$pdf->SetXY(10, 5);
|
||||
$pdf->Cell(40, 10, $scoreId, 0, 0, 'L');
|
||||
}
|
||||
}
|
||||
|
||||
return $pdf->Output('F', $pdfPath);
|
||||
$pdf->Output('F', $pdfPath);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
error_log('Watermark error: ' . $e->getMessage());
|
||||
return false;
|
||||
|
||||
+3
-45
@@ -1,48 +1,6 @@
|
||||
<?php
|
||||
|
||||
// Load environment variables from .env file
|
||||
$envFile = __DIR__ . '/.env';
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0) continue;
|
||||
if (strpos($line, '=') === false) continue;
|
||||
list($key, $value) = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
if (!empty($key)) {
|
||||
putenv("$key=$value");
|
||||
$_ENV[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORS headers - MUST be sent before any other output
|
||||
$allowedOrigins = ['http://localhost:5173', 'http://localhost:3000', 'http://localhost:4173', 'https://ohmj2.free.fr', 'https://partitions.c.nadal-fr.com', 'https://ohmj-api.c.nadal-fr.com'];
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
|
||||
if (in_array($origin, $allowedOrigins)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
} else {
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
}
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
||||
|
||||
// Handle OPTIONS preflight immediately
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Router script for PHP built-in server
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
|
||||
// Serve existing files directly
|
||||
if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Route everything through index.php
|
||||
// Dev server router: every request goes through index.php (environment
|
||||
// loading, CORS and OPTIONS handling all live there). No file is ever served
|
||||
// statically, so .env, config/users.json and composer.phar stay unreachable.
|
||||
require __DIR__ . '/index.php';
|
||||
|
||||
+489
-69
@@ -175,13 +175,18 @@ class APITest {
|
||||
public function testCreateScoreWithPieces() {
|
||||
$this->section("Create Score with Pieces - Functional");
|
||||
|
||||
// Test 0: Try to create score with existing ID should fail
|
||||
// Test 0: Try to create score with existing ID should fail (self-contained)
|
||||
$seed = $this->request('POST', 'admin/scores', ['name' => 'Seed Duplicate']);
|
||||
$seedId = $seed['body']['score']['id'] ?? null;
|
||||
$result = $this->request('POST', 'admin/scores', [
|
||||
'name' => 'Test Duplicate',
|
||||
'compositor' => 'Test',
|
||||
'id' => '001'
|
||||
'id' => $seedId
|
||||
]);
|
||||
$this->test('Create with existing ID returns error', $result['code'] === 400 && isset($result['body']['error']));
|
||||
if ($seedId) {
|
||||
$this->request('DELETE', "admin/scores/$seedId");
|
||||
}
|
||||
|
||||
// Test 1: Create score with 2 pieces
|
||||
$testName = 'Test Score ' . date('YmdHis');
|
||||
@@ -300,60 +305,16 @@ class APITest {
|
||||
// Reset rate limiting
|
||||
array_map('unlink', glob(sys_get_temp_dir() . '/rate_*'));
|
||||
|
||||
// Get a score to upload to
|
||||
$scores = $this->request('GET', 'scores');
|
||||
|
||||
if (empty($scores['body']['scores'])) {
|
||||
echo " ⚠ No scores available for watermark test\n";
|
||||
// Create a dedicated score so the test never touches real data
|
||||
$created = $this->request('POST', 'admin/scores', ['name' => 'Watermark Test']);
|
||||
$scoreId = $created['body']['score']['id'] ?? null;
|
||||
if (!$scoreId) {
|
||||
$this->test('Dedicated watermark score available', false);
|
||||
return;
|
||||
}
|
||||
|
||||
$scoreId = $scores['body']['scores'][0]['id'];
|
||||
|
||||
// Create a minimal test PDF
|
||||
$pdfContent = "%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /Resources 4 0 R /MediaBox [0 0 612 792] /Contents 5 0 R >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Font << /F1 6 0 R >> >>
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Length 44 >>
|
||||
stream
|
||||
BT
|
||||
/F1 12 Tf
|
||||
100 700 Td
|
||||
(Test) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 7
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000214 00000 n
|
||||
0000000312 00000 n
|
||||
0000000401 00000 n
|
||||
trailer
|
||||
<< /Size 7 /Root 1 0 R >>
|
||||
startxref
|
||||
490
|
||||
%%EOF";
|
||||
|
||||
$tmpFile = sys_get_temp_dir() . '/test_watermark_' . time() . '.pdf';
|
||||
file_put_contents($tmpFile, $pdfContent);
|
||||
$tmpFile = sys_get_temp_dir() . '/test_watermark_' . uniqid() . '.pdf';
|
||||
$this->makePdf($tmpFile, 2);
|
||||
|
||||
// Test upload with watermark left
|
||||
$result = $this->multipartRequest("admin/scores/$scoreId/upload", [
|
||||
@@ -367,18 +328,16 @@ startxref
|
||||
|
||||
$this->test('Upload with watermark left returns 200', $result['code'] === 200);
|
||||
|
||||
// Get files to verify it was uploaded
|
||||
// Get files to verify it was uploaded (recursive tree)
|
||||
$files = $this->request('GET', "admin/scores/$scoreId/files");
|
||||
$hasFile = false;
|
||||
if (!empty($files['body']['files'])) {
|
||||
foreach ($files['body']['files'] as $f) {
|
||||
if (strpos($f['path'], 'direction_1.pdf') !== false) {
|
||||
$hasFile = true;
|
||||
// Clean up
|
||||
@unlink(SCORES_PATH . "$scoreId/" . $f['path']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
$found = $this->findTreeFile($files['body']['files'] ?? [], 'direction_1.pdf');
|
||||
if ($found) {
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
$check = new \setasign\Fpdi\Fpdi();
|
||||
$pages = $check->setSourceFile(SCORES_PATH . "$scoreId/" . $found['path']);
|
||||
$this->test('Watermark preserves all pages', $pages === 2);
|
||||
} else {
|
||||
$this->test('Watermarked file present', false);
|
||||
}
|
||||
|
||||
// Test upload with watermark right
|
||||
@@ -396,13 +355,32 @@ startxref
|
||||
// Clean up
|
||||
@unlink($tmpFile);
|
||||
|
||||
// Clean up uploaded files
|
||||
// Clean up all uploaded files (recursive) then the dedicated score
|
||||
$files = $this->request('GET', "admin/scores/$scoreId/files");
|
||||
if (!empty($files['body']['files'])) {
|
||||
foreach ($files['body']['files'] as $f) {
|
||||
if (strpos($f['path'], 'direction_') !== false) {
|
||||
@unlink(SCORES_PATH . "$scoreId/" . $f['path']);
|
||||
$this->deleteTreeMatching($files['body']['files'] ?? [], $scoreId, 'direction_');
|
||||
$this->request('DELETE', "admin/scores/$scoreId");
|
||||
}
|
||||
|
||||
private function findTreeFile($tree, $needle) {
|
||||
foreach ($tree as $node) {
|
||||
if (($node['type'] ?? '') === 'folder') {
|
||||
$found = $this->findTreeFile($node['children'] ?? [], $needle);
|
||||
if ($found) {
|
||||
return $found;
|
||||
}
|
||||
} elseif (strpos($node['path'] ?? '', $needle) !== false) {
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function deleteTreeMatching($tree, $scoreId, $needle) {
|
||||
foreach ($tree as $node) {
|
||||
if (($node['type'] ?? '') === 'folder') {
|
||||
$this->deleteTreeMatching($node['children'] ?? [], $scoreId, $needle);
|
||||
} elseif (strpos($node['path'] ?? '', $needle) !== false) {
|
||||
@unlink(SCORES_PATH . "$scoreId/" . $node['path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,6 +614,443 @@ startxref
|
||||
|
||||
// ============ SUMMARY ============
|
||||
|
||||
// ============ EXTRA FUNCTIONAL COVERAGE ============
|
||||
|
||||
private function requestWithToken($method, $endpoint, $token) {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => $method,
|
||||
'header' => "Authorization: Bearer $token",
|
||||
'ignore_errors' => true
|
||||
]
|
||||
]);
|
||||
$response = @file_get_contents(BASE_URL . '/' . $endpoint, false, $context);
|
||||
$code = 500;
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('/^HTTP\/\d+\.\d+\s+(\d+)/', $header, $m)) {
|
||||
$code = (int) $m[1];
|
||||
}
|
||||
}
|
||||
return ['code' => $code, 'body' => json_decode($response, true) ?? []];
|
||||
}
|
||||
|
||||
public function testMoreEndpoints() {
|
||||
$this->section("Endpoints - Extended");
|
||||
array_map('unlink', glob(sys_get_temp_dir() . '/rate_*'));
|
||||
|
||||
// Create a dedicated score (auto id) then exercise every sub-resource
|
||||
$created = $this->request('POST', 'admin/scores', ['name' => 'Test Coverage', 'compositor' => 'CI']);
|
||||
$this->test('Create score (auto id) returns 200', $created['code'] === 200);
|
||||
$scoreId = $created['body']['score']['id'] ?? null;
|
||||
if (!$scoreId) {
|
||||
$this->test('Dedicated score available', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalid id is rejected
|
||||
$bad = $this->request('POST', 'admin/scores', ['id' => '../evil', 'name' => 'x']);
|
||||
$this->test('Create with invalid id returns 400', $bad['code'] === 400);
|
||||
|
||||
// Upload a valid PDF
|
||||
$tmpPdf = sys_get_temp_dir() . '/cov_' . uniqid() . '.pdf';
|
||||
$this->makePdf($tmpPdf, 1);
|
||||
$up = $this->multipartRequest("admin/scores/$scoreId/upload", [
|
||||
['name' => 'file', 'file' => $tmpPdf],
|
||||
['name' => 'piece', 'data' => '1'],
|
||||
['name' => 'instrument', 'data' => 'dir'],
|
||||
['name' => 'version', 'data' => '1'],
|
||||
]);
|
||||
$this->test('Upload valid PDF returns 200', $up['code'] === 200);
|
||||
|
||||
// Invalid segment / invalid file type
|
||||
$badSeg = $this->multipartRequest("admin/scores/$scoreId/upload", [
|
||||
['name' => 'file', 'file' => $tmpPdf],
|
||||
['name' => 'piece', 'data' => '1'],
|
||||
['name' => 'instrument', 'data' => '..'],
|
||||
['name' => 'version', 'data' => '1'],
|
||||
]);
|
||||
$this->test('Upload invalid instrument returns 400', $badSeg['code'] === 400);
|
||||
|
||||
$badSeg2 = $this->multipartRequest("admin/scores/$scoreId/upload", [
|
||||
['name' => 'file', 'file' => $tmpPdf],
|
||||
['name' => 'piece', 'data' => '..'],
|
||||
['name' => 'instrument', 'data' => 'dir'],
|
||||
['name' => 'version', 'data' => '1'],
|
||||
]);
|
||||
$this->test('Upload invalid piece returns 400', $badSeg2['code'] === 400);
|
||||
|
||||
$noFile = $this->request('POST', "admin/scores/$scoreId/upload", ['unexpected' => 'body']);
|
||||
$this->test('Upload without file part returns 413', $noFile['code'] === 413);
|
||||
|
||||
// Instruments (list + filter)
|
||||
$instr = $this->request('GET', "scores/$scoreId/instruments");
|
||||
$this->test('Instruments returns 200', $instr['code'] === 200);
|
||||
$instrPiece = $this->request('GET', "scores/$scoreId/instruments", null, true, ['piece' => 1]);
|
||||
$this->test('Instruments?piece=1 returns 200', $instrPiece['code'] === 200);
|
||||
|
||||
// Pieces endpoint content
|
||||
$pieces = $this->request('GET', "pieces/$scoreId");
|
||||
$this->test('Pieces returns 200', $pieces['code'] === 200);
|
||||
|
||||
// Download success + failures
|
||||
$path = "$scoreId/1/dir/1/direction_1.pdf";
|
||||
$ok = $this->request('GET', "download/$path");
|
||||
$this->test('Download with token returns 200', $ok['code'] === 200);
|
||||
$noToken = $this->request('GET', "download/$path", null, false);
|
||||
$this->test('Download without token returns 401', $noToken['code'] === 401);
|
||||
$badToken = $this->requestWithToken('GET', "download/$path", 'not.a.token');
|
||||
$this->test('Download with invalid token returns 401', $badToken['code'] === 401);
|
||||
$nonPdf = $this->request('GET', "download/$scoreId/score.ini");
|
||||
$this->test('Download non-PDF returns 403', $nonPdf['code'] === 403);
|
||||
$missing = $this->request('GET', "download/$scoreId/1/dir/1/nope.pdf");
|
||||
$this->test('Download missing file returns 404', $missing['code'] === 404);
|
||||
$badApi = $this->requestWithToken('GET', 'scores', 'a.b.c');
|
||||
$this->test('Protected route with invalid token returns 401', $badApi['code'] === 401);
|
||||
$root = realpath(SCORES_PATH);
|
||||
@symlink('/etc/hostname', $root . "/$scoreId/evil.pdf");
|
||||
$escaped = $this->request('GET', "download/$scoreId/evil.pdf");
|
||||
$this->test('Download escaped symlink returns 403', $escaped['code'] === 403);
|
||||
@unlink($root . "/$scoreId/evil.pdf");
|
||||
|
||||
// updatePieces
|
||||
$upd = $this->request('PUT', "admin/scores/$scoreId/pieces", [
|
||||
'pieces' => [
|
||||
['number' => 1, 'name' => 'Mouvement A'],
|
||||
['number' => 2, 'name' => 'Mouvement B']
|
||||
]
|
||||
]);
|
||||
$this->test('Update pieces returns 200', $upd['code'] === 200);
|
||||
$badPiece = $this->request('PUT', "admin/scores/$scoreId/pieces", ['pieces' => [['number' => 'x', 'name' => 'bad']]]);
|
||||
$this->test('Update pieces invalid number returns 400', $badPiece['code'] === 400);
|
||||
|
||||
// updateScore with ressource (persist + invalid)
|
||||
$updScore = $this->request('PUT', "admin/scores/$scoreId", ['name' => 'Test Coverage', 'compositor' => 'CI', 'ressource' => 'https://example.com/x']);
|
||||
$this->test('Update score with ressource returns 200', $updScore['code'] === 200);
|
||||
$score = $this->request('GET', "scores/$scoreId");
|
||||
$this->test('Ressource persisted', ($score['body']['score']['ressource'] ?? null) === 'https://example.com/x');
|
||||
$badUrl = $this->request('PUT', "admin/scores/$scoreId", ['ressource' => 'javascript:alert(1)']);
|
||||
$this->test('Update score invalid ressource returns 400', $badUrl['code'] === 400);
|
||||
|
||||
// Non-admin cannot reach admin routes
|
||||
$userLogin = $this->request('POST', 'login', ['username' => 'user', 'password' => 'password'], false);
|
||||
if (($userLogin['body']['token'] ?? null)) {
|
||||
$saved = $this->token;
|
||||
$this->token = $userLogin['body']['token'];
|
||||
$forbidden = $this->request('POST', 'admin/scores', ['name' => 'nope']);
|
||||
$this->test('Non-admin blocked on admin route (403)', $forbidden['code'] === 403);
|
||||
$this->token = $saved;
|
||||
}
|
||||
|
||||
// Delete uploaded file, then the score
|
||||
$del = $this->request('DELETE', "admin/scores/$scoreId/files", null, true, ['path' => '1/dir/1/direction_1.pdf']);
|
||||
$this->test('Delete file returns 200', $del['code'] === 200);
|
||||
$delScore = $this->request('DELETE', "admin/scores/$scoreId");
|
||||
$this->test('Delete score returns 200', $delScore['code'] === 200);
|
||||
|
||||
// Unknown route
|
||||
$unknown = $this->request('GET', 'does/not/exist');
|
||||
$this->test('Unknown route returns 404', $unknown['code'] === 404);
|
||||
|
||||
@unlink($tmpPdf);
|
||||
}
|
||||
|
||||
// ============ UNIT TESTS (direct library coverage) ============
|
||||
|
||||
private function makePdf($path, $pages = 1) {
|
||||
require_once __DIR__ . '/vendor/setasign/fpdf/fpdf.php';
|
||||
$pdf = new FPDF();
|
||||
for ($i = 0; $i < $pages; $i++) {
|
||||
$pdf->AddPage();
|
||||
$pdf->SetFont('Helvetica', '', 12);
|
||||
$pdf->Cell(40, 10, 'Page ' . ($i + 1));
|
||||
}
|
||||
$pdf->Output('F', $path);
|
||||
}
|
||||
|
||||
private function rrmdir($dir) {
|
||||
if (!is_dir($dir)) return;
|
||||
foreach (scandir($dir) as $item) {
|
||||
if ($item === '.' || $item === '..') continue;
|
||||
$p = $dir . '/' . $item;
|
||||
is_dir($p) ? $this->rrmdir($p) : @unlink($p);
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
public function testUnits() {
|
||||
$this->section("Unit - ScoreScanner & Auth");
|
||||
require_once __DIR__ . '/lib/ScoreScanner.php';
|
||||
require_once __DIR__ . '/lib/Auth.php';
|
||||
|
||||
$base = sys_get_temp_dir() . '/ohmj_unit_' . uniqid() . '/';
|
||||
@mkdir($base . '001/1/cla/1', 0777, true);
|
||||
@mkdir($base . '001/1/cor/1', 0777, true);
|
||||
@mkdir($base . '001/1/cor/2', 0777, true);
|
||||
@mkdir($base . '001/1/emt/1', 0777, true);
|
||||
@mkdir($base . '001/2/xyz', 0777, true);
|
||||
@mkdir($base . '002', 0777, true);
|
||||
@mkdir($base . '003', 0777, true);
|
||||
@mkdir($base . '004', 0777, true);
|
||||
@mkdir($base . '.hidden', 0777, true);
|
||||
@mkdir($base . 'notnum', 0777, true);
|
||||
@mkdir($base . '005/1/cla/1', 0777, true);
|
||||
@mkdir($base . '007/cla/1', 0777, true);
|
||||
@mkdir($base . '008', 0777, true);
|
||||
|
||||
file_put_contents($base . '001/score.ini', "[info]\nname = Test\ncompositor = Comp\nressource = https://example.com\n\n[pieces]\ncount = 1\n");
|
||||
file_put_contents($base . '002/score.ini', "name = Broken\ncompositor = Someone\nkey = \"unterminated\n");
|
||||
file_put_contents($base . '004/score.ini', "just text no equals\n");
|
||||
file_put_contents($base . '008/score.ini', "\"unterminated line\n");
|
||||
file_put_contents($base . '007/cla/1/flute-ut-1.pdf', '%PDF-1.4 test');
|
||||
file_put_contents($base . '005/score.ini', "[info]\nname = NoInstruments\n");
|
||||
|
||||
$this->makePdf($base . '001/1/cla/1/clarinette-sib-1.pdf', 1);
|
||||
$this->makePdf($base . '001/1/cor/1/cor-fa-1.pdf', 1);
|
||||
$this->makePdf($base . '001/1/cor/2/cor-fa-1.pdf', 1);
|
||||
$this->makePdf($base . '001/1/cla/1/weird.txt', 1);
|
||||
file_put_contents($base . '001/1/cla/1/clarinette-sib-solo-2.pdf', '%PDF-1.4 test');
|
||||
file_put_contents($base . '001/1/cla/1/clarinette-mib-1_2.pdf', '%PDF-1.4 test');
|
||||
file_put_contents($base . '001/1/cla/1/readme.txt', 'x');
|
||||
file_put_contents($base . '001/1/emt/1/fake.txt', 'x');
|
||||
|
||||
$scanner = new ScoreScanner($base);
|
||||
|
||||
$scores = $scanner->listScores();
|
||||
$this->test('Unit: listScores returns array', is_array($scores) && count($scores) >= 1);
|
||||
$this->test('Unit: getScore returns detail', is_array($scanner->getScore('001')));
|
||||
$this->test('Unit: getScore unknown returns null', $scanner->getScore('999') === null);
|
||||
$this->test('Unit: getScore no instruments returns detail', is_array($scanner->getScore('005')));
|
||||
$this->test('Unit: getPieces returns 1 piece', count($scanner->getPieces('001')) === 1);
|
||||
$this->test('Unit: getPieces unknown returns []', $scanner->getPieces('999') === []);
|
||||
$this->test('Unit: getInstruments returns array', is_array($scanner->getInstruments('001')));
|
||||
$this->test('Unit: getInstruments piece filter', is_array($scanner->getInstruments('001', '1')));
|
||||
|
||||
$bad = new ScoreScanner('/nonexistent/path/');
|
||||
$this->test('Unit: missing scores dir returns error', isset($bad->listScores()['error']));
|
||||
$this->test('Unit: getScore no ini returns null', $scanner->getScore('003') === null);
|
||||
$this->test('Unit: getPieces parse failure returns []', $scanner->getPieces('002') === []);
|
||||
$this->test('Unit: getInstruments unknown returns []', $scanner->getInstruments('999') === []);
|
||||
|
||||
// parseFilename branches via reflection
|
||||
$ref = new ReflectionClass('ScoreScanner');
|
||||
$parse = $ref->getMethod('parseFilename');
|
||||
$parse->setAccessible(true);
|
||||
$p1 = $parse->invoke($scanner, 'clarinette-sib-1');
|
||||
$this->test('Unit: parse key+part', $p1['key'] === 'sib' && $p1['part'] === '1');
|
||||
$p2 = $parse->invoke($scanner, 'clarinette-mib-1_2');
|
||||
$this->test('Unit: parse combined part', $p2['key'] === 'mib' && $p2['part'] === '1_2');
|
||||
$p3 = $parse->invoke($scanner, 'cornet-sib-solo-2');
|
||||
$this->test('Unit: parse variant solo', $p3['variant'] === 'solo');
|
||||
$p4 = $parse->invoke($scanner, 'cor-fa-1-clesol');
|
||||
$this->test('Unit: parse clef', $p4['clef'] === 'clesol');
|
||||
$p5 = $parse->invoke($scanner, 'baryton-sib-default-cor-2');
|
||||
$this->test('Unit: parse variant default', $p5['variant'] === 'default');
|
||||
$p6 = $parse->invoke($scanner, 'tuba-ut-1');
|
||||
$this->test('Unit: parse key ut', $p6['key'] === 'ut');
|
||||
$getName = $ref->getMethod('getInstrumentName');
|
||||
$getName->setAccessible(true);
|
||||
$this->test('Unit: instrument name mapping', $getName->invoke($scanner, 'cla') === 'Clarinette');
|
||||
$this->test('Unit: instrument name fallback', $getName->invoke($scanner, 'zzz') === 'zzz');
|
||||
$sanitize = $ref->getMethod('sanitizeIniValue');
|
||||
$sanitize->setAccessible(true);
|
||||
$this->test('Unit: sanitize removes newlines', $sanitize->invoke($scanner, "a\nb\rc") === 'abc');
|
||||
$valid = $ref->getMethod('isValidSegment');
|
||||
$valid->setAccessible(true);
|
||||
$this->test('Unit: segment accepts digits', $valid->invoke($scanner, '12', '/^\d+$/') === true);
|
||||
$this->test('Unit: segment rejects traversal', $valid->invoke($scanner, '..', '/^\d+$/') === false);
|
||||
$this->test('Unit: segment rejects empty', $valid->invoke($scanner, '', '/^\d+$/') === false);
|
||||
|
||||
// Legacy (no piece level) paths via reflection
|
||||
$gii = $ref->getMethod('getInstrumentInfo');
|
||||
$gii->setAccessible(true);
|
||||
$gpi = $ref->getMethod('getPartInfo');
|
||||
$gpi->setAccessible(true);
|
||||
$this->test('Unit: getInstrumentInfo missing dir returns null', $gii->invoke($scanner, '999', 'cla') === null);
|
||||
$this->test('Unit: getPartInfo missing dir returns null', $gpi->invoke($scanner, '999', 'cla', '1') === null);
|
||||
$legacy = $gii->invoke($scanner, '007', 'cla');
|
||||
$this->test('Unit: getInstrumentInfo no-piece path', is_array($legacy) && isset($legacy['parts'][0]['files'][0]['path']));
|
||||
$this->test('Unit: getInstrumentInfo empty parts returns null', $gii->invoke($scanner, '001', 'emt', '1') === null);
|
||||
|
||||
// createScore branches
|
||||
$this->test('Unit: createScore invalid id', $scanner->createScore('../x', 'n', 'c')['success'] === false);
|
||||
$this->test('Unit: createScore ok', $scanner->createScore('100', 'New', 'Comp')['success'] === true);
|
||||
$this->test('Unit: createScore duplicate', $scanner->createScore('100', 'New', 'Comp')['success'] === false);
|
||||
$this->test('Unit: createScore invalid piece', $scanner->createScore('101', 'New', 'Comp', [['number' => 'x', 'name' => 'y']])['success'] === false);
|
||||
$fileAsBase = new ScoreScanner($base . '001/score.ini/');
|
||||
$this->test('Unit: createScore mkdir failure', $fileAsBase->createScore('900', 'n', 'c')['success'] === false);
|
||||
$this->test('Unit: createScore with pieces', $scanner->createScore('102', 'New', 'Comp', [['number' => 1, 'name' => 'A']])['success'] === true);
|
||||
|
||||
// updateScore branches
|
||||
$this->test('Unit: updateScore unknown', $scanner->updateScore('999', 'a', 'b')['success'] === false);
|
||||
$this->test('Unit: updateScore invalid ressource', $scanner->updateScore('100', null, null, 'javascript:x')['success'] === false);
|
||||
$this->test('Unit: updateScore ok', $scanner->updateScore('100', 'Renamed', 'Comp2', 'https://ok.example')['success'] === true);
|
||||
$read = $ref->getMethod('getScoreInfo');
|
||||
$read->setAccessible(true);
|
||||
$info = $read->invoke($scanner, '100');
|
||||
$this->test('Unit: updateScore persisted ressource', ($info['ressource'] ?? '') === 'https://ok.example');
|
||||
$this->test('Unit: updateScore preserves on partial', $scanner->updateScore('100', 'OnlyName', null)['success'] === true);
|
||||
$info2 = $read->invoke($scanner, '100');
|
||||
$this->test('Unit: updateScore kept ressource', ($info2['ressource'] ?? '') === 'https://ok.example');
|
||||
$this->test('Unit: updateScore parse failure', $scanner->updateScore('002', 'x', null)['success'] === false);
|
||||
$scanner->createScore('104', 'W', 'C');
|
||||
@chmod($base . '104/score.ini', 0444);
|
||||
$this->test('Unit: updateScore write failure', $scanner->updateScore('104', 'x', null)['success'] === false);
|
||||
$this->test('Unit: updatePieces write failure', $scanner->updatePieces('104', [['number' => 1, 'name' => 'a']])['success'] === false);
|
||||
@chmod($base . '104/score.ini', 0644);
|
||||
|
||||
// updatePieces branches
|
||||
$this->test('Unit: updatePieces unknown', $scanner->updatePieces('999', [])['success'] === false);
|
||||
$this->test('Unit: updatePieces invalid number', $scanner->updatePieces('100', [['number' => 'x', 'name' => 'a']])['success'] === false);
|
||||
$this->test('Unit: updatePieces ok', $scanner->updatePieces('100', [['number' => 1, 'name' => 'A']])['success'] === true);
|
||||
$this->test('Unit: updatePieces parse failure', $scanner->updatePieces('002', [])['success'] === false);
|
||||
|
||||
// getScoreInfo malformed / manual parse
|
||||
$broken = $read->invoke($scanner, '002');
|
||||
$this->test('Unit: getScoreInfo manual parse fallback', is_array($broken) || $broken === null);
|
||||
$this->test('Unit: getScoreInfo missing ini returns null', $read->invoke($scanner, '003') === null);
|
||||
$manual = $read->invoke($scanner, '002');
|
||||
$this->test('Unit: getScoreInfo manual name+compositor', is_array($manual) && ($manual['compositor'] ?? '') === 'Someone');
|
||||
$this->test('Unit: getScoreInfo manual nothing returns null', $read->invoke($scanner, '008') === null);
|
||||
|
||||
// uploadPdf branches (copy path, no uploaded flag)
|
||||
$file = ['tmp_name' => $base . '001/1/cla/1/clarinette-sib-1.pdf', 'name' => 'x.pdf', 'error' => UPLOAD_ERR_OK, 'size' => 100];
|
||||
$this->test('Unit: upload invalid score id', $scanner->uploadPdf('../x', $file, '1', 'dir', '1')['success'] === false);
|
||||
$this->test('Unit: upload unknown score', $scanner->uploadPdf('999', $file, '1', 'dir', '1')['success'] === false);
|
||||
$this->test('Unit: upload invalid instrument', $scanner->uploadPdf('100', $file, '1', '..', '1')['success'] === false);
|
||||
$this->test('Unit: upload invalid extension', $scanner->uploadPdf('100', ['tmp_name' => $base . '001/1/cla/1/readme.txt', 'name' => 'r.txt', 'error' => UPLOAD_ERR_OK, 'size' => 1], '1', 'dir', '1')['success'] === false);
|
||||
$this->test('Unit: upload bad header', $scanner->uploadPdf('100', ['tmp_name' => $base . '001/1/cla/1/readme.txt', 'name' => 'r.pdf', 'error' => UPLOAD_ERR_OK, 'size' => 1], '1', 'dir', '1')['success'] === false);
|
||||
$weird = $base . '001/1/cla/1/weird.txt';
|
||||
$this->test('Unit: upload pdf with wrong extension', $scanner->uploadPdf('100', ['tmp_name' => $weird, 'name' => 'weird.txt', 'error' => UPLOAD_ERR_OK, 'size' => filesize($weird)], '1', 'dir', '1')['success'] === false);
|
||||
$big = ['tmp_name' => $base . '001/1/cla/1/clarinette-sib-1.pdf', 'name' => 'big.pdf', 'error' => UPLOAD_ERR_OK, 'size' => 999999999];
|
||||
$this->test('Unit: upload too large', $scanner->uploadPdf('100', $big, '1', 'dir', '1')['success'] === false);
|
||||
$okUpload = $scanner->uploadPdf('100', $file, '1', 'cla', '1', 'sib', '', 'solo', '1');
|
||||
$this->test('Unit: upload ok (copy)', $okUpload['success'] === true);
|
||||
$this->test('Unit: upload invalid version', $scanner->uploadPdf('100', $file, '1', 'dir', '..')['success'] === false);
|
||||
$this->test('Unit: upload invalid part', $scanner->uploadPdf('100', $file, '1', 'dir', '1', '', '', '', '..')['success'] === false);
|
||||
$this->test('Unit: upload invalid key', $scanner->uploadPdf('100', $file, '1', 'dir', '1', '..')['success'] === false);
|
||||
$this->test('Unit: upload invalid clef', $scanner->uploadPdf('100', $file, '1', 'dir', '1', '', '..')['success'] === false);
|
||||
$this->test('Unit: upload invalid variant', $scanner->uploadPdf('100', $file, '1', 'dir', '1', '', '', '..')['success'] === false);
|
||||
$this->test('Unit: upload no file', $scanner->uploadPdf('100', [], '1', 'dir', '1')['success'] === false);
|
||||
$this->test('Unit: upload error code', $scanner->uploadPdf('100', ['tmp_name' => $file['tmp_name'], 'name' => 'v.pdf', 'error' => UPLOAD_ERR_PARTIAL, 'size' => 1], '1', 'dir', '1')['success'] === false);
|
||||
$this->test('Unit: upload with clef ok', $scanner->uploadPdf('100', $file, '1', 'flu', '1', 'ut', 'clesol')['success'] === true);
|
||||
$wmPdf = $base . 'wm_src.pdf';
|
||||
$this->makePdf($wmPdf, 2);
|
||||
$wmFile = ['tmp_name' => $wmPdf, 'name' => 'w.pdf', 'error' => UPLOAD_ERR_OK, 'size' => filesize($wmPdf)];
|
||||
$wmUp = $scanner->uploadPdf('100', $wmFile, '1', 'cor', '1', '', '', '', '1', true, 'left');
|
||||
$this->test('Unit: uploadPdf watermark left ok', $wmUp['success'] === true);
|
||||
file_put_contents($base . 'bad.pdf', '%PDF not a real pdf document');
|
||||
$badWm = ['tmp_name' => $base . 'bad.pdf', 'name' => 'bad.pdf', 'error' => UPLOAD_ERR_OK, 'size' => 26];
|
||||
$this->test('Unit: uploadPdf watermark failure', $scanner->uploadPdf('100', $badWm, '1', 'dir', '1', '', '', '', '1', true, 'left')['success'] === false);
|
||||
@chmod($base . '100/1', 0555);
|
||||
$this->test('Unit: upload mkdir failure', $scanner->uploadPdf('100', $file, '1', 'xx', '1')['success'] === false);
|
||||
@chmod($base . '100/1', 0755);
|
||||
@chmod($base . '100/1/cor/1', 0555);
|
||||
$this->test('Unit: uploadPdf watermark copy failure', $scanner->uploadPdf('100', $wmFile, '1', 'cor', '1', '', '', '', '1', true, 'left')['success'] === false);
|
||||
@chmod($base . '100/1/cor/1', 0755);
|
||||
@chmod($base . '100/1/flu/1', 0555);
|
||||
$this->test('Unit: upload copy failure', $scanner->uploadPdf('100', $file, '1', 'flu', '1')['success'] === false);
|
||||
@chmod($base . '100/1/flu/1', 0755);
|
||||
@mkdir($base . '100/1/dir/1/direction_1.pdf', 0777, true);
|
||||
$this->test('Unit: uploadPdf rename failure', $scanner->uploadPdf('100', $wmFile, '1', 'dir', '1', '', '', '', '1', true, 'left')['success'] === false);
|
||||
@rmdir($base . '100/1/dir/1/direction_1.pdf');
|
||||
$this->test('Unit: getScoreFiles unknown', $scanner->getScoreFiles('999')['success'] === false);
|
||||
$this->test('Unit: getScoreFiles ok', $scanner->getScoreFiles('100')['success'] === true);
|
||||
|
||||
// deleteScoreFile branches
|
||||
$this->test('Unit: deleteFile unknown score', $scanner->deleteScoreFile('999', 'a.pdf')['success'] === false);
|
||||
$this->test('Unit: deleteFile traversal', $scanner->deleteScoreFile('100', '../../etc/passwd')['success'] === false);
|
||||
$this->test('Unit: deleteFile non-pdf', $scanner->deleteScoreFile('100', 'score.ini')['success'] === false);
|
||||
$this->test('Unit: deleteFile missing', $scanner->deleteScoreFile('100', '1/cla/1/none.pdf')['success'] === false);
|
||||
$rel = '1/cla/1/' . basename($okUpload['path']);
|
||||
$delFile = $scanner->deleteScoreFile('100', $rel);
|
||||
$this->test('Unit: deleteFile ok', $delFile['success'] === true);
|
||||
@symlink('/etc/hostname', $base . '100/evil.pdf');
|
||||
$this->test('Unit: deleteFile escaped symlink', $scanner->deleteScoreFile('100', 'evil.pdf')['success'] === false);
|
||||
$this->test('Unit: deleteFile null byte', $scanner->deleteScoreFile('100', "bad\0.pdf")['success'] === false);
|
||||
@mkdir($base . '100/1/cla/1/subdir', 0777, true);
|
||||
$this->test('Unit: deleteFile directory', $scanner->deleteScoreFile('100', '1/cla/1/subdir')['success'] === false);
|
||||
@chmod($base . '100/1/flu/1', 0555);
|
||||
$this->test('Unit: deleteFile unlink failure', $scanner->deleteScoreFile('100', '1/flu/1/flute_ut_clesol_1.pdf')['success'] === false);
|
||||
@chmod($base . '100/1/flu/1', 0755);
|
||||
|
||||
// deleteScore + deleteDirectory
|
||||
$this->test('Unit: deleteScore unknown', $scanner->deleteScore('999')['success'] === false);
|
||||
$this->test('Unit: deleteScore ok', $scanner->deleteScore('100')['success'] === true);
|
||||
|
||||
// addWatermark multi-page (via reflection)
|
||||
$multi = $base . 'wm_multi.pdf';
|
||||
$this->makePdf($multi, 2);
|
||||
$wm = $ref->getMethod('addWatermark');
|
||||
$wm->setAccessible(true);
|
||||
$this->test('Unit: addWatermark multi-page returns true', $wm->invoke($scanner, $multi, '102', 'right') === true);
|
||||
$wmBad = $base . 'wm_bad.pdf';
|
||||
file_put_contents($wmBad, 'not a pdf');
|
||||
$this->test('Unit: addWatermark invalid returns false', $wm->invoke($scanner, $wmBad, '102', 'left') === false);
|
||||
|
||||
// Auth branches
|
||||
$authOk = new Auth();
|
||||
$login = $authOk->login('admin', 'password');
|
||||
$this->test('Unit: auth login ok', !empty($login['token']));
|
||||
$this->test('Unit: auth login wrong password', $authOk->login('admin', 'nope')['success'] === false);
|
||||
$this->test('Unit: auth login unknown user', $authOk->login('ghost', 'x')['success'] === false);
|
||||
$token = $login['token'];
|
||||
$this->test('Unit: verifyToken ok', is_array($authOk->verifyToken($token)));
|
||||
$this->test('Unit: verifyToken garbage', $authOk->verifyToken('a.b.c') === null);
|
||||
$this->test('Unit: verifyToken wrong parts', $authOk->verifyToken('onlyone') === null);
|
||||
$parts = explode('.', $token);
|
||||
$this->test('Unit: verifyToken bad signature', $authOk->verifyToken($parts[0] . '.' . $parts[1] . '.AAAA') === null);
|
||||
// alg=none header
|
||||
$algNone = rtrim(strtr(base64_encode(json_encode(['alg' => 'none', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
||||
$this->test('Unit: verifyToken rejects alg none', $authOk->verifyToken($algNone . '.' . $parts[1] . '.' . $parts[2]) === null);
|
||||
// missing exp / expired payload signed with the real secret
|
||||
$secret = getenv('JWT_SECRET') ?: ($_ENV['JWT_SECRET'] ?? '');
|
||||
$mk = function ($payload) use ($secret) {
|
||||
$h = rtrim(strtr(base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
||||
$p = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '=');
|
||||
$s = rtrim(strtr(base64_encode(hash_hmac('sha256', "$h.$p", $secret, true)), '+/', '-_'), '=');
|
||||
return "$h.$p.$s";
|
||||
};
|
||||
$this->test('Unit: verifyToken missing exp', $authOk->verifyToken($mk(['username' => 'a'])) === null);
|
||||
$this->test('Unit: verifyToken expired', $authOk->verifyToken($mk(['username' => 'a', 'exp' => time() - 10])) === null);
|
||||
$this->test('Unit: verifyToken non-array payload', $authOk->verifyToken($mk('scalar')) === null);
|
||||
// missing users file
|
||||
$authNoUsers = new Auth($base . 'no_such_users.json');
|
||||
$this->test('Unit: auth missing users file', $authNoUsers->login('x', 'y')['success'] === false);
|
||||
// missing secret
|
||||
$savedSecret = getenv('JWT_SECRET');
|
||||
putenv('JWT_SECRET=');
|
||||
unset($_ENV['JWT_SECRET']);
|
||||
try {
|
||||
new Auth();
|
||||
$this->test('Unit: auth throws without secret', false);
|
||||
} catch (Throwable $e) {
|
||||
$this->test('Unit: auth throws without secret', true);
|
||||
}
|
||||
if ($savedSecret !== false) {
|
||||
putenv('JWT_SECRET=' . $savedSecret);
|
||||
$_ENV['JWT_SECRET'] = $savedSecret;
|
||||
}
|
||||
|
||||
$this->rrmdir($base);
|
||||
}
|
||||
|
||||
public function testGeneralRateLimit() {
|
||||
$this->section("Security - General Rate Limit");
|
||||
array_map('unlink', glob(sys_get_temp_dir() . '/rate_*'));
|
||||
|
||||
$got429 = false;
|
||||
for ($i = 0; $i < 520; $i++) {
|
||||
$r = $this->request('GET', 'does/not/exist', null, false);
|
||||
if ($r['code'] === 429) {
|
||||
$got429 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->test('General rate limit returns 429', $got429);
|
||||
|
||||
array_map('unlink', glob(sys_get_temp_dir() . '/rate_*'));
|
||||
}
|
||||
|
||||
public function summary() {
|
||||
echo "\n=== Summary ===\n";
|
||||
echo "Passed: {$this->passed}\n";
|
||||
@@ -680,5 +1095,10 @@ $tests->testFileUploadSecurity();
|
||||
$tests->testInjectionAttacks();
|
||||
$tests->testHttpsEnforcement();
|
||||
|
||||
// Extended functional + unit coverage
|
||||
$tests->testMoreEndpoints();
|
||||
$tests->testGeneralRateLimit();
|
||||
$tests->testUnits();
|
||||
|
||||
// Summary
|
||||
$tests->summary();
|
||||
|
||||
+1
-3
@@ -35,8 +35,6 @@ cd $DEPLOY_DIR/partitions
|
||||
rm -rf build
|
||||
npm run build
|
||||
|
||||
# Copy .mjs to .js for correct MIME type
|
||||
cp build/pdf.worker.min.mjs build/pdf.worker.min.js 2>/dev/null || echo "pdf.worker.min.mjs not found"
|
||||
cd ../..
|
||||
|
||||
# 5. Copy built frontend as 'frontend' directory (not symlink, for zip compatibility)
|
||||
@@ -50,7 +48,7 @@ cp partitions/nginx.conf $DEPLOY_DIR/frontend/nginx.conf 2>/dev/null || echo "ng
|
||||
# 7. Create zip (without tests.php and legacy)
|
||||
echo "[7/8] Creating zip..."
|
||||
cd $DEPLOY_DIR
|
||||
zip -r ../_builds/deploy_ohmj.zip api frontend -x "*.DS_Store" "node_modules/*" ".svelte-kit/*" "api/tests.php"
|
||||
zip -r ../_builds/deploy_ohmj.zip api frontend -x "*.DS_Store" "node_modules/*" ".svelte-kit/*" "api/tests.php" "api/coverage-prepend.php" "api/coverage-report.php"
|
||||
|
||||
# 8. Cleanup
|
||||
echo "[8/8] Cleaning up..."
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
@@ -10,15 +10,15 @@ server {
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# Cache static assets (including .mjs)
|
||||
location ~* \.(js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# PDF.js worker: serve the tracked .mjs with a JS MIME type
|
||||
location = /pdf.worker.min.mjs {
|
||||
default_type application/javascript;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Force correct MIME type for .mjs files
|
||||
location ~* \.mjs$ {
|
||||
add_header Content-Type application/javascript;
|
||||
# Cache static assets
|
||||
location ~* \.(js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
Generated
+406
-230
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test:e2e": "node tests/e2e-smoke.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
@@ -14,13 +16,16 @@
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"lucide-svelte": "^0.574.0",
|
||||
"pdfjs-dist": "^4.0.379"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.21.5": true
|
||||
}
|
||||
}
|
||||
|
||||
+16
-18
@@ -6,7 +6,12 @@ import { get } from 'svelte/store';
|
||||
const API_BASE_URL_LOCAL = 'http://localhost:8000';
|
||||
const API_BASE_URL_PROD = 'https://ohmj-api.c.nadal-fr.com';
|
||||
|
||||
const API_BASE_URL = import.meta.env.DEV ? API_BASE_URL_LOCAL : API_BASE_URL_PROD;
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_URL || (import.meta.env.DEV ? API_BASE_URL_LOCAL : API_BASE_URL_PROD);
|
||||
|
||||
function encodePath(path: string): string {
|
||||
return path.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -30,9 +35,13 @@ api.interceptors.response.use(
|
||||
(error: AxiosError) => {
|
||||
// Only logout on actual 401 from the server, not network/CORS errors
|
||||
if (error.response?.status === 401 && !error.message?.includes('Network Error')) {
|
||||
auth.logout();
|
||||
if (browser) {
|
||||
window.location.href = '/';
|
||||
const url = error.config?.url || '';
|
||||
console.error('[API 401]', url, error.response?.data);
|
||||
if (!url.includes('/login')) {
|
||||
auth.logout();
|
||||
if (browser) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
@@ -104,26 +113,15 @@ export const apiService = {
|
||||
},
|
||||
|
||||
async downloadPdf(path: string): Promise<Blob> {
|
||||
const response = await api.get(`/download/${path}`, {
|
||||
const response = await api.get(`/download/${encodePath(path)}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getDownloadUrl(path: string): string {
|
||||
// Pass token in URL for direct browser access (PDF viewer, iframe, etc.)
|
||||
// Safe over HTTPS
|
||||
const authState = get(auth);
|
||||
const token = authState.token ? encodeURIComponent(authState.token) : '';
|
||||
return `${API_BASE_URL}/download/${path}?token=${token}`;
|
||||
},
|
||||
|
||||
// New method to download with proper auth header
|
||||
async downloadFileWithAuth(path: string): Promise<Blob> {
|
||||
const response = await api.get(`/download/${path}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
return response.data;
|
||||
// Full URL used by the PDF viewer; auth is sent via the axios interceptor
|
||||
return `${API_BASE_URL}/download/${encodePath(path)}`;
|
||||
},
|
||||
|
||||
async createScore(name: string, compositor: string, pieces: { number: number; name: string }[] = []): Promise<{ success: boolean; score?: any; error?: string }> {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
confirmLabel?: string;
|
||||
busy?: boolean;
|
||||
onconfirm: () => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open,
|
||||
title,
|
||||
message = '',
|
||||
detail = '',
|
||||
confirmLabel = 'Supprimer',
|
||||
busy = false,
|
||||
onconfirm,
|
||||
oncancel
|
||||
}: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') oncancel();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 w-full h-full bg-black bg-opacity-50 cursor-default"
|
||||
aria-label="Fermer"
|
||||
onclick={oncancel}
|
||||
></button>
|
||||
<div
|
||||
class="relative bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
>
|
||||
<h3 id="confirm-dialog-title" class="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
{#if message}
|
||||
<p class="text-gray-600 mb-6">{message}</p>
|
||||
{/if}
|
||||
{#if detail}
|
||||
<p class="text-sm text-gray-500 mb-6 font-mono bg-gray-100 p-2 rounded">{detail}</p>
|
||||
{/if}
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={oncancel}
|
||||
disabled={busy}
|
||||
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onconfirm}
|
||||
disabled={busy}
|
||||
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Suppression...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,27 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
|
||||
|
||||
interface Props {
|
||||
pdfUrl: string;
|
||||
title?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
let { pdfUrl, title = 'Partition', key = '' }: Props = $props();
|
||||
let { pdfUrl, title = 'Partition' }: Props = $props();
|
||||
|
||||
// Force re-render when key changes
|
||||
$effect(() => {
|
||||
if (key) {
|
||||
loadPdf();
|
||||
}
|
||||
});
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let canvas = $state<HTMLCanvasElement>();
|
||||
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
|
||||
let objectUrl: string | null = null;
|
||||
let currentPage = $state(1);
|
||||
let totalPages = $state(0);
|
||||
let scale = $state(1.2);
|
||||
@@ -33,29 +26,41 @@
|
||||
async function loadPdf() {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
// Clean up any previous document / object URL
|
||||
if (pdfDoc) {
|
||||
pdfDoc.destroy();
|
||||
pdfDoc = null;
|
||||
}
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// pdfUrl already contains the full URL with token
|
||||
// Use it directly with axios
|
||||
const response = await api.get(pdfUrl, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const loadingTask = pdfjsLib.getDocument(url);
|
||||
pdfDoc = await loadingTask.promise;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
pdfDoc = await pdfjsLib.getDocument(objectUrl).promise;
|
||||
totalPages = pdfDoc.numPages;
|
||||
currentPage = 1;
|
||||
|
||||
// The canvas only exists once `loading` is false: render after mount
|
||||
loading = false;
|
||||
await tick();
|
||||
await renderPage(currentPage);
|
||||
} catch (err) {
|
||||
console.error('Error loading PDF:', err);
|
||||
error = 'Erreur lors du chargement du PDF';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage(pageNum: number) {
|
||||
if (!pdfDoc || rendering) return;
|
||||
if (!pdfDoc || !canvas || rendering) return;
|
||||
rendering = true;
|
||||
|
||||
try {
|
||||
@@ -118,7 +123,6 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPdf();
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
@@ -127,6 +131,9 @@
|
||||
if (pdfDoc) {
|
||||
pdfDoc.destroy();
|
||||
}
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface InstrumentInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
defaultKey: string;
|
||||
}
|
||||
|
||||
export const INSTRUMENTS: InstrumentInfo[] = [
|
||||
{ code: 'dir', name: 'Direction', icon: '🎼', defaultKey: '' },
|
||||
{ code: 'pic', name: 'Piccolo', icon: '/icons/piccolo.png', defaultKey: 'do' },
|
||||
{ code: 'flu', name: 'Flûte', icon: '/icons/flute.png', defaultKey: 'do' },
|
||||
{ code: 'cla', name: 'Clarinette', icon: '/icons/clarinette.png', defaultKey: 'sib' },
|
||||
{ code: 'clb', name: 'Clarinette Basse', icon: '/icons/clarinette_basse.png', defaultKey: 'sib' },
|
||||
{ code: 'sax', name: 'Sax Alto', icon: '/icons/sax-alto.png', defaultKey: 'mib' },
|
||||
{ code: 'sat', name: 'Sax Ténor', icon: '/icons/sax-tenor.png', defaultKey: 'sib' },
|
||||
{ code: 'sab', name: 'Sax Baryton', icon: '/icons/sax-barython.png', defaultKey: 'mib' },
|
||||
{ code: 'coa', name: 'Cor Anglais', icon: '/icons/cor-anglais.jpg', defaultKey: 'fa' },
|
||||
{ code: 'cba', name: 'Contrebasse', icon: '/icons/contrebasse.png', defaultKey: 'sib' },
|
||||
{ code: 'cor', name: 'Cor', icon: '/icons/cor.png', defaultKey: 'fa' },
|
||||
{ code: 'trp', name: 'Trompette', icon: '/icons/trumpet.png', defaultKey: 'sib' },
|
||||
{ code: 'crn', name: 'Cornet', icon: '/icons/cornet.png', defaultKey: 'sib' },
|
||||
{ code: 'trb', name: 'Trombone', icon: '/icons/tronbonne.png', defaultKey: 'do' },
|
||||
{ code: 'eup', name: 'Euphonium', icon: '/icons/euphonium.png', defaultKey: 'sib' },
|
||||
{ code: 'tub', name: 'Tuba', icon: '/icons/tuba.png', defaultKey: 'do' },
|
||||
{ code: 'htb', name: 'Hautbois', icon: '/icons/hautbois.png', defaultKey: 'do' },
|
||||
{ code: 'bas', name: 'Basson', icon: '/icons/basson.png', defaultKey: 'do' },
|
||||
{ code: 'per', name: 'Percussions', icon: '/icons/percussion.png', defaultKey: '' },
|
||||
{ code: 'pia', name: 'Piano', icon: '🎹', defaultKey: '' },
|
||||
{ code: 'har', name: 'Harpe', icon: '/icons/harpe.png', defaultKey: '' },
|
||||
{ code: 'sup', name: 'Parties supplémentaires', icon: '📄', defaultKey: '' },
|
||||
{ code: 'par', name: 'Parties', icon: '📄', defaultKey: '' }
|
||||
];
|
||||
|
||||
export const INSTRUMENT_NAMES: Record<string, string> = Object.fromEntries(
|
||||
INSTRUMENTS.map((i) => [i.code, i.name])
|
||||
);
|
||||
|
||||
export const INSTRUMENT_ICONS: Record<string, string> = Object.fromEntries(
|
||||
INSTRUMENTS.map((i) => [i.code, i.icon])
|
||||
);
|
||||
|
||||
export function getInstrumentName(code: string): string {
|
||||
return INSTRUMENT_NAMES[code] ?? code;
|
||||
}
|
||||
|
||||
export function getInstrumentIcon(code: string): string {
|
||||
return INSTRUMENT_ICONS[code] ?? '🎵';
|
||||
}
|
||||
|
||||
export function getInstrument(code: string): InstrumentInfo | undefined {
|
||||
return INSTRUMENTS.find((i) => i.code === code);
|
||||
}
|
||||
|
||||
export function getDefaultKey(code: string): string {
|
||||
return getInstrument(code)?.defaultKey ?? '';
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function createAuthStore() {
|
||||
}
|
||||
}
|
||||
|
||||
const { subscribe, set, update } = writable<AuthState>(initial);
|
||||
const { subscribe, set } = writable<AuthState>(initial);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
@@ -56,11 +56,6 @@ function createAuthStore() {
|
||||
if (browser) {
|
||||
localStorage.removeItem('auth');
|
||||
}
|
||||
},
|
||||
getToken: () => {
|
||||
let token: string | null = null;
|
||||
update((s) => (token = s.token));
|
||||
return token;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,40 +2,21 @@
|
||||
import '../app.css';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const publicRoutes = ['/'];
|
||||
|
||||
let isAuthenticated = false;
|
||||
let currentPath = '/';
|
||||
let userRole = '';
|
||||
let isAuthenticated = $derived(!!$auth.token);
|
||||
let userRole = $derived($auth.user?.role || '');
|
||||
let currentPath = $derived(page.url.pathname);
|
||||
|
||||
auth.subscribe((state) => {
|
||||
isAuthenticated = !!state.token;
|
||||
userRole = state.user?.role || '';
|
||||
});
|
||||
|
||||
page.subscribe(($page) => {
|
||||
currentPath = $page.url.pathname;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = page.subscribe(($page) => {
|
||||
const path = $page.url.pathname;
|
||||
const isPublic = publicRoutes.includes(path);
|
||||
|
||||
// Get current auth state directly
|
||||
const authState = get(auth);
|
||||
const authenticated = !!authState.token;
|
||||
|
||||
if (!isPublic && !authenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
$effect(() => {
|
||||
const isPublic = publicRoutes.includes(currentPath);
|
||||
if (!isPublic && !isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
function logout() {
|
||||
@@ -72,7 +53,7 @@
|
||||
{/if}
|
||||
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
{#if isAuthenticated && currentPath !== '/'}
|
||||
|
||||
@@ -3,119 +3,34 @@
|
||||
import { apiService, type Score } from '$lib/api';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const INSTRUMENTS = [
|
||||
{ code: 'dir', name: 'Direction', defaultKey: '' },
|
||||
{ code: 'pic', name: 'Piccolo', defaultKey: 'do' },
|
||||
{ code: 'flu', name: 'Flûte', defaultKey: 'do' },
|
||||
{ code: 'cla', name: 'Clarinette', defaultKey: 'sib' },
|
||||
{ code: 'clb', name: 'Clarinette Basse', defaultKey: 'sib' },
|
||||
{ code: 'sax', name: 'Saxophone Alto', defaultKey: 'mib' },
|
||||
{ code: 'sat', name: 'Saxophone Ténor', defaultKey: 'sib' },
|
||||
{ code: 'sab', name: 'Saxophone Baryton', defaultKey: 'mib' },
|
||||
{ code: 'coa', name: 'Cor Anglais', defaultKey: 'fa' },
|
||||
{ code: 'htb', name: 'Hautbois', defaultKey: 'do' },
|
||||
{ code: 'bas', name: 'Basson', defaultKey: 'do' },
|
||||
{ code: 'cor', name: 'Cor', defaultKey: 'fa' },
|
||||
{ code: 'trp', name: 'Trompette', defaultKey: 'sib' },
|
||||
{ code: 'crn', name: 'Cornet', defaultKey: 'sib' },
|
||||
{ code: 'trb', name: 'Trombone', defaultKey: 'do' },
|
||||
{ code: 'eup', name: 'Euphonium', defaultKey: 'sib' },
|
||||
{ code: 'tub', name: 'Tuba', defaultKey: 'do' },
|
||||
{ code: 'cba', name: 'Contrebasse', defaultKey: 'sib' },
|
||||
{ code: 'per', name: 'Percussions', defaultKey: '' },
|
||||
{ code: 'pia', name: 'Piano', defaultKey: '' },
|
||||
{ code: 'har', name: 'Harpe', defaultKey: '' }
|
||||
];
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
let scores: Score[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let userRole = $state('');
|
||||
let userRole = $derived($auth.user?.role || '');
|
||||
let isAdmin = $derived(userRole === 'admin');
|
||||
|
||||
// Form for new score
|
||||
let showForm = $state(false);
|
||||
let newName = $state('');
|
||||
let newCompositor = $state('');
|
||||
let newRessource = $state('');
|
||||
let newPieceCount = $state(1);
|
||||
let newPieceNames = $state<string[]>(['']);
|
||||
let saving = $state(false);
|
||||
|
||||
// Upload form
|
||||
let uploadScoreId = $state('');
|
||||
let uploadPiece = $state('1');
|
||||
let uploadInstrument = $state('');
|
||||
let uploadVersion = $state('1');
|
||||
let uploadFile: File | null = $state(null);
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state('');
|
||||
let uploadSuccess = $state('');
|
||||
let selectedScorePieceCount = $state(1);
|
||||
let selectedScorePieces = $state<{id: string; name: string}[]>([]);
|
||||
let showAdvanced = $state(false);
|
||||
let deleteConfirmId = $state<string | null>(null);
|
||||
let uploadKey = $state('');
|
||||
let uploadClef = $state('');
|
||||
let uploadVariant = $state('');
|
||||
let uploadPart = $state('1');
|
||||
let uploadWatermark = $state(false);
|
||||
|
||||
function handlePieceCountChange(count: number) {
|
||||
newPieceCount = count;
|
||||
newPieceNames = Array(count).fill('').map((_, i) => newPieceNames[i] || '');
|
||||
}
|
||||
|
||||
// When score is selected, get piece count
|
||||
async function handleScoreSelect(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
uploadScoreId = target.value;
|
||||
if (uploadScoreId) {
|
||||
try {
|
||||
const pieces = await apiService.getPieces(uploadScoreId);
|
||||
selectedScorePieceCount = pieces.length;
|
||||
selectedScorePieces = pieces.map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name || `Partie ${p.id}`
|
||||
}));
|
||||
if (selectedScorePieceCount === 1) {
|
||||
uploadPiece = '1';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
selectedScorePieceCount = 1;
|
||||
selectedScorePieces = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getExpectedFilename(): string {
|
||||
if (!uploadInstrument) return '';
|
||||
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
|
||||
const instName = inst ? inst.name.toLowerCase().replace(/ /g, '_') : uploadInstrument;
|
||||
const key = uploadKey || inst?.defaultKey || '';
|
||||
const keyStr = key ? `_${key}` : '';
|
||||
const clef = uploadClef ? `_${uploadClef}` : '';
|
||||
const variant = uploadVariant ? `_${uploadVariant}` : '';
|
||||
return `${instName}${variant}${keyStr}${clef}_${uploadPart}.pdf`;
|
||||
}
|
||||
|
||||
let expectedFilename = $derived(getExpectedFilename());
|
||||
|
||||
onMount(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const unsubscribe = auth.subscribe((state) => {
|
||||
userRole = state.user?.role || '';
|
||||
});
|
||||
|
||||
if (userRole !== 'admin') {
|
||||
unsubscribe();
|
||||
goto('/scores');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await loadScores();
|
||||
});
|
||||
|
||||
@@ -146,7 +61,6 @@
|
||||
showForm = false;
|
||||
newName = '';
|
||||
newCompositor = '';
|
||||
newRessource = '';
|
||||
newPieceCount = 1;
|
||||
newPieceNames = [''];
|
||||
await loadScores();
|
||||
@@ -174,48 +88,6 @@
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
uploadFile = target.files[0];
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPdf() {
|
||||
if (!uploadFile || !uploadInstrument || !uploadScoreId) {
|
||||
uploadError = 'Veuillez remplir tous les champs';
|
||||
return;
|
||||
}
|
||||
uploading = true;
|
||||
uploadError = '';
|
||||
uploadSuccess = '';
|
||||
try {
|
||||
const result = await apiService.uploadPdf(
|
||||
uploadScoreId,
|
||||
uploadFile,
|
||||
uploadPiece,
|
||||
uploadInstrument,
|
||||
uploadVersion,
|
||||
uploadKey,
|
||||
uploadClef,
|
||||
uploadVariant,
|
||||
uploadPart,
|
||||
uploadWatermark
|
||||
);
|
||||
if (result.success) {
|
||||
uploadSuccess = 'Fichier uploadé avec succès!';
|
||||
uploadFile = null;
|
||||
} else {
|
||||
uploadError = result.error || 'Erreur';
|
||||
}
|
||||
} catch (err) {
|
||||
uploadError = 'Erreur lors de l\'upload';
|
||||
console.error(err);
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -263,8 +135,9 @@
|
||||
<div class="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<form onsubmit={(e) => { e.preventDefault(); createScore(); }} class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nom</label>
|
||||
<label for="new-name" class="block text-sm font-medium text-gray-700">Nom</label>
|
||||
<input
|
||||
id="new-name"
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
required
|
||||
@@ -273,8 +146,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Compositeur</label>
|
||||
<label for="new-compositor" class="block text-sm font-medium text-gray-700">Compositeur</label>
|
||||
<input
|
||||
id="new-compositor"
|
||||
type="text"
|
||||
bind:value={newCompositor}
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-ohmj-primary focus:border-ohmj-primary"
|
||||
@@ -282,8 +156,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nombre de parties</label>
|
||||
<label for="new-piece-count" class="block text-sm font-medium text-gray-700">Nombre de parties</label>
|
||||
<select
|
||||
id="new-piece-count"
|
||||
value={newPieceCount}
|
||||
onchange={(e) => handlePieceCountChange(parseInt((e.target as HTMLSelectElement).value))}
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||
@@ -294,17 +169,18 @@
|
||||
</select>
|
||||
</div>
|
||||
{#if newPieceCount > 1}
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Nom des parties</label>
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="block text-sm font-medium text-gray-700">Nom des parties</legend>
|
||||
{#each newPieceNames as pieceName, i}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPieceNames[i]}
|
||||
aria-label="Nom de la partie {i + 1}"
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-ohmj-primary focus:border-ohmj-primary"
|
||||
placeholder="Partie {i + 1}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
@@ -320,12 +196,12 @@
|
||||
|
||||
<!-- List of scores -->
|
||||
<div class="bg-white rounded-lg shadow overflow-x-auto">
|
||||
<table class="w-full min-w-[700px]">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap w-16">ID</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">Nom</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">Compositeur</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nom</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Compositeur</th>
|
||||
<th class="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase whitespace-nowrap w-24">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -333,8 +209,8 @@
|
||||
{#each scores as score}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{score.id}</td>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-700">{score.name}</td>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500">{score.compositor || '-'}</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-700">{score.name}</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500">{score.compositor || '-'}</td>
|
||||
<td class="px-3 py-4 whitespace-nowrap text-right text-sm">
|
||||
<div class="flex justify-end gap-3">
|
||||
<a
|
||||
@@ -361,32 +237,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if deleteConfirmId}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onclick={() => deleteConfirmId = null}>
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onclick={(e) => e.stopPropagation()}>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Confirmer la suppression</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Êtes-vous sûr de vouloir supprimer cette partition ? Cette action est irréversible et supprimera également tous les fichiers associés.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={() => deleteConfirmId = null}
|
||||
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onclick={async () => {
|
||||
const id = deleteConfirmId;
|
||||
deleteConfirmId = null;
|
||||
if (id) await deleteScore(id);
|
||||
}}
|
||||
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded transition-colors"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<ConfirmDialog
|
||||
open={!!deleteConfirmId}
|
||||
title="Confirmer la suppression"
|
||||
message="Êtes-vous sûr de vouloir supprimer cette partition ? Cette action est irréversible et supprimera également tous les fichiers associés."
|
||||
onconfirm={async () => {
|
||||
const id = deleteConfirmId;
|
||||
deleteConfirmId = null;
|
||||
if (id) await deleteScore(id);
|
||||
}}
|
||||
oncancel={() => (deleteConfirmId = null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,42 +3,23 @@
|
||||
import { apiService } from '$lib/api';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { INSTRUMENTS, getInstrument } from '$lib/instruments';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
const INSTRUMENTS = [
|
||||
{ code: 'dir', name: 'Direction', defaultKey: '' },
|
||||
{ code: 'pic', name: 'Piccolo', defaultKey: 'do' },
|
||||
{ code: 'flu', name: 'Flûte', defaultKey: 'do' },
|
||||
{ code: 'cla', name: 'Clarinette', defaultKey: 'sib' },
|
||||
{ code: 'clb', name: 'Clarinette Basse', defaultKey: 'sib' },
|
||||
{ code: 'sax', name: 'Saxophone Alto', defaultKey: 'mib' },
|
||||
{ code: 'sat', name: 'Saxophone Ténor', defaultKey: 'sib' },
|
||||
{ code: 'sab', name: 'Saxophone Baryton', defaultKey: 'mib' },
|
||||
{ code: 'coa', name: 'Cor Anglais', defaultKey: 'fa' },
|
||||
{ code: 'htb', name: 'Hautbois', defaultKey: 'do' },
|
||||
{ code: 'bas', name: 'Basson', defaultKey: 'do' },
|
||||
{ code: 'cor', name: 'Cor', defaultKey: 'fa' },
|
||||
{ code: 'trp', name: 'Trompette', defaultKey: 'sib' },
|
||||
{ code: 'crn', name: 'Cornet', defaultKey: 'sib' },
|
||||
{ code: 'trb', name: 'Trombone', defaultKey: 'do' },
|
||||
{ code: 'eup', name: 'Euphonium', defaultKey: 'sib' },
|
||||
{ code: 'tub', name: 'Tuba', defaultKey: 'do' },
|
||||
{ code: 'cba', name: 'Contrebasse', defaultKey: 'sib' },
|
||||
{ code: 'per', name: 'Percussions', defaultKey: '' },
|
||||
{ code: 'pia', name: 'Piano', defaultKey: '' },
|
||||
{ code: 'har', name: 'Harpe', defaultKey: '' },
|
||||
{ code: 'sup', name: 'Parties supplémentaires', defaultKey: '' }
|
||||
];
|
||||
|
||||
let scoreId = $derived($page.params.id || '');
|
||||
let scoreId = $derived(page.params.id || '');
|
||||
let scoreName = $state('');
|
||||
let scoreCompositor = $state('');
|
||||
let scoreRessource = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let userRole = $state('');
|
||||
let userRole = $derived($auth.user?.role || '');
|
||||
let isAdmin = $derived(userRole === 'admin');
|
||||
|
||||
function focusOnMount(node: HTMLElement) {
|
||||
node.focus();
|
||||
}
|
||||
|
||||
// Edit mode
|
||||
let editingField = $state<string | null>(null);
|
||||
let editValue = $state('');
|
||||
@@ -88,7 +69,7 @@
|
||||
// Auto-set default key when instrument changes
|
||||
$effect(() => {
|
||||
if (uploadInstrument) {
|
||||
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
|
||||
const inst = getInstrument(uploadInstrument);
|
||||
if (inst?.defaultKey) {
|
||||
uploadKey = inst.defaultKey;
|
||||
}
|
||||
@@ -147,7 +128,7 @@
|
||||
|
||||
function getExpectedFilename(): string {
|
||||
if (!uploadInstrument) return '';
|
||||
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
|
||||
const inst = getInstrument(uploadInstrument);
|
||||
const instName = inst ? inst.name.toLowerCase().replace(/ /g, '_') : uploadInstrument;
|
||||
const key = uploadKey || inst?.defaultKey || '';
|
||||
const keyStr = key ? `_${key}` : '';
|
||||
@@ -159,18 +140,11 @@
|
||||
let expectedFilename = $derived(getExpectedFilename());
|
||||
|
||||
onMount(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const unsubscribe = auth.subscribe((state) => {
|
||||
userRole = state.user?.role || '';
|
||||
});
|
||||
|
||||
if (userRole !== 'admin') {
|
||||
unsubscribe();
|
||||
goto('/scores');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await loadScoreInfo();
|
||||
await handleScoreSelect();
|
||||
await loadFiles();
|
||||
@@ -311,12 +285,12 @@
|
||||
|
||||
<!-- Name -->
|
||||
<div class="flex items-center gap-4 py-3 border-b border-gray-100">
|
||||
<label class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Nom</label>
|
||||
<span class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Nom</span>
|
||||
{#if editingField === 'name'}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editValue}
|
||||
autofocus
|
||||
use:focusOnMount
|
||||
onkeydown={(e) => { if (e.key === 'Enter') saveEdit('name'); if (e.key === 'Escape') cancelEdit(); }}
|
||||
class="flex-1 text-xl font-bold text-ohmj-primary border-b-2 border-ohmj-primary bg-transparent px-3 py-2 focus:outline-none"
|
||||
/>
|
||||
@@ -327,9 +301,9 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="flex-1 text-xl font-bold text-ohmj-primary cursor-pointer hover:text-ohmj-secondary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('name', scoreName)} title="Cliquer pour modifier">
|
||||
<button type="button" class="flex-1 text-left text-xl font-bold text-ohmj-primary cursor-pointer hover:text-ohmj-secondary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('name', scoreName)} title="Cliquer pour modifier">
|
||||
{scoreName || 'Sans titre'}
|
||||
</span>
|
||||
</button>
|
||||
<button onclick={() => startEdit('name', scoreName)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
|
||||
</button>
|
||||
@@ -338,12 +312,12 @@
|
||||
|
||||
<!-- Compositor -->
|
||||
<div class="flex items-center gap-4 py-3 border-b border-gray-100">
|
||||
<label class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Compositeur</label>
|
||||
<span class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Compositeur</span>
|
||||
{#if editingField === 'compositor'}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editValue}
|
||||
autofocus
|
||||
use:focusOnMount
|
||||
placeholder="Compositeur"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') saveEdit('compositor'); if (e.key === 'Escape') cancelEdit(); }}
|
||||
class="flex-1 text-lg text-gray-700 border-b-2 border-ohmj-primary bg-transparent px-3 py-2 focus:outline-none"
|
||||
@@ -355,9 +329,9 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="flex-1 text-lg text-gray-700 cursor-pointer hover:text-ohmj-primary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('compositor', scoreCompositor)} title="Cliquer pour modifier">
|
||||
<button type="button" class="flex-1 text-left text-lg text-gray-700 cursor-pointer hover:text-ohmj-primary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('compositor', scoreCompositor)} title="Cliquer pour modifier">
|
||||
{scoreCompositor || 'Aucun compositeur'}
|
||||
</span>
|
||||
</button>
|
||||
<button onclick={() => startEdit('compositor', scoreCompositor)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
|
||||
</button>
|
||||
@@ -366,12 +340,12 @@
|
||||
|
||||
<!-- Ressource -->
|
||||
<div class="flex items-center gap-4 py-3">
|
||||
<label class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Ressource</label>
|
||||
<span class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Ressource</span>
|
||||
{#if editingField === 'ressource'}
|
||||
<input
|
||||
type="url"
|
||||
bind:value={editValue}
|
||||
autofocus
|
||||
use:focusOnMount
|
||||
placeholder="URL (YouTube, Google Drive, etc.)"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') saveEdit('ressource'); if (e.key === 'Escape') cancelEdit(); }}
|
||||
class="flex-1 text-sm text-blue-600 border-b-2 border-ohmj-primary bg-transparent px-3 py-2 focus:outline-none"
|
||||
@@ -384,13 +358,13 @@
|
||||
</button>
|
||||
{:else}
|
||||
{#if scoreRessource}
|
||||
<span class="flex-1 text-sm text-blue-600 cursor-pointer hover:text-blue-800 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', scoreRessource)} title="Cliquer pour modifier">
|
||||
<button type="button" class="flex-1 text-left text-sm text-blue-600 cursor-pointer hover:text-blue-800 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', scoreRessource)} title="Cliquer pour modifier">
|
||||
🔗 {scoreRessource}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="flex-1 text-sm text-gray-400 cursor-pointer hover:text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', '')} title="Cliquer pour ajouter">
|
||||
<button type="button" class="flex-1 text-left text-sm text-gray-400 cursor-pointer hover:text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', '')} title="Cliquer pour ajouter">
|
||||
Aucune ressource
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => startEdit('ressource', scoreRessource)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
|
||||
@@ -468,8 +442,9 @@
|
||||
<form onsubmit={(e) => { e.preventDefault(); uploadPdf(); }} class="space-y-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Instrument</label>
|
||||
<label for="up-instrument" class="block text-sm font-medium text-gray-700">Instrument</label>
|
||||
<select
|
||||
id="up-instrument"
|
||||
bind:value={uploadInstrument}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||
@@ -496,8 +471,9 @@
|
||||
<div class="grid grid-cols-2 md:grid-cols-7 gap-4 p-3 bg-gray-100 rounded">
|
||||
{#if selectedScorePieceCount > 1}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Pièce</label>
|
||||
<label for="up-piece" class="block text-sm font-medium text-gray-700">Pièce</label>
|
||||
<select
|
||||
id="up-piece"
|
||||
bind:value={uploadPiece}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||
@@ -509,8 +485,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Version</label>
|
||||
<label for="up-version" class="block text-sm font-medium text-gray-700">Version</label>
|
||||
<input
|
||||
id="up-version"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={uploadVersion}
|
||||
@@ -520,19 +497,19 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Tonalité</label>
|
||||
<select bind:value={uploadKey} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<label for="up-key" class="block text-sm font-medium text-gray-700">Tonalité</label>
|
||||
<select id="up-key" bind:value={uploadKey} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="">-</option>
|
||||
<option value="sib">Si♭ (sib)</option>
|
||||
<option value="mib">Mi♭ (mif)</option>
|
||||
<option value="fa">Fa (fa)</option>
|
||||
<option value="do">Do (ut)</option>
|
||||
<option value="sol">Sol</option>
|
||||
<option value="sib">Si♭ (B♭)</option>
|
||||
<option value="mib">Mi♭ (E♭)</option>
|
||||
<option value="fa">Fa (F)</option>
|
||||
<option value="do">Do (C/Ut)</option>
|
||||
<option value="sol">Sol (G)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Clé</label>
|
||||
<select bind:value={uploadClef} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<label for="up-clef" class="block text-sm font-medium text-gray-700">Clé</label>
|
||||
<select id="up-clef" bind:value={uploadClef} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="">-</option>
|
||||
<option value="clesol">Sol</option>
|
||||
<option value="clefa">Fa</option>
|
||||
@@ -540,8 +517,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Partie</label>
|
||||
<select bind:value={uploadPart} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<label for="up-part" class="block text-sm font-medium text-gray-700">Partie</label>
|
||||
<select id="up-part" bind:value={uploadPart} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
@@ -549,8 +526,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Variante</label>
|
||||
<select bind:value={uploadVariant} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<label for="up-variant" class="block text-sm font-medium text-gray-700">Variante</label>
|
||||
<select id="up-variant" bind:value={uploadVariant} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="">-</option>
|
||||
<option value="solo">Solo</option>
|
||||
<option value="default">Default</option>
|
||||
@@ -569,7 +546,7 @@
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Fichier PDF</label>
|
||||
<label for="file-input" class="block text-sm font-medium text-gray-700">Fichier PDF</label>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<label class="relative inline-flex items-center cursor-pointer whitespace-nowrap">
|
||||
<input type="checkbox" bind:checked={uploadWatermark} class="sr-only peer" />
|
||||
@@ -682,32 +659,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if deleteFilePath}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onclick={() => deleteFilePath = null}>
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onclick={(e) => e.stopPropagation()}>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Confirmer la suppression</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mb-6 font-mono bg-gray-100 p-2 rounded">
|
||||
{deleteFilePath}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={() => deleteFilePath = null}
|
||||
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onclick={deleteFile}
|
||||
disabled={deletingFile}
|
||||
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deletingFile ? 'Suppression...' : 'Supprimer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<ConfirmDialog
|
||||
open={!!deleteFilePath}
|
||||
title="Confirmer la suppression"
|
||||
message="Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
|
||||
detail={deleteFilePath ?? ''}
|
||||
busy={deletingFile}
|
||||
onconfirm={deleteFile}
|
||||
oncancel={() => (deleteFilePath = null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,70 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { apiService, type Score, type Piece } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { apiService, type Score, type Piece, type Instrument } from '$lib/api';
|
||||
import { getInstrumentIcon, getInstrumentName } from '$lib/instruments';
|
||||
|
||||
const INSTRUMENT_ICONS: Record<string, string> = {
|
||||
dir: '🎼', pic: '/icons/piccolo.png', flu: '/icons/flute.png', cla: '/icons/clarinette.png', clb: '/icons/clarinette_basse.png',
|
||||
sax: '/icons/sax-alto.png', sab: '/icons/sax-barython.png', sat: '/icons/sax-tenor.png', coa: '/icons/cor-anglais.jpg', cba: '/icons/contrebasse.png',
|
||||
cor: '/icons/cor.png', trp: '/icons/trumpet.png', trb: '/icons/tronbonne.png', tub: '/icons/tuba.png', htb: '/icons/hautbois.png',
|
||||
bas: '/icons/basson.png', per: '/icons/percussion.png', crn: '/icons/cornet.png', eup: '/icons/euphonium.png', har: '/icons/harpe.png',
|
||||
pia: '🎹', sup: '📄', par: '📄'
|
||||
};
|
||||
|
||||
const INSTRUMENT_NAMES: Record<string, string> = {
|
||||
dir: 'Direction', pic: 'Piccolo', flu: 'Flûte', cla: 'Clarinette',
|
||||
clb: 'Clarinette Basse', sax: 'Sax Alto', sat: 'Sax Ténor', sab: 'Sax Baryton',
|
||||
coa: 'Cor Anglais', cba: 'Contrebasse', cor: 'Cor', trp: 'Trompette',
|
||||
trb: 'Trombone', tub: 'Tuba', htb: 'Hautbois', bas: 'Basson',
|
||||
per: 'Percussions', crn: 'Cornet', eup: 'Euphonium', har: 'Harpe',
|
||||
pia: 'Piano', sup: 'Parties supplementaires', par: 'Parties'
|
||||
};
|
||||
|
||||
function getInstrumentIcon(code: string): string {
|
||||
return INSTRUMENT_ICONS[code] || '🎵';
|
||||
function isSafeUrl(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url.trim());
|
||||
}
|
||||
|
||||
function getInstrumentName(code: string): string {
|
||||
return INSTRUMENT_NAMES[code] || code;
|
||||
}
|
||||
|
||||
let scoreId = '';
|
||||
let score: Score | null = null;
|
||||
let pieces: Piece[] = [];
|
||||
let instruments: any[] = [];
|
||||
let scoreId = $derived(page.params.id || '');
|
||||
let score = $state<Score | null>(null);
|
||||
let pieces = $state<Piece[]>([]);
|
||||
let instruments = $state<Instrument[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let selectedPiece = $state(1);
|
||||
|
||||
page.subscribe(($page) => {
|
||||
scoreId = $page.params.id || '';
|
||||
// Read piece from URL param or query string
|
||||
const pieceParam = $page.params.piece || $page.url.searchParams.get('piece');
|
||||
$effect(() => {
|
||||
if (!scoreId) return;
|
||||
const pieceParam = page.url.searchParams.get('piece');
|
||||
if (pieceParam) {
|
||||
selectedPiece = parseInt(pieceParam) || 1;
|
||||
}
|
||||
loadScore(scoreId);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
async function loadScore(id: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
score = await apiService.getScore(scoreId);
|
||||
pieces = await apiService.getPieces(scoreId);
|
||||
|
||||
// Set default piece to 1 if not in URL
|
||||
if (pieces.length > 0 && !pieces.find(p => p.id === selectedPiece)) {
|
||||
score = await apiService.getScore(id);
|
||||
pieces = await apiService.getPieces(id);
|
||||
// Default to the first piece if the requested one does not exist
|
||||
if (pieces.length > 0 && !pieces.find((p) => p.id === selectedPiece)) {
|
||||
selectedPiece = pieces[0].id;
|
||||
}
|
||||
|
||||
// Load instruments for the selected piece
|
||||
await loadInstrumentsForPiece(selectedPiece);
|
||||
instruments = await apiService.getInstruments(id, selectedPiece);
|
||||
} catch (err) {
|
||||
error = 'Erreur lors du chargement';
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInstrumentsForPiece(pieceId: number) {
|
||||
loading = true;
|
||||
@@ -107,7 +84,7 @@
|
||||
{#if score?.compositor}
|
||||
<p class="text-gray-600 mt-1 text-lg">{score.compositor}</p>
|
||||
{/if}
|
||||
{#if score?.ressource}
|
||||
{#if score?.ressource && isSafeUrl(score.ressource)}
|
||||
<a
|
||||
href={score.ressource}
|
||||
target="_blank"
|
||||
@@ -143,7 +120,7 @@
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{#each instruments as instrument}
|
||||
{@const code = instrument.id || instrument.code || ''}
|
||||
{@const code = instrument.id || ''}
|
||||
{@const title = instrument.title || getInstrumentName(code)}
|
||||
{@const fileCount = instrument.parts?.[0]?.files?.length || 0}
|
||||
{@const icon = getInstrumentIcon(code)}
|
||||
|
||||
@@ -1,43 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { apiService, type Score, type Instrument } from '$lib/api';
|
||||
import { page } from '$app/state';
|
||||
import { apiService, type Instrument } from '$lib/api';
|
||||
import { getInstrumentIcon } from '$lib/instruments';
|
||||
|
||||
const INSTRUMENT_ICONS: Record<string, string> = {
|
||||
dir: '🎼', pic: '/icons/piccolo.png', flu: '/icons/flute.png', cla: '/icons/clarinette.png', clb: '/icons/clarinette_basse.png',
|
||||
sax: '/icons/sax-alto.png', sab: '/icons/sax-barython.png', sat: '/icons/sax-tenor.png', coa: '/icons/cor-anglais.jpg', cba: '/icons/contrebasse.png',
|
||||
cor: '/icons/cor.png', trp: '/icons/trumpet.png', trb: '/icons/tronbonne.png', tub: '/icons/tuba.png', htb: '/icons/hautbois.png',
|
||||
bas: '/icons/basson.png', per: '/icons/percussion.png', crn: '/icons/cornet.png', eup: '/icons/euphonium.png', har: '/icons/harpe.png',
|
||||
pia: '🎹', sup: '📄', par: '📄'
|
||||
};
|
||||
|
||||
function getInstrumentIcon(code: string): string {
|
||||
return INSTRUMENT_ICONS[code] || '🎵';
|
||||
}
|
||||
|
||||
let scoreId = '';
|
||||
let pieceId = '';
|
||||
let instruments: Instrument[] = $state([]);
|
||||
let scoreId = $derived(page.params.id || '');
|
||||
let pieceId = $derived(page.params.piece || '');
|
||||
let instruments = $state<Instrument[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
page.subscribe(($page) => {
|
||||
scoreId = $page.params.id || '';
|
||||
pieceId = $page.params.piece || '';
|
||||
$effect(() => {
|
||||
if (!scoreId) return;
|
||||
loadInstruments(scoreId, pieceId);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
async function loadInstruments(id: string, piece: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const score = await apiService.getScore(scoreId);
|
||||
// Filter instruments by piece
|
||||
instruments = (score.instruments || []).filter(i => i.piece === pieceId);
|
||||
const score = await apiService.getScore(id);
|
||||
instruments = (score.instruments || []).filter((i) => i.piece === piece);
|
||||
} catch (err) {
|
||||
error = 'Erreur lors du chargement';
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -1,41 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { apiService, type Part, type PdfFile } from '$lib/api';
|
||||
import { getInstrumentName } from '$lib/instruments';
|
||||
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||
|
||||
const INSTRUMENT_NAMES: Record<string, string> = {
|
||||
dir: 'Direction',
|
||||
pic: 'Piccolo',
|
||||
flu: 'Flûte',
|
||||
cla: 'Clarinette',
|
||||
clb: 'Clarinette basse',
|
||||
sax: 'Saxophone',
|
||||
sab: 'Saxophone baryton',
|
||||
sat: 'Saxophone ténor',
|
||||
cba: 'Contrebasse',
|
||||
cor: 'Cor',
|
||||
trp: 'Trompette',
|
||||
trb: 'Trombone',
|
||||
tub: 'Tuba',
|
||||
htb: 'Hautbois',
|
||||
bas: 'Basson',
|
||||
per: 'Percussion',
|
||||
crn: 'Cornet',
|
||||
eup: 'Euphonium',
|
||||
har: 'Harpe',
|
||||
pia: 'Piano'
|
||||
};
|
||||
|
||||
function getInstrumentName(code: string): string {
|
||||
return INSTRUMENT_NAMES[code] || code;
|
||||
}
|
||||
|
||||
let scoreId = '';
|
||||
let pieceId = '';
|
||||
let instrumentCode = '';
|
||||
let instrumentTitle = '';
|
||||
let parts: Part[] = $state([]);
|
||||
let scoreId = $derived(page.params.id || '');
|
||||
let pieceId = $derived(page.params.piece || '');
|
||||
let instrumentCode = $derived(page.params.instrument || '');
|
||||
let instrumentTitle = $state('');
|
||||
let parts = $state<Part[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let selectedFile = $state('');
|
||||
@@ -44,27 +17,29 @@
|
||||
let pieceCount = $state(0);
|
||||
|
||||
let pdfViewerUrl = $derived(selectedFile ? apiService.getDownloadUrl(selectedFile) : '');
|
||||
let pdfFileName = $derived(selectedFile ? selectedFile.split('/').pop() || 'Partition' : 'Partition');
|
||||
let pdfFileName = $derived(
|
||||
selectedFile ? selectedFile.split('/').pop() || 'Partition' : 'Partition'
|
||||
);
|
||||
|
||||
page.subscribe(($page) => {
|
||||
scoreId = $page.params.id || '';
|
||||
pieceId = $page.params.piece || '';
|
||||
instrumentCode = $page.params.instrument || '';
|
||||
$effect(() => {
|
||||
if (!scoreId) return;
|
||||
loadInstrument(scoreId, pieceId, instrumentCode);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
async function loadInstrument(id: string, piece: string, code: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
// Get pieces info
|
||||
const pieces = await apiService.getPieces(scoreId);
|
||||
const pieces = await apiService.getPieces(id);
|
||||
pieceCount = pieces.length;
|
||||
const piece = pieces.find(p => p.id.toString() === pieceId);
|
||||
if (piece) {
|
||||
pieceName = piece.name;
|
||||
const found = pieces.find((p) => p.id.toString() === piece);
|
||||
if (found) {
|
||||
pieceName = found.name;
|
||||
}
|
||||
|
||||
const score = await apiService.getScore(scoreId);
|
||||
|
||||
const score = await apiService.getScore(id);
|
||||
const instrument = (score.instruments || []).find(
|
||||
i => i.id === instrumentCode && i.piece === pieceId
|
||||
(i) => i.id === code && i.piece === piece
|
||||
);
|
||||
if (instrument) {
|
||||
instrumentTitle = instrument.title;
|
||||
@@ -79,7 +54,7 @@
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getFileInfo(file: PdfFile): string {
|
||||
const info = [];
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
const INSTRUMENT_ICONS: Record<string, string> = {
|
||||
dir: '🎼',
|
||||
pic: '/icons/piccolo.png',
|
||||
flu: '/icons/flute.png',
|
||||
cla: '/icons/clarinette.png',
|
||||
clb: '/icons/clarinette_basse.png',
|
||||
sax: '/icons/sax-alto.png',
|
||||
sab: '/icons/sax-barython.png',
|
||||
sat: '/icons/sax-tenor.png',
|
||||
coa: '/icons/cor-anglais.jpg',
|
||||
cba: '/icons/contrebasse.png',
|
||||
cor: '/icons/cor.png',
|
||||
trp: '/icons/trumpet.png',
|
||||
trb: '/icons/tronbonne.png',
|
||||
tub: '/icons/tuba.png',
|
||||
htb: '/icons/hautbois.png',
|
||||
bas: '/icons/basson.png',
|
||||
per: '/icons/percussion.png',
|
||||
crn: '/icons/cornet.png',
|
||||
eup: '/icons/euphonium.png',
|
||||
har: '/icons/harpe.png',
|
||||
pia: '🎹',
|
||||
sup: '📄',
|
||||
par: '📄'
|
||||
};
|
||||
|
||||
const INSTRUMENT_NAMES: Record<string, string> = {
|
||||
dir: 'Direction',
|
||||
pic: 'Piccolo',
|
||||
flu: 'Flûte',
|
||||
cla: 'Clarinette',
|
||||
clb: 'Clarinette Basse',
|
||||
sax: 'Sax Alto',
|
||||
sat: 'Sax Ténor',
|
||||
sab: 'Sax Baryton',
|
||||
coa: 'Cor Anglais',
|
||||
cba: 'Contrebasse',
|
||||
cor: 'Cor',
|
||||
trp: 'Trompette',
|
||||
trb: 'Trombone',
|
||||
tub: 'Tuba',
|
||||
htb: 'Hautbois',
|
||||
bas: 'Basson',
|
||||
per: 'Percussions',
|
||||
crn: 'Cornet',
|
||||
eup: 'Euphonium',
|
||||
har: 'Harpe',
|
||||
pia: 'Piano',
|
||||
sup: 'Parties supplementaires',
|
||||
par: 'Parties'
|
||||
};
|
||||
|
||||
const instruments = Object.entries(INSTRUMENT_NAMES).map(([code, name]) => ({
|
||||
code,
|
||||
name,
|
||||
icon: INSTRUMENT_ICONS[code] || '❓'
|
||||
}));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Test icônes instruments - OHMJ</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold text-ohmj-primary mb-8">Test icônes instruments</h1>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{#each instruments as inst}
|
||||
<div class="bg-white p-6 rounded-lg shadow border border-gray-200 text-center">
|
||||
<div class="text-5xl mb-2">
|
||||
{#if inst.icon.startsWith('/')}
|
||||
<img src={inst.icon} alt={inst.name} class="w-12 h-12 mx-auto object-contain" />
|
||||
{:else}
|
||||
{inst.icon}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="font-semibold">{inst.name}</p>
|
||||
<p class="text-sm text-gray-400">{inst.code}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* End-to-end smoke test (dependency-free) driving the system Chromium via CDP.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - API running: cd api && php -c php-upload.ini -S localhost:8000 router.php
|
||||
* - Front running: cd partitions && npm run dev
|
||||
*
|
||||
* Run: npm run test:e2e
|
||||
*
|
||||
* Env overrides: API_URL, FRONT_URL, CHROME_PATH, LOGIN_USER, LOGIN_PASS
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const API = process.env.API_URL || 'http://localhost:8000';
|
||||
const FRONT = process.env.FRONT_URL || 'http://localhost:5173';
|
||||
const CHROME = process.env.CHROME_PATH || 'chromium';
|
||||
const USER = process.env.LOGIN_USER || 'admin';
|
||||
const PASS = process.env.LOGIN_PASS || 'password';
|
||||
const CDP_PORT = 9300 + Math.floor(Math.random() * 400);
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function check(name, cond) {
|
||||
if (cond) {
|
||||
console.log(`✓ ${name}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`✗ ${name}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCdp() {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json`)).json();
|
||||
const page = list.find((t) => t.type === 'page');
|
||||
if (page) return page.webSocketDebuggerUrl;
|
||||
} catch {
|
||||
/* retry */
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
throw new Error('CDP endpoint not available');
|
||||
}
|
||||
|
||||
const chrome = spawn(
|
||||
CHROME,
|
||||
[
|
||||
`--remote-debugging-port=${CDP_PORT}`,
|
||||
'--headless=new',
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--window-size=1280,900',
|
||||
`--user-data-dir=/tmp/opencode/e2e-profile-${CDP_PORT}`,
|
||||
'about:blank'
|
||||
],
|
||||
{ stdio: 'ignore' }
|
||||
);
|
||||
|
||||
let ws;
|
||||
try {
|
||||
// 1. Verify servers are up
|
||||
const apiLogin = await fetch(`${API}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: USER, password: PASS })
|
||||
}).then((r) => r.json());
|
||||
check('API login succeeds', apiLogin.success === true && !!apiLogin.token);
|
||||
|
||||
const frontStatus = await fetch(FRONT).then((r) => r.status).catch(() => 0);
|
||||
check('Frontend dev server reachable', frontStatus === 200);
|
||||
|
||||
// 2. Connect to Chromium
|
||||
ws = new WebSocket(await waitForCdp());
|
||||
await new Promise((res, rej) => {
|
||||
ws.onopen = res;
|
||||
ws.onerror = rej;
|
||||
});
|
||||
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const apiResponses = [];
|
||||
const downloadRequests = [];
|
||||
ws.onmessage = (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && pending.has(m.id)) {
|
||||
pending.get(m.id).res(m.result);
|
||||
pending.delete(m.id);
|
||||
} else if (m.method === 'Network.responseReceived' && m.params.response.url.startsWith(API)) {
|
||||
apiResponses.push({ status: m.params.response.status, url: m.params.response.url });
|
||||
} else if (m.method === 'Network.requestWillBeSent' && m.params.request.url.includes('/download/')) {
|
||||
downloadRequests.push({
|
||||
url: m.params.request.url,
|
||||
method: m.params.request.method,
|
||||
headers: m.params.request.headers
|
||||
});
|
||||
}
|
||||
};
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((res) => {
|
||||
const i = ++id;
|
||||
pending.set(i, { res });
|
||||
ws.send(JSON.stringify({ id: i, method, params }));
|
||||
});
|
||||
|
||||
await send('Page.enable');
|
||||
await send('Runtime.enable');
|
||||
await send('Network.enable');
|
||||
|
||||
// 3. Login through the API then inject the session (same as the app does)
|
||||
await send('Page.navigate', { url: `${FRONT}/` });
|
||||
await sleep(2500);
|
||||
await send('Runtime.evaluate', {
|
||||
expression: `localStorage.setItem('auth', ${JSON.stringify(JSON.stringify({ token: apiLogin.token, user: apiLogin.user }))})`
|
||||
});
|
||||
|
||||
async function visit(path, expectation) {
|
||||
apiResponses.length = 0;
|
||||
await send('Page.navigate', { url: FRONT + path });
|
||||
await sleep(3500);
|
||||
const body = await send('Runtime.evaluate', {
|
||||
expression: 'document.body.innerText',
|
||||
returnByValue: true
|
||||
});
|
||||
const text = body.result.value || '';
|
||||
const bad = apiResponses.filter((r) => r.status >= 400);
|
||||
check(`${path}: no API error`, bad.length === 0);
|
||||
check(`${path}: ${expectation.label}`, expectation.test(text));
|
||||
const pathname = await send('Runtime.evaluate', {
|
||||
expression: 'window.location.pathname',
|
||||
returnByValue: true
|
||||
});
|
||||
check(`${path}: stays on page`, pathname.result.value === path);
|
||||
return text;
|
||||
}
|
||||
|
||||
// 4. Public pages
|
||||
await visit('/scores', { label: 'renders repertoire', test: (t) => t.includes('Répertoire') });
|
||||
await visit('/admin', { label: 'renders admin', test: (t) => t.includes('Administration') });
|
||||
|
||||
// 5. Pick a real score/instrument through the API and open the viewer
|
||||
const token = apiLogin.token;
|
||||
const auth = { Authorization: `Bearer ${token}` };
|
||||
const scores = await fetch(`${API}/scores`, { headers: auth }).then((r) => r.json());
|
||||
const candidates = scores.scores || [];
|
||||
check('API returns at least one score', candidates.length > 0);
|
||||
|
||||
// Find a score that actually has an instrument with files (skip empty ones)
|
||||
let chosen = null;
|
||||
for (const score of candidates.slice(0, 25)) {
|
||||
const instr = await fetch(`${API}/scores/${score.id}/instruments`, { headers: auth }).then((r) => r.json());
|
||||
const instrument = (instr.instruments || []).find((i) => i.parts?.[0]?.files?.length);
|
||||
if (instrument) {
|
||||
chosen = { scoreId: score.id, instrument };
|
||||
break;
|
||||
}
|
||||
}
|
||||
check('API returns a playable score/instrument', !!chosen);
|
||||
|
||||
if (chosen) {
|
||||
await visit(`/scores/${chosen.scoreId}`, {
|
||||
label: 'renders score detail',
|
||||
test: (t) => t.length > 0
|
||||
});
|
||||
|
||||
const viewerPath = `/scores/${chosen.scoreId}/${chosen.instrument.piece}/${chosen.instrument.id}`;
|
||||
downloadRequests.length = 0;
|
||||
await visit(viewerPath, { label: 'renders instrument', test: (t) => t.length > 0 });
|
||||
check('Viewer fetches the PDF', downloadRequests.some((r) => r.method === 'GET'));
|
||||
const getDownloads = downloadRequests.filter((r) => r.method === 'GET');
|
||||
const hasAuthHeader = getDownloads.every((r) =>
|
||||
Object.keys(r.headers).some((k) => k.toLowerCase() === 'authorization')
|
||||
);
|
||||
check(
|
||||
'PDF download uses Authorization header',
|
||||
hasAuthHeader && getDownloads.every((r) => !r.url.includes('token='))
|
||||
);
|
||||
|
||||
await visit(`/admin/${chosen.scoreId}`, {
|
||||
label: 'renders admin detail',
|
||||
test: (t) => t.includes('Informations de la partition')
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n=== E2E Summary ===\nPassed: ${passed}\nFailed: ${failed}\nTotal: ${passed + failed}`);
|
||||
if (failed > 0) {
|
||||
console.log('\n⚠ Some E2E checks failed!');
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('\n✓ All E2E checks passed!');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('E2E error:', err.message || err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (ws) ws.close();
|
||||
chrome.kill();
|
||||
}
|
||||
@@ -5,8 +5,5 @@ export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL || 'http://localhost:8000')
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run the whole OHMJ test suite:
|
||||
# 1. Backend functional tests (api/tests.php)
|
||||
# 2. Backend code coverage (Xdebug, if available)
|
||||
# 3. Frontend type/a11y check (svelte-check)
|
||||
# 4. Frontend E2E smoke test (Chromium via CDP)
|
||||
#
|
||||
# Usage: ./test.sh
|
||||
# Servers are started automatically if not already running, and stopped at the end.
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
API_DIR="$ROOT/api"
|
||||
FRONT_DIR="$ROOT/partitions"
|
||||
API_URL="http://localhost:8000"
|
||||
FRONT_URL="http://localhost:5173"
|
||||
COV_DIR="${TMPDIR:-/tmp}/ohmj_cov"
|
||||
|
||||
API_PID=""
|
||||
FRONT_PID=""
|
||||
STARTED_API=0
|
||||
STARTED_FRONT=0
|
||||
FAILED=0
|
||||
|
||||
cleanup() {
|
||||
rm -f "${TMPDIR:-/tmp}"/rate_* 2>/dev/null || true
|
||||
if [ "$STARTED_API" = "1" ] && [ -n "$API_PID" ]; then
|
||||
kill -TERM "-$API_PID" 2>/dev/null || kill "$API_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ "$STARTED_FRONT" = "1" ] && [ -n "$FRONT_PID" ]; then
|
||||
kill -TERM "-$FRONT_PID" 2>/dev/null || kill "$FRONT_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_http() {
|
||||
local url="$1" tries="${2:-40}"
|
||||
for _ in $(seq 1 "$tries"); do
|
||||
if curl -s -m 2 -o /dev/null "$url"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_xdebug() {
|
||||
php -d zend_extension=xdebug -r 'exit(function_exists("xdebug_start_code_coverage") ? 0 : 1);' 2>/dev/null
|
||||
}
|
||||
|
||||
# Xdebug is present but commented in php.ini: load it explicitly via -d.
|
||||
XDEBUG=0
|
||||
if has_xdebug; then
|
||||
XDEBUG=1
|
||||
fi
|
||||
|
||||
echo "=== OHMJ test suite ==="
|
||||
echo "Backend : php -c php-upload.ini -S localhost:8000 router.php"
|
||||
echo "Frontend: npm run check && npm run test:e2e"
|
||||
echo "Coverage: $([ "$XDEBUG" = "1" ] && echo 'Xdebug available' || echo 'Xdebug not found (skipped)')"
|
||||
echo
|
||||
|
||||
# ---------- Backend + coverage ----------
|
||||
rm -rf "$COV_DIR" 2>/dev/null || true
|
||||
rm -f "${TMPDIR:-/tmp}"/rate_* 2>/dev/null || true
|
||||
|
||||
echo "--- [1/4] Backend tests ---"
|
||||
if curl -s -m 2 -o /dev/null "$API_URL/login" -X POST -H 'Content-Type: application/json' -d '{}' 2>/dev/null; then
|
||||
echo "API already running, reusing it (coverage may be skipped)."
|
||||
else
|
||||
if [ "$XDEBUG" = "1" ]; then
|
||||
( cd "$API_DIR" && setsid php -d zend_extension=xdebug -d xdebug.mode=coverage \
|
||||
-d auto_prepend_file="$API_DIR/coverage-prepend.php" \
|
||||
-c php-upload.ini -S localhost:8000 router.php >/tmp/ohmj_api.log 2>&1 </dev/null & echo $! >/tmp/ohmj_api.pid )
|
||||
else
|
||||
( cd "$API_DIR" && setsid php -c php-upload.ini -S localhost:8000 router.php >/tmp/ohmj_api.log 2>&1 </dev/null & echo $! >/tmp/ohmj_api.pid )
|
||||
fi
|
||||
API_PID="$(cat /tmp/ohmj_api.pid)"
|
||||
STARTED_API=1
|
||||
wait_http "$API_URL/login" || { echo "API failed to start (see /tmp/ohmj_api.log)"; exit 1; }
|
||||
fi
|
||||
|
||||
rm -f "${TMPDIR:-/tmp}"/rate_* 2>/dev/null || true
|
||||
if ( cd "$API_DIR" && php tests.php ); then
|
||||
echo "Backend tests: OK"
|
||||
else
|
||||
echo "Backend tests: FAILED"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "--- [2/4] Backend coverage ---"
|
||||
if [ "$XDEBUG" = "1" ] && [ "$STARTED_API" = "1" ]; then
|
||||
rm -f "${TMPDIR:-/tmp}"/rate_* 2>/dev/null || true
|
||||
( cd "$API_DIR" && php -d zend_extension=xdebug -d xdebug.mode=coverage \
|
||||
-d auto_prepend_file="$API_DIR/coverage-prepend.php" tests.php >/dev/null 2>&1 )
|
||||
( cd "$ROOT" && SHOW_MISSING=1 php api/coverage-report.php \
|
||||
api/index.php api/router.php api/lib/Auth.php api/lib/ScoreScanner.php )
|
||||
elif [ "$XDEBUG" != "1" ]; then
|
||||
echo "Xdebug not available: run 'omarchy-pkg-install xdebug' to enable coverage."
|
||||
else
|
||||
echo "External API detected: coverage skipped (it needs the instrumented server)."
|
||||
echo "Stop the running API and re-run ./test.sh to measure coverage."
|
||||
fi
|
||||
|
||||
# ---------- Frontend ----------
|
||||
echo
|
||||
echo "--- [3/4] Frontend check (svelte-check) ---"
|
||||
if ( cd "$FRONT_DIR" && npm run check ); then
|
||||
echo "Frontend check: OK"
|
||||
else
|
||||
echo "Frontend check: FAILED"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "--- [4/4] Frontend E2E smoke test ---"
|
||||
if curl -s -m 2 -o /dev/null "$FRONT_URL/"; then
|
||||
echo "Frontend already running, reusing it."
|
||||
else
|
||||
( cd "$FRONT_DIR" && setsid npm run dev -- --port 5173 >/tmp/ohmj_front.log 2>&1 </dev/null & echo $! >/tmp/ohmj_front.pid )
|
||||
FRONT_PID="$(cat /tmp/ohmj_front.pid)"
|
||||
STARTED_FRONT=1
|
||||
wait_http "$FRONT_URL/" || { echo "Frontend failed to start (see /tmp/ohmj_front.log)"; exit 1; }
|
||||
fi
|
||||
|
||||
if ( cd "$FRONT_DIR" && npm run test:e2e ); then
|
||||
echo "E2E: OK"
|
||||
else
|
||||
echo "E2E: FAILED"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" = "1" ]; then
|
||||
echo "=== RESULT: FAILURES ==="
|
||||
exit 1
|
||||
fi
|
||||
echo "=== RESULT: ALL GREEN ==="
|
||||
Reference in New Issue
Block a user