#18 — Branding Revendeur
PLANIFIÉ
Priorité: 🟠 HAUTE · Type: TYPE E · Conteneurs: rgz-portal + rgz-api · Code: portal/css/brand.css + app/api/v1/endpoints/branding.py
Dépendances: #3 rgz-portal · #1 rgz-api
Description
Le module Branding Revendeur permet à chaque partenaire du réseau ACCESS de personnaliser l'apparence du portail captif sur ses propres points d'accès. Lorsqu'un abonné atterrit sur le portail, le NAS-ID de la borne WiFi est transmis en paramètre URL. Le portail interroge l'API pour récupérer la configuration de branding associée à ce NAS-ID, puis injecte dynamiquement les variables CSS : logo, couleur principale, couleur secondaire, et nom du revendeur.
Cette personnalisation renforce la relation commerciale des revendeurs avec leurs clients locaux tout en maintenant la cohérence de la marque ACCESS (charte graphique, police Poppins, structure de page). Le fallback est toujours la charte ACCESS standard — si le NAS-ID est inconnu ou si l'API est indisponible, le portail affiche les couleurs ACCESS par défaut (jaune #f5c445, bleu #3f68ae).
La convention de nommage NAS-ID est strictement appliquée : access_[slug] pour les sites mono (V1) et access_[slug]_s[N] pour les sites multi (V2/V3). Le slug est dérivé du nom du revendeur en minuscules, sans accents, avec underscores. Cette convention est partagée avec les outils #6 RADIUS, #31 VLAN, #33 CPE Preconfig et #56 Onboarding.
La configuration de branding est intégrée dans le hash Redis rgz:portal:config:{nas_id} (TTL 3600s), qui centralise toutes les données nécessaires au portail pour un hotspot donné (branding + bannières + forfaits disponibles). Ce cache est invalidé automatiquement lors de toute mise à jour de branding via l'API.
Architecture Interne
Flux de Chargement Branding
Appareil WiFi → Portail captif (/index.html?nas_id=access_kossou)
↓
portal.js — DOMContentLoaded:
const params = new URLSearchParams(window.location.search);
const nasId = params.get('nas_id') || 'default';
↓
GET /api/v1/branding/{nas_id}
↓
Si 200:
{logo_url, primary_color, secondary_color, reseller_name, ssid}
↓
applyBranding(config):
document.documentElement.style.setProperty('--brand-primary', primary_color)
document.documentElement.style.setProperty('--brand-secondary', secondary_color)
document.getElementById('brand-logo').src = logo_url
document.title = `ACCESS ${reseller_name} — Portail WiFi`
Si 404 ou erreur réseau:
applyBranding(DEFAULT_BRAND) ← couleurs ACCESS standard
↓
Portail rendu avec branding injectéVariables CSS Dynamiques (portal/css/brand.css)
:root {
/* Valeurs par défaut ACCESS (fallback) */
--brand-primary: #3f68ae; /* Couleur principale */
--brand-secondary: #f5c445; /* Couleur accentuation */
--brand-logo-url: url('/img/access-logo.svg');
--brand-name: 'ACCESS';
/* Variables ACCESS standard (ne jamais écraser) */
--access-yellow: #f5c445;
--access-blue: #3f68ae;
--access-red: #da3747;
--access-dark: #34383c;
--access-white: #ffffff;
}
/* Bouton principal utilise la couleur brand */
.btn-primary {
background-color: var(--brand-primary);
color: var(--access-white);
}
/* Header utilise la couleur secondaire */
.portal-header {
background-color: var(--brand-secondary);
border-bottom: 3px solid var(--brand-primary);
}
/* Zone logo */
.brand-logo {
max-height: 60px;
min-width: 80px;
object-fit: contain;
}
/* Mention légale ACCESS toujours visible */
.powered-by {
font-size: 11px;
color: var(--access-dark);
opacity: 0.6;
}Modèle de Données
# Table: reseller_branding
class ResellerBranding(Base):
__tablename__ = "reseller_branding"
id: UUID = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
reseller_id: UUID = Column(UUID(as_uuid=True), ForeignKey("resellers.id"), unique=True)
logo_url: str = Column(Text, nullable=True)
primary_color: str = Column(String(7), default="#3f68ae") # Hex 6 digits
secondary_color: str = Column(String(7), default="#f5c445")
reseller_display_name: str = Column(Text, nullable=True) # Nom affiché portail
created_at: datetime = Column(TIMESTAMP(timezone=True), default=func.now())
updated_at: datetime = Column(TIMESTAMP(timezone=True), onupdate=func.now())Résolution NAS-ID → Branding
async def get_branding_for_nas(nas_id: str, db: Session) -> BrandingResponse:
"""
1. Chercher le site via nas_id → reseller_id
2. Chercher le branding via reseller_id
3. Retourner les données + SSID du site
Flux:
nas_id → reseller_sites.nas_id → reseller_id
→ reseller_branding.reseller_id → logo, colors
→ resellers.slug → display_name fallback
"""
# Étape 1: trouver le site
site = db.query(ResellerSite)\
.filter(ResellerSite.nas_id == nas_id)\
.first()
if not site:
raise HTTPException(404, detail={
"error": {
"code": "ERR_NAS_NOT_FOUND",
"message": f"NAS-ID '{nas_id}' not registered",
"details": {}
}
})
# Étape 2: trouver le branding
branding = db.query(ResellerBranding)\
.filter(ResellerBranding.reseller_id == site.reseller_id)\
.first()
# Étape 3: construire réponse (avec fallbacks)
reseller = db.query(Reseller)\
.filter(Reseller.id == site.reseller_id)\
.first()
return BrandingResponse(
logo_url=branding.logo_url if branding else None,
primary_color=branding.primary_color if branding else "#3f68ae",
secondary_color=branding.secondary_color if branding else "#f5c445",
reseller_name=branding.reseller_display_name if branding else reseller.name,
ssid=site.ssid,
nas_id=nas_id
)Configuration
Variables d'environnement
# Branding
BRANDING_LOGO_MAX_SIZE_BYTES=512000 # 500 KB max pour logos
BRANDING_ALLOWED_LOGO_TYPES=image/png,image/svg+xml
BRANDING_STORAGE_PATH=/app/data/logos
BRANDING_CDN_BASE_URL=https://cdn.rgz.local/logos
BRANDING_CACHE_TTL_SECONDS=3600
# Couleurs fallback ACCESS
BRANDING_DEFAULT_PRIMARY=#3f68ae
BRANDING_DEFAULT_SECONDARY=#f5c445Convention NAS-ID
| Classification | NAS-ID Format | Exemple |
|---|---|---|
| Mono-site V1 | access_[slug] | access_kossou |
| Multi-site V2 site 1 | access_[slug]_s1 | access_tech_connect_s1 |
| Multi-site V2 site 2 | access_[slug]_s2 | access_tech_connect_s2 |
| Multi-site V3 site N | access_[slug]_s[N] | access_grand_marche_s4 |
Règles slug :
- Minuscules uniquement
- Accents remplacés (
é→e,ê→e,ç→c, etc.) - Espaces et tirets → underscores
- Max 20 caractères
- Caractères autorisés :
[a-z0-9_]
Endpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/branding/{nas_id} | — | 200 {logo_url, colors, name, ssid} | Aucune | Portail (public) |
| PUT | /api/v1/branding/{nas_id} | {primary_color, secondary_color, reseller_name} | 200 | Admin/Revendeur JWT | Mise à jour |
| POST | /api/v1/branding/{nas_id}/logo | Multipart image | 200 {logo_url} | Admin/Revendeur JWT | Upload logo |
| DELETE | /api/v1/branding/{nas_id}/logo | — | 204 | Admin/Revendeur JWT | Supprimer logo |
GET /api/v1/branding/access_kossou
Response 200:
{
"nas_id": "access_kossou",
"ssid": "ACCESS Kossou",
"reseller_name": "Kossou WiFi",
"logo_url": "https://cdn.rgz.local/logos/kossou_logo.png",
"primary_color": "#1a5276",
"secondary_color": "#f0b429"
}Response 404 (NAS inconnu — portail utilise fallback):
{
"error": {
"code": "ERR_NAS_NOT_FOUND",
"message": "NAS-ID 'access_unknown' not registered",
"details": {}
}
}PUT /api/v1/branding/access_kossou
Request:
{
"primary_color": "#1a5276",
"secondary_color": "#f0b429",
"reseller_name": "Kossou WiFi — Fiducia"
}Response 200:
{
"nas_id": "access_kossou",
"primary_color": "#1a5276",
"secondary_color": "#f0b429",
"reseller_name": "Kossou WiFi — Fiducia",
"updated_at": "2026-02-21T14:00:00Z"
}POST /api/v1/branding/access_kossou/logo (Multipart)
Request: Content-Type: multipart/form-data; boundary=...
--boundary
Content-Disposition: form-data; name="file"; filename="logo.png"
Content-Type: image/png
[binary PNG data]
--boundary--Response 200:
{
"logo_url": "https://cdn.rgz.local/logos/kossou_logo_v2.png",
"updated_at": "2026-02-21T14:05:00Z"
}Redis Keys
| Clé | Type | TTL | Usage |
|---|---|---|---|
rgz:portal:config:{nas_id} | Hash | 3600s | Config complète du hotspot (inclut champs brand_logo, brand_primary, brand_secondary, brand_name) |
Champs Hash utilisés pour le branding :
rgz:portal:config:access_kossou → {
brand_logo: "https://cdn.rgz.local/logos/kossou_logo.png",
brand_primary: "#1a5276",
brand_secondary: "#f0b429",
brand_name: "Kossou WiFi",
ssid: "ACCESS Kossou",
...autres champs (bannières, forfaits)...
}Invalidation cache : sur tout PUT/POST/DELETE branding → DEL rgz:portal:config:{nas_id}
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-01 | IDOR : un revendeur ne peut modifier que le branding de ses propres NAS-IDs. Vérifier site.reseller_id == current_user.reseller_id avant toute modification |
| SEC-12 | Upload logo : vérification magic bytes (89504E47 pour PNG, détecter SVG via <svg), Content-Type whitelist (image/png, image/svg+xml), taille max 500 KB |
| SEC-13 | Injection CSS : valider que primary_color et secondary_color sont des codes hex valides (regex ^#[0-9A-Fa-f]{6}$) avant stockage et injection |
| SEC-11 | CORS : endpoint GET /api/v1/branding/{nas_id} autorisé depuis domaine portail captif uniquement |
# Validation couleur hex (prévention injection CSS)
import re
HEX_COLOR_REGEX = re.compile(r'^#[0-9A-Fa-f]{6}$')
def validate_hex_color(color: str) -> str:
if not HEX_COLOR_REGEX.match(color):
raise ValueError(f"Invalid hex color: {color}")
return color.upper()
# SEC-01 — Vérification NAS-ID appartient au revendeur
async def check_nas_ownership(nas_id: str, reseller_id: UUID, db: Session):
site = db.query(ResellerSite)\
.filter(
ResellerSite.nas_id == nas_id,
ResellerSite.reseller_id == reseller_id
).first()
if not site:
raise HTTPException(403, detail={
"error": {
"code": "ERR_NAS_NOT_YOURS",
"message": "This NAS-ID does not belong to your account",
"details": {}
}
})
# SEC-12 — Upload logo avec vérification magic bytes
ALLOWED_MAGIC = {
b'\x89PNG': 'image/png',
b'<svg': 'image/svg+xml',
}
async def validate_logo_upload(file: UploadFile) -> bytes:
if file.size > 512_000:
raise HTTPException(400, "Logo trop grand (max 500 KB)")
content = await file.read()
for magic, mime in ALLOWED_MAGIC.items():
if content[:len(magic)] == magic:
return content
raise HTTPException(400, "Format fichier non supporté (PNG ou SVG uniquement)")Commandes Utiles
# Récupérer branding d'un NAS (portail public, pas de JWT)
curl https://api-rgz.duckdns.org/api/v1/branding/access_kossou
# Mettre à jour couleurs (admin ou revendeur)
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"primary_color": "#1a5276", "secondary_color": "#f0b429"}' \
https://api-rgz.duckdns.org/api/v1/branding/access_kossou
# Upload logo revendeur
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/logo.png" \
https://api-rgz.duckdns.org/api/v1/branding/access_kossou/logo
# Voir config branding en cache Redis
docker exec rgz-redis redis-cli HGETALL "rgz:portal:config:access_kossou"
# Invalider cache branding
docker exec rgz-redis redis-cli DEL "rgz:portal:config:access_kossou"
# Vérifier site existant pour un NAS-ID
docker exec rgz-db psql -U rgz -d rgzdb \
-c "SELECT r.slug, rs.nas_id, rs.ssid FROM reseller_sites rs
JOIN resellers r ON r.id = rs.reseller_id
WHERE rs.nas_id = 'access_kossou';"
# Logs branding
docker logs rgz-api --tail=50 | grep "branding"Implémentation TODO
- [ ] Modèle SQLAlchemy
ResellerBranding(unique surreseller_id) - [ ] Migration Alembic table
reseller_branding - [ ] Endpoint
GET /api/v1/branding/{nas_id}(public, pas de JWT, avec fallback couleurs ACCESS) - [ ] Endpoint
PUT /api/v1/branding/{nas_id}(admin + revendeur owner) - [ ] Endpoint
POST /api/v1/branding/{nas_id}/logo(upload avec magic bytes check) - [ ] Endpoint
DELETE /api/v1/branding/{nas_id}/logo - [ ] Validation hex color (regex
^#[0-9A-Fa-f]{6}$) sur PUT - [ ] Intégration cache
rgz:portal:config:{nas_id}(write-through + invalidation) - [ ]
portal/css/brand.cssavec variables CSS--brand-primary,--brand-secondary - [ ]
portal/js/portal.js: lire?nas_id=, GET branding,setProperty()CSS vars, fallback - [ ] Injection titre page
ACCESS [NomRevendeur] — Portail WiFi - [ ] Mention "Powered by RGZ" toujours visible (classe
.powered-by) - [ ] Tests SEC-01 (IDOR revendeur croisé → 403)
- [ ] Tests SEC-12 (upload fichier malveillant → 400)
- [ ] Tests SEC-13 (injection CSS via couleur invalide → 422)
- [ ] Tests fallback (NAS inconnu → couleurs ACCESS par défaut dans portail)
Dernière mise à jour: 2026-02-21