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
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)
from flask import Flask, request, abort import os, requests from graph import graph_request TENANT_ID = os.getenv("TENANT_ID") SITE_PATH = os.getenv("SITE_PATH") GRAPH_BASE = "https://graph.microsoft.com/v1.0" WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET") if not WEBHOOK_SECRET: raise RuntimeError("WEBHOOK_SECRET not set") # Détection de la taille du fichier / Upload thresholds MAX_SIMPLE_UPLOAD = 4 * 1024 * 1024 # 4 MB CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB app = Flask(__name__) # ───────────────────────────── # Sécurité webhook MinIO # ───────────────────────────── 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") # ───────────────────────────── # 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"] # INITIALISATION AU DÉMARRAGE print("Initializing SharePoint drive…") DRIVE_ID = get_drive_id() print(f"Drive ID initialized: {DRIVE_ID}") # ───────────────────────────── # Upload helpers # ───────────────────────────── def create_upload_session(sp_path): res = graph_request( "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, data): graph_request( "PUT", f"{GRAPH_BASE}/drives/{DRIVE_ID}/root:/{sp_path}:/content", data=data ) def upload_chunked(sp_path, file_path): 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) r.raise_for_status() start += len(chunk) # ───────────────────────────── # Event handlers # ───────────────────────────── def upload_object(key): print(f"Uploading {key} to SharePoint", flush=True) # 🔴 TODO : ici télécharger le fichier depuis MinIO vers /tmp file_path = f"/tmp/{os.path.basename(key)}" # (temporaire pour tests) with open(file_path, "wb") as f: f.write(b"TEST") size = os.path.getsize(file_path) if size <= MAX_SIMPLE_UPLOAD: with open(file_path, "rb") as f: upload_simple(key, f.read()) else: upload_chunked(key, file_path) os.remove(file_path) def delete_object(key): print(f"Deleting {key} from SharePoint", flush=True) graph_request( "DELETE", f"{GRAPH_BASE}/drives/{DRIVE_ID}/root:/{key}" ) # ───────────────────────────── # Webhook endpoint # ───────────────────────────── @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 = record["s3"]["object"]["key"] if event_name.startswith("s3:ObjectCreated"): upload_object(key) elif event_name.startswith("s3:ObjectRemoved"): delete_object(key) return "", 204 if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)
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:
- ./sp-proxy:/app
- ./certs:/certs:ro
working_dir: /app
command: python main.py
env_file:
- .env
depends_on:
- minio
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
FROM python:3.12-slim
WORKDIR /app
# Installer les dépendances système minimales
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copier les dépendances Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copier le code
COPY . .
EXPOSE 8080
CMD ["python", "main.py"]
docker compose build sharepoint-proxy
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 event add minio/test arn:minio:sqs::sharepoint:webhook --event put,delete => doit renvoyer Successfully added arn:minio:sqs::sharepoint:webhook
Commentaires :
mc event list minio/test => doit renvoyer arn:minio:sqs::sharepoint:webhook s3:ObjectCreated:*,s3:ObjectRemoved:* Filter:
mc event remove minio/test --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
Microsoft Graph refuse :
Principe Graph (officiel)
import os MAX_SIMPLE_UPLOAD = 4 * 1024 * 1024 # 4 MB size = os.path.getsize(file_path) if size <= MAX_SIMPLE_UPLOAD: upload_simple(path, file_path) else: upload_chunked(path, file_path)
def create_upload_session(drive_id, sp_path): return graph_request( "POST", f"{GRAPH_BASE}/drives/{drive_id}/root:/{sp_path}:/createUploadSession", json={ "item": { "@microsoft.graph.conflictBehavior": "replace" } } )["uploadUrl"]
import requests
def upload_chunked(sp_path, local_file):
upload_url = create_upload_session(DRIVE_ID, sp_path)
chunk_size = 10 * 1024 * 1024 # 10 MB
size = os.path.getsize(local_file)
with open(local_file, "rb") as f:
start = 0
while start < size:
data = f.read(chunk_size)
end = start + len(data) - 1
headers = {
"Content-Length": str(len(data)),
"Content-Range": f"bytes {start}-{end}/{size}"
}
r = requests.put(upload_url, headers=headers, data=data)
r.raise_for_status()
start += chunk_size