// 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 }