From c3ddd3e23b061780bb5fe8cde340ec9a5e7d6eaa Mon Sep 17 00:00:00 2001 From: XHyperDEVX Date: Sun, 3 May 2026 17:06:48 +0000 Subject: [PATCH 1/3] feat: rebuild webhooktimer with lightweight scheduler, JSON persistence, and minimal UI --- .dockerignore | 8 + .gitignore | 2 + Dockerfile | 39 +-- README.md | 83 +++-- docker-compose.yml | 3 + go.mod | 24 +- go.sum | 57 ---- internal/handlers/handlers.go | 196 ----------- internal/model/model.go | 100 ++++++ internal/models/db.go | 44 --- internal/models/timer.go | 31 -- internal/scheduler/scheduler.go | 316 ++++++++++++++++++ internal/server/server.go | 402 +++++++++++++++++++++++ internal/store/store.go | 267 +++++++++++++++ internal/timer/manager.go | 283 ---------------- main.go | 127 ++++--- web/index.html | 564 ++++++++++++++++++++++++++++++++ web/templates/index.html | 419 ------------------------ 18 files changed, 1802 insertions(+), 1163 deletions(-) create mode 100644 .dockerignore delete mode 100644 go.sum delete mode 100644 internal/handlers/handlers.go create mode 100644 internal/model/model.go delete mode 100644 internal/models/db.go delete mode 100644 internal/models/timer.go create mode 100644 internal/scheduler/scheduler.go create mode 100644 internal/server/server.go create mode 100644 internal/store/store.go delete mode 100644 internal/timer/manager.go create mode 100644 web/index.html delete mode 100644 web/templates/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0acb23b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +.github +/data +/bin +/vendor +*.db +*.log diff --git a/.gitignore b/.gitignore index b582144..e61b426 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ webhooktimer *.exe *.test .DS_Store +/data/ +*.corrupt-* diff --git a/Dockerfile b/Dockerfile index f5325d3..8c11009 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,40 +1,25 @@ -# Build stage -FROM golang:alpine AS builder +FROM golang:1.22-alpine AS builder -WORKDIR /app +WORKDIR /src -# Install build dependencies -RUN apk add --no-cache git +RUN apk add --no-cache ca-certificates -# Copy go mod and sum files -COPY go.mod go.sum ./ - -# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed +COPY go.mod ./ RUN go mod download -# Copy the source from the current directory to the Working Directory inside the container COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/webhooktimer . -# Build the Go app -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o webhooktimer . - -# Final stage -FROM alpine:latest - -RUN apk --no-cache add ca-certificates tzdata - -WORKDIR /app +FROM scratch -# Copy the Pre-built binary file from the previous stage -COPY --from=builder /app/webhooktimer . -COPY --from=builder /app/web ./web +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /out/webhooktimer /webhooktimer -# Expose port 8080 to the outside world EXPOSE 8080 +VOLUME ["/data"] -# Environment variables ENV PORT=8080 -ENV DB_PATH=/data/timers.db +ENV STATE_PATH=/data/state.json +ENV TZ=UTC -# Command to run the executable -CMD ["./webhooktimer"] +ENTRYPOINT ["/webhooktimer"] diff --git a/README.md b/README.md index 410725b..2385a06 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,56 @@ # Webhook Timer -A minimalistic timer for webhooks, designed specifically for N8N Webhooks. - -## Features -- Minimalistic Go-based daemon -- SQLite persistence for configuration -- Web-UI for managing timers -- Support for Fixed and Random intervals -- WebSocket for live countdown updates -- Ultra-lightweight Docker Image (~26MB) - -## ⚠️ AI GENERATED PROJECT ⚠️ - -> [!WARNING] -> This project was **100% generated by AI** and is **not actively maintained**. Use it at your own risk. - -## Deployment - -### Docker Compose -```yaml -services: - timerhook: - image: ghcr.io/xhyperdevx/webhooktimer:latest - ports: - - "8080:8080" - volumes: - - ./data:/data - restart: unless-stopped +A tiny 24/7 webhook scheduler with a minimal web UI. + +## What this rebuild includes + +- Extremely simple UI (single page, no frontend dependencies) +- Fixed or random intervals +- Sleep window (no executions during configured quiet time) +- Per-entry enable/disable +- Live countdown to next execution +- Last execution status and timestamp +- Per-entry execution log (last 10) +- **Run now** button with immediate success/error feedback +- Atomic JSON state persistence (new format, old DB files are intentionally not reused) + +## Why it is lightweight + +- Pure Go + standard library only +- No JS framework, no CSS framework, no websocket dependency +- Static binary in a `scratch` container image + +## Run locally + +```bash +go run . ``` -## Local Development -1. Install Go 1.23+ -2. `go run main.go` -3. Access UI at `http://localhost:8080` +Open: `http://localhost:8080` + +## Docker + +```bash +docker compose up --build +``` + +### Environment variables + +- `PORT` (default `8080`) +- `STATE_PATH` (default `/data/state.json`) +- `TZ` (default `UTC`, used for sleep window calculations) + +## API (used by the UI) + +- `GET /api/entries` +- `POST /api/entries` +- `PUT /api/entries/{id}` +- `DELETE /api/entries/{id}` +- `POST /api/entries/{id}/toggle` +- `POST /api/entries/{id}/execute` +- `GET /api/entries/{id}/logs` + +## Notes + +- The new persistence format is JSON, not SQLite. +- If an old database exists from previous versions, it is ignored by design. diff --git a/docker-compose.yml b/docker-compose.yml index ecbfa44..3e06bf6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,9 @@ services: build: . ports: - "8080:8080" + environment: + - TZ=UTC + - STATE_PATH=/data/state.json volumes: - ./data:/data restart: unless-stopped diff --git a/go.mod b/go.mod index c21480f..ab778c5 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,3 @@ module webhooktimer -go 1.25.0 - -require ( - github.com/go-chi/chi/v5 v5.2.5 - github.com/google/uuid v1.6.0 - github.com/gorilla/websocket v1.5.3 - modernc.org/sqlite v1.30.0 -) - -require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.42.0 // indirect - modernc.org/gc/v3 v3.1.2 // indirect - modernc.org/libc v1.70.0 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect - modernc.org/strutil v1.2.1 // indirect - modernc.org/token v1.1.0 // indirect -) +go 1.22 diff --git a/go.sum b/go.sum deleted file mode 100644 index f458a3f..0000000 --- a/go.sum +++ /dev/null @@ -1,57 +0,0 @@ -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= -modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= -modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.30.0 h1:8YhPUs/HTnlEgErn/jSYQTwHN/ex8CjHHjg+K9iG7LM= -modernc.org/sqlite v1.30.0/go.mod h1:cgkTARJ9ugeXSNaLBPK3CqbOe7Ec7ZhWPoMFGldEYEw= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go deleted file mode 100644 index ceba8fe..0000000 --- a/internal/handlers/handlers.go +++ /dev/null @@ -1,196 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "sync" - "webhooktimer/internal/models" - "webhooktimer/internal/timer" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "github.com/gorilla/websocket" -) - -type Handler struct { - Manager *timer.Manager - clients map[*websocket.Conn]bool - mu sync.Mutex -} - -func NewHandler(m *timer.Manager) *Handler { - h := &Handler{ - Manager: m, - clients: make(map[*websocket.Conn]bool), - } - m.OnUpdate = h.broadcastTimers - return h -} - -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, -} - -func (h *Handler) broadcastTimers(_ string) { - h.mu.Lock() - defer h.mu.Unlock() - timers := h.Manager.GetTimers() - for client := range h.clients { - err := client.WriteJSON(timers) - if err != nil { - client.Close() - delete(h.clients, client) - } - } -} - -func (h *Handler) GetTimers(w http.ResponseWriter, r *http.Request) { - timers := h.Manager.GetTimers() - json.NewEncoder(w).Encode(timers) -} - -func (h *Handler) CreateTimer(w http.ResponseWriter, r *http.Request) { - var t models.TimerEntry - if err := json.NewDecoder(r.Body).Decode(&t); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - t.ID = uuid.New().String() - if t.WebhookTimeout == 0 { - t.WebhookTimeout = 5 - } - if t.Method == "" { - t.Method = "POST" - } - - if t.Type == "" { - t.Type = "other" - } - - _, err := models.DB.Exec("INSERT INTO timers (id, name, webhook_url, mode, fixed_interval, min_interval, max_interval, active, webhook_timeout, method, type, sleep_time_start, sleep_time_end) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - t.ID, t.Name, t.WebhookURL, t.Mode, t.FixedInterval, t.MinInterval, t.MaxInterval, t.Active, t.WebhookTimeout, t.Method, t.Type, t.SleepTimeStart, t.SleepTimeEnd) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - h.Manager.UpdateTimer(&t) - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(t) -} - -func (h *Handler) UpdateTimer(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - var t models.TimerEntry - if err := json.NewDecoder(r.Body).Decode(&t); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - t.ID = id - - _, err := models.DB.Exec("UPDATE timers SET name = ?, webhook_url = ?, mode = ?, fixed_interval = ?, min_interval = ?, max_interval = ?, active = ?, webhook_timeout = ?, method = ?, type = ?, sleep_time_start = ?, sleep_time_end = ? WHERE id = ?", - t.Name, t.WebhookURL, t.Mode, t.FixedInterval, t.MinInterval, t.MaxInterval, t.Active, t.WebhookTimeout, t.Method, t.Type, t.SleepTimeStart, t.SleepTimeEnd, id) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - h.Manager.UpdateTimer(&t) - json.NewEncoder(w).Encode(t) -} - -func (h *Handler) DeleteTimer(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - _, err := models.DB.Exec("DELETE FROM timers WHERE id = ?", id) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - h.Manager.DeleteTimer(id) - h.broadcastTimers("") - w.WriteHeader(http.StatusNoContent) -} - -func (h *Handler) CallNow(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - h.Manager.CallNow(id) - w.WriteHeader(http.StatusNoContent) -} - -func (h *Handler) ToggleTimer(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - var body struct { - Active bool `json:"active"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - _, err := models.DB.Exec("UPDATE timers SET active = ? WHERE id = ?", body.Active, id) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // Fetch updated timer - var t models.TimerEntry - row := models.DB.QueryRow("SELECT id, name, webhook_url, mode, fixed_interval, min_interval, max_interval, active, last_execution, webhook_timeout, method, type, sleep_time_start, sleep_time_end FROM timers WHERE id = ?", id) - err = row.Scan(&t.ID, &t.Name, &t.WebhookURL, &t.Mode, &t.FixedInterval, &t.MinInterval, &t.MaxInterval, &t.Active, &t.LastExecution, &t.WebhookTimeout, &t.Method, &t.Type, &t.SleepTimeStart, &t.SleepTimeEnd) - if err == nil { - h.Manager.UpdateTimer(&t) - } - - w.WriteHeader(http.StatusNoContent) -} - -func (h *Handler) GetLogs(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - rows, err := models.DB.Query("SELECT id, timer_id, timestamp, status, message FROM logs WHERE timer_id = ? ORDER BY timestamp DESC LIMIT 3", id) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer rows.Close() - - logs := []models.LogEntry{} - for rows.Next() { - var l models.LogEntry - if err := rows.Scan(&l.ID, &l.TimerID, &l.Timestamp, &l.Status, &l.Message); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - logs = append(logs, l) - } - json.NewEncoder(w).Encode(logs) -} - -func (h *Handler) HandleWS(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - h.mu.Lock() - h.clients[conn] = true - h.mu.Unlock() - - defer func() { - h.mu.Lock() - delete(h.clients, conn) - h.mu.Unlock() - conn.Close() - }() - - // Initial push - timers := h.Manager.GetTimers() - if err := conn.WriteJSON(timers); err != nil { - return - } - - // Keep connection open - for { - if _, _, err := conn.ReadMessage(); err != nil { - break - } - } -} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..8a21488 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,100 @@ +package model + +import "time" + +type Mode string + +const ( + ModeFixed Mode = "fixed" + ModeRandom Mode = "random" +) + +type Entry struct { + ID string `json:"id"` + Name string `json:"name"` + WebhookURL string `json:"webhookURL"` + Method string `json:"method"` + Mode Mode `json:"mode"` + FixedSeconds int `json:"fixedSeconds"` + RandomMin int `json:"randomMin"` + RandomMax int `json:"randomMax"` + SleepEnabled bool `json:"sleepEnabled"` + SleepStart string `json:"sleepStart"` + SleepEnd string `json:"sleepEnd"` + TimeoutSeconds int `json:"timeoutSeconds"` + Active bool `json:"active"` + LastExecution time.Time `json:"lastExecution"` + LastResult string `json:"lastResult"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + NextExecutionAt time.Time `json:"-"` +} + +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Trigger string `json:"trigger"` + Success bool `json:"success"` + StatusCode int `json:"statusCode"` + DurationMS int64 `json:"durationMs"` + Message string `json:"message"` +} + +type ExecuteResult struct { + Timestamp time.Time + Trigger string + Success bool + StatusCode int + DurationMS int64 + Message string +} + +type APIEntry struct { + ID string `json:"id"` + Name string `json:"name"` + WebhookURL string `json:"webhookURL"` + Method string `json:"method"` + Mode Mode `json:"mode"` + FixedSeconds int `json:"fixedSeconds"` + RandomMin int `json:"randomMin"` + RandomMax int `json:"randomMax"` + SleepEnabled bool `json:"sleepEnabled"` + SleepStart string `json:"sleepStart"` + SleepEnd string `json:"sleepEnd"` + TimeoutSeconds int `json:"timeoutSeconds"` + Active bool `json:"active"` + LastExecution *time.Time `json:"lastExecution"` + LastResult string `json:"lastResult"` + NextExecution *time.Time `json:"nextExecution"` + Logs []LogEntry `json:"logs"` +} + +func ToAPIEntry(entry Entry, logs []LogEntry) APIEntry { + api := APIEntry{ + ID: entry.ID, + Name: entry.Name, + WebhookURL: entry.WebhookURL, + Method: entry.Method, + Mode: entry.Mode, + FixedSeconds: entry.FixedSeconds, + RandomMin: entry.RandomMin, + RandomMax: entry.RandomMax, + SleepEnabled: entry.SleepEnabled, + SleepStart: entry.SleepStart, + SleepEnd: entry.SleepEnd, + TimeoutSeconds: entry.TimeoutSeconds, + Active: entry.Active, + LastResult: entry.LastResult, + Logs: logs, + } + + if !entry.LastExecution.IsZero() { + last := entry.LastExecution + api.LastExecution = &last + } + if !entry.NextExecutionAt.IsZero() { + next := entry.NextExecutionAt + api.NextExecution = &next + } + + return api +} diff --git a/internal/models/db.go b/internal/models/db.go deleted file mode 100644 index 33c4e1b..0000000 --- a/internal/models/db.go +++ /dev/null @@ -1,44 +0,0 @@ -package models - -import ( - "database/sql" - _ "modernc.org/sqlite" -) - -var DB *sql.DB - -func InitDB(dataSourceName string) error { - var err error - DB, err = sql.Open("sqlite", dataSourceName) - if err != nil { - return err - } - - _, err = DB.Exec(` - CREATE TABLE IF NOT EXISTS timers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - webhook_url TEXT NOT NULL, - mode TEXT NOT NULL, - fixed_interval INTEGER, - min_interval INTEGER, - max_interval INTEGER, - active BOOLEAN DEFAULT TRUE, - last_execution DATETIME, - webhook_timeout INTEGER DEFAULT 5, - method TEXT DEFAULT 'POST', - type TEXT DEFAULT 'other', - sleep_time_start TEXT, - sleep_time_end TEXT - ); - CREATE TABLE IF NOT EXISTS logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timer_id TEXT NOT NULL, - timestamp DATETIME NOT NULL, - status TEXT NOT NULL, - message TEXT, - FOREIGN KEY (timer_id) REFERENCES timers(id) ON DELETE CASCADE - ); - `) - return err -} diff --git a/internal/models/timer.go b/internal/models/timer.go deleted file mode 100644 index b3daeba..0000000 --- a/internal/models/timer.go +++ /dev/null @@ -1,31 +0,0 @@ -package models - -import ( - "time" -) - -type TimerEntry struct { - ID string `json:"id"` - Name string `json:"name"` - WebhookURL string `json:"webhookURL"` - Method string `json:"method"` - Type string `json:"type"` // n8n or other - Mode string `json:"mode"` // fixed or random - FixedInterval int `json:"fixedInterval"` - MinInterval int `json:"minInterval"` - MaxInterval int `json:"maxInterval"` - Active bool `json:"active"` - LastExecution time.Time `json:"lastExecution"` - WebhookTimeout int `json:"webhookTimeout"` - NextExecution time.Time `json:"nextExecution"` // Only in RAM - SleepTimeStart string `json:"sleepTimeStart"` // HH:MM format, 24-hour - SleepTimeEnd string `json:"sleepTimeEnd"` // HH:MM format, 24-hour -} - -type LogEntry struct { - ID int `json:"id"` - TimerID string `json:"timerId"` - Timestamp time.Time `json:"timestamp"` - Status string `json:"status"` - Message string `json:"message"` -} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..d7af65b --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,316 @@ +package scheduler + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "net/http" + "sync" + "time" + "webhooktimer/internal/model" + "webhooktimer/internal/store" +) + +type Manager struct { + store *store.Store + location *time.Location + httpClient *http.Client + + jobsMu sync.Mutex + jobs map[string]context.CancelFunc + + execMu sync.Mutex + exec map[string]*sync.Mutex +} + +func New(st *store.Store, location *time.Location) *Manager { + return &Manager{ + store: st, + location: location, + httpClient: &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90 * time.Second, + }, + }, + jobs: make(map[string]context.CancelFunc), + exec: make(map[string]*sync.Mutex), + } +} + +func (m *Manager) Start() { + for _, entry := range m.store.ListEntries() { + if entry.Active { + m.startJob(entry.ID) + } + } +} + +func (m *Manager) Shutdown() { + m.jobsMu.Lock() + defer m.jobsMu.Unlock() + + for id, cancel := range m.jobs { + cancel() + _ = m.store.SetNextExecution(id, time.Time{}) + } + m.jobs = make(map[string]context.CancelFunc) +} + +func (m *Manager) SyncEntry(id string) { + m.stopJob(id) + + entry, ok := m.store.GetEntry(id) + if !ok || !entry.Active { + return + } + + m.startJob(id) +} + +func (m *Manager) RemoveEntry(id string) { + m.stopJob(id) + m.execMu.Lock() + delete(m.exec, id) + m.execMu.Unlock() +} + +func (m *Manager) ExecuteNow(id string) (model.ExecuteResult, error) { + entry, ok := m.store.GetEntry(id) + if !ok { + return model.ExecuteResult{}, store.ErrNotFound + } + + result := m.execute(entry, "manual") + if err := m.store.RecordExecution(id, result); err != nil { + return result, err + } + + return result, nil +} + +func (m *Manager) startJob(id string) { + ctx, cancel := context.WithCancel(context.Background()) + + m.jobsMu.Lock() + if existing, ok := m.jobs[id]; ok { + existing() + } + m.jobs[id] = cancel + m.jobsMu.Unlock() + + go m.runJob(ctx, id) +} + +func (m *Manager) stopJob(id string) { + m.jobsMu.Lock() + if cancel, ok := m.jobs[id]; ok { + cancel() + delete(m.jobs, id) + } + m.jobsMu.Unlock() + + _ = m.store.SetNextExecution(id, time.Time{}) +} + +func (m *Manager) runJob(ctx context.Context, id string) { + for { + entry, ok := m.store.GetEntry(id) + if !ok || !entry.Active { + _ = m.store.SetNextExecution(id, time.Time{}) + return + } + + next := m.nextExecution(entry) + _ = m.store.SetNextExecution(id, next) + + wait := time.Until(next) + if wait < time.Second { + wait = time.Second + } + + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + _ = m.store.SetNextExecution(id, time.Time{}) + return + case <-timer.C: + } + + entry, ok = m.store.GetEntry(id) + if !ok || !entry.Active { + continue + } + + result := m.execute(entry, "scheduled") + if err := m.store.RecordExecution(id, result); err != nil && !errors.Is(err, store.ErrNotFound) { + continue + } + } +} + +func (m *Manager) nextExecution(entry model.Entry) time.Time { + now := time.Now().In(m.location) + interval := m.pickInterval(entry) + candidate := now.Add(interval) + + if entry.SleepEnabled { + start, end, ok := parseHHMM(entry.SleepStart, entry.SleepEnd) + if ok && start != end { + minutes := candidate.Hour()*60 + candidate.Minute() + if isInSleepWindow(minutes, start, end) { + candidate = sleepEndTime(candidate, start, end) + } + } + } + + if candidate.Before(now.Add(time.Second)) { + candidate = now.Add(time.Second) + } + + return candidate.UTC() +} + +func (m *Manager) pickInterval(entry model.Entry) time.Duration { + if entry.Mode == model.ModeRandom { + min := entry.RandomMin + max := entry.RandomMax + if min < 1 { + min = 1 + } + if max < min { + max = min + } + return time.Duration(min+rand.IntN(max-min+1)) * time.Second + } + + seconds := entry.FixedSeconds + if seconds < 1 { + seconds = 1 + } + return time.Duration(seconds) * time.Second +} + +func (m *Manager) execute(entry model.Entry, trigger string) model.ExecuteResult { + lock := m.execLock(entry.ID) + lock.Lock() + defer lock.Unlock() + + timeout := time.Duration(entry.TimeoutSeconds) * time.Second + if timeout < time.Second { + timeout = 10 * time.Second + } + + start := time.Now().UTC() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, entry.Method, entry.WebhookURL, nil) + if err != nil { + return model.ExecuteResult{ + Timestamp: start, + Trigger: trigger, + Success: false, + StatusCode: 0, + DurationMS: 0, + Message: err.Error(), + } + } + req.Header.Set("User-Agent", "webhooktimer/2") + + resp, err := m.httpClient.Do(req) + duration := time.Since(start).Milliseconds() + if err != nil { + return model.ExecuteResult{ + Timestamp: time.Now().UTC(), + Trigger: trigger, + Success: false, + StatusCode: 0, + DurationMS: duration, + Message: err.Error(), + } + } + defer resp.Body.Close() + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + message := fmt.Sprintf("HTTP %d", resp.StatusCode) + if !success { + message = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + + return model.ExecuteResult{ + Timestamp: time.Now().UTC(), + Trigger: trigger, + Success: success, + StatusCode: resp.StatusCode, + DurationMS: duration, + Message: message, + } +} + +func (m *Manager) execLock(id string) *sync.Mutex { + m.execMu.Lock() + defer m.execMu.Unlock() + + if lock, ok := m.exec[id]; ok { + return lock + } + + lock := &sync.Mutex{} + m.exec[id] = lock + return lock +} + +func parseHHMM(start string, end string) (int, int, bool) { + sh, sm, ok := parseOneHHMM(start) + if !ok { + return 0, 0, false + } + eh, em, ok := parseOneHHMM(end) + if !ok { + return 0, 0, false + } + return sh*60 + sm, eh*60 + em, true +} + +func parseOneHHMM(value string) (int, int, bool) { + if len(value) != 5 || value[2] != ':' { + return 0, 0, false + } + var h, m int + if _, err := fmt.Sscanf(value, "%02d:%02d", &h, &m); err != nil { + return 0, 0, false + } + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, 0, false + } + return h, m, true +} + +func isInSleepWindow(minutes int, start int, end int) bool { + if start < end { + return minutes >= start && minutes < end + } + return minutes >= start || minutes < end +} + +func sleepEndTime(t time.Time, start int, end int) time.Time { + y, m, d := t.Date() + loc := t.Location() + endToday := time.Date(y, m, d, end/60, end%60, 0, 0, loc) + + if start < end { + return endToday + } + + minutes := t.Hour()*60 + t.Minute() + if minutes >= start { + return endToday.Add(24 * time.Hour) + } + + return endToday +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..cf7e6eb --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,402 @@ +package server + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "webhooktimer/internal/model" + "webhooktimer/internal/scheduler" + "webhooktimer/internal/store" +) + +type Server struct { + store *store.Store + scheduler *scheduler.Manager + indexHTML []byte +} + +type entryPayload struct { + Name string `json:"name"` + WebhookURL string `json:"webhookURL"` + Method string `json:"method"` + Mode model.Mode `json:"mode"` + FixedSeconds int `json:"fixedSeconds"` + RandomMin int `json:"randomMin"` + RandomMax int `json:"randomMax"` + SleepEnabled bool `json:"sleepEnabled"` + SleepStart string `json:"sleepStart"` + SleepEnd string `json:"sleepEnd"` + TimeoutSeconds int `json:"timeoutSeconds"` + Active *bool `json:"active"` +} + +type executeResponse struct { + Success bool `json:"success"` + StatusCode int `json:"statusCode"` + DurationMS int64 `json:"durationMs"` + Message string `json:"message"` + ExecutedAt time.Time `json:"executedAt"` +} + +func New(st *store.Store, sched *scheduler.Manager, indexHTML []byte) *Server { + return &Server{store: st, scheduler: sched, indexHTML: indexHTML} +} + +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/", s.handleIndex) + mux.HandleFunc("/healthz", s.handleHealth) + mux.HandleFunc("/api/entries", s.handleEntries) + mux.HandleFunc("/api/entries/", s.handleEntryByID) + + return mux +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(s.indexHTML) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleEntries(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, s.store.APIEntries(10)) + case http.MethodPost: + var payload entryPayload + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + entry, err := entryFromPayload(payload, true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + entry.ID = generateID() + + if err := s.store.UpsertEntry(entry); err != nil { + writeError(w, http.StatusInternalServerError, "could not store entry") + return + } + s.scheduler.SyncEntry(entry.ID) + + stored, _ := s.store.GetEntry(entry.ID) + writeJSON(w, http.StatusCreated, model.ToAPIEntry(stored, s.store.Logs(entry.ID, 10))) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (s *Server) handleEntryByID(w http.ResponseWriter, r *http.Request) { + id, action, ok := parseEntryPath(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + + switch action { + case "": + s.handleEntryCRUD(w, r, id) + case "toggle": + s.handleToggle(w, r, id) + case "execute": + s.handleExecute(w, r, id) + case "logs": + s.handleLogs(w, r, id) + default: + http.NotFound(w, r) + } +} + +func (s *Server) handleEntryCRUD(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodPut: + existing, ok := s.store.GetEntry(id) + if !ok { + writeError(w, http.StatusNotFound, "entry not found") + return + } + + var payload entryPayload + if err := decodeJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + entry, err := entryFromPayload(payload, existing.Active) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + entry.ID = id + + if err := s.store.UpsertEntry(entry); err != nil { + writeError(w, http.StatusInternalServerError, "could not update entry") + return + } + s.scheduler.SyncEntry(id) + + stored, _ := s.store.GetEntry(id) + writeJSON(w, http.StatusOK, model.ToAPIEntry(stored, s.store.Logs(id, 10))) + case http.MethodDelete: + if err := s.store.DeleteEntry(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "entry not found") + return + } + writeError(w, http.StatusInternalServerError, "could not delete entry") + return + } + s.scheduler.RemoveEntry(id) + w.WriteHeader(http.StatusNoContent) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (s *Server) handleToggle(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var body struct { + Active bool `json:"active"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := s.store.SetActive(id, body.Active); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "entry not found") + return + } + writeError(w, http.StatusInternalServerError, "could not update entry") + return + } + s.scheduler.SyncEntry(id) + + entry, _ := s.store.GetEntry(id) + writeJSON(w, http.StatusOK, model.ToAPIEntry(entry, s.store.Logs(id, 10))) +} + +func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + result, err := s.scheduler.ExecuteNow(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "entry not found") + return + } + writeError(w, http.StatusInternalServerError, "execution failed") + return + } + + writeJSON(w, http.StatusOK, executeResponse{ + Success: result.Success, + StatusCode: result.StatusCode, + DurationMS: result.DurationMS, + Message: result.Message, + ExecutedAt: result.Timestamp, + }) +} + +func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + if _, ok := s.store.GetEntry(id); !ok { + writeError(w, http.StatusNotFound, "entry not found") + return + } + + limit := 10 + if value := r.URL.Query().Get("limit"); value != "" { + n, err := strconv.Atoi(value) + if err != nil || n < 1 || n > 100 { + writeError(w, http.StatusBadRequest, "invalid limit") + return + } + limit = n + } + + writeJSON(w, http.StatusOK, s.store.Logs(id, limit)) +} + +func entryFromPayload(payload entryPayload, defaultActive bool) (model.Entry, error) { + name := strings.TrimSpace(payload.Name) + if name == "" { + return model.Entry{}, errors.New("name is required") + } + + webhook := strings.TrimSpace(payload.WebhookURL) + if webhook == "" { + return model.Entry{}, errors.New("webhookURL is required") + } + parsed, err := url.ParseRequestURI(webhook) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return model.Entry{}, errors.New("webhookURL must be a valid http/https URL") + } + + method := strings.ToUpper(strings.TrimSpace(payload.Method)) + if method == "" { + method = http.MethodPost + } + switch method { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + default: + return model.Entry{}, errors.New("unsupported method") + } + + mode := payload.Mode + if mode == "" { + mode = model.ModeFixed + } + if mode != model.ModeFixed && mode != model.ModeRandom { + return model.Entry{}, errors.New("mode must be fixed or random") + } + + fixed := payload.FixedSeconds + randomMin := payload.RandomMin + randomMax := payload.RandomMax + + switch mode { + case model.ModeFixed: + if fixed < 1 { + return model.Entry{}, errors.New("fixedSeconds must be at least 1") + } + case model.ModeRandom: + if randomMin < 1 || randomMax < 1 { + return model.Entry{}, errors.New("random intervals must be at least 1") + } + if randomMax < randomMin { + return model.Entry{}, errors.New("randomMax must be greater than or equal to randomMin") + } + } + + timeout := payload.TimeoutSeconds + if timeout == 0 { + timeout = 10 + } + if timeout < 1 || timeout > 600 { + return model.Entry{}, errors.New("timeoutSeconds must be between 1 and 600") + } + + sleepStart := "" + sleepEnd := "" + if payload.SleepEnabled { + sleepStart = strings.TrimSpace(payload.SleepStart) + sleepEnd = strings.TrimSpace(payload.SleepEnd) + if !validHHMM(sleepStart) || !validHHMM(sleepEnd) { + return model.Entry{}, errors.New("sleep times must be in HH:MM format") + } + } + + active := defaultActive + if payload.Active != nil { + active = *payload.Active + } + + return model.Entry{ + Name: name, + WebhookURL: webhook, + Method: method, + Mode: mode, + FixedSeconds: fixed, + RandomMin: randomMin, + RandomMax: randomMax, + SleepEnabled: payload.SleepEnabled, + SleepStart: sleepStart, + SleepEnd: sleepEnd, + TimeoutSeconds: timeout, + Active: active, + }, nil +} + +func validHHMM(value string) bool { + if len(value) != 5 { + return false + } + _, err := time.Parse("15:04", value) + return err == nil +} + +func parseEntryPath(path string) (id string, action string, ok bool) { + trimmed := strings.TrimPrefix(path, "/api/entries/") + trimmed = strings.Trim(trimmed, "/") + if trimmed == "" { + return "", "", false + } + + parts := strings.Split(trimmed, "/") + if len(parts) == 1 { + return parts[0], "", true + } + if len(parts) == 2 { + return parts[0], parts[1], true + } + + return "", "", false +} + +func decodeJSON(r *http.Request, target any) error { + defer r.Body.Close() + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + return nil +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func generateID() string { + bytes := make([]byte, 8) + if _, err := rand.Read(bytes); err != nil { + return strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + } + return hex.EncodeToString(bytes) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..68db23a --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,267 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" + "webhooktimer/internal/model" +) + +var ErrNotFound = errors.New("entry not found") + +type Store struct { + mu sync.RWMutex + path string + entries map[string]model.Entry + logs map[string][]model.LogEntry +} + +type persistedState struct { + Entries []model.Entry `json:"entries"` + Logs map[string][]model.LogEntry `json:"logs"` +} + +func New(path string) *Store { + return &Store{ + path: path, + entries: make(map[string]model.Entry), + logs: make(map[string][]model.LogEntry), + } +} + +func (s *Store) Load() error { + raw, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("read state file: %w", err) + } + + var state persistedState + if err := json.Unmarshal(raw, &state); err != nil { + backup := fmt.Sprintf("%s.corrupt-%d", s.path, time.Now().UTC().Unix()) + _ = os.Rename(s.path, backup) + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.entries = make(map[string]model.Entry, len(state.Entries)) + for _, entry := range state.Entries { + s.entries[entry.ID] = entry + } + + if state.Logs == nil { + s.logs = make(map[string][]model.LogEntry) + } else { + s.logs = make(map[string][]model.LogEntry, len(state.Logs)) + for id, logs := range state.Logs { + s.logs[id] = append([]model.LogEntry(nil), logs...) + } + } + + return nil +} + +func (s *Store) ListEntries() []model.Entry { + s.mu.RLock() + defer s.mu.RUnlock() + + entries := make([]model.Entry, 0, len(s.entries)) + for _, entry := range s.entries { + entries = append(entries, entry) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name < entries[j].Name + }) + + return entries +} + +func (s *Store) APIEntries(logLimit int) []model.APIEntry { + entries := s.ListEntries() + result := make([]model.APIEntry, 0, len(entries)) + + for _, entry := range entries { + result = append(result, model.ToAPIEntry(entry, s.Logs(entry.ID, logLimit))) + } + + return result +} + +func (s *Store) GetEntry(id string) (model.Entry, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.entries[id] + return entry, ok +} + +func (s *Store) UpsertEntry(entry model.Entry) error { + now := time.Now().UTC() + + s.mu.Lock() + if existing, ok := s.entries[entry.ID]; ok { + entry.CreatedAt = existing.CreatedAt + entry.LastExecution = existing.LastExecution + entry.LastResult = existing.LastResult + entry.NextExecutionAt = existing.NextExecutionAt + } else if entry.CreatedAt.IsZero() { + entry.CreatedAt = now + } + entry.UpdatedAt = now + s.entries[entry.ID] = entry + snapshot := s.snapshotLocked() + s.mu.Unlock() + + return s.persist(snapshot) +} + +func (s *Store) DeleteEntry(id string) error { + s.mu.Lock() + if _, ok := s.entries[id]; !ok { + s.mu.Unlock() + return ErrNotFound + } + delete(s.entries, id) + delete(s.logs, id) + snapshot := s.snapshotLocked() + s.mu.Unlock() + + return s.persist(snapshot) +} + +func (s *Store) SetActive(id string, active bool) error { + now := time.Now().UTC() + + s.mu.Lock() + entry, ok := s.entries[id] + if !ok { + s.mu.Unlock() + return ErrNotFound + } + entry.Active = active + entry.UpdatedAt = now + if !active { + entry.NextExecutionAt = time.Time{} + } + s.entries[id] = entry + snapshot := s.snapshotLocked() + s.mu.Unlock() + + return s.persist(snapshot) +} + +func (s *Store) SetNextExecution(id string, next time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + entry, ok := s.entries[id] + if !ok { + return ErrNotFound + } + entry.NextExecutionAt = next + s.entries[id] = entry + return nil +} + +func (s *Store) RecordExecution(id string, result model.ExecuteResult) error { + s.mu.Lock() + entry, ok := s.entries[id] + if !ok { + s.mu.Unlock() + return ErrNotFound + } + + entry.LastExecution = result.Timestamp + if result.Success { + entry.LastResult = fmt.Sprintf("success (%d)", result.StatusCode) + } else { + entry.LastResult = result.Message + } + entry.UpdatedAt = result.Timestamp + s.entries[id] = entry + + entryLogs := append([]model.LogEntry(nil), s.logs[id]...) + entryLogs = append(entryLogs, model.LogEntry{ + Timestamp: result.Timestamp, + Trigger: result.Trigger, + Success: result.Success, + StatusCode: result.StatusCode, + DurationMS: result.DurationMS, + Message: result.Message, + }) + if len(entryLogs) > 10 { + entryLogs = entryLogs[len(entryLogs)-10:] + } + s.logs[id] = entryLogs + + snapshot := s.snapshotLocked() + s.mu.Unlock() + + return s.persist(snapshot) +} + +func (s *Store) Logs(id string, limit int) []model.LogEntry { + s.mu.RLock() + defer s.mu.RUnlock() + + logs := append([]model.LogEntry(nil), s.logs[id]...) + for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 { + logs[i], logs[j] = logs[j], logs[i] + } + + if limit > 0 && len(logs) > limit { + logs = logs[:limit] + } + + return logs +} + +func (s *Store) snapshotLocked() persistedState { + entries := make([]model.Entry, 0, len(s.entries)) + for _, entry := range s.entries { + entry.NextExecutionAt = time.Time{} + entries = append(entries, entry) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].CreatedAt.Before(entries[j].CreatedAt) + }) + + logs := make(map[string][]model.LogEntry, len(s.logs)) + for id, entryLogs := range s.logs { + logs[id] = append([]model.LogEntry(nil), entryLogs...) + } + + return persistedState{Entries: entries, Logs: logs} +} + +func (s *Store) persist(state persistedState) error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create state directory: %w", err) + } + + raw, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("marshal state: %w", err) + } + + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o644); err != nil { + return fmt.Errorf("write temp state: %w", err) + } + if err := os.Rename(tmp, s.path); err != nil { + return fmt.Errorf("replace state file: %w", err) + } + + return nil +} diff --git a/internal/timer/manager.go b/internal/timer/manager.go deleted file mode 100644 index d662acf..0000000 --- a/internal/timer/manager.go +++ /dev/null @@ -1,283 +0,0 @@ -package timer - -import ( - "context" - "crypto/rand" - "database/sql" - "encoding/json" - "fmt" - "log" - "math/big" - "net/http" - "sync" - "time" - "webhooktimer/internal/models" -) - -type Manager struct { - mu sync.RWMutex - timers map[string]*models.TimerEntry - cancel map[string]context.CancelFunc - db *sql.DB - OnUpdate func(string) // To notify via WebSocket -} - -func NewManager(db *sql.DB) *Manager { - return &Manager{ - timers: make(map[string]*models.TimerEntry), - cancel: make(map[string]context.CancelFunc), - db: db, - } -} - -func (m *Manager) StartAll() error { - rows, err := m.db.Query("SELECT id, name, webhook_url, mode, fixed_interval, min_interval, max_interval, active, last_execution, webhook_timeout, method, type, sleep_time_start, sleep_time_end FROM timers") - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - var t models.TimerEntry - var lastExec sql.NullTime - err := rows.Scan(&t.ID, &t.Name, &t.WebhookURL, &t.Mode, &t.FixedInterval, &t.MinInterval, &t.MaxInterval, &t.Active, &lastExec, &t.WebhookTimeout, &t.Method, &t.Type, &t.SleepTimeStart, &t.SleepTimeEnd) - if err != nil { - return err - } - if lastExec.Valid { - t.LastExecution = lastExec.Time - } - m.mu.Lock() - m.timers[t.ID] = &t - m.mu.Unlock() - - if t.Active { - m.startTimer(&t) - } - } - return nil -} - -func (m *Manager) startTimer(t *models.TimerEntry) { - ctx, cancel := context.WithCancel(context.Background()) - m.mu.Lock() - if oldCancel, ok := m.cancel[t.ID]; ok { - oldCancel() - } - m.cancel[t.ID] = cancel - m.mu.Unlock() - - go m.runTimer(ctx, t.ID) -} - -func (m *Manager) StopTimer(id string) { - m.mu.Lock() - if cancel, ok := m.cancel[id]; ok { - cancel() - delete(m.cancel, id) - } - if t, ok := m.timers[id]; ok { - t.Active = false - t.NextExecution = time.Time{} - } - m.mu.Unlock() - if m.OnUpdate != nil { - m.OnUpdate(id) - } -} - -func (m *Manager) UpdateTimer(t *models.TimerEntry) { - m.mu.Lock() - m.timers[t.ID] = t - active := t.Active - m.mu.Unlock() - - if active { - m.startTimer(t) - } else { - m.StopTimer(t.ID) - } -} - -func (m *Manager) DeleteTimer(id string) { - m.StopTimer(id) - m.mu.Lock() - delete(m.timers, id) - m.mu.Unlock() -} - -func (m *Manager) GetTimers() []*models.TimerEntry { - m.mu.RLock() - defer m.mu.RUnlock() - res := make([]*models.TimerEntry, 0, len(m.timers)) - for _, t := range m.timers { - res = append(res, t) - } - return res -} - -func (m *Manager) runTimer(ctx context.Context, id string) { - for { - m.mu.RLock() - t, ok := m.timers[id] - m.mu.RUnlock() - if !ok || !t.Active { - return - } - - interval := m.calculateInterval(t) - t.NextExecution = time.Now().Add(interval) - if m.OnUpdate != nil { - m.OnUpdate(id) - } - - select { - case <-ctx.Done(): - return - case <-time.After(interval): - if m.isSleepTime(t) { - log.Printf("Skipping webhook for %s: within sleep time window", t.Name) - continue - } - m.executeWebhook(t) - if m.OnUpdate != nil { - m.OnUpdate(id) - } - } - } -} - -func (m *Manager) calculateInterval(t *models.TimerEntry) time.Duration { - if t.Mode == "fixed" { - return time.Duration(t.FixedInterval) * time.Second - } - // random - min := int64(t.MinInterval) - max := int64(t.MaxInterval) - if max <= min { - return time.Duration(min) * time.Second - } - - diff := max - min - n, _ := rand.Int(rand.Reader, big.NewInt(diff+1)) - return time.Duration(min+n.Int64()) * time.Second -} - -func (m *Manager) isSleepTime(t *models.TimerEntry) bool { - if t.SleepTimeStart == "" || t.SleepTimeEnd == "" { - return false - } - - loc, err := time.LoadLocation("Europe/Berlin") - if err != nil { - loc = time.Local - } - now := time.Now().In(loc) - - startH, startM, err := parseTime(t.SleepTimeStart) - if err != nil { - return false - } - endH, endM, err := parseTime(t.SleepTimeEnd) - if err != nil { - return false - } - - currentMinutes := now.Hour()*60 + now.Minute() - startMinutes := startH*60 + startM - endMinutes := endH*60 + endM - - // Handle sleep time that spans midnight (e.g., 23:00-06:00) - if startMinutes <= endMinutes { - // Normal case: 00:00-12:00 - return currentMinutes >= startMinutes && currentMinutes < endMinutes - } - // Spans midnight: 23:00-06:00 - return currentMinutes >= startMinutes || currentMinutes < endMinutes -} - -func parseTime(hhmm string) (int, int, error) { - var h, m int - _, err := fmt.Sscanf(hhmm, "%d:%d", &h, &m) - if err != nil { - return 0, 0, err - } - return h, m, nil -} - -func (m *Manager) CallNow(id string) { - m.mu.RLock() - t, ok := m.timers[id] - m.mu.RUnlock() - if !ok { - return - } - - go func() { - m.executeWebhook(t) - if m.OnUpdate != nil { - m.OnUpdate(id) - } - }() -} - -func (m *Manager) executeWebhook(t *models.TimerEntry) { - timeout := time.Duration(t.WebhookTimeout) * time.Second - if t.Type == "n8n" && timeout < 30*time.Second { - timeout = 30 * time.Second - } - - client := &http.Client{ - Timeout: timeout, - } - - method := t.Method - if method == "" { - method = "POST" - } - - req, err := http.NewRequest(method, t.WebhookURL, nil) - if err != nil { - log.Printf("Error creating request: %v", err) - return - } - - resp, err := client.Do(req) - status := "success" - message := "" - - if err != nil { - status = "error" - message = err.Error() - } else { - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - status = "error" - message = fmt.Sprintf("HTTP Status %d", resp.StatusCode) - } else if t.Type == "n8n" { - var body struct { - Message string `json:"message"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - status = "error" - message = "Invalid JSON response" - } else if body.Message != "Workflow was started" { - status = "error" - message = fmt.Sprintf("Unexpected message: %s", body.Message) - } - } - } - - t.LastExecution = time.Now() - - // Update last execution in DB - _, _ = m.db.Exec("UPDATE timers SET last_execution = ? WHERE id = ?", t.LastExecution, t.ID) - - // Add log entry - _, _ = m.db.Exec("INSERT INTO logs (timer_id, timestamp, status, message) VALUES (?, ?, ?, ?)", t.ID, t.LastExecution, status, message) - - // Keep only last 3 logs - _, _ = m.db.Exec("DELETE FROM logs WHERE timer_id = ? AND id NOT IN (SELECT id FROM logs WHERE timer_id = ? ORDER BY timestamp DESC LIMIT 3)", t.ID, t.ID) - - log.Printf("Executed webhook for %s: %s %s", t.Name, status, message) -} diff --git a/main.go b/main.go index ce1bcec..b480904 100644 --- a/main.go +++ b/main.go @@ -1,69 +1,92 @@ package main import ( - "log" - "net/http" - "os" - "path/filepath" - "webhooktimer/internal/handlers" - "webhooktimer/internal/models" - "webhooktimer/internal/timer" + "context" + "embed" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + "webhooktimer/internal/scheduler" + "webhooktimer/internal/server" + "webhooktimer/internal/store" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" + _ "time/tzdata" ) +//go:embed web/index.html +var webFiles embed.FS + func main() { - dbPath := os.Getenv("DB_PATH") - if dbPath == "" { - dbPath = "/data/timers.db" - } + port := envOrDefault("PORT", "8080") + statePath := envOrDefault("STATE_PATH", "/data/state.json") + timezoneName := envOrDefault("TZ", "UTC") + + location, err := time.LoadLocation(timezoneName) + if err != nil { + log.Printf("invalid TZ %q, using UTC", timezoneName) + location = time.UTC + } - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { - log.Fatal(err) - } + st := store.New(statePath) + if err := st.Load(); err != nil { + log.Fatalf("could not load state: %v", err) + } - if err := models.InitDB(dbPath); err != nil { - log.Fatal(err) - } + sched := scheduler.New(st, location) + sched.Start() + defer sched.Shutdown() - manager := timer.NewManager(models.DB) - if err := manager.StartAll(); err != nil { - log.Fatal(err) - } + indexHTML, err := webFiles.ReadFile("web/index.html") + if err != nil { + log.Fatalf("could not load UI: %v", err) + } - h := handlers.NewHandler(manager) + api := server.New(st, sched, indexHTML) + httpServer := &http.Server{ + Addr: ":" + port, + Handler: loggingMiddleware(api.Routes()), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 90 * time.Second, + } - r := chi.NewRouter() - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) + go func() { + log.Printf("webhooktimer listening on :%s (TZ=%s)", port, location.String()) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server stopped: %v", err) + } + }() - r.Get("/api/timers", h.GetTimers) - r.Post("/api/timers", h.CreateTimer) - r.Put("/api/timers/{id}", h.UpdateTimer) - r.Delete("/api/timers/{id}", h.DeleteTimer) - r.Post("/api/timers/{id}/toggle", h.ToggleTimer) - r.Post("/api/timers/{id}/call", h.CallNow) - r.Get("/api/timers/{id}/logs", h.GetLogs) - r.HandleFunc("/ws", h.HandleWS) + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop - // Serve static files - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "web/templates/index.html") - }) - - // Create a file server for static assets - staticDir := http.Dir("web/static") - r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(staticDir))) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(ctx); err != nil { + log.Printf("graceful shutdown failed: %v", err) + } +} - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } +func envOrDefault(key string, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} - log.Printf("Server starting on port %s", port) - if err := http.ListenAndServe(":"+port, r); err != nil { - log.Fatal(err) - } +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + if strings.HasPrefix(r.URL.Path, "/api/") { + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + } + }) } diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..baddec8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,564 @@ + + + + + + Webhook Timer + + + +

Webhook Timer

+
+
+

Create entry

+
+ + +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + + +
+ +
+ + + +
+ +
+ +
+ + +
+
+
+ +
+
+

Entries

+
Auto-refresh every 5 seconds. Countdown updates live every second.
+ + + + + + + + + + + +
NameStateNext runLast runActions
+
+
+ + + + diff --git a/web/templates/index.html b/web/templates/index.html deleted file mode 100644 index 4faccf1..0000000 --- a/web/templates/index.html +++ /dev/null @@ -1,419 +0,0 @@ - - - - - - Webhook Timer - - - - - - - - -
-
- - - - - - - - - - - - - -
NameStatusLast ExecutionCountdownActions
-
-
- - - - - - - - - - From ce6643934aa158fed470e7916651c50956779a34 Mon Sep 17 00:00:00 2001 From: XHyperDEVX <68189420+XHyperDEVX@users.noreply.github.com> Date: Sun, 3 May 2026 20:04:45 +0200 Subject: [PATCH 2/3] Update README.md --- README.md | 83 +++++++++++++++++++++---------------------------------- 1 file changed, 31 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 2385a06..410725b 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,35 @@ # Webhook Timer -A tiny 24/7 webhook scheduler with a minimal web UI. - -## What this rebuild includes - -- Extremely simple UI (single page, no frontend dependencies) -- Fixed or random intervals -- Sleep window (no executions during configured quiet time) -- Per-entry enable/disable -- Live countdown to next execution -- Last execution status and timestamp -- Per-entry execution log (last 10) -- **Run now** button with immediate success/error feedback -- Atomic JSON state persistence (new format, old DB files are intentionally not reused) - -## Why it is lightweight - -- Pure Go + standard library only -- No JS framework, no CSS framework, no websocket dependency -- Static binary in a `scratch` container image - -## Run locally - -```bash -go run . +A minimalistic timer for webhooks, designed specifically for N8N Webhooks. + +## Features +- Minimalistic Go-based daemon +- SQLite persistence for configuration +- Web-UI for managing timers +- Support for Fixed and Random intervals +- WebSocket for live countdown updates +- Ultra-lightweight Docker Image (~26MB) + +## ⚠️ AI GENERATED PROJECT ⚠️ + +> [!WARNING] +> This project was **100% generated by AI** and is **not actively maintained**. Use it at your own risk. + +## Deployment + +### Docker Compose +```yaml +services: + timerhook: + image: ghcr.io/xhyperdevx/webhooktimer:latest + ports: + - "8080:8080" + volumes: + - ./data:/data + restart: unless-stopped ``` -Open: `http://localhost:8080` - -## Docker - -```bash -docker compose up --build -``` - -### Environment variables - -- `PORT` (default `8080`) -- `STATE_PATH` (default `/data/state.json`) -- `TZ` (default `UTC`, used for sleep window calculations) - -## API (used by the UI) - -- `GET /api/entries` -- `POST /api/entries` -- `PUT /api/entries/{id}` -- `DELETE /api/entries/{id}` -- `POST /api/entries/{id}/toggle` -- `POST /api/entries/{id}/execute` -- `GET /api/entries/{id}/logs` - -## Notes - -- The new persistence format is JSON, not SQLite. -- If an old database exists from previous versions, it is ignored by design. +## Local Development +1. Install Go 1.23+ +2. `go run main.go` +3. Access UI at `http://localhost:8080` From 81ca3d9962f4984d3496036313caa8c1ca7894d4 Mon Sep 17 00:00:00 2001 From: XHyperDEVX <68189420+XHyperDEVX@users.noreply.github.com> Date: Sun, 3 May 2026 20:05:09 +0200 Subject: [PATCH 3/3] Update docker-build.yml --- .github/workflows/docker-build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index df653dd..937ed47 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -4,9 +4,9 @@ on: push: branches: - main - pull_request: - branches: - - main + #pull_request: + # branches: + # - main env: REGISTRY: ghcr.io