- **fix(sql):** wrap `rows.Close` in a `defer` function to safely handle potential close errors - **fix(api):** handle JSON encoding errors in API responses to prevent unhandled edge cases - **docs:** correct typos and improve phrasing in `.claude/CLAUDE.md` - **test:** add error handling for `UpdateEvent` in test cases
367 lines
10 KiB
Go
367 lines
10 KiB
Go
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 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 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 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
|
|
}
|
|
|
|
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)
|
|
err = json.NewEncoder(w).Encode(Tag{ID: int(id), Name: req.Name})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|