- Add DATABASE_URL env var branching: pgx/PostgreSQL when set, SQLite when absent
- Blank-import github.com/jackc/pgx/v5/stdlib to register 'pgx' driver
- Log 'Using PostgreSQL database' or 'Using SQLite database at {path}' on startup
- Replace RunMigrations with RunSQLiteMigrations (rename from Plan 01)
- Fix TagsHandler UNIQUE detection to use strings.ToLower for cross-dialect compat
273 lines
7.7 KiB
Go
273 lines
7.7 KiB
Go
package diunwebhook
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const maxBodyBytes = 1 << 20 // 1 MB
|
|
|
|
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"`
|
|
}
|
|
|
|
// Server holds the application dependencies for HTTP handlers.
|
|
type Server struct {
|
|
store Store
|
|
webhookSecret string
|
|
}
|
|
|
|
// NewServer constructs a Server backed by the given Store.
|
|
func NewServer(store Store, webhookSecret string) *Server {
|
|
return &Server{store: store, webhookSecret: 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(s.webhookSecret)) != 1 {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
var event DiunEvent
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
|
var maxBytesErr *http.MaxBytesError
|
|
if errors.As(err, &maxBytesErr) {
|
|
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
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 := s.store.UpsertEvent(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)
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
image := strings.TrimPrefix(r.URL.Path, "/api/updates/")
|
|
if image == "" {
|
|
http.Error(w, "bad request: image name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
found, err := s.store.AcknowledgeUpdate(image)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !found {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// TagsHandler handles GET /api/tags and POST /api/tags
|
|
func (s *Server) TagsHandler(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
tags, err := s.store.ListTags()
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(tags) //nolint:errcheck
|
|
|
|
case http.MethodPost:
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
var maxBytesErr *http.MaxBytesError
|
|
if errors.As(err, &maxBytesErr) {
|
|
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
http.Error(w, "bad request: name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
http.Error(w, "bad request: name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
tag, err := s.store.CreateTag(req.Name)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
http.Error(w, "conflict: tag name already exists", http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(tag) //nolint:errcheck
|
|
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// TagByIDHandler handles DELETE /api/tags/{id}
|
|
func (s *Server) 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
|
|
}
|
|
found, err := s.store.DeleteTag(id)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !found {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// TagAssignmentHandler handles PUT /api/tag-assignments and DELETE /api/tag-assignments
|
|
func (s *Server) TagAssignmentHandler(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodPut:
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
var req struct {
|
|
Image string `json:"image"`
|
|
TagID int `json:"tag_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
var maxBytesErr *http.MaxBytesError
|
|
if errors.As(err, &maxBytesErr) {
|
|
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Image == "" {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
exists, err := s.store.TagExists(req.TagID)
|
|
if err != nil || !exists {
|
|
http.Error(w, "not found: tag does not exist", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err := s.store.AssignTag(req.Image, req.TagID); err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
case http.MethodDelete:
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
var req struct {
|
|
Image string `json:"image"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
var maxBytesErr *http.MaxBytesError
|
|
if errors.As(err, &maxBytesErr) {
|
|
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Image == "" {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.store.UnassignTag(req.Image); err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|