admin.go

v1.4.3
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
	"strconv"
11
12
	"github.com/go-git/go-git/v6/plumbing/transport"
13
14
	"go.bigb.es/curator/internal/git"
15
	"go.bigb.es/curator/internal/store"
16
)
17
18
//go:embed templates/*
19
var content embed.FS
20
21
// Handler serves the admin UI.
22
type Handler struct {
23
	store   *store.Store
24
	pages   map[string]*template.Template
25
	version string
26
}
27
28
var funcs = template.FuncMap{
29
	"boolToYes": func(b bool) string {
30
		if b {
31
			return "Yes"
32
		}
33
		return "No"
34
	},
35
}
36
37
// New creates a new admin handler.
38
func New(st *store.Store, version string) (*Handler, error) {
39
	pages := map[string]*template.Template{}
40
	for _, name := range []string{"dashboard.html", "modules.html", "patterns.html", "credentials.html"} {
41
		t, err := template.New("").Funcs(funcs).ParseFS(content,
42
			"templates/base_admin.html", "templates/"+name)
43
		if err != nil {
44
			return nil, fmt.Errorf("parse admin templates: %w", err)
45
		}
46
		pages[name] = t
47
	}
48
49
	return &Handler{store: st, pages: pages, version: version}, nil
50
}
51
52
// ServeHTTP routes admin requests.
53
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
54
	switch {
55
	case r.URL.Path == "/-/admin/" || r.URL.Path == "/-/admin":
56
		h.dashboard(w, r)
57
	case r.URL.Path == "/-/admin/modules" && r.Method == http.MethodGet:
58
		h.listModules(w, r)
59
	case r.URL.Path == "/-/admin/modules" && r.Method == http.MethodPost:
60
		h.addModule(w, r)
61
	case r.URL.Path == "/-/admin/modules/update" && r.Method == http.MethodPost:
62
		h.updateModule(w, r)
63
	case r.URL.Path == "/-/admin/modules/delete" && r.Method == http.MethodPost:
64
		h.deleteModule(w, r)
65
	case r.URL.Path == "/-/admin/patterns" && r.Method == http.MethodGet:
66
		h.listPatterns(w, r)
67
	case r.URL.Path == "/-/admin/patterns" && r.Method == http.MethodPost:
68
		h.addPattern(w, r)
69
	case r.URL.Path == "/-/admin/patterns/update" && r.Method == http.MethodPost:
70
		h.updatePattern(w, r)
71
	case r.URL.Path == "/-/admin/patterns/delete" && r.Method == http.MethodPost:
72
		h.deletePattern(w, r)
73
	case r.URL.Path == "/-/admin/credentials" && r.Method == http.MethodGet:
74
		h.listCredentials(w, r)
75
	case r.URL.Path == "/-/admin/credentials" && r.Method == http.MethodPost:
76
		h.addCredential(w, r)
77
	case r.URL.Path == "/-/admin/credentials/delete" && r.Method == http.MethodPost:
78
		h.deleteCredential(w, r)
79
	default:
80
		http.NotFound(w, r)
81
	}
82
}
83
84
func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) {
85
	modules, _ := h.store.ListModules()
86
	patterns, _ := h.store.ListPatterns()
87
88
	data := map[string]any{
89
		"ModuleCount":     len(modules),
90
		"PatternCount":    len(patterns),
91
		"CredentialCount": h.store.CredentialCount(),
92
		"Version":         h.version,
93
	}
94
95
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
96
	if err := h.pages["dashboard.html"].ExecuteTemplate(w, "base", data); err != nil {
97
		log.Printf("admin: render dashboard: %v", err)
98
	}
99
}
100
101
func (h *Handler) listModules(w http.ResponseWriter, r *http.Request) {
102
	modules, err := h.store.ListModules()
103
	if err != nil {
104
		http.Error(w, "internal error", http.StatusInternalServerError)
105
		return
106
	}
107
108
	creds, _ := h.store.ListCredentials()
109
110
	data := map[string]any{
111
		"Modules":     modules,
112
		"Credentials": creds,
113
		"Flash":       r.URL.Query().Get("msg"),
114
		"Error":       r.URL.Query().Get("err"),
115
		"Version":     h.version,
116
	}
117
118
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
119
	if err := h.pages["modules.html"].ExecuteTemplate(w, "base", data); err != nil {
120
		log.Printf("admin: render modules: %v", err)
121
	}
122
}
123
124
func (h *Handler) renderModulesWithError(w http.ResponseWriter, errMsg string, form *store.ModuleRow) {
125
	modules, _ := h.store.ListModules()
126
	creds, _ := h.store.ListCredentials()
127
128
	data := map[string]any{
129
		"Modules":     modules,
130
		"Credentials": creds,
131
		"Error":       errMsg,
132
		"Version":     h.version,
133
	}
134
	if form != nil {
135
		data["Form"] = form
136
	}
137
138
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
139
	if err := h.pages["modules.html"].ExecuteTemplate(w, "base", data); err != nil {
140
		log.Printf("admin: render modules: %v", err)
141
	}
142
}
143
144
func (h *Handler) addModule(w http.ResponseWriter, r *http.Request) {
145
	if err := r.ParseForm(); err != nil {
146
		http.Error(w, "bad request", http.StatusBadRequest)
147
		return
148
	}
149
150
	m := store.ModuleRow{
151
		Name:           r.FormValue("name"),
152
		VCS:            r.FormValue("vcs"),
153
		Repo:           r.FormValue("repo"),
154
		Web:            r.FormValue("web"),
155
		Private:        r.FormValue("private") == "on",
156
		CredentialName: r.FormValue("credential_name"),
157
	}
158
159
	if m.Name == "" || m.Repo == "" {
160
		h.renderModulesWithError(w, "Name and repo are required", &m)
161
		return
162
	}
163
164
	if m.VCS == "" {
165
		m.VCS = "git"
166
	}
167
168
	// Verify the repository is accessible.
169
	auth := h.authForCredential(m.CredentialName)
170
	if err := store.VerifyModule(m.Repo, auth); err != nil {
171
		log.Printf("admin: verify module %s: %v", m.Name, err)
172
		h.renderModulesWithError(w, "Repository verification failed: "+err.Error(), &m)
173
		return
174
	}
175
176
	if err := h.store.AddModule(m); err != nil {
177
		log.Printf("admin: add module %s: %v", m.Name, err)
178
		h.renderModulesWithError(w, err.Error(), &m)
179
		return
180
	}
181
182
	http.Redirect(w, r, "/-/admin/modules?msg=Module+"+m.Name+"+added", http.StatusSeeOther)
183
}
184
185
func (h *Handler) updateModule(w http.ResponseWriter, r *http.Request) {
186
	if err := r.ParseForm(); err != nil {
187
		http.Error(w, "bad request", http.StatusBadRequest)
188
		return
189
	}
190
191
	m := store.ModuleRow{
192
		Name:           r.FormValue("name"),
193
		VCS:            r.FormValue("vcs"),
194
		Repo:           r.FormValue("repo"),
195
		Web:            r.FormValue("web"),
196
		Private:        r.FormValue("private") == "on",
197
		CredentialName: r.FormValue("credential_name"),
198
	}
199
200
	if m.Name == "" || m.Repo == "" {
201
		h.renderModulesWithError(w, "Name and repo are required", &m)
202
		return
203
	}
204
205
	if m.VCS == "" {
206
		m.VCS = "git"
207
	}
208
209
	// Verify the repository is accessible.
210
	auth := h.authForCredential(m.CredentialName)
211
	if err := store.VerifyModule(m.Repo, auth); err != nil {
212
		log.Printf("admin: verify module %s: %v", m.Name, err)
213
		h.renderModulesWithError(w, "Repository verification failed: "+err.Error(), &m)
214
		return
215
	}
216
217
	// Handle rename if the name changed.
218
	originalName := r.FormValue("original_name")
219
	if originalName != "" && originalName != m.Name {
220
		if err := h.store.RenameModule(originalName, m.Name); err != nil {
221
			log.Printf("admin: rename module %s → %s: %v", originalName, m.Name, err)
222
			h.renderModulesWithError(w, "Rename failed: "+err.Error(), &m)
223
			return
224
		}
225
	}
226
227
	if err := h.store.UpdateModule(m); err != nil {
228
		log.Printf("admin: update module %s: %v", m.Name, err)
229
		h.renderModulesWithError(w, err.Error(), &m)
230
		return
231
	}
232
233
	http.Redirect(w, r, "/-/admin/modules?msg=Module+"+m.Name+"+updated", http.StatusSeeOther)
234
}
235
236
func (h *Handler) deleteModule(w http.ResponseWriter, r *http.Request) {
237
	name := r.FormValue("name")
238
	if name == "" {
239
		http.Redirect(w, r, "/-/admin/modules?err=Name+required", http.StatusSeeOther)
240
		return
241
	}
242
243
	if err := h.store.DeleteModule(name); err != nil {
244
		log.Printf("admin: delete module %s: %v", name, err)
245
		http.Redirect(w, r, "/-/admin/modules?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
246
		return
247
	}
248
249
	http.Redirect(w, r, "/-/admin/modules?msg=Module+"+name+"+deleted", http.StatusSeeOther)
250
}
251
252
func (h *Handler) listPatterns(w http.ResponseWriter, r *http.Request) {
253
	patterns, err := h.store.ListPatterns()
254
	if err != nil {
255
		http.Error(w, "internal error", http.StatusInternalServerError)
256
		return
257
	}
258
259
	creds, _ := h.store.ListCredentials()
260
261
	data := map[string]any{
262
		"Patterns":    patterns,
263
		"Credentials": creds,
264
		"Flash":       r.URL.Query().Get("msg"),
265
		"Error":       r.URL.Query().Get("err"),
266
		"Version":     h.version,
267
	}
268
269
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
270
	if err := h.pages["patterns.html"].ExecuteTemplate(w, "base", data); err != nil {
271
		log.Printf("admin: render patterns: %v", err)
272
	}
273
}
274
275
func (h *Handler) renderPatternsWithError(w http.ResponseWriter, errMsg string, form *store.PatternRow) {
276
	patterns, _ := h.store.ListPatterns()
277
	creds, _ := h.store.ListCredentials()
278
279
	data := map[string]any{
280
		"Patterns":    patterns,
281
		"Credentials": creds,
282
		"Error":       errMsg,
283
		"Version":     h.version,
284
	}
285
	if form != nil {
286
		data["Form"] = form
287
	}
288
289
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
290
	if err := h.pages["patterns.html"].ExecuteTemplate(w, "base", data); err != nil {
291
		log.Printf("admin: render patterns: %v", err)
292
	}
293
}
294
295
func (h *Handler) addPattern(w http.ResponseWriter, r *http.Request) {
296
	if err := r.ParseForm(); err != nil {
297
		http.Error(w, "bad request", http.StatusBadRequest)
298
		return
299
	}
300
301
	priority, _ := strconv.Atoi(r.FormValue("priority"))
302
303
	p := store.PatternRow{
304
		Pattern:        r.FormValue("pattern"),
305
		VCS:            r.FormValue("vcs"),
306
		Repo:           r.FormValue("repo"),
307
		Web:            r.FormValue("web"),
308
		Private:        r.FormValue("private") == "on",
309
		Priority:       priority,
310
		CredentialName: r.FormValue("credential_name"),
311
	}
312
313
	if p.Pattern == "" || p.Repo == "" {
314
		h.renderPatternsWithError(w, "Pattern and repo template are required", &p)
315
		return
316
	}
317
318
	if p.VCS == "" {
319
		p.VCS = "git"
320
	}
321
322
	// Verify the regex compiles.
323
	if err := store.VerifyPattern(p.Pattern); err != nil {
324
		log.Printf("admin: verify pattern %s: %v", p.Pattern, err)
325
		h.renderPatternsWithError(w, err.Error(), &p)
326
		return
327
	}
328
329
	if err := h.store.AddPattern(p); err != nil {
330
		log.Printf("admin: add pattern %s: %v", p.Pattern, err)
331
		h.renderPatternsWithError(w, err.Error(), &p)
332
		return
333
	}
334
335
	http.Redirect(w, r, "/-/admin/patterns?msg=Pattern+added", http.StatusSeeOther)
336
}
337
338
func (h *Handler) updatePattern(w http.ResponseWriter, r *http.Request) {
339
	if err := r.ParseForm(); err != nil {
340
		http.Error(w, "bad request", http.StatusBadRequest)
341
		return
342
	}
343
344
	priority, _ := strconv.Atoi(r.FormValue("priority"))
345
346
	p := store.PatternRow{
347
		Pattern:        r.FormValue("pattern"),
348
		VCS:            r.FormValue("vcs"),
349
		Repo:           r.FormValue("repo"),
350
		Web:            r.FormValue("web"),
351
		Private:        r.FormValue("private") == "on",
352
		Priority:       priority,
353
		CredentialName: r.FormValue("credential_name"),
354
	}
355
356
	if p.Pattern == "" || p.Repo == "" {
357
		h.renderPatternsWithError(w, "Pattern and repo template are required", &p)
358
		return
359
	}
360
361
	if p.VCS == "" {
362
		p.VCS = "git"
363
	}
364
365
	idStr := r.FormValue("edit_id")
366
	if idStr == "" {
367
		h.renderPatternsWithError(w, "Pattern ID required for update", &p)
368
		return
369
	}
370
	if _, err := fmt.Sscanf(idStr, "%d", &p.ID); err != nil {
371
		h.renderPatternsWithError(w, "Invalid pattern ID", &p)
372
		return
373
	}
374
375
	// Verify the regex compiles.
376
	if err := store.VerifyPattern(p.Pattern); err != nil {
377
		log.Printf("admin: verify pattern %s: %v", p.Pattern, err)
378
		h.renderPatternsWithError(w, err.Error(), &p)
379
		return
380
	}
381
382
	if err := h.store.UpdatePattern(p); err != nil {
383
		log.Printf("admin: update pattern %d: %v", p.ID, err)
384
		h.renderPatternsWithError(w, err.Error(), &p)
385
		return
386
	}
387
388
	http.Redirect(w, r, "/-/admin/patterns?msg=Pattern+updated", http.StatusSeeOther)
389
}
390
391
func (h *Handler) deletePattern(w http.ResponseWriter, r *http.Request) {
392
	idStr := r.FormValue("id")
393
	if idStr == "" {
394
		http.Redirect(w, r, "/-/admin/patterns?err=ID+required", http.StatusSeeOther)
395
		return
396
	}
397
398
	var id int64
399
	if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
400
		http.Redirect(w, r, "/-/admin/patterns?err=Invalid+ID", http.StatusSeeOther)
401
		return
402
	}
403
404
	if err := h.store.DeletePattern(id); err != nil {
405
		log.Printf("admin: delete pattern %d: %v", id, err)
406
		http.Redirect(w, r, "/-/admin/patterns?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
407
		return
408
	}
409
410
	http.Redirect(w, r, "/-/admin/patterns?msg=Pattern+deleted", http.StatusSeeOther)
411
}
412
413
func (h *Handler) listCredentials(w http.ResponseWriter, r *http.Request) {
414
	creds, err := h.store.ListCredentials()
415
	if err != nil {
416
		http.Error(w, "internal error", http.StatusInternalServerError)
417
		return
418
	}
419
420
	data := map[string]any{
421
		"Credentials": creds,
422
		"Flash":       r.URL.Query().Get("msg"),
423
		"Error":       r.URL.Query().Get("err"),
424
		"Version":     h.version,
425
	}
426
427
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
428
	if err := h.pages["credentials.html"].ExecuteTemplate(w, "base", data); err != nil {
429
		log.Printf("admin: render credentials: %v", err)
430
	}
431
}
432
433
func (h *Handler) addCredential(w http.ResponseWriter, r *http.Request) {
434
	if err := r.ParseForm(); err != nil {
435
		http.Error(w, "bad request", http.StatusBadRequest)
436
		return
437
	}
438
439
	name := r.FormValue("name")
440
	credType := r.FormValue("type")
441
442
	if name == "" || credType == "" {
443
		http.Redirect(w, r, "/-/admin/credentials?err=Name+and+type+are+required", http.StatusSeeOther)
444
		return
445
	}
446
447
	// Build JSON data from form fields based on credential type.
448
	var data string
449
	switch credType {
450
	case "basic":
451
		d, _ := json.Marshal(map[string]string{
452
			"username": r.FormValue("username"),
453
			"password": r.FormValue("password"),
454
		})
455
		data = string(d)
456
	case "token":
457
		d, _ := json.Marshal(map[string]string{
458
			"token": r.FormValue("token"),
459
		})
460
		data = string(d)
461
	case "ssh":
462
		d, _ := json.Marshal(map[string]string{
463
			"key":      r.FormValue("key"),
464
			"password": r.FormValue("key_password"),
465
		})
466
		data = string(d)
467
	default:
468
		http.Redirect(w, r, "/-/admin/credentials?err=Invalid+credential+type", http.StatusSeeOther)
469
		return
470
	}
471
472
	c := store.CredentialRow{
473
		Name: name,
474
		Type: credType,
475
		Data: data,
476
	}
477
478
	if err := h.store.AddCredential(c); err != nil {
479
		log.Printf("admin: add credential %s: %v", name, err)
480
		http.Redirect(w, r, "/-/admin/credentials?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
481
		return
482
	}
483
484
	http.Redirect(w, r, "/-/admin/credentials?msg=Credential+"+name+"+added", http.StatusSeeOther)
485
}
486
487
func (h *Handler) deleteCredential(w http.ResponseWriter, r *http.Request) {
488
	name := r.FormValue("name")
489
	if name == "" {
490
		http.Redirect(w, r, "/-/admin/credentials?err=Name+required", http.StatusSeeOther)
491
		return
492
	}
493
494
	if err := h.store.DeleteCredential(name); err != nil {
495
		log.Printf("admin: delete credential %s: %v", name, err)
496
		http.Redirect(w, r, "/-/admin/credentials?err="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
497
		return
498
	}
499
500
	http.Redirect(w, r, "/-/admin/credentials?msg=Credential+"+name+"+deleted", http.StatusSeeOther)
501
}
502
503
// authForCredential looks up a credential by name and builds a transport.AuthMethod.
504
// Returns nil if name is empty or the credential cannot be found/built.
505
func (h *Handler) authForCredential(name string) transport.AuthMethod {
506
	if name == "" {
507
		return nil
508
	}
509
	cred, err := h.store.GetCredential(name)
510
	if err != nil {
511
		return nil
512
	}
513
	auth, err := git.AuthMethodFromCredential(cred.Type, cred.Data)
514
	if err != nil {
515
		return nil
516
	}
517
	return auth
518
}
519

Source Files