v2.30.0-pre4 15d9fe3

handler.go RawHistoryFolder

1// Package admin implements the HTTP adapter for the admin API.
2package admin
3
4import (
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"io"
10	"net/http"
11	"net/url"
12	"strconv"
13	"strings"
14	"time"
15
16	"github.com/bnema/zerowrap"
17
18	"github.com/bnema/gordon/internal/adapters/dto"
19	"github.com/bnema/gordon/internal/boundaries/in"
20	"github.com/bnema/gordon/internal/boundaries/out"
21	"github.com/bnema/gordon/internal/domain"
22	"github.com/bnema/gordon/internal/usecase/config"
23	"github.com/bnema/gordon/internal/usecase/registry"
24	"github.com/bnema/gordon/pkg/validation"
25)
26
27// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
28const maxAdminRequestSize = 1 << 20 // 1MB
29
30// maxLogLines is the maximum allowed number of log lines that can be requested.
31const maxLogLines = 10000
32
33type registryDeployService interface {
34	in.RegistryService
35	in.DeployCoordinator
36}
37
38// Handler implements the HTTP handler for the admin API.
39type Handler struct {
40	configSvc    in.ConfigService
41	authSvc      in.AuthService
42	containerSvc in.ContainerService
43	backupSvc    in.BackupService
44	imageSvc     in.ImageService
45	healthSvc    in.HealthService
46	secretSvc    in.SecretService
47	logSvc       in.LogService
48	volumeSvc    in.VolumeService
49	registrySvc  registryDeployService
50	previewSvc   previewService
51	eventBus     out.EventPublisher
52	log          zerowrap.Logger
53}
54
55// Type aliases for API responses using shared DTO types.
56type routeInfoResponse = dto.RouteInfo
57type attachmentResponse = dto.Attachment
58type routeResponse = dto.Route
59
60// toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
61func toAttachmentResponse(a domain.Attachment) dto.Attachment {
62	return dto.Attachment{
63		Name:        a.Name,
64		Image:       a.Image,
65		ContainerID: a.ContainerID,
66		Status:      a.Status,
67		Network:     a.Network,
68	}
69}
70
71// toRouteInfoResponse converts a domain.RouteInfo to a dto.RouteInfo.
72func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
73	attachments := make([]dto.Attachment, 0, len(r.Attachments))
74	for _, a := range r.Attachments {
75		attachments = append(attachments, toAttachmentResponse(a))
76	}
77	return dto.RouteInfo{
78		Domain:          r.Domain,
79		Image:           r.Image,
80		ContainerID:     r.ContainerID,
81		ContainerStatus: r.ContainerStatus,
82		Network:         r.Network,
83		Attachments:     attachments,
84	}
85}
86
87// toRouteResponse converts a domain.Route to a dto.Route.
88func toRouteResponse(r domain.Route) dto.Route {
89	return dto.Route{
90		Domain: r.Domain,
91		Image:  r.Image,
92		HTTPS:  r.HTTPS,
93	}
94}
95
96func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
97	var startedAt *time.Time
98	if !job.StartedAt.IsZero() {
99		t := job.StartedAt
100		startedAt = &t
101	}
102	var completedAt *time.Time
103	if !job.CompletedAt.IsZero() {
104		t := job.CompletedAt
105		completedAt = &t
106	}
107
108	return dto.BackupJob{
109		ID:          job.ID,
110		Domain:      job.Domain,
111		DBName:      job.DBName,
112		Schedule:    string(job.Schedule),
113		Type:        string(job.Type),
114		Status:      string(job.Status),
115		StartedAt:   startedAt,
116		CompletedAt: completedAt,
117		SizeBytes:   job.SizeBytes,
118		Error:       job.Error,
119	}
120}
121
122func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
123	return dto.DatabaseInfo{
124		Type:        string(db.Type),
125		Name:        db.Name,
126		Version:     db.Version,
127		Host:        db.Host,
128		Port:        db.Port,
129		ContainerID: db.ContainerID,
130		ImageName:   db.ImageName,
131	}
132}
133
134// HandlerDeps contains all dependencies for the admin HTTP handler.
135type HandlerDeps struct {
136	ConfigSvc    in.ConfigService
137	AuthSvc      in.AuthService
138	ContainerSvc in.ContainerService
139	HealthSvc    in.HealthService
140	SecretSvc    in.SecretService
141	LogSvc       in.LogService
142	RegistrySvc  registryDeployService
143	EventBus     out.EventPublisher
144	Log          zerowrap.Logger
145	BackupSvc    in.BackupService
146	PreviewSvc   previewService
147	ImageSvc     in.ImageService
148	VolumeSvc    in.VolumeService
149}
150
151// NewHandler creates a new admin HTTP handler.
152func NewHandler(deps HandlerDeps) *Handler {
153	return &Handler{
154		configSvc:    deps.ConfigSvc,
155		authSvc:      deps.AuthSvc,
156		containerSvc: deps.ContainerSvc,
157		backupSvc:    deps.BackupSvc,
158		imageSvc:     deps.ImageSvc,
159		healthSvc:    deps.HealthSvc,
160		secretSvc:    deps.SecretSvc,
161		logSvc:       deps.LogSvc,
162		volumeSvc:    deps.VolumeSvc,
163		registrySvc:  deps.RegistrySvc,
164		previewSvc:   deps.PreviewSvc,
165		eventBus:     deps.EventBus,
166		log:          deps.Log,
167	}
168}
169
170// RegisterRoutes registers the admin routes on the given mux.
171func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
172	mux.HandleFunc("/admin/", h.handleAdminRoutes)
173}
174
175// ServeHTTP implements http.Handler.
176func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
177	h.handleAdminRoutes(w, r)
178}
179
180func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
181	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
182		zerowrap.FieldLayer:   "adapter",
183		zerowrap.FieldAdapter: "http",
184		zerowrap.FieldHandler: "admin",
185		zerowrap.FieldMethod:  r.Method,
186		zerowrap.FieldPath:    r.URL.Path,
187	})
188	r = r.WithContext(ctx)
189
190	path := strings.TrimPrefix(r.URL.Path, "/admin")
191
192	// Route to appropriate handler
193	if handler, ok := h.matchRoute(path); ok {
194		handler(w, r, path)
195		return
196	}
197	h.sendError(w, http.StatusNotFound, "route not found")
198}
199
200// routeHandler is the signature for path-based route handlers.
201type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
202
203// matchRoute returns the handler for a given path, or false if not found.
204func (h *Handler) matchRoute(path string) (routeHandler, bool) {
205	// Exact match routes
206	exactRoutes := map[string]routeHandler{
207		"/networks":      func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
208		"/status":        func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
209		"/health":        func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
210		"/bootstrap":     func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },
211		"/reload":        func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
212		"/config":        func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
213		"/auth/verify":   func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
214		"/volumes":       func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) },
215		"/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) },
216	}
217	if handler, ok := exactRoutes[path]; ok {
218		return handler, true
219	}
220
221	// Prefix match routes
222	prefixRoutes := []struct {
223		prefix  string
224		handler routeHandler
225	}{
226		{"/backups", h.handleBackups},
227		{"/attachments/by-image", h.handleAttachmentsByImage},
228		{"/attachments", h.handleAttachmentsConfig},
229		{"/routes/by-image", h.handleRoutesByImage},
230		{"/routes", h.handleRoutes},
231		{"/secrets", h.handleSecrets},
232		{"/deploy-intent", h.handleDeployIntent},
233		{"/deploy", h.handleDeploy},
234		{"/restart", h.handleRestart},
235		{"/tags", h.handleTags},
236		{"/images", h.handleImages},
237		{"/logs", h.handleLogs},
238		{"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains},
239		{"/previews", h.handlePreviewList},
240		{"/preview", h.handlePreviewAction},
241	}
242	for _, route := range prefixRoutes {
243		if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
244			return route.handler, true
245		}
246	}
247
248	return nil, false
249}
250
251// handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint.
252// Returns all attachment targets associated with the given image name.
253func (h *Handler) handleAttachmentsByImage(w http.ResponseWriter, r *http.Request, path string) {
254	if r.Method != http.MethodGet {
255		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
256		return
257	}
258
259	ctx := r.Context()
260
261	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
262		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
263		return
264	}
265
266	imageName := strings.TrimPrefix(path, "/attachments/by-image/")
267	if imageName == "" || imageName == "/attachments/by-image" {
268		h.sendError(w, http.StatusBadRequest, "image name required in path")
269		return
270	}
271
272	imageName, err := url.PathUnescape(imageName)
273	if err != nil {
274		h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
275		return
276	}
277
278	targets := h.configSvc.FindAttachmentTargetsByImage(ctx, imageName)
279
280	h.sendJSON(w, http.StatusOK, dto.AttachmentTargetsByImageResponse{
281		Image:   imageName,
282		Targets: targets,
283	})
284}
285
286// handleDeployIntent handles /admin/deploy-intent/:image endpoint.
287// It registers a deploy intent, suppressing event-based deploys for the image.
288func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) {
289	if r.Method != http.MethodPost {
290		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
291		return
292	}
293
294	ctx := r.Context()
295	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
296		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
297		return
298	}
299
300	if h.registrySvc == nil {
301		h.sendError(w, http.StatusServiceUnavailable, "registry service unavailable")
302		return
303	}
304
305	rawName := strings.TrimPrefix(path, "/deploy-intent/")
306	if rawName == "" || rawName == "/deploy-intent" {
307		h.sendError(w, http.StatusBadRequest, "image name required")
308		return
309	}
310
311	imageName, err := url.PathUnescape(rawName)
312	if err != nil {
313		h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
314		return
315	}
316
317	log := zerowrap.FromCtx(ctx)
318	log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events")
319
320	h.registrySvc.SuppressDeployEvent(imageName)
321
322	h.sendJSON(w, http.StatusOK, map[string]string{
323		"status": "ok",
324		"image":  imageName,
325	})
326}
327
328func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
329	if r.Method != http.MethodPost {
330		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
331		return
332	}
333
334	ctx := r.Context()
335	log := zerowrap.FromCtx(ctx)
336
337	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
338		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
339		return
340	}
341
342	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
343
344	var req dto.BootstrapRequest
345	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
346		log.Warn().Err(err).Msg("invalid bootstrap JSON")
347		h.sendError(w, http.StatusBadRequest, "invalid JSON")
348		return
349	}
350
351	if req.Domain == "" {
352		h.sendError(w, http.StatusBadRequest, "domain is required")
353		return
354	}
355
356	if req.Image == "" {
357		h.sendError(w, http.StatusBadRequest, "image is required")
358		return
359	}
360
361	normalizedImage, err := config.NormalizeBootstrapImage(req.Image, h.configSvc.GetRegistryDomain())
362	if err != nil {
363		h.sendError(w, http.StatusBadRequest, err.Error())
364		return
365	}
366
367	if err := h.validateBootstrapPermissions(ctx, req); err != nil {
368		h.sendError(w, http.StatusForbidden, err.Error())
369		return
370	}
371
372	resp := dto.BootstrapResponse{
373		Domain: req.Domain,
374		Image:  normalizedImage,
375		Next:   fmt.Sprintf("push %s to trigger deployment", normalizedImage),
376	}
377	addStep := func(name, status string) {
378		resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
379	}
380
381	err = h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
382	switch {
383	case err == nil:
384		addStep("route", "configured")
385	case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
386		addStep("route", "failed")
387		h.sendError(w, http.StatusBadRequest, err.Error())
388		return
389	default:
390		addStep("route", "failed")
391		log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")
392		h.sendJSON(w, http.StatusInternalServerError, resp)
393		return
394	}
395
396	if err := h.bootstrapAttachments(ctx, h.configSvc, req.Domain, req.Attachments, &resp.Steps); err != nil {
397		log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachments")
398		h.sendJSON(w, http.StatusInternalServerError, resp)
399		return
400	}
401
402	if err := h.bootstrapSecrets(ctx, h.secretSvc, req.Domain, req.Env, &resp.Steps); err != nil {
403		log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env")
404		h.sendJSON(w, http.StatusInternalServerError, resp)
405		return
406	}
407
408	if err := h.bootstrapAttachmentSecrets(ctx, h.secretSvc, req.Domain, req.AttachmentEnv, &resp.Steps); err != nil {
409		log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachment env")
410		h.sendJSON(w, http.StatusInternalServerError, resp)
411		return
412	}
413
414	h.sendJSON(w, http.StatusOK, resp)
415}
416
417func (h *Handler) validateBootstrapPermissions(ctx context.Context, req dto.BootstrapRequest) error {
418	if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
419		return errors.New("insufficient permissions for secrets:write")
420	}
421	if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
422		return errors.New("insufficient permissions for config:write")
423	}
424
425	return nil
426}
427
428func (h *Handler) bootstrapAttachments(ctx context.Context, configSvc in.ConfigService, domainName string, attachments []string, steps *[]dto.BootstrapStep) error {
429	for _, attachment := range attachments {
430		err := configSvc.AddAttachment(ctx, domainName, attachment)
431		switch {
432		case err == nil:
433			*steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "created"})
434		case errors.Is(err, domain.ErrAttachmentExists):
435			*steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "noop"})
436		default:
437			*steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "failed"})
438			return err
439		}
440	}
441
442	return nil
443}
444
445func (h *Handler) bootstrapSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, env map[string]string, steps *[]dto.BootstrapStep) error {
446	if len(env) == 0 {
447		return nil
448	}
449
450	if err := secretSvc.Set(ctx, domainName, env); err != nil {
451		*steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "failed"})
452		return err
453	}
454
455	*steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "updated"})
456	return nil
457}
458
459func (h *Handler) bootstrapAttachmentSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, attachmentEnv map[string]map[string]string, steps *[]dto.BootstrapStep) error {
460	for service, env := range attachmentEnv {
461		if err := secretSvc.SetAttachment(ctx, domainName, service, env); err != nil {
462			*steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "failed"})
463			return err
464		}
465
466		*steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "updated"})
467	}
468
469	return nil
470}
471
472// sendJSON sends a JSON response.
473func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
474	w.Header().Set("Content-Type", "application/json")
475	w.WriteHeader(status)
476	if data != nil {
477		_ = json.NewEncoder(w).Encode(data)
478	}
479}
480
481// sendError sends an error response.
482func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
483	h.sendJSON(w, status, dto.ErrorResponse{Error: message})
484}
485
486// handleRoutes handles /admin/routes endpoints.
487func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
488	// Parse domain from path if present
489	routeDomain := strings.TrimPrefix(path, "/routes/")
490	if routeDomain == "/routes" {
491		routeDomain = ""
492	}
493
494	switch r.Method {
495	case http.MethodGet:
496		h.handleRoutesGet(w, r, routeDomain)
497	case http.MethodPost:
498		h.handleRoutesPost(w, r)
499	case http.MethodPut:
500		h.handleRoutesPut(w, r, routeDomain)
501	case http.MethodDelete:
502		h.handleRoutesDelete(w, r, routeDomain)
503	default:
504		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
505	}
506}
507
508func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
509	ctx := r.Context()
510
511	// Check read permission
512	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
513		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
514		return
515	}
516
517	if routeDomain == "" {
518		if r.URL.Query().Get("detailed") == "true" {
519			routes := h.containerSvc.ListRoutesWithDetails(ctx)
520			response := make([]routeInfoResponse, 0, len(routes))
521			for _, route := range routes {
522				response = append(response, toRouteInfoResponse(route))
523			}
524			h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response})
525			return
526		}
527
528		routes := h.configSvc.GetRoutes(ctx)
529		response := make([]routeResponse, 0, len(routes))
530		for _, route := range routes {
531			response = append(response, toRouteResponse(route))
532		}
533		h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response})
534		return
535	}
536
537	if strings.HasSuffix(routeDomain, "/attachments") {
538		parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
539		if parentDomain == "" {
540			h.sendError(w, http.StatusBadRequest, "domain required in path")
541			return
542		}
543		attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
544		response := make([]attachmentResponse, 0, len(attachments))
545		for _, attachment := range attachments {
546			response = append(response, toAttachmentResponse(attachment))
547		}
548		h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response})
549		return
550	}
551
552	route, err := h.configSvc.GetRoute(ctx, routeDomain)
553	if err != nil {
554		h.sendError(w, http.StatusNotFound, "route not found")
555		return
556	}
557	h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
558}
559
560func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
561	ctx := r.Context()
562	log := zerowrap.FromCtx(ctx)
563
564	// Check write permission
565	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
566		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
567		return
568	}
569
570	// Limit request body size
571	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
572
573	var route domain.Route
574	if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
575		log.Warn().Err(err).Msg("invalid route JSON")
576		h.sendError(w, http.StatusBadRequest, "invalid JSON")
577		return
578	}
579
580	if err := h.configSvc.AddRoute(ctx, route); err != nil {
581		log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
582		switch {
583		case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
584			h.sendError(w, http.StatusBadRequest, err.Error())
585		default:
586			h.sendError(w, http.StatusInternalServerError, "failed to add route")
587		}
588		return
589	}
590
591	log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
592	h.sendJSON(w, http.StatusCreated, toRouteResponse(route))
593}
594
595func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
596	ctx := r.Context()
597	log := zerowrap.FromCtx(ctx)
598
599	// Check write permission
600	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
601		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
602		return
603	}
604
605	if routeDomain == "" {
606		h.sendError(w, http.StatusBadRequest, "domain required in path")
607		return
608	}
609
610	// Limit request body size
611	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
612
613	var route domain.Route
614	if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
615		log.Warn().Err(err).Msg("invalid route JSON")
616		h.sendError(w, http.StatusBadRequest, "invalid JSON")
617		return
618	}
619
620	route.Domain = routeDomain
621
622	if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
623		log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
624		switch {
625		case errors.Is(err, domain.ErrRouteNotFound):
626			h.sendError(w, http.StatusNotFound, "route not found")
627		case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
628			h.sendError(w, http.StatusBadRequest, err.Error())
629		default:
630			h.sendError(w, http.StatusInternalServerError, "failed to update route")
631		}
632		return
633	}
634
635	log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
636	h.sendJSON(w, http.StatusOK, toRouteResponse(route))
637}
638
639func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
640	ctx := r.Context()
641	log := zerowrap.FromCtx(ctx)
642
643	// Check write permission
644	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
645		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
646		return
647	}
648
649	if routeDomain == "" {
650		h.sendError(w, http.StatusBadRequest, "domain required in path")
651		return
652	}
653
654	if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
655		log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
656		if errors.Is(err, domain.ErrRouteNotFound) {
657			h.sendError(w, http.StatusNotFound, "route not found")
658		} else {
659			h.sendError(w, http.StatusInternalServerError, "failed to remove route")
660		}
661		return
662	}
663
664	log.Info().Str("domain", routeDomain).Msg("route removed")
665	h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
666}
667
668// handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint.
669// Returns all routes associated with the given image name.
670func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, path string) {
671	if r.Method != http.MethodGet {
672		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
673		return
674	}
675
676	ctx := r.Context()
677
678	if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
679		h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
680		return
681	}
682
683	imageName := strings.TrimPrefix(path, "/routes/by-image/")
684	if imageName == "" || imageName == "/routes/by-image" {
685		h.sendError(w, http.StatusBadRequest, "image name required in path")
686		return
687	}
688
689	routes := h.configSvc.FindRoutesByImage(ctx, imageName)
690
691	response := make([]routeResponse, 0, len(routes))
692	for _, route := range routes {
693		response = append(response, toRouteResponse(route))
694	}
695
696	h.sendJSON(w, http.StatusOK, dto.RoutesByImageResponse{
697		Image:  imageName,
698		Routes: response,
699	})
700}
701
702func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
703	if r.Method != http.MethodGet {
704		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
705		return
706	}
707
708	ctx := r.Context()
709
710	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
711		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
712		return
713	}
714
715	networks, err := h.containerSvc.ListNetworks(ctx)
716	if err != nil {
717		h.sendError(w, http.StatusInternalServerError, "failed to list networks")
718		return
719	}
720
721	response := make([]dto.Network, 0, len(networks))
722	for _, network := range networks {
723		if network == nil {
724			continue
725		}
726		response = append(response, dto.Network{
727			Name:       network.Name,
728			Driver:     network.Driver,
729			Containers: append([]string{}, network.Containers...),
730		})
731	}
732
733	h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
734}
735
736// handleSecrets handles /admin/secrets endpoints.
737func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
738	// Parse path: /secrets/{domain} or /secrets/{domain}/{key}
739	parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
740	if len(parts) == 0 || parts[0] == "" {
741		h.sendError(w, http.StatusBadRequest, "domain required")
742		return
743	}
744
745	secretDomain := parts[0]
746
747	// Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}]
748	if len(parts) >= 2 && parts[1] == "attachments" {
749		switch r.Method {
750		case http.MethodPost:
751			// Expected path: /secrets/{domain}/attachments/{service}
752			if len(parts) != 3 {
753				h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}")
754				return
755			}
756			service := parts[2]
757			h.handleAttachmentSecrets(w, r, secretDomain, service, "")
758			return
759		case http.MethodDelete:
760			// Expected path: /secrets/{domain}/attachments/{service}/{key}
761			if len(parts) != 4 {
762				h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}/{key}")
763				return
764			}
765			service := parts[2]
766			attachmentKey := parts[3]
767			h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
768			return
769		default:
770			h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
771			return
772		}
773	}
774
775	secretKey := ""
776	if len(parts) > 1 {
777		secretKey = parts[1]
778	}
779
780	switch r.Method {
781	case http.MethodGet:
782		h.handleSecretsGet(w, r, secretDomain)
783	case http.MethodPost:
784		h.handleSecretsPost(w, r, secretDomain)
785	case http.MethodDelete:
786		h.handleSecretsDelete(w, r, secretDomain, secretKey)
787	default:
788		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
789	}
790}
791
792// handleSecretsPost handles POST /admin/secrets/{domain} - set secrets.
793func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) {
794	ctx := r.Context()
795	log := zerowrap.FromCtx(ctx)
796
797	if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
798		h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
799		return
800	}
801
802	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
803
804	var data map[string]string
805	if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
806		log.Warn().Err(err).Msg("invalid secrets JSON")
807		h.sendError(w, http.StatusBadRequest, "invalid JSON")
808		return
809	}
810
811	if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
812		log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
813		h.sendError(w, http.StatusBadRequest, "invalid domain")
814		return
815	}
816
817	log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
818	h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
819}
820
821// handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret.
822func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) {
823	ctx := r.Context()
824	log := zerowrap.FromCtx(ctx)
825
826	if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
827		h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
828		return
829	}
830	if secretKey == "" {
831		h.sendError(w, http.StatusBadRequest, "key required in path")
832		return
833	}
834
835	if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
836		log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
837		h.sendError(w, http.StatusBadRequest, "invalid domain")
838		return
839	}
840
841	log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
842	h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
843}
844
845// handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
846func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) {
847	ctx := r.Context()
848	log := zerowrap.FromCtx(ctx)
849
850	// Check read permission
851	if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
852		h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
853		return
854	}
855
856	// List secrets for domain (names only, not values) including attachments
857	keys, attachments, err := h.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
858	if err != nil {
859		log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
860		h.sendError(w, http.StatusBadRequest, "invalid domain")
861		return
862	}
863
864	// Convert attachments to DTO format
865	var attachmentDTOs []dto.AttachmentSecretsResponse
866	for _, att := range attachments {
867		attachmentDTOs = append(attachmentDTOs, dto.AttachmentSecretsResponse{
868			Service: att.Service,
869			Keys:    att.Keys,
870		})
871	}
872
873	h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{
874		Domain:      secretDomain,
875		Keys:        keys,
876		Attachments: attachmentDTOs,
877	})
878}
879
880// handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}] endpoints.
881func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) {
882	ctx := r.Context()
883	log := zerowrap.FromCtx(ctx)
884
885	switch r.Method {
886	case http.MethodPost:
887		if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
888			h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
889			return
890		}
891
892		r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
893
894		var data map[string]string
895		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
896			log.Warn().Err(err).Msg("invalid attachment secrets JSON")
897			h.sendError(w, http.StatusBadRequest, "invalid JSON")
898			return
899		}
900
901		if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil {
902			log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets")
903			h.sendError(w, http.StatusBadRequest, "invalid domain or service")
904			return
905		}
906
907		log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set")
908		h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
909
910	case http.MethodDelete:
911		if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
912			h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
913			return
914		}
915
916		if key == "" {
917			h.sendError(w, http.StatusBadRequest, "key required in path")
918			return
919		}
920
921		if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil {
922			log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret")
923			h.sendError(w, http.StatusBadRequest, "invalid domain or service")
924			return
925		}
926
927		log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted")
928		h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
929
930	default:
931		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
932	}
933}
934
935// handleHealth handles /admin/health endpoint.
936// Returns detailed health status for all routes including HTTP probe results.
937func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
938	if r.Method != http.MethodGet {
939		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
940		return
941	}
942
943	ctx := r.Context()
944
945	// Check read permission
946	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
947		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
948		return
949	}
950
951	health := h.healthSvc.CheckAllRoutes(ctx)
952
953	response := make(map[string]dto.HealthStatus, len(health))
954	for domain, healthStatus := range health {
955		if healthStatus == nil {
956			continue
957		}
958		response[domain] = dto.HealthStatus{
959			ContainerStatus: healthStatus.ContainerStatus,
960			HTTPStatus:      healthStatus.HTTPStatus,
961			ResponseTimeMs:  healthStatus.ResponseTimeMs,
962			Healthy:         healthStatus.Healthy,
963			Error:           healthStatus.Error,
964		}
965	}
966
967	h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
968}
969
970// handleStatus handles /admin/status endpoint.
971func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
972	if r.Method != http.MethodGet {
973		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
974		return
975	}
976
977	ctx := r.Context()
978
979	// Check read permission
980	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
981		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
982		return
983	}
984
985	routes := h.configSvc.GetRoutes(ctx)
986
987	// Get container statuses
988	statuses := make(map[string]string)
989	for _, route := range routes {
990		status := "unknown"
991		container, ok := h.containerSvc.Get(ctx, route.Domain)
992		if ok && container != nil {
993			status = container.Status
994		}
995		statuses[route.Domain] = status
996	}
997
998	status := dto.StatusResponse{
999		Routes:            len(routes),
1000		RegistryDomain:    h.configSvc.GetRegistryDomain(),
1001		RegistryPort:      h.configSvc.GetRegistryPort(),
1002		ServerPort:        h.configSvc.GetServerPort(),
1003		AutoRoute:         h.configSvc.IsAutoRouteEnabled(),
1004		NetworkIsolation:  h.configSvc.IsNetworkIsolationEnabled(),
1005		ContainerStatuses: statuses,
1006	}
1007
1008	h.sendJSON(w, http.StatusOK, status)
1009}
1010
1011// handleBackups handles /admin/backups endpoints.
1012func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) {
1013	if h.backupSvc == nil {
1014		h.sendError(w, http.StatusServiceUnavailable, "backup service not available")
1015		return
1016	}
1017
1018	path = strings.TrimSuffix(path, "/")
1019
1020	if path == "/backups" || path == "/backups/status" {
1021		h.handleBackupsStatus(w, r)
1022		return
1023	}
1024
1025	suffix := strings.TrimPrefix(path, "/backups/")
1026	parts := strings.Split(suffix, "/")
1027	if len(parts) == 0 || parts[0] == "" {
1028		h.sendError(w, http.StatusBadRequest, "domain required in path")
1029		return
1030	}
1031
1032	backupDomain := parts[0]
1033	if len(parts) == 1 {
1034		h.handleBackupsDomain(w, r, backupDomain)
1035		return
1036	}
1037
1038	if len(parts) == 2 && parts[1] == "detect" {
1039		h.handleBackupsDetect(w, r, backupDomain)
1040		return
1041	}
1042
1043	h.sendError(w, http.StatusNotFound, "route not found")
1044}
1045
1046func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {
1047	ctx := r.Context()
1048	if r.Method != http.MethodGet {
1049		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1050		return
1051	}
1052	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1053		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1054		return
1055	}
1056
1057	jobs, err := h.backupSvc.Status(ctx)
1058	if err != nil {
1059		h.sendError(w, http.StatusInternalServerError, "failed to get backup status")
1060		return
1061	}
1062	h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
1063}
1064
1065func (h *Handler) handleBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) {
1066	switch r.Method {
1067	case http.MethodGet:
1068		h.handleBackupsDomainList(w, r, backupDomain)
1069	case http.MethodPost:
1070		h.handleBackupsDomainRun(w, r, backupDomain)
1071	default:
1072		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1073	}
1074}
1075
1076func (h *Handler) handleBackupsDomainList(w http.ResponseWriter, r *http.Request, backupDomain string) {
1077	ctx := r.Context()
1078	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1079		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1080		return
1081	}
1082
1083	jobs, err := h.backupSvc.ListBackups(ctx, backupDomain)
1084	if err != nil {
1085		h.sendError(w, http.StatusInternalServerError, "failed to list backups")
1086		return
1087	}
1088
1089	h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
1090}
1091
1092func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request, backupDomain string) {
1093	ctx := r.Context()
1094	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1095		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1096		return
1097	}
1098
1099	var req dto.BackupRunRequest
1100	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1101	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
1102		h.sendError(w, http.StatusBadRequest, "invalid JSON")
1103		return
1104	}
1105
1106	result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB)
1107	if err != nil {
1108		log := zerowrap.FromCtx(ctx)
1109		log.Error().Err(err).Str("domain", backupDomain).Msg("backup run failed")
1110		h.sendError(w, http.StatusInternalServerError, "failed to run backup")
1111		return
1112	}
1113	log := zerowrap.FromCtx(ctx)
1114	log.Info().Str("domain", backupDomain).Str("db", req.DB).Str("job_id", result.Job.ID).Msg("backup completed via admin API")
1115
1116	job := toBackupJobResponse(result.Job)
1117	h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{
1118		Status: "completed",
1119		Backup: &job,
1120	})
1121}
1122
1123func (h *Handler) handleBackupsDetect(w http.ResponseWriter, r *http.Request, backupDomain string) {
1124	ctx := r.Context()
1125	if r.Method != http.MethodGet {
1126		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1127		return
1128	}
1129	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1130		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1131		return
1132	}
1133
1134	dbs, err := h.backupSvc.DetectDatabases(ctx, backupDomain)
1135	if err != nil {
1136		h.sendError(w, http.StatusInternalServerError, "failed to detect databases")
1137		return
1138	}
1139
1140	response := make([]dto.DatabaseInfo, 0, len(dbs))
1141	for _, db := range dbs {
1142		response = append(response, toDatabaseInfoResponse(db))
1143	}
1144
1145	h.sendJSON(w, http.StatusOK, dto.BackupDetectResponse{Databases: response})
1146}
1147
1148func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob {
1149	response := make([]dto.BackupJob, 0, len(jobs))
1150	for _, job := range jobs {
1151		response = append(response, toBackupJobResponse(job))
1152	}
1153	return response
1154}
1155
1156// handleReload handles /admin/reload endpoint.
1157// This reloads configuration from file into memory and triggers container sync.
1158func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
1159	if r.Method != http.MethodPost {
1160		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1161		return
1162	}
1163
1164	ctx := r.Context()
1165	log := zerowrap.FromCtx(ctx)
1166
1167	// Check write permission (reload modifies state)
1168	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1169		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1170		return
1171	}
1172
1173	// Reload configuration from file into memory
1174	// Using Reload() instead of Load() to ensure we re-read the file from disk
1175	if err := h.configSvc.Reload(ctx); err != nil {
1176		log.Error().Err(err).Msg("failed to reload config")
1177		h.sendError(w, http.StatusInternalServerError, "failed to reload config")
1178		return
1179	}
1180
1181	// Publish manual reload event to sync containers
1182	// ManualReloadHandler starts missing containers without restarting running ones
1183	if h.eventBus != nil {
1184		if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
1185			log.Warn().Err(err).Msg("failed to publish manual reload event")
1186		}
1187	}
1188
1189	log.Info().Msg("config reloaded via admin API")
1190	h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
1191}
1192
1193// handleConfig handles /admin/config endpoint.
1194func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
1195	if r.Method != http.MethodGet {
1196		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1197		return
1198	}
1199
1200	ctx := r.Context()
1201
1202	// Check read permission
1203	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1204		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1205		return
1206	}
1207
1208	routes := h.configSvc.GetRoutes(ctx)
1209	routeResponses := make([]dto.Route, 0, len(routes))
1210	for _, route := range routes {
1211		routeResponses = append(routeResponses, toRouteResponse(route))
1212	}
1213
1214	externalRoutes := h.configSvc.GetExternalRoutes()
1215	externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
1216	for domain, target := range externalRoutes {
1217		externalResponses = append(externalResponses, dto.ExternalRoute{
1218			Domain: domain,
1219			Target: target,
1220		})
1221	}
1222
1223	config := dto.ConfigResponse{
1224		Server: dto.ServerConfig{
1225			Port:           h.configSvc.GetServerPort(),
1226			RegistryPort:   h.configSvc.GetRegistryPort(),
1227			RegistryDomain: h.configSvc.GetRegistryDomain(),
1228			DataDir:        h.configSvc.GetDataDir(),
1229		},
1230		AutoRoute: dto.AutoRouteConfig{
1231			Enabled: h.configSvc.IsAutoRouteEnabled(),
1232		},
1233		NetworkIsolation: dto.NetworkIsolationConfig{
1234			Enabled: h.configSvc.IsNetworkIsolationEnabled(),
1235			Prefix:  h.configSvc.GetNetworkPrefix(),
1236		},
1237		Routes:         routeResponses,
1238		ExternalRoutes: externalResponses,
1239	}
1240
1241	h.sendJSON(w, http.StatusOK, config)
1242}
1243
1244// handleDeploy handles /admin/deploy/:domain endpoint.
1245// POST triggers a deployment for the specified domain.
1246func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
1247	if r.Method != http.MethodPost {
1248		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1249		return
1250	}
1251
1252	ctx := r.Context()
1253	log := zerowrap.FromCtx(ctx)
1254
1255	// Check write permission
1256	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1257		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1258		return
1259	}
1260
1261	// Parse domain from path
1262	deployDomain := strings.TrimPrefix(path, "/deploy/")
1263	if deployDomain == "" || deployDomain == "/deploy" {
1264		h.sendError(w, http.StatusBadRequest, "domain required in path")
1265		return
1266	}
1267	if err := validation.ValidateDomainParam(deployDomain); err != nil {
1268		h.sendError(w, http.StatusBadRequest, "invalid domain")
1269		return
1270	}
1271
1272	// Get the route for this domain
1273	route, err := h.configSvc.GetRoute(ctx, deployDomain)
1274	if err != nil {
1275		h.sendError(w, http.StatusNotFound, "route not found")
1276		return
1277	}
1278
1279	// Deploy is an internal server-side action: pull from local registry path.
1280	container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
1281	if err != nil {
1282		log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1283		h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
1284		return
1285	}
1286
1287	// Clear deploy event suppression now that the explicit deploy has completed.
1288	// This re-enables event-based deploys for future direct docker pushes.
1289	if route.Image != "" && h.registrySvc != nil {
1290		// Use the registry package's image name normaliser so digest-form refs
1291		// and multi-segment paths are handled correctly.
1292		imageName := registry.ExtractImageName(route.Image)
1293		h.registrySvc.ClearDeployEventSuppression(imageName)
1294	}
1295
1296	log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
1297	h.sendJSON(w, http.StatusOK, dto.DeployResponse{
1298		Status:      "deployed",
1299		ContainerID: container.ID,
1300		Domain:      deployDomain,
1301	})
1302}
1303
1304// handleRestart handles /admin/restart/:domain endpoint.
1305func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
1306	if r.Method != http.MethodPost {
1307		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1308		return
1309	}
1310
1311	ctx := r.Context()
1312	log := zerowrap.FromCtx(ctx)
1313
1314	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1315		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1316		return
1317	}
1318
1319	restartDomain := strings.TrimPrefix(path, "/restart/")
1320	if restartDomain == "" || restartDomain == "/restart" {
1321		h.sendError(w, http.StatusBadRequest, "domain required in path")
1322		return
1323	}
1324	if err := validation.ValidateDomainParam(restartDomain); err != nil {
1325		h.sendError(w, http.StatusBadRequest, "invalid domain")
1326		return
1327	}
1328
1329	withAttachments := r.URL.Query().Get("attachments") == "true"
1330
1331	// Use a detached context so the restart completes even if the HTTP client
1332	// disconnects. Podman restart can take 30+ seconds (SIGTERM + wait + start).
1333	restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
1334	defer cancel()
1335
1336	if err := h.containerSvc.Restart(restartCtx, restartDomain, withAttachments); err != nil {
1337		log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
1338		if errors.Is(err, domain.ErrContainerNotFound) {
1339			h.sendError(w, http.StatusNotFound, "container not found")
1340			return
1341		}
1342		h.sendError(w, http.StatusInternalServerError, "failed to restart container")
1343		return
1344	}
1345
1346	log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
1347	h.sendJSON(w, http.StatusOK, dto.RestartResponse{
1348		Status: "restarted",
1349		Domain: restartDomain,
1350	})
1351}
1352
1353// handleTags handles /admin/tags/:repository endpoint.
1354func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
1355	if r.Method != http.MethodGet {
1356		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1357		return
1358	}
1359
1360	ctx := r.Context()
1361
1362	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1363		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1364		return
1365	}
1366
1367	repository := strings.TrimPrefix(path, "/tags/")
1368	if repository == "" || repository == "/tags" {
1369		h.sendError(w, http.StatusBadRequest, "repository name required in path")
1370		return
1371	}
1372	if err := validation.ValidateRepositoryName(repository); err != nil {
1373		h.sendError(w, http.StatusBadRequest, "invalid repository name")
1374		return
1375	}
1376
1377	if h.registrySvc == nil {
1378		h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
1379		return
1380	}
1381
1382	tags, err := h.registrySvc.ListTags(ctx, repository)
1383	if err != nil {
1384		if errors.Is(err, registry.ErrRepositoryNotFound) {
1385			h.sendError(w, http.StatusNotFound, "repository not found")
1386			return
1387		}
1388		h.sendError(w, http.StatusInternalServerError, "failed to list tags")
1389		return
1390	}
1391
1392	h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
1393		Repository: repository,
1394		Tags:       tags,
1395	})
1396}
1397
1398// handleLogs handles /admin/logs endpoints.
1399// GET /admin/logs - Gordon process logs
1400// GET /admin/logs/:domain - Container logs for a specific domain
1401func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
1402	if r.Method != http.MethodGet {
1403		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1404		return
1405	}
1406
1407	ctx := r.Context()
1408	log := zerowrap.FromCtx(ctx)
1409
1410	// Check read permission
1411	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1412		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1413		return
1414	}
1415
1416	// Check if LogService is available
1417	if h.logSvc == nil {
1418		h.sendError(w, http.StatusServiceUnavailable, "log service not available")
1419		return
1420	}
1421
1422	// Parse query parameters
1423	lines := 50 // default
1424	if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
1425		if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
1426			lines = n
1427		}
1428	}
1429	if lines > maxLogLines {
1430		lines = maxLogLines
1431	}
1432	follow := r.URL.Query().Get("follow") == "true"
1433
1434	// Parse domain from path
1435	logDomain := strings.TrimPrefix(path, "/logs/")
1436	if logDomain == "/logs" {
1437		logDomain = ""
1438	}
1439
1440	if logDomain != "" {
1441		if err := validation.ValidateDomainParam(logDomain); err != nil {
1442			h.sendError(w, http.StatusBadRequest, "invalid domain")
1443			return
1444		}
1445	}
1446
1447	if logDomain == "" {
1448		// Gordon process logs
1449		h.handleProcessLogs(w, r, lines, follow)
1450	} else {
1451		// Container logs
1452		h.handleContainerLogs(w, r, logDomain, lines, follow)
1453	}
1454
1455	// Prevent unused variable warning when follow is implemented
1456	_ = log
1457}
1458
1459// handleProcessLogs handles Gordon process logs.
1460func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
1461	ctx := r.Context()
1462	log := zerowrap.FromCtx(ctx)
1463
1464	if follow {
1465		// SSE streaming
1466		h.streamProcessLogs(w, r, lines)
1467		return
1468	}
1469
1470	// Return last N lines as JSON
1471	logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
1472	if err != nil {
1473		log.Warn().Err(err).Msg("failed to get process logs")
1474		h.sendError(w, http.StatusInternalServerError, "failed to get logs")
1475		return
1476	}
1477
1478	h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
1479}
1480
1481// handleContainerLogs handles container logs for a specific domain.
1482func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
1483	ctx := r.Context()
1484	log := zerowrap.FromCtx(ctx)
1485
1486	if follow {
1487		// SSE streaming
1488		h.streamContainerLogs(w, r, logDomain, lines)
1489		return
1490	}
1491
1492	// Return last N lines as JSON
1493	logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
1494	if err != nil {
1495		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
1496		h.sendError(w, http.StatusInternalServerError, "failed to get container logs")
1497		return
1498	}
1499
1500	h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
1501		Domain: logDomain,
1502		Lines:  logLines,
1503	})
1504}
1505
1506// streamProcessLogs streams Gordon process logs via SSE.
1507func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
1508	ctx := r.Context()
1509	log := zerowrap.FromCtx(ctx)
1510
1511	// Check for flusher support before setting up SSE
1512	flusher, ok := w.(http.Flusher)
1513	if !ok {
1514		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1515		return
1516	}
1517
1518	// Set up SSE
1519	w.Header().Set("Content-Type", "text/event-stream")
1520	w.Header().Set("Cache-Control", "no-cache")
1521	w.Header().Set("Connection", "keep-alive")
1522	w.WriteHeader(http.StatusOK)
1523
1524	ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
1525	if err != nil {
1526		log.Warn().Err(err).Msg("failed to follow process logs")
1527		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
1528		flusher.Flush()
1529		return
1530	}
1531
1532	for {
1533		select {
1534		case line, ok := <-ch:
1535			if !ok {
1536				return
1537			}
1538			// SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1539			// First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1540			// Per the SSE spec, multi-line data must use separate "data:" prefixes.
1541			normalized := strings.ReplaceAll(line, "\r\n", "\n")
1542			normalized = strings.ReplaceAll(normalized, "\r", "\n")
1543			escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1544			_, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1545			flusher.Flush()
1546		case <-ctx.Done():
1547			return
1548		}
1549	}
1550}
1551
1552// streamContainerLogs streams container logs via SSE.
1553func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
1554	ctx := r.Context()
1555	log := zerowrap.FromCtx(ctx)
1556
1557	// Check for flusher support before setting up SSE
1558	flusher, ok := w.(http.Flusher)
1559	if !ok {
1560		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1561		return
1562	}
1563
1564	// Set up SSE
1565	w.Header().Set("Content-Type", "text/event-stream")
1566	w.Header().Set("Cache-Control", "no-cache")
1567	w.Header().Set("Connection", "keep-alive")
1568	w.WriteHeader(http.StatusOK)
1569
1570	ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
1571	if err != nil {
1572		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
1573		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
1574		flusher.Flush()
1575		return
1576	}
1577
1578	for {
1579		select {
1580		case line, ok := <-ch:
1581			if !ok {
1582				return
1583			}
1584			// SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1585			// First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1586			// Per the SSE spec, multi-line data must use separate "data:" prefixes.
1587			normalized := strings.ReplaceAll(line, "\r\n", "\n")
1588			normalized = strings.ReplaceAll(normalized, "\r", "\n")
1589			escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1590			_, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1591			flusher.Flush()
1592		case <-ctx.Done():
1593			return
1594		}
1595	}
1596}
1597
1598// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
1599func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
1600	// Parse target (domain or group) from path
1601	target := strings.TrimPrefix(path, "/attachments/")
1602	if target == "/attachments" {
1603		target = ""
1604	}
1605
1606	switch r.Method {
1607	case http.MethodGet:
1608		h.handleAttachmentsConfigGet(w, r, target)
1609	case http.MethodPost:
1610		h.handleAttachmentsConfigPost(w, r, target)
1611	case http.MethodDelete:
1612		h.handleAttachmentsConfigDelete(w, r, target)
1613	default:
1614		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1615	}
1616}
1617
1618func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
1619	ctx := r.Context()
1620
1621	// Check read permission
1622	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1623		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1624		return
1625	}
1626
1627	if target == "" {
1628		// List all attachments
1629		attachments := h.configSvc.GetAllAttachments(ctx)
1630		h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
1631		return
1632	}
1633
1634	// List attachments for specific target
1635	images, err := h.configSvc.GetAttachmentsFor(ctx, target)
1636	if err != nil {
1637		if errors.Is(err, domain.ErrAttachmentNotFound) {
1638			h.sendError(w, http.StatusNotFound, "no attachments found for target")
1639			return
1640		}
1641		h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
1642		return
1643	}
1644
1645	h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
1646}
1647
1648func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
1649	ctx := r.Context()
1650	log := zerowrap.FromCtx(ctx)
1651
1652	// Check write permission
1653	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1654		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1655		return
1656	}
1657
1658	if target == "" {
1659		h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1660		return
1661	}
1662
1663	// Limit request body size
1664	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1665
1666	var req dto.AttachmentAddRequest
1667	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1668		log.Warn().Err(err).Msg("invalid attachment JSON")
1669		h.sendError(w, http.StatusBadRequest, "invalid JSON")
1670		return
1671	}
1672
1673	if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
1674		log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
1675		switch {
1676		case errors.Is(err, domain.ErrAttachmentExists):
1677			h.sendError(w, http.StatusConflict, "attachment already exists")
1678		case errors.Is(err, domain.ErrAttachmentImageEmpty):
1679			h.sendError(w, http.StatusBadRequest, err.Error())
1680		case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1681			h.sendError(w, http.StatusBadRequest, err.Error())
1682		default:
1683			h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
1684		}
1685		return
1686	}
1687
1688	log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
1689	h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
1690}
1691
1692func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
1693	ctx := r.Context()
1694	log := zerowrap.FromCtx(ctx)
1695
1696	// Check write permission
1697	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1698		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1699		return
1700	}
1701
1702	if target == "" {
1703		h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1704		return
1705	}
1706
1707	// Parse image from path: /attachments/{target}/{image}
1708	// target at this point contains "{domain}/{image}" or just "{domain}"
1709	parts := strings.SplitN(target, "/", 2)
1710	if len(parts) != 2 {
1711		h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
1712		return
1713	}
1714
1715	domainOrGroup := parts[0]
1716	image := parts[1]
1717
1718	if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
1719		log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
1720		switch {
1721		case errors.Is(err, domain.ErrAttachmentNotFound):
1722			h.sendError(w, http.StatusNotFound, "attachment not found")
1723		case errors.Is(err, domain.ErrAttachmentImageEmpty):
1724			h.sendError(w, http.StatusBadRequest, err.Error())
1725		case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1726			h.sendError(w, http.StatusBadRequest, err.Error())
1727		default:
1728			h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
1729		}
1730		return
1731	}
1732
1733	log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
1734	h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1735}
1736
1737func (h *Handler) handleAutoRouteAllowedDomains(w http.ResponseWriter, r *http.Request, path string) {
1738	switch r.Method {
1739	case http.MethodGet:
1740		h.handleAutoRouteAllowedDomainsGet(w, r)
1741	case http.MethodPost:
1742		h.handleAutoRouteAllowedDomainsPost(w, r)
1743	case http.MethodDelete:
1744		h.handleAutoRouteAllowedDomainsDelete(w, r, path)
1745	default:
1746		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1747	}
1748}
1749
1750func (h *Handler) handleAutoRouteAllowedDomainsGet(w http.ResponseWriter, r *http.Request) {
1751	ctx := r.Context()
1752	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1753		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1754		return
1755	}
1756
1757	domains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx)
1758	if err != nil {
1759		h.sendError(w, http.StatusInternalServerError, "failed to get auto-route allowed domains")
1760		return
1761	}
1762
1763	h.sendJSON(w, http.StatusOK, dto.AutoRouteAllowedDomainsResponse{Domains: domains})
1764}
1765
1766func (h *Handler) handleAutoRouteAllowedDomainsPost(w http.ResponseWriter, r *http.Request) {
1767	ctx := r.Context()
1768	log := zerowrap.FromCtx(ctx)
1769	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1770		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1771		return
1772	}
1773
1774	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1775	var req dto.AutoRouteAllowedDomainRequest
1776	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1777		log.Warn().Err(err).Msg("invalid auto-route allowlist JSON")
1778		h.sendError(w, http.StatusBadRequest, "invalid JSON")
1779		return
1780	}
1781
1782	if err := h.configSvc.AddAutoRouteAllowedDomain(ctx, req.Pattern); err != nil {
1783		if errors.Is(err, domain.ErrInvalidDomainPattern) {
1784			h.sendError(w, http.StatusBadRequest, err.Error())
1785		} else {
1786			log.Error().Err(err).Str("pattern", req.Pattern).Msg("failed to add auto-route allowed domain")
1787			h.sendError(w, http.StatusInternalServerError, "failed to add auto-route allowed domain")
1788		}
1789		return
1790	}
1791
1792	h.sendJSON(w, http.StatusCreated, dto.AutoRouteStatusResponse{Status: "added"})
1793}
1794
1795func (h *Handler) handleAutoRouteAllowedDomainsDelete(w http.ResponseWriter, r *http.Request, path string) {
1796	ctx := r.Context()
1797	log := zerowrap.FromCtx(ctx)
1798	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1799		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1800		return
1801	}
1802
1803	raw := strings.TrimPrefix(path, "/autoroute/allowed-domains/")
1804	if raw == path || raw == "" || raw == "/" {
1805		h.sendError(w, http.StatusBadRequest, "missing domain pattern")
1806		return
1807	}
1808	pattern, err := url.PathUnescape(raw)
1809	if err != nil || strings.TrimSpace(pattern) == "" {
1810		h.sendError(w, http.StatusBadRequest, "invalid domain pattern")
1811		return
1812	}
1813
1814	if err := h.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern); err != nil {
1815		log.Error().Err(err).Str("pattern", pattern).Msg("failed to remove auto-route allowed domain")
1816		h.sendError(w, http.StatusInternalServerError, "failed to remove auto-route allowed domain")
1817		return
1818	}
1819
1820	h.sendJSON(w, http.StatusOK, dto.AutoRouteStatusResponse{Status: "removed"})
1821}
1822
1823// handleAuthVerify handles /admin/auth/verify endpoint.
1824// Validates authentication session and returns token status.
1825func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
1826	ctx := r.Context()
1827
1828	// Only allow GET method
1829	if r.Method != http.MethodGet {
1830		w.Header().Set("Allow", http.MethodGet)
1831		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1832		return
1833	}
1834
1835	// Call use case to get auth status
1836	status, err := h.authSvc.GetAuthStatus(ctx)
1837	if err != nil {
1838		h.sendError(w, http.StatusInternalServerError, "failed to get auth status")
1839		return
1840	}
1841
1842	// Convert domain.AuthStatus to DTO
1843	response := dto.AuthVerifyResponse{
1844		Valid:     status.Valid,
1845		Subject:   status.Subject,
1846		Scopes:    status.Scopes,
1847		ExpiresAt: status.ExpiresAt,
1848		IssuedAt:  status.IssuedAt,
1849	}
1850
1851	h.sendJSON(w, http.StatusOK, response)
1852}

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