from flask import Flask, request, abort import os import requests import subprocess import time import json from datetime import datetime, timezone from urllib.parse import unquote from graph import graph_request # ===================================================== # CONFIGURATION GLOBALE # ===================================================== GRAPH_BASE = "https://graph.microsoft.com/v1.0" SITE_PATH = os.getenv("SITE_PATH") MINIO_BUCKET = "lycee" TMP_DIR = "/tmp" WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET") if not WEBHOOK_SECRET: raise RuntimeError("WEBHOOK_SECRET not set") # Upload thresholds MAX_SIMPLE_UPLOAD = 4 * 1024 * 1024 # 4 MB CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB # Graph retry / backoff MAX_RETRIES = 5 INITIAL_BACKOFF = 1.0 # seconds app = Flask(__name__) print("=== SHAREPOINT PROXY STARTED ===", flush=True) # ===================================================== # LOGGING STRUCTURÉ (JSON) # ===================================================== def log(event, **data): print(json.dumps({ "timestamp": datetime.now(timezone.utc).isoformat(), "service": "sharepoint-proxy", "event": event, **data }), flush=True) # ===================================================== # SÉCURITÉ WEBHOOK MINIO (token statique) # ===================================================== def verify_minio_webhook(req): auth = req.headers.get("Authorization") if not auth or not auth.startswith("Bearer "): abort(401, "Missing Authorization header") token = auth[len("Bearer "):].strip() if token != WEBHOOK_SECRET: abort(403, "Invalid MinIO webhook token") # ===================================================== # INIT SHAREPOINT DRIVE # ===================================================== def get_drive_id(): site = graph_request("GET", f"{GRAPH_BASE}/sites/{SITE_PATH}") drive = graph_request("GET", f"{GRAPH_BASE}/sites/{site['id']}/drive") return drive["id"] log("startup", message="Initializing SharePoint drive") DRIVE_ID = get_drive_id() log("startup", drive_id=DRIVE_ID) # ===================================================== # GRAPH REQUEST RETRY / BACKOFF # ===================================================== def graph_request_with_retry(method, url, **kwargs): backoff = INITIAL_BACKOFF for attempt in range(1, MAX_RETRIES + 1): try: return graph_request(method, url, **kwargs) except Exception as e: if attempt == MAX_RETRIES: log("graph_error", attempt=attempt, error=str(e)) raise log("graph_retry", attempt=attempt, backoff=backoff, error=str(e)) time.sleep(backoff) backoff *= 2 # ===================================================== # MAPPING PÉDAGOGIQUE # ===================================================== def map_mapping_path(key: str) -> str | None: key = key.lstrip("/").split("?")[0] ALLOWED_PREFIXES = [ "administration/", "bts-sio/", "bts-mco/" ] for prefix in ALLOWED_PREFIXES: if key.startswith(prefix): return key return None # ===================================================== # DOWNLOAD DEPUIS MINIO # ===================================================== def download_from_minio(key: str) -> str: local_path = os.path.join(TMP_DIR, os.path.basename(key)) cmd = [ "mc", "cp", f"minio/{MINIO_BUCKET}/{key}", local_path ] subprocess.check_call(cmd) return local_path # ===================================================== # UPLOAD SHAREPOINT # ===================================================== def create_upload_session(sp_path: str) -> str: res = graph_request_with_retry( "POST", f"{GRAPH_BASE}/drives/{DRIVE_ID}/root:/{sp_path}:/createUploadSession", json={ "item": { "@microsoft.graph.conflictBehavior": "replace" } } ) return res["uploadUrl"] def upload_simple(sp_path: str, data: bytes): graph_request_with_retry( "PUT", f"{GRAPH_BASE}/drives/{DRIVE_ID}/root:/{sp_path}:/content", data=data ) def upload_chunked(sp_path: str, file_path: str): upload_url = create_upload_session(sp_path) size = os.path.getsize(file_path) with open(file_path, "rb") as f: start = 0 while start < size: chunk = f.read(CHUNK_SIZE) end = start + len(chunk) - 1 headers = { "Content-Length": str(len(chunk)), "Content-Range": f"bytes {start}-{end}/{size}" } r = requests.put(upload_url, headers=headers, data=chunk) if not r.ok: raise RuntimeError(f"Chunk upload failed: {r.status_code} {r.text}") start += len(chunk) # ===================================================== # HANDLERS ÉVÉNEMENTS # ===================================================== def upload_object(key: str): sp_path = map_mapping_path(key) if not sp_path: log("ignored", reason="non_pedagogical", key=key) return log("upload_start", key=sp_path) file_path = download_from_minio(key) size = os.path.getsize(file_path) try: if size <= MAX_SIMPLE_UPLOAD: log("upload_mode", mode="simple", size=size) with open(file_path, "rb") as f: upload_simple(sp_path, f.read()) else: log("upload_mode", mode="chunked", size=size) upload_chunked(sp_path, file_path) log("upload_success", key=sp_path, size=size) finally: os.remove(file_path) def delete_object(key: str): sp_path = map_mapping_path(key) if not sp_path: return log("delete", key=sp_path) graph_request_with_retry( "DELETE", f"{GRAPH_BASE}/drives/{DRIVE_ID}/root:/{sp_path}" ) # ===================================================== # ENDPOINT WEBHOOK MINIO # ===================================================== @app.route("/s3event", methods=["POST"]) def s3_event(): verify_minio_webhook(request) event = request.get_json() record = event["Records"][0] event_name = record["eventName"] key = unquote(record["s3"]["object"]["key"]).split("?")[0] log("event_received", s3_event=event_name, key=key) if event_name.startswith("s3:ObjectCreated"): upload_object(key) elif event_name.startswith("s3:ObjectRemoved"): delete_object(key) return "", 204 # ===================================================== # ENDPOINT HEALTH # ===================================================== @app.route("/health", methods=["GET"]) def health(): return { "status": "ok", "service": "sharepoint-proxy" }, 200