v2.16.0 ebd2ab3

handler.go RawHistoryFolder

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

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