fruitvote
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Elizabeth Hunt 2024-03-10 16:45:26 -06:00
parent 5a85dd89bc
commit def61909c2
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
43 changed files with 534 additions and 21 deletions

2
go.mod
View File

@ -1,3 +1,5 @@
module tilde.club/~simponic
go 1.21.5
require github.com/mattn/go-sqlite3 v1.14.22 // indirect

2
go.sum Normal file
View File

@ -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=

126
html/fruitvote/fruits.json Normal file
View File

@ -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"
}
]

BIN
html/fruitvote/fruitvote Executable file

Binary file not shown.

View File

@ -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,
}
}

View File

@ -0,0 +1,17 @@
<table>
<tr>
<th>picture</th>
<th>name</th>
<th>elo</th>
</tr>
{{ range . }}
<tr>
<td><img src="{{ .Img }}" alt="{{ .Name }}" width="100" height="100"></td>
<td>{{ .Name }}</td>
<td>{{ .Elo }}</td>
</tr>
{{ end }}
</table>
<br />
<a href="/~simponic/fruitvote">back</a>
<br />

View File

@ -0,0 +1,28 @@
<form method="POST">
<div class="fruitvote">
{{ range $i, $fruit := . }}
{{ if $i }}
<div class="versus">
<h1>OR</h1>
</div>
{{ end }}
<label class="contestant">
<input type="radio" name="winner" value="{{ .Name }}" {{ if eq $i 0 }} checked {{ end }} />
<div>
<img src="{{ .Img }}" alt="image" />
<p>{{ .Name }}</p>
</div>
</label>
<input type="hidden" name="contestant[]" value="{{ .Name }}" />
{{ end }}
</div>
<br />
<div>
<input type="submit" value="Vote" />
</div>
</form>
<br />
<div><a href="/~simponic/fruitvote"><button>Skip</button></a></div>
<br />
<div><a href="/~simponic/fruitvote/stats">view rankings!</a></div>

View File

@ -75,3 +75,100 @@ p {
li {
margin-left: 20px;
}
.fruitvote {
display: flex;
flex-direction: row;
margin-top: 20px;
gap: 2rem;
max-width: 800px;
}
.contestant {
display: flex;
flex: 1;
flex-direction: column;
align-items: stretch;
border: 2px solid #ff69b4;
border-radius: 10px;
padding: 0.5rem;
}
.contestant div {
display: flex;
flex-direction: column;
justify-content: space-between;
border-radius: 10px;
height: 100%;
transition: background-color 0.3s ease;
padding: 1rem;
}
.contestant > input {
visibility: hidden;
position: absolute;
}
.contestant div:hover {
background-color: #ff69b4;
color: #2a2a2a;
cursor: pointer;
}
.contestant > input:checked + div {
background-color: #ff69b4;
color: #2a2a2a;
cursor: pointer;
}
.contestant div img {
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
border-radius: 10px;
}
.versus {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
table {
width: auto; /* Adjust based on content, not full width */
border-collapse: collapse;
background-color: #383838; /* Darker background for contrast */
}
th,
td {
padding: 12px 20px; /* Good padding for readability */
border: 1px solid #f4c2c2; /* Soft pink borders */
color: #f4c2c2; /* Soft pink text */
text-align: left;
}
thead th {
background-color: #ff69b4; /* Brighter pink for header */
color: white; /* White text for contrast */
font-family: "Comic Sans MS", "Chalkboard SE", sans-serif;
}
tbody tr:nth-child(odd) {
background-color: #2f2f2f; /* Slightly lighter background for every other row for readability */
}
tbody tr {
transition: background-color 0.3s ease;
}
tbody tr:hover {
background-color: #ff47da; /* Change to a lighter pink on hover for interactivity */
color: #2a2a2a; /* Dark text for contrast */
}

View File

@ -4,7 +4,7 @@ class GoPage {
private $socket;
private $template;
public function __construct($page, $socket = "/home/simponic/fruitvote/http.sock", $template = "../template.html") {
public function __construct($page, $socket = "/home/lizzy/fruitvote/http.sock", $template = "../template.html") {
$this->page = $page;
$this->socket = $socket;
$this->template = $template;
@ -24,9 +24,23 @@ class GoPage {
curl_setopt($ch, CURLOPT_UNIX_SOCKET_PATH, $this->socket);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
// forward query params
$query = $_SERVER['QUERY_STRING'];
if ($query) {
curl_setopt($ch, CURLOPT_URL, $url."?".$query);
}
//forward post data
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('php://input'));
}
$output = curl_exec($ch);
curl_close($ch);
// todo: get headers / cookies, forward back response
return $output;
}

View File

@ -0,0 +1,7 @@
<?php
require_once("GoPage.php");
$page = new GoPage("/health");
echo $page->render();
?>

View File

@ -0,0 +1,7 @@
<?php
require_once("GoPage.php");
$page = new GoPage("/stats");
echo $page->render();
?>

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB