e2bcfff5ab
The skills-review desk is static, so "which draft would you ship?" needs a stateful counterpart. vote-service is a small Go API on its own pod backed by a JSON file on a ReadWriteOnce PVC, with one active vote per skill per source IP as the anti-abuse rule and CORS (ALLOWED_ORIGIN) as the caller boundary. Deployment notes that differ from the obvious path, all confirmed against the live cluster: the image is side-loaded with `ctr image import` plus `imagePullPolicy: Never` because kubelet has no credentials for the Nexus ref; the pod is pinned to `kubernets` because the hostpath PV takes a nodeAffinity for whichever node first binds it; and public exposure is Caddy on the VPS, not the cloudflared tunnel. The ingress controller runs with `use-forwarded-headers` off, so nginx overwrites X-Forwarded-For with its own peer — every visitor would collapse into one voter and each skill would cap at one vote overall. Caddy stamps the true remote address into X-Client-IP, which nginx forwards untouched, and clientIP() reads that first. Scoped to this app rather than flipping the global flag, which would change client-IP handling for every other ingress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
271 lines
8.3 KiB
Go
271 lines
8.3 KiB
Go
// Command vote-service is a tiny, dependency-free HTTP API that lets the
|
|
// skills-review page (a static site) collect "prefer original / prefer
|
|
// improved" votes per submitted skill.
|
|
//
|
|
// It is intentionally minimal: one Go binary, no external dependencies, one
|
|
// JSON file on disk as the store. That fits the workshop scale of this
|
|
// feature (dozens of voters, not thousands) and keeps the container image
|
|
// and the Kubernetes footprint small.
|
|
//
|
|
// Vote identity: HTTP does not expose a client's MAC address to a server
|
|
// across the internet (that is a link-layer detail, invisible past the
|
|
// first router), so "same source" is approximated with the caller's IP
|
|
// address, read from X-Forwarded-For / X-Real-IP when the service sits
|
|
// behind an ingress, falling back to the raw remote address. One IP may
|
|
// hold at most one active vote per skill; casting a new choice updates that
|
|
// vote instead of adding a second one. A client-supplied X-Voter-Id header
|
|
// (a random id the frontend keeps in localStorage) is layered on top only
|
|
// to let a browser recognize and display its own prior vote — it is not
|
|
// trusted as the sole anti-abuse signal, since it is trivially resettable.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
type voteChoice string
|
|
|
|
const (
|
|
choiceOriginal voteChoice = "original"
|
|
choiceImproved voteChoice = "improved"
|
|
)
|
|
|
|
func (c voteChoice) valid() bool { return c == choiceOriginal || c == choiceImproved }
|
|
|
|
// store is the on-disk vote ledger. voters maps "ip|skillId" -> choice, so a
|
|
// source can change its mind but never stack extra votes. counts is kept in
|
|
// sync for O(1) tally reads.
|
|
type store struct {
|
|
mu sync.Mutex
|
|
path string
|
|
Voters map[string]voteChoice `json:"voters"`
|
|
Counts map[string]map[voteChoice]int `json:"counts"`
|
|
}
|
|
|
|
func loadStore(path string) (*store, error) {
|
|
s := &store{path: path, Voters: map[string]voteChoice{}, Counts: map[string]map[voteChoice]int{}}
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return s, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) == 0 {
|
|
return s, nil
|
|
}
|
|
decoded := struct {
|
|
Voters map[string]voteChoice `json:"voters"`
|
|
}{}
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
s.Voters = decoded.Voters
|
|
for key, choice := range s.Voters {
|
|
skillID := key[strings.IndexByte(key, '|')+1:]
|
|
s.bump(skillID, choice, 1)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *store) bump(skillID string, choice voteChoice, delta int) {
|
|
if s.Counts[skillID] == nil {
|
|
s.Counts[skillID] = map[voteChoice]int{}
|
|
}
|
|
s.Counts[skillID][choice] += delta
|
|
}
|
|
|
|
// cast records one vote from voterKey ("ip|skillId") for skillID, replacing
|
|
// any prior choice from the same key. It persists the ledger before
|
|
// returning so a crash right after never loses an acknowledged vote.
|
|
func (s *store) cast(voterKey, skillID string, choice voteChoice) (tally map[voteChoice]int, err error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if prev, ok := s.Voters[voterKey]; ok {
|
|
if prev == choice {
|
|
return s.snapshot(skillID), nil
|
|
}
|
|
s.bump(skillID, prev, -1)
|
|
}
|
|
s.Voters[voterKey] = choice
|
|
s.bump(skillID, choice, 1)
|
|
if err := s.persist(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.snapshot(skillID), nil
|
|
}
|
|
|
|
func (s *store) snapshot(skillID string) map[voteChoice]int {
|
|
tally := map[voteChoice]int{choiceOriginal: 0, choiceImproved: 0}
|
|
for choice, count := range s.Counts[skillID] {
|
|
tally[choice] = count
|
|
}
|
|
return tally
|
|
}
|
|
|
|
func (s *store) all() map[string]map[voteChoice]int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make(map[string]map[voteChoice]int, len(s.Counts))
|
|
for skillID := range s.Counts {
|
|
out[skillID] = s.snapshot(skillID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *store) persist() error {
|
|
data, err := json.Marshal(struct {
|
|
Voters map[string]voteChoice `json:"voters"`
|
|
}{s.Voters})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
// Set by the edge proxy (Caddy on the VPS) to the true remote address.
|
|
// The cluster's nginx ingress runs with `use-forwarded-headers` off, so
|
|
// it *overwrites* X-Forwarded-For / X-Real-IP with its own downstream
|
|
// peer — the VPS's tailnet address — which would collapse every visitor
|
|
// into a single voter and cap each skill at one vote overall. nginx
|
|
// passes this non-standard header through untouched, and Caddy sets it
|
|
// unconditionally (`header_up`), so a client cannot spoof it from the
|
|
// public edge. Trust here is exactly the trust already placed in
|
|
// X-Forwarded-For below.
|
|
if edge := r.Header.Get("X-Client-IP"); edge != "" {
|
|
return strings.TrimSpace(strings.Split(edge, ",")[0])
|
|
}
|
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
|
return strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
|
}
|
|
if real := r.Header.Get("X-Real-IP"); real != "" {
|
|
return real
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
func withCORS(allowedOrigin string, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
|
|
w.Header().Set("Vary", "Origin")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Voter-Id")
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
dbPath := envOr("VOTE_DB_PATH", "/data/votes.json")
|
|
allowedOrigin := envOr("ALLOWED_ORIGIN", "https://netcracker.pages.marcospaulo.dev.br")
|
|
addr := ":" + envOr("PORT", "8080")
|
|
|
|
s, err := loadStore(dbPath)
|
|
if err != nil {
|
|
log.Fatalf("load vote store %s: %v", dbPath, err)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
|
|
mux.HandleFunc("/api/votes", withCORS(allowedOrigin, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
handleResults(w, r, s)
|
|
case http.MethodPost:
|
|
handleVote(w, r, s)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}))
|
|
|
|
log.Printf("vote-service listening on %s (db=%s, origin=%s)", addr, dbPath, allowedOrigin)
|
|
log.Fatal(http.ListenAndServe(addr, mux))
|
|
}
|
|
|
|
// handleResults returns the tally for every skill. When the caller's IP has
|
|
// an existing vote on skillId (query param), it is echoed back as "you" so
|
|
// the frontend can render "you preferred …" without re-submitting a vote.
|
|
func handleResults(w http.ResponseWriter, r *http.Request, s *store) {
|
|
tallies := s.all()
|
|
skillID := strings.TrimSpace(r.URL.Query().Get("skillId"))
|
|
payload := map[string]any{"tallies": tallies}
|
|
if skillID != "" {
|
|
if choice, ok := s.mine(clientIP(r), skillID); ok {
|
|
payload["you"] = choice
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, payload)
|
|
}
|
|
|
|
func (s *store) mine(ip, skillID string) (voteChoice, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
choice, ok := s.Voters[ip+"|"+skillID]
|
|
return choice, ok
|
|
}
|
|
|
|
func handleVote(w http.ResponseWriter, r *http.Request, s *store) {
|
|
var body struct {
|
|
SkillID string `json:"skillId"`
|
|
Choice voteChoice `json:"choice"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<12)).Decode(&body); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
body.SkillID = strings.TrimSpace(body.SkillID)
|
|
if body.SkillID == "" || strings.ContainsAny(body.SkillID, "|") || !body.Choice.valid() {
|
|
http.Error(w, "skillId and a valid choice are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ip := clientIP(r)
|
|
voterKey := ip + "|" + body.SkillID
|
|
|
|
tally, err := s.cast(voterKey, body.SkillID, body.Choice)
|
|
if err != nil {
|
|
log.Printf("persist vote: %v", err)
|
|
http.Error(w, "could not save vote", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"skillId": body.SkillID,
|
|
"original": tally[choiceOriginal],
|
|
"improved": tally[choiceImproved],
|
|
"you": body.Choice,
|
|
})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|