1package admin
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "strings"
9 "time"
10
11 "github.com/bnema/zerowrap"
12
13 "github.com/bnema/gordon/internal/domain"
14)
15
16// previewService is a minimal interface for preview management used by the admin handler.
17type previewService interface {
18 List(ctx context.Context) ([]domain.PreviewRoute, error)
19 Delete(ctx context.Context, name string) error
20 Extend(ctx context.Context, name string, ttl time.Duration) error
21}
22
23// previewListResponse is the JSON payload returned by GET /admin/previews.
24type previewListResponse struct {
25 Previews []domain.PreviewRoute `json:"previews"`
26}
27
28// previewExtendRequest is the JSON body for PATCH /admin/preview/{name}.
29type previewExtendRequest struct {
30 TTL string `json:"ttl"` // e.g. "24h", "30m"
31}
32
33// handlePreviewList handles GET /admin/previews.
34// Returns a JSON array of all active preview environments.
35func (h *Handler) handlePreviewList(w http.ResponseWriter, r *http.Request, _ string) {
36 if r.Method != http.MethodGet {
37 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
38 return
39 }
40
41 ctx := r.Context()
42
43 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
44 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
45 return
46 }
47
48 if h.previewSvc == nil {
49 h.sendError(w, http.StatusServiceUnavailable, "preview service not available")
50 return
51 }
52
53 previews, err := h.previewSvc.List(ctx)
54 if err != nil {
55 log := zerowrap.FromCtx(ctx)
56 log.Error().Err(err).Msg("failed to list previews")
57 h.sendError(w, http.StatusInternalServerError, "failed to list previews")
58 return
59 }
60
61 if previews == nil {
62 previews = []domain.PreviewRoute{}
63 }
64
65 h.sendJSON(w, http.StatusOK, previewListResponse{Previews: previews})
66}
67
68// handlePreviewAction handles DELETE and PATCH on /admin/preview/{name}.
69//
70// DELETE /admin/preview/{name} — tear down the preview
71// PATCH /admin/preview/{name} — extend TTL (body: {"ttl":"24h"})
72func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, path string) {
73 ctx := r.Context()
74 log := zerowrap.FromCtx(ctx)
75
76 if h.previewSvc == nil {
77 h.sendError(w, http.StatusServiceUnavailable, "preview service not available")
78 return
79 }
80
81 name := strings.TrimPrefix(path, "/preview/")
82 if name == "" || name == "/preview" {
83 h.sendError(w, http.StatusBadRequest, "preview name required in path")
84 return
85 }
86
87 switch r.Method {
88 case http.MethodDelete:
89 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
90 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
91 return
92 }
93
94 if err := h.previewSvc.Delete(ctx, name); err != nil {
95 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")
96 if errors.Is(err, domain.ErrPreviewNotFound) {
97 h.sendError(w, http.StatusNotFound, "preview not found")
98 } else {
99 h.sendError(w, http.StatusInternalServerError, "failed to delete preview")
100 }
101 return
102 }
103
104 log.Info().Str("name", name).Msg("preview deleted via admin API")
105 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
106
107 case http.MethodPatch:
108 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
109 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
110 return
111 }
112
113 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
114
115 var req previewExtendRequest
116 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
117 log.Warn().Err(err).Msg("invalid preview extend JSON")
118 h.sendError(w, http.StatusBadRequest, "invalid JSON")
119 return
120 }
121
122 if req.TTL == "" {
123 h.sendError(w, http.StatusBadRequest, "ttl is required")
124 return
125 }
126
127 ttl, err := time.ParseDuration(req.TTL)
128 if err != nil {
129 h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")
130 return
131 }
132
133 if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
134 log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
135 if errors.Is(err, domain.ErrPreviewNotFound) {
136 h.sendError(w, http.StatusNotFound, "preview not found")
137 } else {
138 h.sendError(w, http.StatusInternalServerError, "failed to extend preview")
139 }
140 return
141 }
142
143 log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")
144 h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})
145
146 default:
147 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
148 }
149}