Auth HTTP Basic + grille de volume adaptative par niveau
- Grille de calcul du volume adaptee dynamiquement au pas spatial de chaque niveau (au lieu d'une grille fixe derivee du niveau 0) : evite l'effondrement artificiel du "matching cells %" observe des le premier niveau de decimation (99% -> 58% -> 13% -> 3% -> 0.9% -> 0.2%), qui refletait un desalignement grille/densite plutot qu'une vraie perte d'information. - Authentification HTTP Basic sur toute l'app (UI + API), identifiants en env (AUTH_USERNAME / AUTH_PASSWORD_HASH hashe bcrypt), desactivable en dev local si non definis. /health reste public pour le healthcheck Docker/Coolify.
This commit is contained in:
parent
79daa30a8a
commit
7f2e4c323b
17 changed files with 313 additions and 30 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ data/
|
|||
.DS_Store
|
||||
coverage/
|
||||
.claude/
|
||||
.env
|
||||
|
|
|
|||
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -54,10 +54,18 @@ E57 volontairement hors perimetre.
|
|||
decimation produit un nuage plus clairseme que la resolution de grille utilisee pour le calcul de
|
||||
volume — exactement l'indicateur cherche pour ce projet (voir `matchingCellsWarnThreshold` dans
|
||||
`config.ts`, defaut 90%).
|
||||
- Le `-GRID_STEP` du calcul de volume doit rester **fixe** (le meme pour les 6 niveaux) pour que les
|
||||
volumes restent comparables entre eux — sinon on mele l'effet de la decimation avec l'effet d'un
|
||||
changement de resolution de calcul. On le derive une seule fois du pas spatial initial (voir plus
|
||||
bas) et on le reutilise identique partout.
|
||||
- **`-GRID_STEP` doit etre adapte a CHAQUE niveau, pas fige sur la resolution du niveau 0.** Premiere
|
||||
version du pipeline : un `GRID_STEP` unique (derive du niveau 0) reutilise sur les 6 niveaux "pour
|
||||
rester comparable". En pratique ca fait exactement l'inverse de ce qu'on veut tester : une fois la
|
||||
decimation plus grossiere que cette grille fixe, `matching cells %` s'effondre mecaniquement
|
||||
(observe : 99% -> 58% -> 13% -> 3% -> 0.9% -> 0.2% sur 5 niveaux) parce que la grille est trop fine
|
||||
pour les points restants, pas parce que le volume est vraiment devenu impossible a estimer. Ce n'est
|
||||
pas un signal de robustesse utile, juste un artefact de resolution. **Fix retenu** : `GRID_STEP` par
|
||||
niveau = pas spatial de CE niveau x `gridStepMultiplier` (le niveau 0 utilise `initialStep x
|
||||
multiplier`). Chaque niveau a donc sa propre grille adaptee a sa propre densite de points — le
|
||||
`matching cells %` refletera alors la vraie perte d'information due a la decimation, pas un
|
||||
desalignement grille/densite. Voir `LevelResult.gridStep` (par niveau) et
|
||||
`RunReport.params.gridStepMultiplier` (le seul parametre encore global).
|
||||
|
||||
### `-SS SPATIAL` / `-SS RANDOM` (sous-echantillonnage)
|
||||
|
||||
|
|
@ -163,7 +171,8 @@ manquantes), mais pourraient etre simplifies.
|
|||
## Constantes du pipeline (voir `src/config.ts`)
|
||||
|
||||
- `decimationFactor = 2`, `decimationSteps = 5` (fixes par la spec, pas exposes en config utilisateur).
|
||||
- `gridStepMultiplier = 2` : le grid step de `-VOLUME` = pas spatial initial x2.
|
||||
- `gridStepMultiplier = 2` : grid step de `-VOLUME` a CHAQUE niveau = pas spatial de ce niveau x2
|
||||
(niveau 0 : `initialStep x2`). Adapte dynamiquement, pas fige — voir section `-VOLUME` plus haut.
|
||||
- `statsSampleCap = 50000` : nb de points echantillonnes pour estimer la distance mediane au plus
|
||||
proche voisin (KD-tree scipy) qui sert de pas spatial initial.
|
||||
- `minPointsToContinue = 25` : la decimation s'arrete si un niveau tombe en dessous.
|
||||
|
|
|
|||
|
|
@ -56,6 +56,6 @@ VOLUME ["/data"]
|
|||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD curl -fsS "http://localhost:${PORT:-3000}/" || exit 1
|
||||
CMD curl -fsS "http://localhost:${PORT:-3000}/health" || exit 1
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
|
|
|||
46
README.md
46
README.md
|
|
@ -6,8 +6,9 @@ Application locale (Docker) qui, a partir d'un nuage de points (LAS/LAZ/COPC.laz
|
|||
2. le decime progressivement en methode **spatiale**, facteur **x2** sur **5 etapes** (le pas spatial double
|
||||
a chaque etape, en partant d'un pas initial estime automatiquement = distance mediane au plus proche
|
||||
voisin, calculee par KD-tree sur un echantillon) ;
|
||||
3. recalcule le volume a chaque niveau avec la **meme** grille et le **meme** plan de reference, pour rendre
|
||||
les 6 mesures comparables ;
|
||||
3. recalcule le volume a chaque niveau avec le **meme plan de reference**, et une **grille de calcul
|
||||
adaptee dynamiquement a la densite de chaque niveau** (grid step = pas spatial du niveau x2) pour
|
||||
eviter des grilles artificiellement vides quand la decimation depasse la resolution de depart ;
|
||||
4. assemble les 6 nuages (complet + 5 decimations) dans un seul fichier `.bin` CloudCompare, nomme et
|
||||
consultable dans CloudCompare Desktop ;
|
||||
5. produit un dossier de resultats : rapport CSV/JSON, cartes de hauteur par niveau, graphiques de
|
||||
|
|
@ -25,14 +26,44 @@ Puis ouvrir http://localhost:8080
|
|||
Les donnees (uploads, resultats, base sqlite) sont persistees dans un volume Docker nomme `app-data`
|
||||
(pas un bind mount vers `./data`, voir "Deploiement Coolify" ci-dessous pour la raison).
|
||||
|
||||
## Authentification
|
||||
|
||||
L'UI et l'API sont protegees par HTTP Basic Auth (identifiants dans `AUTH_USERNAME` /
|
||||
`AUTH_PASSWORD_HASH`, hash bcrypt — jamais le mot de passe en clair). Seul `/health` reste public
|
||||
(healthcheck Docker/Coolify).
|
||||
|
||||
**Si ces deux variables ne sont pas definies, l'auth est desactivee** (pratique pour du dev local,
|
||||
un warning est logge au demarrage). En production/Coolify, toujours les definir.
|
||||
|
||||
Generer un hash pour un nouveau mot de passe :
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('bcryptjs').hashSync('VOTRE_MOT_DE_PASSE', 10))"
|
||||
```
|
||||
|
||||
Puis definir dans Coolify (onglet "Environment Variables" de l'application, **pas** dans
|
||||
`docker-compose.yml` du repo, pour ne pas committer le hash) :
|
||||
|
||||
```
|
||||
AUTH_USERNAME=nico
|
||||
AUTH_PASSWORD_HASH=$2a$10$... # sortie de la commande ci-dessus
|
||||
```
|
||||
|
||||
En local avec `docker compose`, un fichier `.env` (gitignore) a la racine du repo fonctionne aussi :
|
||||
|
||||
```
|
||||
AUTH_USERNAME=nico
|
||||
AUTH_PASSWORD_HASH=$2a$10$...
|
||||
```
|
||||
|
||||
## Deploiement Coolify
|
||||
|
||||
Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> build + deploy auto) :
|
||||
|
||||
- Le `Dockerfile` fait tourner **lint + typecheck + tests unitaires** avant de compiler — un commit qui
|
||||
casse l'un de ces trois echoue le build, donc n'est jamais deploye.
|
||||
- `HEALTHCHECK` integre au Dockerfile (`curl` sur `/`) : Coolify l'utilise pour verifier que le
|
||||
deploiement est reellement sain.
|
||||
- `HEALTHCHECK` integre au Dockerfile (`curl` sur `/health`, route publique non authentifiee) :
|
||||
Coolify l'utilise pour verifier que le deploiement est reellement sain.
|
||||
- Persistance : le volume `/data` doit etre monte comme un **volume nomme/gere par Coolify**, pas un
|
||||
bind mount vers le checkout git (Coolify re-clone le repo a chaque deploiement — un bind mount
|
||||
relatif au checkout perdrait toutes les donnees a chaque redeploy). Deux options :
|
||||
|
|
@ -40,8 +71,9 @@ Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> bui
|
|||
nomme `app-data` declare dedans est deja correct.
|
||||
- Resource **"Application"** (Dockerfile) : configurer un volume persistant sur `/data` depuis
|
||||
l'onglet "Storage" de Coolify, et le port expose sur `3000` (variable `PORT`, lue par l'appli).
|
||||
- Variables d'environnement optionnelles a definir dans Coolify si besoin de les changer par rapport
|
||||
aux defauts (voir tableau plus bas) : `MAX_UPLOAD_MB`, `STATS_SAMPLE_CAP`, `CC_TIMEOUT_MS`.
|
||||
- Variables d'environnement a definir dans Coolify : `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`
|
||||
(obligatoire, voir "Authentification" plus haut), et optionnellement `MAX_UPLOAD_MB`,
|
||||
`STATS_SAMPLE_CAP`, `CC_TIMEOUT_MS` si besoin de changer les defauts (voir tableau plus bas).
|
||||
- Le webhook de deploiement automatique est deja configure cote Coolify sur ce repo.
|
||||
|
||||
## Utilisation
|
||||
|
|
@ -71,6 +103,8 @@ Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> bui
|
|||
|---|---|---|
|
||||
| `PORT` | 3000 | port interne du serveur |
|
||||
| `DATA_DIR` | `/data` | dossier de persistance (uploads, resultats, sqlite) |
|
||||
| `AUTH_USERNAME` | _(non defini = auth off)_ | nom d'utilisateur HTTP Basic |
|
||||
| `AUTH_PASSWORD_HASH` | _(non defini = auth off)_ | hash bcrypt du mot de passe |
|
||||
| `MAX_UPLOAD_MB` | 2048 | taille max d'un nuage uploade |
|
||||
| `STATS_SAMPLE_CAP` | 50000 | nb de points echantillonnes pour estimer le pas spatial initial |
|
||||
| `CC_TIMEOUT_MS` | 600000 | timeout par appel CloudCompare CLI |
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ services:
|
|||
- MAX_UPLOAD_MB=2048
|
||||
- STATS_SAMPLE_CAP=50000
|
||||
- CC_TIMEOUT_MS=600000
|
||||
# Auth HTTP Basic (UI + API). Non definies = auth desactivee (dev local). Sur Coolify,
|
||||
# definir ces deux variables dans l'onglet "Environment Variables" de l'application (pas ici,
|
||||
# pour ne pas committer le hash dans le repo). Voir README pour generer AUTH_PASSWORD_HASH.
|
||||
- AUTH_USERNAME=${AUTH_USERNAME:-}
|
||||
- AUTH_PASSWORD_HASH=${AUTH_PASSWORD_HASH:-}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
|
|
|||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
|
|
@ -2142,6 +2144,13 @@
|
|||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
|
|
@ -3442,6 +3451,12 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
"@nestjs/cli": "^10.4.9",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^1.4.12",
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ function levelRow(l, id) {
|
|||
<tr class="border-b border-slate-800 ${warnCls}">
|
||||
<td class="py-2 px-3 font-medium">${l.level}</td>
|
||||
<td class="py-2 px-3">${l.spatialStep !== null ? fmt(l.spatialStep, 5) + ' m' : 'complet'}</td>
|
||||
<td class="py-2 px-3">${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? l.pointCount.toLocaleString('fr-FR') : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.pointRatio * 100, 2) + ' %' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'}</td>
|
||||
|
|
@ -179,7 +180,7 @@ function paintDetail(run) {
|
|||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-900 text-slate-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="py-2 px-3">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Points</th>
|
||||
<th class="py-2 px-3">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Grille volume</th><th class="py-2 px-3">Points</th>
|
||||
<th class="py-2 px-3">% points vs L0</th><th class="py-2 px-3">Volume</th><th class="py-2 px-3">Surface</th>
|
||||
<th class="py-2 px-3">Matching cells</th><th class="py-2 px-3">Statut</th>
|
||||
</tr>
|
||||
|
|
@ -192,7 +193,7 @@ function paintDetail(run) {
|
|||
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mt-4">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Zref</dt><dd>${fmt(r.params.zref, 4)} (${r.params.zrefSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Pas initial</dt><dd>${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Grille volume</dt><dd>${fmt(r.params.gridStep, 5)} m</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Grille volume</dt><dd>x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Facteur / etapes</dt><dd>x${r.params.factor} sur ${r.params.steps}</dd></div>
|
||||
</dl>` : '';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import * as path from 'path';
|
||||
import { HealthController } from './health.controller';
|
||||
import { RunsModule } from './runs/runs.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: path.resolve(__dirname, '..', 'public'),
|
||||
exclude: ['/api*'],
|
||||
exclude: ['/api*', '/health'],
|
||||
}),
|
||||
RunsModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
107
src/auth/basic-auth.middleware.spec.ts
Normal file
107
src/auth/basic-auth.middleware.spec.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import * as bcrypt from 'bcryptjs';
|
||||
import { config } from '../config';
|
||||
import { basicAuthMiddleware } from './basic-auth.middleware';
|
||||
|
||||
function fakeReqRes(path: string, authHeader?: string) {
|
||||
const req: any = { path, headers: authHeader ? { authorization: authHeader } : {} };
|
||||
const res: any = {
|
||||
statusCode: 200,
|
||||
headers: {} as Record<string, string>,
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name] = value;
|
||||
},
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
send(_body?: string) {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): string {
|
||||
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
}
|
||||
|
||||
describe('basicAuthMiddleware', () => {
|
||||
const originalUsername = config.authUsername;
|
||||
const originalHash = config.authPasswordHash;
|
||||
const passwordHash = bcrypt.hashSync('blidulphe', 4); // cost bas pour des tests rapides
|
||||
|
||||
afterEach(() => {
|
||||
(config as any).authUsername = originalUsername;
|
||||
(config as any).authPasswordHash = originalHash;
|
||||
});
|
||||
|
||||
it('laisse passer toutes les requetes si AUTH_USERNAME/AUTH_PASSWORD_HASH ne sont pas definis', () => {
|
||||
(config as any).authUsername = null;
|
||||
(config as any).authPasswordHash = null;
|
||||
const middleware = basicAuthMiddleware();
|
||||
const { req, res } = fakeReqRes('/');
|
||||
const next = jest.fn();
|
||||
middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('laisse toujours passer /health, meme sans authentification', () => {
|
||||
(config as any).authUsername = 'nico';
|
||||
(config as any).authPasswordHash = passwordHash;
|
||||
const middleware = basicAuthMiddleware();
|
||||
const { req, res } = fakeReqRes('/health');
|
||||
const next = jest.fn();
|
||||
middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renvoie 401 si aucun header Authorization n\'est fourni', () => {
|
||||
(config as any).authUsername = 'nico';
|
||||
(config as any).authPasswordHash = passwordHash;
|
||||
const middleware = basicAuthMiddleware();
|
||||
const { req, res } = fakeReqRes('/');
|
||||
const next = jest.fn();
|
||||
middleware(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.headers['WWW-Authenticate']).toMatch(/Basic/);
|
||||
});
|
||||
|
||||
it('renvoie 401 pour un mauvais mot de passe', (done) => {
|
||||
(config as any).authUsername = 'nico';
|
||||
(config as any).authPasswordHash = passwordHash;
|
||||
const middleware = basicAuthMiddleware();
|
||||
const { req, res } = fakeReqRes('/', basicHeader('nico', 'mauvais-mdp'));
|
||||
const next = jest.fn();
|
||||
middleware(req, res, next);
|
||||
setImmediate(() => {
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(401);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('renvoie 401 pour un mauvais utilisateur', () => {
|
||||
(config as any).authUsername = 'nico';
|
||||
(config as any).authPasswordHash = passwordHash;
|
||||
const middleware = basicAuthMiddleware();
|
||||
const { req, res } = fakeReqRes('/', basicHeader('quelquun-dautre', 'blidulphe'));
|
||||
const next = jest.fn();
|
||||
middleware(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('laisse passer avec les bons identifiants', (done) => {
|
||||
(config as any).authUsername = 'nico';
|
||||
(config as any).authPasswordHash = passwordHash;
|
||||
const middleware = basicAuthMiddleware();
|
||||
const { req, res } = fakeReqRes('/', basicHeader('nico', 'blidulphe'));
|
||||
const next = jest.fn();
|
||||
middleware(req, res, next);
|
||||
setImmediate(() => {
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
67
src/auth/basic-auth.middleware.ts
Normal file
67
src/auth/basic-auth.middleware.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Logger } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { config } from '../config';
|
||||
|
||||
const logger = new Logger('BasicAuth');
|
||||
|
||||
// Routes accessibles sans authentification (healthcheck Docker/Coolify).
|
||||
const PUBLIC_PATHS = new Set(['/health']);
|
||||
|
||||
/**
|
||||
* Middleware HTTP Basic Auth applique a TOUTE l'application (UI statique + API), pour eviter
|
||||
* d'exposer le pipeline sans authentification. Identifiants dans AUTH_USERNAME / AUTH_PASSWORD_HASH
|
||||
* (hash bcrypt) - voir README pour generer un nouveau hash. Si l'une des deux variables est absente,
|
||||
* l'auth est desactivee (dev local uniquement) avec un warning au demarrage.
|
||||
*/
|
||||
export function basicAuthMiddleware() {
|
||||
const { authUsername, authPasswordHash } = config;
|
||||
const enabled = Boolean(authUsername && authPasswordHash);
|
||||
|
||||
if (!enabled) {
|
||||
logger.warn('AUTH_USERNAME / AUTH_PASSWORD_HASH non definis : authentification DESACTIVEE.');
|
||||
}
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!enabled || PUBLIC_PATHS.has(req.path)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const deny = () => {
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="volume-app", charset="UTF-8"');
|
||||
res.status(401).send('Authentification requise');
|
||||
};
|
||||
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Basic ')) {
|
||||
deny();
|
||||
return;
|
||||
}
|
||||
|
||||
let user: string;
|
||||
let pass: string;
|
||||
try {
|
||||
const decoded = Buffer.from(header.slice('Basic '.length), 'base64').toString('utf-8');
|
||||
const idx = decoded.indexOf(':');
|
||||
user = decoded.slice(0, idx);
|
||||
pass = decoded.slice(idx + 1);
|
||||
} catch {
|
||||
deny();
|
||||
return;
|
||||
}
|
||||
|
||||
if (user !== authUsername) {
|
||||
deny();
|
||||
return;
|
||||
}
|
||||
|
||||
bcrypt
|
||||
.compare(pass, authPasswordHash as string)
|
||||
.then((ok) => {
|
||||
if (ok) next();
|
||||
else deny();
|
||||
})
|
||||
.catch(() => deny());
|
||||
};
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ export const config = {
|
|||
decimationSteps: 5,
|
||||
// Nombre max de points echantillonnes pour estimer la distance moyenne au plus proche voisin
|
||||
statsSampleCap: envInt('STATS_SAMPLE_CAP', 50000),
|
||||
// Le pas de grille utilise pour TOUS les calculs de volume (fixe, derive du pas spatial initial)
|
||||
// afin que les 6 niveaux restent comparables entre eux.
|
||||
// Grille de calcul du volume = pas spatial DE CHAQUE NIVEAU x ce multiplicateur (adaptee
|
||||
// dynamiquement a chaque niveau, pas figee sur la resolution du niveau 0 - voir CLAUDE.md).
|
||||
gridStepMultiplier: 2,
|
||||
// En dessous de ce nombre de points, on arrete la decimation progressive (cloud devenu inexploitable)
|
||||
minPointsToContinue: 25,
|
||||
|
|
@ -29,6 +29,10 @@ export const config = {
|
|||
maxUploadBytes: envInt('MAX_UPLOAD_MB', 2048) * 1024 * 1024,
|
||||
// Timeout par appel CloudCompare CLI (ms)
|
||||
ccTimeoutMs: envInt('CC_TIMEOUT_MS', 10 * 60 * 1000),
|
||||
// Auth HTTP Basic (UI + API). Si l'une des deux variables est absente, l'auth est desactivee
|
||||
// (pratique en dev local) - un warning est logge au demarrage dans ce cas, voir main.ts.
|
||||
authUsername: process.env.AUTH_USERNAME ?? null,
|
||||
authPasswordHash: process.env.AUTH_PASSWORD_HASH ?? null,
|
||||
};
|
||||
|
||||
export const uploadsDir = () => path.join(config.dataDir, 'uploads');
|
||||
|
|
|
|||
9
src/health.controller.ts
Normal file
9
src/health.controller.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,13 @@ import 'reflect-metadata';
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { basicAuthMiddleware } from './auth/basic-auth.middleware';
|
||||
import { config } from './config';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { bodyParser: true });
|
||||
app.enableCors();
|
||||
app.use(basicAuthMiddleware());
|
||||
await app.listen(config.port, '0.0.0.0');
|
||||
new Logger('Bootstrap').log(`Application demarree sur http://0.0.0.0:${config.port} (data dir: ${config.dataDir})`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,11 +73,16 @@ export class PipelineService {
|
|||
|
||||
const initialStep = params.initialStepOverride ?? stats.median_nn;
|
||||
const zref = params.zrefOverride ?? stats.bbox.zmin;
|
||||
const gridStep = initialStep * config.gridStepMultiplier;
|
||||
// La grille de calcul du volume est adaptee dynamiquement a CHAQUE niveau (grid_step = pas
|
||||
// spatial du niveau x multiplicateur), plutot que figee sur la resolution du niveau 0. Une
|
||||
// grille fixe forcait des cellules vides des que la decimation depassait la resolution de
|
||||
// depart (matching cells % s'effondrait des le niveau 1) - voir CLAUDE.md.
|
||||
const level0GridStep = initialStep * config.gridStepMultiplier;
|
||||
|
||||
onProgress(
|
||||
`Pas initial=${initialStep.toFixed(5)}m (${params.initialStepOverride ? 'override' : 'auto, mediane NN'}), ` +
|
||||
`grille volume=${gridStep.toFixed(5)}m, Zref=${zref.toFixed(4)} (${params.zrefOverride ? 'override' : 'auto, Zmin echantillon'}). ` +
|
||||
`grille volume niveau 0=${level0GridStep.toFixed(5)}m (adaptee dynamiquement aux niveaux suivants), ` +
|
||||
`Zref=${zref.toFixed(4)} (${params.zrefOverride ? 'override' : 'auto, Zmin echantillon'}). ` +
|
||||
`[${Date.now() - t0}ms]`,
|
||||
);
|
||||
|
||||
|
|
@ -94,7 +99,7 @@ export class PipelineService {
|
|||
imagesDir,
|
||||
rawDir,
|
||||
workDir,
|
||||
gridStep,
|
||||
gridStep: level0GridStep,
|
||||
zref,
|
||||
refPointCount: totalPointCount,
|
||||
onProgress,
|
||||
|
|
@ -120,7 +125,10 @@ export class PipelineService {
|
|||
}
|
||||
|
||||
const spatialStep = initialStep * Math.pow(config.decimationFactor, i);
|
||||
onProgress(`Niveau ${i} : decimation spatiale (pas=${spatialStep.toFixed(5)}m) depuis le niveau ${i - 1}...`);
|
||||
const levelGridStep = spatialStep * config.gridStepMultiplier;
|
||||
onProgress(
|
||||
`Niveau ${i} : decimation spatiale (pas=${spatialStep.toFixed(5)}m, grille volume=${levelGridStep.toFixed(5)}m) depuis le niveau ${i - 1}...`,
|
||||
);
|
||||
|
||||
try {
|
||||
const lvl = await this.computeLevel({
|
||||
|
|
@ -132,7 +140,7 @@ export class PipelineService {
|
|||
imagesDir,
|
||||
rawDir,
|
||||
workDir,
|
||||
gridStep,
|
||||
gridStep: levelGridStep,
|
||||
zref,
|
||||
refPointCount: totalPointCount,
|
||||
onProgress,
|
||||
|
|
@ -150,6 +158,7 @@ export class PipelineService {
|
|||
levels.push({
|
||||
level: i,
|
||||
spatialStep,
|
||||
gridStep: levelGridStep,
|
||||
pointCount: 0,
|
||||
pointRatio: 0,
|
||||
volume: null,
|
||||
|
|
@ -196,7 +205,7 @@ export class PipelineService {
|
|||
zrefSource: params.zrefOverride !== undefined ? 'override' : 'auto',
|
||||
initialStep,
|
||||
initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto',
|
||||
gridStep,
|
||||
gridStepMultiplier: config.gridStepMultiplier,
|
||||
factor: config.decimationFactor,
|
||||
steps: config.decimationSteps,
|
||||
sampleStats: {
|
||||
|
|
@ -228,6 +237,7 @@ export class PipelineService {
|
|||
return {
|
||||
level,
|
||||
spatialStep: null,
|
||||
gridStep: null,
|
||||
pointCount: 0,
|
||||
pointRatio: 0,
|
||||
volume: null,
|
||||
|
|
@ -279,7 +289,7 @@ export class PipelineService {
|
|||
const res = await this.cc.run(['-O', opts.sourceBinOrOriginal, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath], workDir);
|
||||
const n = this.cc.parseLoadedPointCount(res.stdout);
|
||||
if (n === null) {
|
||||
return this.errorLevel(level, spatialStep, `Conversion niveau 0 echouee. Sortie:\n${res.stdout.slice(-1500)}`, t0);
|
||||
return this.errorLevel(level, spatialStep, `Conversion niveau 0 echouee. Sortie:\n${res.stdout.slice(-1500)}`, t0, null, gridStep);
|
||||
}
|
||||
pointCount = n;
|
||||
await this.assertExists(path.join(workDir, binRelPath), 'conversion niveau 0', res.stdout);
|
||||
|
|
@ -296,6 +306,7 @@ export class PipelineService {
|
|||
return {
|
||||
level,
|
||||
spatialStep,
|
||||
gridStep: opts.gridStep,
|
||||
pointCount: 0,
|
||||
pointRatio: 0,
|
||||
volume: null,
|
||||
|
|
@ -326,7 +337,7 @@ export class PipelineService {
|
|||
);
|
||||
const reportFile = await this.cc.findLatestFile(workDir, { prefix: 'VolumeCalculationReport', suffix: '.txt', after: beforeVolume });
|
||||
if (!reportFile) {
|
||||
return this.errorLevel(level, spatialStep, `Rapport de volume introuvable au niveau ${level}. Sortie:\n${volRes.stdout.slice(-1500)}`, t0, binRelPath);
|
||||
return this.errorLevel(level, spatialStep, `Rapport de volume introuvable au niveau ${level}. Sortie:\n${volRes.stdout.slice(-1500)}`, t0, binRelPath, gridStep);
|
||||
}
|
||||
const vol = await this.cc.parseVolumeReportFile(reportFile);
|
||||
const rawReportDest = path.join(opts.rawDir, `L${level}_volume_report.txt`);
|
||||
|
|
@ -362,6 +373,7 @@ export class PipelineService {
|
|||
return {
|
||||
level,
|
||||
spatialStep,
|
||||
gridStep,
|
||||
pointCount,
|
||||
pointRatio: refPointCount > 0 ? pointCount / refPointCount : 0,
|
||||
volume: vol.volume,
|
||||
|
|
@ -379,10 +391,18 @@ export class PipelineService {
|
|||
};
|
||||
}
|
||||
|
||||
private errorLevel(level: number, spatialStep: number | null, message: string, t0: number, binFile: string | null = null): LevelResult {
|
||||
private errorLevel(
|
||||
level: number,
|
||||
spatialStep: number | null,
|
||||
message: string,
|
||||
t0: number,
|
||||
binFile: string | null = null,
|
||||
gridStep: number | null = null,
|
||||
): LevelResult {
|
||||
return {
|
||||
level,
|
||||
spatialStep,
|
||||
gridStep,
|
||||
pointCount: 0,
|
||||
pointRatio: 0,
|
||||
volume: null,
|
||||
|
|
@ -410,6 +430,7 @@ export function reportToCsv(report: RunReport): string {
|
|||
const headers = [
|
||||
'level',
|
||||
'spatialStep',
|
||||
'gridStep',
|
||||
'pointCount',
|
||||
'pointRatio',
|
||||
'volume',
|
||||
|
|
@ -428,6 +449,7 @@ export function reportToCsv(report: RunReport): string {
|
|||
[
|
||||
l.level,
|
||||
l.spatialStep ?? '',
|
||||
l.gridStep ?? '',
|
||||
l.pointCount,
|
||||
l.pointRatio,
|
||||
l.volume ?? '',
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ describe('reportToCsv', () => {
|
|||
zrefSource: 'auto',
|
||||
initialStep: 0.02,
|
||||
initialStepSource: 'auto',
|
||||
gridStep: 0.04,
|
||||
gridStepMultiplier: 2,
|
||||
factor: 2,
|
||||
steps: 1,
|
||||
sampleStats: { sampleCount: 100, meanNn: 0.02, medianNn: 0.02, bboxApprox: { xmin: 0, xmax: 1, ymin: 0, ymax: 1, zmin: 100, zmax: 101 } },
|
||||
|
|
@ -39,6 +39,7 @@ describe('reportToCsv', () => {
|
|||
{
|
||||
level: 0,
|
||||
spatialStep: null,
|
||||
gridStep: 0.04,
|
||||
pointCount: 1000,
|
||||
pointRatio: 1,
|
||||
volume: 12.5,
|
||||
|
|
@ -57,6 +58,7 @@ describe('reportToCsv', () => {
|
|||
{
|
||||
level: 1,
|
||||
spatialStep: 0.04,
|
||||
gridStep: 0.08,
|
||||
pointCount: 0,
|
||||
pointRatio: 0,
|
||||
volume: null,
|
||||
|
|
@ -82,7 +84,7 @@ describe('reportToCsv', () => {
|
|||
const lines = csv.trim().split('\n');
|
||||
expect(lines).toHaveLength(3); // header + 2 niveaux
|
||||
expect(lines[0]).toBe(
|
||||
'level,spatialStep,pointCount,pointRatio,volume,surface,addedVolume,removedVolume,matchingCellsPct,groundNonMatchingPct,ceilNonMatchingPct,status,warn,computeTimeMs,message',
|
||||
'level,spatialStep,gridStep,pointCount,pointRatio,volume,surface,addedVolume,removedVolume,matchingCellsPct,groundNonMatchingPct,ceilNonMatchingPct,status,warn,computeTimeMs,message',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -91,7 +93,7 @@ describe('reportToCsv', () => {
|
|||
const lines = csv.trim().split('\n');
|
||||
expect(lines[2]).not.toContain('\n');
|
||||
// le message ne doit contenir aucune virgule qui ajouterait une colonne supplementaire
|
||||
const messageField = lines[2].split(',').slice(14).join(',');
|
||||
const messageField = lines[2].split(',').slice(15).join(',');
|
||||
expect(messageField).not.toMatch(/,/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export interface LevelResult {
|
||||
level: number;
|
||||
spatialStep: number | null; // null pour le niveau 0 (nuage complet, non decime)
|
||||
gridStep: number | null; // grille de calcul du volume a CE niveau (adaptee dynamiquement a la densite, voir CLAUDE.md)
|
||||
pointCount: number;
|
||||
pointRatio: number; // vs niveau 0
|
||||
volume: number | null;
|
||||
|
|
@ -32,7 +33,7 @@ export interface RunReport {
|
|||
zrefSource: 'override' | 'auto';
|
||||
initialStep: number;
|
||||
initialStepSource: 'override' | 'auto';
|
||||
gridStep: number;
|
||||
gridStepMultiplier: number;
|
||||
factor: number;
|
||||
steps: number;
|
||||
sampleStats: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue