v2.30.0-pre 7341e0e

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
1268	// Get the route for this domain
1269	route, err := h.configSvc.GetRoute(ctx, deployDomain)
1270	if err != nil {
1271		h.sendError(w, http.StatusNotFound, "route not found")
1272		return
1273	}
1274
1275	// Deploy is an internal server-side action: pull from local registry path.
1276	container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
1277	if err != nil {
1278		log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1279		h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
1280		return
1281	}
1282
1283	// Clear deploy event suppression now that the explicit deploy has completed.
1284	// This re-enables event-based deploys for future direct docker pushes.
1285	if route.Image != "" && h.registrySvc != nil {
1286		// Use the registry package's image name normaliser so digest-form refs
1287		// and multi-segment paths are handled correctly.
1288		imageName := registry.ExtractImageName(route.Image)
1289		h.registrySvc.ClearDeployEventSuppression(imageName)
1290	}
1291
1292	log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
1293	h.sendJSON(w, http.StatusOK, dto.DeployResponse{
1294		Status:      "deployed",
1295		ContainerID: container.ID,
1296		Domain:      deployDomain,
1297	})
1298}
1299
1300// handleRestart handles /admin/restart/:domain endpoint.
1301func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
1302	if r.Method != http.MethodPost {
1303		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1304		return
1305	}
1306
1307	ctx := r.Context()
1308	log := zerowrap.FromCtx(ctx)
1309
1310	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1311		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1312		return
1313	}
1314
1315	restartDomain := strings.TrimPrefix(path, "/restart/")
1316	if restartDomain == "" || restartDomain == "/restart" {
1317		h.sendError(w, http.StatusBadRequest, "domain required in path")
1318		return
1319	}
1320	if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {
1321		h.sendError(w, http.StatusBadRequest, "invalid domain")
1322		return
1323	}
1324
1325	withAttachments := r.URL.Query().Get("attachments") == "true"
1326
1327	// Use a detached context so the restart completes even if the HTTP client
1328	// disconnects. Podman restart can take 30+ seconds (SIGTERM + wait + start).
1329	restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
1330	defer cancel()
1331
1332	if err := h.containerSvc.Restart(restartCtx, restartDomain, withAttachments); err != nil {
1333		log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
1334		if errors.Is(err, domain.ErrContainerNotFound) {
1335			h.sendError(w, http.StatusNotFound, "container not found")
1336			return
1337		}
1338		h.sendError(w, http.StatusInternalServerError, "failed to restart container")
1339		return
1340	}
1341
1342	log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
1343	h.sendJSON(w, http.StatusOK, dto.RestartResponse{
1344		Status: "restarted",
1345		Domain: restartDomain,
1346	})
1347}
1348
1349// handleTags handles /admin/tags/:repository endpoint.
1350func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
1351	if r.Method != http.MethodGet {
1352		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1353		return
1354	}
1355
1356	ctx := r.Context()
1357
1358	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1359		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1360		return
1361	}
1362
1363	repository := strings.TrimPrefix(path, "/tags/")
1364	if repository == "" || repository == "/tags" {
1365		h.sendError(w, http.StatusBadRequest, "repository name required in path")
1366		return
1367	}
1368	if err := validation.ValidateRepositoryName(repository); err != nil {
1369		h.sendError(w, http.StatusBadRequest, "invalid repository name")
1370		return
1371	}
1372
1373	if h.registrySvc == nil {
1374		h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
1375		return
1376	}
1377
1378	tags, err := h.registrySvc.ListTags(ctx, repository)
1379	if err != nil {
1380		if errors.Is(err, registry.ErrRepositoryNotFound) {
1381			h.sendError(w, http.StatusNotFound, "repository not found")
1382			return
1383		}
1384		h.sendError(w, http.StatusInternalServerError, "failed to list tags")
1385		return
1386	}
1387
1388	h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
1389		Repository: repository,
1390		Tags:       tags,
1391	})
1392}
1393
1394// handleLogs handles /admin/logs endpoints.
1395// GET /admin/logs - Gordon process logs
1396// GET /admin/logs/:domain - Container logs for a specific domain
1397func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
1398	if r.Method != http.MethodGet {
1399		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1400		return
1401	}
1402
1403	ctx := r.Context()
1404	log := zerowrap.FromCtx(ctx)
1405
1406	// Check read permission
1407	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1408		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1409		return
1410	}
1411
1412	// Check if LogService is available
1413	if h.logSvc == nil {
1414		h.sendError(w, http.StatusServiceUnavailable, "log service not available")
1415		return
1416	}
1417
1418	// Parse query parameters
1419	lines := 50 // default
1420	if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
1421		if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
1422			lines = n
1423		}
1424	}
1425	if lines > maxLogLines {
1426		lines = maxLogLines
1427	}
1428	follow := r.URL.Query().Get("follow") == "true"
1429
1430	// Parse domain from path
1431	logDomain := strings.TrimPrefix(path, "/logs/")
1432	if logDomain == "/logs" {
1433		logDomain = ""
1434	}
1435
1436	if logDomain == "" {
1437		// Gordon process logs
1438		h.handleProcessLogs(w, r, lines, follow)
1439	} else {
1440		// Container logs
1441		h.handleContainerLogs(w, r, logDomain, lines, follow)
1442	}
1443
1444	// Prevent unused variable warning when follow is implemented
1445	_ = log
1446}
1447
1448// handleProcessLogs handles Gordon process logs.
1449func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
1450	ctx := r.Context()
1451	log := zerowrap.FromCtx(ctx)
1452
1453	if follow {
1454		// SSE streaming
1455		h.streamProcessLogs(w, r, lines)
1456		return
1457	}
1458
1459	// Return last N lines as JSON
1460	logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
1461	if err != nil {
1462		log.Warn().Err(err).Msg("failed to get process logs")
1463		h.sendError(w, http.StatusInternalServerError, "failed to get logs")
1464		return
1465	}
1466
1467	h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
1468}
1469
1470// handleContainerLogs handles container logs for a specific domain.
1471func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
1472	ctx := r.Context()
1473	log := zerowrap.FromCtx(ctx)
1474
1475	if follow {
1476		// SSE streaming
1477		h.streamContainerLogs(w, r, logDomain, lines)
1478		return
1479	}
1480
1481	// Return last N lines as JSON
1482	logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
1483	if err != nil {
1484		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
1485		h.sendError(w, http.StatusInternalServerError, "failed to get container logs")
1486		return
1487	}
1488
1489	h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
1490		Domain: logDomain,
1491		Lines:  logLines,
1492	})
1493}
1494
1495// streamProcessLogs streams Gordon process logs via SSE.
1496func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
1497	ctx := r.Context()
1498	log := zerowrap.FromCtx(ctx)
1499
1500	// Check for flusher support before setting up SSE
1501	flusher, ok := w.(http.Flusher)
1502	if !ok {
1503		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1504		return
1505	}
1506
1507	// Set up SSE
1508	w.Header().Set("Content-Type", "text/event-stream")
1509	w.Header().Set("Cache-Control", "no-cache")
1510	w.Header().Set("Connection", "keep-alive")
1511	w.WriteHeader(http.StatusOK)
1512
1513	ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
1514	if err != nil {
1515		log.Warn().Err(err).Msg("failed to follow process logs")
1516		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
1517		flusher.Flush()
1518		return
1519	}
1520
1521	for {
1522		select {
1523		case line, ok := <-ch:
1524			if !ok {
1525				return
1526			}
1527			// SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1528			// First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1529			// Per the SSE spec, multi-line data must use separate "data:" prefixes.
1530			normalized := strings.ReplaceAll(line, "\r\n", "\n")
1531			normalized = strings.ReplaceAll(normalized, "\r", "\n")
1532			escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1533			_, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1534			flusher.Flush()
1535		case <-ctx.Done():
1536			return
1537		}
1538	}
1539}
1540
1541// streamContainerLogs streams container logs via SSE.
1542func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
1543	ctx := r.Context()
1544	log := zerowrap.FromCtx(ctx)
1545
1546	// Check for flusher support before setting up SSE
1547	flusher, ok := w.(http.Flusher)
1548	if !ok {
1549		h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1550		return
1551	}
1552
1553	// Set up SSE
1554	w.Header().Set("Content-Type", "text/event-stream")
1555	w.Header().Set("Cache-Control", "no-cache")
1556	w.Header().Set("Connection", "keep-alive")
1557	w.WriteHeader(http.StatusOK)
1558
1559	ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
1560	if err != nil {
1561		log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
1562		_, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
1563		flusher.Flush()
1564		return
1565	}
1566
1567	for {
1568		select {
1569		case line, ok := <-ch:
1570			if !ok {
1571				return
1572			}
1573			// SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1574			// First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1575			// Per the SSE spec, multi-line data must use separate "data:" prefixes.
1576			normalized := strings.ReplaceAll(line, "\r\n", "\n")
1577			normalized = strings.ReplaceAll(normalized, "\r", "\n")
1578			escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1579			_, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1580			flusher.Flush()
1581		case <-ctx.Done():
1582			return
1583		}
1584	}
1585}
1586
1587// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
1588func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
1589	// Parse target (domain or group) from path
1590	target := strings.TrimPrefix(path, "/attachments/")
1591	if target == "/attachments" {
1592		target = ""
1593	}
1594
1595	switch r.Method {
1596	case http.MethodGet:
1597		h.handleAttachmentsConfigGet(w, r, target)
1598	case http.MethodPost:
1599		h.handleAttachmentsConfigPost(w, r, target)
1600	case http.MethodDelete:
1601		h.handleAttachmentsConfigDelete(w, r, target)
1602	default:
1603		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1604	}
1605}
1606
1607func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
1608	ctx := r.Context()
1609
1610	// Check read permission
1611	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1612		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1613		return
1614	}
1615
1616	if target == "" {
1617		// List all attachments
1618		attachments := h.configSvc.GetAllAttachments(ctx)
1619		h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
1620		return
1621	}
1622
1623	// List attachments for specific target
1624	images, err := h.configSvc.GetAttachmentsFor(ctx, target)
1625	if err != nil {
1626		if errors.Is(err, domain.ErrAttachmentNotFound) {
1627			h.sendError(w, http.StatusNotFound, "no attachments found for target")
1628			return
1629		}
1630		h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
1631		return
1632	}
1633
1634	h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
1635}
1636
1637func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
1638	ctx := r.Context()
1639	log := zerowrap.FromCtx(ctx)
1640
1641	// Check write permission
1642	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1643		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1644		return
1645	}
1646
1647	if target == "" {
1648		h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1649		return
1650	}
1651
1652	// Limit request body size
1653	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1654
1655	var req dto.AttachmentAddRequest
1656	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1657		log.Warn().Err(err).Msg("invalid attachment JSON")
1658		h.sendError(w, http.StatusBadRequest, "invalid JSON")
1659		return
1660	}
1661
1662	if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
1663		log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
1664		switch {
1665		case errors.Is(err, domain.ErrAttachmentExists):
1666			h.sendError(w, http.StatusConflict, "attachment already exists")
1667		case errors.Is(err, domain.ErrAttachmentImageEmpty):
1668			h.sendError(w, http.StatusBadRequest, err.Error())
1669		case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1670			h.sendError(w, http.StatusBadRequest, err.Error())
1671		default:
1672			h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
1673		}
1674		return
1675	}
1676
1677	log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
1678	h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
1679}
1680
1681func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
1682	ctx := r.Context()
1683	log := zerowrap.FromCtx(ctx)
1684
1685	// Check write permission
1686	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1687		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1688		return
1689	}
1690
1691	if target == "" {
1692		h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1693		return
1694	}
1695
1696	// Parse image from path: /attachments/{target}/{image}
1697	// target at this point contains "{domain}/{image}" or just "{domain}"
1698	parts := strings.SplitN(target, "/", 2)
1699	if len(parts) != 2 {
1700		h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
1701		return
1702	}
1703
1704	domainOrGroup := parts[0]
1705	image := parts[1]
1706
1707	if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
1708		log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
1709		switch {
1710		case errors.Is(err, domain.ErrAttachmentNotFound):
1711			h.sendError(w, http.StatusNotFound, "attachment not found")
1712		case errors.Is(err, domain.ErrAttachmentImageEmpty):
1713			h.sendError(w, http.StatusBadRequest, err.Error())
1714		case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1715			h.sendError(w, http.StatusBadRequest, err.Error())
1716		default:
1717			h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
1718		}
1719		return
1720	}
1721
1722	log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
1723	h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1724}
1725
1726func (h *Handler) handleAutoRouteAllowedDomains(w http.ResponseWriter, r *http.Request, path string) {
1727	switch r.Method {
1728	case http.MethodGet:
1729		h.handleAutoRouteAllowedDomainsGet(w, r)
1730	case http.MethodPost:
1731		h.handleAutoRouteAllowedDomainsPost(w, r)
1732	case http.MethodDelete:
1733		h.handleAutoRouteAllowedDomainsDelete(w, r, path)
1734	default:
1735		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1736	}
1737}
1738
1739func (h *Handler) handleAutoRouteAllowedDomainsGet(w http.ResponseWriter, r *http.Request) {
1740	ctx := r.Context()
1741	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1742		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1743		return
1744	}
1745
1746	domains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx)
1747	if err != nil {
1748		h.sendError(w, http.StatusInternalServerError, "failed to get auto-route allowed domains")
1749		return
1750	}
1751
1752	h.sendJSON(w, http.StatusOK, dto.AutoRouteAllowedDomainsResponse{Domains: domains})
1753}
1754
1755func (h *Handler) handleAutoRouteAllowedDomainsPost(w http.ResponseWriter, r *http.Request) {
1756	ctx := r.Context()
1757	log := zerowrap.FromCtx(ctx)
1758	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1759		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1760		return
1761	}
1762
1763	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1764	var req dto.AutoRouteAllowedDomainRequest
1765	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1766		log.Warn().Err(err).Msg("invalid auto-route allowlist JSON")
1767		h.sendError(w, http.StatusBadRequest, "invalid JSON")
1768		return
1769	}
1770
1771	if err := h.configSvc.AddAutoRouteAllowedDomain(ctx, req.Pattern); err != nil {
1772		if errors.Is(err, domain.ErrInvalidDomainPattern) {
1773			h.sendError(w, http.StatusBadRequest, err.Error())
1774		} else {
1775			log.Error().Err(err).Str("pattern", req.Pattern).Msg("failed to add auto-route allowed domain")
1776			h.sendError(w, http.StatusInternalServerError, "failed to add auto-route allowed domain")
1777		}
1778		return
1779	}
1780
1781	h.sendJSON(w, http.StatusCreated, dto.AutoRouteStatusResponse{Status: "added"})
1782}
1783
1784func (h *Handler) handleAutoRouteAllowedDomainsDelete(w http.ResponseWriter, r *http.Request, path string) {
1785	ctx := r.Context()
1786	log := zerowrap.FromCtx(ctx)
1787	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1788		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1789		return
1790	}
1791
1792	raw := strings.TrimPrefix(path, "/autoroute/allowed-domains/")
1793	if raw == path || raw == "" || raw == "/" {
1794		h.sendError(w, http.StatusBadRequest, "missing domain pattern")
1795		return
1796	}
1797	pattern, err := url.PathUnescape(raw)
1798	if err != nil || strings.TrimSpace(pattern) == "" {
1799		h.sendError(w, http.StatusBadRequest, "invalid domain pattern")
1800		return
1801	}
1802
1803	if err := h.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern); err != nil {
1804		log.Error().Err(err).Str("pattern", pattern).Msg("failed to remove auto-route allowed domain")
1805		h.sendError(w, http.StatusInternalServerError, "failed to remove auto-route allowed domain")
1806		return
1807	}
1808
1809	h.sendJSON(w, http.StatusOK, dto.AutoRouteStatusResponse{Status: "removed"})
1810}
1811
1812// handleAuthVerify handles /admin/auth/verify endpoint.
1813// Validates authentication session and returns token status.
1814func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
1815	ctx := r.Context()
1816
1817	// Only allow GET method
1818	if r.Method != http.MethodGet {
1819		w.Header().Set("Allow", http.MethodGet)
1820		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1821		return
1822	}
1823
1824	// Call use case to get auth status
1825	status, err := h.authSvc.GetAuthStatus(ctx)
1826	if err != nil {
1827		h.sendError(w, http.StatusInternalServerError, "failed to get auth status")
1828		return
1829	}
1830
1831	// Convert domain.AuthStatus to DTO
1832	response := dto.AuthVerifyResponse{
1833		Valid:     status.Valid,
1834		Subject:   status.Subject,
1835		Scopes:    status.Scopes,
1836		ExpiresAt: status.ExpiresAt,
1837		IssuedAt:  status.IssuedAt,
1838	}
1839
1840	h.sendJSON(w, http.StatusOK, response)
1841}

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