package main import ( "embed" "encoding/json" "errors" "html/template" "log" "net/http" "os" "strconv" "strings" "sync" ) //go:embed static/* var indexHTML embed.FS type store struct { mu sync.RWMutex value int } func (s *store) get() int { s.mu.RLock() defer s.mu.RUnlock() return s.value } func (s *store) set(v int) { s.mu.Lock() s.value = v s.mu.Unlock() } func main() { secret := os.Getenv("WRITE_SECRET") addr := os.Getenv("ADDR") if cfg, err := loadConfig("config.json"); err != nil { if !errors.Is(err, os.ErrNotExist) { log.Fatalf("load config: %v", err) } } else { if secret == "" { secret = cfg.WriteSecret } if addr == "" { addr = cfg.Addr } } if secret == "" { log.Fatal("WRITE_SECRET is required") } if addr == "" { addr = ":8080" } app := &server{ secret: secret, store: &store{}, } log.Printf("listening on %s", addr) log.Fatal(http.ListenAndServe(addr, app.routes())) } type server struct { secret string store *store } type config struct { WriteSecret string `json:"write_secret"` Addr string `json:"addr"` } func loadConfig(path string) (config, error) { data, err := os.ReadFile(path) if err != nil { return config{}, err } var cfg config if err := json.Unmarshal(data, &cfg); err != nil { return config{}, err } return cfg, nil } func (s *server) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", s.handleIndex) mux.HandleFunc("/favicon.svg", s.handleFavicon) mux.HandleFunc("/write", s.handleWrite) return mux } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } page, err := template.New("index.html").ParseFS(indexHTML, "static/index.html") if err != nil { http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := page.Execute(w, map[string]any{"Watts": s.store.get()}); err != nil { http.Error(w, "render error", http.StatusInternalServerError) } } const faviconSVG = ` ` func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/favicon.svg" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/svg+xml") _, _ = w.Write([]byte(faviconSVG)) } func (s *server) handleWrite(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if got := r.Header.Get("X-Secret"); got == "" || got != s.secret { http.Error(w, "unauthorized", http.StatusUnauthorized) return } value, err := parseWatts(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } s.store.set(value) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"watts": value}) } func parseWatts(r *http.Request) (int, error) { contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "application/json") { var payload struct { Watts *int `json:"watts"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { return 0, errors.New("invalid json") } if payload.Watts == nil { return 0, errors.New("missing watts") } return *payload.Watts, nil } if err := r.ParseForm(); err != nil { return 0, errors.New("invalid form data") } if v := r.FormValue("watts"); v != "" { return strconv.Atoi(v) } return 0, errors.New("missing watts") }