commit 22020ba873a9ef8562c1441d6f08401238afc4b4 Author: Alexander Khodyrev Date: Sat Sep 14 11:10:27 2024 +0300 initial version flake largely copied from the new birdtown-visit-counter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5e1b27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/gomod2nix-template +.envrc +.direnv diff --git a/birdtown-leaderboard.go b/birdtown-leaderboard.go new file mode 100644 index 0000000..0d9afe2 --- /dev/null +++ b/birdtown-leaderboard.go @@ -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) +} diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..22c4522 --- /dev/null +++ b/default.nix @@ -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; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3166dd0 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..805f831 --- /dev/null +++ b/flake.nix @@ -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; + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0eb6e9a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.akho.name/akho/birdtown-visit-counter + +go 1.22 diff --git a/gomod2nix.toml b/gomod2nix.toml new file mode 100644 index 0000000..43cd4cf --- /dev/null +++ b/gomod2nix.toml @@ -0,0 +1,3 @@ +schema = 3 + +[mod] diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..ab72918 --- /dev/null +++ b/module.nix @@ -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"; + }; + }; + }; +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..4b6de19 --- /dev/null +++ b/shell.nix @@ -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 + ]; +}