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/.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
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/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
+
+
+
+
+
+ Entries
+ Auto-refresh every 5 seconds. Countdown updates live every second.
+
+
+
+ | Name |
+ State |
+ Next run |
+ Last run |
+ Actions |
+
+
+
+
+
+
+
+
+
+
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
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Name |
- Status |
- Last Execution |
- Countdown |
- Actions |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-