from __future__ import annotations import base64, hashlib, hmac, json, logging, re, time, unicodedata, uuid from datetime import datetime, timezone from pathlib import Path from urllib.parse import parse_qs, urlparse from xml.sax.saxutils import escape from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Request from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response from jinja2 import Environment, FileSystemLoader, select_autoescape from markdown_it import MarkdownIt from markupsafe import Markup from app.core.config import get_settings from app.football.editorial import db, run_editorial_cycle log=logging.getLogger(__name__) router=APIRouter() def _run_cycle_safely(trigger:str): try:run_editorial_cycle(trigger) except Exception as exc:log.error("Editorial background cycle failed (%s)",type(exc).__name__) BASE=Path(__file__).parent templates=Environment(loader=FileSystemLoader(str(BASE/"templates")),autoescape=select_autoescape(["html","xml"])) markdown=MarkdownIt("commonmark",{"html":False,"linkify":False,"typographer":True}) def render(name:str,**context)->HTMLResponse: s=get_settings() context.setdefault("site_name",s.site_name) context.setdefault("site_url",s.site_url.rstrip("/")) context.setdefault("site_description",s.site_description) context.setdefault("current_year",datetime.now(timezone.utc).year) return HTMLResponse(templates.get_template(name).render(**context)) def _session_token()->str: key=get_settings().app_api_key timestamp=str(int(time.time())) signature=hmac.new(key.encode(),timestamp.encode(),hashlib.sha256).hexdigest() return base64.urlsafe_b64encode(f"{timestamp}.{signature}".encode()).decode().rstrip("=") def _valid_session(request:Request)->bool: key=get_settings().app_api_key token=request.cookies.get("lv_admin") if not token or not key:return False try: raw=base64.urlsafe_b64decode(token+"="*(-len(token)%4)).decode() timestamp,signature=raw.split(".",1) if abs(int(time.time())-int(timestamp))>8*60*60:return False expected=hmac.new(key.encode(),timestamp.encode(),hashlib.sha256).hexdigest() return hmac.compare_digest(signature,expected) except Exception:return False def _admin_allowed(request:Request)->bool: key=get_settings().app_api_key supplied=request.headers.get("x-admin-key","") return _valid_session(request) or bool(key and supplied and hmac.compare_digest(key,supplied)) def _check_origin(request:Request): origin=request.headers.get("origin") if origin: parsed=urlparse(origin) host=request.headers.get("host","") if parsed.scheme!="https" or parsed.netloc!=host: raise HTTPException(403,"Invalid origin") def _slugify(value:str)->str: normalized=unicodedata.normalize("NFKD",value).encode("ascii","ignore").decode("ascii") return re.sub(r"[^a-z0-9]+","-",normalized.lower()).strip("-") def _article_rows(status:str="published",limit:int=30,category:str|None=None,query:str|None=None): sql="""select id,title,slug,dek,category,tags,author_name,reading_time_minutes,published_at,created_at, (image_data is not null) as has_image,confidence from public.articles where status=%s""" args=[status] if category:sql+=" and category=%s";args.append(category) if query: sql+=" and (title ilike %s or dek ilike %s or body_markdown ilike %s)" pattern="%"+query[:100]+"%";args.extend([pattern,pattern,pattern]) sql+=" order by coalesce(published_at,created_at) desc limit %s";args.append(limit) with db() as conn: with conn.cursor() as cur: cur.execute(sql,args); cols=[d.name for d in cur.description] return [dict(zip(cols,row)) for row in cur.fetchall()] def _article_sources(article_id): with db() as conn: with conn.cursor() as cur: cur.execute("select source_name,source_title,url from public.sources where article_id=%s order by source_name",(article_id,)) return [{"name":r[0],"title":r[1],"url":r[2]} for r in cur.fetchall()] @router.head("/",include_in_schema=False) def home_head():return Response(status_code=200) @router.get("/",response_class=HTMLResponse,include_in_schema=False) def home(): try:articles=_article_rows(limit=25) except Exception: log.exception("Homepage database query failed");articles=[] schema={"@context":"https://schema.org","@type":"WebSite","name":get_settings().site_name,"url":get_settings().site_url,"potentialAction":{"@type":"SearchAction","target":get_settings().site_url.rstrip("/")+"/search?q={search_term_string}","query-input":"required name=search_term_string"}} return render("home.html",title="Le football, au-delà du score",description=get_settings().site_description, canonical=get_settings().site_url.rstrip("/")+"/",jsonld=Markup(json.dumps(schema,ensure_ascii=False)), hero=articles[0] if articles else None,secondary=articles[1:4],latest=articles[4:],active_nav="Accueil") @router.get("/a-propos",response_class=HTMLResponse,include_in_schema=False) def about_page(): return render("page.html",title="À propos",description="La ligne éditoriale du Vestiaire : football, analyse et contexte.",canonical=get_settings().site_url.rstrip("/")+"/a-propos",kicker="Le média",page_title="Le football, avec du recul.",lead="Le Vestiaire raconte le jeu, ses acteurs et les idées qui le font évoluer.",sections=[ {"heading":"Notre regard","paragraphs":["Nous couvrons le football professionnel et amateur avec une priorité : donner du contexte aux résultats, aux décisions et aux tendances de jeu.","Nous privilégions les faits vérifiables, les sources identifiables et l’analyse plutôt que le bruit ou les réactions à chaud."]}, {"heading":"Nos sujets","paragraphs":["Compétitions françaises et européennes, football féminin, mercato, tactique, économie du sport et culture football."]}, {"heading":"Notre méthode","paragraphs":["Les contenus sont préparés avec une assistance IA, puis soumis à des contrôles de sources et de cohérence avant publication. Les articles publiés restent liés à leurs références."]} ],active_nav="À propos") @router.get("/charte-editoriale",response_class=HTMLResponse,include_in_schema=False) def editorial_charter(): return render("page.html",title="Charte éditoriale",description="Sources, vérification, intelligence artificielle et corrections.",canonical=get_settings().site_url.rstrip("/")+"/charte-editoriale",kicker="Nos engagements",page_title="Une règle simple : les faits d’abord.",lead="La vitesse ne doit jamais prendre le pas sur l’exactitude.",sections=[ {"heading":"Sources et vérification","paragraphs":["Nous recherchons des sources primaires lorsque cela est possible et recoupons les informations avec plusieurs domaines indépendants.","Les rumeurs sont attribuées explicitement et ne sont jamais présentées comme des faits confirmés. Les scores, dates, statistiques et citations doivent être vérifiés."]}, {"heading":"Intelligence artificielle","paragraphs":["L’IA assiste la recherche, la structuration et la rédaction. Elle ne constitue pas une source. Les affirmations publiées doivent être rattachées à des références consultables.","La publication automatique reste désactivée : les nouveaux articles sont conservés en brouillon jusqu’à validation éditoriale."]}, {"heading":"Originalité et droits","paragraphs":["Nous produisons une synthèse originale et limitons les citations au strict nécessaire. Les images de couverture sont générées pour illustrer les articles et ne prétendent pas représenter une photographie documentaire."]}, {"heading":"Corrections","paragraphs":["Lorsqu’une erreur est identifiée, elle doit être corrigée rapidement. Les corrections substantielles sont signalées dans l’article concerné."]} ],active_nav="Charte éditoriale") @router.get("/corrections",response_class=HTMLResponse,include_in_schema=False) def corrections_page(): email=get_settings().publisher_email return render("page.html",title="Corrections",description="Signaler une erreur ou demander une correction au Vestiaire.",canonical=get_settings().site_url.rstrip("/")+"/corrections",kicker="Transparence",page_title="Une erreur ? Signalons-la.",lead="La fiabilité d’un média se mesure aussi à sa capacité à corriger.",sections=[ {"heading":"Que signaler ?","paragraphs":["Une erreur de score, de date, de nom, de statistique, une attribution incorrecte ou une source devenue indisponible. Indiquez l’URL de l’article et le passage concerné."]}, {"heading":"Contacter la rédaction","paragraphs":[f"Adresse de contact : {email}" if email else "L’adresse de contact éditoriale doit encore être configurée par l’éditeur."]} ],active_nav="Corrections") @router.get("/confidentialite",response_class=HTMLResponse,include_in_schema=False) def privacy_page(): email=get_settings().publisher_email return render("page.html",title="Confidentialité",description="Données techniques et cookies utilisés par Le Vestiaire.",canonical=get_settings().site_url.rstrip("/")+"/confidentialite",kicker="Données personnelles",page_title="La sobriété comme principe.",lead="Le Vestiaire ne propose pas de compte public, de commentaires ni de formulaire visiteur.",sections=[ {"heading":"Données et cookies","paragraphs":["Aucun compte lecteur ni profilage publicitaire. Une mesure d’audience interne facultative est proposée avec votre consentement : elle enregistre les pages consultées et un identifiant de session aléatoire pour comprendre les parcours. Elle n’enregistre ni adresse IP, ni nom, ni adresse e-mail, ni empreinte de navigateur. Vous pouvez refuser sans perdre l’accès au site.","L’hébergeur peut traiter des journaux techniques nécessaires à la sécurité et au fonctionnement du service. Vous pouvez retirer votre accord de mesure d’audience en supprimant le cookie lv_analytics dans les réglages de votre navigateur."]}, {"heading":"Utilisation de l’IA","paragraphs":["Les recherches éditoriales portent sur des informations publiques. Les visiteurs ne transmettent pas de données personnelles à l’API OpenAI via le site."]}, {"heading":"Exercer ses droits","paragraphs":[f"Contact : {email}" if email else "L’adresse de contact pour les demandes relatives aux données personnelles doit être renseignée avant ouverture commerciale."]} ],active_nav="Confidentialité") @router.get("/mentions-legales",response_class=HTMLResponse,include_in_schema=False) def legal_page(): s=get_settings() complete=bool(s.publisher_name and s.publisher_address and s.publisher_email) return render("page.html",title="Mentions légales",description="Informations légales relatives au site Le Vestiaire.",canonical=s.site_url.rstrip("/")+"/mentions-legales",kicker="Informations légales",page_title="Mentions légales",lead="Informations relatives à l’édition et à l’hébergement du site.",sections=[ {"heading":"Éditeur du site","paragraphs":[f"Éditeur : {s.publisher_name}" if s.publisher_name else "Éditeur : informations à renseigner.",f"Adresse : {s.publisher_address}" if s.publisher_address else "Adresse : informations à renseigner.",f"Contact : {s.publisher_email}" if s.publisher_email else "Contact : informations à renseigner."]}, {"heading":"Hébergement","paragraphs":["Service hébergé sur Render, Inc. via la plateforme Render."]}, {"heading":"Contenus","paragraphs":["Les articles sont produits avec assistance IA et contrôle éditorial. Les sources sont référencées dans les articles. Les droits sur les marques, noms et compétitions cités appartiennent à leurs titulaires respectifs."]} ],notice=None if complete else "Configuration incomplète : les coordonnées de l’éditeur doivent être renseignées avant toute monétisation ou exploitation commerciale.",active_nav="Mentions légales") @router.get("/category/{category}",response_class=HTMLResponse,include_in_schema=False) def category_page(category:str): allowed={"Ligue 1","Europe","International","Mercato","Tactique","Économie","Féminines","Culture foot"} normalized=next((x for x in allowed if _slugify(x)==category.lower()),None) if not normalized:raise HTTPException(404,"Rubrique introuvable") rows=_article_rows(limit=40,category=normalized) return render("listing.html",title=normalized,description=f"Analyses et actualités football — {normalized}.",canonical=get_settings().site_url.rstrip("/")+"/category/"+_slugify(normalized),articles=rows,active_nav=normalized) @router.get("/search",response_class=HTMLResponse,include_in_schema=False) def search(q:str=Query(default="",max_length=100)): term=q.strip();rows=_article_rows(limit=40,query=term) if len(term)>=2 else [] return render("listing.html",title="Recherche",description="Rechercher dans les articles du Vestiaire.",canonical=get_settings().site_url.rstrip("/")+"/search",robots="noindex,follow",articles=rows,active_nav="Recherche",search_term=term) @router.get("/article/{slug}",response_class=HTMLResponse,include_in_schema=False) def article(slug:str): with db() as conn: with conn.cursor() as cur: cur.execute("""select id,title,slug,dek,body_markdown,category,tags,author_name,reading_time_minutes,published_at,seo_description,alt_text,(image_data is not null) as has_image from public.articles where slug=%s and status='published'""",(slug,)) row=cur.fetchone() if not row:raise HTTPException(404,"Article introuvable") data=dict(zip([d.name for d in cur.description],row)) data["body_html"]=Markup(markdown.render(data["body_markdown"])) data["sources"]=_article_sources(data["id"]) related=[x for x in _article_rows(limit=4,category=data["category"]) if x["slug"]!=slug][:3] canonical=get_settings().site_url.rstrip("/")+"/article/"+slug schema={"@context":"https://schema.org","@type":"NewsArticle","headline":data["title"], "description":data["seo_description"] or data["dek"], "datePublished":data["published_at"].isoformat() if data["published_at"] else None, "author":{"@type":"Organization","name":get_settings().site_name}, "publisher":{"@type":"Organization","name":get_settings().site_name},"mainEntityOfPage":canonical} return render("article.html",article=data,title=data["title"],description=data["seo_description"] or data["dek"], canonical=canonical,related=related,jsonld=Markup(json.dumps(schema,ensure_ascii=False))) @router.get("/media/{article_id}",include_in_schema=False) def media(article_id:str): try:article_uuid=uuid.UUID(article_id) except ValueError:raise HTTPException(404,"Image introuvable") with db() as conn: with conn.cursor() as cur: cur.execute("select image_data,image_mime_type from public.articles where id=%s and status='published'",(article_uuid,)) row=cur.fetchone() if not row or not row[0]:raise HTTPException(404,"Image introuvable") return Response(content=bytes(row[0]),media_type=row[1] or "image/webp",headers={"Cache-Control":"public, max-age=86400","X-Content-Type-Options":"nosniff"}) @router.get("/sitemap.xml",include_in_schema=False) def sitemap(): base=get_settings().site_url.rstrip("/") with db() as conn: with conn.cursor() as cur: cur.execute("select slug,published_at from public.articles where status='published' order by published_at desc");rows=cur.fetchall() entries=[f"{escape(base)}/"] static_paths=["/a-propos","/charte-editoriale","/corrections","/confidentialite","/mentions-legales"] for path in static_paths:entries.append(f"{escape(base)}{path}") with db() as conn: with conn.cursor() as cur: cur.execute("select distinct category,max(published_at) from public.articles where status='published' group by category") categories=cur.fetchall() for category,published in categories: path="/category/"+_slugify(category) last=f"{published.astimezone(timezone.utc).isoformat()}" if published else "" entries.append(f"{escape(base)}{path}{last}") for slug,published in rows: last=f"{published.astimezone(timezone.utc).isoformat()}" if published else "" entries.append(f"{escape(base)}/article/{escape(slug)}{last}") return Response(''+''.join(entries)+"",media_type="application/xml") @router.get("/feed.xml",include_in_schema=False) def feed(): base=get_settings().site_url.rstrip("/");rows=_article_rows(limit=30);items=[] for row in rows: link=base+"/article/"+row["slug"] pub=row["published_at"].strftime("%a, %d %b %Y %H:%M:%S +0000") if row["published_at"] else "" items.append(f"{escape(row['title'])}{escape(link)}{escape(link)}{escape(row['dek'] or '')}{escape(pub)}") xml=''+escape(get_settings().site_name)+''+escape(base)+''+escape(get_settings().site_description)+''+''.join(items)+'' return Response(xml,media_type="application/rss+xml") @router.get("/robots.txt",response_class=PlainTextResponse,include_in_schema=False) def robots(): base=get_settings().site_url.rstrip("/") return PlainTextResponse(f"User-agent: *\nAllow: /\nDisallow: /admin\nDisallow: /internal\nSitemap: {base}/sitemap.xml\n") @router.get("/admin/login",response_class=HTMLResponse,include_in_schema=False) def admin_login(request:Request): if _valid_session(request):return RedirectResponse("/admin",status_code=303) return render("login.html",title="Accès rédaction",description="Espace privé de la rédaction.") @router.post("/admin/login",include_in_schema=False) async def admin_login_post(request:Request): _check_origin(request);s=get_settings() if not s.app_api_key:raise HTTPException(503,"APP_API_KEY non configurée") form=parse_qs((await request.body()).decode("utf-8","replace"));password=(form.get("password") or [""])[0] if not hmac.compare_digest(password,s.app_api_key): return render("login.html",title="Accès rédaction",description="Identifiants invalides.",error="Clé administrateur incorrecte.") response=RedirectResponse("/admin",status_code=303) response.set_cookie("lv_admin",_session_token(),httponly=True,secure=(s.app_env=="production"),samesite="strict",max_age=8*60*60,path="/admin") return response @router.post("/admin/logout",include_in_schema=False) def admin_logout(request:Request): _check_origin(request);response=RedirectResponse("/admin/login",status_code=303);response.delete_cookie("lv_admin",path="/admin");return response @router.get("/admin",response_class=HTMLResponse,include_in_schema=False) def admin_dashboard(request:Request): if not _valid_session(request):return RedirectResponse("/admin/login",status_code=303) with db() as conn: with conn.cursor() as cur: cur.execute("select status,count(*) from public.articles group by status");counts={r[0]:r[1] for r in cur.fetchall()} cur.execute("select coalesce(sum(estimated_cost_usd),0) from public.ai_usage_events where created_at>=date_trunc('month',now())");month_cost=float(cur.fetchone()[0] or 0) cur.execute("select id,title,slug,category,confidence,review_reason,created_at,(image_data is not null) from public.articles where status='draft' order by created_at desc limit 50") drafts=[{"id":str(r[0]),"title":r[1],"slug":r[2],"category":r[3],"confidence":float(r[4]),"review_reason":r[5],"created_at":r[6],"has_image":r[7]} for r in cur.fetchall()] cur.execute("select status,started_at,finished_at,collected_count,draft_count,published_count,error_summary from public.automation_runs order by started_at desc limit 8") runs=[{"status":r[0],"started_at":r[1],"finished_at":r[2],"collected":r[3],"drafts":r[4],"published":r[5],"errors":r[6]} for r in cur.fetchall()] return render("admin.html",title="Commandement éditorial",description="Pilotage du média.",drafts=drafts,runs=runs, published_count=counts.get("published",0),draft_count=counts.get("draft",0),rejected_count=counts.get("rejected",0), month_cost=month_cost,auto_publish=get_settings().auto_publish_enabled,daily_budget=get_settings().openai_daily_budget_usd) @router.get("/admin/drafts/{slug}",response_class=HTMLResponse,include_in_schema=False) def draft_preview(slug:str,request:Request): if not _valid_session(request):return RedirectResponse("/admin/login",status_code=303) with db() as conn: with conn.cursor() as cur: cur.execute("""select id,title,slug,dek,body_markdown,category,tags,author_name,reading_time_minutes,created_at,seo_description,alt_text,(image_data is not null) as has_image,confidence,review_reason from public.articles where slug=%s and status='draft'""",(slug,)) row=cur.fetchone() if not row:raise HTTPException(404,"Brouillon introuvable") article_data=dict(zip([d.name for d in cur.description],row)) article_data["body_html"]=Markup(markdown.render(article_data["body_markdown"])) article_data["sources"]=_article_sources(article_data["id"]) return render("draft.html",title=article_data["title"],description=article_data["dek"],article=article_data) @router.get("/admin/media/{article_id}",include_in_schema=False) def admin_media(article_id:str,request:Request): if not _valid_session(request):raise HTTPException(401,"Unauthorized") try:article_uuid=uuid.UUID(article_id) except ValueError:raise HTTPException(404,"Image introuvable") with db() as conn: with conn.cursor() as cur: cur.execute("select image_data,image_mime_type from public.articles where id=%s and status='draft'",(article_uuid,)) row=cur.fetchone() if not row or not row[0]:raise HTTPException(404,"Image introuvable") return Response(content=bytes(row[0]),media_type=row[1] or "image/webp",headers={"Cache-Control":"private, max-age=300","X-Content-Type-Options":"nosniff"}) @router.post("/admin/editorial/cycle",include_in_schema=False) def admin_cycle(request:Request,background_tasks:BackgroundTasks): if not _admin_allowed(request):raise HTTPException(401,"Unauthorized") _check_origin(request) background_tasks.add_task(_run_cycle_safely,"manual") return RedirectResponse("/admin?run=queued",status_code=303) @router.post("/internal/cron/editorial",include_in_schema=False) def scheduled_cycle(request:Request,background_tasks:BackgroundTasks): expected=get_settings().editorial_cron_token;supplied=request.headers.get("x-cron-token","") if not expected or not supplied or not hmac.compare_digest(expected,supplied):raise HTTPException(401,"Unauthorized") background_tasks.add_task(_run_cycle_safely,"scheduled") return JSONResponse({"status":"accepted","message":"Editorial cycle queued"},status_code=202) @router.post("/admin/articles/{article_id}/publish",include_in_schema=False) def publish_article(article_id:str,request:Request): if not _admin_allowed(request):raise HTTPException(401,"Unauthorized") _check_origin(request) try:article_uuid=uuid.UUID(article_id) except ValueError:raise HTTPException(404,"Article introuvable") with db() as conn: with conn.cursor() as cur: cur.execute("update public.articles set status='published',published_at=now(),updated_at=now() where id=%s and status='draft' returning id",(article_uuid,)) if not cur.fetchone():raise HTTPException(409,"Article introuvable ou déjà traité") conn.commit() return RedirectResponse("/admin",status_code=303) @router.post("/admin/articles/{article_id}/reject",include_in_schema=False) def reject_article(article_id:str,request:Request): if not _admin_allowed(request):raise HTTPException(401,"Unauthorized") _check_origin(request) try:article_uuid=uuid.UUID(article_id) except ValueError:raise HTTPException(404,"Article introuvable") with db() as conn: with conn.cursor() as cur: cur.execute("update public.articles set status='rejected',updated_at=now() where id=%s and status='draft' returning id",(article_uuid,)) if not cur.fetchone():raise HTTPException(409,"Article introuvable ou déjà traité") conn.commit() return RedirectResponse("/admin",status_code=303)