adventure: a click-through page for every adventurer on the board
Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
This commit is contained in:
@@ -6,11 +6,22 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// buyerLocalpart normalises a session's Authentik username to the Matrix
|
||||
// localpart gogobee keys everything on. Matrix localparts are lowercase; an
|
||||
// Authentik display of the same name may not be, and gogobee pushes balances and
|
||||
// resolves orders under the lowercase form. Lowercasing on both sides is what
|
||||
// keeps the buyer's balance lookup and their order's identity pointing at the
|
||||
// same person.
|
||||
func buyerLocalpart(u *SessionUser) string {
|
||||
return strings.ToLower(strings.TrimSpace(u.Username))
|
||||
}
|
||||
|
||||
// The mischief storefront's web seam.
|
||||
//
|
||||
// Two audiences, two auth models, same file. Browsers hit the OIDC-gated buy
|
||||
@@ -51,7 +62,8 @@ func (s *Server) handleMischiefOrder(w http.ResponseWriter, r *http.Request) {
|
||||
// A session minted before the storefront existed carries no username, and
|
||||
// without one gogobee can't name the buyer to its ledger. Send them back
|
||||
// through sign-in to mint a fresh one rather than place an unfulfillable order.
|
||||
if u.Username == "" {
|
||||
buyer := buyerLocalpart(u)
|
||||
if buyer == "" {
|
||||
writeMischiefError(w, http.StatusConflict, "please sign in again")
|
||||
return
|
||||
}
|
||||
@@ -100,13 +112,13 @@ func (s *Server) handleMischiefOrder(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
order, err := storage.InsertMischiefOrder(u.Sub, u.Username, mark.Token, mark.Name, tier.Key, req.Signed)
|
||||
order, err := storage.InsertMischiefOrder(u.Sub, buyer, mark.Token, mark.Name, tier.Key, req.Signed)
|
||||
if err != nil {
|
||||
slog.Error("mischief: insert order", "err", err)
|
||||
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
slog.Info("mischief: order placed", "guid", order.GUID, "buyer", u.Username, "tier", tier.Key, "signed", req.Signed)
|
||||
slog.Info("mischief: order placed", "guid", order.GUID, "buyer", buyer, "tier", tier.Key, "signed", req.Signed)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, order)
|
||||
}
|
||||
@@ -159,8 +171,8 @@ func (s *Server) handleMischiefCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
tiers = []storage.MischiefTier{}
|
||||
}
|
||||
resp := mischiefCatalogResp{Tiers: tiers}
|
||||
if u.Username != "" {
|
||||
euro, has, err := storage.UserEuro(u.Username)
|
||||
if lp := buyerLocalpart(u); lp != "" {
|
||||
euro, has, err := storage.UserEuro(lp)
|
||||
if err != nil {
|
||||
slog.Error("mischief: balance", "err", err)
|
||||
} else {
|
||||
|
||||
@@ -134,6 +134,51 @@ func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// detailPush is the payload gogobee POSTs to /api/ingest/detail: the private,
|
||||
// owner-only expansion for every player, in its own keyspace (keyed by localpart)
|
||||
// and on its own endpoint so a fat inventory can't blow the roster push's budget.
|
||||
type detailPush struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Players []storage.PlayerDetail `json:"players"`
|
||||
}
|
||||
|
||||
// handleDetailIngest replaces the private self-detail set with gogobee's latest
|
||||
// push. Bearer-authed like the roster; the data is owner-private and only ever
|
||||
// served back to the one authenticated user it belongs to (see PlayerDetailByOwner).
|
||||
func (s *Server) handleDetailIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// A generous cap: this carries every player's inventory + vault, heavier than
|
||||
// the board, but still a bounded realm — the ceiling only stops a runaway push.
|
||||
var push detailPush
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&push); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(push.Players) > rosterMaxEntries {
|
||||
http.Error(w, "detail set too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if push.SnapshotAt <= 0 {
|
||||
push.SnapshotAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
if err := storage.ReplacePlayerDetail(push.Players, push.SnapshotAt); err != nil {
|
||||
slog.Error("detail ingest: replace failed", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("detail ingest: self-view set replaced", "players", len(push.Players))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
|
||||
// open tab goes live without a reload. Public — same exposure as the rendered
|
||||
// page, no more.
|
||||
|
||||
@@ -102,7 +102,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
shared []string
|
||||
pages []string
|
||||
}{
|
||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who"}},
|
||||
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
|
||||
}
|
||||
tpls := make(map[string]*template.Template)
|
||||
@@ -219,6 +219,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
|
||||
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
|
||||
|
||||
// The private, owner-only self-detail (inventory/house/pets). Bearer-authed
|
||||
// ingest like the roster; there is no public read — it is only ever served
|
||||
// back to its owner, inline on the detail page below.
|
||||
mux.HandleFunc("POST /api/ingest/detail", s.handleDetailIngest)
|
||||
|
||||
// One adventurer's detail page and its live re-poll. Both public (stats +
|
||||
// gear); the page adds the owner's private extras when the signed-in viewer
|
||||
// owns it. Three path segments, so it never overlaps /adventure/{guid} (two)
|
||||
// or /adventure/art/{type}.
|
||||
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
|
||||
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
|
||||
|
||||
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||
// channel listing, registered in the channels loop above).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@
|
||||
{{range .Roster}}
|
||||
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
|
||||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||||
<span class="font-semibold">{{.Name}}</span>
|
||||
<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
||||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||||
@@ -107,7 +107,7 @@
|
||||
: '';
|
||||
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
|
||||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||||
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
|
||||
'<a href="/adventure/who/' + esc(a.Token) + '" class="font-semibold hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
|
||||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||||
esc(a.Where) + idle + '</span>' + button + '</li>';
|
||||
|
||||
197
internal/web/templates/who.html
Normal file
197
internal/web/templates/who.html
Normal file
@@ -0,0 +1,197 @@
|
||||
{{define "title"}}{{.Mark.Name}} — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
|
||||
<nav class="mb-4">
|
||||
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||
<span aria-hidden="true">←</span> The board
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{if .Mark.OnRun}}⚔{{else}}🏠{{end}}</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-80">adventurer</p>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Mark.Name}}</h1>
|
||||
<p class="mt-2 opacity-90">Level {{.Mark.Level}} {{.Mark.ClassRace}}</p>
|
||||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75" id="who-where">
|
||||
{{if .Mark.OnRun}}{{.Mark.Where}}{{else}}In town{{if .Mark.Idle}} · {{.Mark.Idle}}{{end}}{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .HasDetail}}
|
||||
<!-- Stats + gear: public, the same anonymity model as the board. -->
|
||||
<section class="mt-8 grid gap-6 sm:grid-cols-2">
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Vitals</h2>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Hit points</span>
|
||||
<span class="font-semibold" id="who-hp">{{.Detail.HPCurrent}} / {{.Detail.HPMax}}{{if .Detail.TempHP}} <span class="text-theme-adventure">+{{.Detail.TempHP}}</span>{{end}}</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 rounded-full bg-[color:var(--ink)]/10 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-theme-adventure transition-all" id="who-hp-bar" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-baseline justify-between">
|
||||
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Armor class</span>
|
||||
<span class="font-semibold" id="who-ac">{{.Detail.ArmorClass}}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-3 gap-2">
|
||||
{{range .Abilities}}
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50">{{.Label}}</div>
|
||||
<div class="font-display text-lg font-bold leading-none mt-1">{{.Score}}</div>
|
||||
<div class="text-xs text-[color:var(--ink)]/60">{{.ModStr}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Mark.OnRun}}
|
||||
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 space-y-1.5 text-sm" id="who-expedition">
|
||||
{{if .Detail.Room}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Room</span><span class="font-semibold" id="who-room">{{.Detail.Room}}</span></div>{{end}}
|
||||
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Supplies</span><span class="font-semibold" id="who-supplies">{{.Detail.Supplies}}</span></div>
|
||||
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Gear</h2>
|
||||
{{if .Detail.Gear}}
|
||||
<ul class="space-y-2">
|
||||
{{range .Detail.Gear}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45 w-16 shrink-0">{{.Slot}}</span>
|
||||
<span class="font-semibold flex-1">{{.Name}}{{if .Masterwork}} <span title="masterwork">★</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasSelf}}
|
||||
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
|
||||
only because your signed-in localpart owns this page's token. -->
|
||||
<section class="mt-8">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="rounded-full bg-theme-adventure text-white text-xs font-semibold px-3 py-1">Your adventurer</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">House tier</span><span class="font-semibold">{{if .Self.House.Tier}}{{.Self.House.Tier}}{{else}}no house{{end}}</span></div>
|
||||
{{if .Self.House.LoanBalance}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Loan balance</span><span class="font-semibold">{{.Self.House.LoanBalance}}</span></div>{{end}}
|
||||
{{if .Self.House.Rate}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Rate</span><span class="font-semibold">{{.Self.House.Rate}}</span></div>{{end}}
|
||||
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Autopay</span><span class="font-semibold">on</span></div>{{end}}
|
||||
</div>
|
||||
|
||||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
|
||||
{{if .Self.Pets}}
|
||||
<ul class="space-y-2 text-sm">
|
||||
{{range .Self.Pets}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-[color:var(--ink)]/45 font-normal">{{.Type}}</span></span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-sm text-[color:var(--ink)]/50">No pets right now.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Backpack</h2>
|
||||
{{if .Self.Inventory}}
|
||||
<ul class="space-y-1.5 text-sm max-h-72 overflow-y-auto pr-1">
|
||||
{{range .Self.Inventory}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Self.Vault}}
|
||||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
|
||||
<ul class="space-y-1.5 text-sm max-h-52 overflow-y-auto pr-1">
|
||||
{{range .Self.Vault}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</article>
|
||||
|
||||
<script>
|
||||
// The sheet is state, like the board: keep an open tab honest without a reload.
|
||||
// Public detail only (HP, room, supplies) — the private panels stay as rendered.
|
||||
(function () {
|
||||
var root = document.getElementById('who');
|
||||
if (!root) return;
|
||||
var token = root.getAttribute('data-token');
|
||||
|
||||
function setBar() {
|
||||
var el = document.getElementById('who-hp-bar');
|
||||
var hp = document.getElementById('who-hp');
|
||||
if (!el || !hp) return;
|
||||
var m = hp.textContent.match(/(-?\d+)\s*\/\s*(\d+)/);
|
||||
if (!m) return;
|
||||
var cur = parseInt(m[1], 10), max = parseInt(m[2], 10);
|
||||
var pct = max > 0 ? Math.max(0, Math.min(100, Math.round(cur / max * 100))) : 0;
|
||||
el.style.width = pct + '%';
|
||||
}
|
||||
setBar();
|
||||
|
||||
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
||||
|
||||
function refresh() {
|
||||
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
if (!data.live) {
|
||||
txt('who-where', 'Back in town');
|
||||
return;
|
||||
}
|
||||
var d = data.detail || {};
|
||||
var mark = data.mark || {};
|
||||
if (data.has_detail) {
|
||||
var temp = d.temp_hp ? ' +' + d.temp_hp : '';
|
||||
txt('who-hp', d.hp_current + ' / ' + d.hp_max + temp);
|
||||
txt('who-ac', d.armor_class);
|
||||
txt('who-room', d.room);
|
||||
txt('who-supplies', d.supplies);
|
||||
txt('who-threat', d.threat_level);
|
||||
setBar();
|
||||
}
|
||||
if (mark.OnRun) txt('who-where', mark.Where);
|
||||
})
|
||||
.catch(function () { /* transient — next tick will do */ });
|
||||
}
|
||||
setInterval(refresh, 60000);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
169
internal/web/who.go
Normal file
169
internal/web/who.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The adventurer detail page.
|
||||
//
|
||||
// A click-through from the live board: anyone may see a mark's current stats and
|
||||
// equipped gear (the same anonymity model as the board — a character name and a
|
||||
// sheet, never a Matrix handle). The signed-in owner sees the same page enriched
|
||||
// with their private inventory, vault, house, and pets.
|
||||
//
|
||||
// Both halves are still gogobee → Pete: the public detail rides the roster
|
||||
// snapshot, the private detail rides its own push. Pete renders what it was
|
||||
// given; it never reaches back into the game box.
|
||||
|
||||
// whoDetail is the public sheet as gogobee pushed it (RosterEntry.Detail).
|
||||
type whoDetail struct {
|
||||
HPCurrent int `json:"hp_current"`
|
||||
HPMax int `json:"hp_max"`
|
||||
TempHP int `json:"temp_hp"`
|
||||
ArmorClass int `json:"armor_class"`
|
||||
Abilities [6]int `json:"abilities"`
|
||||
Modifiers [6]int `json:"modifiers"`
|
||||
Gear []whoGear `json:"gear"`
|
||||
Supplies int `json:"supplies"`
|
||||
ThreatLevel int `json:"threat_level"`
|
||||
Room string `json:"room"`
|
||||
}
|
||||
|
||||
type whoGear struct {
|
||||
Slot string `json:"slot"`
|
||||
Name string `json:"name"`
|
||||
Tier int `json:"tier"`
|
||||
Condition int `json:"condition"`
|
||||
Masterwork bool `json:"masterwork"`
|
||||
}
|
||||
|
||||
// abilityRow is one ability line, pre-formatted for the template.
|
||||
type abilityRow struct {
|
||||
Label string
|
||||
Score int
|
||||
ModStr string // "+2", "-1"
|
||||
}
|
||||
|
||||
var abilityLabels = [6]string{"STR", "DEX", "CON", "INT", "WIS", "CHA"}
|
||||
|
||||
type whoPage struct {
|
||||
pageData
|
||||
Mark RosterView
|
||||
Stale bool
|
||||
HasDetail bool
|
||||
Detail whoDetail
|
||||
Abilities []abilityRow
|
||||
HasSelf bool
|
||||
Self storage.PlayerDetail
|
||||
}
|
||||
|
||||
// handleAdventureWho serves one adventurer's detail page. Public; 404s when the
|
||||
// token isn't on the current board — the same liveness gate the storefront uses,
|
||||
// so a stale or guessed token never resolves to a page.
|
||||
func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
token := r.PathValue("token")
|
||||
entry, ok, err := storage.RosterEntryByToken(token)
|
||||
if err != nil {
|
||||
slog.Error("who: roster lookup failed", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.track(r, "adventure")
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "adventure"
|
||||
base.NoIndex = true // names a player character; keep out of search indexes
|
||||
|
||||
page := whoPage{
|
||||
pageData: base,
|
||||
Mark: toRosterView(entry),
|
||||
Stale: storage.RosterSnapshotAt() == 0,
|
||||
}
|
||||
if d, abil, ok := decodeWhoDetail(entry.Detail); ok {
|
||||
page.HasDetail = true
|
||||
page.Detail = d
|
||||
page.Abilities = abil
|
||||
}
|
||||
|
||||
// Owner enrichment: only when the signed-in user's localpart owns this exact
|
||||
// page token. The ownership is proven by a row gogobee pushed, never by
|
||||
// reversing the token, so no visitor can unlock another player's self extras.
|
||||
if s.auth != nil {
|
||||
if u := s.auth.userFromRequest(r); u != nil {
|
||||
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
|
||||
page.HasSelf = true
|
||||
page.Self = self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.render(w, "who", page)
|
||||
}
|
||||
|
||||
// handleAdventureWhoAPI serves the public detail as JSON for the page's live
|
||||
// re-poll, so an open tab tracks HP / room / supplies as they move. Public — the
|
||||
// same exposure as the rendered page. The private self extras are not included:
|
||||
// inventory changes rarely and stays server-rendered behind the ownership check.
|
||||
func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
token := r.PathValue("token")
|
||||
entry, ok, err := storage.RosterEntryByToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if !ok {
|
||||
// Gone from the board (expedition ended, opted out): tell the poller so it
|
||||
// can stop, rather than 404-ing an open tab into an error.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
|
||||
return
|
||||
}
|
||||
d, abil, hasDetail := decodeWhoDetail(entry.Detail)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"live": true,
|
||||
"mark": toRosterView(entry),
|
||||
"has_detail": hasDetail,
|
||||
"detail": d,
|
||||
"abilities": abil,
|
||||
})
|
||||
}
|
||||
|
||||
// decodeWhoDetail unpacks the public detail blob and builds the ability rows.
|
||||
// Returns ok=false when there is no detail (a snapshot from before the detail
|
||||
// push), so the page can fall back to the summary the board already has.
|
||||
func decodeWhoDetail(raw json.RawMessage) (whoDetail, []abilityRow, bool) {
|
||||
if len(raw) == 0 {
|
||||
return whoDetail{}, nil, false
|
||||
}
|
||||
var d whoDetail
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return whoDetail{}, nil, false
|
||||
}
|
||||
rows := make([]abilityRow, 0, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
rows = append(rows, abilityRow{
|
||||
Label: abilityLabels[i],
|
||||
Score: d.Abilities[i],
|
||||
ModStr: fmt.Sprintf("%+d", d.Modifiers[i]),
|
||||
})
|
||||
}
|
||||
return d, rows, true
|
||||
}
|
||||
199
internal/web/who_test.go
Normal file
199
internal/web/who_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The adventurer detail page. Two contracts under test: the public sheet reaches
|
||||
// any visitor (stats + gear, never a handle), and the private self-view unlocks
|
||||
// only for the signed-in owner of that exact page — anon and other users see the
|
||||
// public page and nothing more.
|
||||
|
||||
func postDetail(t *testing.T, s *Server, token string, push detailPush) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(push)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/detail", bytes.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.handleDetailIngest(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// publicDetail is the sheet gogobee hangs on a roster entry, marshalled the way
|
||||
// the roster push carries it.
|
||||
func publicDetail(t *testing.T) json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"hp_current": 30,
|
||||
"hp_max": 42,
|
||||
"temp_hp": 5,
|
||||
"armor_class": 17,
|
||||
"abilities": [6]int{16, 14, 15, 10, 12, 8},
|
||||
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
|
||||
"gear": []map[string]any{
|
||||
{"slot": "weapon", "name": "Rusty Sword", "tier": 0, "condition": 100},
|
||||
{"slot": "armor", "name": "Padded Vest", "tier": 0, "condition": 100},
|
||||
},
|
||||
"supplies": 8,
|
||||
"threat_level": 2,
|
||||
"room": "3 / 7",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// seedWho puts one adventurer on the board with a public detail sheet, and
|
||||
// pushes a private self-detail set owned by `owner` for that same token.
|
||||
func seedWho(t *testing.T, owner string) *Server {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
now := time.Now().Unix()
|
||||
|
||||
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||
e.Level = 14
|
||||
e.ClassRace = "human cleric"
|
||||
e.Detail = publicDetail(t)
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||
t.Fatalf("seed roster = %d", w.Code)
|
||||
}
|
||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||
Localpart: owner,
|
||||
Token: "tok-josie",
|
||||
Inventory: []storage.ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
||||
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
|
||||
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||
}}}); w.Code != 200 {
|
||||
t.Fatalf("seed detail = %d", w.Code)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
|
||||
req.SetPathValue("token", token)
|
||||
if asUser != "" {
|
||||
payload, _ := json.Marshal(SessionUser{Sub: "sub-1", Username: asUser, Exp: time.Now().Add(time.Hour).Unix()})
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdventureWho(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestWhoPublicSheet: any visitor sees the mark's stats and gear — driven
|
||||
// through the real template, so a field slip 500s here rather than in prod.
|
||||
func TestWhoPublicSheet(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
w := getWho(t, s, "tok-josie", "") // anonymous
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET who = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Josie", "human cleric", "Rusty Sword", "Padded Vest"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("public page missing %q", want)
|
||||
}
|
||||
}
|
||||
// The private goods must NOT be on an anonymous render.
|
||||
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Errorf("anonymous page leaked private detail %q", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoSelfUnlock: the owner, signed in on their own page, sees the private
|
||||
// inventory/vault/house/pets inline.
|
||||
func TestWhoSelfUnlock(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
w := getWho(t, s, "tok-josie", "josie")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("owner GET who = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("owner page missing private detail %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
|
||||
// username; the localpart it maps to is lowercase. The owner must still unlock.
|
||||
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
w := getWho(t, s, "tok-josie", "Josie") // capital-J session
|
||||
if !strings.Contains(w.Body.String(), "Iron Ore") {
|
||||
t.Error("a mixed-case owner username failed to unlock their own self-view")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoOtherUserNoUnlock: a different signed-in player sees only the public
|
||||
// sheet on someone else's page — the ownership join must not leak across users.
|
||||
func TestWhoOtherUserNoUnlock(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
w := getWho(t, s, "tok-josie", "quack") // signed in, but not the owner
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("other-user GET who = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Josie") {
|
||||
t.Error("public sheet missing for a signed-in non-owner")
|
||||
}
|
||||
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Errorf("a non-owner unlocked private detail %q", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoOffBoardIs404: a token that isn't on the current board resolves to
|
||||
// nothing — the same liveness gate the storefront uses, so a stale or guessed
|
||||
// token never renders a page.
|
||||
func TestWhoOffBoardIs404(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
if w := getWho(t, s, "tok-ghost", ""); w.Code != 404 {
|
||||
t.Errorf("off-board token = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailIngestReplacesAndAuth: the detail push is bearer-gated and swaps the
|
||||
// set whole, so a player dropped from a later push loses their self-view.
|
||||
func TestDetailIngestReplacesAndAuth(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
if w := postDetail(t, s, "wrong", detailPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 {
|
||||
t.Errorf("bad bearer = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// A later push without Josie: her self-view must be gone, but the public
|
||||
// board (a separate keyspace) is untouched, so her page still renders public.
|
||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: time.Now().Unix() + 60}); w.Code != 200 {
|
||||
t.Fatalf("empty detail push = %d, want 200", w.Code)
|
||||
}
|
||||
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||
if strings.Contains(body, "Iron Ore") {
|
||||
t.Error("owner still unlocked after her detail was dropped from the push")
|
||||
}
|
||||
if !strings.Contains(body, "Josie") {
|
||||
t.Error("public page vanished when only the private detail was dropped")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user