Principe :
| S3 (MinIO) | SharePoint |
|---|---|
| Bucket | Dossier dans Documents |
| Objet | Fichier |
| Prefix | Sous‑dossier |
| DELETE | Suppression |
| RENAME | DELETE + CREATE |
| PUT | Upload |
| GET | Download |
S3 ne supporte pas le “rename” natif. MinIO envoie :
s3://dossier/fichier.pdf -> Documents/dossier/fichier.pdf
Ce service :
Un webhook est un mécanisme de notification automatique entre applications.
Un webhook est :
Contrairement à une API classique où l'on demande l’information, avec un webhook l’information vient à toi toute seule.
MinIO
└── webhook
└── appelle sharepoint-proxy
└── appelle Microsoft Graph
└── modifie SharePoint
POST http://sharepoint-proxy:8080/s3event Content-Type: application/json
{
"EventName": "s3:ObjectCreated:Put",
"Key": "dossier/fichierr.pdf"
}
sharepoint-proxy/ ├── main.py ├── graph.py ├── requirements.txt └── README.md
flask requests msal gunicorn
Utilisatio de gunicorn (production).
Gunicorn est le serveur d’exécution Python de référence pour les applications Flask en production ; il remplace le serveur de développement Flask, non fiable et non sécurisé.
gunicorn -w 2 -b 0.0.0.0:8080 main:app
Commentaires :
import os import msal import requests TENANT_ID = os.getenv("TENANT_ID") CLIENT_ID = os.getenv("CLIENT_ID") CERT_PATH = os.getenv("CERT_PATH") KEY_PATH = os.getenv("KEY_PATH") SITE_PATH = os.getenv("SITE_PATH") AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" SCOPE = ["https://graph.microsoft.com/.default"] def get_token(): app = msal.ConfidentialClientApplication( CLIENT_ID, authority=AUTHORITY, client_credential={ "private_key": open(KEY_PATH).read(), "public_certificate": open(CERT_PATH).read() } ) token = app.acquire_token_for_client(scopes=SCOPE) if "access_token" not in token: raise RuntimeError(f"Token error: {token}") return token["access_token"] def graph_request(method, url, headers=None, data=None): token = get_token() h = { "Authorization": f"Bearer {token}", "Content-Type": "application/json" } if headers: h.update(headers) r = requests.request(method, url, headers=h, data=data) r.raise_for_status() return r.json() if r.text else None
Pour sécuriser le webhook de MinIO vers le Proxy, utilisation d'un secret partagé (token) et vérification dans main.py du secret. Voir plus loin pour la configuration de la signature dans le Webhook.
Gérer les fichiers volumineux :
Microsoft Graph refuse :
Pour des fichiers > 250 Mo (vidéos, archives, sauvegardes)
Principe Graph (officiel)
Gestion de plusieurs dossiers dans une seule équipe SharePoint :
Structure :
Documents ├── administration │ ├── gestion │ ├── scolaires │ └── administration ├── bts-sio │ ├── stages │ ├── ccf │ └── livrets ├── bts-mco │ ├── stages │ ├── ccf │ └── livrets
Mapping pédagogique MinIO → SharePoint (préfixe MinIO = dossier SharePoint)
| MinIO (bucket pedagogie) | SharePoint (Documents) |
|---|---|
| administration/gestion/ | Documents/administration/gestion/ |
| bts-sio/ | Documents/bts-sio/ |
| bts-sio/stages/ | Documents/bts-sio/stages/ |
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
services:
minio:
image: minio/minio:latest
container_name: minio
command: server /data --console-address ":9001"
env_file:
- .env
volumes:
- minio-data:/data
ports:
- "9000:9000"
- "9001:9001"
sharepoint-proxy:
build: ./sharepoint-proxy
container_name: sharepoint-proxy
volumes:
- ./certs:/certs:ro
working_dir: /app
env_file:
- .env
depends_on:
- minio
command: >
gunicorn main:app
-- workers 2
--bind 0.0.0.0:8080
ports:
- "8080:8080"
volumes:
minio-data:
Utiliszer gunicorn en production
MINIO_ROOT_USER=admin MINIO_ROOT_PASSWORD=motdepasse TENANT_ID=<ID du tenant> CLIENT_ID=<ID de l'application dans Entra ID> SITE_PATH=tenant.sharepoint.com:/sites/site CERT_PATH=/certs/minio-sharepoint.pem KEY_PATH=/certs/minio-sharepoint.key AUTHORITY=https://login.microsoftonline.com/<ID du tenant> GRAPH_SCOPE=https://graph.microsoft.com/.default WEBHOOK_SECRET="super-secret-minio" DOCUMENT_LIBRARY="Documents"
docker compose exec sharepoint-proxy env
touch entrypoint.sh chmod +x entrypoint.sh
#!/bin/sh set -e echo "Initialising mc alias for MinIO..." mc alias set minio \ "http://minio:9000" \ "$MINIO_ROOT_USER" \ "$MINIO_ROOT_PASSWORD" \ --api S3v4 echo "mc alias 'minio' configured" # Lancer Gunicorn (process principal) exec "$@"
FROM python:3.12-slim
WORKDIR /app
# ---- Dépendances système utiles ----
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# ---- Installer MinIO Client (mc) ----
RUN curl -sSL https://dl.min.io/client/mc/release/linux-amd64/mc \
-o /usr/local/bin/mc \
&& chmod +x /usr/local/bin/mc
# ---- Dépendances Python ----
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ---- Code applicatif ----
COPY . .
ENV PYTHONUNBUFFERED=1
EXPOSE 8080
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8080", "main:app"]
docker compose build sharepoint-proxy --no-cache
MinIO n’active pas automatiquement le Webhook : il faut le déclarer avec mc.
L'automatisation consiste à créer sans intervention manuelle.
Solution idempotente : il est possible de relancer autant de fois que de besoin
Ce conteneur est basé sur minio/mc qui contient mc.
Code du Dockercompose.yml pour la création de l'alias, du bucket et des events
minio-init:
image: minio/mc:latest
container_name: minio-init
depends_on:
- minio
env_file:
- .env
entrypoint: >
/bin/sh -c "
echo 'Attente de MinIO...' &&
sleep 5 &&
echo ' Création de l alias MinIO' &&
mc alias set minio http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD &&
echo 'Création du bucket lycee (si absent)' &&
mc mb minio/lycee || true &&
echo '🧹 Suppression des anciens events' &&
mc event remove minio/lycee arn:minio:sqs::sharepoint:webhook || true &&
echo 'Création des events pédagogiques' &&
mc event add minio/lycee arn:minio:sqs::sharepoint:webhook --event put,delete --prefix administration/ &&
mc event add minio/lycee arn:minio:sqs::sharepoint:webhook --event put,delete --prefix bts-sio/ &&
mc event add minio/lycee arn:minio:sqs::sharepoint:webhook --event put,delete --prefix bts-mco/ &&
echo ' MinIO initialisé avec succès'
"
Le sharepoint-proxy utilise aussi mc. Il a aussi besion de l’alias.
La bonne pratique est d'utiliser un entrypoint.sh dans sharepoint-proxy (à mettre dans le dossier de sharepoint-proxy).
#!/bin/sh set -e echo "Initialisation de l'alias mc (MinIO)..." mc alias set minio \ http://minio:9000 \ "$MINIO_ROOT_USER" \ "$MINIO_ROOT_PASSWORD" \ --api S3v4 echo "Alias mc 'minio' prêt" # Lancer Gunicorn (process principal) exec "$@"
COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8080", "main:app"]
docker compose down docker compose up -d
docker compose exec sharepoint-proxy mc alias list => Résultat attendu : minio http
mc event list minio/lycee => résultats attendus Visualisation des 3 préfixes pédagogiques.
MinIO n’active pas automatiquement le Webhook : il faut le déclarer avec mc.
La mc signifie MinIO Client : C’est l’outil en ligne de commande officiel de MinIO.
curl -L https://dl.min.io/client/mc/release/linux-amd64/mc -o mc
chmod +x mc
mv mc /usr/local/bin
mc --version
mc alias set minio http://localhost:9000 minioadmin minioadmin123 => résultat attendu Added `minio` successfully
mc alias list => résultat attendu gcs URL : https://storage.googleapis.com AccessKey : YOUR-ACCESS-KEY-HERE SecretKey : YOUR-SECRET-KEY-HERE API : S3v2 Path : dns Src : /root/.mc/config.json local URL : http://localhost:9000 AccessKey : SecretKey : API : Path : auto Src : /root/.mc/config.json minio URL : http://localhost:9000 AccessKey : admin SecretKey : Orpheus87 API : s3v4 Path : auto Src : /root/.mc/config.json play URL : https://play.min.io AccessKey : Q3AM3UQ867SPQQA43P2F SecretKey : zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG API : S3v4 Path : auto Src : /root/.mc/config.json s3 URL : https://s3.amazonaws.com AccessKey : YOUR-ACCESS-KEY-HERE SecretKey : YOUR-SECRET-KEY-HERE API : S3v4 Path : dns Src : /root/.mc/config.json
Dans Docker, il faut utiliser le nom du service Docker et pas le nom DNS réseau de la VM où s'éxécute le conteneur.
Si le proxy est sur un serveur distinct et non dans Docker, il faut alors utiliser le nom DNS du server qui exécute le proxy.
Pour sécuriser le webhook MinIO vers le Proxy, utilisation d'un header statique contenant un token pour empêcher toute requête non légitime d’appeler /s3event.
mc admin config set minio notify_webhook:sharepoint \ endpoint="http://sharepoint-proxy:8080/s3event" \ auth_token="$WEBHOOK_SECRET" => résultat attendu : Successfully applied new settings. Please restart your server 'mc admin service restart minio'.
mc admin service restart minio
mc alias list => revoie notamment minio URL : http://localhost:9000
mc admin config get minio
mc admin config get minio notify_webhook => doit renvoyer # MINIO_NOTIFY_WEBHOOK_ENABLE=on notify_webhook enable=off endpoint= auth_token= queue_limit=0 queue_dir= client_cert= client_key= notify_webhook:sharepoint endpoint=http://sharepoint-proxy:8080/s3event auth_token= queue_limit=1000 queue_dir=/tmp/notify client_cert= client_key=
Commentaires :
mc admin config get minio notify_webhook:sharepoint => doit renvoyer notify_webhook:sharepoint endpoint=http://sharepoint-proxy:8080/s3event auth_token= queue_limit=1000 queue_dir=/tmp/notify client_cert= client_key=
mc admin config reset minio notify_webhook:sharepoint => doit renvoyer 'notify_webhook:sharepoint' is successfully reset. Please restart your server with `mc admin service restart minio`.
mc mb minio/lycee => résultat attendu Bucket created successfully `minio/lycee`.
mc ls minio
Filtrage côté MinIO (RECOMMANDÉ) : le proxy ne reçoit QUE ce qui est attendu
mc event add minio/lycee arn:minio:sqs::sharepoint:webhook \ --event put,delete \ --prefix administration => doit renvoyer Successfully added arn:minio:sqs::sharepoint:webhook mc event add minio/lycee arn:minio:sqs::sharepoint:webhook \ --event put,delete \ --prefix bts-sio => doit renvoyer Successfully added arn:minio:sqs::sharepoint:webhook mc event add minio/lycee arn:minio:sqs::sharepoint:webhook \ --event put,delete \ --prefix bts-mco => doit renvoyer Successfully added arn:minio:sqs::sharepoint:webhook
Commentaires :
mc event list minio/lycee => doit renvoyer arn:minio:sqs::sharepoint:webhook s3:ObjectCreated:*,s3:ObjectRemoved:* Filter: prefix="administration" arn:minio:sqs::sharepoint:webhook s3:ObjectCreated:*,s3:ObjectRemoved:* Filter: prefix="bts-sio" arn:minio:sqs::sharepoint:webhook s3:ObjectCreated:*,s3:ObjectRemoved:* Filter: prefix="bts-mco"
mc event remove minio/lycee --force
docker compose up -d => on doit voir Initializing SharePoint drive… Drive ID initialized: xxxx * Running on all addresses (0.0.0.0) * Running on http://127.0.0.1:8080
docker compose logs -f sharepoint-proxy
mc cp devoir.pdf minio/dossier/fichier.pdf
docker compose exec minio curl -v http://sharepoint-proxy:8080/s3event => doit renvoyer code HTTP 204 ou 405 (selon ton proxy) : Connexion OK
mc rm minio/dossier/fichier.pdf
curl http://localhost:8080/health
=> doit renvoyer
{"service":"sharepoint-proxy","status":"ok"}
Gunicorn fonctionne
Flask fonctionne
Le conteneur est joignable
docker compose logs sharepoint-proxy
=> il doit y avoir une ligne avec les informations "event": "startup" et "drive_id": "b!y5dzoJWR_0KMCQ3-yqD7EjV1iKiz8A5It-..."
sharepoint-proxy | {"timestamp": "2026-04-24T10:44:19.044366+00:00", "service": "sharepoint-proxy", "event": "startup", "drive_id": "b!y5dzoJWR_0KMCQ3-yqD7EjV1iK..."}
docker compose exec sharepoint-proxy sh
echo "TEST SHAREPOINT DIRECT" > /tmp/test-sp.txt
python
from main import upload_simple with open("/tmp/test-sp.txt", "rb") as f: upload_simple("administration/test-sharepoint.txt", f.read())
exit()
Documents
└── administration
└── test-sharepoint.txt