package main import ( "database/sql" "encoding/json" "flag" "fmt" _ "github.com/mattn/go-sqlite3" "log" "math" "net" "net/http" "os" "os/exec" "os/signal" "strings" "syscall" "text/template" ) type Fruit struct { Name string `json:"name"` Img string `json:"img"` Elo int `json:"elo"` } type Context struct { db *sql.DB users []string templatePath string socketPath string fruitsPath string } type CurriedContextHandler func(*Context, http.ResponseWriter, *http.Request) func curryContext(context *Context) func(CurriedContextHandler) http.HandlerFunc { return func(handler CurriedContextHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { log.Println(r.Method, r.URL.Path, r.RemoteAddr) handler(context, w, r) } } } func indexHandler(context *Context, resp http.ResponseWriter, req *http.Request) { // get from POST winner := req.FormValue("winner") contestants := req.Form["contestant[]"] if winner != "" && len(contestants) == 2 { losingFruitName := string(contestants[0]) if losingFruitName == winner { losingFruitName = string(contestants[1]) } winningFruit := fruitByName(context.db, winner) losingFruit := fruitByName(context.db, losingFruitName) winningFruit.Elo, losingFruit.Elo = updateElo(winningFruit.Elo, losingFruit.Elo) updateFruit(context.db, winningFruit) updateFruit(context.db, losingFruit) log.Println(winningFruit.Name, "won against", losingFruit.Name, "new elo:", winningFruit.Elo, losingFruit.Elo) } fruitOne := randomFruit(context.db) fruitTwo := randomFruit(context.db) for fruitOne.Name == fruitTwo.Name { fruitTwo = randomFruit(context.db) } templateFile := context.templatePath + "/vote.html" vote, err := template.ParseFiles(templateFile) if err != nil { panic(err) } fruits := []Fruit{fruitOne, fruitTwo} err = vote.Execute(resp, fruits) if err != nil { panic(err) } resp.Header().Set("Content-Type", "text/html") } func getStatsHandler(context *Context, resp http.ResponseWriter, _req *http.Request) { rows, err := context.db.Query("SELECT name, img, elo FROM fruits ORDER BY elo DESC") if err != nil { panic(err) } defer rows.Close() fruits := []Fruit{} for rows.Next() { fruit := Fruit{} err = rows.Scan(&fruit.Name, &fruit.Img, &fruit.Elo) if err != nil { panic(err) } fruits = append(fruits, fruit) } templateFile := context.templatePath + "/stats.html" stats, err := template.ParseFiles(templateFile) if err != nil { panic(err) } err = stats.Execute(resp, fruits) if err != nil { panic(err) } resp.Header().Set("Content-Type", "text/html") } func healthCheckHandler(context *Context, resp http.ResponseWriter, req *http.Request) { resp.WriteHeader(http.StatusOK) resp.Write([]byte("healthy")) } func main() { log.Println("starting server...") log.SetFlags(log.LstdFlags | log.Lshortfile) context := getArgs() log.Println("removing socket file", context.socketPath) os.Remove(context.socketPath) log.Println("migrating database...") migrate(context.db) seedFruits(context.db, context.fruitsPath) listener, err := net.Listen("unix", context.socketPath) if err != nil { panic(err) } log.Println("listening on", context.socketPath) os.Chmod(context.socketPath, 0700) sigc := make(chan os.Signal, 1) signal.Notify(sigc, os.Interrupt, os.Kill, syscall.SIGTERM) go func(c chan os.Signal) { sig := <-c log.Printf("caught signal %s: shutting down.", sig) listener.Close() os.Exit(0) }(sigc) defer listener.Close() log.Println("setting ACLs for users", context.users) for _, user := range context.users { setACL(context.socketPath, user) } curriedContext := curryContext(context) mux := http.NewServeMux() mux.HandleFunc("/", curriedContext(indexHandler)) mux.HandleFunc("/health", curriedContext(healthCheckHandler)) mux.HandleFunc("/stats", curriedContext(getStatsHandler)) log.Println("serving http...") http.Serve(listener, mux) } func calculateRatingDelta(winnerRating int, loserRating int, K int) (int, int) { winnerRatingFloat := float64(winnerRating) loserRatingFloat := float64(loserRating) expectedScoreWinner := 1 / (1 + math.Pow(10, (loserRatingFloat-winnerRatingFloat)/400)) expectedScoreLoser := 1 - expectedScoreWinner changeWinner := int(math.Round(float64(K) * (1 - expectedScoreWinner))) changeLoser := -int(math.Round(float64(K) * (expectedScoreLoser))) return changeWinner, changeLoser } func updateElo(winnerElo int, loserElo int) (int, int) { const K = 32 changeWinner, changeLoser := calculateRatingDelta(winnerElo, loserElo, K) newWinnerElo := winnerElo + changeWinner newLoserElo := loserElo + changeLoser return newWinnerElo, newLoserElo } func seedFruits(db *sql.DB, fruitsPath string) { log.Println("seeding fruits (haha)...") fruitsContents, err := os.ReadFile(fruitsPath) if err != nil { panic(err) } jsonFruits := []Fruit{} err = json.Unmarshal(fruitsContents, &jsonFruits) if err != nil { panic(err) } for _, fruit := range jsonFruits { // insert if not exists _, err := db.Exec("INSERT OR IGNORE INTO fruits (name, img) VALUES (?, ?)", fruit.Name, fruit.Img) if err != nil { panic(err) } } } func migrate(db *sql.DB) { log.Println("creating fruits table...") _, err := db.Exec(` CREATE TABLE IF NOT EXISTS fruits ( name TEXT PRIMARY KEY, img TEXT, elo INTEGER DEFAULT 1400 ); `) if err != nil { panic(err) } } func randomFruit(db *sql.DB) Fruit { fruit := Fruit{} err := db.QueryRow("SELECT name, img, elo FROM fruits ORDER BY RANDOM() LIMIT 1").Scan(&fruit.Name, &fruit.Img, &fruit.Elo) if err != nil { panic(err) } return fruit } func fruitByName(db *sql.DB, name string) Fruit { fruit := Fruit{} err := db.QueryRow("SELECT name, img, elo FROM fruits WHERE name = ?", name).Scan(&fruit.Name, &fruit.Img, &fruit.Elo) if err != nil { panic(err) } return fruit } func updateFruit(db *sql.DB, fruit Fruit) { _, err := db.Exec("UPDATE fruits SET img = ?, elo = ? WHERE name = ?", fruit.Img, fruit.Elo, fruit.Name) if err != nil { panic(err) } } func setACL(socketPath, user string) { cmd := exec.Command("setfacl", "-m", "u:"+user+":rwx", socketPath) if err := cmd.Run(); err != nil { panic("failed to set ACL: " + err.Error()) } } func getArgs() *Context { socketPath := flag.String("socket-path", "/tmp/go-server.sock", "Path to the Unix socket") users := flag.String("users", "", "Comma-separated list of users for ACL") database := flag.String("database-path", "/tmp/go-server.db", "Path to the SQLite database") fruitsPath := flag.String("fruits", "/dev/null", "Path to the fruits file") templatePath := flag.String("template", "", "Path to the template directory") flag.Parse() if *users == "" { fmt.Println("You must specify at least one user with --users") os.Exit(1) } if *templatePath == "" { fmt.Println("You must specify a template directory with --template") os.Exit(1) } log.Println("opening database at", *database, "with foreign keys enabled") db, err := sql.Open("sqlite3", *database+"?_foreign_keys=on") if err != nil { panic(err) } return &Context{ db: db, users: strings.Split(*users, ","), socketPath: *socketPath, fruitsPath: *fruitsPath, templatePath: *templatePath, } }