feat(02-02): convert handlers to Server struct methods, remove globals

- Add Server struct with store Store and webhookSecret fields
- Add NewServer constructor
- Convert all 6 handler functions to methods on *Server
- Replace all inline SQL with s.store.X() calls
- Remove package-level globals db, mu, webhookSecret
- Remove InitDB, SetWebhookSecret, UpdateEvent, GetUpdates functions
- Update export_test.go: replace old helpers with NewTestServer, NewTestServerWithSecret, TestUpsertEvent, TestGetUpdatesMap
- Update main.go: sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> routes
This commit is contained in:
2026-03-23 22:02:53 +01:00
parent 50805b103f
commit 78543d79e9
3 changed files with 96 additions and 230 deletions

View File

@@ -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
}