#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
# 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
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
# 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/bannersCSS Transition (portal/css/main.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éthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/banners?nas_id={nas_id} | — | 200 {items, total} | Aucune | Portail (public) |
| POST | /api/v1/banners | {type, image_url, link_url, reseller_id?} | 201 | Admin JWT | Créer bannière |
| PUT | /api/v1/banners/{id} | {is_active, valid_until, ...} | 200 | Admin/Revendeur JWT | Modifier bannière |
| PUT | /api/v1/banners/{id}/impression | — | 200 | Aucune | Incrément compteur |
| POST | /api/v1/banners/{id}/click | — | 200 | Aucune | Incrément clicks |
| DELETE | /api/v1/banners/{id} | — | 204 | Admin JWT | Supprimer bannière |
GET /api/v1/banners?nas_id=access_kossou
Response 200:
{
"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:
{
"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:
{
"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:
{
"id": "550e8400-e29b-41d4-a716-446655440010",
"impressions_count": 1247
}Redis Keys
| Clé | Type | TTL | Usage |
|---|---|---|---|
rgz:portal:config:{nas_id} | Hash | 3600s | Config complète du hotspot (inclut liste bannières sérialisée) |
rgz:banner:impressions:{id} | String | 300s | Buffer compteur impressions avant flush DB (batching) |
rgz:banner:clicks:{id} | String | 300s | Buffer 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ègle | Implémentation |
|---|---|
| SEC-01 | IDOR : un revendeur ne peut lire/modifier que ses propres bannières (type=reseller ET reseller_id == current_user.reseller_id) |
| SEC-12 | Upload image : vérification magic bytes (FFD8FF pour JPEG, 89504E47 pour PNG), Content-Type whitelist, taille max 5 MB |
| SEC-13 | Rendu bannière via <img src="..."> uniquement — jamais innerHTML avec contenu tiers. Attribut rel="noopener noreferrer" sur liens |
| SEC-11 | CORS : endpoint /api/v1/banners autorisé depuis domaine portail uniquement |
# 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// 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
# 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
Banneravec CHECK constrainttype 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