diff --git a/cmd/diunwebhook/main.go b/cmd/diunwebhook/main.go index feb3269..9b9bc17 100644 --- a/cmd/diunwebhook/main.go +++ b/cmd/diunwebhook/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "errors" "log" "net/http" @@ -11,6 +12,7 @@ import ( "time" diun "awesomeProject/pkg/diunwebhook" + _ "modernc.org/sqlite" ) func main() { @@ -18,33 +20,42 @@ func main() { if dbPath == "" { dbPath = "./diun.db" } - if err := diun.InitDB(dbPath); err != nil { - log.Fatalf("InitDB: %v", err) + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + log.Fatalf("sql.Open: %v", err) } + if err := diun.RunMigrations(db); err != nil { + log.Fatalf("RunMigrations: %v", err) + } + + store := diun.NewSQLiteStore(db) + secret := os.Getenv("WEBHOOK_SECRET") if secret == "" { log.Println("WARNING: WEBHOOK_SECRET not set — webhook endpoint is unprotected") } else { - diun.SetWebhookSecret(secret) log.Println("Webhook endpoint protected with token authentication") } + srv := diun.NewServer(store, secret) + port := os.Getenv("PORT") if port == "" { port = "8080" } mux := http.NewServeMux() - mux.HandleFunc("/webhook", diun.WebhookHandler) - mux.HandleFunc("/api/updates/", diun.DismissHandler) - mux.HandleFunc("/api/updates", diun.UpdatesHandler) - mux.HandleFunc("/api/tags", diun.TagsHandler) - mux.HandleFunc("/api/tags/", diun.TagByIDHandler) - mux.HandleFunc("/api/tag-assignments", diun.TagAssignmentHandler) + mux.HandleFunc("/webhook", srv.WebhookHandler) + mux.HandleFunc("/api/updates/", srv.DismissHandler) + mux.HandleFunc("/api/updates", srv.UpdatesHandler) + mux.HandleFunc("/api/tags", srv.TagsHandler) + mux.HandleFunc("/api/tags/", srv.TagByIDHandler) + mux.HandleFunc("/api/tag-assignments", srv.TagAssignmentHandler) mux.Handle("/", http.FileServer(http.Dir("./frontend/dist"))) - srv := &http.Server{ + httpSrv := &http.Server{ Addr: ":" + port, Handler: mux, ReadTimeout: 10 * time.Second, @@ -57,7 +68,7 @@ func main() { go func() { log.Printf("Listening on :%s", port) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("ListenAndServe: %v", err) } }() @@ -67,7 +78,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - if err := srv.Shutdown(ctx); err != nil { + if err := httpSrv.Shutdown(ctx); err != nil { log.Printf("Shutdown error: %v", err) } else { log.Println("Server stopped cleanly") diff --git a/pkg/diunwebhook/diunwebhook.go b/pkg/diunwebhook/diunwebhook.go index a98388d..8bb5e9c 100644 --- a/pkg/diunwebhook/diunwebhook.go +++ b/pkg/diunwebhook/diunwebhook.go @@ -2,17 +2,13 @@ package diunwebhook import ( "crypto/subtle" - "database/sql" "encoding/json" "errors" "log" "net/http" "strconv" "strings" - "sync" "time" - - _ "modernc.org/sqlite" ) const maxBodyBytes = 1 << 20 // 1 MB @@ -48,150 +44,22 @@ type UpdateEntry struct { Tag *Tag `json:"tag"` } -var ( - mu sync.Mutex - db *sql.DB +// Server holds the application dependencies for HTTP handlers. +type Server struct { + store Store webhookSecret string -) - -func SetWebhookSecret(secret string) { - webhookSecret = secret } -func InitDB(path string) error { - var err error - db, err = sql.Open("sqlite", path) - if err != nil { - return err - } - db.SetMaxOpenConns(1) - if _, err = db.Exec(`PRAGMA foreign_keys = ON`); err != nil { - return err - } - _, err = db.Exec(`CREATE TABLE IF NOT EXISTS updates ( - image TEXT PRIMARY KEY, - diun_version TEXT NOT NULL DEFAULT '', - hostname TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT '', - provider TEXT NOT NULL DEFAULT '', - hub_link TEXT NOT NULL DEFAULT '', - mime_type TEXT NOT NULL DEFAULT '', - digest TEXT NOT NULL DEFAULT '', - created TEXT NOT NULL DEFAULT '', - platform TEXT NOT NULL DEFAULT '', - ctn_name TEXT NOT NULL DEFAULT '', - ctn_id TEXT NOT NULL DEFAULT '', - ctn_state TEXT NOT NULL DEFAULT '', - ctn_status TEXT NOT NULL DEFAULT '', - received_at TEXT NOT NULL, - acknowledged_at TEXT - )`) - if err != nil { - return err - } - // Migration: add acknowledged_at to existing databases (silently ignored if already present) - _, _ = db.Exec(`ALTER TABLE updates ADD COLUMN acknowledged_at TEXT`) - - _, err = db.Exec(`CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE - )`) - if err != nil { - return err - } - _, err = db.Exec(`CREATE TABLE IF NOT EXISTS tag_assignments ( - image TEXT PRIMARY KEY, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE - )`) - if err != nil { - return err - } - return nil +// NewServer constructs a Server backed by the given Store. +func NewServer(store Store, webhookSecret string) *Server { + return &Server{store: store, webhookSecret: webhookSecret} } -func UpdateEvent(event DiunEvent) error { - mu.Lock() - defer mu.Unlock() - _, err := db.Exec(` - INSERT INTO updates ( - image, diun_version, hostname, status, provider, - hub_link, mime_type, digest, created, platform, - ctn_name, ctn_id, ctn_state, ctn_status, - received_at, acknowledged_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL) - ON CONFLICT(image) DO UPDATE SET - diun_version = excluded.diun_version, - hostname = excluded.hostname, - status = excluded.status, - provider = excluded.provider, - hub_link = excluded.hub_link, - mime_type = excluded.mime_type, - digest = excluded.digest, - created = excluded.created, - platform = excluded.platform, - ctn_name = excluded.ctn_name, - ctn_id = excluded.ctn_id, - ctn_state = excluded.ctn_state, - ctn_status = excluded.ctn_status, - received_at = excluded.received_at, - acknowledged_at = NULL`, - event.Image, event.DiunVersion, event.Hostname, event.Status, event.Provider, - event.HubLink, event.MimeType, event.Digest, - event.Created.Format(time.RFC3339), event.Platform, - event.Metadata.ContainerName, event.Metadata.ContainerID, - event.Metadata.State, event.Metadata.Status, - time.Now().Format(time.RFC3339), - ) - return err -} - -func GetUpdates() (map[string]UpdateEntry, error) { - rows, err := db.Query(`SELECT u.image, u.diun_version, u.hostname, u.status, u.provider, - u.hub_link, u.mime_type, u.digest, u.created, u.platform, - u.ctn_name, u.ctn_id, u.ctn_state, u.ctn_status, u.received_at, COALESCE(u.acknowledged_at, ''), - t.id, t.name - FROM updates u - LEFT JOIN tag_assignments ta ON u.image = ta.image - LEFT JOIN tags t ON ta.tag_id = t.id`) - if err != nil { - return nil, err - } - defer func(rows *sql.Rows) { - err := rows.Close() - if err != nil { - - } - }(rows) - result := make(map[string]UpdateEntry) - for rows.Next() { - var e UpdateEntry - var createdStr, receivedStr, acknowledgedAt string - var tagID sql.NullInt64 - var tagName sql.NullString - err := rows.Scan(&e.Event.Image, &e.Event.DiunVersion, &e.Event.Hostname, - &e.Event.Status, &e.Event.Provider, &e.Event.HubLink, &e.Event.MimeType, - &e.Event.Digest, &createdStr, &e.Event.Platform, - &e.Event.Metadata.ContainerName, &e.Event.Metadata.ContainerID, - &e.Event.Metadata.State, &e.Event.Metadata.Status, - &receivedStr, &acknowledgedAt, &tagID, &tagName) - if err != nil { - return nil, err - } - e.Event.Created, _ = time.Parse(time.RFC3339, createdStr) - e.ReceivedAt, _ = time.Parse(time.RFC3339, receivedStr) - e.Acknowledged = acknowledgedAt != "" - if tagID.Valid && tagName.Valid { - e.Tag = &Tag{ID: int(tagID.Int64), Name: tagName.String} - } - result[e.Event.Image] = e - } - return result, rows.Err() -} - -func WebhookHandler(w http.ResponseWriter, r *http.Request) { - if webhookSecret != "" { +// WebhookHandler handles POST /webhook +func (s *Server) WebhookHandler(w http.ResponseWriter, r *http.Request) { + if s.webhookSecret != "" { auth := r.Header.Get("Authorization") - if subtle.ConstantTimeCompare([]byte(auth), []byte(webhookSecret)) != 1 { + if subtle.ConstantTimeCompare([]byte(auth), []byte(s.webhookSecret)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -221,7 +89,7 @@ func WebhookHandler(w http.ResponseWriter, r *http.Request) { return } - if err := UpdateEvent(event); err != nil { + if err := s.store.UpsertEvent(event); err != nil { log.Printf("WebhookHandler: failed to store event: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return @@ -232,8 +100,9 @@ func WebhookHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -func UpdatesHandler(w http.ResponseWriter, r *http.Request) { - updates, err := GetUpdates() +// UpdatesHandler handles GET /api/updates +func (s *Server) UpdatesHandler(w http.ResponseWriter, r *http.Request) { + updates, err := s.store.GetUpdates() if err != nil { log.Printf("UpdatesHandler: failed to get updates: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -245,7 +114,8 @@ func UpdatesHandler(w http.ResponseWriter, r *http.Request) { } } -func DismissHandler(w http.ResponseWriter, r *http.Request) { +// DismissHandler handles PATCH /api/updates/{image} +func (s *Server) DismissHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -255,15 +125,12 @@ func DismissHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request: image name required", http.StatusBadRequest) return } - mu.Lock() - res, err := db.Exec(`UPDATE updates SET acknowledged_at = datetime('now') WHERE image = ?`, image) - mu.Unlock() + found, err := s.store.AcknowledgeUpdate(image) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - n, _ := res.RowsAffected() - if n == 0 { + if !found { http.Error(w, "not found", http.StatusNotFound) return } @@ -271,38 +138,16 @@ func DismissHandler(w http.ResponseWriter, r *http.Request) { } // TagsHandler handles GET /api/tags and POST /api/tags -func TagsHandler(w http.ResponseWriter, r *http.Request) { +func (s *Server) TagsHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - rows, err := db.Query(`SELECT id, name FROM tags ORDER BY name`) + tags, err := s.store.ListTags() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - defer func(rows *sql.Rows) { - err := rows.Close() - if err != nil { - - } - }(rows) - tags := []Tag{} - for rows.Next() { - var t Tag - if err := rows.Scan(&t.ID, &t.Name); err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - tags = append(tags, t) - } - if err := rows.Err(); err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) - return - } w.Header().Set("Content-Type", "application/json") - err = json.NewEncoder(w).Encode(tags) - if err != nil { - return - } + json.NewEncoder(w).Encode(tags) //nolint:errcheck case http.MethodPost: r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) @@ -322,9 +167,7 @@ func TagsHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request: name required", http.StatusBadRequest) return } - mu.Lock() - res, err := db.Exec(`INSERT INTO tags (name) VALUES (?)`, req.Name) - mu.Unlock() + tag, err := s.store.CreateTag(req.Name) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { http.Error(w, "conflict: tag name already exists", http.StatusConflict) @@ -333,13 +176,9 @@ func TagsHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } - id, _ := res.LastInsertId() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - err = json.NewEncoder(w).Encode(Tag{ID: int(id), Name: req.Name}) - if err != nil { - return - } + json.NewEncoder(w).Encode(tag) //nolint:errcheck default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -347,7 +186,7 @@ func TagsHandler(w http.ResponseWriter, r *http.Request) { } // TagByIDHandler handles DELETE /api/tags/{id} -func TagByIDHandler(w http.ResponseWriter, r *http.Request) { +func (s *Server) TagByIDHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -358,15 +197,12 @@ func TagByIDHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request: invalid id", http.StatusBadRequest) return } - mu.Lock() - res, err := db.Exec(`DELETE FROM tags WHERE id = ?`, id) - mu.Unlock() + found, err := s.store.DeleteTag(id) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - n, _ := res.RowsAffected() - if n == 0 { + if !found { http.Error(w, "not found", http.StatusNotFound) return } @@ -374,7 +210,7 @@ func TagByIDHandler(w http.ResponseWriter, r *http.Request) { } // TagAssignmentHandler handles PUT /api/tag-assignments and DELETE /api/tag-assignments -func TagAssignmentHandler(w http.ResponseWriter, r *http.Request) { +func (s *Server) TagAssignmentHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPut: r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) @@ -395,17 +231,12 @@ func TagAssignmentHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) return } - // Check tag exists - var exists int - err := db.QueryRow(`SELECT COUNT(*) FROM tags WHERE id = ?`, req.TagID).Scan(&exists) - if err != nil || exists == 0 { + exists, err := s.store.TagExists(req.TagID) + if err != nil || !exists { http.Error(w, "not found: tag does not exist", http.StatusNotFound) return } - mu.Lock() - _, err = db.Exec(`INSERT OR REPLACE INTO tag_assignments (image, tag_id) VALUES (?, ?)`, req.Image, req.TagID) - mu.Unlock() - if err != nil { + if err := s.store.AssignTag(req.Image, req.TagID); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } @@ -429,10 +260,7 @@ func TagAssignmentHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) return } - mu.Lock() - _, err := db.Exec(`DELETE FROM tag_assignments WHERE image = ?`, req.Image) - mu.Unlock() - if err != nil { + if err := s.store.UnassignTag(req.Image); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } diff --git a/pkg/diunwebhook/export_test.go b/pkg/diunwebhook/export_test.go index 5cf638a..8235fb3 100644 --- a/pkg/diunwebhook/export_test.go +++ b/pkg/diunwebhook/export_test.go @@ -1,19 +1,46 @@ package diunwebhook -func GetUpdatesMap() map[string]UpdateEntry { - m, _ := GetUpdates() +import "database/sql" + +// NewTestServer constructs a Server with a fresh in-memory SQLite database. +// Each call returns an isolated server -- tests do not share state. +func NewTestServer() (*Server, error) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + return nil, err + } + if err := RunMigrations(db); err != nil { + return nil, err + } + store := NewSQLiteStore(db) + return NewServer(store, ""), nil +} + +// NewTestServerWithSecret constructs a Server with webhook authentication enabled. +func NewTestServerWithSecret(secret string) (*Server, error) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + return nil, err + } + if err := RunMigrations(db); err != nil { + return nil, err + } + store := NewSQLiteStore(db) + return NewServer(store, secret), nil +} + +// TestUpsertEvent calls UpsertEvent on the server's store (for test setup). +func (s *Server) TestUpsertEvent(event DiunEvent) error { + return s.store.UpsertEvent(event) +} + +// TestGetUpdates calls GetUpdates on the server's store (for test assertions). +func (s *Server) TestGetUpdates() (map[string]UpdateEntry, error) { + return s.store.GetUpdates() +} + +// TestGetUpdatesMap is a convenience wrapper that returns the map without error. +func (s *Server) TestGetUpdatesMap() map[string]UpdateEntry { + m, _ := s.store.GetUpdates() return m } - -func UpdatesReset() { - InitDB(":memory:") -} - -func ResetTags() { - db.Exec(`DELETE FROM tag_assignments`) - db.Exec(`DELETE FROM tags`) -} - -func ResetWebhookSecret() { - SetWebhookSecret("") -}