From 450300ddf6d775141c2fc0c68d4cfc3ba41617fa Mon Sep 17 00:00:00 2001 From: dduck Date: Fri, 25 Sep 2026 13:58:11 +0200 Subject: [PATCH] Ajout du backend --- backend/app.py | 123 +++++++++++++++++++++++++++++++++++++ backend/config.py | 30 +++++++++ backend/store_file.py | 31 ++++++++++ backend/store_firestore.py | 18 ++++++ 4 files changed, 202 insertions(+) create mode 100644 backend/app.py create mode 100644 backend/config.py create mode 100644 backend/store_file.py create mode 100644 backend/store_firestore.py diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..b42a87c --- /dev/null +++ b/backend/app.py @@ -0,0 +1,123 @@ +import logging +from datetime import datetime + +from flask import Flask, jsonify, request + +import config + +# --- Stockage --------------------------------------------------------------- +if config.STORE_BACKEND == "firestore": + from store_firestore import FirestoreStore as Store + store = Store(project_id=config.PROJECT_ID) # noqa: F401 (voir config) +else: + from store_file import FileStore as Store + store = Store(config.DATA_DIR) + +# --- Logging ---------------------------------------------------------------- +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") +log = logging.getLogger("iot-api") + +app = Flask(__name__) + + +# --- Aide : réponses génériques --------------------------------- +def err(status, code): + return jsonify({"error": code}), status + + +# --- Authentification dispositif ------------------------------ +def auth_device(headers): + """Retourne (device_id, error) — résout l'identité du device ou renvoie l'erreur.""" + device_id = headers.get("X-Device-ID") + secret = headers.get("X-Device-Secret") + + if not device_id: + return None, err(401, "unauthorized") + entry = config.DEVICE_SECRETS.get(device_id) + if not entry or entry["secret"] != secret: + log.warning("ingest refused bad-secret device=%s", device_id) + return None, err(401, "unauthorized") + + return device_id, None + + +# --- Authentification utilisateur (mode démo ; Firebase en extension) -------- +def auth_user(headers): + """Retourne (username, error). Mode démo : token = nom d'utilisateur.""" + auth = headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return None, err(401, "unauthorized") + token = auth[len("Bearer "):] + if token not in config.DEMO_USERS: + return None, err(401, "unauthorized") + return token, None + + +# --- Validation payload ------------------------------------------ +def validate_payload(payload, device_id): + """Valide le payload. Retourne (measure, error).""" + if not isinstance(payload, dict): + return None, err(400, "invalid-payload") + + pid = payload.get("device_id") + if pid != device_id: + # L'identité déclarée dans le body doit correspondre à l'en-tête. + return None, err(400, "invalid-payload") + + ts = payload.get("timestamp") + try: + datetime.fromisoformat(ts) + except (TypeError, ValueError): + return None, err(400, "invalid-payload") + + light = payload.get("light_value") + if not isinstance(light, int): + return None, err(400, "invalid-payload") + if not (config.LIGHT_MIN <= light <= config.LIGHT_MAX): + log.warning( + "ingest refused invalid-payload device=%s (light out of range)", + device_id, + ) + return None, err(400, "invalid-payload") + + return {"device_id": pid, "timestamp": ts, "light_value": light}, None + + +# --- Endpoints ---------------------------------------------------------------- +@app.post("/api/ingest") +def ingest(): + device_id, aerr = auth_device(request.headers) + if aerr is not None: + return aerr + + payload = request.get_json(silent=True) + measure, verr = validate_payload(payload, device_id) + if verr is not None: + return verr + + store.add_measure(measure) + log.info("ingest ok device=%s", device_id) + return jsonify({"ok": True}), 201 + + +@app.get("/api/measures") +def list_measures(): + username, aerr = auth_user(request.headers) + if aerr is not None: + return aerr + + device_id = request.args.get("device_id", "") + + # Autorisation SUR LA RESSOURCE: l'utilisateur ne lit + # que les devices dont il est propriétaire. + if device_id not in config.DEMO_USERS[username]["devices"]: + log.warning("measures refused user=%s device=%s", username, device_id) + return err(403, "forbidden") + + measures = store.list_measures(device_id) + log.info("measures ok user=%s device=%s", username, device_id) + return jsonify(measures) + + +if __name__ == "__main__": + app.run(host=config.HOST, port=config.PORT) \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..ee80eb2 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,30 @@ +DEVICE_SECRETS = { + "esp32-01": {"secret": "demo-secret-esp32-01", "owner": "demo-user-alice"}, + "esp32-02": {"secret": "demo-secret-esp32-02", "owner": "demo-user-bob"}, +} + +# Utilisateurs de démonstration (mode local, sans Firebase). +# En production : tokens émis par le fournisseur d'identité. +DEMO_USERS = { + "demo-user-alice": {"devices": ["esp32-01"]}, + "demo-user-bob": {"devices": ["esp32-02"]}, +} + +# --- Stockage --- +# "file" : fichier JSON local (aucun compte cloud). +# "firestore" : extension documentée (store_firestore.py). +STORE_BACKEND = "file" +DATA_DIR = "data" + +# --- Validation --- +LIGHT_MIN = 0 +LIGHT_MAX = 100000 + +# --- Sécurité --- +# Secret du compte de service Firestore (si STORE_BACKEND=firestore) : + +# --- Divers --- +# 0.0.0.0 : accessible depuis le réseau local (nécessaire pour l'ESP32). +# +HOST = "0.0.0.0" +PORT = 5000 \ No newline at end of file diff --git a/backend/store_file.py b/backend/store_file.py new file mode 100644 index 0000000..b1179bd --- /dev/null +++ b/backend/store_file.py @@ -0,0 +1,31 @@ +import json +import os +import threading + + +class FileStore: + def __init__(self, data_dir): + self.path = os.path.join(data_dir, "measures.json") + self._lock = threading.Lock() + os.makedirs(data_dir, exist_ok=True) + if not os.path.exists(self.path): + self._write([]) + + def _read(self): + with open(self.path, "r", encoding="utf-8") as f: + return json.load(f) + + def _write(self, data): + with open(self.path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def add_measure(self, measure): + with self._lock: + data = self._read() + data.append(measure) + self._write(data) + + def list_measures(self, device_id, limit=50): + with self._lock: + data = self._read() + return [m for m in data if m["device_id"] == device_id][-limit:] \ No newline at end of file diff --git a/backend/store_firestore.py b/backend/store_firestore.py new file mode 100644 index 0000000..70b53b2 --- /dev/null +++ b/backend/store_firestore.py @@ -0,0 +1,18 @@ +class FirestoreStore: + def __init__(self, project_id, collection="measures"): + from google.cloud import firestore # import tardif : optionnel + + self.db = firestore.Client(project=project_id) + self.collection = collection + + def add_measure(self, measure): + self.db.collection(self.collection).add(measure) + + def list_measures(self, device_id, limit=50): + docs = ( + self.db.collection(self.collection) + .where("device_id", "==", device_id) + .limit(limit) + .stream() + ) + return [d.to_dict() for d in docs] \ No newline at end of file