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) et openssl (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 dans config.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é Secure et les mots de passe circulent en clair.

Ni Composer, ni Node, ni serveur de messagerie, ni tâche planifiée.

Copie des fichiers

  1. Télécharger l'archive du dépôt et la décompresser.
  2. Relire config.php (voir Configuration).
  3. Copier l'ensemble sur l'hébergement, par FTP ou autrement, à la racine du site ou dans un sous-dossier.
  4. Rendre data/ accessible en écriture par PHP (droits 755 ou 775 selon l'hébergeur).
  5. 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.
  6. Créer une collection dans la page Collections du back-office (voir Éditeur de schéma). L'API l'accepte dès son enregistrement.
  7. 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 que collections/ et imports/, s'ils existent) et au fichier config.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_headers est disponible ; les réponses de index.php les envoient elles-mêmes ;
  • compresse les réponses JSON si mod_deflate est disponible.
Vérification après l'installation

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) :

/etc/nginx/sites-available/geocrowd
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-wal et geocrowd.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.

Depuis une version sans chiffrement des secrets

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.

Depuis une version à fichiers de collections

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).

config.php
<?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éfautRôle
databasedata/geocrowd.sqliteChemin 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_dirdataDossier 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_keyfalseÀ 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_hour20Nombre 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_hour500Nombre 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_pending2000Nombre de points et de propositions de modification en attente de modération au-delà duquel les envois publics sont refusés (429).
uploads_max_mb2000Place 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_minute120Nombre 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_days7Durée de validité, en jours, d'un lien d'invitation au back-office.
max_points100000Nombre 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.

définition de bancs
{
  "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

OptionDéfautRô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.
moderationtrueÀ 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_submissiontrueAutorise 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_editvaleur de public_submissionAutorise les propositions de modification de points publiés par l'API publique et le site public.
fieldsaucun champListe 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.

TypeDans l'éditeurValeur en JSONOptions
textTexte courtchaîne sur une lignerequired, maxLength (255 par défaut)
textareaTexte longchaîne sur plusieurs lignesrequired, maxLength (2000 par défaut)
numberNombrenombre JSON (pas une chaîne)required, min, max
selectChoix uniqueun des choix, à l'identiquerequired, options (obligatoire)
multiselectChoix multiplesliste de choix parmi les optionsrequired, options (obligatoire)
booleanOui / nontrue ou false
dateDatechaîne AAAA-MM-JJrequired
urlAdresse webadresse web complète, en http ou httpsrequired, maxLength (500 par défaut)
imageImagesliste de fichiers (voir Images)required, max (nombre d'images, 1 par défaut), maxSize (Mo par image, 5 par défaut)
refLien vers un pointnumé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éthodeCheminRôle
GET/api/collectionsListe des collections
GET/api/collections/{id}Une collection
GET/api/collections/{id}/pointsPoints publiés, avec filtres
GET/api/collections/{id}/points/{n}Un point publié
POST/api/collections/{id}/pointsSoumission 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/frontConfiguration 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
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'
JavaScript (fetch)
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.

Limite de cette protection

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 de cors_origin dans config.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.
  • Access-Control-Allow-Methods: GET, POST
  • Access-Control-Allow-Headers: Content-Type, X-Api-Key
  • Vary: 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 :

Feature
{
  "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 champs ref.
  • geometry.coordinates : longitude puis latitude, dans l'ordre imposé par GeoJSON. Les soumissions utilisent des champs nommés lat et lng.
  • properties : uniquement les champs renseignés. Un champ vide est absent, et non null.
  • 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."
  }
}
CodeSignification
200Lecture réussie.
201Soumission ou proposition enregistrée : {"status": "pending"} ou {"status": "published"}.
204Réponse à une requête de contrôle CORS OPTIONS.
400Requê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.
404Collection, 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é.
405Méthode non autorisée sur ce chemin.
413Requête trop volumineuse : corps JSON de plus de 64 Ko, ou envoi dépassant la limite de PHP (post_max_size).
422Données invalides, avec details par champ. Position invalide : details.position.
429Limite 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).
500Erreur 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.

LimiteDéfautRéponse au-delà
Soumissions et propositions de modification par adresse IP20 par heure (submissions_per_hour)429 « Trop de soumissions, réessayez plus tard. »
Soumissions et propositions de modification pour l'ensemble des visiteurs500 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ération2000 (max_pending)429 « La file de modération est pleine, réessayez plus tard. »
Place occupée par les images, pour un envoi avec images2000 Mo (uploads_max_mb)507 « L'espace réservé aux images est plein. »
Lectures de listes de points (GET …/points) par adresse IP120 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 429 ne portent pas d'en-tête Retry-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
curl -H 'X-Api-Key: gc_VOTRE_CLE' https://votre-instance.org/api/collections
JavaScript (fetch)
const reponse = await fetch('https://votre-instance.org/api/collections', {
  headers: { 'X-Api-Key': 'gc_VOTRE_CLE' },
});
const collections = await reponse.json();
Réponse 200
[
  {
    "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
curl -H 'X-Api-Key: gc_VOTRE_CLE' https://votre-instance.org/api/collections/bancs
JavaScript (fetch)
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();
Réponse 200
{
  "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,nord en 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=photos renvoie 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_points points sont renvoyés (100 000 par défaut), sans pagination. Au-delà, les plus anciens sont omis. Pour une grande collection, le paramètre bbox limite 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_minute lectures 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 demande mod_deflate ; sur Nginx, la directive gzip.
curl
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'
JavaScript (fetch)
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();
Réponse 200
{
  "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
curl -H 'X-Api-Key: gc_VOTRE_CLE' https://votre-instance.org/api/collections/bancs/points/128
JavaScript (fetch)
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;
Réponse 200
{
  "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 champ data et les fichiers dans des champs nommés comme la propriété image suivie de [] (photos[]).
curl
# 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'
JavaScript (fetch)
// 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.", … }
}
Réponse 201
{ "status": "pending" }      // en attente de modération
{ "status": "published" }    // collection sans modération : point publié
Réponse 422
{
  "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.

HTML
<!-- 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. null ou "" 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'option max du 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.

curl
# 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'
JavaScript (fetch)
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'
Réponse 201
{ "status": "pending" }      // proposition en attente de modération
{ "status": "published" }    // collection sans modération : point modifié
Réponse 422
{ "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/png ou image/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
curl -O https://votre-instance.org/api/files/3f2a9c0d41b7e8a65c1d2e3f4a5b6c7d.jpg
JavaScript
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) et can_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).
Réponse 200
{
  "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.

carte.html
<!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 bbox limite 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 bbox au 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/ et GET /api/front répond 404.
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 ref et 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_submission de la collection vaille true ;
  • une proposition de modification demande que les modifications soient autorisées dans les réglages du site et que l'option public_edit de la collection vaille true ;
  • 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é.

Conséquence

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ôleDroits
ModérationValider, refuser, ajouter, modifier et supprimer des points ; appliquer ou refuser les propositions de modification.
AdminDroits 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 :

  1. Un compte admin ouvre la page Membres, saisit l'adresse de la personne et son rôle, et obtient un lien d'invitation.
  2. Le lien est transmis à la personne par un autre canal (messagerie, e-mail, en personne).
  3. 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 ; new est 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_at est 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).

  1. 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.org pour les sous-domaines, localhost pour le développement.
  2. 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.

Ni e-mail, ni SMS

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.

  1. Le back-office affiche un QR code et la clé correspondante, pour une saisie manuelle.
  2. L'application scanne le QR code et affiche un code renouvelé toutes les 30 secondes.
  3. La saisie de ce code confirme l'activation.
  4. 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 :

  1. 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.
  2. 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.
  3. 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.org

    Sans SSH (hébergement mutualisé avec FTP seul), sur une copie locale de geocrowd avec PHP :

    1. télécharger data/geocrowd.sqlite, ainsi que geocrowd.sqlite-wal et geocrowd.sqlite-shm s'ils existent, dans un même dossier ;
    2. lancer php bin/reset-2fa.php adresse@exemple.org --db=chemin/vers/geocrowd.sqlite ;
    3. renvoyer geocrowd.sqlite sur le serveur, puis y supprimer geocrowd.sqlite-wal et geocrowd.sqlite-shm s'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.php du serveur ; seul geocrowd.sqlite est renvoyé, et le secret.php du serveur reste en place.

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).

Perte de secret.php

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.

bancs.json
{
  "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 nom area.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 donne default ;
  • default : valeur quand le tag est absent ou non traduit ;
  • angle: true : le tag est un angle en degrés, comme direction (numérique, ou traduit par map pour 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 de fields reprennent la valeur issue d'OSM, valeurs fixes comprises.
  • Les points saisis manuellement ne sont pas modifiés.
  • --file=reponse.json importe 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.
Licence des données

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 ref doit désigner un point publié.
  • Champs Adresse web limités aux adresses http et https : une adresse javascript: ou data: 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, et Secure en 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/) et config.php inaccessibles depuis le web, à vérifier après l'installation (voir Apache). Le back-office affiche une alerte aux comptes admin si data/geocrowd.sqlite se 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 (database et data_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.php et uploads/ (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.