Documentation
geocrowd collecte des points géolocalisés et les publie. Une API publique permet de les lire et d'en proposer, un back-office permet de les modérer, et un site public facultatif les affiche sur une carte. Cette page décrit l'installation, les collections, l'API, le site public et l'administration.
Installation
Prérequis
- PHP 8.1 ou supérieur, avec les extensions
pdo_sqlite(base de données),gd(traitement des images) etopenssl(chiffrement des secrets de double authentification). Ces extensions sont disponibles sur la plupart des hébergements mutualisés. - L'extension
exif, facultative, sert à corriger l'orientation des photos prises avec un téléphone. - Un dossier
data/accessible en écriture par PHP. La base SQLite, les images et la clé de chiffrement y sont créées. Ces emplacements se changent dansconfig.php. - Un serveur à l'heure : la double authentification utilise l'horloge, avec une tolérance de 30 secondes.
- HTTPS, recommandé : sans HTTPS, le cookie de session du back-office n'est pas marqué
Secureet les mots de passe circulent en clair.
Ni Composer, ni Node, ni serveur de messagerie, ni tâche planifiée.
Copie des fichiers
- Télécharger l'archive du dépôt et la décompresser.
- Relire
config.php(voir Configuration). - Copier l'ensemble sur l'hébergement, par FTP ou autrement, à la racine du site ou dans un sous-dossier.
- Rendre
data/accessible en écriture par PHP (droits755ou775selon l'hébergeur). - Ouvrir
https://votre-instance.org/admin/. Le premier écran crée le premier compte admin : nom, adresse, mot de passe de 10 caractères au moins, puis configuration de la double authentification. - Créer une collection dans la page Collections du back-office (voir Éditeur de schéma). L'API l'accepte dès son enregistrement.
- Facultatif : activer le site public dans la page Site public. Tant qu'il est désactivé, la racine de l'instance renvoie vers le back-office.
Une installation dans un sous-dossier fonctionne sans réglage : l'API répond alors sous https://votre-instance.org/sous-dossier/api/….
Apache et .htaccess
Le fichier .htaccess fourni est prévu pour Apache avec mod_rewrite. Il :
- interdit l'accès direct aux dossiers internes
src/,data/,bin/(ainsi quecollections/etimports/, s'ils existent) et au fichierconfig.php; - interdit l'accès aux fichiers et dossiers cachés, dont le nom commence par un point (
.git,.env…), sauf.well-known/; - désactive le listage des dossiers ;
- sert tels quels les fichiers statiques (
admin/,front/,vendor/) et envoie toutes les autres requêtes àindex.php; - ajoute aux fichiers statiques les en-têtes de sécurité si
mod_headersest disponible ; les réponses deindex.phples envoient elles-mêmes ; - compresse les réponses JSON si
mod_deflateest disponible.
L'adresse https://votre-instance.org/data/geocrowd.sqlite doit renvoyer une erreur 403. Si le fichier se télécharge, le .htaccess n'est pas appliqué (fichier absent de la copie, ou AllowOverride désactivé) et la base est exposée.
Le back-office fait cette vérification à chaque ouverture par un compte admin : si la base se télécharge, une alerte s'affiche en haut de page et invite à refuser l'accès au dossier data/ dans la configuration du serveur, ou à placer la base et le dossier de données hors de la racine web (voir Configuration). La vérification porte sur l'emplacement par défaut, data/geocrowd.sqlite.
Nginx
Nginx ne lit pas les fichiers .htaccess. La configuration doit reproduire ses règles : refuser l'accès aux dossiers internes, à config.php et aux fichiers cachés, servir tels quels les fichiers statiques de admin/, front/ et vendor/ avec les en-têtes de sécurité, et envoyer les autres requêtes à index.php, racine comprise : c'est index.php qui sert le site public ou renvoie vers le back-office. Exemple pour une installation à la racine, à adapter (chemin, version de PHP-FPM) :
server {
server_name votre-instance.org;
root /var/www/geocrowd;
# Dossiers et fichiers internes : jamais servis.
location ~ ^/(src|data|collections|bin|imports)(/|$) { return 403; }
location = /config.php { return 403; }
# Fichiers et dossiers cachés (.git, .env…) : jamais servis, sauf .well-known.
location ~ /\.(?!well-known) { return 403; }
# Fichiers statiques (admin/, front/, vendor/) tels quels, avec les en-têtes
# de sécurité ; tout le reste vers index.php, qui envoie ses propres en-têtes.
location / {
index index.html;
try_files $uri $uri/ /index.php$is_args$args;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https://tile.openstreetmap.org; font-src 'self'; connect-src 'self' https://nominatim.openstreetmap.org; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
# En HTTPS seulement.
add_header Strict-Transport-Security "max-age=31536000" always;
}
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~ \.php$ { return 404; }
# Réponses JSON compressées.
gzip on;
gzip_types application/json;
# Taille maximale d'une soumission avec images.
client_max_body_size 20m;
}
Les directives add_header sont placées dans le bloc location / : elles s'appliquent aux fichiers statiques, mais pas aux réponses de index.php, qui portent déjà ces en-têtes. La valeur de Content-Security-Policy doit rester identique à celle de geocrowd (constante Http::CSP de src/Http.php). La vérification de l'adresse /data/geocrowd.sqlite décrite pour Apache vaut aussi pour Nginx.
En local
Le serveur intégré de PHP permet d'essayer geocrowd ou de le développer :
php -S localhost:8000 index.php
Le back-office est alors à http://localhost:8000/admin/ et l'API sous http://localhost:8000/api/. Ce serveur ne lit pas le .htaccess (les fichiers statiques n'y reçoivent donc pas les en-têtes de sécurité) et ne compresse pas les réponses : il n'est pas prévu pour la production.
Mise à jour et sauvegarde
Par défaut, toutes les données sont dans data/ (emplacements réglables par database et data_dir, voir Configuration) :
- geocrowd.sqlite
- La base : points, collections, membres, réglages du site public et journal. Pendant le fonctionnement, elle est accompagnée des fichiers
geocrowd.sqlite-waletgeocrowd.sqlite-shm. - secret.php
- La clé de chiffrement des secrets de double authentification (voir Chiffrement des secrets).
- uploads/
- Les images des points.
Ces éléments se sauvegardent ensemble : geocrowd.sqlite avec ses fichiers -wal et -shm, secret.php et uploads/. Une base restaurée sans le fichier secret.php qui l'accompagnait oblige chaque membre à reconfigurer sa double authentification.
La mise à jour consiste à remplacer les fichiers du programme (index.php, src/, admin/, front/, vendor/, bin/, .htaccess) sans toucher à data/, puis à comparer config.php avec la nouvelle version pour y reporter les nouvelles clés. Une clé absente d'un config.php antérieur prend sa valeur par défaut. Le schéma de la base est mis à jour à la requête suivante.
Les secrets de double authentification enregistrés en clair par une version antérieure sont chiffrés automatiquement à la première requête web qui suit la mise à jour ; le fichier secret.php est créé à ce moment, et s'ajoute aux sauvegardes. Les scripts de bin/ ne font pas ce chiffrement : lancés sur une copie locale de la base, ils n'ont pas la clé du serveur.
Les premières versions décrivaient chaque collection dans un fichier collections/*.json. Au premier lancement d'une version récente, ces fichiers sont importés dans la base, une seule fois, avec leurs points. Les collections se modifient ensuite dans le back-office ; le dossier collections n'est plus lu et peut être supprimé. Un fichier invalide bloque ce premier lancement, avec une erreur dans le journal PHP.
Configuration
Les réglages de l'instance sont dans config.php, à la racine. Les valeurs par défaut conviennent à la plupart des hébergements. Les limites de débit sont décrites dans Limites de débit. Les réglages du site public se font dans le back-office (voir Site public).
<?php
return [
// Fichier SQLite (créé automatiquement) et dossier des images et de la clé de chiffrement
// (secret.php, à sauvegarder avec la base). Le .htaccess les protège ; sur un hébergement
// qui le permet, mieux vaut les placer hors de la racine web, par exemple :
// 'database' => dirname(__DIR__) . '/geocrowd-data/geocrowd.sqlite',
// 'data_dir' => dirname(__DIR__) . '/geocrowd-data',
'database' => __DIR__ . '/data/geocrowd.sqlite',
'data_dir' => __DIR__ . '/data',
// Origines autorisées à appeler l'API publique ('*' = toutes).
'cors_origin' => '*',
// Exiger une clé d'API pour l'API publique.
'api_key' => false,
// Envois publics (soumissions et propositions de modification) : maximum par adresse IP
// (par bloc /64 en IPv6) et par heure, puis pour l'ensemble des visiteurs et par heure.
'submissions_per_hour' => 20,
'submissions_total_per_hour' => 500,
// Au-delà de ce nombre de points et de propositions en attente, les envois publics sont refusés.
'max_pending' => 2000,
// Place maximale des images, en Mo : au-delà, les envois publics avec images sont refusés.
'uploads_max_mb' => 2000,
// Lectures de listes de points par adresse IP et par minute.
'reads_per_minute' => 120,
// Durée de validité d'un lien d'invitation, en jours.
'invitation_days' => 7,
// Nombre maximum de points renvoyés par une requête de lecture.
'max_points' => 100000,
];
| Clé | Défaut | Rôle |
|---|---|---|
database | data/geocrowd.sqlite | Chemin du fichier SQLite, créé au premier appel dans un dossier existant et accessible en écriture par PHP. Sur un hébergement qui le permet, il est préférable de le placer hors de la racine web ; config.php en donne un exemple en commentaire. |
data_dir | data | Dossier des images (uploads/) et de la clé de chiffrement secret.php. Comme database, il peut être placé hors de la racine web. Les dossiers absents sont créés au premier lancement ; ils doivent être accessibles en écriture par PHP. |
cors_origin | '*' | Valeur de l'en-tête Access-Control-Allow-Origin de l'API publique quand les clés ne sont pas exigées : '*' pour toutes les origines, ou une origine unique comme 'https://www.exemple.org'. Voir CORS. |
api_key | false | À true, toute requête à l'API publique doit porter une clé valide, sauf la lecture des images et les requêtes du site public de l'instance. Les clés se créent dans le back-office. Voir Clés d'API. |
submissions_per_hour | 20 | Nombre maximum de soumissions et de propositions de modification par adresse IP, sur une heure glissante, site public compris. En IPv6, la limite porte sur le bloc /64 de l'adresse. Au-delà, l'API répond 429. |
submissions_total_per_hour | 500 | Nombre maximum de soumissions et de propositions de modification sur une heure glissante, pour l'ensemble des visiteurs. Au-delà, l'API répond 429. |
max_pending | 2000 | Nombre de points et de propositions de modification en attente de modération au-delà duquel les envois publics sont refusés (429). |
uploads_max_mb | 2000 | Place maximale occupée par les images, en Mo. Un envoi public avec images qui la dépasserait est refusé (507) ; les envois sans image restent acceptés. |
reads_per_minute | 120 | Nombre maximum de lectures de listes de points (GET …/points) par adresse IP (bloc /64 en IPv6) et par minute, site public compris. Au-delà, l'API répond 429. |
invitation_days | 7 | Durée de validité, en jours, d'un lien d'invitation au back-office. |
max_points | 100000 | Nombre maximum de points renvoyés par GET …/points. Les points les plus récents sont renvoyés en premier. |
Collections
Une collection est un type de point : des bancs, des arbres, des points d'eau. Elle a ses champs, sa modération et ses règles d'envoi. Les collections se créent, se modifient et se suppriment dans le back-office, page Collections, avec l'éditeur de schéma (comptes admin). Elles sont enregistrées dans la base SQLite, avec les points.
geocrowd est livré sans collection. La première se crée après le premier compte admin.
Définition JSON
L'éditeur affiche la définition JSON de la collection pendant la saisie. L'API renvoie cette définition (GET /api/collections), sans l'option moderation. Un formulaire de soumission peut ainsi être construit à partir de la réponse de l'API.
{
"id": "bancs",
"name": "Bancs publics",
"description": "Bancs installés sur l'espace public, avec leur état.",
"moderation": true,
"public_submission": true,
"public_edit": true,
"fields": [
{ "name": "etat", "label": "État", "type": "select", "required": true,
"options": ["Bon", "Abîmé", "Cassé"] },
{ "name": "places", "label": "Places assises", "type": "number", "min": 1, "max": 20 },
{ "name": "dossier", "label": "Avec dossier", "type": "boolean" },
{ "name": "photos", "label": "Photos", "type": "image", "max": 3, "maxSize": 5 }
]
}
Chaque point a une latitude et une longitude, qui ne se déclarent pas comme champs. Chaque champ a un name (nom technique, clé dans les données), un label (libellé affiché) et un type. Les autres options dépendent du type ; seules celles du type choisi sont conservées.
Options de la collection
| Option | Défaut | Rôle |
|---|---|---|
id | (obligatoire) | Identifiant utilisé dans les URL de l'API : lettres minuscules, chiffres, - et _, 50 caractères au plus. Il ne change plus après la création. |
name | (obligatoire) | Nom affiché, 100 caractères au plus. |
description | "" | Texte facultatif, 1000 caractères au plus. Il est renvoyé par l'API et affiché sur le site public. |
moderation | true | À true, les soumissions et les propositions de modification venues de l'API ou du site public attendent une validation dans le back-office. À false, elles sont publiées ou appliquées directement. |
public_submission | true | Autorise la soumission de nouveaux points par l'API publique et le site public. À false, seuls les membres du back-office et l'import OSM ajoutent des points. |
public_edit | valeur de public_submission | Autorise les propositions de modification de points publiés par l'API publique et le site public. |
fields | aucun champ | Liste des champs, 100 au plus, dans l'ordre d'affichage. |
Types de champs
Toutes les options sont facultatives sauf mention contraire. required: true rend le champ obligatoire. Un champ non renseigné n'est pas enregistré et n'apparaît pas dans les réponses de l'API.
| Type | Dans l'éditeur | Valeur en JSON | Options |
|---|---|---|---|
text | Texte court | chaîne sur une ligne | required, maxLength (255 par défaut) |
textarea | Texte long | chaîne sur plusieurs lignes | required, maxLength (2000 par défaut) |
number | Nombre | nombre JSON (pas une chaîne) | required, min, max |
select | Choix unique | un des choix, à l'identique | required, options (obligatoire) |
multiselect | Choix multiples | liste de choix parmi les options | required, options (obligatoire) |
boolean | Oui / non | true ou false | |
date | Date | chaîne AAAA-MM-JJ | required |
url | Adresse web | adresse web complète, en http ou https | required, maxLength (500 par défaut) |
image | Images | liste de fichiers (voir Images) | required, max (nombre d'images, 1 par défaut), maxSize (Mo par image, 5 par défaut) |
ref | Lien vers un point | numéro (entier) d'un point publié | required, collection (obligatoire) : collection du point visé |
Limites des options : maxLength de 1 à 10 000 ; 200 choix au plus, de 200 caractères chacun ; max de 1 à 10 images ; maxSize de 1 à 20 Mo. Les espaces de début et de fin des textes sont supprimés. Les propriétés qui ne correspondent à aucun champ déclaré sont ignorées.
Liens entre points
Un champ ref relie un point à un point publié d'une autre collection ou de la même. Exemple : une collection signalements dont chaque point désigne un banc.
{ "name": "banc", "label": "Banc concerné", "type": "ref", "required": true, "collection": "bancs" }
À la soumission, geocrowd vérifie que le point visé existe et qu'il est publié. En lecture, GET /api/collections/signalements/points?banc=128 renvoie les signalements publiés liés au banc 128. Le site public affiche ces liens dans les deux sens.
Une collection désignée par un champ ref d'une autre collection ne peut pas être supprimée tant que ce champ existe.
Images
Les images sont envoyées en multipart/form-data (voir Soumettre un point). Formats acceptés : JPEG, PNG et WebP. À l'enregistrement, chaque image est réencodée :
- ses métadonnées sont supprimées, dont la géolocalisation de l'appareil et le modèle du téléphone ;
- son orientation est appliquée avant la suppression des métadonnées ;
- son plus grand côté est limité à 2000 pixels ;
- elle reçoit un nom aléatoire de 32 caractères.
Dans les réponses de l'API, la valeur d'un champ image est une liste d'URL complètes vers /api/files/{nom}.
La limite maxSize d'un champ est plafonnée par les réglages PHP de l'hébergement : upload_max_filesize (par fichier) et post_max_size (par requête). Ces valeurs se modifient dans le panneau de l'hébergeur ou dans un fichier .user.ini.
API publique
L'API publique permet de lire les points publiés et d'en proposer de nouveaux, sans compte. Elle échange du JSON encodé en UTF-8. Les points sont renvoyés en GeoJSON, format lu par Leaflet, MapLibre, OpenLayers et QGIS.
| Méthode | Chemin | Rôle |
|---|---|---|
| GET | /api/collections | Liste des collections |
| GET | /api/collections/{id} | Une collection |
| GET | /api/collections/{id}/points | Points publiés, avec filtres |
| GET | /api/collections/{id}/points/{n} | Un point publié |
| POST | /api/collections/{id}/points | Soumission d'un point |
| POST | /api/collections/{id}/points/{n} | Proposition de modification d'un point |
| GET | /api/files/{nom} | Image d'un point |
| GET | /api/front | Configuration du site public |
URL de base
L'API répond sous /api/, à l'adresse d'installation de geocrowd. Dans cette documentation, l'instance est à https://votre-instance.org :
https://votre-instance.org/api/collections
Pour une installation dans un sous-dossier : https://www.exemple.org/geocrowd/api/collections.
Authentification par clé d'API
Par défaut, l'API est ouverte. Avec 'api_key' => true dans config.php, chaque requête doit porter une clé, sauf GET /api/files/{nom} (les noms d'images sont aléatoires, et une balise <img> ne peut pas envoyer de clé) et les requêtes du site public de l'instance.
La clé, de la forme gc_…, s'envoie :
- dans l'en-tête
X-Api-Key: gc_…(recommandé) ; - ou dans le paramètre d'URL
?key=gc_…, pour un lien ou un outil qui ne gère pas les en-têtes. L'URL complète peut alors figurer dans les journaux des serveurs et l'historique du navigateur.
curl -H 'X-Api-Key: gc_VOTRE_CLE' https://votre-instance.org/api/collections
# ou
curl 'https://votre-instance.org/api/collections?key=gc_VOTRE_CLE'
const reponse = await fetch('https://votre-instance.org/api/collections', {
headers: { 'X-Api-Key': 'gc_VOTRE_CLE' },
});
Les clés sont créées par un compte admin, page Clés d'API du back-office. Une clé est limitée à des domaines ou non.
Clé limitée à des domaines (page web)
Usage : une carte en JavaScript sur un autre site, qui interroge l'API depuis le navigateur des visiteurs. La clé est lisible dans le code de la page ; la protection repose sur la liste des domaines autorisés. La requête doit venir d'une page de l'un de ces domaines, ce que geocrowd vérifie avec l'en-tête Origin envoyé par le navigateur, ou à défaut l'en-tête Referer.
Les domaines s'écrivent un par ligne :
exemple.org
www.exemple.org
*.exemple.org # tous les sous-domaines (pas exemple.org lui-même)
localhost # développement local
Seul le nom de domaine compte : le protocole et le port sont ignorés (http://localhost:8080 correspond à localhost). Une adresse complète saisie dans le champ est ramenée à son domaine.
La même clé utilisée sur un autre site reçoit une erreur 403 : le navigateur annonce un autre domaine, et une page web ne peut pas modifier ces en-têtes.
Les en-têtes Origin et Referer sont fixés par le navigateur, mais un programme exécuté hors d'un navigateur (curl, Python, robot) peut leur donner n'importe quelle valeur. La restriction de domaine empêche l'usage de la clé par d'autres sites web ; elle n'empêche pas l'interrogation de l'API par un programme. Les soumissions restent soumises aux limites de débit, au champ piège et à la modération.
Clé sans domaine (serveur ou script)
Une clé sans domaine est acceptée quelle que soit la provenance de la requête : serveur de synchronisation, script d'export, application mobile compilée. Elle doit rester secrète, hors de toute page web et de tout dépôt de code public. En cas de fuite, elle doit être révoquée et remplacée.
Recommandations
- Une clé par site ou par usage, avec un nom explicite (« Carte du site », « Export mensuel »), pour pouvoir en révoquer une sans interrompre les autres.
- La date de dernière utilisation, affichée dans le back-office, permet de repérer les clés inutilisées.
- Seul un hachage de la clé est stocké : une clé perdue ne peut pas être réaffichée, il faut en créer une autre.
CORS
Pour que les pages d'autres domaines puissent lire les réponses, l'API envoie les en-têtes CORS suivants :
Access-Control-Allow-Origin:- clés non exigées (
'api_key' => false) : la valeur decors_origindansconfig.php,*par défaut ; - clés exigées : l'origine de la page appelante quand l'une des clés l'autorise, sinon la valeur de
cors_origin.
- clés non exigées (
Access-Control-Allow-Methods: GET, POSTAccess-Control-Allow-Headers: Content-Type, X-Api-KeyVary: Origin, pour que les caches ne mélangent pas les réponses destinées à deux sites.
Une requête qui envoie l'en-tête X-Api-Key ou un corps JSON déclenche d'abord une requête de contrôle OPTIONS du navigateur, à laquelle l'API répond 204. Le paramètre ?key= dans une requête GET évite cet aller-retour.
Format des réponses
Un point est une Feature GeoJSON :
{
"type": "Feature",
"id": 128,
"geometry": { "type": "Point", "coordinates": [4.8357, 45.764] },
"properties": {
"etat": "Abîmé",
"places": 4,
"dossier": true,
"photos": [
"https://votre-instance.org/api/files/3f2a9c0d41b7e8a65c1d2e3f4a5b6c7d.jpg"
],
"created_at": "2026-10-02T14:31:07Z"
}
}
id: numéro du point, unique dans l'instance. C'est le{n}des URL et la valeur des champsref.geometry.coordinates: longitude puis latitude, dans l'ordre imposé par GeoJSON. Les soumissions utilisent des champs nomméslatetlng.properties: uniquement les champs renseignés. Un champ vide est absent, et nonnull.- Champs image : URL complètes, utilisables dans une balise
<img>. created_at: date de création du point, en UTC, au format ISO 8601.
Une liste de points est une FeatureCollection :
{ "type": "FeatureCollection", "features": [ { "type": "Feature", … }, … ] }
Erreurs et codes HTTP
Une erreur renvoie un objet JSON avec un message en français. Les erreurs de validation y ajoutent details, un message par champ :
{
"error": "Certains champs sont invalides.",
"details": {
"etat": "Champ obligatoire.",
"places": "Maximum : 20."
}
}
| Code | Signification |
|---|---|
200 | Lecture réussie. |
201 | Soumission ou proposition enregistrée : {"status": "pending"} ou {"status": "published"}. |
204 | Réponse à une requête de contrôle CORS OPTIONS. |
400 | Requête mal formée : corps qui n'est pas un objet JSON, bbox incomplète, filtre inconnu. |
401 | {"error": "Clé d'API manquante ou invalide."} : clé absente, inconnue ou révoquée. |
403 | {"error": "Domaine non autorisé pour cette clé."}, ou collection fermée aux soumissions ou aux modifications publiques. |
404 | Collection, point ou image introuvable. Un point en attente ou refusé est introuvable pour l'API publique. GET /api/front renvoie aussi 404 quand le site public est désactivé. |
405 | Méthode non autorisée sur ce chemin. |
413 | Requête trop volumineuse : corps JSON de plus de 64 Ko, ou envoi dépassant la limite de PHP (post_max_size). |
422 | Données invalides, avec details par champ. Position invalide : details.position. |
429 | Limite de débit atteinte (voir Limites de débit) : « Trop de soumissions, réessayez plus tard. » (limite par adresse IP), « Trop de soumissions sur l'ensemble du site, réessayez plus tard. » (limite globale), « La file de modération est pleine, réessayez plus tard. », ou « Trop de requêtes, réessayez dans une minute. » (lectures de listes de points). |
500 | Erreur interne. Le détail est écrit dans le journal d'erreurs PHP, jamais dans la réponse. |
507 | « L'espace réservé aux images est plein. » : un envoi avec images dépasserait la place maximale des images. |
Limites de débit
L'API publique limite les envois et les lectures coûteuses, site public compris. Les valeurs se règlent dans config.php. Les membres du back-office n'y sont pas soumis.
| Limite | Défaut | Réponse au-delà |
|---|---|---|
| Soumissions et propositions de modification par adresse IP | 20 par heure (submissions_per_hour) | 429 « Trop de soumissions, réessayez plus tard. » |
| Soumissions et propositions de modification pour l'ensemble des visiteurs | 500 par heure (submissions_total_per_hour) | 429 « Trop de soumissions sur l'ensemble du site, réessayez plus tard. » |
| Points et propositions en attente de modération | 2000 (max_pending) | 429 « La file de modération est pleine, réessayez plus tard. » |
| Place occupée par les images, pour un envoi avec images | 2000 Mo (uploads_max_mb) | 507 « L'espace réservé aux images est plein. » |
Lectures de listes de points (GET …/points) par adresse IP | 120 par minute (reads_per_minute) | 429 « Trop de requêtes, réessayez dans une minute. » |
- Les durées sont des fenêtres glissantes : une heure, ou une minute pour les lectures.
- En IPv6, les limites par adresse portent sur le bloc /64 de l'adresse : une seule connexion dispose de tout un bloc et pourrait sinon changer d'adresse à chaque requête.
- Chaque envoi compte, y compris un envoi ensuite refusé (champ invalide, file de modération pleine). Un envoi qui remplit le champ piège ne compte pas.
- La lecture des collections, d'un point seul et des images n'est pas limitée.
- Les réponses
429ne portent pas d'en-têteRetry-After. - Les adresses IP ne sont conservées que sous forme de hachage (voir Sécurité).
Lister les collections
GET /api/collections
Renvoie toutes les collections, avec leur description, leurs champs tels que définis dans l'éditeur de schéma et leur nombre de points publiés.
curl -H 'X-Api-Key: gc_VOTRE_CLE' https://votre-instance.org/api/collections
const reponse = await fetch('https://votre-instance.org/api/collections', {
headers: { 'X-Api-Key': 'gc_VOTRE_CLE' },
});
const collections = await reponse.json();
[
{
"id": "bancs",
"name": "Bancs publics",
"description": "Bancs installés sur l'espace public, avec leur état.",
"public_submission": true,
"public_edit": true,
"fields": [
{ "name": "etat", "label": "État", "type": "select", "required": true,
"options": ["Bon", "Abîmé", "Cassé"] },
{ "name": "photos", "label": "Photos", "type": "image", "max": 3, "maxSize": 5 }
],
"count": 1240
},
{
"id": "signalements",
"name": "Signalements",
"description": "",
"public_submission": true,
"public_edit": false,
"fields": [
{ "name": "banc", "label": "Banc concerné", "type": "ref", "required": true, "collection": "bancs" },
{ "name": "constat", "label": "Constat", "type": "multiselect", "required": true,
"options": ["Assise cassée", "Pied descellé", "Graffitis", "Peinture écaillée", "Autre"] }
],
"count": 87
}
]
Les champs sont abrégés ici. description vaut "" quand la collection n'en a pas. L'option moderation n'est pas exposée : la réponse d'une soumission indique si le point attend une validation.
Lire une collection
GET /api/collections/{id}
Renvoie une collection, au même format qu'un élément de la liste précédente. 404 si elle n'existe pas.
curl -H 'X-Api-Key: gc_VOTRE_CLE' https://votre-instance.org/api/collections/bancs
const reponse = await fetch('https://votre-instance.org/api/collections/bancs', {
headers: { 'X-Api-Key': 'gc_VOTRE_CLE' },
});
const { name, description, fields, count } = await reponse.json();
{
"id": "bancs",
"name": "Bancs publics",
"description": "Bancs installés sur l'espace public, avec leur état.",
"public_submission": true,
"public_edit": true,
"fields": [ … ],
"count": 1240
}
Lire les points d'une collection
GET /api/collections/{id}/points
Renvoie les points publiés de la collection, en FeatureCollection GeoJSON, du plus récent au plus ancien. Les points en attente ou refusés n'apparaissent pas.
Paramètres d'URL
Tous facultatifs et combinables.
- bbox
- Emprise
ouest,sud,est,norden degrés décimaux (longitude, latitude, longitude, latitude). Seuls les points situés à l'intérieur sont renvoyés. Exemple :bbox=4.80,45.74,4.87,45.78. - {champ_ref}
- Pour chaque champ de type
ref: ne garde que les points liés au point indiqué. Exemple :/api/collections/signalements/points?banc=128. - with
- Nom d'un champ : ne garde que les points où ce champ est renseigné. Exemple :
/api/collections/bancs/points?with=photosrenvoie les bancs qui ont au moins une photo. - key
- Clé d'API, si elle n'est pas envoyée dans l'en-tête
X-Api-Key.
Volume et performances
- Au plus
max_pointspoints sont renvoyés (100 000 par défaut), sans pagination. Au-delà, les plus anciens sont omis. Pour une grande collection, le paramètrebboxlimite la réponse à la zone affichée. - La réponse est envoyée au fil de l'eau, point par point : le serveur ne charge pas toute la liste en mémoire.
- Chaque adresse IP peut faire
reads_per_minutelectures de listes par minute (120 par défaut) ; au-delà,429(voir Limites de débit). Une carte qui recharge les points à chaque déplacement fait une lecture par déplacement. - Elle est compressée (gzip) quand le client l'accepte. Avec curl : option
--compressed. Sur Apache, la compression demandemod_deflate; sur Nginx, la directivegzip.
curl --compressed -H 'X-Api-Key: gc_VOTRE_CLE' \
'https://votre-instance.org/api/collections/bancs/points?bbox=4.80,45.74,4.87,45.78&with=photos'
const params = new URLSearchParams({
bbox: [4.80, 45.74, 4.87, 45.78].join(','),
with: 'photos',
});
const reponse = await fetch(
`https://votre-instance.org/api/collections/bancs/points?${params}`,
{ headers: { 'X-Api-Key': 'gc_VOTRE_CLE' } },
);
const { features } = await reponse.json();
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": 128,
"geometry": { "type": "Point", "coordinates": [4.8357, 45.764] },
"properties": {
"etat": "Abîmé",
"places": 4,
"dossier": true,
"photos": ["https://votre-instance.org/api/files/3f2a9c0d41b7e8a65c1d2e3f4a5b6c7d.jpg"],
"created_at": "2026-10-02T14:31:07Z"
}
}
]
}
Erreurs propres à cette route : 400 avec bbox attendu : ouest,sud,est,nord., banc : numéro de point attendu. (sur signalements) ou with : champ inconnu. ; 429 avec Trop de requêtes, réessayez dans une minute.
Lire un point
GET /api/collections/{id}/points/{n}
Renvoie un point publié, en Feature GeoJSON. 404 (« Point introuvable. ») si le point n'existe pas, s'il appartient à une autre collection ou s'il n'est pas publié.
curl -H 'X-Api-Key: gc_VOTRE_CLE' https://votre-instance.org/api/collections/bancs/points/128
const reponse = await fetch('https://votre-instance.org/api/collections/bancs/points/128', {
headers: { 'X-Api-Key': 'gc_VOTRE_CLE' },
});
if (reponse.status === 404) {
// point supprimé ou pas encore validé
}
const banc = await reponse.json();
const [lng, lat] = banc.geometry.coordinates;
{
"type": "Feature",
"id": 128,
"geometry": { "type": "Point", "coordinates": [4.8357, 45.764] },
"properties": {
"etat": "Abîmé",
"places": 4,
"dossier": true,
"photos": ["https://votre-instance.org/api/files/3f2a9c0d41b7e8a65c1d2e3f4a5b6c7d.jpg"],
"created_at": "2026-10-02T14:31:07Z"
}
}
Soumettre un point
POST /api/collections/{id}/points
Propose un nouveau point. Si la collection a une modération, le point attend une validation dans le back-office ; sinon, il est publié immédiatement. Possible seulement si public_submission vaut true (sinon 403).
Corps de la requête
- lat, lng
- Obligatoires. Latitude (−90 à 90) et longitude (−180 à 180), en nombres JSON.
- properties
- Objet des champs de la collection. Chaque valeur est validée selon son type.
- website
- Champ piège, à laisser vide (voir plus bas).
Deux formats sont acceptés :
- Sans image : un corps JSON (
Content-Type: application/json), 64 Ko au plus. - Avec images :
multipart/form-data, avec le même objet JSON dans le champdataet les fichiers dans des champs nommés comme la propriété image suivie de[](photos[]).
# Sans image
curl -X POST https://votre-instance.org/api/collections/bancs/points \
-H 'X-Api-Key: gc_VOTRE_CLE' \
-H 'Content-Type: application/json' \
-d '{
"lat": 45.764,
"lng": 4.8357,
"properties": {
"etat": "Abîmé",
"places": 4,
"dossier": true
}
}'
# Avec images
curl -X POST https://votre-instance.org/api/collections/signalements/points \
-H 'X-Api-Key: gc_VOTRE_CLE' \
-F 'data={"lat": 45.764, "lng": 4.8357, "properties": {"banc": 128, "constat": ["Assise cassée"], "description": "Deux lattes manquent."}}' \
-F 'photos[]=@banc.jpg' \
-F 'photos[]=@detail.jpg'
// Sans image
await fetch('https://votre-instance.org/api/collections/bancs/points', {
method: 'POST',
headers: { 'X-Api-Key': 'gc_VOTRE_CLE', 'Content-Type': 'application/json' },
body: JSON.stringify({
lat: 45.764,
lng: 4.8357,
properties: { etat: 'Abîmé', places: 4, dossier: true },
}),
});
// Avec images, depuis un formulaire contenant <input type="file" name="photos" multiple>
const donnees = new FormData();
donnees.append('data', JSON.stringify({
lat: 45.764,
lng: 4.8357,
properties: { banc: 128, constat: ['Assise cassée'], description: formulaire.description.value },
website: formulaire.website.value, // champ piège
}));
for (const fichier of formulaire.photos.files) {
donnees.append('photos[]', fichier);
}
// Content-Type non fixé : le navigateur l'écrit avec la frontière multipart.
const reponse = await fetch('https://votre-instance.org/api/collections/signalements/points', {
method: 'POST',
headers: { 'X-Api-Key': 'gc_VOTRE_CLE' },
body: donnees,
});
const resultat = await reponse.json();
if (reponse.status === 422) {
// resultat.details : { "constat": "Champ obligatoire.", … }
}
{ "status": "pending" } // en attente de modération
{ "status": "published" } // collection sans modération : point publié
{
"error": "Certains champs sont invalides.",
"details": {
"etat": "Champ obligatoire.",
"photos": "3 image(s) maximum."
}
}
La réponse ne contient pas le numéro du point : un point en attente n'est pas lisible par l'API publique avant sa validation.
Limites
Chaque adresse IP (bloc /64 en IPv6) peut envoyer submissions_per_hour soumissions par heure (20 par défaut), propositions de modification comprises, et l'ensemble des visiteurs submissions_total_per_hour (500 par défaut). Les envois sont aussi refusés quand la file de modération est pleine (max_pending) et, avec des images, quand la place des images est épuisée (uploads_max_mb). Chaque envoi compte, y compris un envoi refusé pour un champ invalide. Au-delà, l'API répond 429 ou 507 : détail dans Limites de débit. Les adresses ne sont conservées que sous forme de hachage, supprimé au bout d'une heure.
Champ piège
Les robots qui parcourent le web remplissent tous les champs d'un formulaire. Le formulaire comporte donc un champ website masqué, dont la valeur est recopiée dans l'objet JSON envoyé, à côté de lat et lng. Si ce champ est rempli, l'API répond 201 mais n'enregistre rien.
<!-- Masqué à l'écran et pour les lecteurs d'écran, ignoré au clavier. -->
<div style="position: absolute; left: -10000px" aria-hidden="true">
<label>Ne pas remplir <input name="website" tabindex="-1" autocomplete="off"></label>
</div>
Proposer une modification d'un point
POST /api/collections/{id}/points/{n}
Propose une correction d'un point publié : nouvelle position, champ complété ou vidé, image ajoutée ou retirée. Si la collection a une modération, la proposition attend une décision dans le back-office, page Modifications. Sinon, elle est appliquée directement.
Possible seulement si l'option public_edit de la collection vaut true (par défaut, elle suit public_submission) : sinon 403. 404 si le point n'existe pas ou n'est pas publié.
Corps de la requête
Même format qu'une soumission (JSON, ou multipart/form-data avec le JSON dans data), mais tout est facultatif : seules les modifications sont envoyées.
- lat, lng
- Nouvelle position. Les deux valeurs sont envoyées ensemble.
- properties
- Champs modifiés uniquement.
nullou""vide un champ. Les champs absents gardent leur valeur actuelle. - comment
- Texte libre, 1000 caractères au plus, destiné à la modération (« Banc remplacé en septembre, photo jointe »). Il n'est pas publié.
- website
- Champ piège, à laisser vide, comme pour une soumission.
Champs image
- Champ absent de
properties: les images ne changent pas. - Champ présent : sa valeur est la liste des images actuelles à conserver, sous forme d'URL renvoyées par l'API ou de noms de fichier. Les images non listées sont retirées.
- Les fichiers envoyés dans
photos[]s'ajoutent aux images conservées. Le total doit respecter l'optionmaxdu champ.
Validation
Seuls les champs envoyés sont validés, avec les mêmes règles qu'une soumission : un point importé dont un autre champ sort des options peut donc être corrigé. Un champ obligatoire vidé ou une valeur hors des options renvoie 422 avec le détail par champ. Si le résultat est identique au point actuel : 422 avec le message « Aucune modification. » Les limites de débit et le champ piège sont les mêmes que pour les soumissions.
# Corriger deux champs et en vider un
curl -X POST https://votre-instance.org/api/collections/bancs/points/128 \
-H 'X-Api-Key: gc_VOTRE_CLE' \
-H 'Content-Type: application/json' \
-d '{
"properties": {
"etat": "Bon",
"dossier": true,
"places": null
},
"comment": "Banc remplacé en septembre par un modèle avec dossier."
}'
# Déplacer le point
curl -X POST https://votre-instance.org/api/collections/bancs/points/128 \
-H 'X-Api-Key: gc_VOTRE_CLE' \
-H 'Content-Type: application/json' \
-d '{"lat": 45.7643, "lng": 4.8361, "comment": "Le banc est de l’autre côté de la place."}'
# Conserver une image existante et en ajouter une
curl -X POST https://votre-instance.org/api/collections/bancs/points/128 \
-H 'X-Api-Key: gc_VOTRE_CLE' \
-F 'data={"properties": {"photos": ["https://votre-instance.org/api/files/3f2a9c0d41b7e8a65c1d2e3f4a5b6c7d.jpg"]}, "comment": "Photo du nouveau banc."}' \
-F 'photos[]=@nouveau-banc.jpg'
const url = 'https://votre-instance.org/api/collections/bancs/points/128';
// Corriger deux champs et en vider un
await fetch(url, {
method: 'POST',
headers: { 'X-Api-Key': 'gc_VOTRE_CLE', 'Content-Type': 'application/json' },
body: JSON.stringify({
properties: { etat: 'Bon', dossier: true, places: null },
comment: 'Banc remplacé en septembre par un modèle avec dossier.',
}),
});
// Retirer une image sur deux et en ajouter une
const point = await (await fetch(url, { headers: { 'X-Api-Key': 'gc_VOTRE_CLE' } })).json();
const aGarder = point.properties.photos.slice(0, 1);
const donnees = new FormData();
donnees.append('data', JSON.stringify({
properties: { photos: aGarder },
comment: 'La deuxième photo montrait un autre banc.',
}));
donnees.append('photos[]', fichier);
const reponse = await fetch(url, {
method: 'POST',
headers: { 'X-Api-Key': 'gc_VOTRE_CLE' },
body: donnees,
});
const { status } = await reponse.json(); // 'pending' ou 'published'
{ "status": "pending" } // proposition en attente de modération
{ "status": "published" } // collection sans modération : point modifié
{ "error": "Aucune modification." }
{ "error": "Certains champs sont invalides.", "details": { "etat": "Champ obligatoire." } }
Lire une image
GET /api/files/{nom}
Renvoie une image d'un point. L'URL complète figure dans les propriétés image des points. Aucune clé n'est demandée, même quand les clés sont exigées : le nom de 32 caractères aléatoires n'est pas devinable, et une balise <img> ne peut pas envoyer d'en-tête.
- Type :
image/jpeg,image/pngouimage/webp. - Mise en cache d'un an (
Cache-Control: public, max-age=31536000, immutable) : une image n'est jamais modifiée, une nouvelle image reçoit un nouveau nom. 404(« Image introuvable. ») si le nom n'existe pas.
curl -O https://votre-instance.org/api/files/3f2a9c0d41b7e8a65c1d2e3f4a5b6c7d.jpg
for (const src of banc.properties.photos ?? []) {
const img = document.createElement('img');
img.src = src;
img.alt = '';
img.loading = 'lazy';
galerie.append(img);
}
Configuration du site public
GET /api/front
Renvoie la configuration du site public : titre, texte d'introduction, collections affichées avec leurs champs et les envois permis, vue de carte par défaut. Le site public lit cette route au chargement. 404 si le site public est désactivé.
- title
- Titre du site public.
- intro
- Texte d'introduction.
- collections
- Collections affichées, dans l'ordre des réglages, au format de
GET /api/collections, avec les envois permis depuis le site :can_submit(ajouts) etcan_edit(propositions de modification). Chacun combine le réglage du site public et celui de la collection. - center
- Centre de la vue de carte par défaut :
[latitude, longitude]. - zoom
- Niveau de zoom de la vue par défaut (1 à 19).
{
"title": "Bancs publics",
"intro": "Carte des bancs publics. Les ajouts sont publiés après relecture.",
"collections": [
{
"id": "bancs",
"name": "Bancs publics",
"description": "Bancs installés sur l'espace public, avec leur état.",
"public_submission": true,
"public_edit": true,
"fields": [ … ],
"count": 1240,
"can_submit": true,
"can_edit": true
}
],
"center": [45.764, 4.8357],
"zoom": 15
}
Exemple : carte Leaflet
Page complète, à placer sur un autre site. Elle affiche les bancs de la zone visible, en rouge quand ils sont cassés, avec une fiche au clic. La clé utilisée est limitée au domaine de ce site (voir Clés d'API). Le site public intégré couvre le même besoin sans développement.
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bancs publics</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
html, body, #carte { height: 100%; margin: 0; }
.fiche img { display: block; max-width: 220px; margin-top: 6px; }
</style>
</head>
<body>
<div id="carte"></div>
<script>
const API = 'https://votre-instance.org/api';
const CLE = 'gc_VOTRE_CLE'; // clé limitée au domaine de ce site
const carte = L.map('carte').setView([45.764, 4.8357], 15);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(carte);
const calque = L.layerGroup().addTo(carte);
// Échappe un texte saisi par le public avant de l'insérer en HTML.
const texte = (valeur) => String(valeur).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
function fiche(feature) {
const p = feature.properties;
let html = `<div class="fiche"><strong>Banc n° ${feature.id}</strong>`;
html += `<br>État : ${texte(p.etat ?? 'non renseigné')}`;
if (p.places) html += `<br>Places : ${texte(p.places)}`;
if (p.dossier !== undefined) html += `<br>Dossier : ${p.dossier ? 'oui' : 'non'}`;
for (const src of p.photos ?? []) html += `<img src="${texte(src)}" alt="" loading="lazy">`;
return html + '</div>';
}
let enCours;
async function charger() {
enCours?.abort();
enCours = new AbortController();
const b = carte.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].map((n) => n.toFixed(5));
try {
const reponse = await fetch(`${API}/collections/bancs/points?bbox=${bbox.join(',')}`, {
headers: { 'X-Api-Key': CLE },
signal: enCours.signal,
});
const donnees = await reponse.json();
if (!reponse.ok) throw new Error(donnees.error);
calque.clearLayers();
L.geoJSON(donnees, {
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
radius: 7,
weight: 2,
color: '#ffffff',
fillOpacity: 1,
fillColor: feature.properties.etat === 'Cassé' ? '#b42318' : '#0b6b4a',
}),
onEachFeature: (feature, layer) => layer.bindPopup(fiche(feature)),
}).addTo(calque);
} catch (erreur) {
if (erreur.name !== 'AbortError') console.error('Chargement impossible :', erreur.message);
}
}
carte.on('moveend', charger);
charger();
</script>
</body>
</html>
Remarques :
- Les textes viennent du public : la fonction
texte()les échappe avant leur insertion en HTML. Sans cet échappement, un champ contenant du HTML serait interprété par la page. - Le paramètre
bboxlimite la réponse à la zone affichée. La requête précédente est annulée quand la carte bouge de nouveau. - Pour une collection de quelques milliers de points, un seul appel sans
bboxau chargement suffit. - Les points importés d'OpenStreetMap doivent être attribués : la mention « © OpenStreetMap » du fond de carte couvre les deux.
Site public
Le site public est une interface web facultative, servie à la racine de l'instance (https://votre-instance.org/). Il permet de consulter et d'alimenter les collections sans développer de site. Il est désactivé par défaut ; la racine renvoie alors vers le back-office.
Il utilise l'API publique de l'instance et sa route GET /api/front. Son style est volontairement neutre (polices du système, gris et une couleur d'accent) et il est utilisable sur téléphone.
Réglages
Les réglages se font dans le back-office, page Site public, réservée aux comptes admin.
- Activation
- Active ou désactive le site public. Désactivé, la racine renvoie vers
/admin/etGET /api/frontrépond404. - Titre
- Titre affiché en tête du site et dans l'onglet du navigateur.
- Introduction
- Texte affiché sous le titre.
- Collections
- Collections affichées sur le site. Les autres collections n'y apparaissent pas.
- Ajouts
- Autorise l'ajout de points depuis le site.
- Modifications
- Autorise les propositions de modification depuis le site.
- Vue de carte
- Centre et zoom de la carte à l'ouverture du site. Le bouton prévu reprend la vue courante de la carte du back-office.
Pages et formulaires
- Carte : les points publiés de la collection choisie, sur un fond OpenStreetMap. Le titre, l'introduction et la description de la collection sont affichés à côté.
- Fiche d'un point : ouverte par un clic sur le point. Elle affiche les champs renseignés, les images, les liens vers les points désignés par ses champs
refet la liste des points qui le désignent. - Formulaire d'ajout : généré à partir du schéma de la collection (types, choix, champs obligatoires, images). La position du point se place d'un clic sur la carte.
- Formulaire de proposition de modification : ouvert depuis la fiche d'un point, avec un commentaire facultatif destiné à la modération.
Ajouts et propositions
Les envois du site public passent par l'API publique et suivent les mêmes règles qu'un envoi d'une autre provenance :
- un ajout demande que les ajouts soient autorisés dans les réglages du site et que l'option
public_submissionde la collection vailletrue; - une proposition de modification demande que les modifications soient autorisées dans les réglages du site et que l'option
public_editde la collection vailletrue; - dans une collection avec modération, l'ajout ou la proposition attend une validation dans le back-office ;
- les limites de débit et le champ piège s'appliquent.
Clés d'API et site public
Quand 'api_key' => true, le site public fonctionne sans clé : les requêtes envoyées depuis les pages de l'instance elle-même (même domaine, d'après l'en-tête Origin ou à défaut Referer) sont acceptées tant que le site public est activé.
Cette vérification repose sur les mêmes en-têtes que les clés limitées à des domaines. Un programme exécuté hors d'un navigateur peut les fixer : quand le site public est activé, l'API de l'instance est donc accessible sans clé à un tel programme. Les limites de débit, le champ piège et la modération restent appliqués.
Back-office
Le back-office est à https://votre-instance.org/admin/. Il affiche une carte OpenStreetMap en fond et les panneaux de travail par-dessus.
La navigation compte les pages Modération, Modifications et Points, et, pour les comptes admin, Collections, Membres, Clés d'API, Site public et Journal. Un champ de recherche de lieux, qui interroge le service Nominatim d'OpenStreetMap, déplace la carte.
Rôles
| Rôle | Droits |
|---|---|
| Modération | Valider, refuser, ajouter, modifier et supprimer des points ; appliquer ou refuser les propositions de modification. |
| Admin | Droits de la modération, plus : gestion des membres et des invitations, des collections, des clés d'API et des réglages du site public ; réinitialisation de la double authentification d'un membre ; consultation du journal. |
Chaque membre dispose d'une page Mon compte (codes de secours de la double authentification).
Invitations
geocrowd n'envoie pas d'e-mail. L'ajout d'un membre se fait par lien d'invitation :
- Un compte admin ouvre la page Membres, saisit l'adresse de la personne et son rôle, et obtient un lien d'invitation.
- Le lien est transmis à la personne par un autre canal (messagerie, e-mail, en personne).
- La personne ouvre le lien, choisit son nom et son mot de passe (10 caractères au moins), puis configure la double authentification.
Le lien est valable invitation_days jours (7 par défaut). Une invitation en attente peut être annulée. Depuis la même page, un compte admin change le rôle d'un membre ou lui retire l'accès (sauf à son propre compte).
Modération
La page Modération liste les points en attente ; leur nombre figure dans le menu. Pour chaque point, la page affiche sa position sur la carte et ses champs, et propose les actions suivantes :
- valider : le point est publié et apparaît dans l'API et sur le site public ;
- refuser : le point reste dans la base, invisible pour l'API, et peut être publié plus tard ;
- modifier avant décision : déplacer le point, corriger un champ, retirer une image ;
- supprimer : le point et ses images sont effacés définitivement.
La page Points liste tous les points par statut (publiés, en attente, refusés). Les membres peuvent y ajouter un point, publié directement.
Modifications
La page Modifications liste les propositions de modification en attente. Pour chacune :
- la valeur actuelle et la valeur proposée de chaque champ modifié ;
- l'ancienne et la nouvelle position sur la carte, si le point est déplacé ;
- le commentaire joint à la proposition.
Appliquer met à jour le point publié. Refuser écarte la proposition sans modifier le point. Dans une collection sans modération, les propositions sont appliquées directement et n'apparaissent pas dans cette page.
Éditeur de schéma
La page Collections, réservée aux comptes admin, liste les collections avec leur nombre de champs, de points publiés et en attente, et leurs règles d'envoi. Nouvelle collection ouvre l'éditeur ; un clic sur une collection l'ouvre en modification. À côté du formulaire, un aperçu affiche la définition JSON renvoyée par l'API.
La collection
- Nom : nom affiché, 100 caractères au plus.
- Identifiant : généré à partir du nom (« Bancs publics » donne
bancs-publics), modifiable avant la création. Il sert dans les URL de l'API : lettres minuscules, chiffres,-et_, 50 caractères au plus ;newest réservé. Il ne change plus après la création. - Description : facultative, 1000 caractères au plus, affichée sur le site public (option
description). - Modération : les envois du public attendent une validation avant publication (option
moderation). - Soumission publique : l'API et le site public acceptent de nouveaux points (
public_submission). - Modification publique : l'API et le site public acceptent des propositions de modification des points publiés (
public_edit).
Les champs
Un champ s'ajoute en choisissant son type, puis Ajouter un champ. Pour chaque champ :
- Libellé : texte affiché, 100 caractères au plus.
- Nom technique : clé du champ dans les données et l'API, généré à partir du libellé (« Places assises » donne
places_assises). Lettres minuscules, chiffres et_, commençant par une lettre, 50 caractères au plus ;created_atest réservé. - Type : Texte court, Texte long, Nombre, Choix unique, Choix multiples, Oui / non, Date, Adresse web, Images ou Lien vers un point (voir Types de champs).
- Obligatoire, sauf pour un champ Oui / non.
- Les options du type : longueur maximale, minimum et maximum, choix proposés (un par ligne), nombre et taille maximale des images, collection des points désignés.
Les flèches ↑ et ↓ changent l'ordre des champs, qui est l'ordre d'affichage. La croix retire un champ. Un champ Lien vers un point peut désigner la collection elle-même, y compris pendant sa création.
Limites : 100 champs par collection ; 200 choix de 200 caractères au plus ; longueur maximale de 1 à 10 000 caractères ; de 1 à 10 images, de 1 à 20 Mo chacune. Les erreurs s'affichent à côté du champ concerné à l'enregistrement.
Modification d'une collection qui a des points
- Le nom technique et le type d'un champ enregistré ne changent plus. Son libellé, ses options et sa position restent modifiables. Pour changer de type, il faut retirer le champ et en créer un autre.
- Chaque champ enregistré indique le nombre de points où il est renseigné.
- Retirer un champ efface ses valeurs de tous les points de la collection et des propositions de modification en attente, images comprises. À l'enregistrement, le back-office indique le nombre de points concernés et demande une confirmation.
- Une règle plus stricte (champ rendu obligatoire, choix retiré) ne modifie pas les points existants : elle s'applique aux envois et modifications suivants.
Suppression d'une collection
Supprimer la collection efface ses points, leurs images et les propositions de modification en attente, après confirmation. La suppression est définitive. Elle est impossible tant qu'un champ Lien vers un point d'une autre collection désigne cette collection.
Clés d'API
La page Clés d'API, réservée aux comptes admin, sert quand 'api_key' => true (voir Authentification).
- Saisir un nom pour la clé (« Carte du site ») et, pour un usage dans une page web, la liste des domaines autorisés, un par ligne :
exemple.org,*.exemple.orgpour les sous-domaines,localhostpour le développement. - Copier la clé affichée, de la forme
gc_…. Elle n'est affichée qu'une fois : seul un hachage est conservé.
La liste indique, pour chaque clé, ses domaines et sa date de dernière utilisation. Révoquer rend une clé inutilisable immédiatement ; les requêtes qui l'utilisent reçoivent alors une erreur 401.
Le site public de l'instance n'a pas besoin de clé (voir Clés d'API et site public).
Journal
La page Journal, réservée aux comptes admin, liste les tentatives de connexion et les actions faites dans le back-office, de la plus récente à la plus ancienne, par pages de 50. Chaque entrée indique la date, le membre, l'action, son objet (adresse d'un membre, identifiant d'une collection, numéro d'un point, nom d'une clé) et, selon l'action, des détails : changement de statut, rôle, champs retirés.
- Connexions
- Connexions réussies, mots de passe refusés (sans membre, avec l'adresse saisie), codes de double authentification refusés, création du premier compte, activation de la double authentification, nouveaux codes de secours.
- Modération
- Points ajoutés, modifiés, changés de statut ou supprimés.
- Propositions
- Propositions de modification appliquées ou refusées.
- Collections
- Créations, modifications (avec les champs retirés) et suppressions.
- Membres et invitations
- Invitations créées, annulées ou acceptées, rôles modifiés, membres retirés, doubles authentifications réinitialisées depuis la page Membres.
- Clés d'API
- Créations (avec les premiers caractères de la clé) et révocations.
- Site public
- Modifications des réglages, avec l'état activé ou désactivé.
Les entrées sont conservées un an, puis supprimées automatiquement. Le journal ne contient pas d'adresse IP. Les scripts de bin/, dont reset-2fa.php, n'y écrivent pas.
Double authentification
La connexion au back-office demande un mot de passe puis un code à 6 chiffres affiché par une application d'authentification. La double authentification est obligatoire pour tous les comptes.
Toute application compatible TOTP convient : Aegis, FreeOTP, Google Authenticator, Microsoft Authenticator, 1Password, Bitwarden, KeePassXC, etc.
Le code est calculé par l'application à partir d'un secret partagé à l'activation et de l'heure courante (norme RFC 6238). Le serveur n'envoie aucun message. L'horloge du serveur doit être à l'heure, avec une tolérance de 30 secondes.
Activation
La configuration est demandée à la création du premier compte, à l'acceptation d'une invitation, et à la première connexion d'un compte qui n'en a pas.
- Le back-office affiche un QR code et la clé correspondante, pour une saisie manuelle.
- L'application scanne le QR code et affiche un code renouvelé toutes les 30 secondes.
- La saisie de ce code confirme l'activation.
- Le back-office affiche alors 10 codes de secours, une seule fois. Ils sont à conserver hors du téléphone (gestionnaire de mots de passe, papier).
Codes de secours
Un code de secours remplace une fois le code de l'application. La page Mon compte indique le nombre de codes restants et permet d'en générer une nouvelle série, sur présentation d'un code de l'application ; les anciens codes sont alors invalidés.
Téléphone perdu ou changé
Trois solutions, dans cet ordre :
- Un code de secours. Après connexion, de nouveaux codes se génèrent dans Mon compte. Pour un changement de téléphone : réinitialisation par un compte admin (point suivant) ou transfert des comptes avec la fonction d'export de l'application.
- Un compte admin. Depuis la page Membres, il réinitialise la double authentification du membre, qui la reconfigure avec un nouveau QR code à sa connexion suivante.
- L'accès aux fichiers, si aucun compte admin ne peut se connecter. Avec un accès SSH à l'hébergement :
php bin/reset-2fa.php adresse@exemple.orgSans SSH (hébergement mutualisé avec FTP seul), sur une copie locale de geocrowd avec PHP :
- télécharger
data/geocrowd.sqlite, ainsi quegeocrowd.sqlite-waletgeocrowd.sqlite-shms'ils existent, dans un même dossier ; - lancer
php bin/reset-2fa.php adresse@exemple.org --db=chemin/vers/geocrowd.sqlite; - renvoyer
geocrowd.sqlitesur le serveur, puis y supprimergeocrowd.sqlite-waletgeocrowd.sqlite-shms'ils sont présents.
Les données enregistrées sur le serveur pendant l'opération sont perdues.
Le script n'utilise pas la clé de chiffrement des secrets : il efface le secret et les codes de secours du membre visé, sans toucher aux secrets chiffrés des autres membres. Il fonctionne donc sur une copie locale qui n'a pas le fichier
secret.phpdu serveur ; seulgeocrowd.sqliteest renvoyé, et lesecret.phpdu serveur reste en place. - télécharger
Tentatives limitées
- 10 mots de passe erronés par adresse IP (bloc /64 en IPv6) en 15 minutes.
- 5 codes erronés par compte en 15 minutes.
- Un code déjà utilisé est refusé, même dans sa fenêtre de 30 secondes.
Une fois la limite atteinte, les tentatives sont refusées jusqu'à la fin de la période. Les mots de passe et les codes refusés figurent dans le journal.
Chiffrement des secrets
Le secret partagé de chaque membre est chiffré en base (AES-256-GCM). La clé de chiffrement est dans le fichier secret.php du dossier de données (data/secret.php par défaut), créé automatiquement par geocrowd au premier besoin, avec des droits réservés à son propriétaire. Demandé depuis le web, ce fichier PHP s'exécute sans rien afficher. Une base copiée seule, par exemple téléchargée par erreur, ne permet donc pas de générer les codes.
Les secrets enregistrés en clair par une version antérieure sont chiffrés automatiquement à la première requête web qui suit la mise à jour (voir Mise à jour et sauvegarde).
Le fichier secret.php se sauvegarde avec la base. S'il est perdu ou remplacé, les secrets enregistrés sont illisibles. Les codes de secours, stockés à part, restent acceptés ; un code de l'application est refusé avec le message « Double authentification illisible : la clé de chiffrement (secret.php) a été perdue ou remplacée. Utilisez un code de secours, ou demandez à un compte admin de la réinitialiser (page Membres ou bin/reset-2fa.php). »
La double authentification de chaque membre doit alors être reconfigurée : un compte admin, connecté avec un code de secours, la réinitialise depuis la page Membres. Si aucun compte admin ne peut plus se connecter, bin/reset-2fa.php réinitialise d'abord un compte admin (voir Téléphone perdu ou changé), qui réinitialise ensuite les autres membres.
Import OpenStreetMap
Le script bin/import-osm.php importe des objets OpenStreetMap comme points publiés d'une collection existante. Un fichier JSON décrit l'import : la collection visée, la zone, la requête Overpass et la correspondance entre tags OSM et champs.
{
"collection": "bancs",
"area": "FR",
"query": "node[amenity=bench](area.zone);",
"fields": {
"etat": { "value": "Bon" },
"dossier": { "tag": "backrest", "map": { "yes": true, "no": false } }
}
}
- collection
- Identifiant de la collection à remplir.
- area
- Code pays ISO 3166-1 (
FR,BE,CH…). La requête le reçoit sous le nomarea.zone. - query
- Requête Overpass. Les nœuds, chemins et relations sont acceptés (
nwr) ; pour les chemins et relations, le centre sert de position. - fields
- Pour chaque champ de la collection, la source de sa valeur :
value: valeur fixe ;tag: valeur du tag OSM, telle quelle ;tag+map: valeur du tag traduite par la table ; une valeur absente de la table donnedefault;default: valeur quand le tag est absent ou non traduit ;angle: true: le tag est un angle en degrés, commedirection(numérique, ou traduit parmappour les points cardinaux :{"N": 0, "E": 90, …}), arrondi et ramené entre 0 et 359. Les valeurs non numériques (both,0-359) sont ignorées.
Dans l'exemple, chaque banc importé est noté en bon état, et le tag backrest devient le champ Oui / non dossier ; un banc sans ce tag n'a pas de valeur. Les valeurs importées ne passent pas par la validation de la collection : elles doivent faire partie des choix des champs à choix et avoir le bon type (true et non "yes").
php bin/import-osm.php bancs.json --dry-run # compte sans rien écrire
php bin/import-osm.php bancs.json # importe
php bin/import-osm.php bancs.json --file=reponse.json # réponse Overpass enregistrée
- À chaque nouvelle exécution, le script met à jour les points déjà importés (position et champs issus d'OSM) sans changer leur statut ni les champs absents de
fields: un point refusé reste refusé, une image ajoutée par le public est conservée. Les champs defieldsreprennent la valeur issue d'OSM, valeurs fixes comprises. - Les points saisis manuellement ne sont pas modifiés.
--file=reponse.jsonimporte une réponse Overpass enregistrée, quand le serveur ne peut pas joindre Overpass (connexions sortantes bloquées chez certains hébergeurs mutualisés).- Sans accès en ligne de commande sur l'hébergement, l'import se fait sur une copie locale, puis la base est renvoyée sur le serveur, avec les précautions décrites pour la réinitialisation de la double authentification.
Les données OpenStreetMap sont sous licence ODbL. La source doit être citée là où les points sont affichés (« © OpenStreetMap », avec un lien vers openstreetmap.org/copyright), et la base qui les contient diffusée sous la même licence.
Sécurité et vie privée
geocrowd conserve le minimum de données sur les personnes qui envoient des points.
Données conservées pour une soumission
- La position, les champs déclarés dans la collection, les images réencodées et la date.
- Ni nom, ni adresse e-mail, ni compte : l'envoi est anonyme.
- Les adresses IP ne sont pas stockées en clair. Pour les limites de débit, seul un hachage de l'adresse, ou de son bloc /64 en IPv6 (SHA-256 avec un sel aléatoire propre à l'instance), est conservé, puis supprimé au bout d'une heure, à la première requête soumise à une limite.
Images
- Réencodées à l'enregistrement : métadonnées EXIF supprimées, dont la géolocalisation de l'appareil, la date de prise de vue et le modèle du téléphone.
- Format vérifié sur le contenu du fichier, pas sur son extension. Seuls JPEG, PNG et WebP sont acceptés.
- Noms aléatoires de 32 caractères, servis avec
X-Content-Type-Options: nosniff. - Une image peut montrer un visage ou une plaque d'immatriculation : la modération peut la refuser ou la retirer.
Protection contre les abus
- Limites de débit, site public compris : soumissions et propositions par adresse IP (bloc /64 en IPv6) et pour l'ensemble des visiteurs, taille de la file de modération, place des images, lectures de listes de points par adresse IP.
- Champ piège
website: un envoi qui le remplit reçoit une réponse positive, sans enregistrement. - Validation de chaque champ ; propriétés non déclarées ignorées ; un champ
refdoit désigner un point publié. - Champs Adresse web limités aux adresses
httpethttps: une adressejavascript:oudata:est refusée. - Corps JSON limité à 64 Ko.
- Modération activée par défaut.
- Clés d'API limitées à des domaines, en option.
Back-office
- Double authentification obligatoire, tentatives limitées. Secrets de double authentification chiffrés en base, avec une clé gardée dans un fichier à part (voir Chiffrement des secrets).
- Mots de passe de 10 caractères au moins, hachés avec l'algorithme par défaut de PHP (bcrypt).
- Cookie de session
HttpOnly,SameSite=Strict, etSecureen HTTPS. Jeton anti-CSRF exigé pour toute action. - Liens d'invitation et clés d'API stockés uniquement sous forme de hachage.
- Seuls les comptes admin modifient les collections et les réglages du site public ; chaque définition est validée par le serveur avant enregistrement.
- Connexions réussies et refusées, codes refusés et actions du back-office consignés pendant un an dans le journal, consultable par les comptes admin.
Ressources tierces
- Leaflet, le générateur de QR code (qrcode-generator) et les polices Geist sont fournis dans le dossier
vendor/, avec leurs licences : le back-office et le site public ne chargent aucun script, aucune feuille de style ni aucune police depuis un autre serveur. - Seuls services tiers : les fonds de carte (
tile.openstreetmap.org), dans le back-office et sur le site public, et la recherche de lieux du back-office (nominatim.openstreetmap.org). Ces services reçoivent l'adresse IP des personnes qui consultent ces pages.
En-têtes HTTP
Toutes les réponses de index.php portent les en-têtes suivants ; le .htaccess les ajoute aux fichiers statiques (voir Nginx pour une configuration équivalente).
- Content-Security-Policy
- Scripts, feuilles de style et polices de l'instance uniquement ; images de l'instance et des fonds de carte OpenStreetMap ; connexions vers l'instance et vers la recherche de lieux OpenStreetMap ; aucun objet intégré ; formulaires envoyés à l'instance seulement ; aucun affichage dans un cadre.
- X-Content-Type-Options
nosniff- X-Frame-Options
DENY: les pages de l'instance, site public compris, ne peuvent pas être affichées dans un cadre (iframe) d'un autre site.- Referrer-Policy
strict-origin-when-cross-origin- Permissions-Policy
camera=(), microphone=(), geolocation=(), payment=()- Cross-Origin-Opener-Policy
same-origin- Strict-Transport-Security
max-age=31536000, en HTTPS seulement.
Valeur complète de la politique de sécurité du contenu :
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https://tile.openstreetmap.org; font-src 'self'; connect-src 'self' https://nominatim.openstreetmap.org; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
Site public
- Les fonds de carte sont chargés depuis les serveurs d'OpenStreetMap, qui reçoivent l'adresse IP des visiteurs.
- Seuls les points publiés des collections choisies sont affichés.
- Quand les clés d'API sont exigées, l'activation du site public ouvre l'API aux requêtes qui annoncent le domaine de l'instance (voir Clés d'API et site public).
Hébergement
- Dossiers internes (
src/,data/,bin/) etconfig.phpinaccessibles depuis le web, à vérifier après l'installation (voir Apache). Le back-office affiche une alerte aux comptes admin sidata/geocrowd.sqlitese télécharge. - Fichiers et dossiers cachés (
.git,.env…) inaccessibles depuis le web, sauf.well-known/. - Base et dossier de données déplaçables hors de la racine web (
databaseetdata_dir, voir Configuration), sur un hébergement qui le permet. - Erreurs internes écrites dans le journal PHP, jamais renvoyées au client.
- Sauvegarde régulière et conjointe de
geocrowd.sqlite,secret.phpetuploads/(voir Mise à jour et sauvegarde).
Signaler une faille
Les failles de sécurité se signalent en privé, par l'onglet Security du dépôt github.com/geocrowd/geocrowd (« Report a vulnerability »), et non dans un ticket public, avant la publication d'un correctif. La procédure est décrite dans le fichier SECURITY.md du dépôt.
Licence
geocrowd est un logiciel libre sous licence MIT. Il peut être utilisé, modifié et redistribué, y compris à des fins commerciales, à condition de conserver la mention de copyright et de licence.
Les données collectées ne relèvent pas de la licence du logiciel. Leur licence est choisie par l'instance qui les publie, sauf pour les points importés d'OpenStreetMap, sous ODbL.
Code source, questions et contributions : github.com/geocrowd/geocrowd.