diff --git a/.gitignore b/.gitignore index 3739dcd..a2e4405 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ gemini-server gemini_site .env +request_counts.json diff --git a/counter.go b/counter.go new file mode 100644 index 0000000..fa432c3 --- /dev/null +++ b/counter.go @@ -0,0 +1,137 @@ +package main + +import ( + "encoding/json" + "log/slog" + "os" + "sync" + "time" +) + +// RequestCounter tracks request counts per path in memory with periodic snapshots +type RequestCounter struct { + counts map[string]int64 + mu sync.RWMutex + snapshotPath string + stopChan chan struct{} + wg sync.WaitGroup +} + +// NewRequestCounter creates a new request counter with periodic snapshots +func NewRequestCounter(snapshotPath string, snapshotInterval time.Duration) (*RequestCounter, error) { + c := &RequestCounter{ + counts: make(map[string]int64), + snapshotPath: snapshotPath, + stopChan: make(chan struct{}), + } + + // Load existing counts from snapshot file + if err := c.load(); err != nil { + slog.Warn("failed to load counter snapshot, starting fresh", "error", err, "path", snapshotPath) + } + + // Start periodic snapshot goroutine + c.wg.Add(1) + go c.periodicSnapshot(snapshotInterval) + + return c, nil +} + +// Increment increments the counter for a given path and returns the new count +func (c *RequestCounter) Increment(path string) int64 { + c.mu.Lock() + defer c.mu.Unlock() + c.counts[path]++ + return c.counts[path] +} + +// Get returns the current count for a given path +func (c *RequestCounter) Get(path string) int64 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.counts[path] +} + +func (c *RequestCounter) GetTotal() int64 { + c.mu.Lock() + defer c.mu.Unlock() + var total int64 + for _, count := range c.counts { + total += count + } + return total +} + +// load reads the snapshot file and loads counts into memory +func (c *RequestCounter) load() error { + data, err := os.ReadFile(c.snapshotPath) + if err != nil { + if os.IsNotExist(err) { + return nil // File doesn't exist yet, that's okay + } + return err + } + + c.mu.Lock() + defer c.mu.Unlock() + + if err := json.Unmarshal(data, &c.counts); err != nil { + return err + } + + slog.Info("loaded request counter snapshot", "paths", len(c.counts), "file", c.snapshotPath) + return nil +} + +// snapshot writes current counts to disk +func (c *RequestCounter) snapshot() error { + c.mu.RLock() + data, err := json.MarshalIndent(c.counts, "", " ") + c.mu.RUnlock() + + if err != nil { + return err + } + + // Write to temp file first, then rename for atomicity + tempPath := c.snapshotPath + ".tmp" + if err := os.WriteFile(tempPath, data, 0644); err != nil { + return err + } + + if err := os.Rename(tempPath, c.snapshotPath); err != nil { + return err + } + + slog.Debug("saved request counter snapshot", "paths", len(c.counts), "file", c.snapshotPath) + return nil +} + +// periodicSnapshot runs in a goroutine and saves snapshots at regular intervals +func (c *RequestCounter) periodicSnapshot(interval time.Duration) { + defer c.wg.Done() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := c.snapshot(); err != nil { + slog.Error("failed to save counter snapshot", "error", err) + } + case <-c.stopChan: + // Final snapshot before shutdown + if err := c.snapshot(); err != nil { + slog.Error("failed to save final counter snapshot", "error", err) + } + return + } + } +} + +// Close stops the periodic snapshot goroutine and saves a final snapshot +func (c *RequestCounter) Close() error { + close(c.stopChan) + c.wg.Wait() + return nil +} diff --git a/dev.sh b/dev.sh index a36cc39..5061467 100755 --- a/dev.sh +++ b/dev.sh @@ -4,4 +4,4 @@ set -e source .env -fd | entr -r go run main.go -cert=./keys/localhost.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080 +fd | entr -r go run . -cert=./keys/localhost.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080 diff --git a/main.go b/main.go index b19d8eb..5dad5fc 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "database/sql" - "errors" "flag" "fmt" "log/slog" @@ -26,11 +25,17 @@ type MainHandler struct { blog microblog.Handler gemlog gemlog.Handler guestbook guestbook.Handler + db *sql.DB + counter *RequestCounter } func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { + // Increment request counter + count := h.counter.Increment(req.URL.Path) + slog.Info("gemini request", "path", req.URL.Path, + "count", count, "user", strings.Join(userName(req), " ")) // Check if this is a blog request @@ -52,37 +57,40 @@ func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { switch req.URL.Path { case "/": - gemini.ServeFileName("pages/home.gmi", "text/gemini")(w, req) - // err := w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - // requireNoError(err) - // _, err = w.WriteBody([]byte("Hello, world!")) - // requireNoError(err) - case "/user": - if req.Certificate() == nil { - w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication Required") - return - } w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - w.WriteBody([]byte(req.Certificate().Subject.CommonName)) - case "/die": - requireNoError(errors.New("must die")) - case "/file": - gemini.ServeFileName("cmd/example/hello.gmi", "text/gemini")(w, req) - case "/post": - if req.URL.Scheme != gemini.SchemaTitan { - w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - w.WriteBody([]byte("Use titan scheme to upload data")) - return - } - payload, err := req.ReadTitanPayload() + data, err := os.ReadFile("pages/home.gmi") requireNoError(err) - w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - w.WriteBody([]byte("Titan Upload Parameters\r\n")) - w.WriteBody([]byte(fmt.Sprintf("Upload MIME Type: %s\r\n", req.Titan.Mime))) - w.WriteBody([]byte(fmt.Sprintf("Token: %s\r\n", req.Titan.Token))) - w.WriteBody([]byte(fmt.Sprintf("Size: %v\r\n", req.Titan.Size))) - w.WriteBody([]byte("Payload:\r\n")) - w.WriteBody(payload) + page := string(data) + var content strings.Builder + content.WriteString(page) + content.WriteString(fmt.Sprintf("\n\n------ stats: total requests served %d, this page %d ------", h.counter.GetTotal(), h.counter.Get(req.URL.Path))) + w.WriteBody([]byte(content.String())) + // case "/user": + // if req.Certificate() == nil { + // w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication Required") + // return + // } + // w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + // w.WriteBody([]byte(req.Certificate().Subject.CommonName)) + // case "/die": + // requireNoError(errors.New("must die")) + // case "/file": + // gemini.ServeFileName("cmd/example/hello.gmi", "text/gemini")(w, req) + // case "/post": + // if req.URL.Scheme != gemini.SchemaTitan { + // w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + // w.WriteBody([]byte("Use titan scheme to upload data")) + // return + // } + // payload, err := req.ReadTitanPayload() + // requireNoError(err) + // w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + // w.WriteBody([]byte("Titan Upload Parameters\r\n")) + // w.WriteBody([]byte(fmt.Sprintf("Upload MIME Type: %s\r\n", req.Titan.Mime))) + // w.WriteBody([]byte(fmt.Sprintf("Token: %s\r\n", req.Titan.Token))) + // w.WriteBody([]byte(fmt.Sprintf("Size: %v\r\n", req.Titan.Size))) + // w.WriteBody([]byte("Payload:\r\n")) + // w.WriteBody(payload) default: w.WriteStatusMsg(gemini.StatusNotFound, req.URL.Path) @@ -132,11 +140,21 @@ func main() { flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file") flag.Parse() + // Initialize request counter with 30-second snapshot interval + counter, err := NewRequestCounter("request_counts.json", 30*time.Second) + if err != nil { + slog.Error("failed to initialize request counter", "error", err) + os.Exit(1) + } + defer counter.Close() + pbClient := pocketbase.NewPocketBaseClient() handler := MainHandler{ blog: microblog.NewHandler(pbClient), gemlog: gemlog.NewHandler(), guestbook: guestbook.NewGuestBook(db), + db: db, + counter: counter, } err = gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini)) diff --git a/pages/home.gmi b/pages/home.gmi index 492653d..141f934 100644 --- a/pages/home.gmi +++ b/pages/home.gmi @@ -32,6 +32,11 @@ What I'm currently working on: ## Site updates +== 2025.11.07 == + +Added request counter. Scroll to bottom of this page to check it out. + +=> /codeview/raw/personal-gemini-capsule/src/branch/main/counter.go Code for the counter here == 2025.10.07 ==