diff options
Diffstat (limited to 'html/fruitvote/main.go')
-rw-r--r-- | html/fruitvote/main.go | 249 |
1 files changed, 231 insertions, 18 deletions
diff --git a/html/fruitvote/main.go b/html/fruitvote/main.go index b41c88f..cfd1bb8 100644 --- a/html/fruitvote/main.go +++ b/html/fruitvote/main.go @@ -1,9 +1,13 @@ package main import ( + "database/sql" + "encoding/json" "flag" "fmt" + _ "github.com/mattn/go-sqlite3" "log" + "math" "net" "net/http" "os" @@ -11,51 +15,241 @@ import ( "os/signal" "strings" "syscall" + "text/template" ) -func indexHandler(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("Hello, this is a Unix socket HTTP server in Go!")) +type Fruit struct { + Name string `json:"name"` + Img string `json:"img"` + Elo int `json:"elo"` } -func healthCheckHandler(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("healthy")) +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() { - socketPath, users := getArgs() - os.Remove(socketPath) + log.Println("starting server...") + log.SetFlags(log.LstdFlags | log.Lshortfile) + + context := getArgs() + log.Println("removing socket file", context.socketPath) + os.Remove(context.socketPath) - listener, err := net.Listen("unix", 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) } - os.Chmod(socketPath, 0700) + 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) { - // Wait for a SIGINT or SIGKILL: sig := <-c - log.Printf("Caught signal %s: shutting down.", sig) + log.Printf("caught signal %s: shutting down.", sig) listener.Close() os.Exit(0) }(sigc) defer listener.Close() - for _, user := range strings.Split(users, ",") { - setACL(socketPath, user) + 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("/", indexHandler) - mux.HandleFunc("/health", healthCheckHandler) + 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 { @@ -63,15 +257,34 @@ func setACL(socketPath, user string) { } } -func getArgs() (string, string) { +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) + } - return *socketPath, *users + 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, + } } |