v2.31.0-dev7 9ef6929

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

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