Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/db-dev
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ fi
# wait for ready
printf "waiting for %s to be ready..." ${CONTAINER}
for _ in $(seq 1 30); do
if docker exec "$CONTAINER" pg_isready -U "$USER" >/dev/null 2>&1; then
if docker exec "$CONTAINER" pg_isready -h 127.0.0.1 -p 5432 -U "$USER" -d "$DB" >/dev/null 2>&1; then
printf "\n"
mkdir -p "/tmp/rulette"
touch "/tmp/rulette/db-ready"
trap "rm -f /tmp/rulette/db-ready" EXIT
trap 'rm -f /tmp/rulette/db-ready; cleanup' EXIT
echo "${CONTAINER} ready on :${PORT}"
echo
echo "ctrl-c to stop"
Expand Down
14 changes: 7 additions & 7 deletions game.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func gameHandler(w http.ResponseWriter, r *http.Request) {
}

filepath := path.Join("static", "html", "tmpl.game.html")
tmpl, err := readParse(static, filepath)
tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context()))
if err != nil {
log.Error("read and parse template",
"error", err,
Expand Down Expand Up @@ -131,7 +131,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
switch topic {
case "players":
filepath := path.Join("static", "html", "tmpl.players.html")
tmpl, err := readParse(static, filepath)
tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context()))
if err != nil {
log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
Expand All @@ -148,7 +148,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
return
case "table":
filepath := path.Join("static", "html", "tmpl.table.html")
tmpl, err := readParse(static, filepath)
tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context()))
if err != nil {
log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
Expand All @@ -166,7 +166,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
return
case "status":
filepath := path.Join("static", "html", "tmpl.status.html")
tmpl, err := readParse(static, filepath)
tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context()))
if err != nil {
log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
Expand All @@ -189,7 +189,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
}
case "points":
filepath := path.Join("static", "html", "tmpl.points.html")
tmpl, err := readParse(static, filepath)
tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context()))
if err != nil {
log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
Expand Down Expand Up @@ -223,7 +223,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
return
}
filepath := path.Join("static", "html", "tmpl.change_points_dialog.html")
tmpl, err := readParse(static, filepath)
tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context()))
if err != nil {
log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
Expand All @@ -240,7 +240,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
return
case "accuse":
filepath := path.Join("static", "html", "tmpl.accuse_dialog.html")
tmpl, err := readParse(static, filepath)
tmpl, err := readParse(static, filepath, LocaleFromContext(r.Context()))
if err != nil {
log.Error(ErrReadParseTemplate.Error(), "filepath", filepath, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
Expand Down
119 changes: 119 additions & 0 deletions i18n.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package main

import (
"context"
"embed"
"encoding/json"
"fmt"
"net/http"
"path"
"sort"
"strings"
)

const defaultLocale = "en"

// translations is locale -> key -> value, populated by loadTranslations
// at startup and read-only thereafter.
var translations = map[string]map[string]string{}

type ctxLocaleKey struct{}

func init() {
if err := loadTranslations(translationsFS); err != nil {
panic(fmt.Sprintf("load translations: %v", err))
}
}

// loadTranslations reads every tx/*.json file from fs and registers
// each as a locale named after the file's basename (e.g. tx/en.json
// becomes locale "en").
func loadTranslations(fs embed.FS) error {
entries, err := fs.ReadDir("tx")
if err != nil {
return fmt.Errorf("read tx dir: %w", err)
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
raw, err := fs.ReadFile(path.Join("tx", e.Name()))
if err != nil {
return fmt.Errorf("read %s: %w", e.Name(), err)
}
m := map[string]string{}
if err := json.Unmarshal(raw, &m); err != nil {
return fmt.Errorf("parse %s: %w", e.Name(), err)
}
locale := strings.TrimSuffix(e.Name(), ".json")
translations[locale] = m
}
if _, ok := translations[defaultLocale]; !ok {
return fmt.Errorf("missing default locale %q", defaultLocale)
}
return nil
}

// Tx looks up key for the given locale, falling back to the default
// locale, and finally to the key itself so that missing entries are
// loud but non-fatal.
func Tx(locale, key string) string {
if m, ok := translations[locale]; ok {
if v, ok := m[key]; ok {
return v
}
}
if m, ok := translations[defaultLocale]; ok {
if v, ok := m[key]; ok {
return v
}
}
return key
}

// Locales returns the registered locale codes in sorted order.
func Locales() []string {
out := make([]string, 0, len(translations))
for k := range translations {
out = append(out, k)
}
sort.Strings(out)
return out
}

// LocaleFromContext returns the locale stored on ctx by i18nMW,
// falling back to the default locale.
func LocaleFromContext(ctx context.Context) string {
if v, ok := ctx.Value(ctxLocaleKey{}).(string); ok && v != "" {
return v
}
return defaultLocale
}

// WithLocale returns a new context carrying locale.
func WithLocale(ctx context.Context, locale string) context.Context {
return context.WithValue(ctx, ctxLocaleKey{}, locale)
}

// detectLocale picks the request locale by precedence:
// 1. lang cookie (the persisted toggle choice)
// 2. Accept-Language header (first tag's primary subtag)
// 3. defaultLocale
// A candidate is only honored if it's a registered locale.
func detectLocale(r *http.Request) string {
if c, err := r.Cookie("lang"); err == nil {
if _, ok := translations[c.Value]; ok {
return c.Value
}
}
if h := r.Header.Get("Accept-Language"); h != "" {
first := strings.SplitN(h, ",", 2)[0]
first = strings.SplitN(first, ";", 2)[0]
first = strings.ToLower(strings.TrimSpace(first))
first = strings.SplitN(first, "-", 2)[0]
if _, ok := translations[first]; ok {
return first
}
}
return defaultLocale
}
31 changes: 31 additions & 0 deletions lang.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"net/http"
"time"
)

// langHandler persists the user's locale preference via a cookie.
// On invalid or unknown locales, falls back silently to the default.
func langHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
choice := r.FormValue("lang")
if _, ok := translations[choice]; !ok {
choice = defaultLocale
}
http.SetCookie(w, &http.Cookie{
Name: "lang",
Value: choice,
Path: "/",
MaxAge: int((365 * 24 * time.Hour).Seconds()),
SameSite: http.SameSiteLaxMode,
})
target := r.Referer()
if target == "" {
target = "/"
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
19 changes: 12 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ var dbSchema string
//go:embed static
var static embed.FS

//go:embed tx
var translationsFS embed.FS

func init() {
var err error
log, err = logger.New(slog.HandlerOptions{})
Expand All @@ -64,14 +67,16 @@ func main() {
mux.Handle("/static/img/", logMW(rateMW(http.FileServer(http.FS(static)))))
mux.Handle("/static/fonts/", logMW(rateMW(http.FileServer(http.FS(static)))))
// pregame.go
mux.Handle("/", logMW(rateMW(http.HandlerFunc(rootHandler))))
mux.Handle("/create", logMW(rateMW(http.HandlerFunc(createHandler))))
mux.Handle("/{game_id}/join", logMW(rateMW(http.HandlerFunc(joinHandler))))
mux.Handle("/", logMW(rateMW(i18nMW(http.HandlerFunc(rootHandler)))))
mux.Handle("/create", logMW(rateMW(i18nMW(http.HandlerFunc(createHandler)))))
mux.Handle("/{game_id}/join", logMW(rateMW(i18nMW(http.HandlerFunc(joinHandler)))))
// lang.go — locale toggle; no i18nMW (it sets the locale source).
mux.Handle("/lang", logMW(rateMW(http.HandlerFunc(langHandler))))
// game.go
mux.Handle("/{game_id}", logMW(rateMW(http.HandlerFunc(gameHandler))))
mux.Handle("/{game_id}/qr", logMW(rateMW(http.HandlerFunc(qrHandler))))
mux.Handle("/{game_id}/data/{topic}", logMW(rateMW(http.HandlerFunc(dataHandler))))
mux.Handle("/{game_id}/action/{action}", logMW(rateMW(http.HandlerFunc(actionHandler))))
mux.Handle("/{game_id}", logMW(rateMW(i18nMW(http.HandlerFunc(gameHandler)))))
mux.Handle("/{game_id}/qr", logMW(rateMW(i18nMW(http.HandlerFunc(qrHandler)))))
mux.Handle("/{game_id}/data/{topic}", logMW(rateMW(i18nMW(http.HandlerFunc(dataHandler)))))
mux.Handle("/{game_id}/action/{action}", logMW(rateMW(i18nMW(http.HandlerFunc(actionHandler)))))

ctx := context.Background()
// RULETTE_PG_URL is a postgres connection string, e.g.:
Expand Down
9 changes: 9 additions & 0 deletions middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import (
"golang.org/x/time/rate"
)

// i18nMW resolves the request locale (cookie > Accept-Language > default)
// and stashes it on the request context for handlers and templates.
func i18nMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := WithLocale(r.Context(), detectLocale(r))
next.ServeHTTP(w, r.WithContext(ctx))
})
}

// logMW logs every incoming request.
func logMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
48 changes: 46 additions & 2 deletions pregame.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func setCookieErr(w http.ResponseWriter, err error) {
// from which a user can start a new game with a POST to /create.
func rootHandler(w http.ResponseWriter, r *http.Request) {
indexPath := path.Join("static", "html", "index.html")
tmpl, err := readParse(static, indexPath)
tmpl, err := readParse(static, indexPath, LocaleFromContext(r.Context()))
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
Expand Down Expand Up @@ -104,9 +104,31 @@ func joinHandler(w http.ResponseWriter, r *http.Request) {
}
switch r.Method {
case http.MethodGet:
// if the visitor is already a player in this game, don't show them
// the join form — bounce them back to the game page. Fail closed
// on state lookup errors: a cookied visitor reaching the form is
// the bug we're trying to prevent.
if _, cookieKey, err := cookie(r); err == nil {
s, err := stateFromCacheOrDB(r.Context(), &cache, gameID)
if err != nil {
log.Error("fetch game state for rejoin check",
"error", err,
"game_id", gameID,
)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if s.isPlayerInGame(cookieKey) {
log.Warn("player attempted to rejoin, redirecting to game",
"game_id", gameID,
)
http.Redirect(w, r, fmt.Sprintf("/%s", gameID), http.StatusSeeOther)
return
}
}
w.Header().Set("Content-Type", "text/html")
templateFilepath := path.Join("static", "html", "tmpl.join.html")
tmpl, err := readParse(static, templateFilepath)
tmpl, err := readParse(static, templateFilepath, LocaleFromContext(r.Context()))
if err != nil {
log.Error("read parse",
"error", err,
Expand Down Expand Up @@ -135,6 +157,28 @@ func joinHandler(w http.ResponseWriter, r *http.Request) {
return
}

// reject rejoin: if the caller already has a session for this game,
// bounce to the game page rather than minting a new player/session
// (which would orphan the original identity and host initiative).
if _, cookieKey, err := cookie(r); err == nil {
s, err := stateFromCacheOrDB(r.Context(), &cache, gameID)
if err != nil {
log.Error("fetch game state for rejoin check",
"error", err,
"game_id", gameID,
)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if s.isPlayerInGame(cookieKey) {
log.Warn("POST rejoin blocked, redirecting to game",
"game_id", gameID,
)
http.Redirect(w, r, fmt.Sprintf("/%s", gameID), http.StatusSeeOther)
return
}
}

switch game.StateID {
case 6:
log.Info("join attempt to closed game")
Expand Down
32 changes: 32 additions & 0 deletions rulette_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,38 @@ func TestGame(t *testing.T) {
)
})

t.Run("GET /{game_id}/join (existing player)", func(t *testing.T) {
req := httptest.NewRequest(
http.MethodGet, fmt.Sprintf("/%s/join", gameID), nil,
)
req.AddCookie(users[0].cookie)
w := httptest.NewRecorder()
joinHandler(w, req)
require.Equal(t, http.StatusSeeOther, w.Result().StatusCode,
"existing player GET /join should redirect",
)
require.Equal(t, fmt.Sprintf("/%s", gameID),
w.Result().Header.Get("Location"),
"redirect target should be the game page",
)
for _, c := range w.Result().Cookies() {
require.NotEqual(t, "session", c.Name,
"existing-player redirect must not issue a new session cookie",
)
}
})

t.Run("GET /{game_id}/join (no cookie)", func(t *testing.T) {
req := httptest.NewRequest(
http.MethodGet, fmt.Sprintf("/%s/join", gameID), nil,
)
w := httptest.NewRecorder()
joinHandler(w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode,
"strangers without a cookie should still get the join form",
)
})

t.Run("GET /{game_id}/qr (inviting)", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/{game_id}/qr", qrHandler)
Expand Down
Loading
Loading