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