diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..df653dd --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,56 @@ +name: Docker Build and Push + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set Image Name + run: echo "IMAGE_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,format=short + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b582144 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/vendor/ +/bin/ +webhooktimer +*.db +*.db-journal +*.exe +*.test +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f5325d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Build stage +FROM golang:alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# 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 +RUN go mod download + +# Copy the source from the current directory to the Working Directory inside the container +COPY . . + +# 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 + +# Copy the Pre-built binary file from the previous stage +COPY --from=builder /app/webhooktimer . +COPY --from=builder /app/web ./web + +# Expose port 8080 to the outside world +EXPOSE 8080 + +# Environment variables +ENV PORT=8080 +ENV DB_PATH=/data/timers.db + +# Command to run the executable +CMD ["./webhooktimer"] diff --git a/README.md b/README.md index 96304ad..3c79006 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ -# webhooktimer -Minimalistic Timer for Webhooks (designed for N8N Webhooks) +# 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 +- Multi-stage Docker image (~25MB) + +## ⚠️ 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 +``` + +## Local Development +1. Install Go 1.23+ +2. `go run main.go` +3. Access UI at `http://localhost:8080` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ecbfa44 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + timerhook: + build: . + ports: + - "8080:8080" + volumes: + - ./data:/data + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c21480f --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f458a3f --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..660d0d5 --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,196 @@ +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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + t.ID, t.Name, t.WebhookURL, t.Mode, t.FixedInterval, t.MinInterval, t.MaxInterval, t.Active, t.WebhookTimeout, t.Method, t.Type) + 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 = ? WHERE id = ?", + t.Name, t.WebhookURL, t.Mode, t.FixedInterval, t.MinInterval, t.MaxInterval, t.Active, t.WebhookTimeout, t.Method, t.Type, 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 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) + 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/models/db.go b/internal/models/db.go new file mode 100644 index 0000000..6d587a4 --- /dev/null +++ b/internal/models/db.go @@ -0,0 +1,42 @@ +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' + ); + 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 new file mode 100644 index 0000000..6a902f6 --- /dev/null +++ b/internal/models/timer.go @@ -0,0 +1,29 @@ +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 +} + +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/timer/manager.go b/internal/timer/manager.go new file mode 100644 index 0000000..9b14c21 --- /dev/null +++ b/internal/timer/manager.go @@ -0,0 +1,237 @@ +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 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) + 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): + 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) 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 new file mode 100644 index 0000000..ce1bcec --- /dev/null +++ b/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "log" + "net/http" + "os" + "path/filepath" + "webhooktimer/internal/handlers" + "webhooktimer/internal/models" + "webhooktimer/internal/timer" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func main() { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "/data/timers.db" + } + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + log.Fatal(err) + } + + if err := models.InitDB(dbPath); err != nil { + log.Fatal(err) + } + + manager := timer.NewManager(models.DB) + if err := manager.StartAll(); err != nil { + log.Fatal(err) + } + + h := handlers.NewHandler(manager) + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + 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) + + // 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))) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Server starting on port %s", port) + if err := http.ListenAndServe(":"+port, r); err != nil { + log.Fatal(err) + } +} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..fb672f6 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,379 @@ + + +
+ + +| Name | +Status | +Last Execution | +Countdown | +Actions | +
|---|