1// Package admin implements the HTTP adapter for the admin API.
2package admin
3
4import (
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net/http"
9 "strconv"
10 "strings"
11
12 "github.com/bnema/zerowrap"
13
14 "github.com/bnema/gordon/internal/adapters/dto"
15 "github.com/bnema/gordon/internal/boundaries/in"
16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"
18 "github.com/bnema/gordon/internal/usecase/registry"
19 "github.com/bnema/gordon/pkg/validation"
20)
21
22// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
23const maxAdminRequestSize = 1 << 20 // 1MB
24
25// maxLogLines is the maximum allowed number of log lines that can be requested.
26const maxLogLines = 10000
27
28// Handler implements the HTTP handler for the admin API.
29type Handler struct {
30 configSvc in.ConfigService
31 authSvc in.AuthService
32 containerSvc in.ContainerService
33 healthSvc in.HealthService
34 secretSvc in.SecretService
35 logSvc in.LogService
36 registrySvc in.RegistryService
37 eventBus out.EventPublisher
38 log zerowrap.Logger
39}
40
41// Type aliases for API responses using shared DTO types.
42type routeInfoResponse = dto.RouteInfo
43type attachmentResponse = dto.Attachment
44type routeResponse = dto.Route
45
46// toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
47func toAttachmentResponse(a domain.Attachment) dto.Attachment {
48 return dto.Attachment{
49 Name: a.Name,
50 Image: a.Image,
51 ContainerID: a.ContainerID,
52 Status: a.Status,
53 Network: a.Network,
54 }
55}
56
57// toRouteInfoResponse converts a domain.RouteInfo to a dto.RouteInfo.
58func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
59 attachments := make([]dto.Attachment, 0, len(r.Attachments))
60 for _, a := range r.Attachments {
61 attachments = append(attachments, toAttachmentResponse(a))
62 }
63 return dto.RouteInfo{
64 Domain: r.Domain,
65 Image: r.Image,
66 ContainerID: r.ContainerID,
67 ContainerStatus: r.ContainerStatus,
68 Network: r.Network,
69 Attachments: attachments,
70 }
71}
72
73// toRouteResponse converts a domain.Route to a dto.Route.
74func toRouteResponse(r domain.Route) dto.Route {
75 return dto.Route{
76 Domain: r.Domain,
77 Image: r.Image,
78 HTTPS: r.HTTPS,
79 }
80}
81
82// NewHandler creates a new admin HTTP handler.
83func NewHandler(
84 configSvc in.ConfigService,
85 authSvc in.AuthService,
86 containerSvc in.ContainerService,
87 healthSvc in.HealthService,
88 secretSvc in.SecretService,
89 logSvc in.LogService,
90 registrySvc in.RegistryService,
91 eventBus out.EventPublisher,
92 log zerowrap.Logger,
93) *Handler {
94 return &Handler{
95 configSvc: configSvc,
96 authSvc: authSvc,
97 containerSvc: containerSvc,
98 healthSvc: healthSvc,
99 secretSvc: secretSvc,
100 logSvc: logSvc,
101 registrySvc: registrySvc,
102 eventBus: eventBus,
103 log: log,
104 }
105}
106
107// RegisterRoutes registers the admin routes on the given mux.
108func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
109 mux.HandleFunc("/admin/", h.handleAdminRoutes)
110}
111
112// ServeHTTP implements http.Handler.
113func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
114 h.handleAdminRoutes(w, r)
115}
116
117func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
118 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
119 zerowrap.FieldLayer: "adapter",
120 zerowrap.FieldAdapter: "http",
121 zerowrap.FieldHandler: "admin",
122 zerowrap.FieldMethod: r.Method,
123 zerowrap.FieldPath: r.URL.Path,
124 })
125 r = r.WithContext(ctx)
126
127 path := strings.TrimPrefix(r.URL.Path, "/admin")
128
129 // Route to appropriate handler
130 if handler, ok := h.matchRoute(path); ok {
131 handler(w, r, path)
132 return
133 }
134 h.sendError(w, http.StatusNotFound, "route not found")
135}
136
137// routeHandler is the signature for path-based route handlers.
138type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
139
140// matchRoute returns the handler for a given path, or false if not found.
141func (h *Handler) matchRoute(path string) (routeHandler, bool) {
142 // Exact match routes
143 exactRoutes := map[string]routeHandler{
144 "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
145 "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
146 "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
147 "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
148 "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
149 "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
150 }
151 if handler, ok := exactRoutes[path]; ok {
152 return handler, true
153 }
154
155 // Prefix match routes
156 prefixRoutes := []struct {
157 prefix string
158 handler routeHandler
159 }{
160 {"/attachments", h.handleAttachmentsConfig},
161 {"/routes", h.handleRoutes},
162 {"/secrets", h.handleSecrets},
163 {"/deploy", h.handleDeploy},
164 {"/restart", h.handleRestart},
165 {"/tags", h.handleTags},
166 {"/logs", h.handleLogs},
167 }
168 for _, route := range prefixRoutes {
169 if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
170 return route.handler, true
171 }
172 }
173
174 return nil, false
175}
176
177// sendJSON sends a JSON response.
178func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
179 w.Header().Set("Content-Type", "application/json")
180 w.WriteHeader(status)
181 if data != nil {
182 _ = json.NewEncoder(w).Encode(data)
183 }
184}
185
186// sendError sends an error response.
187func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
188 h.sendJSON(w, status, dto.ErrorResponse{Error: message})
189}
190
191// handleRoutes handles /admin/routes endpoints.
192func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
193 // Parse domain from path if present
194 routeDomain := strings.TrimPrefix(path, "/routes/")
195 if routeDomain == "/routes" {
196 routeDomain = ""
197 }
198
199 switch r.Method {
200 case http.MethodGet:
201 h.handleRoutesGet(w, r, routeDomain)
202 case http.MethodPost:
203 h.handleRoutesPost(w, r)
204 case http.MethodPut:
205 h.handleRoutesPut(w, r, routeDomain)
206 case http.MethodDelete:
207 h.handleRoutesDelete(w, r, routeDomain)
208 default:
209 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
210 }
211}
212
213func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
214 ctx := r.Context()
215
216 // Check read permission
217 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
218 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
219 return
220 }
221
222 if routeDomain == "" {
223 if r.URL.Query().Get("detailed") == "true" {
224 routes := h.containerSvc.ListRoutesWithDetails(ctx)
225 response := make([]routeInfoResponse, 0, len(routes))
226 for _, route := range routes {
227 response = append(response, toRouteInfoResponse(route))
228 }
229 h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response})
230 return
231 }
232
233 routes := h.configSvc.GetRoutes(ctx)
234 response := make([]routeResponse, 0, len(routes))
235 for _, route := range routes {
236 response = append(response, toRouteResponse(route))
237 }
238 h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response})
239 return
240 }
241
242 if strings.HasSuffix(routeDomain, "/attachments") {
243 parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
244 if parentDomain == "" {
245 h.sendError(w, http.StatusBadRequest, "domain required in path")
246 return
247 }
248 attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
249 response := make([]attachmentResponse, 0, len(attachments))
250 for _, attachment := range attachments {
251 response = append(response, toAttachmentResponse(attachment))
252 }
253 h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response})
254 return
255 }
256
257 route, err := h.configSvc.GetRoute(ctx, routeDomain)
258 if err != nil {
259 h.sendError(w, http.StatusNotFound, "route not found")
260 return
261 }
262 h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
263}
264
265func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
266 ctx := r.Context()
267 log := zerowrap.FromCtx(ctx)
268
269 // Check write permission
270 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
271 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
272 return
273 }
274
275 // Limit request body size
276 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
277
278 var route domain.Route
279 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
280 log.Warn().Err(err).Msg("invalid route JSON")
281 h.sendError(w, http.StatusBadRequest, "invalid JSON")
282 return
283 }
284
285 // Validate image exists in registry before adding route
286 if h.registrySvc != nil && route.Image != "" {
287 imageName, imageRef := validation.ParseImageReference(route.Image)
288 if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
289 if errors.Is(err, domain.ErrManifestNotFound) {
290 log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
291 h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
292 } else {
293 log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
294 h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
295 }
296 return
297 }
298 }
299
300 if err := h.configSvc.AddRoute(ctx, route); err != nil {
301 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
302 switch {
303 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
304 h.sendError(w, http.StatusBadRequest, err.Error())
305 default:
306 h.sendError(w, http.StatusInternalServerError, "failed to add route")
307 }
308 return
309 }
310
311 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
312 h.sendJSON(w, http.StatusCreated, toRouteResponse(route))
313}
314
315func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
316 ctx := r.Context()
317 log := zerowrap.FromCtx(ctx)
318
319 // Check write permission
320 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
321 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
322 return
323 }
324
325 if routeDomain == "" {
326 h.sendError(w, http.StatusBadRequest, "domain required in path")
327 return
328 }
329
330 // Limit request body size
331 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
332
333 var route domain.Route
334 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
335 log.Warn().Err(err).Msg("invalid route JSON")
336 h.sendError(w, http.StatusBadRequest, "invalid JSON")
337 return
338 }
339
340 route.Domain = routeDomain
341
342 // Validate image exists in registry before updating route
343 if h.registrySvc != nil && route.Image != "" {
344 imageName, imageRef := validation.ParseImageReference(route.Image)
345 if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
346 if errors.Is(err, domain.ErrManifestNotFound) {
347 log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
348 h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
349 } else {
350 log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
351 h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
352 }
353 return
354 }
355 }
356
357 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
358 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
359 switch {
360 case errors.Is(err, domain.ErrRouteNotFound):
361 h.sendError(w, http.StatusNotFound, "route not found")
362 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
363 h.sendError(w, http.StatusBadRequest, err.Error())
364 default:
365 h.sendError(w, http.StatusInternalServerError, "failed to update route")
366 }
367 return
368 }
369
370 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
371 h.sendJSON(w, http.StatusOK, toRouteResponse(route))
372}
373
374func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
375 ctx := r.Context()
376 log := zerowrap.FromCtx(ctx)
377
378 // Check write permission
379 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
380 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
381 return
382 }
383
384 if routeDomain == "" {
385 h.sendError(w, http.StatusBadRequest, "domain required in path")
386 return
387 }
388
389 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
390 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
391 if errors.Is(err, domain.ErrRouteNotFound) {
392 h.sendError(w, http.StatusNotFound, "route not found")
393 } else {
394 h.sendError(w, http.StatusInternalServerError, "failed to remove route")
395 }
396 return
397 }
398
399 log.Info().Str("domain", routeDomain).Msg("route removed")
400 h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
401}
402
403func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
404 if r.Method != http.MethodGet {
405 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
406 return
407 }
408
409 ctx := r.Context()
410
411 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
412 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
413 return
414 }
415
416 networks, err := h.containerSvc.ListNetworks(ctx)
417 if err != nil {
418 h.sendError(w, http.StatusInternalServerError, "failed to list networks")
419 return
420 }
421
422 response := make([]dto.Network, 0, len(networks))
423 for _, network := range networks {
424 if network == nil {
425 continue
426 }
427 var labelsCopy map[string]string
428 if network.Labels != nil {
429 labelsCopy = make(map[string]string, len(network.Labels))
430 for key, value := range network.Labels {
431 labelsCopy[key] = value
432 }
433 }
434 response = append(response, dto.Network{
435 ID: network.ID,
436 Name: network.Name,
437 Driver: network.Driver,
438 Containers: append([]string{}, network.Containers...),
439 Labels: labelsCopy,
440 })
441 }
442
443 h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
444}
445
446// handleSecrets handles /admin/secrets endpoints.
447func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
448 // Parse path: /secrets/{domain} or /secrets/{domain}/{key}
449 parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
450 if len(parts) == 0 || parts[0] == "" {
451 h.sendError(w, http.StatusBadRequest, "domain required")
452 return
453 }
454
455 secretDomain := parts[0]
456
457 // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}]
458 if len(parts) >= 2 && parts[1] == "attachments" {
459 switch r.Method {
460 case http.MethodPost:
461 // Expected path: /secrets/{domain}/attachments/{service}
462 if len(parts) != 3 {
463 h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}")
464 return
465 }
466 service := parts[2]
467 h.handleAttachmentSecrets(w, r, secretDomain, service, "")
468 return
469 case http.MethodDelete:
470 // Expected path: /secrets/{domain}/attachments/{service}/{key}
471 if len(parts) != 4 {
472 h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}/{key}")
473 return
474 }
475 service := parts[2]
476 attachmentKey := parts[3]
477 h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
478 return
479 default:
480 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
481 return
482 }
483 }
484
485 secretKey := ""
486 if len(parts) > 1 {
487 secretKey = parts[1]
488 }
489
490 switch r.Method {
491 case http.MethodGet:
492 h.handleSecretsGet(w, r, secretDomain)
493 case http.MethodPost:
494 h.handleSecretsPost(w, r, secretDomain)
495 case http.MethodDelete:
496 h.handleSecretsDelete(w, r, secretDomain, secretKey)
497 default:
498 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
499 }
500}
501
502// handleSecretsPost handles POST /admin/secrets/{domain} - set secrets.
503func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) {
504 ctx := r.Context()
505 log := zerowrap.FromCtx(ctx)
506
507 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
508 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
509 return
510 }
511
512 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
513
514 var data map[string]string
515 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
516 log.Warn().Err(err).Msg("invalid secrets JSON")
517 h.sendError(w, http.StatusBadRequest, "invalid JSON")
518 return
519 }
520
521 if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
522 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
523 h.sendError(w, http.StatusBadRequest, "invalid domain")
524 return
525 }
526
527 log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
528 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
529}
530
531// handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret.
532func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) {
533 ctx := r.Context()
534 log := zerowrap.FromCtx(ctx)
535
536 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
537 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
538 return
539 }
540 if secretKey == "" {
541 h.sendError(w, http.StatusBadRequest, "key required in path")
542 return
543 }
544
545 if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
546 log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
547 h.sendError(w, http.StatusBadRequest, "invalid domain")
548 return
549 }
550
551 log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
552 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
553}
554
555// handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
556func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) {
557 ctx := r.Context()
558 log := zerowrap.FromCtx(ctx)
559
560 // Check read permission
561 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
562 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
563 return
564 }
565
566 // List secrets for domain (names only, not values) including attachments
567 keys, attachments, err := h.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
568 if err != nil {
569 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
570 h.sendError(w, http.StatusBadRequest, "invalid domain")
571 return
572 }
573
574 // Convert attachments to DTO format
575 var attachmentDTOs []dto.AttachmentSecretsResponse
576 for _, att := range attachments {
577 attachmentDTOs = append(attachmentDTOs, dto.AttachmentSecretsResponse{
578 Service: att.Service,
579 Keys: att.Keys,
580 })
581 }
582
583 h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{
584 Domain: secretDomain,
585 Keys: keys,
586 Attachments: attachmentDTOs,
587 })
588}
589
590// handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}] endpoints.
591func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) {
592 ctx := r.Context()
593 log := zerowrap.FromCtx(ctx)
594
595 switch r.Method {
596 case http.MethodPost:
597 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
598 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
599 return
600 }
601
602 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
603
604 var data map[string]string
605 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
606 log.Warn().Err(err).Msg("invalid attachment secrets JSON")
607 h.sendError(w, http.StatusBadRequest, "invalid JSON")
608 return
609 }
610
611 if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil {
612 log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets")
613 h.sendError(w, http.StatusBadRequest, "invalid domain or service")
614 return
615 }
616
617 log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set")
618 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
619
620 case http.MethodDelete:
621 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
622 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
623 return
624 }
625
626 if key == "" {
627 h.sendError(w, http.StatusBadRequest, "key required in path")
628 return
629 }
630
631 if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil {
632 log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret")
633 h.sendError(w, http.StatusBadRequest, "invalid domain or service")
634 return
635 }
636
637 log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted")
638 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
639
640 default:
641 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
642 }
643}
644
645// handleHealth handles /admin/health endpoint.
646// Returns detailed health status for all routes including HTTP probe results.
647func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
648 if r.Method != http.MethodGet {
649 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
650 return
651 }
652
653 ctx := r.Context()
654
655 // Check read permission
656 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
657 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
658 return
659 }
660
661 health := h.healthSvc.CheckAllRoutes(ctx)
662
663 response := make(map[string]dto.HealthStatus, len(health))
664 for domain, healthStatus := range health {
665 if healthStatus == nil {
666 continue
667 }
668 response[domain] = dto.HealthStatus{
669 ContainerStatus: healthStatus.ContainerStatus,
670 HTTPStatus: healthStatus.HTTPStatus,
671 ResponseTimeMs: healthStatus.ResponseTimeMs,
672 Healthy: healthStatus.Healthy,
673 Error: healthStatus.Error,
674 }
675 }
676
677 h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
678}
679
680// handleStatus handles /admin/status endpoint.
681func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
682 if r.Method != http.MethodGet {
683 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
684 return
685 }
686
687 ctx := r.Context()
688
689 // Check read permission
690 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
691 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
692 return
693 }
694
695 routes := h.configSvc.GetRoutes(ctx)
696
697 // Get container statuses
698 statuses := make(map[string]string)
699 for _, route := range routes {
700 status := "unknown"
701 container, ok := h.containerSvc.Get(ctx, route.Domain)
702 if ok && container != nil {
703 status = container.Status
704 }
705 statuses[route.Domain] = status
706 }
707
708 status := dto.StatusResponse{
709 Routes: len(routes),
710 RegistryDomain: h.configSvc.GetRegistryDomain(),
711 RegistryPort: h.configSvc.GetRegistryPort(),
712 ServerPort: h.configSvc.GetServerPort(),
713 AutoRoute: h.configSvc.IsAutoRouteEnabled(),
714 NetworkIsolation: h.configSvc.IsNetworkIsolationEnabled(),
715 ContainerStatuses: statuses,
716 }
717
718 h.sendJSON(w, http.StatusOK, status)
719}
720
721// handleReload handles /admin/reload endpoint.
722// This reloads configuration from file into memory and triggers container sync.
723func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
724 if r.Method != http.MethodPost {
725 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
726 return
727 }
728
729 ctx := r.Context()
730 log := zerowrap.FromCtx(ctx)
731
732 // Check write permission (reload modifies state)
733 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
734 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
735 return
736 }
737
738 // Reload configuration from file into memory
739 // Using Reload() instead of Load() to ensure we re-read the file from disk
740 if err := h.configSvc.Reload(ctx); err != nil {
741 log.Error().Err(err).Msg("failed to reload config")
742 h.sendError(w, http.StatusInternalServerError, "failed to reload config")
743 return
744 }
745
746 // Publish manual reload event to sync containers
747 // ManualReloadHandler starts missing containers without restarting running ones
748 if h.eventBus != nil {
749 if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
750 log.Warn().Err(err).Msg("failed to publish manual reload event")
751 }
752 }
753
754 log.Info().Msg("config reloaded via admin API")
755 h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
756}
757
758// handleConfig handles /admin/config endpoint.
759func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
760 if r.Method != http.MethodGet {
761 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
762 return
763 }
764
765 ctx := r.Context()
766
767 // Check read permission
768 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
769 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
770 return
771 }
772
773 routes := h.configSvc.GetRoutes(ctx)
774 routeResponses := make([]dto.Route, 0, len(routes))
775 for _, route := range routes {
776 routeResponses = append(routeResponses, toRouteResponse(route))
777 }
778
779 externalRoutes := h.configSvc.GetExternalRoutes()
780 externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
781 for domain, target := range externalRoutes {
782 externalResponses = append(externalResponses, dto.ExternalRoute{
783 Domain: domain,
784 Target: target,
785 })
786 }
787
788 config := dto.ConfigResponse{
789 Server: dto.ServerConfig{
790 Port: h.configSvc.GetServerPort(),
791 RegistryPort: h.configSvc.GetRegistryPort(),
792 RegistryDomain: h.configSvc.GetRegistryDomain(),
793 DataDir: h.configSvc.GetDataDir(),
794 },
795 AutoRoute: dto.AutoRouteConfig{
796 Enabled: h.configSvc.IsAutoRouteEnabled(),
797 },
798 NetworkIsolation: dto.NetworkIsolationConfig{
799 Enabled: h.configSvc.IsNetworkIsolationEnabled(),
800 Prefix: h.configSvc.GetNetworkPrefix(),
801 },
802 Routes: routeResponses,
803 ExternalRoutes: externalResponses,
804 }
805
806 h.sendJSON(w, http.StatusOK, config)
807}
808
809// handleDeploy handles /admin/deploy/:domain endpoint.
810// POST triggers a deployment for the specified domain.
811func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
812 if r.Method != http.MethodPost {
813 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
814 return
815 }
816
817 ctx := r.Context()
818 log := zerowrap.FromCtx(ctx)
819
820 // Check write permission
821 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
822 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
823 return
824 }
825
826 // Parse domain from path
827 deployDomain := strings.TrimPrefix(path, "/deploy/")
828 if deployDomain == "" || deployDomain == "/deploy" {
829 h.sendError(w, http.StatusBadRequest, "domain required in path")
830 return
831 }
832
833 // Get the route for this domain
834 route, err := h.configSvc.GetRoute(ctx, deployDomain)
835 if err != nil {
836 h.sendError(w, http.StatusNotFound, "route not found")
837 return
838 }
839
840 // Deploy is an internal server-side action: pull from local registry path.
841 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
842 if err != nil {
843 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
844 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
845 return
846 }
847
848 log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
849 h.sendJSON(w, http.StatusOK, dto.DeployResponse{
850 Status: "deployed",
851 ContainerID: container.ID,
852 Domain: deployDomain,
853 })
854}
855
856// handleRestart handles /admin/restart/:domain endpoint.
857func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
858 if r.Method != http.MethodPost {
859 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
860 return
861 }
862
863 ctx := r.Context()
864 log := zerowrap.FromCtx(ctx)
865
866 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
867 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
868 return
869 }
870
871 restartDomain := strings.TrimPrefix(path, "/restart/")
872 if restartDomain == "" || restartDomain == "/restart" {
873 h.sendError(w, http.StatusBadRequest, "domain required in path")
874 return
875 }
876 if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {
877 h.sendError(w, http.StatusBadRequest, "invalid domain")
878 return
879 }
880
881 withAttachments := r.URL.Query().Get("attachments") == "true"
882
883 if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
884 log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
885 if errors.Is(err, domain.ErrContainerNotFound) {
886 h.sendError(w, http.StatusNotFound, "container not found")
887 return
888 }
889 h.sendError(w, http.StatusInternalServerError, "failed to restart container")
890 return
891 }
892
893 log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
894 h.sendJSON(w, http.StatusOK, dto.RestartResponse{
895 Status: "restarted",
896 Domain: restartDomain,
897 })
898}
899
900// handleTags handles /admin/tags/:repository endpoint.
901func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
902 if r.Method != http.MethodGet {
903 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
904 return
905 }
906
907 ctx := r.Context()
908
909 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
910 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
911 return
912 }
913
914 repository := strings.TrimPrefix(path, "/tags/")
915 if repository == "" || repository == "/tags" {
916 h.sendError(w, http.StatusBadRequest, "repository name required in path")
917 return
918 }
919 if err := validation.ValidateRepositoryName(repository); err != nil {
920 h.sendError(w, http.StatusBadRequest, "invalid repository name")
921 return
922 }
923
924 if h.registrySvc == nil {
925 h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
926 return
927 }
928
929 tags, err := h.registrySvc.ListTags(ctx, repository)
930 if err != nil {
931 if errors.Is(err, registry.ErrRepositoryNotFound) {
932 h.sendError(w, http.StatusNotFound, "repository not found")
933 return
934 }
935 h.sendError(w, http.StatusInternalServerError, "failed to list tags")
936 return
937 }
938
939 h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
940 Repository: repository,
941 Tags: tags,
942 })
943}
944
945// handleLogs handles /admin/logs endpoints.
946// GET /admin/logs - Gordon process logs
947// GET /admin/logs/:domain - Container logs for a specific domain
948func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
949 if r.Method != http.MethodGet {
950 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
951 return
952 }
953
954 ctx := r.Context()
955 log := zerowrap.FromCtx(ctx)
956
957 // Check read permission
958 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
959 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
960 return
961 }
962
963 // Check if LogService is available
964 if h.logSvc == nil {
965 h.sendError(w, http.StatusServiceUnavailable, "log service not available")
966 return
967 }
968
969 // Parse query parameters
970 lines := 50 // default
971 if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
972 if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
973 lines = n
974 }
975 }
976 if lines > maxLogLines {
977 lines = maxLogLines
978 }
979 follow := r.URL.Query().Get("follow") == "true"
980
981 // Parse domain from path
982 logDomain := strings.TrimPrefix(path, "/logs/")
983 if logDomain == "/logs" {
984 logDomain = ""
985 }
986
987 if logDomain == "" {
988 // Gordon process logs
989 h.handleProcessLogs(w, r, lines, follow)
990 } else {
991 // Container logs
992 h.handleContainerLogs(w, r, logDomain, lines, follow)
993 }
994
995 // Prevent unused variable warning when follow is implemented
996 _ = log
997}
998
999// handleProcessLogs handles Gordon process logs.
1000func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
1001 ctx := r.Context()
1002 log := zerowrap.FromCtx(ctx)
1003
1004 if follow {
1005 // SSE streaming
1006 h.streamProcessLogs(w, r, lines)
1007 return
1008 }
1009
1010 // Return last N lines as JSON
1011 logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
1012 if err != nil {
1013 log.Warn().Err(err).Msg("failed to get process logs")
1014 h.sendError(w, http.StatusInternalServerError, "failed to get logs")
1015 return
1016 }
1017
1018 h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
1019}
1020
1021// handleContainerLogs handles container logs for a specific domain.
1022func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
1023 ctx := r.Context()
1024 log := zerowrap.FromCtx(ctx)
1025
1026 if follow {
1027 // SSE streaming
1028 h.streamContainerLogs(w, r, logDomain, lines)
1029 return
1030 }
1031
1032 // Return last N lines as JSON
1033 logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
1034 if err != nil {
1035 log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
1036 h.sendError(w, http.StatusInternalServerError, "failed to get container logs")
1037 return
1038 }
1039
1040 h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
1041 Domain: logDomain,
1042 Lines: logLines,
1043 })
1044}
1045
1046// streamProcessLogs streams Gordon process logs via SSE.
1047func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
1048 ctx := r.Context()
1049 log := zerowrap.FromCtx(ctx)
1050
1051 // Check for flusher support before setting up SSE
1052 flusher, ok := w.(http.Flusher)
1053 if !ok {
1054 h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1055 return
1056 }
1057
1058 // Set up SSE
1059 w.Header().Set("Content-Type", "text/event-stream")
1060 w.Header().Set("Cache-Control", "no-cache")
1061 w.Header().Set("Connection", "keep-alive")
1062 w.WriteHeader(http.StatusOK)
1063
1064 ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
1065 if err != nil {
1066 log.Warn().Err(err).Msg("failed to follow process logs")
1067 _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
1068 flusher.Flush()
1069 return
1070 }
1071
1072 for {
1073 select {
1074 case line, ok := <-ch:
1075 if !ok {
1076 return
1077 }
1078 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1079 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1080 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
1081 normalized := strings.ReplaceAll(line, "\r\n", "\n")
1082 normalized = strings.ReplaceAll(normalized, "\r", "\n")
1083 escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1084 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1085 flusher.Flush()
1086 case <-ctx.Done():
1087 return
1088 }
1089 }
1090}
1091
1092// streamContainerLogs streams container logs via SSE.
1093func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
1094 ctx := r.Context()
1095 log := zerowrap.FromCtx(ctx)
1096
1097 // Check for flusher support before setting up SSE
1098 flusher, ok := w.(http.Flusher)
1099 if !ok {
1100 h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1101 return
1102 }
1103
1104 // Set up SSE
1105 w.Header().Set("Content-Type", "text/event-stream")
1106 w.Header().Set("Cache-Control", "no-cache")
1107 w.Header().Set("Connection", "keep-alive")
1108 w.WriteHeader(http.StatusOK)
1109
1110 ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
1111 if err != nil {
1112 log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
1113 _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
1114 flusher.Flush()
1115 return
1116 }
1117
1118 for {
1119 select {
1120 case line, ok := <-ch:
1121 if !ok {
1122 return
1123 }
1124 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1125 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1126 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
1127 normalized := strings.ReplaceAll(line, "\r\n", "\n")
1128 normalized = strings.ReplaceAll(normalized, "\r", "\n")
1129 escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1130 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1131 flusher.Flush()
1132 case <-ctx.Done():
1133 return
1134 }
1135 }
1136}
1137
1138// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
1139func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
1140 // Parse target (domain or group) from path
1141 target := strings.TrimPrefix(path, "/attachments/")
1142 if target == "/attachments" {
1143 target = ""
1144 }
1145
1146 switch r.Method {
1147 case http.MethodGet:
1148 h.handleAttachmentsConfigGet(w, r, target)
1149 case http.MethodPost:
1150 h.handleAttachmentsConfigPost(w, r, target)
1151 case http.MethodDelete:
1152 h.handleAttachmentsConfigDelete(w, r, target)
1153 default:
1154 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1155 }
1156}
1157
1158func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
1159 ctx := r.Context()
1160
1161 // Check read permission
1162 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1163 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1164 return
1165 }
1166
1167 if target == "" {
1168 // List all attachments
1169 attachments := h.configSvc.GetAllAttachments(ctx)
1170 h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
1171 return
1172 }
1173
1174 // List attachments for specific target
1175 images, err := h.configSvc.GetAttachmentsFor(ctx, target)
1176 if err != nil {
1177 if errors.Is(err, domain.ErrAttachmentNotFound) {
1178 h.sendError(w, http.StatusNotFound, "no attachments found for target")
1179 return
1180 }
1181 h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
1182 return
1183 }
1184
1185 h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
1186}
1187
1188func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
1189 ctx := r.Context()
1190 log := zerowrap.FromCtx(ctx)
1191
1192 // Check write permission
1193 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1194 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1195 return
1196 }
1197
1198 if target == "" {
1199 h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1200 return
1201 }
1202
1203 // Limit request body size
1204 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1205
1206 var req dto.AttachmentAddRequest
1207 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1208 log.Warn().Err(err).Msg("invalid attachment JSON")
1209 h.sendError(w, http.StatusBadRequest, "invalid JSON")
1210 return
1211 }
1212
1213 if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
1214 log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
1215 switch {
1216 case errors.Is(err, domain.ErrAttachmentExists):
1217 h.sendError(w, http.StatusConflict, "attachment already exists")
1218 case errors.Is(err, domain.ErrAttachmentImageEmpty):
1219 h.sendError(w, http.StatusBadRequest, err.Error())
1220 case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1221 h.sendError(w, http.StatusBadRequest, err.Error())
1222 default:
1223 h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
1224 }
1225 return
1226 }
1227
1228 log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
1229 h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
1230}
1231
1232func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
1233 ctx := r.Context()
1234 log := zerowrap.FromCtx(ctx)
1235
1236 // Check write permission
1237 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1238 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1239 return
1240 }
1241
1242 if target == "" {
1243 h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1244 return
1245 }
1246
1247 // Parse image from path: /attachments/{target}/{image}
1248 // target at this point contains "{domain}/{image}" or just "{domain}"
1249 parts := strings.SplitN(target, "/", 2)
1250 if len(parts) != 2 {
1251 h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
1252 return
1253 }
1254
1255 domainOrGroup := parts[0]
1256 image := parts[1]
1257
1258 if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
1259 log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
1260 switch {
1261 case errors.Is(err, domain.ErrAttachmentNotFound):
1262 h.sendError(w, http.StatusNotFound, "attachment not found")
1263 case errors.Is(err, domain.ErrAttachmentImageEmpty):
1264 h.sendError(w, http.StatusBadRequest, err.Error())
1265 case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1266 h.sendError(w, http.StatusBadRequest, err.Error())
1267 default:
1268 h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
1269 }
1270 return
1271 }
1272
1273 log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
1274 h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1275}
1276
1277// handleAuthVerify handles /admin/auth/verify endpoint.
1278// Validates authentication session and returns token status.
1279func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
1280 ctx := r.Context()
1281
1282 // Only allow GET method
1283 if r.Method != http.MethodGet {
1284 w.Header().Set("Allow", http.MethodGet)
1285 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1286 return
1287 }
1288
1289 // Call use case to get auth status
1290 status, err := h.authSvc.GetAuthStatus(ctx)
1291 if err != nil {
1292 h.sendError(w, http.StatusInternalServerError, "failed to get auth status")
1293 return
1294 }
1295
1296 // Convert domain.AuthStatus to DTO
1297 response := dto.AuthVerifyResponse{
1298 Valid: status.Valid,
1299 Subject: status.Subject,
1300 Scopes: status.Scopes,
1301 ExpiresAt: status.ExpiresAt,
1302 IssuedAt: status.IssuedAt,
1303 }
1304
1305 h.sendJSON(w, http.StatusOK, response)
1306}