admin.go

v1.2.0
Doc Versions Source
1
package admin
2
3
import (
4
	"embed"
5
	"encoding/json"
6
	"fmt"
7
	"html/template"
8
	"log"
9
	"net/http"
10
11
	"github.com/go-git/go-git/v6/plumbing/transport"
12
13
	"example.com/curator/internal/git"
14
	"example.com/curator/internal/store"
15
)
16
17
//go:embed templates/*
18
var content embed.FS
19
20
// Handler serves the admin UI.
21
type Handler struct {
22
	store   *store.Store
23
	pages   map[string]*template.Template
24
	version string
25
}
26
27
var funcs = template.FuncMap{
28
	"boolToYes": func(b bool) string {
29
		if b {
30
			return "Yes"
31
		}
32
		return "No"
33
	},
34
}
35
36
// New creates a new admin handler.
37
func New(st *store.Store, version string) (*Handler, error) {
38
	pages := map[string]*template.Template{}
39
	for _, name := range []string{"dashboard.html", "modules.html", "patterns.html", "credentials.html"} {
40
		t, err := template.New("").Funcs(funcs).ParseFS(content,
41
			"templates/base_admin.html", "templates/"+name)
42
		if err != nil {
43
			return nil, fmt.Errorf("parse admin templates: %w", err)
44
		}
45
		pages[name] = t
46
	}
47
48
	return &Handler{store: st, pages: pages, version: version}, nil
49
}
50
51
// ServeHTTP routes admin requests.
52
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
53
	switch {
54
	case r.URL.Path == "/-/admin/" || r.URL.Path == "/-/admin":
55
		h.dashboard(w, r)
56
	case r.URL.Path == "/-/admin/modules" && r.Method == http.MethodGet:
57
		h.listModules(w, r)
58
	case r.URL.Path == "/-/admin/modules" && r.Method == http.MethodPost:
59
		h.addModule(w, r)
60
	case r.URL.Path == "/-/admin/modules/delete" && r.Method == http.MethodPost:
61
		h.deleteModule(w, r)
62
	case r.URL.Path == "/-/admin/patterns" && r.Method == http.MethodGet:
63
		h.listPatterns(w, r)
64
	case r.URL.Path == "/-/admin/patterns" && r.Method == http.MethodPost:
65
		h.addPattern(w, r)
66
	case r.URL.Path == "/-/admin/patterns/delete" && r.Method == http.MethodPost:
67
		h.deletePattern(w, r)
68
	case r.URL.Path == "/-/admin/credentials" && r.Method == http.MethodGet:
69
		h.listCredentials(w, r)
70
	case r.URL.Path == "/-/admin/credentials" && r.Method == http.MethodPost:
71
		h.addCredential(w, r)
72
	case r.URL.Path == "/-/admin/credentials/delete" && r.Method == http.MethodPost:
73
		h.deleteCredential(w, r)
74
	default:
75
		http.NotFound(w, r)
76
	}
77
}
78
79
func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) {
80
	modules, _ := h.store.ListModules()
81
	patterns, _ := h.store.ListPatterns()
82
83
	data := map[string]any{
84
		"ModuleCount":     len(modules),
85
		"PatternCount":    len(patterns),
86
		"CredentialCount": h.store.CredentialCount(),
87
		"Version":         h.version,
88
	}
89
90
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
91
	if err := h.pages["dashboard.html"].ExecuteTemplate(w, "base", data); err != nil {
92
		log.Printf("admin: render dashboard: %v", err)
93
	}
94
}
95
96
func (h *Handler) listModules(w http.ResponseWriter, r *http.Request) {
97
	modules, err := h.store.ListModules()
98
	if err != nil {
99
		http.Error(w, "internal error", http.StatusInternalServerError)
100
		return
101
	}
102
103
	creds, _ := h.store.ListCredentials()
104
105
	data := map[string]any{
106
		"Modules":     modules,
107
		"Credentials": creds,
108
		"Flash":       r.URL.Query().Get("msg"),
109
		"Error":       r.URL.Query().Get("err"),
110
		"Version":     h.version,
111
	}
112
113
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
114
	if err := h.pages["modules.html"].ExecuteTemplate(w, "base", data); err != nil {
115
		log.Printf("admin: render modules: %v", err)
116
	}
117
}
118
119
func (h *Handler) addModule(w http.ResponseWriter, r *http.Request) {
120
	if err := r.ParseForm(); err != nil {
121
		http.Error(w, "bad request", http.StatusBadRequest)
122
		return
123
	}
124
125
	m := store.ModuleRow{
126
		Name:           r.FormValue("name"),
127
		VCS:            r.FormValue("vcs"),
128
		Repo:           r.FormValue("repo"),
129
		Web:            r.FormValue("web"),
130
		Private:        r.FormValue("private") == "on",
131
		CredentialName: r.FormValue("credential_name"),
132
	}
133
134
	if m.Name == "" || m.Repo == "" {
135
		http.Redirect(w, r, "/-/admin/modules?err=Name+and+repo+are+required", http.StatusSeeOther)
136
		return
137
	}
138
139
	if m.VCS == "" {
140
		m.VCS = "git"
141
	}
142
143
	// Verify the repository is accessible.
144
	auth := h.authForCredential(m.CredentialName)
145
	if err := store.VerifyModule(m.Repo, auth); err != nil {
146
		log.Printf("admin: verify module %s: %v", m.Name, err)
147
		http.Redirect(w, r, "/-/admin/modules?err=Repository+verification+failed:+"+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
148
		return
149
	}
150
151
	if err := h.store.AddModule(m); err != nil {
152
		log.Printf("admin: add module %s: %v", m.Name, err)
153
		http.Redirect(w, r, "/-/admin/modules?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
154
		return
155
	}
156
157
	http.Redirect(w, r, "/-/admin/modules?msg=Module+"+m.Name+"+added", http.StatusSeeOther)
158
}
159
160
func (h *Handler) deleteModule(w http.ResponseWriter, r *http.Request) {
161
	name := r.FormValue("name")
162
	if name == "" {
163
		http.Redirect(w, r, "/-/admin/modules?err=Name+required", http.StatusSeeOther)
164
		return
165
	}
166
167
	if err := h.store.DeleteModule(name); err != nil {
168
		log.Printf("admin: delete module %s: %v", name, err)
169
		http.Redirect(w, r, "/-/admin/modules?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
170
		return
171
	}
172
173
	http.Redirect(w, r, "/-/admin/modules?msg=Module+"+name+"+deleted", http.StatusSeeOther)
174
}
175
176
func (h *Handler) listPatterns(w http.ResponseWriter, r *http.Request) {
177
	patterns, err := h.store.ListPatterns()
178
	if err != nil {
179
		http.Error(w, "internal error", http.StatusInternalServerError)
180
		return
181
	}
182
183
	creds, _ := h.store.ListCredentials()
184
185
	data := map[string]any{
186
		"Patterns":    patterns,
187
		"Credentials": creds,
188
		"Flash":       r.URL.Query().Get("msg"),
189
		"Error":       r.URL.Query().Get("err"),
190
		"Version":     h.version,
191
	}
192
193
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
194
	if err := h.pages["patterns.html"].ExecuteTemplate(w, "base", data); err != nil {
195
		log.Printf("admin: render patterns: %v", err)
196
	}
197
}
198
199
func (h *Handler) addPattern(w http.ResponseWriter, r *http.Request) {
200
	if err := r.ParseForm(); err != nil {
201
		http.Error(w, "bad request", http.StatusBadRequest)
202
		return
203
	}
204
205
	p := store.PatternRow{
206
		Pattern:        r.FormValue("pattern"),
207
		VCS:            r.FormValue("vcs"),
208
		Repo:           r.FormValue("repo"),
209
		Web:            r.FormValue("web"),
210
		Private:        r.FormValue("private") == "on",
211
		CredentialName: r.FormValue("credential_name"),
212
	}
213
214
	if p.Pattern == "" || p.Repo == "" {
215
		http.Redirect(w, r, "/-/admin/patterns?err=Pattern+and+repo+template+are+required", http.StatusSeeOther)
216
		return
217
	}
218
219
	if p.VCS == "" {
220
		p.VCS = "git"
221
	}
222
223
	// Verify the regex compiles.
224
	if err := store.VerifyPattern(p.Pattern); err != nil {
225
		log.Printf("admin: verify pattern %s: %v", p.Pattern, err)
226
		http.Redirect(w, r, "/-/admin/patterns?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
227
		return
228
	}
229
230
	if err := h.store.AddPattern(p); err != nil {
231
		log.Printf("admin: add pattern %s: %v", p.Pattern, err)
232
		http.Redirect(w, r, "/-/admin/patterns?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
233
		return
234
	}
235
236
	http.Redirect(w, r, "/-/admin/patterns?msg=Pattern+added", http.StatusSeeOther)
237
}
238
239
func (h *Handler) deletePattern(w http.ResponseWriter, r *http.Request) {
240
	idStr := r.FormValue("id")
241
	if idStr == "" {
242
		http.Redirect(w, r, "/-/admin/patterns?err=ID+required", http.StatusSeeOther)
243
		return
244
	}
245
246
	var id int64
247
	if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
248
		http.Redirect(w, r, "/-/admin/patterns?err=Invalid+ID", http.StatusSeeOther)
249
		return
250
	}
251
252
	if err := h.store.DeletePattern(id); err != nil {
253
		log.Printf("admin: delete pattern %d: %v", id, err)
254
		http.Redirect(w, r, "/-/admin/patterns?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
255
		return
256
	}
257
258
	http.Redirect(w, r, "/-/admin/patterns?msg=Pattern+deleted", http.StatusSeeOther)
259
}
260
261
func (h *Handler) listCredentials(w http.ResponseWriter, r *http.Request) {
262
	creds, err := h.store.ListCredentials()
263
	if err != nil {
264
		http.Error(w, "internal error", http.StatusInternalServerError)
265
		return
266
	}
267
268
	data := map[string]any{
269
		"Credentials": creds,
270
		"Flash":       r.URL.Query().Get("msg"),
271
		"Error":       r.URL.Query().Get("err"),
272
		"Version":     h.version,
273
	}
274
275
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
276
	if err := h.pages["credentials.html"].ExecuteTemplate(w, "base", data); err != nil {
277
		log.Printf("admin: render credentials: %v", err)
278
	}
279
}
280
281
func (h *Handler) addCredential(w http.ResponseWriter, r *http.Request) {
282
	if err := r.ParseForm(); err != nil {
283
		http.Error(w, "bad request", http.StatusBadRequest)
284
		return
285
	}
286
287
	name := r.FormValue("name")
288
	credType := r.FormValue("type")
289
290
	if name == "" || credType == "" {
291
		http.Redirect(w, r, "/-/admin/credentials?err=Name+and+type+are+required", http.StatusSeeOther)
292
		return
293
	}
294
295
	// Build JSON data from form fields based on credential type.
296
	var data string
297
	switch credType {
298
	case "basic":
299
		d, _ := json.Marshal(map[string]string{
300
			"username": r.FormValue("username"),
301
			"password": r.FormValue("password"),
302
		})
303
		data = string(d)
304
	case "token":
305
		d, _ := json.Marshal(map[string]string{
306
			"token": r.FormValue("token"),
307
		})
308
		data = string(d)
309
	case "ssh":
310
		d, _ := json.Marshal(map[string]string{
311
			"key":      r.FormValue("key"),
312
			"password": r.FormValue("key_password"),
313
		})
314
		data = string(d)
315
	default:
316
		http.Redirect(w, r, "/-/admin/credentials?err=Invalid+credential+type", http.StatusSeeOther)
317
		return
318
	}
319
320
	c := store.CredentialRow{
321
		Name: name,
322
		Type: credType,
323
		Data: data,
324
	}
325
326
	if err := h.store.AddCredential(c); err != nil {
327
		log.Printf("admin: add credential %s: %v", name, err)
328
		http.Redirect(w, r, "/-/admin/credentials?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
329
		return
330
	}
331
332
	http.Redirect(w, r, "/-/admin/credentials?msg=Credential+"+name+"+added", http.StatusSeeOther)
333
}
334
335
func (h *Handler) deleteCredential(w http.ResponseWriter, r *http.Request) {
336
	name := r.FormValue("name")
337
	if name == "" {
338
		http.Redirect(w, r, "/-/admin/credentials?err=Name+required", http.StatusSeeOther)
339
		return
340
	}
341
342
	if err := h.store.DeleteCredential(name); err != nil {
343
		log.Printf("admin: delete credential %s: %v", name, err)
344
		http.Redirect(w, r, "/-/admin/credentials?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
345
		return
346
	}
347
348
	http.Redirect(w, r, "/-/admin/credentials?msg=Credential+"+name+"+deleted", http.StatusSeeOther)
349
}
350
351
// authForCredential looks up a credential by name and builds a transport.AuthMethod.
352
// Returns nil if name is empty or the credential cannot be found/built.
353
func (h *Handler) authForCredential(name string) transport.AuthMethod {
354
	if name == "" {
355
		return nil
356
	}
357
	cred, err := h.store.GetCredential(name)
358
	if err != nil {
359
		return nil
360
	}
361
	auth, err := git.AuthMethodFromCredential(cred.Type, cred.Data)
362
	if err != nil {
363
		return nil
364
	}
365
	return auth
366
}
367

Source Files