Compare commits

..

No commits in common. "82e61dcded0b7d1278e37a8b5e361a0495e62dc2" and "3d23e05351c1f12f69db36cd139a15aea6e2686b" have entirely different histories.

28 changed files with 1 additions and 1089 deletions

7
.gitignore vendored
View File

@ -64,10 +64,3 @@ migration-data/
# Dumps PostgreSQL (données clients — ne jamais committer) # Dumps PostgreSQL (données clients — ne jamais committer)
*.dump *.dump
*.sql.gz *.sql.gz
# Sauvegardes locales — ne jamais committer de dump de données clients
ManagerService/Deployment/backups/
# Provisioning : seuls le .example et les scripts sont versionnés
ManagerService/Deployment/provisioning/.env
ManagerService/Deployment/provisioning/jwt.txt

View File

@ -306,11 +306,7 @@ namespace ManagerService.Controllers
Email = old.Email, Email = old.Email,
Password = old.Password, Password = old.Password,
FirstName = old.FirstName, FirstName = old.FirstName,
// Mongo n'impose rien sur le nom, Postgres le veut NOT NULL : LastName = old.LastName,
// `test@email.be` a LastName null et faisait échouer tout le
// lot au SaveChanges. Le dry run ne peut pas voir ce défaut —
// il n'écrit rien, donc aucune contrainte n'est évaluée.
LastName = old.LastName ?? string.Empty,
Token = old.Token, Token = old.Token,
DateCreation = old.DateCreation, DateCreation = old.DateCreation,
InstanceId = old.InstanceId, InstanceId = old.InstanceId,

View File

@ -1,129 +0,0 @@
# Sauvegardes — installation
Complète le snapshot quotidien OVH, qui restaure un **disque** mais ne garantit pas un
Postgres cohérent. `pg_dump` prend un snapshot MVCC : c'est une sauvegarde au niveau base.
Pour restaurer, voir **[RESTORE.md](RESTORE.md)**.
## Scripts
| Script | Rôle |
|---|---|
| `backup-postgres.sh` | dump `-Fc`, vérifie qu'il est relisible, rotation 7 jours, copie hors site |
| `restore-check.sh` | restaure dans une base jetable et compare les comptages — le seul test qui vaille |
| `extract-instance.sh` | extrait une seule instance d'un dump global (RGPD, récupération d'un client) |
| `check-freshness.sh` | alerte si la dernière sauvegarde a plus de 48 h |
| `dump-mongo-prod.sh` | sauvegarde de la prod Mongo, lecture seule, jusqu'à la bascule |
| `notify.sh` | point unique de notification, **à brancher sur un canal réel** |
## Installation sur le VPS
```sh
install -d /opt/myinfomate /var/backups/myinfomate
cp -r backup /opt/myinfomate/
install -m 600 backup/systemd/myim-backup.env.example /etc/myim-backup.env
# renseigner BACKUP_DEST et BACKUP_ALERT_WEBHOOK
cp backup/systemd/*.service backup/systemd/*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now myim-backup.timer myim-backup-freshness.timer myim-restore-check.timer
systemctl start myim-backup.service && journalctl -u myim-backup.service -n 30
```
`rclone` est requis dès que `BACKUP_DEST` est un remote (`gcs:`, `s3:`).
## Pourquoi systemd et pas cron
cron échoue en silence. `OnFailure=` appelle `notify.sh` sur tout échec, et les logs vont
dans journald. `Persistent=true` rattrape le tir si la machine était éteinte à l'heure prévue.
## Pourquoi pas Hangfire
Huit jobs récurrents existent déjà dans l'application ([Startup.cs](../../Startup.cs)). Mais une
sauvegarde de base pilotée par l'app qui dépend de cette base ne tourne pas quand l'app est
tombée — c'est-à-dire le jour où on en a besoin. Le timer reste indépendant du conteneur.
## Les trois modes de panne, et ce qui les couvre
| Panne | Couverture |
|---|---|
| Le script échoue | `OnFailure=``notify.sh` |
| Le script ne tourne plus depuis des semaines | `myim-backup-freshness.timer` |
| Les dumps existent mais ne se restaurent pas | `myim-restore-check.timer`, mensuel |
Le deuxième est le mode réel, et c'est le seul qu'un `OnFailure` n'attrape pas.
## Décisions
**`ContentEmbeddings` exclu** (données seulement, le schéma reste) : régénérable depuis le
contenu CMS, et mesuré à 12 Mo → 1,7 Mo sur la base de dev. Contrepartie : réindexer après
restauration, étape 4 de RESTORE.md.
**Rétention en deux étages** : 7 quotidiens sur le VPS, hebdo/mensuels par lifecycle rule côté
bucket. Le VPS ne garde pas son propre historique, puisque c'est lui qu'on suppose perdu.
**Un dump global, pas un par client.** Une restauration par client se fait par extraction
depuis le dump global (`extract-instance.sh`). Des sauvegardes par client multiplieraient par
N les jobs à planifier, les échecs à surveiller et les restaurations à tester, pour la même
capacité de récupération.
## La destination — arrêtée le 2026-09-07
| | |
|---|---|
| Projet GCP | `myinfomate-backups` (séparé de `mymuseum-3b97f`, qui porte la prod) |
| Bucket | `gs://unov-myinfomate-backups`, `EU` multi-région, versioning activé, accès public interdit |
| Préfixes | `pg/` pour les dumps Postgres, `media/` pour les médias |
| Identité | service account `backup-writer`, `objectCreator` + `objectViewer` |
| Rétention | lifecycle rule : suppression à 400 jours **sous `pg/` uniquement**, versions périmées à 30 jours |
**Le service account ne peut pas supprimer, et c'est vérifié** — pas seulement voulu. Test du
2026-09-07 : l'écriture passe, le `rm` renvoie `HTTPError 403 storage.objects.delete denied`.
À rejouer si les rôles changent :
```sh
export CLOUDSDK_CONFIG=$(mktemp -d) # config isolée, ne touche pas au compte courant
gcloud auth activate-service-account --key-file=backup-writer.json
gcloud storage cp t.txt gs://unov-myinfomate-backups/pg/t.txt # doit réussir
gcloud storage rm gs://unov-myinfomate-backups/pg/t.txt # doit échouer en 403
```
Deux conséquences voulues de cette absence de droit de suppression :
- la rotation longue **ne peut pas** venir du VPS : seule la lifecycle rule efface, côté Google,
hors d'atteinte d'une machine compromise ;
- la copie des médias ne pourra être qu'**additive** (`rclone copy`, jamais `sync`). Un fichier
effacé côté Firebase reste dans la sauvegarde — le bon comportement pour un backup, garanti
par les permissions et non par la discipline.
Bonus non demandé : GCS active une `soft_delete_policy` de 7 jours par défaut sur les nouveaux
buckets. Seconde couche sous la précédente.
## Ce qui manque encore
- **`rclone` n'est pas installé ni configuré sur le VPS** — c'est la dernière pièce entre les
scripts et une sauvegarde réelle.
- **`notify.sh` n'est branché sur rien** — sans canal réel, les trois surveillances écrivent
dans le journal et personne ne les lit.
- **La copie des médias a été faite une fois, le 2026-09-07 — mais elle n'est pas planifiée.**
1,43 Go, 2407 objets, serveur à serveur, 170 Mio/s :
```sh
gcloud storage rsync -r gs://mymuseum-3b97f.appspot.com gs://unov-myinfomate-backups/media/
```
Vérifiée : **taille identique à l'octet** des deux côtés. Le seul objet non copié est
`pictures/65ccc67265373befd15be511/`, un marqueur de dossier de 0 octet créé par la console
Firebase — `rsync` l'ignore à juste titre.
⚠️ Toujours `rsync` **sans** `--delete-unmatched-destination-objects` : un fichier effacé côté
Firebase doit rester dans la sauvegarde.
**Comment la planifier — décision du 2026-09-07.** Storage Transfer Service a été envisagé puis
écarté : il aurait fallu 5 attributions de rôles IAM et un agent de service à qui Google
recommande le droit de suppression, pour automatiser une commande à relancer toutes les
quelques semaines sur des données qui bougent de **~2 ressources par mois** (mesuré : 9 créées
en 5 mois). Disproportionné. Le mécanisme reste ce `rsync`, qui tourne avec un compte humain et
ne demande **aucun changement de permission**. Il sera ajouté aux timers du VPS en même temps
que la sauvegarde Postgres — un seul endroit, une seule mécanique.
- ✅ ~~Le bucket des médias n'a pas le versioning~~**activé le 2026-09-07**, avec purge des
versions non courantes à 30 jours. Le filet est donc en place **avant** que
`Firebase:StorageBucket` soit renseignée — jour où `Delete` commencera réellement à supprimer
les blobs. Aujourd'hui la clé est vide : supprimer une ressource dans manager-app retire la
ligne en base et **laisse le fichier**, ce qui est la source des orphelins.

View File

@ -1,99 +0,0 @@
# Restauration — procédure
> Document à lire **sous stress**. Il ne cherche pas à expliquer, il dit quoi taper.
> La procédure est rejouée automatiquement chaque mois par `myim-restore-check.timer`.
**Règle unique, jamais enfreinte : la base de production n'est jamais la cible d'un `pg_restore`.**
On restaure toujours dans une base neuve, on regarde, puis on décide.
---
## Scénario A — sinistre complet (la machine est perdue)
1. Récupérer le dump depuis la destination hors site :
```sh
rclone lsl "$BACKUP_DEST" | sort -k2 | tail -5 # choisir, ne pas prendre le dernier à l'aveugle
rclone copy "$BACKUP_DEST/<fichier>.dump" .
```
⚠️ Vérifier la **date** avant de restaurer. Le dernier dump peut être postérieur à
l'incident et contenir déjà les dégâts.
2. Monter Postgres avec ses extensions (`postgis` **et** `vector`), puis appliquer le schéma :
```sh
docker compose -f docker-compose.preprod.yaml up -d postgres
dotnet ef database update
```
3. Restaurer :
```sh
docker exec -i myim_postgres pg_restore -U mym -d my_info_mate \
--no-owner --no-privileges < <fichier>.dump
```
4. **Relancer l'indexation des embeddings.** `ContentEmbeddings` est exclu des dumps
(régénérable, et ça divise leur taille par 7). Sans cette étape le guide IA répond
à côté sans le dire.
5. Vérifier avant d'ouvrir au public : login manager-app, une app visiteur avec sa clé API,
un parcours, une carte, un PDF, **un quiz avec ses questions**. C'est la liste du point 20
du §1quinquies, et les deux derniers sont ceux qui échouent en silence.
## Scénario B — un seul client à récupérer
Un client a supprimé son contenu, ou demande ses données (RGPD). **On ne restaure pas la
prod** : on extrait depuis le dump global.
```sh
./extract-instance.sh <dump-global>.dump <instanceId>
```
Le script restaure le dump dans une base jetable, la réduit à cette instance, la re-dumpe,
et affiche les comptages obtenus. Lire ces comptages : c'est là qu'on voit qu'un quiz est
arrivé sans ses questions.
Puis réinjecter en connaissance de cause — les ID sont conservés, donc une ligne encore
présente en prod provoquera un conflit de clé. Choisir explicitement entre écraser et
ignorer ; il n'y a pas de bon défaut.
### Ce que l'extraction ne contient pas
- **Les médias.** Les blobs vivent dans Firebase Storage, la base n'en garde que le chemin.
Une extraction seule rend un catalogue de liens morts.
- **Les lignes sans `InstanceId`** (logs d'audit système) : elles n'appartiennent à aucun
client, donc à aucun export. C'est volontaire — sans ça elles fuiteraient dans l'export
de *chaque* client.
- `SubscriptionPlans`, `spatial_ref_sys` et `__EFMigrationsHistory` repartent entiers :
données de référence, pas données client.
## Vérifier une sauvegarde sans rien restaurer
```sh
./restore-check.sh <fichier>.dump
```
Restaure dans une base jetable, compare les comptages table par table avec la base courante,
puis supprime la base jetable. Aucun effet sur la prod. Écart attendu : `ContentEmbeddings` à 0.
---
## Pièges rencontrés pour de vrai
**« collation version mismatch »** — bloque la création de toute base, donc toute
restauration. Arrivé deux fois en local (09/08 et 07/09). Dans cet ordre ; le second seul
masque le symptôme sans le corriger :
```sql
REINDEX DATABASE "<base>";
ALTER DATABASE "<base>" REFRESH COLLATION VERSION;
```
Peut concerner `template1` et `postgres`, pas seulement la base applicative — et c'est
`template1` qui bloque `createdb`.
**`pg_restore --list /dev/stdin`** échoue (« did not find magic string ») : un pipe n'est pas
seekable. Sans argument, `pg_restore` lit stdin correctement.
**`docker exec -i` dans une boucle** consomme le stdin de la boucle. Ne pas passer `-i` quand
la commande n'en a pas besoin.
**`"InstanceId" <> 'x'` ne supprime pas les `NULL`.** En SQL `NULL <> 'x'` vaut `NULL`, pas
vrai. Utiliser `is distinct from`. Repéré parce que la somme des extractions par client
dépassait le global de 37 lignes.

View File

@ -1,53 +0,0 @@
#!/bin/sh
# Sauvegarde applicative de la base Postgres, en complément du snapshot OVH.
# Lecture seule vis-à-vis de la base : pg_dump prend un snapshot MVCC, il ne verrouille rien.
set -eu
CONTAINER=${CONTAINER:-myim_postgres}
DB=${POSTGRES_DATABASE:-my_info_mate}
DB_USER=${POSTGRES_USER:-mym}
OUT=${OUT:-./backups}
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
FILE="$OUT/${DB}_${STAMP}.dump"
mkdir -p "$OUT"
# Les embeddings sont régénérables depuis le contenu CMS : on garde le schéma, pas les vecteurs.
docker exec "$CONTAINER" pg_dump \
--username="$DB_USER" --dbname="$DB" \
--format=custom --compress=6 --no-owner --no-privileges \
--exclude-table-data='public."ContentEmbeddings"' \
> "$FILE"
# Un dump tronqué ne doit jamais entrer dans la rotation.
if ! docker exec -i "$CONTAINER" pg_restore --list < "$FILE" > /dev/null; then
echo "Dump illisible, supprimé : $FILE" >&2
rm -f "$FILE"
exit 1
fi
echo "$FILE ($(du -h "$FILE" | cut -f1))"
# Copie hors du VPS. C'est cette étape qui fait la différence entre un fichier
# et une sauvegarde : un dump posé sur la machine qui héberge la base disparaît
# avec elle. BACKUP_DEST vide = rien n'est protégé, le script le dit.
if [ -z "${BACKUP_DEST:-}" ]; then
echo "BACKUP_DEST vide : le dump reste sur cette machine, il ne protege de rien." >&2
else
# Un remote rclone s'ecrit "nom:chemin", sans double slash — d'ou le test sur
# le prefixe de chemin et non sur "://", qui ne matchait aucun remote.
case "$BACKUP_DEST" in
/*|./*|../*) mkdir -p "$BACKUP_DEST" && cp "$FILE" "$BACKUP_DEST/" ;;
*:*) rclone copy --immutable "$FILE" "$BACKUP_DEST" ;;
*) mkdir -p "$BACKUP_DEST" && cp "$FILE" "$BACKUP_DEST/" ;;
esac
echo "copie -> $BACKUP_DEST"
fi
# Rétention : 7 quotidiens. Les hebdo/mensuels sont gérés côté stockage objet
# par une lifecycle rule, pas ici — le VPS ne doit pas être le gardien de l'historique.
ls -1t "$OUT"/${DB}_*.dump 2>/dev/null | tail -n +8 | while read -r old; do
echo "rotation: $old"
rm -f "$old"
done

View File

@ -1,40 +0,0 @@
#!/bin/sh
# Le mode de panne reel n'est pas "le script plante", c'est "le script ne tourne
# plus depuis trois semaines et personne ne l'a vu". Un OnFailure n'attrape pas
# ca : il faut regarder l'age de la derniere sauvegarde, pas un code de sortie.
set -eu
MAX_AGE_HOURS=${MAX_AGE_HOURS:-48}
DEST=${BACKUP_DEST:?export BACKUP_DEST}
is_remote() {
case "$1" in
/*|./*|../*) return 1 ;;
*:*) return 0 ;;
*) return 1 ;;
esac
}
if is_remote "$DEST"; then
LATEST=$(rclone lsl "$DEST" | sort -k2 | tail -1)
else
LATEST=$(ls -1t "$DEST" 2>/dev/null | head -1)
fi
if [ -z "${LATEST:-}" ]; then
echo "Aucune sauvegarde dans $DEST" >&2
exit 1
fi
if is_remote "$DEST"; then
AGE_S=$(( $(date +%s) - $(date -d "$(echo "$LATEST" | awk '{print $2" "$3}')" +%s) ))
else
AGE_S=$(( $(date +%s) - $(stat -c %Y "$DEST/$LATEST") ))
fi
AGE_H=$(( AGE_S / 3600 ))
if [ "$AGE_H" -gt "$MAX_AGE_HOURS" ]; then
echo "Derniere sauvegarde vieille de ${AGE_H}h (seuil ${MAX_AGE_HOURS}h) : $LATEST" >&2
exit 1
fi
echo "OK — derniere sauvegarde il y a ${AGE_H}h"

View File

@ -1,38 +0,0 @@
#!/bin/sh
# Sauvegarde de la prod MongoDB. LECTURE SEULE : ce script ne contient
# volontairement aucun mongorestore, pour qu'aucune erreur de manipulation
# ne puisse écrire dans la prod.
set -eu
URI=${MONGO_URI:?export MONGO_URI="mongodb://user:pass@host:27017/?authSource=admin"}
DB=${MONGO_DB:-TabletDb}
LABEL=${LABEL:-prod}
OUT=${OUT:-./backups/mongo}
COLLECTIONS=${COLLECTIONS:-Instances Configurations Sections Resources Users Devices}
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
DEST="$OUT/${LABEL}_${STAMP}"
mkdir -p "$DEST"
# MSYS_NO_PATHCONV : sous Git Bash, un argument commençant par "/" est réécrit en chemin Windows.
# Variable ignorée ailleurs, le script reste utilisable tel quel sur le VPS.
HOST_DEST=$(cd "$DEST" && { pwd -W 2>/dev/null || pwd; })
run() { MSYS_NO_PATHCONV=1 docker run --rm -w /out -v "$HOST_DEST:/out" mongo:6 "$@"; }
# 1. Archive BSON : le vrai filet, types préservés, restaurable dans un Mongo local.
run mongodump --uri="$URI" --db="$DB" --gzip --archive=dump.gz
# 2. JSON par collection : lisible, diffable, et sert aux comptages de contrôle
# après la bascule Postgres (point 15 du §1quinquies).
for c in $COLLECTIONS; do
run mongoexport --uri="$URI" --db="$DB" --collection="$c" \
--jsonArray --pretty --out="$DB.$c.json"
done
echo "--- comptages ---"
for c in $COLLECTIONS; do
n=$(run mongosh "$URI" --quiet --eval "db.getSiblingDB('$DB').$c.countDocuments()")
printf '%-18s %s\n' "$c" "$n"
done | tee "$DEST/counts.txt"
echo "$DEST"

View File

@ -1,87 +0,0 @@
#!/bin/sh
# Extrait les données d'une seule instance depuis un dump global.
#
# Ne touche JAMAIS la base de production : le dump est restauré dans une base
# jetable, réduit à l'instance demandée, puis re-dumpé. La cible d'un
# pg_restore est toujours une base neuve.
#
# usage: extract-instance.sh <dump-global> <instanceId>
set -eu
FILE=${1:?usage: extract-instance.sh <dump-global> <instanceId>}
INSTANCE=${2:?usage: extract-instance.sh <dump-global> <instanceId>}
CONTAINER=${CONTAINER:-myim_postgres}
DB_USER=${POSTGRES_USER:-mym}
OUT=${OUT:-./backups}
SCRATCH="extract_$(date -u +%s)"
psql() { docker exec "$CONTAINER" psql -qtAX -v ON_ERROR_STOP=1 --username="$DB_USER" --dbname="$1" -c "$2"; }
docker exec "$CONTAINER" createdb --username="$DB_USER" "$SCRATCH"
trap 'docker exec "$CONTAINER" dropdb --username="$DB_USER" --if-exists "$SCRATCH" >/dev/null 2>&1' EXIT
docker exec -i "$CONTAINER" pg_restore \
--username="$DB_USER" --dbname="$SCRATCH" --no-owner --no-privileges < "$FILE"
if [ "$(psql "$SCRATCH" "select count(*) from \"Instances\" where \"Id\" = '$INSTANCE'")" != "1" ]; then
echo "Instance '$INSTANCE' absente de ce dump. Instances disponibles :" >&2
psql "$SCRATCH" 'select "Id" || ' ' || "Name" from "Instances"' >&2 || true
exit 1
fi
# "is distinct from" et non "<>" : une ligne dont InstanceId est NULL n appartient
# a aucune instance, et "<> x" ne la supprime pas — elle fuiterait dans l export
# de chaque client. Repere en verifiant que la somme des extractions egale le global.
#
# Suppression des enfants avant les parents. Les 7 premières tables n'ont pas
# d'InstanceId : elles se rattachent par leur parent, et c'est là que se cachent
# les oublis silencieux — un quiz sans ses questions, un agenda sans ses events.
psql "$SCRATCH" "
begin;
delete from \"QuizQuestions\" q where
(q.\"SectionQuizId\" is not null and q.\"SectionQuizId\" not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE'))
or (q.\"GuidedStepId\" is not null and q.\"GuidedStepId\" not in (select s.\"Id\" from \"GuidedSteps\" s join \"GuidedPaths\" p on p.\"Id\" = s.\"GuidedPathId\" where p.\"InstanceId\" = '$INSTANCE'))
or (q.\"SectionQuizId\" is null and q.\"GuidedStepId\" is null);
delete from \"MapAnnotations\" a where
(a.\"SectionEventId\" is not null and a.\"SectionEventId\" not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE'))
or (a.\"ProgrammeBlockId\" is not null and a.\"ProgrammeBlockId\" not in (select b.\"Id\" from \"ProgrammeBlocks\" b join \"Sections\" s on s.\"Id\" = b.\"SectionEventId\" where s.\"InstanceId\" = '$INSTANCE'));
delete from \"GuidedSteps\" s where s.\"GuidedPathId\" not in (select \"Id\" from \"GuidedPaths\" where \"InstanceId\" = '$INSTANCE');
delete from \"EventAgendas\" e where coalesce(e.\"SectionAgendaId\", e.\"SectionEventId\") not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE');
delete from \"GeoPoints\" g where coalesce(g.\"SectionMapId\", g.\"SectionEventId\") not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE');
delete from \"ProgrammeBlocks\" b where b.\"SectionEventId\" not in (select \"Id\" from \"Sections\" where \"InstanceId\" = '$INSTANCE');
delete from \"AppConfigurationLinks\" l where
l.\"ConfigurationId\" not in (select \"Id\" from \"Configurations\" where \"InstanceId\" = '$INSTANCE')
or (l.\"ApplicationInstanceId\" is not null and l.\"ApplicationInstanceId\" not in (select \"Id\" from \"ApplicationInstances\" where \"InstanceId\" = '$INSTANCE'))
or (l.\"DeviceId\" is not null and l.\"DeviceId\" not in (select \"Id\" from \"Devices\" where \"InstanceId\" = '$INSTANCE'));
delete from \"ApplicationInstances\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"GuidedPaths\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"Sections\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"Devices\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"Configurations\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"Resources\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"Users\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"ApiKeys\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"AuditLogs\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"ContentEmbeddings\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"PushNotifications\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"QuestionThemeMonthlies\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"VisitEvents\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"VisitorQuestions\" where \"InstanceId\" is distinct from '$INSTANCE';
delete from \"Instances\" where \"Id\" <> '$INSTANCE';
commit;"
mkdir -p "$OUT"
DEST="$OUT/instance_${INSTANCE}_$(date -u +%Y%m%dT%H%M%SZ).dump"
docker exec "$CONTAINER" pg_dump --username="$DB_USER" --dbname="$SCRATCH" \
--format=custom --compress=6 --no-owner --no-privileges > "$DEST"
echo "--- contenu extrait ---"
psql "$SCRATCH" "
select table_name || ' ' || (xpath('/row/c/text()',
query_to_xml('select count(*) as c from public.\"' || table_name || '\"', false, true, '')))[1]::text::int
from information_schema.tables
where table_schema = 'public' and table_type = 'BASE TABLE'
order by table_name" | awk '{ if ($2 > 0) printf " %-24s %s\n", $1, $2 }'
echo "$DEST ($(du -h "$DEST" | cut -f1))"

View File

@ -1,16 +0,0 @@
#!/bin/sh
# Point unique de notification. Appele par les units OnFailure.
# A brancher sur le canal reel (webhook, mail, MQTT) — un echo dans le journal
# ne reveille personne.
set -eu
SUBJECT=${1:-"Sauvegarde MyInfoMate en echec"}
BODY=$(cat)
logger -t myim-backup "$SUBJECT: $BODY"
if [ -n "${BACKUP_ALERT_WEBHOOK:-}" ]; then
curl -sS --max-time 15 -X POST "$BACKUP_ALERT_WEBHOOK" \
-H 'Content-Type: application/json' \
--data "$(printf '{"text":%s}' "$(printf '%s\n%s' "$SUBJECT" "$BODY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \
|| echo "Notification webhook en echec" >&2
fi

View File

@ -1,29 +0,0 @@
#!/bin/sh
# Restaure un dump dans une base jetable et compare les comptages à la base source.
# C'est ce script qui fait la différence entre "on a des fichiers" et "on a des sauvegardes".
set -eu
FILE=${1:?usage: restore-check.sh <fichier.dump>}
CONTAINER=${CONTAINER:-myim_postgres}
DB=${POSTGRES_DATABASE:-my_info_mate}
DB_USER=${POSTGRES_USER:-mym}
SCRATCH="restore_check_$(date -u +%s)"
psql_scratch() { docker exec "$CONTAINER" psql -qtAX --username="$DB_USER" --dbname="$1" -c "$2"; }
docker exec "$CONTAINER" createdb --username="$DB_USER" "$SCRATCH"
trap 'docker exec "$CONTAINER" dropdb --username="$DB_USER" --if-exists "$SCRATCH"' EXIT
docker exec -i "$CONTAINER" pg_restore \
--username="$DB_USER" --dbname="$SCRATCH" --no-owner --no-privileges < "$FILE"
TABLES=$(psql_scratch "$DB" "select tablename from pg_tables where schemaname='public' order by tablename")
printf '%-28s %10s %10s\n' TABLE SOURCE RESTORE
echo "$TABLES" | while read -r t; do
[ -n "$t" ] || continue
a=$(psql_scratch "$DB" "select count(*) from public.\"$t\"")
b=$(psql_scratch "$SCRATCH" "select count(*) from public.\"$t\"")
if [ "$a" = "$b" ]; then mark=""; else mark=" <-- ECART"; fi
printf '%-28s %10s %10s%s\n' "$t" "$a" "$b" "$mark"
done

View File

@ -1,7 +0,0 @@
[Unit]
Description=Notification d'echec de sauvegarde
[Service]
Type=oneshot
EnvironmentFile=/etc/myim-backup.env
ExecStart=/bin/sh -c 'journalctl -u myim-backup.service -n 40 --no-pager | /opt/myinfomate/backup/notify.sh "Sauvegarde PostgreSQL en echec"'

View File

@ -1,8 +0,0 @@
[Unit]
Description=Controle de fraicheur de la derniere sauvegarde
OnFailure=myim-backup-failure.service
[Service]
Type=oneshot
EnvironmentFile=/etc/myim-backup.env
ExecStart=/opt/myinfomate/backup/check-freshness.sh

View File

@ -1,9 +0,0 @@
[Unit]
Description=Controle quotidien de fraicheur des sauvegardes
[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -1,11 +0,0 @@
# Copier en /etc/myim-backup.env (chmod 600) et renseigner.
CONTAINER=myim_postgres
POSTGRES_USER=mym
POSTGRES_DATABASE=my_info_mate
OUT=/var/backups/myinfomate
# Destination hors VPS. Vide = le dump reste sur la machine et ne protege de rien.
# Tranche le 2026-09-07 : projet GCP myinfomate-backups, bucket EU multi-region.
# Le remote rclone "gcs" doit etre configure avec la cle du service account
# backup-writer, qui peut deposer et lire mais PAS supprimer (verifie : 403).
BACKUP_DEST=gcs:unov-myinfomate-backups/pg
BACKUP_ALERT_WEBHOOK=

View File

@ -1,10 +0,0 @@
[Unit]
Description=Sauvegarde PostgreSQL MyInfoMate
# Un echec doit reveiller quelqu'un. cron echoue en silence, systemd non.
OnFailure=myim-backup-failure.service
[Service]
Type=oneshot
EnvironmentFile=/etc/myim-backup.env
WorkingDirectory=/opt/myinfomate/backup
ExecStart=/opt/myinfomate/backup/backup-postgres.sh

View File

@ -1,11 +0,0 @@
[Unit]
Description=Sauvegarde PostgreSQL quotidienne
[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=900
# Rattrape le tir si la machine etait eteinte a l'heure prevue.
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -1,11 +0,0 @@
[Unit]
Description=Test de restauration de la derniere sauvegarde
# Une sauvegarde jamais restauree n'est pas une sauvegarde. Le test manuel du
# jour d'installation ne vaut que pour ce jour-la.
OnFailure=myim-backup-failure.service
[Service]
Type=oneshot
EnvironmentFile=/etc/myim-backup.env
WorkingDirectory=/opt/myinfomate/backup
ExecStart=/bin/sh -c '/opt/myinfomate/backup/restore-check.sh "$(ls -1t "$OUT"/*.dump | head -1)"'

View File

@ -1,9 +0,0 @@
[Unit]
Description=Test de restauration mensuel
[Timer]
OnCalendar=Sun *-*-01..07 04:30:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -1,16 +0,0 @@
# Copier en .env et remplir. Le .env n'est jamais versionne.
#
# Generer un mot de passe fort (PowerShell) :
# -join ((1..32) | % { (([char[]](48..57+65..90+97..122)) | Get-Random) })
#
# Ne JAMAIS reutiliser mym/mym : cette valeur a servi sur un Postgres expose
# publiquement entre juin et octobre 2025, qui a encaisse 1 074 465 tentatives
# d'authentification. Voir README.md, section « Ce qui a mal tourne ».
POSTGRES_USER=myim
POSTGRES_PASSWORD=
POSTGRES_DATABASE=my_info_mate
# Liaison du port. 127.0.0.1 = joignable seulement depuis l'hote et par tunnel SSH.
# En production, laisser 127.0.0.1. Ne jamais mettre 0.0.0.0 ni "5432:5432".
POSTGRES_BIND=127.0.0.1

View File

@ -1,50 +0,0 @@
<#
Genere le SQL qui cree le PREMIER compte SuperAdmin.
Pourquoi ce detour plutot qu'un INSERT direct avec un mot de passe :
les mots de passe sont haches en scrypt (PasswordUtils, 16384 iterations),
impossible a calculer hors .NET. Le token de reinitialisation, lui, est un
simple SHA-256 hexadecimal (PasswordTokenHelper). On laisse donc l'application
faire le hachage scrypt via set-password : le format est garanti correct, et
personne d'autre que toi ne connait le mot de passe.
MigrationController est [Authorize(Policy = SuperAdmin)] et la migration ne
cree aucun SuperAdmin (Mongo n'a pas de champ Role). Ce compte est donc le
prealable oblige a toute migration de donnees.
#>
param(
[string]$Email,
# L'instance doit EXISTER : TokensService.cs:82 fait Instances.Find(user.InstanceId)
# puis lit .PinCode sans test de nullite -> 500 au login sinon.
[string]$InstanceId = 'sa-bootstrap'
)
if ([string]::IsNullOrWhiteSpace($Email)) { $Email = Read-Host "Email du compte SuperAdmin" }
$Email = $Email.ToLower() # Authenticate fait login.email.ToLower()
$token = -join ((1..40) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) })
$sha = [System.Security.Cryptography.SHA256]::Create()
$hash = ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($token)) |
ForEach-Object { $_.ToString('x2') }) -join ''
Write-Host ""
Write-Host "=== ETAPE A : sur une base VIERGE uniquement, creer l'instance d'echafaudage ===" -ForegroundColor Cyan
Write-Host " (a sauter si tu passes -InstanceId d'une instance deja migree)"
Write-Host @"
insert into "Instances" ("Id","Name","DateCreation")
values ('$InstanceId','Bootstrap SuperAdmin', now());
"@
Write-Host "=== ETAPE B : creer le compte ===" -ForegroundColor Cyan
Write-Host @"
insert into "Users"
("Id","Email","Password","FirstName","LastName","Token","DateCreation","InstanceId","Role","PasswordTokenHash","PasswordTokenExpiresAt")
values
('sa-bootstrap-0001','$Email','pending','Super','Admin','',now(),'$InstanceId',0,'$hash', now() + interval '48 hours');
"@
Write-Host "=== ETAPE C : poser le mot de passe (service demarre) ===" -ForegroundColor Cyan
Write-Host " run.cmd 3-set-password.ps1 -Token $token"
Write-Host ""
Write-Host "Le token vaut 48 h et n'est ecrit dans aucun fichier : garde cette fenetre." -ForegroundColor Yellow
Write-Host "N'oublie pas de COMMITER les INSERT : DBeaver n'auto-commite pas toujours." -ForegroundColor Yellow

View File

@ -1,55 +0,0 @@
<#
Lance manager-service EN LOCAL, branche sur un Mongo source et sur la base
cible (preprod ou prod) a travers un tunnel SSH.
Pourquoi en local plutot que sur le serveur : la migration ne demande pas de
tester un deploiement, et builder l'image evite d'affronter en meme temps le
Dockerfile de manager-app (Flutter 3.7 = Dart 2.19, alors que le pubspec exige
>= 3.1) et la source NuGet git.dev-espaces-naturels.lu qui repond 401.
Rien n'est ecrit dans appsettings : les variables d'environnement ASP.NET Core
(double underscore = ':') prennent le dessus. Aucun secret ne part dans le depot.
#>
param(
[string]$MongoUri = "mongodb://localhost:27018", # Mongo SOURCE (dump restaure)
[string]$PgHost = "127.0.0.1",
[int] $PgPort = 5433, # extremite locale du tunnel SSH
[string]$EnvFile = "$PSScriptRoot\.env"
)
$ErrorActionPreference = "Stop"
if (-not (Test-Path $EnvFile)) { throw "Fichier introuvable : $EnvFile (copier .env.example)" }
$cfg = @{}
Get-Content $EnvFile | Where-Object { $_ -match '^\s*[^#].*=' } | ForEach-Object {
$k,$v = $_ -split '=', 2 ; $cfg[$k.Trim()] = $v.Trim()
}
foreach ($k in 'POSTGRES_USER','POSTGRES_PASSWORD','POSTGRES_DATABASE') {
if ([string]::IsNullOrWhiteSpace($cfg[$k])) { throw "$k absent ou vide dans $EnvFile" }
}
# Le repo est 3 niveaux au-dessus : Deployment/provisioning -> Deployment -> ManagerService -> repo
$repo = (Resolve-Path "$PSScriptRoot\..\..\..").Path
Write-Host "Verifications..." -ForegroundColor Cyan
$mongoPort = if ($MongoUri -match ':(\d+)') { [int]$Matches[1] } else { 27017 }
if (-not (Test-NetConnection 127.0.0.1 -Port $mongoPort -InformationLevel Quiet -WarningAction SilentlyContinue)) {
throw "Mongo source injoignable sur $mongoPort. Le conteneur est-il demarre ?"
}
Write-Host " Mongo source $mongoPort : OK" -ForegroundColor Green
if (-not (Test-NetConnection $PgHost -Port $PgPort -InformationLevel Quiet -WarningAction SilentlyContinue)) {
throw "Postgres injoignable sur ${PgHost}:${PgPort}. Le tunnel SSH est-il ouvert ?"
}
Write-Host " Postgres ${PgHost}:${PgPort} : OK" -ForegroundColor Green
$env:ASPNETCORE_ENVIRONMENT = "Development"
$env:ConnectionStrings__TabletDb = $MongoUri
$env:ConnectionStrings__PostgresConnection = "Host=$PgHost;Port=$PgPort;Database=$($cfg['POSTGRES_DATABASE']);Username=$($cfg['POSTGRES_USER']);Password=$($cfg['POSTGRES_PASSWORD'])"
Write-Host ""
Write-Host " TabletDb -> $MongoUri" -ForegroundColor Yellow
Write-Host " PostgresConnection -> ${PgHost}:${PgPort}/$($cfg['POSTGRES_DATABASE'])" -ForegroundColor Yellow
Write-Host " Swagger -> http://localhost:5000/swagger" -ForegroundColor Cyan
Write-Host ""
Set-Location $repo
dotnet run --project ManagerService

View File

@ -1,29 +0,0 @@
<#
Consomme le token de reinitialisation et pose le mot de passe.
Invoke-RestMethod construit le JSON depuis un objet : aucun probleme de
guillemets (cmd ne connait pas l'apostrophe) ni de caracteres speciaux
(&& est l'operateur d'enchainement de cmd).
#>
param(
[Parameter(Mandatory=$true)][string]$Token,
[string]$Password,
[string]$BaseUrl = "http://localhost:5000"
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
if ([string]::IsNullOrWhiteSpace($Password)) { $Password = Read-PlainPassword "Mot de passe a poser (8 car. min)" }
if ($Password.Length -lt 8) { throw "Le code exige 8 caracteres minimum." }
try {
$r = Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/Authentication/set-password" `
-ContentType "application/json" `
-Body (@{ token = $Token; newPassword = $Password } | ConvertTo-Json)
Write-Host " $r" -ForegroundColor Green
Write-Host " Etape suivante : run.cmd 4-get-jwt.ps1 -Email <ton email>" -ForegroundColor Cyan
} catch {
Write-Host " ECHEC : $(Get-HttpErrorBody $_)" -ForegroundColor Red
Write-Host " Causes usuelles : token deja consomme, expire (48 h)," -ForegroundColor Yellow
Write-Host " ou INSERT non commite dans le client SQL." -ForegroundColor Yellow
exit 1
}

View File

@ -1,35 +0,0 @@
<# S'authentifie et ecrit le jeton dans jwt.txt (non versionne). #>
param(
[Parameter(Mandatory=$true)][string]$Email,
[string]$Password,
[string]$BaseUrl = "http://localhost:5000"
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
if ([string]::IsNullOrWhiteSpace($Password)) { $Password = Read-PlainPassword "Mot de passe" }
try {
$auth = Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/Authentication/Authenticate" `
-ContentType "application/json" `
-Body (@{ email = $Email.ToLower(); password = $Password } | ConvertTo-Json)
} catch {
Write-Host " ECHEC : $(Get-HttpErrorBody $_)" -ForegroundColor Red
Write-Host " 'Object reference not set' = l'instance du compte n'existe pas en base" -ForegroundColor Yellow
Write-Host " (TokensService.cs:82 lit .PinCode sans test de nullite)." -ForegroundColor Yellow
exit 1
}
$jwt = $auth.access_token
if (-not $jwt) { Write-Host "Reponse inattendue :"; $auth | ConvertTo-Json -Depth 4; exit 1 }
Write-Utf8NoBom "$PSScriptRoot\jwt.txt" $jwt
# JsonStringEnumConverter (Startup.cs) serialise les enums en chaine : role vaut
# "SuperAdmin", pas 0. On accepte les deux formes.
$role = "$($auth.role)"
Write-Host " Jeton ecrit dans jwt.txt ($($jwt.Length) caracteres)" -ForegroundColor Green
if ($role -eq "SuperAdmin" -or $role -eq "0") {
Write-Host " Role : $role -- MigrationController acceptera." -ForegroundColor Green
} else {
Write-Host " Role : $role -- il faut SuperAdmin pour migrer." -ForegroundColor Red
}

View File

@ -1,71 +0,0 @@
<#
Joue la migration Mongo -> Postgres.
Sans -Apply : dry run global (n'ecrit rien).
Avec -Apply : joue pour de vrai, instance par instance, de la plus petite a la
plus grande c'est ce qui fait qu'un defaut casse sur le plus petit perimetre
possible. Chaque appel est une transaction unique : soit tout, soit rien.
Un dry run ne peut detecter AUCUNE violation de contrainte : il n'ecrit rien,
donc SaveChanges n'est jamais appele. Un NOT NULL viole ne sort qu'avec -Apply.
C'est arrive le 07/09/2026 (LastName null sur test@email.be).
Compter 2 a 5 min : la sonde HEAD interroge chaque blob Firebase, 30 a la
fois. Elle tourne AUSSI en dry run.
#>
param(
[switch]$Apply,
[string]$InstanceId,
[string]$BaseUrl = "http://localhost:5000"
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$jwt = Get-Jwt $PSScriptRoot
$headers = @{ Authorization = "Bearer $jwt" }
function Invoke-Migration($dryRun, $instance) {
$uri = "$BaseUrl/api/migration/run?dryRun=$($dryRun.ToString().ToLower())"
if ($instance) { $uri += "&instanceId=$instance" }
$label = if ($dryRun) { "DRY RUN" } else { "REEL" }
if ($instance) { $label += " / $instance" }
Write-Host "=== $label ===" -ForegroundColor Cyan
$t0 = Get-Date
try {
$r = Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType "application/json" -Body "{}"
} catch {
Write-Host " ECHEC HTTP : $(Get-HttpErrorBody $_)" -ForegroundColor Red
Write-Host " Un 500 signifie que la transaction a ete ANNULEE : la base est intacte." -ForegroundColor Yellow
return $false
}
$sec = [int]((Get-Date) - $t0).TotalSeconds
Write-Host " duree : $sec s" -ForegroundColor Gray
$m = $r.migrated
($m.PSObject.Properties | Where-Object { $_.Value -is [int] -and $_.Value -gt 0 } |
ForEach-Object { "$($_.Name)=$($_.Value)" }) -join ", " | ForEach-Object { Write-Host " migre : $_" }
Write-Host " erreurs : $($r.errors.Count) | signalees : $($r.skipped.Count)"
$r.errors | Select-Object -First 5 | ForEach-Object { Write-Host " ! $_" -ForegroundColor Yellow }
if ($r.fatalError) { Write-Host " FATAL : $($r.fatalError)" -ForegroundColor Red; return $false }
Write-Host ""
return $true
}
if (-not $Apply) {
Write-Host "Mode simulation. Ajouter -Apply pour ecrire." -ForegroundColor Yellow
Invoke-Migration $true $InstanceId | Out-Null
exit 0
}
if ($InstanceId) { if (Invoke-Migration $false $InstanceId) { exit 0 } else { exit 1 } }
# ⚠️ L'API ne donne pas le volume par instance : cet ordre est ALPHABETIQUE,
# pas croissant. Or jouer la plus petite d'abord est ce qui fait qu'un defaut
# casse sur le plus petit perimetre — c'est ce qui a permis de trouver le
# LastName null sur 1 utilisateur au lieu de 10, le 07/09/2026.
# Pour maitriser l'ordre, enchainer les appels avec -InstanceId a la main.
Write-Host "Ordre ALPHABETIQUE (l'API ne donne pas les volumes)." -ForegroundColor Yellow
Write-Host "Pour commencer par la plus petite, appeler -InstanceId une par une." -ForegroundColor Yellow
Write-Host ""
$ids = (Invoke-RestMethod -Uri "$BaseUrl/api/Instance" -Headers $headers) |
Sort-Object { $_.name } | Select-Object -ExpandProperty id
foreach ($id in $ids) { if (-not (Invoke-Migration $false $id)) { exit 1 } }
Write-Host "Toutes les instances sont passees." -ForegroundColor Green

View File

@ -1,159 +0,0 @@
# Provisionner une base MyInfoMate — préprod ou production
Le flux complet, du serveur nu à une base Postgres remplie avec les données MongoDB.
Éprouvé le **2026-09-07** sur la préprod `51.77.222.154` : 4 instances, 315 sections,
2383 ressources migrées sans perte.
> Ces scripts sont en PowerShell mais s'exécutent depuis `cmd` via `run.cmd`
> dans `cmd`, un `.ps1` n'est pas exécuté, il est passé à l'association de fichier
> et ne fait rien.
>
> ```
> run.cmd 1-bootstrap-superadmin.ps1 -Email moi@exemple.be
> ```
---
## 0. Prérequis sur le serveur
```bash
docker image prune -f # 7,5 Go récupérés sur la préprod : 3 ans de builds
df -h / # il faut ~1 Go pour les images
```
## 1. La base
```bash
cp .env.example .env # puis remplir POSTGRES_PASSWORD
docker-compose -p myim-preprod -f docker-compose.postgres.yml up -d
```
**Le `-p` n'est pas optionnel.** Compose est en 1.21.0 (2018) sur ce serveur : il ne
connaît pas la clé `name:`, et sans `-p` il déduit le projet du dossier courant —
lancé depuis `/home/debian`, il réutiliserait le volume `debian_postgres-data`,
c'est-à-dire la base cassée d'octobre 2025.
Vérifier que le port n'est **pas** exposé :
```bash
docker port myim_pg # doit afficher 127.0.0.1:5432, jamais 0.0.0.0
```
## 2. Le schéma
Aucun `Migrate()` au démarrage : les migrations s'appliquent explicitement.
Depuis le poste de dev, à travers un tunnel SSH :
```bash
ssh -f -N -L 5433:127.0.0.1:5432 -p 55522 user@serveur
```
```powershell
$env:MIGRATIONS_CONNECTION = "Host=127.0.0.1;Port=5433;Database=my_info_mate;Username=myim;Password=..."
dotnet ef database update --project ManagerService
```
`MIGRATIONS_CONNECTION` est prévue pour ça dans `MyInfoMateDbContextFactory`.
Contrôler ensuite que `postgis` **et** `vector` sont bien installées :
```sql
select extname, extversion from pg_extension;
```
## 3. Le Mongo source
`MigrationController` lit un MongoDB **vivant**, pas des fichiers. Un export JSON
ne suffit donc pas : il faut le restaurer dans un Mongo jetable.
```bash
docker run -d --name myim_mongo_src -p 27018:27017 mongo:6
docker run --rm --network container:myim_mongo_src -v "$PWD:/src:ro" mongo:6 \
mongorestore --uri mongodb://localhost:27017 --gzip --archive=/src/dump.gz --drop
```
⚠️ **Ne jamais pointer sur le Mongo de production.** Les `DatabaseService` exposent
`InsertOne`/`ReplaceOne`/`DeleteOne`, et `dryRun` ne protège que Postgres.
## 4. Le compte SuperAdmin
`MigrationController` est `[Authorize(Policy = SuperAdmin)]`, et la migration n'en
crée aucun — MongoDB n'a pas de champ `Role`. C'est donc le préalable obligé.
```
run.cmd 1-bootstrap-superadmin.ps1 -Email moi@exemple.be
```
Il affiche deux `INSERT` à exécuter dans le client SQL (**penser à commiter**), puis
la commande de l'étape suivante. Les mots de passe étant hachés en scrypt, on laisse
l'application faire le hachage via `set-password` : personne d'autre que toi ne
connaît le mot de passe.
## 5. Le service, en local
```
run.cmd 2-run-service-local.ps1
```
Le brancher en local évite de builder une image, donc d'affronter au même moment le
`Dockerfile` de manager-app (Flutter 3.7 = Dart 2.19, alors que le `pubspec` exige
`>= 3.1`) et la source NuGet `git.dev-espaces-naturels.lu` qui répond 401.
Puis, dans une autre fenêtre :
```
run.cmd 3-set-password.ps1 -Token <le token de l'etape 4>
run.cmd 4-get-jwt.ps1 -Email moi@exemple.be
```
## 6. La migration
```
run.cmd 5-migrate.ps1 # simulation
run.cmd 5-migrate.ps1 -Apply -InstanceId <id> # pour de vrai, une instance
```
**Commencer par la plus petite instance.** Chaque appel est une transaction unique :
en cas d'échec, rien n'est écrit.
> ⚠️ **Un dry run ne peut détecter aucune violation de contrainte** : il n'écrit rien,
> donc `SaveChanges` n'est jamais appelé. Un `NOT NULL` violé ne sort qu'au run réel.
> C'est exactement ce qui est arrivé le 07/09/2026 — `LastName` null sur
> `test@email.be` — et commencer par la plus petite instance l'a fait apparaître
> en 3 secondes sur 1 utilisateur au lieu de 10.
Compter 2 à 5 min : la sonde HEAD interroge chaque blob Firebase, 30 à la fois.
Elle tourne **aussi** en dry run.
## 7. Le backfill des tailles
```
POST /api/Resource/backfill-storage?dryRun=true puis dryRun=false
```
La sonde HEAD est **instable sous charge** : une ressource peut arriver à
`SizeBytes = 0` alors que son blob existe. Le 07/09, le backfill a rattrapé une
ressource sur 2383 et confirmé 3 vrais 404. Un `SizeBytes` à 0 n'est donc jamais
une preuve de blob manquant.
## 8. Nettoyage et vérifications
```sql
-- rattacher le SuperAdmin à une vraie instance, puis supprimer l'échafaudage
update "Users" set "InstanceId" = '<instance réelle>' where "Id" = 'sa-bootstrap-0001';
delete from "Instances" where "Id" = 'sa-bootstrap';
```
Puis `pg_dump -Fc` (voir `../backup/`), et les vérifications qui ne se voient qu'à
l'œil : login manager, une app visiteur avec sa clé API, un parcours, une carte,
un PDF, un quiz avec ses questions.
---
## Ce qui a mal tourné, et qu'on ne refait pas
| | |
|---|---|
| **`5432:5432`** sur le Postgres précédent | exposé à l'internet avec `mym`/`mym` du 06/06 au 22/10/2025 : **1 074 465 échecs d'authentification** sur autant de noms distincts. Le compte `mym` n'a jamais été tenté — c'est le seul hasard qui a sauvé la base. D'où le `127.0.0.1` du compose |
| **Aucune limite de log** | un seul conteneur avait atteint 516 Mo sur un disque de 20 Go |
| **Image `postgres:16` nue** | sans PostGIS, la migration `UpdateCoordinatesToGeom` ne passe pas : le schéma était bloqué à 14 migrations sur 68 |
| **`mym`/`mym`** | ne jamais réutiliser ce couple |

View File

@ -1,37 +0,0 @@
# Fonctions partagees par les scripts d'appel HTTP.
# PowerShell 5.1 ne remplit pas ErrorDetails sur une erreur HTTP : sans lire le
# flux de reponse on n'obtient qu'un « 400 Bad Request » opaque, alors que le
# message du serveur est la seule information utile.
function Get-HttpErrorBody($err) {
if ($err.ErrorDetails.Message) { return $err.ErrorDetails.Message }
try {
$resp = $err.Exception.Response
if ($resp) {
$reader = New-Object IO.StreamReader($resp.GetResponseStream())
$body = $reader.ReadToEnd(); $reader.Close()
if ($body) { return $body }
}
} catch { }
return $err.Exception.Message
}
function Read-PlainPassword($prompt) {
$secure = Read-Host $prompt -AsSecureString
[Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
}
# Set-Content -Encoding utf8 ecrit un BOM en PowerShell 5.1, et un BOM dans un
# en-tete Authorization invalide le jeton.
function Write-Utf8NoBom($path, $text) {
[System.IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($false)))
}
function Get-Jwt($dir) {
$p = Join-Path $dir "jwt.txt"
if (-not (Test-Path $p)) { throw "jwt.txt absent. Lancer d'abord 4-get-jwt.ps1" }
# [string] est indispensable : la surcharge Replace(char, char) refuse une
# chaine vide. Sans le cast, on tombe dessus et l'appel echoue.
(Get-Content $p -Raw).Replace([string][char]0xFEFF, '').Trim()
}

View File

@ -1,48 +0,0 @@
# Base PostgreSQL de MyInfoMate — preprod comme production.
#
# Lancement (toujours avec -p, voir README) :
# docker-compose -p myim-preprod -f docker-compose.postgres.yml up -d
#
# ⚠️ Compose du serveur 51.77.222.154 = 1.21.0 (2018) : format 3.6 maximum,
# et la cle « name: » n'existe pas. D'ou le -p obligatoire.
version: '3.6'
services:
postgres:
build:
context: ..
dockerfile: Dockerfile.postgres
image: myinfomate/postgres-pgvector:16-3.4
container_name: ${POSTGRES_CONTAINER:-myim_pg}
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DATABASE}
# Liaison sur la boucle locale : joignable par tunnel SSH, jamais depuis
# l'internet. C'est la lecon du Postgres precedent, expose par un simple
# "5432:5432" et martele pendant quatre mois et demi.
ports:
- "${POSTGRES_BIND:-127.0.0.1}:5432:5432"
volumes:
- pg-data:/var/lib/postgresql/data
networks:
- myim
restart: unless-stopped
# Aucun conteneur du serveur n'avait de limite : un seul log avait atteint
# 516 Mo sur un disque de 20 Go, et a probablement tue la base.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pg-data:
networks:
myim:

View File

@ -1,10 +0,0 @@
@echo off
REM Lance un script .ps1 de ce dossier depuis cmd, ou un .ps1 ne s'execute pas.
REM run.cmd 1-bootstrap-superadmin.ps1 -Email moi@exemple.be
if "%~1"=="" (
echo Usage : run.cmd ^<script.ps1^> [arguments]
echo.
dir /b "%~dp0*.ps1"
exit /b 1
)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0%~1" %2 %3 %4 %5 %6 %7 %8 %9