v2.11.0 dca6537

handler.go RawHistoryFolder

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 the container
841	container, err := h.containerSvc.Deploy(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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close