mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-13 10:15:55 +00:00
PlayedWord.player_id was declared and read by the client but never set by the server, so a room of three or four never showed who played each word. The chain byline now comes from the room, with a producer-side test. The process also gains the ceilings it was missing: a cap on live rooms and on open sockets, a per-connection frame-rate limit so a payload-less frame is no longer free, and an opt-in trusted-proxy list so the join limiter can tell players apart behind the documented reverse proxy instead of putting them in one bucket. The typed word is sanitized before the engine stores it, since every seat is shown it; a room exiting on its idle clock releases the sessions still bound to it; the dictionary builder escapes its SQLite path like the store does and renames over the old database instead of deleting it first.
183 lines
4.6 KiB
Go
183 lines
4.6 KiB
Go
// Command noitu-server runs the nối từ game server: the WebSocket API, the
|
|
// rooms behind it, and the built frontend, in one binary.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/tiennm99dev/noitu/server/internal/dictionary"
|
|
"github.com/tiennm99dev/noitu/server/internal/wsapi"
|
|
)
|
|
|
|
const (
|
|
defaultAddr = ":8080"
|
|
defaultDBPath = "data/noitu.db"
|
|
defaultTurnLimit = 30 * time.Second
|
|
defaultGrace = 30 * time.Second
|
|
|
|
// shutdownGrace bounds how long in-flight requests get once a signal
|
|
// arrives. Rooms are told separately and immediately, so this only covers
|
|
// the HTTP side.
|
|
shutdownGrace = 10 * time.Second
|
|
)
|
|
|
|
type config struct {
|
|
addr string
|
|
dbPath string
|
|
turnLimit time.Duration
|
|
grace time.Duration
|
|
allowedOrigins []string
|
|
webDir string
|
|
trustedProxies []string
|
|
maxRooms int
|
|
maxConnections int
|
|
}
|
|
|
|
func main() {
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
|
|
|
if err := run(); err != nil {
|
|
slog.Error("server exited", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
cfg := loadConfig()
|
|
|
|
store, err := dictionary.Open(cfg.dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The store deliberately does not log — a library writing to the global
|
|
// logger fights the server's own handler — so the CC BY-SA 4.0 attribution
|
|
// that ships with the data surfaces here or nowhere.
|
|
slog.Info("dictionary loaded",
|
|
"path", cfg.dbPath,
|
|
"words", store.WordCount(),
|
|
"aliases", store.AliasCount(),
|
|
"meanings", store.MeaningCount(),
|
|
"license", store.License(),
|
|
)
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
api := wsapi.NewServer(ctx, store, wsapi.Config{
|
|
TurnLimit: cfg.turnLimit,
|
|
GraceFor: cfg.grace,
|
|
AllowedOrigins: cfg.allowedOrigins,
|
|
WebDir: cfg.webDir,
|
|
TrustedProxies: cfg.trustedProxies,
|
|
MaxRooms: cfg.maxRooms,
|
|
MaxConnections: cfg.maxConnections,
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.addr,
|
|
Handler: api,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
errc := make(chan error, 1)
|
|
go func() {
|
|
slog.Info("listening", "addr", cfg.addr, "turn_limit", cfg.turnLimit, "web_dir", cfg.webDir)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
errc <- err
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case err := <-errc:
|
|
return err
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
// Tell players why before the sockets go, rather than dropping them and
|
|
// leaving the UI to guess.
|
|
slog.Info("shutting down")
|
|
api.Shutdown()
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
|
|
defer cancel()
|
|
return srv.Shutdown(shutdownCtx)
|
|
}
|
|
|
|
func loadConfig() config {
|
|
return config{
|
|
addr: env("NOITU_ADDR", defaultAddr),
|
|
dbPath: env("NOITU_DB_PATH", defaultDBPath),
|
|
turnLimit: envDuration("NOITU_TURN_LIMIT", defaultTurnLimit),
|
|
grace: envDuration("NOITU_GRACE", defaultGrace),
|
|
allowedOrigins: envList("NOITU_ALLOWED_ORIGINS"),
|
|
webDir: env("NOITU_WEB_DIR", ""),
|
|
trustedProxies: envList("NOITU_TRUSTED_PROXIES"),
|
|
maxRooms: envInt("NOITU_MAX_ROOMS", 0),
|
|
maxConnections: envInt("NOITU_MAX_CONNECTIONS", 0),
|
|
}
|
|
}
|
|
|
|
// envInt falls back loudly, like envDuration. Zero means "use the built-in
|
|
// default", so it is what an unset or invalid value becomes.
|
|
func envInt(key string, fallback int) int {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < 0 {
|
|
slog.Warn("ignoring invalid integer", "key", key, "value", raw, "using", fallback)
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
func env(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// envDuration falls back loudly. A typo in a timing variable would otherwise
|
|
// silently change the rules of the game.
|
|
func envDuration(key string, fallback time.Duration) time.Duration {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
d, err := time.ParseDuration(raw)
|
|
if err != nil || d <= 0 {
|
|
slog.Warn("ignoring invalid duration", "key", key, "value", raw, "using", fallback)
|
|
return fallback
|
|
}
|
|
return d
|
|
}
|
|
|
|
// envList returns nil for an unset variable, which coder/websocket reads as
|
|
// same-origin only.
|
|
func envList(key string) []string {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|