v2.31.0-dev5 4162c86

preview_handler.go RawHistoryFolder

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}

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