diff --git a/go.mod b/go.mod index bb8a917..b06e58c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module tilde.club/~simponic go 1.21.5 + +require github.com/mattn/go-sqlite3 v1.14.22 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e8d092a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/html/fruitvote/fruits.json b/html/fruitvote/fruits.json new file mode 100644 index 0000000..a97f846 --- /dev/null +++ b/html/fruitvote/fruits.json @@ -0,0 +1,126 @@ +[ + { + "name": "apple", + "img": "/~simponic/img/fruitvote/dock-april.png" + }, + { + "name": "apricot", + "img": "/~simponic/img/fruitvote/apricot.jpg" + }, + { + "name": "avocado", + "img": "/~simponic/img/fruitvote/avocado.jpg" + }, + { + "name": "banana", + "img": "/~simponic/img/fruitvote/banana.png" + }, + { + "name": "blackberry", + "img": "/~simponic/img/fruitvote/blackberry.jpg" + }, + { + "name": "blueberry", + "img": "/~simponic/img/fruitvote/blueberry.jpg" + }, + { + "name": "cherry", + "img": "/~simponic/img/fruitvote/cherry.jpg" + }, + { + "name": "coconut", + "img": "/~simponic/img/fruitvote/coconut.jpg" + }, + { + "name": "cranberry", + "img": "/~simponic/img/fruitvote/cranberry.jpg" + }, + { + "name": "fig", + "img": "/~simponic/img/fruitvote/fig.jpg" + }, + { + "name": "grape", + "img": "/~simponic/img/fruitvote/grape.jpg" + }, + { + "name": "guava", + "img": "/~simponic/img/fruitvote/guava.jpg" + }, + { + "name": "honeydew", + "img": "/~simponic/img/fruitvote/honeydew.jpeg" + }, + { + "name": "kiwi", + "img": "/~simponic/img/fruitvote/kiwi.jpg" + }, + { + "name": "lemon", + "img": "/~simponic/img/fruitvote/lemon.jpg" + }, + { + "name": "lime", + "img": "/~simponic/img/fruitvote/lime.jpg" + }, + { + "name": "mango", + "img": "/~simponic/img/fruitvote/mango.jpg" + }, + { + "name": "melon", + "img": "/~simponic/img/fruitvote/melon.jpg" + }, + { + "name": "nectarine", + "img": "/~simponic/img/fruitvote/nectarine.jpg" + }, + { + "name": "orange", + "img": "/~simponic/img/fruitvote/orange.jpg" + }, + { + "name": "peach", + "img": "/~simponic/img/fruitvote/peach.jpg" + }, + { + "name": "pear", + "img": "/~simponic/img/fruitvote/pear.jpg" + }, + { + "name": "pineapple", + "img": "/~simponic/img/fruitvote/pineapple.jpg" + }, + { + "name": "persimmon", + "img": "/~simponic/img/fruitvote/persimmon.jpg" + }, + { + "name": "plum", + "img": "/~simponic/img/fruitvote/plum.jpg" + }, + { + "name": "pomegranate", + "img": "/~simponic/img/fruitvote/pomegranate.jpg" + }, + { + "name": "pumpkin", + "img": "/~simponic/img/fruitvote/pumpkin.jpg" + }, + { + "name": "raspberry", + "img": "/~simponic/img/fruitvote/raspberry.jpg" + }, + { + "name": "strawberry", + "img": "/~simponic/img/fruitvote/strawberry.jpg" + }, + { + "name": "tomato", + "img": "/~simponic/img/fruitvote/tomato.jpg" + }, + { + "name": "watermelon", + "img": "/~simponic/img/fruitvote/watermelon.jpg" + } +] diff --git a/html/fruitvote/fruitvote b/html/fruitvote/fruitvote new file mode 100755 index 0000000..51485cf Binary files /dev/null and b/html/fruitvote/fruitvote differ 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 } -func main() { - socketPath, users := getArgs() - os.Remove(socketPath) +type CurriedContextHandler func(*Context, http.ResponseWriter, *http.Request) - listener, err := net.Listen("unix", socketPath) +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) } - os.Chmod(socketPath, 0700) + + 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) { - // 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, + } } diff --git a/html/fruitvote/templates/stats.html b/html/fruitvote/templates/stats.html new file mode 100644 index 0000000..c5fea5c --- /dev/null +++ b/html/fruitvote/templates/stats.html @@ -0,0 +1,17 @@ +
picture | +name | +elo | +
---|---|---|
+ | {{ .Name }} | +{{ .Elo }} | +