package diunwebhook import ( "database/sql" "encoding/json" "log" "net/http" "strconv" "strings" "sync" "time" _ "modernc.org/sqlite" ) type DiunEvent struct { DiunVersion string `json:"diun_version"` Hostname string `json:"hostname"` Status string `json:"status"` Provider string `json:"provider"` Image string `json:"image"` HubLink string `json:"hub_link"` MimeType string `json:"mime_type"` Digest string `json:"digest"` Created time.Time `json:"created"` Platform string `json:"platform"` Metadata struct { ContainerName string `json:"ctn_names"` ContainerID string `json:"ctn_id"` State string `json:"ctn_state"` Status string `json:"ctn_status"` } `json:"metadata"` } type Tag struct { ID int `json:"id"` Name string `json:"name"` } type UpdateEntry struct { Event DiunEvent `json:"event"` ReceivedAt time.Time `json:"received_at"` Acknowledged bool `json:"acknowledged"` Tag *Tag `json:"tag"` } var ( mu sync.Mutex db *sql.DB ) func InitDB(path string) error { var err error db, err = sql.Open("sqlite", path) if err != nil { return err } db.SetMaxOpenConns(1) _, 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 } func UpdateEvent(event DiunEvent) error { mu.Lock() defer mu.Unlock() _, err := db.Exec(`INSERT OR REPLACE INTO updates VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,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 rows.Close() 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 r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var event DiunEvent if err := json.NewDecoder(r.Body).Decode(&event); err != nil { log.Printf("WebhookHandler: failed to decode request: %v", err) http.Error(w, "bad request", http.StatusBadRequest) return } if event.Image == "" { http.Error(w, "bad request: image field is required", http.StatusBadRequest) return } if err := UpdateEvent(event); err != nil { log.Printf("WebhookHandler: failed to store event: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } log.Printf("Update received: %s (%s)", event.Image, event.Status) w.WriteHeader(http.StatusOK) } func UpdatesHandler(w http.ResponseWriter, r *http.Request) { updates, err := GetUpdates() if err != nil { log.Printf("UpdatesHandler: failed to get updates: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(updates); err != nil { log.Printf("failed to encode updates: %v", err) } } func DismissHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } image := strings.TrimPrefix(r.URL.Path, "/api/updates/") if image == "" { 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() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } n, _ := res.RowsAffected() if n == 0 { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } // TagsHandler handles GET /api/tags and POST /api/tags func 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`) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } defer rows.Close() 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") json.NewEncoder(w).Encode(tags) case http.MethodPost: var req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { 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() if err != nil { if strings.Contains(err.Error(), "UNIQUE") { http.Error(w, "conflict: tag name already exists", http.StatusConflict) return } http.Error(w, "internal error", http.StatusInternalServerError) return } id, _ := res.LastInsertId() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(Tag{ID: int(id), Name: req.Name}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // TagByIDHandler handles DELETE /api/tags/{id} func TagByIDHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } idStr := strings.TrimPrefix(r.URL.Path, "/api/tags/") id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { http.Error(w, "bad request: invalid id", http.StatusBadRequest) return } mu.Lock() res, err := db.Exec(`DELETE FROM tags WHERE id = ?`, id) mu.Unlock() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } n, _ := res.RowsAffected() if n == 0 { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } // TagAssignmentHandler handles PUT /api/tag-assignments and DELETE /api/tag-assignments func TagAssignmentHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPut: var req struct { Image string `json:"image"` TagID int `json:"tag_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Image == "" { 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 { 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 { http.Error(w, "internal error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) case http.MethodDelete: var req struct { Image string `json:"image"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Image == "" { 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 { http.Error(w, "internal error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } }