291 lines
7.2 KiB
Go
291 lines
7.2 KiB
Go
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,
|
|
}
|
|
}
|