diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1fc716a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.env +.git +.gitignore +api +README.md +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed28ee7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o api . + +FROM alpine:latest + +RUN apk --no-cache add ca-certificates wget +WORKDIR /app + +COPY --from=builder /app/api . + +EXPOSE 8080 +ENV PORT=8080 + +CMD ["./api"] diff --git a/api b/api new file mode 100755 index 0000000..92b5bce Binary files /dev/null and b/api differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2dc398b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + hypixel-bans-api: + build: . + container_name: hypixel-bans-api + restart: unless-stopped + env_file: .env + ports: + - "127.0.0.1:8081:8080" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s diff --git a/main.go b/main.go index 4f07749..8af06ec 100644 --- a/main.go +++ b/main.go @@ -3,31 +3,55 @@ package main import ( "encoding/json" "fmt" + "log" "net/http" "os" + "sync" "time" "github.com/joho/godotenv" ) +// PunishmentStats mirrors Hypixel's /punishmentstats response. type PunishmentStats struct { Success bool `json:"success"` - WatchdogLastMinute int `json:"watchdog_lastMinute"` - WatchdogRollingDaily int `json:"watchdog_rollingDaily"` + WatchdogLastMinute int64 `json:"watchdog_lastMinute"` + WatchdogRollingDaily int64 `json:"watchdog_rollingDaily"` WatchdogTotal int64 `json:"watchdog_total"` - StaffRollingDaily int `json:"staff_rollingDaily"` + StaffRollingDaily int64 `json:"staff_rollingDaily"` StaffTotal int64 `json:"staff_total"` } -func fetchStats(apiKey string) (*PunishmentStats, error) { - req, err := http.NewRequest("GET", "https://api.hypixel.net/v2/punishmentstats", nil) +type cachedStats struct { + stats *PunishmentStats + fetchedAt time.Time + fetchError error +} + +type server struct { + apiKey string + client *http.Client + cacheTTL time.Duration + cacheMu sync.RWMutex + cache cachedStats +} + +func newServer(apiKey string, cacheTTL time.Duration) *server { + return &server{ + apiKey: apiKey, + client: &http.Client{Timeout: 10 * time.Second}, + cacheTTL: cacheTTL, + } +} + +func (s *server) fetchStats() (*PunishmentStats, error) { + req, err := http.NewRequest(http.MethodGet, "https://api.hypixel.net/v2/punishmentstats", nil) if err != nil { return nil, err } - req.Header.Set("API-Key", apiKey) + req.Header.Set("API-Key", s.apiKey) - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) + resp, err := s.client.Do(req) if err != nil { return nil, err } @@ -41,16 +65,76 @@ func fetchStats(apiKey string) (*PunishmentStats, error) { if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { return nil, err } + if !stats.Success { + return nil, fmt.Errorf("hypixel api reported failure") + } return &stats, nil } +func (s *server) getStats() (*PunishmentStats, error) { + s.cacheMu.RLock() + cache := s.cache + s.cacheMu.RUnlock() + + if cache.stats != nil && time.Since(cache.fetchedAt) < s.cacheTTL { + return cache.stats, nil + } + + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + // Double-check after acquiring write lock. + if s.cache.stats != nil && time.Since(s.cache.fetchedAt) < s.cacheTTL { + return s.cache.stats, nil + } + + stats, err := s.fetchStats() + s.cache = cachedStats{stats: stats, fetchedAt: time.Now(), fetchError: err} + if err != nil { + return nil, err + } + return stats, nil +} + +func (s *server) handleStats(w http.ResponseWriter, r *http.Request) { + stats, err := s.getStats() + if err != nil { + http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + if err := json.NewEncoder(w).Encode(stats); err != nil { + log.Printf("encode response: %v", err) + } +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"ok"}`) +} + func main() { _ = godotenv.Load() - key := os.Getenv("HYPIXEL_API_KEY") - stats, err := fetchStats(key) - if err != nil { - fmt.Println("error:", err) - os.Exit(1) + + apiKey := os.Getenv("HYPIXEL_API_KEY") + if apiKey == "" { + log.Fatal("HYPIXEL_API_KEY is required") + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + srv := newServer(apiKey, 10*time.Second) + + http.HandleFunc("/health", handleHealth) + http.HandleFunc("/api/stats", srv.handleStats) + + log.Printf("api listening on :%s", port) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("server error: %v", err) } - fmt.Printf("%+v\n", stats) }