RC-8-A
All checks were successful
release-tag / release-image (push) Successful in 4m42s

This commit is contained in:
2026-08-11 19:20:02 +02:00
parent ba3818ef30
commit ce63ca6851
6 changed files with 71 additions and 8 deletions

16
V4.2.3_SESSION_UI_FIX.md Normal file
View File

@@ -0,0 +1,16 @@
# V4.2.3 Customer session/UI diagnostics
This maintenance release fixes two confusing customer-portal behaviours:
- A failure of a downstream API such as `/api/tasks` no longer makes the UI look logged out. `/api/me` is now the authority for the customer session; other portal sections load independently and display a concrete availability error.
- Customer session cookies now carry both `Max-Age` and `Expires`. The public customer cookie uses `SameSite=Lax` so external payment-provider return navigations remain compatible while cross-site POST/fetch cookie use stays blocked. The private Customer Admin cookie remains `SameSite=Strict`.
- Hosted-code pairing failures are written to the Customer Service log with the customer id and concrete internal error.
- The bundled customer UI declares an empty favicon so `/favicon.ico` no longer produces distracting 404 console noise.
For HTTPS production deployments set:
```env
CS_COOKIE_SECURE=true
```
If F5 still shows the login after this release, inspect `GET /api/me` in DevTools. A 401 there means the session cookie/database really is invalid; a 200 means the session is valid and any remaining portal problem is a downstream API error, which V4.2.3 now shows explicitly instead of replacing the portal with the login view.

View File

@@ -112,11 +112,40 @@ func decode(r *http.Request, v any) error {
const customerCookie = "neuralhunt_customer_session"
const csAdminCookie = "neuralhunt_customer_admin"
func (s *Service) cookieSameSite(name string) http.SameSite {
// The public customer portal may return from external payment providers. Lax
// still blocks cross-site fetch/POST cookie use while allowing normal top-level
// navigation back to the portal. Keep the private admin cookie Strict.
if name == customerCookie {
return http.SameSiteLaxMode
}
return http.SameSiteStrictMode
}
func (s *Service) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: int(ttl.Seconds())})
now := time.Now().UTC()
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
HttpOnly: true,
Secure: s.cfg.CookieSecure,
SameSite: s.cookieSameSite(name),
MaxAge: int(ttl.Seconds()),
Expires: now.Add(ttl),
})
}
func (s *Service) clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1})
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: s.cfg.CookieSecure,
SameSite: s.cookieSameSite(name),
MaxAge: -1,
Expires: time.Unix(1, 0).UTC(),
})
}
func (s *Service) customerID(r *http.Request) (string, error) {
c, err := r.Cookie(customerCookie)
@@ -312,6 +341,7 @@ func (s *Service) rewardIdentity(w http.ResponseWriter, r *http.Request) {
}
clientID, err := s.redeemRewardLink(r.Context(), in.LinkCode)
if err != nil {
log.Printf("customer reward identity %s pairing failed: %v", cid, err)
jsonOut(w, http.StatusBadGateway, map[string]string{"error": "Hosted-Code konnte nicht gekoppelt werden: " + err.Error()})
return
}

View File

@@ -1,6 +1,9 @@
package customer
import "testing"
import (
"net/http"
"testing"
)
func TestParseMoneyCentsExact(t *testing.T) {
cases := map[string]int64{
@@ -22,3 +25,13 @@ func TestParseMoneyCentsExact(t *testing.T) {
}
}
}
func TestCustomerCookieUsesLaxAndAdminStrict(t *testing.T) {
s := &Service{}
if got := s.cookieSameSite(customerCookie); got != http.SameSiteLaxMode {
t.Fatalf("customer cookie SameSite = %v; want Lax", got)
}
if got := s.cookieSameSite(csAdminCookie); got != http.SameSiteStrictMode {
t.Fatalf("admin cookie SameSite = %v; want Strict", got)
}
}

View File

@@ -1 +1,2 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Admin</title><link rel="stylesheet" href="/styles.css"></head><body><main><div class="eyebrow">NEURAL HUNT · PRIVATE CONTROL PLANE</div><h1>Customer Service Admin</h1><div id="msg" class="msg hidden"></div><section id="loginBox"><label>Admin<input id="user"></label><label>Passwort<input id="pass" type="password"></label><button id="login">ANMELDEN</button></section><section id="panel" class="hidden"><div class="head"><p>Dieser Listener gehört ausschließlich hinter VPN / privates Netz.</p><button id="logout">ABMELDEN</button></div><div id="manual" class="notice"></div><div id="customers"></div></section></main><script src="/app.js" defer></script></body></html>
<!doctype html><html lang="de"><head>
<link rel="icon" href="data:,"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Admin</title><link rel="stylesheet" href="/styles.css"></head><body><main><div class="eyebrow">NEURAL HUNT · PRIVATE CONTROL PLANE</div><h1>Customer Service Admin</h1><div id="msg" class="msg hidden"></div><section id="loginBox"><label>Admin<input id="user"></label><label>Passwort<input id="pass" type="password"></label><button id="login">ANMELDEN</button></section><section id="panel" class="hidden"><div class="head"><p>Dieser Listener gehört ausschließlich hinter VPN / privates Netz.</p><button id="logout">ABMELDEN</button></div><div id="manual" class="notice"></div><div id="customers"></div></section></main><script src="/app.js" defer></script></body></html>

View File

@@ -1,9 +1,11 @@
const $=id=>document.getElementById(id);let state={me:null,tasks:[],workers:[]};
function msg(t,err=false){const e=$('msg');e.textContent=t;e.className='msg'+(err?' err':'');e.classList.remove('hidden');setTimeout(()=>e.classList.add('hidden'),5000)}
async function api(path,opt={}){const o={credentials:'same-origin',...opt};if(o.body&&typeof o.body!=='string'){o.headers={...(o.headers||{}),'Content-Type':'application/json'};o.body=JSON.stringify(o.body)}const r=await fetch(path,o);const ct=r.headers.get('content-type')||'';const b=ct.includes('json')?await r.json():await r.text();if(!r.ok)throw new Error(b?.error||b||`HTTP ${r.status}`);return b}
async function api(path,opt={}){const o={credentials:'same-origin',...opt};if(o.body&&typeof o.body!=='string'){o.headers={...(o.headers||{}),'Content-Type':'application/json'};o.body=JSON.stringify(o.body)}const r=await fetch(path,o);const ct=r.headers.get('content-type')||'';const b=ct.includes('json')?await r.json():await r.text();if(!r.ok){const e=new Error(b?.error||b||`HTTP ${r.status}`);e.status=r.status;e.path=path;throw e}return b}
const credits=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});
async function boot(){try{await load()}catch(e){$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden')}const q=new URLSearchParams(location.search);if(q.get('paypal')==='return'&&q.get('token')){try{await api('/api/billing/paypal/capture',{method:'POST',body:{order_id:q.get('token')}});history.replaceState({},'',location.pathname);msg('PayPal-Zahlung verbucht');await load()}catch(e){msg(e.message,true)}}}
async function load(){const me=await api('/api/me');state.me=me;const [tasks,workers,ledger,packages]=await Promise.all([api('/api/tasks'),api('/api/workers'),api('/api/ledger'),api('/api/billing/packages')]);state.tasks=Array.isArray(tasks)?tasks:[];state.workers=workers||[];$('auth').classList.add('hidden');$('portal').classList.remove('hidden');$('logout').classList.remove('hidden');$('balance').textContent=credits(me.balance_micros);$('rate').textContent=credits(me.worker_rate_micros_per_minute);$('workerCount').textContent=workers.length;$('runningCount').textContent=`${workers.filter(x=>x.status==='running').length} aktiv`;$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt';renderTasks();renderWorkers();renderLedger(ledger||[]);renderPackages(packages)}
function showAuth(){state.me=null;$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden')}
function showPortal(me){$('auth').classList.add('hidden');$('portal').classList.remove('hidden');$('logout').classList.remove('hidden');$('balance').textContent=credits(me.balance_micros);$('rate').textContent=credits(me.worker_rate_micros_per_minute);$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt'}
async function boot(){try{await load()}catch(e){if(e.status===401)showAuth();else{showAuth();msg(`Portal konnte nicht geladen werden: ${e.message}`,true)}}const q=new URLSearchParams(location.search);if(q.get('paypal')==='return'&&q.get('token')){try{await api('/api/billing/paypal/capture',{method:'POST',body:{order_id:q.get('token')}});history.replaceState({},'',location.pathname);msg('PayPal-Zahlung verbucht');await load()}catch(e){msg(e.message,true)}}}
async function load(){const me=await api('/api/me');state.me=me;showPortal(me);const reqs=[['tasks','/api/tasks'],['workers','/api/workers'],['ledger','/api/ledger'],['packages','/api/billing/packages']];const rs=await Promise.allSettled(reqs.map(([,path])=>api(path)));const errors=[];let tasks=[],workers=[],ledger=[],packages={paypal_enabled:false,packages:[]};rs.forEach((r,i)=>{const [name,path]=reqs[i];if(r.status==='fulfilled'){if(name==='tasks')tasks=r.value;if(name==='workers')workers=r.value;if(name==='ledger')ledger=r.value;if(name==='packages')packages=r.value}else{errors.push(`${path}: ${r.reason?.message||'Fehler'}`)}});state.tasks=Array.isArray(tasks)?tasks:[];state.workers=Array.isArray(workers)?workers:[];$('workerCount').textContent=state.workers.length;$('runningCount').textContent=`${state.workers.filter(x=>x.status==='running').length} aktiv`;renderTasks();renderWorkers();renderLedger(Array.isArray(ledger)?ledger:[]);renderPackages(packages||{paypal_enabled:false,packages:[]});if(errors.length)msg(`Session ist aktiv, aber Teile des Portals sind nicht erreichbar: ${errors.join(' · ')}`,true)}
function taskName(t){return t.display_name||`Task ${String(t.id).slice(-8)}`}
function renderTasks(){const html=state.tasks.map(t=>`<option value="${esc(t.id)}">${esc(taskName(t))} · ${t.range_bits} bit${t.paused?' · PAUSED':''}</option>`).join('');$('newTask').innerHTML=html}
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}

View File

@@ -1,5 +1,6 @@
<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Service</title><link rel="stylesheet" href="/styles.css"></head>
<html lang="de"><head>
<link rel="icon" href="data:,"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Service</title><link rel="stylesheet" href="/styles.css"></head>
<body><main>
<header><div><div class="eyebrow">NEURAL HUNT</div><h1>Customer Service</h1><p class="muted">PrePaid Worker · Task-Zuordnung · Reward Management</p></div><button id="logout" class="ghost hidden">ABMELDEN</button></header>
<div id="msg" class="msg hidden"></div>