v2.14.0 5e11a00

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

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