Skip to content

#16 — Bannières Publicitaires

PLANIFIÉ

Priorité: 🟠 HAUTE · Type: TYPE E · Conteneurs: rgz-portal + rgz-api · Code: portal/js/banners.js + app/api/v1/endpoints/banners.py

Dépendances: #1 rgz-api · #3 rgz-portal


Description

Le module Bannières Publicitaires gère l'affichage rotatif de publicités dans le portail captif ACCESS. Chaque bannière est affichée pendant 8 secondes avant de passer à la suivante, avec une transition douce (CSS fade). Le système distingue deux types de bannières : les bannières globales (pilotées par RGZ pour l'ensemble du réseau) et les bannières revendeur (uploadées par chaque partenaire pour leurs propres hotspots).

Le chargement des bannières est conditionné au NAS-ID de la borne d'accès. Lorsqu'un abonné se connecte sur le hotspot d'un revendeur donné, le portail interroge l'API avec ce NAS-ID et reçoit un mix ordonné de bannières globales et des bannières spécifiques à ce revendeur. Cette mécanique permet à chaque partenaire de monétiser ses propres points d'accès avec des publicités locales (commerçants du quartier, promotions spéciales).

Chaque affichage d'une bannière incrémente un compteur impressions_count et chaque clic incrémente clicks_count. Ces métriques sont exploitées dans le dashboard revendeur (#51) pour mesurer le ROI des campagnes. Les données sont agrégées par période et exportables en CSV.

La configuration complète d'un hotspot (bannières + branding + forfaits) est mise en cache Redis sous la clé rgz:portal:config:{nas_id} avec un TTL de 3600 secondes. Cela réduit la charge sur l'API lors des pics de connexions et assure une réponse rapide au portail même en cas de latence réseau vers l'API.


Architecture Interne

Flux de Rotation (portal/js/banners.js)

Page portail chargée (après DNS sinkhole redirect)

portal.js récupère ?nas_id= depuis les paramètres URL

GET /api/v1/banners?nas_id={nas_id}
    → Response: {items: [{id, image_url, link_url, duration_seconds, type}, ...], total}

Si items.length === 0 → afficher bannière ACCESS par défaut

Préchargement images (new Image().src = url) pour toutes les bannières

Affichage bannière[0]

setInterval(8000ms):
    - POST /api/v1/banners/{id}/impression (non bloquant, fire-and-forget)
    - transition fade-out (300ms CSS)
    - bannière[n+1] visible (cycling)
    - transition fade-in (300ms CSS)

onClick bannière:
    - POST /api/v1/banners/{id}/click
    - window.open(link_url, '_blank', 'noopener,noreferrer')

Modèle de Données

python
# Table: banners
class Banner(Base):
    __tablename__ = "banners"

    id: UUID = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
    reseller_id: UUID = Column(UUID(as_uuid=True), ForeignKey("resellers.id"), nullable=True)
    type: str = Column(
        String,
        CheckConstraint("type IN ('global', 'reseller')"),
        nullable=False
    )
    image_url: str = Column(Text, nullable=False)
    link_url: str = Column(Text, nullable=True)
    duration_seconds: int = Column(Integer, default=8)
    is_active: bool = Column(Boolean, default=True)
    impressions_count: int = Column(BigInteger, default=0)
    clicks_count: int = Column(BigInteger, default=0)
    valid_from: datetime = Column(TIMESTAMP(timezone=True), nullable=True)
    valid_until: datetime = Column(TIMESTAMP(timezone=True), nullable=True)
    created_at: datetime = Column(TIMESTAMP(timezone=True), default=func.now())

Sélection Bannières par NAS-ID

python
async def get_banners_for_nas(nas_id: str, db: Session) -> list[Banner]:
    """
    Sélectionne bannières actives pour un NAS-ID donné.
    Mix : toutes bannières globales + bannières du revendeur propriétaire du NAS.
    Triées par valid_from DESC, id ASC.
    """
    now = datetime.utcnow()

    # Récupérer reseller_id depuis nas_id
    site = db.query(ResellerSite).filter(ResellerSite.nas_id == nas_id).first()
    reseller_id = site.reseller_id if site else None

    query = db.query(Banner).filter(
        Banner.is_active == True,
        or_(Banner.valid_from == None, Banner.valid_from <= now),
        or_(Banner.valid_until == None, Banner.valid_until >= now),
        or_(
            Banner.type == "global",
            and_(Banner.type == "reseller", Banner.reseller_id == reseller_id)
        )
    ).order_by(Banner.valid_from.desc())

    return query.all()

Configuration

Variables d'environnement

bash
# Bannières
BANNER_DEFAULT_DURATION_SECONDS=8
BANNER_CACHE_TTL_SECONDS=3600
BANNER_MAX_SIZE_BYTES=5242880       # 5 MB
BANNER_ALLOWED_TYPES=image/jpeg,image/png
BANNER_STORAGE_PATH=/app/data/banners

# CDN (optionnel)
BANNER_CDN_BASE_URL=https://cdn.rgz.local/banners

CSS Transition (portal/css/main.css)

css
.banner-container {
    position: relative;
    width: 100%;
    height: 120px;
    overflow: hidden;
}

.banner-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: opacity 0.3s ease-in-out;
    cursor: pointer;
}

.banner-img.fade-out {
    opacity: 0;
}

.banner-img.fade-in {
    opacity: 1;
}

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
GET/api/v1/banners?nas_id={nas_id}200 {items, total}AucunePortail (public)
POST/api/v1/banners{type, image_url, link_url, reseller_id?}201Admin JWTCréer bannière
PUT/api/v1/banners/{id}{is_active, valid_until, ...}200Admin/Revendeur JWTModifier bannière
PUT/api/v1/banners/{id}/impression200AucuneIncrément compteur
POST/api/v1/banners/{id}/click200AucuneIncrément clicks
DELETE/api/v1/banners/{id}204Admin JWTSupprimer bannière

GET /api/v1/banners?nas_id=access_kossou

Response 200:

json
{
  "items": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440010",
      "type": "global",
      "reseller_id": null,
      "image_url": "https://cdn.rgz.local/banners/promo_access.jpg",
      "link_url": "https://access.rgz.bj/offres",
      "duration_seconds": 8,
      "is_active": true
    },
    {
      "id": "550e8400-e29b-41d4-a716-446655440011",
      "type": "reseller",
      "reseller_id": "660e8400-e29b-41d4-a716-446655440001",
      "image_url": "https://cdn.rgz.local/banners/kossou_promo.png",
      "link_url": null,
      "duration_seconds": 8,
      "is_active": true
    }
  ],
  "total": 2
}

POST /api/v1/banners (Admin)

Request:

json
{
  "type": "reseller",
  "reseller_id": "660e8400-e29b-41d4-a716-446655440001",
  "image_url": "https://cdn.rgz.local/banners/kossou_fete.png",
  "link_url": "https://www.facebook.com/kossouwifi",
  "duration_seconds": 8,
  "valid_from": "2026-03-01T00:00:00Z",
  "valid_until": "2026-03-31T23:59:59Z"
}

Response 201:

json
{
  "id": "770e8400-e29b-41d4-a716-446655440002",
  "type": "reseller",
  "is_active": true,
  "impressions_count": 0,
  "clicks_count": 0,
  "created_at": "2026-02-21T10:00:00Z"
}

PUT /api/v1/banners/{id}/impression

Response 200:

json
{
  "id": "550e8400-e29b-41d4-a716-446655440010",
  "impressions_count": 1247
}

Redis Keys

CléTypeTTLUsage
rgz:portal:config:{nas_id}Hash3600sConfig complète du hotspot (inclut liste bannières sérialisée)
rgz:banner:impressions:{id}String300sBuffer compteur impressions avant flush DB (batching)
rgz:banner:clicks:{id}String300sBuffer compteur clicks avant flush DB

Stratégie de cache : Les bannières sont incluses dans le hash rgz:portal:config:{nas_id} sous la clé banners_json. Lors d'une mise à jour (POST/PUT/DELETE bannière), invalider ce cache avec DEL rgz:portal:config:{nas_id} pour forcer le rechargement.


Sécurité

RègleImplémentation
SEC-01IDOR : un revendeur ne peut lire/modifier que ses propres bannières (type=reseller ET reseller_id == current_user.reseller_id)
SEC-12Upload image : vérification magic bytes (FFD8FF pour JPEG, 89504E47 pour PNG), Content-Type whitelist, taille max 5 MB
SEC-13Rendu bannière via <img src="..."> uniquement — jamais innerHTML avec contenu tiers. Attribut rel="noopener noreferrer" sur liens
SEC-11CORS : endpoint /api/v1/banners autorisé depuis domaine portail uniquement
python
# SEC-01 — Exemple vérification ownership
async def update_banner(
    banner_id: UUID,
    payload: BannerUpdate,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    banner = db.query(Banner).filter(Banner.id == banner_id).first()
    if not banner:
        raise HTTPException(404)

    # IDOR check
    if current_user.role == "reseller":
        if banner.reseller_id != current_user.reseller_id:
            raise HTTPException(403, detail="Not your banner")

    # ... update logic
javascript
// SEC-13 — Rendu sécurisé dans portal/js/banners.js
function displayBanner(banner) {
    const img = document.getElementById('banner-img');
    const link = document.getElementById('banner-link');

    // CORRECT: utiliser src, pas innerHTML
    img.src = banner.image_url;
    img.alt = "Publicité ACCESS";

    if (banner.link_url) {
        link.href = banner.link_url;
        link.rel = "noopener noreferrer";
        link.target = "_blank";
    } else {
        link.removeAttribute('href');
    }
}

Commandes Utiles

bash
# Lister toutes les bannières actives
curl -H "Authorization: Bearer $TOKEN" \
  https://api-rgz.duckdns.org/api/v1/banners?nas_id=access_kossou

# Créer une bannière globale (admin)
curl -X POST \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"global","image_url":"https://cdn.rgz.local/banners/promo.jpg"}' \
  https://api-rgz.duckdns.org/api/v1/banners

# Incrémenter impression (depuis portail)
curl -X PUT \
  https://api-rgz.duckdns.org/api/v1/banners/550e8400-e29b-41d4-a716-446655440010/impression

# Vérifier cache Redis
docker exec rgz-redis redis-cli GET "rgz:portal:config:access_kossou"

# Invalider cache après mise à jour bannière
docker exec rgz-redis redis-cli DEL "rgz:portal:config:access_kossou"

# Vérifier logs upload bannière
docker logs rgz-api --tail=50 | grep "banner"

Implémentation TODO

  • [ ] Modèle SQLAlchemy Banner avec CHECK constraint type IN ('global', 'reseller')
  • [ ] Migration Alembic table banners
  • [ ] Endpoint GET /api/v1/banners?nas_id= (public, pas de JWT)
  • [ ] Endpoint POST /api/v1/banners (admin uniquement)
  • [ ] Endpoint PUT /api/v1/banners/{id} (admin + revendeur owner)
  • [ ] Endpoint PUT /api/v1/banners/{id}/impression (fire-and-forget)
  • [ ] Endpoint POST /api/v1/banners/{id}/click
  • [ ] Endpoint DELETE /api/v1/banners/{id} (admin)
  • [ ] Upload image avec vérification magic bytes (SEC-12)
  • [ ] Intégration cache Redis rgz:portal:config:{nas_id} (invalider sur write)
  • [ ] JS portal/js/banners.js : rotation setInterval + préchargement
  • [ ] CSS transitions fade (300ms)
  • [ ] Bannière ACCESS par défaut si liste vide
  • [ ] Métriques impressions/clicks dans dashboard revendeur (#51)
  • [ ] Tests unitaires sélection bannières par NAS-ID
  • [ ] Tests SEC-01 (IDOR revendeur croisé)
  • [ ] Tests SEC-12 (upload fichier malveillant)

Dernière mise à jour: 2026-02-21

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