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