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:
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
diun "awesomeProject/pkg/diunwebhook"
|
diun "awesomeProject/pkg/diunwebhook"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -18,33 +20,42 @@ func main() {
|
|||||||
if dbPath == "" {
|
if dbPath == "" {
|
||||||
dbPath = "./diun.db"
|
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")
|
secret := os.Getenv("WEBHOOK_SECRET")
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
log.Println("WARNING: WEBHOOK_SECRET not set — webhook endpoint is unprotected")
|
log.Println("WARNING: WEBHOOK_SECRET not set — webhook endpoint is unprotected")
|
||||||
} else {
|
} else {
|
||||||
diun.SetWebhookSecret(secret)
|
|
||||||
log.Println("Webhook endpoint protected with token authentication")
|
log.Println("Webhook endpoint protected with token authentication")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
srv := diun.NewServer(store, secret)
|
||||||
|
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8080"
|
port = "8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/webhook", diun.WebhookHandler)
|
mux.HandleFunc("/webhook", srv.WebhookHandler)
|
||||||
mux.HandleFunc("/api/updates/", diun.DismissHandler)
|
mux.HandleFunc("/api/updates/", srv.DismissHandler)
|
||||||
mux.HandleFunc("/api/updates", diun.UpdatesHandler)
|
mux.HandleFunc("/api/updates", srv.UpdatesHandler)
|
||||||
mux.HandleFunc("/api/tags", diun.TagsHandler)
|
mux.HandleFunc("/api/tags", srv.TagsHandler)
|
||||||
mux.HandleFunc("/api/tags/", diun.TagByIDHandler)
|
mux.HandleFunc("/api/tags/", srv.TagByIDHandler)
|
||||||
mux.HandleFunc("/api/tag-assignments", diun.TagAssignmentHandler)
|
mux.HandleFunc("/api/tag-assignments", srv.TagAssignmentHandler)
|
||||||
mux.Handle("/", http.FileServer(http.Dir("./frontend/dist")))
|
mux.Handle("/", http.FileServer(http.Dir("./frontend/dist")))
|
||||||
|
|
||||||
srv := &http.Server{
|
httpSrv := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
@@ -57,7 +68,7 @@ func main() {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("Listening on :%s", port)
|
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)
|
log.Fatalf("ListenAndServe: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -67,7 +78,7 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
if err := httpSrv.Shutdown(ctx); err != nil {
|
||||||
log.Printf("Shutdown error: %v", err)
|
log.Printf("Shutdown error: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Println("Server stopped cleanly")
|
log.Println("Server stopped cleanly")
|
||||||
|
|||||||
@@ -2,17 +2,13 @@ package diunwebhook
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxBodyBytes = 1 << 20 // 1 MB
|
const maxBodyBytes = 1 << 20 // 1 MB
|
||||||
@@ -48,150 +44,22 @@ type UpdateEntry struct {
|
|||||||
Tag *Tag `json:"tag"`
|
Tag *Tag `json:"tag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
// Server holds the application dependencies for HTTP handlers.
|
||||||
mu sync.Mutex
|
type Server struct {
|
||||||
db *sql.DB
|
store Store
|
||||||
webhookSecret string
|
webhookSecret string
|
||||||
)
|
|
||||||
|
|
||||||
func SetWebhookSecret(secret string) {
|
|
||||||
webhookSecret = secret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitDB(path string) error {
|
// NewServer constructs a Server backed by the given Store.
|
||||||
var err error
|
func NewServer(store Store, webhookSecret string) *Server {
|
||||||
db, err = sql.Open("sqlite", path)
|
return &Server{store: store, webhookSecret: webhookSecret}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateEvent(event DiunEvent) error {
|
// WebhookHandler handles POST /webhook
|
||||||
mu.Lock()
|
func (s *Server) WebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
defer mu.Unlock()
|
if s.webhookSecret != "" {
|
||||||
_, 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 != "" {
|
|
||||||
auth := r.Header.Get("Authorization")
|
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)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -221,7 +89,7 @@ func WebhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := UpdateEvent(event); err != nil {
|
if err := s.store.UpsertEvent(event); err != nil {
|
||||||
log.Printf("WebhookHandler: failed to store event: %v", err)
|
log.Printf("WebhookHandler: failed to store event: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -232,8 +100,9 @@ func WebhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdatesHandler(w http.ResponseWriter, r *http.Request) {
|
// UpdatesHandler handles GET /api/updates
|
||||||
updates, err := GetUpdates()
|
func (s *Server) UpdatesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updates, err := s.store.GetUpdates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("UpdatesHandler: failed to get updates: %v", err)
|
log.Printf("UpdatesHandler: failed to get updates: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
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 {
|
if r.Method != http.MethodPatch {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -255,15 +125,12 @@ func DismissHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad request: image name required", http.StatusBadRequest)
|
http.Error(w, "bad request: image name required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
found, err := s.store.AcknowledgeUpdate(image)
|
||||||
res, err := db.Exec(`UPDATE updates SET acknowledged_at = datetime('now') WHERE image = ?`, image)
|
|
||||||
mu.Unlock()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n, _ := res.RowsAffected()
|
if !found {
|
||||||
if n == 0 {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -271,38 +138,16 @@ func DismissHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TagsHandler handles GET /api/tags and POST /api/tags
|
// 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 {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
rows, err := db.Query(`SELECT id, name FROM tags ORDER BY name`)
|
tags, err := s.store.ListTags()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
err = json.NewEncoder(w).Encode(tags)
|
json.NewEncoder(w).Encode(tags) //nolint:errcheck
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
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)
|
http.Error(w, "bad request: name required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
tag, err := s.store.CreateTag(req.Name)
|
||||||
res, err := db.Exec(`INSERT INTO tags (name) VALUES (?)`, req.Name)
|
|
||||||
mu.Unlock()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
http.Error(w, "conflict: tag name already exists", http.StatusConflict)
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
err = json.NewEncoder(w).Encode(Tag{ID: int(id), Name: req.Name})
|
json.NewEncoder(w).Encode(tag) //nolint:errcheck
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
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}
|
// 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 {
|
if r.Method != http.MethodDelete {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -358,15 +197,12 @@ func TagByIDHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad request: invalid id", http.StatusBadRequest)
|
http.Error(w, "bad request: invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
found, err := s.store.DeleteTag(id)
|
||||||
res, err := db.Exec(`DELETE FROM tags WHERE id = ?`, id)
|
|
||||||
mu.Unlock()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n, _ := res.RowsAffected()
|
if !found {
|
||||||
if n == 0 {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -374,7 +210,7 @@ func TagByIDHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TagAssignmentHandler handles PUT /api/tag-assignments and DELETE /api/tag-assignments
|
// 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 {
|
switch r.Method {
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
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)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Check tag exists
|
exists, err := s.store.TagExists(req.TagID)
|
||||||
var exists int
|
if err != nil || !exists {
|
||||||
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)
|
http.Error(w, "not found: tag does not exist", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
if err := s.store.AssignTag(req.Image, req.TagID); err != nil {
|
||||||
_, 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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -429,10 +260,7 @@ func TagAssignmentHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
if err := s.store.UnassignTag(req.Image); err != nil {
|
||||||
_, err := db.Exec(`DELETE FROM tag_assignments WHERE image = ?`, req.Image)
|
|
||||||
mu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,46 @@
|
|||||||
package diunwebhook
|
package diunwebhook
|
||||||
|
|
||||||
func GetUpdatesMap() map[string]UpdateEntry {
|
import "database/sql"
|
||||||
m, _ := GetUpdates()
|
|
||||||
|
// 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
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdatesReset() {
|
|
||||||
InitDB(":memory:")
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResetTags() {
|
|
||||||
db.Exec(`DELETE FROM tag_assignments`)
|
|
||||||
db.Exec(`DELETE FROM tags`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResetWebhookSecret() {
|
|
||||||
SetWebhookSecret("")
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user