package main import ( "encoding/json" "io/fs" "log" "net/http" "os" "path/filepath" "sort" "strings" "countrygame/web" ) func main() { port := os.Getenv("PORT") if port == "" { port = "8080" } // Used by the compose healthcheck: the distroless image has no curl. if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { resp, err := http.Get("http://127.0.0.1:" + port + "/healthz") if err != nil || resp.StatusCode != http.StatusOK { os.Exit(1) } os.Exit(0) } // Background music: .mp3 files read live from this folder, so adding a track needs no rebuild. musicDir := os.Getenv("MUSIC_DIR") if musicDir == "" { musicDir = "music" } root, err := fs.Sub(web.Files, ".") if err != nil { log.Fatal(err) } log.Printf("country game listening on :%s (music folder %q)", port, musicDir) log.Fatal(http.ListenAndServe(":"+port, newMux(root, musicDir))) } // newMux serves the embedded game, /healthz, and the music folder under /music/. func newMux(root fs.FS, musicDir string) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) mux.HandleFunc("/music/index.json", musicIndex(musicDir)) mux.Handle("/music/", http.StripPrefix("/music/", noCache(http.FileServer(noListing{http.Dir(musicDir)})))) mux.Handle("/", noCache(http.FileServer(http.FS(root)))) return mux } // noCache keeps iteration simple: data and scripts are always revalidated. func noCache(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") next.ServeHTTP(w, r) }) } // noListing hides directory listings and dot files: only the tracks themselves are served. type noListing struct{ fs http.FileSystem } func (n noListing) Open(name string) (http.File, error) { if strings.Contains(filepath.Base(name), "..") || strings.HasPrefix(filepath.Base(name), ".") { return nil, os.ErrNotExist } f, err := n.fs.Open(name) if err != nil { return nil, err } if info, err := f.Stat(); err != nil || info.IsDir() { f.Close() return nil, os.ErrNotExist } return f, nil } // musicIndex lists the playable tracks: non-empty .mp3 files (the empty placeholder is skipped), sorted by name. // A missing folder is not an error: the game simply shows an empty tape deck. func musicIndex(dir string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tracks := []string{} entries, _ := os.ReadDir(dir) for _, e := range entries { name := e.Name() if e.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".mp3") { continue } if info, err := e.Info(); err == nil && info.Size() > 0 { tracks = append(tracks, name) } } sort.Strings(tracks) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") json.NewEncoder(w).Encode(map[string]any{"tracks": tracks}) } }