initial version
flake largely copied from the new birdtown-visit-counter
This commit is contained in:
commit
22020ba873
9 changed files with 426 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/gomod2nix-template
|
||||
.envrc
|
||||
.direnv
|
||||
193
birdtown-leaderboard.go
Normal file
193
birdtown-leaderboard.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
numScores = 5
|
||||
maxNameLength = 9
|
||||
dailyDiscount = 0.99
|
||||
)
|
||||
|
||||
type scoreStore struct {
|
||||
m map[string]float64
|
||||
*sync.RWMutex
|
||||
}
|
||||
|
||||
type score struct {
|
||||
Player string `json:"player"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
|
||||
func (ds *scoreStore) leaderboardSize() int {
|
||||
l := len(ds.m)
|
||||
if l >= numScores {
|
||||
return numScores
|
||||
} else {
|
||||
return l
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *scoreStore) tops() []score {
|
||||
ds.Lock()
|
||||
defer ds.Unlock()
|
||||
|
||||
// select top scores
|
||||
scores := make([]score, 0, len(ds.m))
|
||||
for p, s := range ds.m {
|
||||
scores = append(scores, score{Player: p, Score: int(math.Round(s))})
|
||||
}
|
||||
slices.SortFunc(scores, func(a, b score) int { return -cmp.Compare(a.Score, b.Score) })
|
||||
scores = scores[:ds.leaderboardSize()]
|
||||
return scores
|
||||
}
|
||||
|
||||
var nonSanitizedRe = regexp.MustCompile(`[^[:word:].-]`)
|
||||
|
||||
func normalizePlayer(p string) string {
|
||||
p = nonSanitizedRe.ReplaceAllString(p, "")
|
||||
chars := 0
|
||||
for i := range p {
|
||||
if chars >= maxNameLength {
|
||||
return p[:i]
|
||||
}
|
||||
chars++
|
||||
}
|
||||
return strings.ToUpper(p)
|
||||
}
|
||||
|
||||
func (ds *scoreStore) update(s score) []score {
|
||||
fs := float64(s.Score)
|
||||
s.Player = normalizePlayer(s.Player)
|
||||
ds.Lock()
|
||||
oldscore, has := ds.m[s.Player]
|
||||
if !has || fs > oldscore {
|
||||
ds.m[s.Player] = fs
|
||||
}
|
||||
ds.Unlock()
|
||||
return ds.tops()
|
||||
}
|
||||
|
||||
type scoreHandler struct {
|
||||
store *scoreStore
|
||||
}
|
||||
|
||||
func (h *scoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/":
|
||||
h.List(w, r)
|
||||
return
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/":
|
||||
h.Create(w, r)
|
||||
return
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/all":
|
||||
h.All(w, r)
|
||||
default:
|
||||
notFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *scoreHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
jsonBytes, err := json.Marshal(h.store.tops())
|
||||
if err != nil {
|
||||
internalServerError(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
|
||||
func (h *scoreHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var s score
|
||||
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
|
||||
internalServerError(w, r)
|
||||
return
|
||||
}
|
||||
top10 := h.store.update(s)
|
||||
jsonBytes, err := json.Marshal(top10)
|
||||
if err != nil {
|
||||
internalServerError(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
|
||||
func (h *scoreHandler) All(w http.ResponseWriter, r *http.Request) {
|
||||
h.store.Lock()
|
||||
jsonBytes, err := json.Marshal(h.store.m)
|
||||
h.store.Unlock()
|
||||
if err != nil {
|
||||
internalServerError(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonBytes)
|
||||
}
|
||||
|
||||
func internalServerError(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("internal server error"))
|
||||
}
|
||||
|
||||
func notFound(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("not found"))
|
||||
}
|
||||
|
||||
func scheduledDiscount(ss scoreStore) {
|
||||
for range time.Tick(24 * time.Hour) {
|
||||
ss.Lock()
|
||||
for k, v := range ss.m {
|
||||
ss.m[k] = v * dailyDiscount
|
||||
}
|
||||
ss.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":11567", "Listen address")
|
||||
initialFile := flag.String("file", "scores.json", "File with initial data in JSON format")
|
||||
flag.Parse()
|
||||
|
||||
initialScoresJson, err := os.ReadFile(*initialFile)
|
||||
initialStore := make(map[string]float64)
|
||||
if err == nil {
|
||||
json.Unmarshal(initialScoresJson, &initialStore)
|
||||
} else {
|
||||
log.Println("initial file", *initialFile, "could not be read, defaulting to empty data")
|
||||
}
|
||||
|
||||
store := scoreStore{m: initialStore, RWMutex: &sync.RWMutex{}}
|
||||
scoreH := &scoreHandler{
|
||||
store: &store,
|
||||
}
|
||||
|
||||
go scheduledDiscount(store)
|
||||
|
||||
http.Handle("/", scoreH)
|
||||
http.Handle("/all", scoreH)
|
||||
|
||||
http.ListenAndServe(*addr, nil)
|
||||
}
|
||||
21
default.nix
Normal file
21
default.nix
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{ pkgs ? (
|
||||
let
|
||||
inherit (builtins) fetchTree fromJSON readFile;
|
||||
inherit ((fromJSON (readFile ./flake.lock)).nodes) nixpkgs gomod2nix;
|
||||
in
|
||||
import (fetchTree nixpkgs.locked) {
|
||||
overlays = [
|
||||
(import "${fetchTree gomod2nix.locked}/overlay.nix")
|
||||
];
|
||||
}
|
||||
)
|
||||
, buildGoApplication ? pkgs.buildGoApplication
|
||||
}:
|
||||
|
||||
buildGoApplication {
|
||||
pname = "birdtown-leaderboard";
|
||||
version = "0.1";
|
||||
pwd = ./.;
|
||||
src = ./.;
|
||||
modules = ./gomod2nix.toml;
|
||||
}
|
||||
85
flake.lock
generated
Normal file
85
flake.lock
generated
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gomod2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1725515722,
|
||||
"narHash": "sha256-+gljgHaflZhQXtr3WjJrGn8NXv7MruVPAORSufuCFnw=",
|
||||
"owner": "nix-community",
|
||||
"repo": "gomod2nix",
|
||||
"rev": "1c6fd4e862bf2f249c9114ad625c64c6c29a8a08",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "gomod2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1725983898,
|
||||
"narHash": "sha256-4b3A9zPpxAxLnkF9MawJNHDtOOl6ruL0r6Og1TEDGCE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1355a0cbfeac61d785b7183c0caaec1f97361b43",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"gomod2nix": "gomod2nix",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
26
flake.nix
Normal file
26
flake.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
description = "birdtown-visit-counter";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
inputs.gomod2nix.url = "github:nix-community/gomod2nix";
|
||||
inputs.gomod2nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
inputs.gomod2nix.inputs.flake-utils.follows = "flake-utils";
|
||||
|
||||
outputs = inputs@{ self, nixpkgs, flake-utils, gomod2nix }:
|
||||
(flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
callPackage =
|
||||
pkgs.darwin.apple_sdk_11_0.callPackage or pkgs.callPackage;
|
||||
in {
|
||||
packages.default = callPackage ./. {
|
||||
inherit (gomod2nix.legacyPackages.${system}) buildGoApplication;
|
||||
};
|
||||
devShells.default = callPackage ./shell.nix {
|
||||
inherit (gomod2nix.legacyPackages.${system}) mkGoEnv gomod2nix;
|
||||
};
|
||||
})) // {
|
||||
nixosModules.default = import ./module.nix inputs;
|
||||
};
|
||||
}
|
||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module git.akho.name/akho/birdtown-visit-counter
|
||||
|
||||
go 1.22
|
||||
3
gomod2nix.toml
Normal file
3
gomod2nix.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
schema = 3
|
||||
|
||||
[mod]
|
||||
66
module.nix
Normal file
66
module.nix
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
inputs:
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
inherit (pkgs.stdenv.hostPlatform) system;
|
||||
cfg = config.services.birdtown-leaderboard;
|
||||
inherit (lib) types mkOption mkIf;
|
||||
package = inputs.self.packages."${system}".default;
|
||||
in {
|
||||
options.services.birdtown-leaderboard = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
};
|
||||
listenPort = mkOption {
|
||||
type = types.int;
|
||||
default = 11568;
|
||||
};
|
||||
};
|
||||
config = mkIf cfg.enable {
|
||||
users.users.birdtown-leaderboard = {
|
||||
group = "birdtown-leaderboard";
|
||||
home = "/var/lib/birdtown-leaderboard";
|
||||
isSystemUser = true;
|
||||
createHome = true;
|
||||
};
|
||||
users.groups.birdtown-leaderboard = { };
|
||||
|
||||
systemd.services.birdtown-leaderboard = {
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
ExecStart =
|
||||
"${package}/bin/birdtown-leaderboard -addr :${toString cfg.listenPort} -file visits.json";
|
||||
User = "birdtown-leaderboard";
|
||||
Group = "birdtown-leaderboard";
|
||||
WorkingDirectory = "/var/lib/birdtown-leaderboard";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.timers.birdtown-leaderboard-saver = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "10m";
|
||||
OnUnitActiveSec = "10m";
|
||||
Unit = "birdtown-leaderboard-saver.service";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.birdtown-leaderboard-saver = {
|
||||
script = ''
|
||||
set -eu
|
||||
if ${pkgs.curl}/bin/curl http://localhost:${toString cfg.listenPort}/all > scores-new.json; then
|
||||
cp scores-new.json "$(date +"%Y-%m-%d-%H-%M").json"
|
||||
mv scores-new.json scores.json
|
||||
${pkgs.findutils}/bin/find . -ctime +10 -delete
|
||||
fi
|
||||
'';
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "birdtown-leaderboard";
|
||||
Group = "birdtown-leaderboard";
|
||||
WorkingDirectory = "/var/lib/birdtown-leaderboard";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
26
shell.nix
Normal file
26
shell.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{ pkgs ? (
|
||||
let
|
||||
inherit (builtins) fetchTree fromJSON readFile;
|
||||
inherit ((fromJSON (readFile ./flake.lock)).nodes) nixpkgs gomod2nix;
|
||||
in
|
||||
import (fetchTree nixpkgs.locked) {
|
||||
overlays = [
|
||||
(import "${fetchTree gomod2nix.locked}/overlay.nix")
|
||||
];
|
||||
}
|
||||
)
|
||||
, mkGoEnv ? pkgs.mkGoEnv
|
||||
, gomod2nix ? pkgs.gomod2nix
|
||||
}:
|
||||
|
||||
let
|
||||
goEnv = mkGoEnv { pwd = ./.; };
|
||||
in
|
||||
pkgs.mkShell {
|
||||
packages = [
|
||||
goEnv
|
||||
pkgs.go-tools
|
||||
pkgs.gopls
|
||||
gomod2nix
|
||||
];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue