Skip to content

#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)

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

python
# 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

python
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

bash
# 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=#f5c445

Convention NAS-ID

ClassificationNAS-ID FormatExemple
Mono-site V1access_[slug]access_kossou
Multi-site V2 site 1access_[slug]_s1access_tech_connect_s1
Multi-site V2 site 2access_[slug]_s2access_tech_connect_s2
Multi-site V3 site Naccess_[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éthodeRouteBodyRéponseAuthNotes
GET/api/v1/branding/{nas_id}200 {logo_url, colors, name, ssid}AucunePortail (public)
PUT/api/v1/branding/{nas_id}{primary_color, secondary_color, reseller_name}200Admin/Revendeur JWTMise à jour
POST/api/v1/branding/{nas_id}/logoMultipart image200 {logo_url}Admin/Revendeur JWTUpload logo
DELETE/api/v1/branding/{nas_id}/logo204Admin/Revendeur JWTSupprimer logo

GET /api/v1/branding/access_kossou

Response 200:

json
{
  "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):

json
{
  "error": {
    "code": "ERR_NAS_NOT_FOUND",
    "message": "NAS-ID 'access_unknown' not registered",
    "details": {}
  }
}

PUT /api/v1/branding/access_kossou

Request:

json
{
  "primary_color": "#1a5276",
  "secondary_color": "#f0b429",
  "reseller_name": "Kossou WiFi — Fiducia"
}

Response 200:

json
{
  "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:

json
{
  "logo_url": "https://cdn.rgz.local/logos/kossou_logo_v2.png",
  "updated_at": "2026-02-21T14:05:00Z"
}

Redis Keys

CléTypeTTLUsage
rgz:portal:config:{nas_id}Hash3600sConfig 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ègleImplémentation
SEC-01IDOR : 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-12Upload 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-13Injection CSS : valider que primary_color et secondary_color sont des codes hex valides (regex ^#[0-9A-Fa-f]{6}$) avant stockage et injection
SEC-11CORS : endpoint GET /api/v1/branding/{nas_id} autorisé depuis domaine portail captif uniquement
python
# 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

bash
# 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 sur reseller_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.css avec 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

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone