a
    ,×€jR  ã                   @   s  d Z ddlZddlZddlZddlZddlZddlZddlmZ ddlm	Z	 ddl
mZ e	dƒZed Zed Zed	 d
 ZdZdZddddde d¡ ddœZdd„ Zdd„ Zed ZdZdd„ Zd,dd„Zd Zd!Zd"d#„ Zd$d%„ Zd&d'„ Zd-d(d)„Zd*d„ Z e!d+k�re "e ƒ ¡ dS ).aÒ  Regenerate the dashboard and publish it to GitHub as index.html.

Runs on the hour and half hour, 06:00-23:00 (SoccerPublishDashboard). Nothing
here trades. The trigger is deliberately aligned to :00/:30 rather than an
arbitrary offset so a reader knows when to expect a fresh page -- note the RUN
starts then, and the published page lands 2-4 minutes later once the resolve
steps and the render have finished.

WHY THE SECRET SCAN IS A HARD GATE. When a human ran this by hand, someone
eyeballed the file before it went public. Unattended hourly publishing removes
that check, so it is replaced with an automated one that ABORTS the push rather
than warning: the API-Football key is hardcoded in at least five source files
and .env holds Kalshi/Poly-US/pinnapi credentials, so a future dashboard change
that happens to echo a config value would otherwise publish it silently. A
missed publish costs an hour of staleness; a leaked key costs a rotation and
lives forever in the git history.

TOKEN HANDLING. GITHUB_TOKEN is read from .env at run time and used only as a
one-shot push URL with credential.helper disabled, so it is never written into
the clone's git config or the OS credential store. It is redacted from all
output. GIT_TERMINAL_PROMPT=0 makes auth failures fail fast instead of blocking
on a prompt that cannot render in a scheduled task (that hang cost 5 minutes on
2026-08-09).

ENCRYPTED AT REST (2026-08-13). The Pages site and the repo behind it are both
world-readable, so the page is AES-256-GCM encrypted at publish time and
decrypted in the browser from a passphrase (DASH_PASSWORD in .env). See
encrypt_page for what that does and does not protect -- in short, it defends
against a passer-by reading the URL, not against a determined offline attacker
with a weak passphrase, and it cannot un-publish the snapshots already pushed
in the clear.

NO-OP WHEN UNCHANGED. If the regenerated dashboard's PLAINTEXT is identical to
what is already published, nothing is committed -- otherwise the repo would
collect 18 empty-diff commits a day. The comparison is on a plaintext hash
embedded in the published page, because a fresh salt and IV make the ciphertext
differ on every single run.

Usage:  python main\publish_dashboard.py [--dry]
é    N)Údatetime)ÚPath)ÚAESGCMz	E:\soccerzdashboard.htmlz.envz.publishzfootball-stuffz"github.com/lsmyrtaj/football-stuffÚmainz\b[0-9a-f]{32,}\bz7gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}z"-----BEGIN [A-Z ]*PRIVATE KEY-----z[\w.\-]+@[\w\-]+\.\w{2,}z	[A-Za-z]:ú\z>(?i)\b(secret|token|password|passwd|api[_-]?key)\b\s*[:=]\s*\S)zlong hex secretzgithub tokenzprivate keyzemail addresszwindows pathZ
assignmentc                  C   s˜   g } z|t jdd� ¡ D ]f}| ¡ }|r| d¡sd|vr:q| dd¡\}}| ¡  d¡ d¡}t|ƒdkr|  | ¡ |f¡ qW n ty’   Y n0 | S )	z»Every value in .env, so the scan blocks OUR actual credentials verbatim.

    Short values are skipped -- a 6-character setting would match half the page
    and block every publish.
    úutf-8©Úencodingú#ú=é   ú"ú'é   )	ÚENVÚ	read_textÚ
splitlinesÚstripÚ
startswithÚsplitÚlenÚappendÚOSError)ÚoutÚlnÚkÚv© r   ú#E:\soccer\main\publish_dashboard.pyÚliteral_secretsM   s    r   c                 C   sd   zLt jdd� ¡ D ]6}| ¡ }| | › d�¡r| dd¡d  ¡   W S qW n ty^   Y n0 d S )Nr   r   r   r   )r   r   r   r   r   r   r   )Úkeyr   r   r   r   Úenvb   s     r!   z.publish_err_last_emailr   c              
   C   s¸  zht jdd� dd¡}t|d ƒt|ƒdkr4|d nd }}ddl}|| krf| ¡ | td k rfW d	S W n ttt	fy€   Y n0 zìt
j dttd
 ƒ¡ ddl}ddlm} ddlm}m}	m}
 |rÊ|
sÐW d	S ||dƒ}d| › �|d< ||	 |d< |d< | dd¡�.}| ||
¡ | ||	| ¡ ¡ W d  ƒ n1 �s<0    Y  ddl}t j| ¡ › d| › �dd� W dS  t�y² } z*tdt|ƒj› d|› d�ƒ W Y d}~d	S d}~0 0 dS )a  Email that the published page has gone stale, at most every ALERT_EVERY_H.

    Rate limited by a marker file keyed on the subject: this runs every 30
    minutes, and an alert that arrives 48 times a day is one that gets filtered,
    which would leave us exactly where we started.
    r   r   Ú
r   r   Ú Ni  Fr   )ÚMIMEText)ÚEMAIL_ADDRESSÚEMAIL_RECIPIENTÚEMAIL_APP_PASSWORDÚhtmlz%[soccer] dashboard NOT publishing -- ZSubjectZFromZTozsmtp.gmail.comiÑ  Tz)  (could not send stale-dashboard alert: ú: ú))Ú
ALERT_MARKr   r   Úfloatr   ÚtimeÚALERT_EVERY_Hr   Ú
ValueErrorÚ
IndexErrorÚsysÚpathÚinsertÚstrÚROOTÚsmtplibZemail.mime.textr$   Údaily_signalsr%   r&   r'   ZSMTP_SSLZloginZsendmailZ	as_stringÚ
write_textÚ	ExceptionÚprintÚtypeÚ__name__)ZsubjectZ	body_htmlÚprevZlast_tsZlast_subZ_tr6   r$   r%   r&   r'   ÚmsgZsrvÚer   r   r   Úalert_staleq   s>    &

2ÿÿ
r@   Tc           	      C   s’   g }t  ¡ D ]P\}}t || ¡}|rt|d tƒr:|d n
t|d ƒ}| ||dd… f¡ q|rŽtƒ D ]$\}}|| v rh| d|› �df¡ qh|S )zBReturn list of (label, sample) hits. Empty list = safe to publish.r   Né   z.env value ú
<redacted>)Ú	FORBIDDENÚitemsÚreÚfindallÚ
isinstancer4   r   r   )	ÚtextZinclude_envÚhitsÚlabelZpatÚmÚsÚnameÚvalr   r   r   Úscan—   s    "rO   iðº a   <!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard</title>
<!-- plain-sha256-b64: __PLAINHASH__ -->
<style>
 :root{color-scheme:light dark}
 body{margin:0;min-height:100vh;display:grid;place-items:center;
      font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
      background:#fbfbfc;color:#16181d}
 @media (prefers-color-scheme:dark){body{background:#0f1115;color:#e7e9ee}}
 form{width:min(92vw,320px);display:flex;flex-direction:column;gap:10px}
 h1{font-size:15px;font-weight:600;margin:0 0 2px;letter-spacing:.01em}
 input,button{font:inherit;padding:10px 12px;border-radius:8px;
      border:1px solid #c7ccd6;background:#fff;color:inherit}
 @media (prefers-color-scheme:dark){input,button{background:#181b21;border-color:#333a46}}
 button{cursor:pointer;font-weight:600;border-color:transparent;background:#2f6feb;color:#fff}
 button:disabled{opacity:.6;cursor:progress}
 #e{min-height:1.2em;font-size:13px;color:#b0304a}
 .h{opacity:.6;font-size:12.5px}
</style>
</head><body>
<form id="f" autocomplete="on">
 <h1>Soccer dashboard</h1>
 <input id="p" type="password" autocomplete="current-password" placeholder="Passphrase" autofocus>
 <button id="b" type="submit">Unlock</button>
 <div id="e"></div>
 <div class="h">Decrypted in your browser. Nothing is sent anywhere.</div>
</form>
<script>
var SALT="__SALT__",IV="__IV__",CT="__CT__",ITERS=__ITERS__;
function b64(s){return Uint8Array.from(atob(s),function(c){return c.charCodeAt(0)})}
function unlock(pw){
  return crypto.subtle.importKey("raw",new TextEncoder().encode(pw),"PBKDF2",false,["deriveKey"])
   .then(function(km){return crypto.subtle.deriveKey(
      {name:"PBKDF2",salt:b64(SALT),iterations:ITERS,hash:"SHA-256"},
      km,{name:"AES-GCM",length:256},false,["decrypt"])})
   .then(function(key){return crypto.subtle.decrypt({name:"AES-GCM",iv:b64(IV)},key,b64(CT))})
   .then(function(pt){return new TextDecoder().decode(pt)});
}
function show(html){document.open();document.write(html);document.close()}
document.getElementById("f").addEventListener("submit",function(ev){
  ev.preventDefault();
  var pw=document.getElementById("p").value,e=document.getElementById("e"),b=document.getElementById("b");
  e.textContent="";b.disabled=true;b.textContent="Decrypting\u2026";
  unlock(pw).then(function(html){
    try{sessionStorage.setItem("dashpw",pw)}catch(_){}
    show(html);
  }).catch(function(){
    e.textContent="Wrong passphrase.";b.disabled=false;b.textContent="Unlock";
  });
});
// Re-unlock silently within the same tab so an hourly refresh does not re-prompt.
// sessionStorage dies with the tab; it is never written to disk by us.
(function(){
  var s=null;try{s=sessionStorage.getItem("dashpw")}catch(_){}
  if(!s)return;
  unlock(s).then(show).catch(function(){try{sessionStorage.removeItem("dashpw")}catch(_){}});
})();
// SELF-REFRESH. The page republishes every 30 minutes, but a tab left open --
// which on a phone is every tab -- shows whatever it decrypted whenever it was
// opened, forever. That is indistinguishable from "the dashboard stopped
// updating", and it is the thing that actually gets noticed.
//
// GitHub Pages serves this with Cache-Control: max-age=600 and lets us set no
// headers of our own, so a plain reload can be answered from cache. The
// cache-busting query param is what forces a real refetch. Reloading is safe
// because the passphrase is in sessionStorage: the tab re-unlocks silently.
// Skipped when the page is hidden, so a backgrounded tab does not refetch all
// night, and re-checked on wake, which is the moment a stale page is looked at.
(function(){
  var PERIOD=15*60*1000, last=Date.now();
  function refresh(){
    var u=location.pathname+"?t="+Date.now();
    location.replace(u);
  }
  function tick(){
    if(document.hidden)return;
    if(Date.now()-last>=PERIOD)refresh();
  }
  setInterval(tick,60*1000);
  document.addEventListener("visibilitychange",function(){
    if(!document.hidden)tick();
  });
})();
</script>
</body></html>
c                 C   sŒ   t  d¡t  d¡ }}tjd| d¡|tdd�}t|ƒ ||  d¡d¡}dd	„ }t 	d
|¡ 	d||ƒ¡ 	d||ƒ¡ 	d||ƒ¡ 	dt
tƒ¡S )a!  Wrap `html` as an AES-256-GCM payload the browser decrypts on unlock.

    Salt and IV are fresh per publish, so the ciphertext differs every run even
    when the content does not -- which is exactly why the no-op check compares
    the PLAINTEXT hash instead of the published bytes.
    é   r   Úsha256r   é    )ZdklenNc                 S   s   t  | ¡ d¡S )NÚascii)Úbase64Ú	b64encodeÚdecode)Úrawr   r   r   Ú<lambda>   ó    zencrypt_page.<locals>.<lambda>Z__PLAINHASH__Z__SALT__Z__IV__Z__CT__Z	__ITERS__)ÚosÚurandomÚhashlibZpbkdf2_hmacÚencodeÚPBKDF2_ITERSr   ZencryptÚ
_LOCK_PAGEÚreplacer4   )r(   ÚpasswordÚ
plain_hashZsaltZivr    ZctZb64r   r   r   Úencrypt_page  s"    ÿÿþýüûrc   c                 C   s    t  t |  d¡¡ ¡ ¡ d¡S )a~  Fingerprint of the plaintext, embedded in the published page to give the
    no-op check something stable to compare against.

    BASE64, NOT HEX, deliberately. A 64-char hex digest matches the scan's
    'long hex secret' pattern, so a hex marker makes the published page look
    like it carries a leaked key the moment anyone points the scan at the
    payload rather than the plaintext -- which is a reasonable-looking
    hardening that would then abort every publish forever with a baffling
    error. Standard base64, not base64url, so the alphabet has no '-' and the
    marker can never accidentally close the HTML comment.
    r   rS   )rT   rU   r\   rQ   r]   ZdigestrV   )r(   r   r   r   Úplain_marker)  s
    ÿÿrd   c                 C   s2   |   ¡ sdS t d| jdd�¡}|r.| d¡S dS )zEMarker of the plaintext behind the currently published page, or None.Nz/<!-- plain-sha256-b64: ([A-Za-z0-9+/]{43}=) -->r   r   r   )ÚexistsrE   Úsearchr   Úgroup)ÚtargetrK   r   r   r   Úpublished_plain_hash9  s    
ÿri   c                 C   sZ   t j| |ddi tdƒj¥ddi¥dd�}|jp0d|jp8d }|rL| |d¡}|j| ¡ fS )	NTrZ   ZGIT_TERMINAL_PROMPTÚ0é´   )ÚcwdZcapture_outputrH   r!   Ztimeoutr#   rB   )	Ú
subprocessÚrunÚ
__import__ÚenvironÚstdoutÚstderrr`   Ú
returncoder   )Úargsrl   ÚtokenÚpr   r   r   r   rn   B  s    þrn   c               
   C   s<  dt jv } t ¡  d¡}z,t j dttd ƒ¡ ddl	m
} |ƒ  W nD tyˆ } z,td|› dt|ƒd d… › �ƒ W Y d }~n
d }~0 0 tt jttd d	 ƒgƒ\}}|dkrÌtd|› d
|d d… › �ƒ tt jttd d ƒdgƒ\}}|dk�r<td|› d|› d|d d… › �ƒ tdd|› d|d d… › d�ƒ dS t ¡ �sdtd|› d�ƒ tddƒ dS tjdd�}t|ƒ}|�rætd|› d�ƒ |D ]\}}	td|› d|	›�ƒ �q’td|d d › �dd d d!„ |D ƒ¡ d" ƒ d#S td$ƒ}
|
�std|› d%�ƒ td&d'ƒ dS t|ƒ}t||
|ƒ}td(ƒ}|�sHtd|› d)�ƒ dS d*|› d+t› �}td,  ¡ �sÄtjjd-d-d.� td/d0d1t› �ttƒg|d2�\}}|dk�rötd|› d3|d d… › �ƒ dS n2td/d4d5tgt|d6� td/d7d8d9t› �gt|d6� td: }t|ƒ|k�r td|› d;�ƒ dS |j|dd� | �r\td|› d<t|ƒ› d=t|ƒ› d>�ƒ dS tg d?¢td@� td/dAdBdAdCdDdEdF|› �gt|d6�\}}|dk�rÈdG|v�rÈtd|› dH|d d… › �ƒ dS td/dAdIdJ|tgt|d6�\}}|dk�rtd|› dK|d d… › �ƒ dS td|› dLt|ƒ› dMt|ƒ› dNt› �ƒ dS )ONz--dryz%Y-%m-%d %H:%Mr   r   )Úresolve_pending_betsú[z$] resolve step failed (continuing): éÈ   zresolve_live_data.pyz)] resolve_live_data failed (continuing): zdashboard.pyz--quietz] dashboard.py failed rc=r)   i,  zdashboard.py failedz*The dashboard build itself failed with rc=z+. The published page is stale.<br><br><pre>iÜ  z</pre>r   z] no dashboard.html producedzno dashboard producedz8dashboard.py exited cleanly but wrote no dashboard.html.r   r   z=] ABORT: dashboard contains material that must not be public:z    zpublish blocked: zkThe dashboard was rebuilt but NOT published, because it contains something that must not go public:<br><br>z<br>c                 s   s"   | ]\}}d |› d|›�V  qdS )z<code>z	</code>: Nr   )Ú.0rJ   Úsampler   r   r   Ú	<genexpr>Š  s   ÿzmain.<locals>.<genexpr>z<br><br>The published page is now stale. Fix the source of that content in dashboard.py and the next run will publish normally.é   ZDASH_PASSWORDz>] no DASH_PASSWORD in .env -- refusing to publish in the clearzno DASH_PASSWORDz“DASH_PASSWORD is missing from .env, so publishing was refused rather than push the page unencrypted. The published page is stale until that is set.ZGITHUB_TOKENz+] no GITHUB_TOKEN in .env -- cannot publishzhttps://x-access-token:ú@z.gitT)ÚparentsÚexist_okÚgitZclonezhttps://)ru   z] clone failed: ZfetchÚorigin)rl   ru   Úresetz--hardzorigin/ú
index.htmlz!] unchanged -- nothing to publishz] DRY RUN -- would publish z bytes (z plaintext, encrypted))r�   Úaddr„   )rl   z-czuser.name=lirsezuser.email=lirsenm@gmail.comZcommitz-mzDashboard snapshot znothing to commitz] commit failed: zcredential.helper=Úpushz] push failed: z] published z bytes encrypted (z plaintext) -> ) r1   Úargvr   ZnowÚstrftimer2   r3   r4   r5   r7   rw   r9   r:   rn   Ú
executabler@   ÚDASHre   r   rO   Újoinr!   rd   rc   ÚREPOÚCLONEÚparentÚmkdirÚBRANCHri   r8   r   )ZdryZstamprw   r?   Zrcr   r(   rI   rJ   r{   ra   rb   Zpayloadru   Zurlrh   r   r   r   r   L  s¾    

6
 
"
ÿÿ
ÿ
ÿþüÿÿ"
ÿ
ÿÿ
ÿ

ÿÿÚ__main__)T)NN)#Ú__doc__rT   r\   rZ   rE   rm   r1   r   Úpathlibr   Z+cryptography.hazmat.primitives.ciphers.aeadr   r5   rŠ   r   r�   rŒ   r�   ÚescaperC   r   r!   r+   r.   r@   rO   r^   r_   rc   rd   ri   rn   r   r<   Úexitr   r   r   r   Ú<module>   sL   (ú
&
![	

 
