Phase 2.1 : preparation au deploiement public
Rend une instance exposee sur Internet exploitable sans la transformer en infrastructure : le produit reste local-first, sans compte et sans telemetrie. Protection du service : limitation de debit en memoire par client et par famille d'endpoint (health et ready jamais limites), interrupteur SHARING_ENABLED, capacite maximale de partages, taille propre aux partages. X-Forwarded-For n'est cru que si TRUST_PROXY_HEADERS l'autorise, et on lit alors la derniere valeur, celle ajoutee par le proxy. Expiration facultative des partages (24 h a 90 jours, ou jamais) : un lien expire repond 410 et la page publique le dit, sans autre detail. Le proprietaire peut prolonger ; aucune purge automatique. Protection des liens non repertories : robots.txt, X-Robots-Tag et meta robots sur /s/ et /api/, Referrer-Policy no-referrer sur tout le site, redaction des jetons dans les journaux d'acces, share_token porte a 128 bits (les anciens restent valides). Schema gere par Alembic, applique au demarrage. Une base creee avant Alembic est estampillee puis migree : verifie sur la base reelle de developpement, 4 partages inchanges et expires_at NULL. Administration par CLI locale au conteneur (stats, liste, suppression, purge, sauvegarde a chaud via l'API SQLite, restauration prudente) : aucune interface /admin exposee. Le jeton de gestion peut enfin etre exporte dans un fichier .openmind-share puis reimporte depuis un autre navigateur, apres verification aupres du serveur. Il n'est toujours jamais ajoute a un export .mmap. Ajoute une page /privacy dont le texte est centralise, et corrige un bug trouve en observant les logs : Alembic, lance dans le processus applicatif, reappliquait la configuration de logs d'alembic.ini et desactivait les loggers existants, ce qui faisait disparaitre les journaux d'acces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
283
README.md
283
README.md
@@ -127,6 +127,24 @@ Toutes definies dans `.env` (modele : `.env.example`). Aucun secret n'est requis
|
||||
| `CORS_ORIGINS` | vide | Origines externes autorisees, separees par des virgules |
|
||||
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
|
||||
Exposition publique :
|
||||
|
||||
| Variable | Defaut | Role |
|
||||
|---|---|---|
|
||||
| `SHARING_ENABLED` | `true` | `false` coupe entierement le service de partage ; les cartes locales continuent de fonctionner |
|
||||
| `SHARE_CREATE_RATE_LIMIT` | `10/hour` | Limite de creation de partages, par client |
|
||||
| `SHARE_READ_RATE_LIMIT` | `120/minute` | Limite de lecture d'un partage, par client |
|
||||
| `SHARE_WRITE_RATE_LIMIT` | `60/hour` | Limite des mises a jour, changements d'expiration et suppressions |
|
||||
| `MAX_SHARED_MAPS` | `10000` | Nombre total de partages heberges. `0` = illimite |
|
||||
| `MAX_SHARE_SIZE_MB` | `0` | Taille maximale d'un partage. `0` reprend `MAX_MAP_SIZE_MB` |
|
||||
| `DEFAULT_SHARE_EXPIRY_DAYS` | `30` | Duree de vie par defaut d'un nouveau partage. `0` = permanent |
|
||||
| `ALLOW_PERMANENT_SHARES` | `true` | Autorise l'option "Jamais" dans le choix d'expiration |
|
||||
| `TRUST_PROXY_HEADERS` | `false` | Croire `X-Forwarded-For`. **A n'activer que derriere un reverse proxy de confiance** |
|
||||
|
||||
Format des limites de debit : `<nombre>/<fenetre>`, ou la fenetre vaut `second`,
|
||||
`minute`, `hour` ou `day`. Une valeur vide, `0` ou `off` desactive la limite
|
||||
concernee.
|
||||
|
||||
Le nom du produit est centralise : cote backend via `APP_NAME`, cote frontend
|
||||
dans `frontend/src/config/app.ts`. Le frontend recupere le nom a l'execution via
|
||||
`GET /api/config` : changer `APP_NAME` suffit, sans recompiler.
|
||||
@@ -145,14 +163,47 @@ Deux stockages **totalement distincts** :
|
||||
Le dossier `./data` est monte en **bind mount** (aucun volume Docker nomme) :
|
||||
les fichiers sont donc directement lisibles depuis l'hote.
|
||||
|
||||
### Sauvegarde a chaud
|
||||
|
||||
SQLite fonctionne en mode WAL : les ecritures recentes vivent dans
|
||||
`openmind.db-wal`, et un simple `cp openmind.db` peut donc produire une copie
|
||||
incoherente. La commande de sauvegarde utilise l'API de sauvegarde de SQLite,
|
||||
qui produit une base complete **pendant que l'application tourne** :
|
||||
|
||||
```bash
|
||||
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind-$(date +%F).db
|
||||
```
|
||||
|
||||
Le fichier obtenu se trouve sur l'hote dans `./data/backups/`. C'est une base
|
||||
SQLite ordinaire : elle s'ouvre avec n'importe quel outil, sans OpenMind.
|
||||
|
||||
A froid, une archive du dossier reste valable :
|
||||
|
||||
```bash
|
||||
docker compose stop
|
||||
tar czf openmind-partages-$(date +%F).tar.gz data/
|
||||
docker compose start
|
||||
```
|
||||
|
||||
SQLite fonctionne en mode WAL : `openmind.db`, `openmind.db-wal` et
|
||||
`openmind.db-shm` doivent etre sauvegardes ensemble.
|
||||
### Restauration
|
||||
|
||||
```bash
|
||||
docker compose exec openmind python -m app.cli restore /app/data/backups/openmind-2026-09-10.db --yes
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
La commande verifie d'abord que le fichier est bien une base OpenMind, place une
|
||||
copie de securite de la base actuelle dans `./data/backups/pre-restore-*.db`,
|
||||
puis ecrit le contenu restaure. Le redemarrage n'est pas indispensable mais reste
|
||||
recommande. Pour une restauration sans aucun risque, arretez le conteneur, copiez
|
||||
le fichier a la place de `openmind.db` (en supprimant les `-wal` / `-shm`
|
||||
associes), puis redemarrez.
|
||||
|
||||
> **Ne modifiez pas `openmind.db` depuis l'hote pendant que le conteneur tourne.**
|
||||
> Les fichiers `-wal` / `-shm` de SQLite sont partages entre les processus qui
|
||||
> ont la base ouverte : une ecriture concurrente depuis l'hote, a travers un bind
|
||||
> mount, peut etre perdue ou desynchroniser l'application du fichier. Passez par
|
||||
> la CLI dans le conteneur, ou arretez le conteneur.
|
||||
|
||||
Redemarrer le serveur n'a **aucun impact** sur les cartes privees locales.
|
||||
|
||||
@@ -168,7 +219,7 @@ Deux secrets distincts sont generes cote serveur avec le module Python
|
||||
|
||||
| Jeton | Entropie | Ou il vit | A quoi il sert |
|
||||
|---|---|---|---|
|
||||
| `share_token` | `secrets.token_urlsafe(12)`, environ 96 bits | dans l'URL publique `/s/<token>` | **lecture seule** |
|
||||
| `share_token` | `secrets.token_urlsafe(16)`, 128 bits | dans l'URL publique `/s/<token>` | **lecture seule** |
|
||||
| `management_token` | `secrets.token_urlsafe(32)`, environ 256 bits | **uniquement** dans l'IndexedDB du proprietaire | modification et suppression |
|
||||
|
||||
Regles appliquees :
|
||||
@@ -182,6 +233,55 @@ Regles appliquees :
|
||||
supprimer un partage.
|
||||
- Le lien est **non repertorie** : il n'apparait dans aucune liste publique,
|
||||
mais toute personne qui le connait peut consulter la carte.
|
||||
- Les jetons emis avant le passage a 128 bits restent valides : la recherche se
|
||||
fait par valeur exacte, sans contrainte de longueur.
|
||||
|
||||
Protection du lien :
|
||||
|
||||
- `robots.txt` interdit `/s/` et `/api/`, et les reponses de ces chemins portent
|
||||
`X-Robots-Tag: noindex, nofollow, noarchive`. La page publique ajoute la balise
|
||||
`<meta name="robots">` correspondante. Ce n'est pas une mesure de securite :
|
||||
c'est le jeton qui autorise la lecture.
|
||||
- `Referrer-Policy: no-referrer` est applique a tout le site. Un lien externe
|
||||
clique depuis une carte partagee ne transmet donc pas l'URL `/s/<token>` au
|
||||
site de destination. Les liens des noeuds s'ouvrent avec
|
||||
`rel="noopener noreferrer nofollow"`.
|
||||
- Les journaux applicatifs remplacent les jetons par `[REDACTED]` dans les
|
||||
chemins `/s/...` et `/api/shares/...`, et designent un partage par une
|
||||
empreinte courte non reversible. Les autres routes restent lisibles pour le
|
||||
diagnostic. **Un reverse proxy place devant l'instance a ses propres journaux**,
|
||||
qui ne dependent pas d'OpenMind : configurez-les separement si vous ne voulez
|
||||
pas y voir apparaitre les jetons.
|
||||
|
||||
### Expiration
|
||||
|
||||
Un partage peut expirer automatiquement. La duree est choisie a la creation
|
||||
(24 heures, 7 jours, 30 jours, 90 jours, ou jamais) et modifiable ensuite depuis
|
||||
le dialogue de partage. `DEFAULT_SHARE_EXPIRY_DAYS` fixe la valeur par defaut de
|
||||
l'instance, `ALLOW_PERMANENT_SHARES=false` retire l'option "Jamais".
|
||||
|
||||
- Un partage expire repond `410 Gone` et la page publique affiche *Ce partage a
|
||||
expire*, sans autre detail.
|
||||
- Il n'est pas supprime pour autant : son proprietaire peut le prolonger, et
|
||||
l'administrateur le purger.
|
||||
- Les partages crees avant l'introduction de l'expiration ont `expires_at = NULL`
|
||||
et restent donc valides indefiniment.
|
||||
|
||||
### Recuperer la main sur un partage
|
||||
|
||||
Le jeton de gestion vit uniquement dans l'IndexedDB du navigateur d'origine.
|
||||
Depuis le dialogue de partage, **Exporter la cle de gestion** produit un petit
|
||||
fichier `.openmind-share` :
|
||||
|
||||
```json
|
||||
{ "version": 1, "shareToken": "...", "managementToken": "...", "title": "..." }
|
||||
```
|
||||
|
||||
Depuis un autre navigateur, ouvrez la carte correspondante puis **Importer une
|
||||
cle de gestion** : la cle est verifiee aupres du serveur concerne avant d'etre
|
||||
enregistree localement. Ce fichier contient un secret : il n'est jamais ajoute
|
||||
automatiquement a un export `.mmap`, et son export est toujours une action
|
||||
explicite.
|
||||
|
||||
Cycle de vie :
|
||||
|
||||
@@ -203,15 +303,146 @@ serveur.
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/health` | - | Etat du service |
|
||||
| `GET` | `/api/config` | - | Nom de l'application et limites |
|
||||
| `GET` | `/api/ready` | - | Le service peut-il repondre (acces SQLite verifie) |
|
||||
| `POST` | `/api/shares` | - | Creer un partage |
|
||||
| `GET` | `/api/shares/{share_token}` | - | Lire une carte partagee |
|
||||
| `PUT` | `/api/shares/{share_token}` | `X-Management-Token` | Mettre a jour |
|
||||
| `PUT` | `/api/shares/{share_token}` | `X-Management-Token` | Mettre a jour le contenu |
|
||||
| `PATCH` | `/api/shares/{share_token}` | `X-Management-Token` | Modifier l'expiration |
|
||||
| `POST` | `/api/shares/{share_token}/verify` | `X-Management-Token` | Verifier une cle de gestion |
|
||||
| `DELETE` | `/api/shares/{share_token}` | `X-Management-Token` | Supprimer |
|
||||
|
||||
`/api/health` et `/api/ready` ne sont soumis a aucune limitation de debit : le
|
||||
healthcheck du conteneur reste fiable meme sous charge. Toutes les routes de
|
||||
partage repondent `503` quand `SHARING_ENABLED=false`, et `429` avec un en-tete
|
||||
`Retry-After` quand une limite est atteinte.
|
||||
|
||||
Documentation interactive : `http://localhost:8080/api/docs`.
|
||||
|
||||
---
|
||||
|
||||
## Administration d'une instance
|
||||
|
||||
Il n'existe volontairement **aucune interface `/admin` exposee sur Internet**.
|
||||
L'administration passe par une CLI locale au conteneur, donc reservee a qui a
|
||||
deja un acces machine :
|
||||
|
||||
```bash
|
||||
docker compose exec openmind python -m app.cli stats
|
||||
docker compose exec openmind python -m app.cli shares list --limit 20
|
||||
docker compose exec openmind python -m app.cli shares list --expired
|
||||
docker compose exec openmind python -m app.cli shares delete <share-token> --yes
|
||||
docker compose exec openmind python -m app.cli shares purge-expired --dry-run
|
||||
docker compose exec openmind python -m app.cli shares purge-expired --yes
|
||||
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind.db
|
||||
docker compose exec openmind python -m app.cli restore /app/data/backups/openmind.db --yes
|
||||
```
|
||||
|
||||
| Commande | Role |
|
||||
|---|---|
|
||||
| `stats` | Nombre de partages (total, actifs, expires), capacite, taille de la base, plus ancien et plus recent |
|
||||
| `shares list` | Listage. Options `--limit`, `--active`, `--expired` |
|
||||
| `shares delete <token>` | Suppression administrative, sans jeton de gestion. Demande confirmation sauf `--yes` |
|
||||
| `shares purge-expired` | Supprime physiquement les partages expires. `--dry-run` affiche sans rien supprimer |
|
||||
| `backup <chemin>` | Sauvegarde a chaud via l'API SQLite. `--yes` ecrase un fichier existant |
|
||||
| `restore <chemin>` | Restauration prudente : verification, copie de securite, puis remplacement |
|
||||
|
||||
Aucune commande n'affiche le jeton de gestion, son hash, ni le contenu des
|
||||
cartes. Les jetons de partage y sont masques (`sbcY...BCyw`) : suffisant pour
|
||||
identifier une ligne, insuffisant pour ouvrir le lien.
|
||||
|
||||
---
|
||||
|
||||
## Deploiement derriere un reverse proxy
|
||||
|
||||
Le conteneur ecoute simplement sur `8080` en HTTP. Le TLS est termine par le
|
||||
reverse proxy ; OpenMind n'embarque ni certificat ni Traefik, et aucune solution
|
||||
n'est imposee.
|
||||
|
||||
Points a respecter :
|
||||
|
||||
1. Transmettre l'hote et le protocole d'origine.
|
||||
2. Activer `TRUST_PROXY_HEADERS=true` **uniquement** dans ce cas : la limitation
|
||||
de debit lit alors la derniere valeur de `X-Forwarded-For`, celle ajoutee par
|
||||
votre proxy. Laisse a `false`, un client direct pourrait forger cet en-tete et
|
||||
changer d'identite a chaque requete.
|
||||
3. Ne pas mettre en cache `/api/`.
|
||||
|
||||
Aucune variable `PUBLIC_BASE_URL` n'est necessaire : le serveur ne fabrique
|
||||
jamais d'URL absolue (il renvoie un chemin relatif `/s/<token>`), et le lien
|
||||
affiche est construit dans le navigateur a partir de `window.location.origin`.
|
||||
Un utilisateur sur `https://mind.example.com` obtient donc un lien sur ce
|
||||
domaine, sans configuration.
|
||||
|
||||
Exemple Nginx :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name mind.example.com;
|
||||
|
||||
# certificats geres par votre outillage habituel
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 12m; # >= MAX_MAP_SIZE_MB
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exemple Traefik (labels sur le service `openmind` du `docker-compose.yml`) :
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.openmind.rule=Host(`mind.example.com`)
|
||||
- traefik.http.routers.openmind.entrypoints=websecure
|
||||
- traefik.http.routers.openmind.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.openmind.loadbalancer.server.port=8080
|
||||
```
|
||||
|
||||
Les journaux du reverse proxy contiennent les URL demandees, jetons de partage
|
||||
compris. Si cela vous derange, configurez leur format ou leur retention : c'est
|
||||
independant d'OpenMind.
|
||||
|
||||
---
|
||||
|
||||
## Migrations de schema
|
||||
|
||||
Le schema SQLite est gere par **Alembic**. Les migrations sont appliquees
|
||||
automatiquement au demarrage du conteneur, avant qu'uvicorn ne serve la moindre
|
||||
requete :
|
||||
|
||||
```
|
||||
docker compose up -d -> docker-entrypoint.sh -> alembic upgrade head -> uvicorn
|
||||
```
|
||||
|
||||
Trois cas, tous pris en charge sans intervention :
|
||||
|
||||
| Etat de depart | Ce qui se passe |
|
||||
|---|---|
|
||||
| Base absente | Toutes les revisions s'appliquent |
|
||||
| Base creee avant Alembic (`create_all`) | La revision initiale est **estampillee** (aucune donnee touchee), puis les suivantes s'appliquent |
|
||||
| Base deja a jour | Rien a faire |
|
||||
|
||||
Manuellement :
|
||||
|
||||
```bash
|
||||
docker compose exec openmind python -m app.db.migrations # applique head
|
||||
docker compose exec openmind alembic current # revision courante
|
||||
docker compose exec openmind alembic history # historique
|
||||
```
|
||||
|
||||
| Revision | Apport |
|
||||
|---|---|
|
||||
| `0001_initial_schema` | Table `shared_maps` telle qu'elle existait avant Alembic |
|
||||
| `0002_share_expiry` | Colonne `expires_at` nullable et son index |
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalites
|
||||
|
||||
**Tableau de bord** : creation, import, recherche, date de derniere
|
||||
@@ -547,6 +778,13 @@ suppression d'un partage, refus avec un mauvais jeton de gestion, refus sans
|
||||
jeton, partage inexistant, payload invalide, limites de taille et de noeuds,
|
||||
non-divulgation du jeton de gestion, repli SPA, acceptation des documents v3 et
|
||||
v4 (ordre des freres, cotes, relations) et refus d'une relation incoherente.
|
||||
S'y ajoutent, pour l'exposition publique : limitation de debit (creation,
|
||||
lecture, exemption du healthcheck, desactivation), coupure du partage, capacite
|
||||
maximale, taille propre aux partages, expiration et prolongation, verification
|
||||
d'une cle de gestion, resolution de l'adresse client derriere un proxy,
|
||||
en-tetes d'indexation et de referrer, redaction des jetons dans les journaux,
|
||||
migrations Alembic sur base neuve et sur base d'avant Alembic, et l'ensemble des
|
||||
commandes de la CLI d'administration.
|
||||
|
||||
Les tests frontend couvrent : stockage IndexedDB, serialisation et validation
|
||||
d'import, migrations v1 -> v4, non-mutation et idempotence de la normalisation,
|
||||
@@ -573,23 +811,26 @@ modeles, assainissement des URL.
|
||||
├── backend/
|
||||
│ ├── requirements.txt
|
||||
│ ├── pyproject.toml # configuration ruff et pytest
|
||||
│ ├── alembic.ini # configuration Alembic (URL prise dans DATABASE_URL)
|
||||
│ ├── migrations/ # revisions de schema
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # assemblage uniquement
|
||||
│ │ ├── cli.py # administration locale : stats, partages, sauvegarde
|
||||
│ │ ├── config.py
|
||||
│ │ ├── logging_config.py
|
||||
│ │ ├── middleware.py # en-tetes de securite, limite de taille
|
||||
│ │ ├── logging_config.py # format des logs et redaction des jetons
|
||||
│ │ ├── middleware.py # en-tetes de securite, cache, limite de taille
|
||||
│ │ ├── web.py # service des fichiers statiques du frontend
|
||||
│ │ ├── api/ # routes health, config, shares
|
||||
│ │ ├── db/ # moteur, session, initialisation du schema
|
||||
│ │ ├── api/ # routes health, ready, config, shares
|
||||
│ │ ├── db/ # moteur, session, migrations
|
||||
│ │ ├── models/ # SharedMap (SQLAlchemy)
|
||||
│ │ ├── schemas/ # validation Pydantic
|
||||
│ │ └── services/ # jetons, logique de partage, erreurs metier
|
||||
│ │ └── services/ # jetons, partage, limitation de debit, adresse client
|
||||
│ └── tests/
|
||||
└── frontend/
|
||||
├── index.html
|
||||
├── public/ # manifest PWA, service worker, icones
|
||||
└── src/
|
||||
├── config/app.ts # nom du produit et constantes
|
||||
├── config/ # nom du produit, constantes, texte de confidentialite
|
||||
├── types/map.ts # modele metier versionne
|
||||
├── db/ # schema Dexie
|
||||
├── services/ # depots, serialisation, client API
|
||||
@@ -600,6 +841,7 @@ modeles, assainissement des URL.
|
||||
│ ├── editor/ # canvas, noeud, barre d'outils, panneaux, layout, bilateral, relations, themes
|
||||
│ ├── templates/ # galerie et definitions des modeles
|
||||
│ ├── maps/ # tableau de bord
|
||||
│ ├── privacy/ # page /privacy
|
||||
│ ├── export/ # PNG, SVG, PDF, impression
|
||||
│ └── sharing/ # dialogue de partage, page publique
|
||||
├── utils/
|
||||
@@ -666,9 +908,24 @@ modeles, assainissement des URL.
|
||||
racines suivantes ne sont pas recentrees individuellement.
|
||||
- **Types de noeuds reserves** : `note`, `image`, `group`, `link` et `task` sont
|
||||
acceptes par le format, mais la V1 ne produit que `root` et `standard`.
|
||||
- **Migrations de schema SQLite** : la V1 initialise le schema avec
|
||||
`create_all`, une seule table. Alembic n'est pas utilise ; il faudra
|
||||
l'introduire au premier changement de schema.
|
||||
- **Limitation de debit en memoire** : les compteurs vivent dans le processus.
|
||||
Un redemarrage du conteneur les remet a zero, et plusieurs workers uvicorn
|
||||
auraient chacun les leurs. C'est un garde-fou contre la saturation triviale
|
||||
d'une instance publique, pas un quota strict. Un reverse proxy peut ajouter sa
|
||||
propre limitation si le besoin est plus exigeant.
|
||||
- **Partage expire et information** : un lien expire repond `410` et non `404`,
|
||||
ce qui revele qu'un partage a existe a cette adresse. C'est assume : sans cela,
|
||||
la page publique ne pourrait pas expliquer la difference entre un lien expire
|
||||
et un lien erronne. Rien d'autre n'est divulgue.
|
||||
- **Purge des partages expires** : elle n'est pas automatique. Un partage expire
|
||||
cesse d'etre lisible mais reste en base jusqu'a un
|
||||
`shares purge-expired`, ce qui laisse a son proprietaire la possibilite de le
|
||||
prolonger. Planifiez la commande si vous voulez liberer l'espace regulierement.
|
||||
- **Ecriture concurrente sur la base SQLite** : ne modifiez pas `openmind.db`
|
||||
depuis l'hote pendant que le conteneur tourne. Les fichiers `-wal` / `-shm`
|
||||
sont partages entre processus ; a travers un bind mount, une ecriture externe
|
||||
peut etre perdue ou desynchroniser l'application du fichier. Utilisez la CLI
|
||||
dans le conteneur, ou arretez le conteneur.
|
||||
- **Guides d'alignement** : ils apparaissent pendant un deplacement lorsqu'un
|
||||
bord ou un centre coincide (a 6 px pres) avec celui d'un autre noeud. Ils sont
|
||||
purement visuels : ils n'aimantent pas le noeud. Pour aimanter, activez
|
||||
|
||||
Reference in New Issue
Block a user