init visit counter
This commit is contained in:
parent
a5e480746e
commit
07aae31703
4 changed files with 187 additions and 31 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ gemini-server
|
||||||
gemini_site
|
gemini_site
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
request_counts.json
|
||||||
|
|
|
||||||
137
counter.go
Normal file
137
counter.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
2
dev.sh
2
dev.sh
|
|
@ -4,4 +4,4 @@ set -e
|
||||||
|
|
||||||
source .env
|
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
|
||||||
|
|
|
||||||
78
main.go
78
main.go
|
|
@ -2,7 +2,6 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
@ -26,11 +25,17 @@ type MainHandler struct {
|
||||||
blog microblog.Handler
|
blog microblog.Handler
|
||||||
gemlog gemlog.Handler
|
gemlog gemlog.Handler
|
||||||
guestbook guestbook.Handler
|
guestbook guestbook.Handler
|
||||||
|
db *sql.DB
|
||||||
|
counter *RequestCounter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) {
|
func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) {
|
||||||
|
// Increment request counter
|
||||||
|
count := h.counter.Increment(req.URL.Path)
|
||||||
|
|
||||||
slog.Info("gemini request",
|
slog.Info("gemini request",
|
||||||
"path", req.URL.Path,
|
"path", req.URL.Path,
|
||||||
|
"count", count,
|
||||||
"user", strings.Join(userName(req), " "))
|
"user", strings.Join(userName(req), " "))
|
||||||
|
|
||||||
// Check if this is a blog request
|
// 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 {
|
switch req.URL.Path {
|
||||||
case "/":
|
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.WriteStatusMsg(gemini.StatusSuccess, "text/gemini")
|
||||||
w.WriteBody([]byte(req.Certificate().Subject.CommonName))
|
data, err := os.ReadFile("pages/home.gmi")
|
||||||
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)
|
requireNoError(err)
|
||||||
w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini")
|
page := string(data)
|
||||||
w.WriteBody([]byte("Titan Upload Parameters\r\n"))
|
var content strings.Builder
|
||||||
w.WriteBody([]byte(fmt.Sprintf("Upload MIME Type: %s\r\n", req.Titan.Mime)))
|
content.WriteString(page)
|
||||||
w.WriteBody([]byte(fmt.Sprintf("Token: %s\r\n", req.Titan.Token)))
|
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(fmt.Sprintf("Size: %v\r\n", req.Titan.Size)))
|
w.WriteBody([]byte(content.String()))
|
||||||
w.WriteBody([]byte("Payload:\r\n"))
|
// case "/user":
|
||||||
w.WriteBody(payload)
|
// 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:
|
default:
|
||||||
w.WriteStatusMsg(gemini.StatusNotFound, req.URL.Path)
|
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.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file")
|
||||||
flag.Parse()
|
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()
|
pbClient := pocketbase.NewPocketBaseClient()
|
||||||
handler := MainHandler{
|
handler := MainHandler{
|
||||||
blog: microblog.NewHandler(pbClient),
|
blog: microblog.NewHandler(pbClient),
|
||||||
gemlog: gemlog.NewHandler(),
|
gemlog: gemlog.NewHandler(),
|
||||||
guestbook: guestbook.NewGuestBook(db),
|
guestbook: guestbook.NewGuestBook(db),
|
||||||
|
db: db,
|
||||||
|
counter: counter,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini))
|
err = gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue