Skip to content

Commit 7132715

Browse files
authored
Move admin server to a sub-package (efixler#43)
1 parent f49a47a commit 7132715

21 files changed

+371
-190
lines changed

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ setup-githooks: ## setup the git hooks
9494
watch-server: ## Start a hot-update scrape-server (requires entr)
9595
@echo "\nStarting hot-update scrape-server. Only templates are watched."
9696
@echo "[space] to restart, q to quit."
97-
@find ./internal/server/htdocs -type f | entr -r make build-and-restart-server
97+
@find ./internal/server/admin/htdocs -type f | entr -r make build-and-restart-server
9898

9999
build-and-restart-server:
100100
@(make > $(BUILD_DIR)/build.log 2>&1; \

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ Command line options:
250250

251251
The root path of the server (`/`) is browsable and provides a simple way to test URLs and results.
252252

253-
![Alt text](internal/server/assets/test-console-with-token.png)
253+
![Alt text](internal/server/admin/htdocs/assets/test-console-with-token.png)
254254

255255
The select on the left lets you select between loading results for a page url or for a feed, or scraping a page using the headless browser instead of a direct http client.
256256

internal/server/home.go renamed to internal/server/admin/admin.go

+94-51
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,39 @@
1-
package server
1+
package admin
22

33
import (
44
"embed"
5+
"fmt"
56
"html/template"
67
"io/fs"
78
"log/slog"
89
"net/http"
10+
"strings"
911
"sync"
10-
"time"
1112

1213
"github.com/efixler/scrape/internal/auth"
1314
"github.com/efixler/scrape/internal/server/version"
1415
)
1516

1617
const (
1718
baseTemplateName = "base.html"
19+
DefaultBasePath = "/admin"
1820
)
1921

22+
type AuthzProvider interface {
23+
AuthEnabled() bool
24+
SigningKey() auth.HMACBase64Key
25+
}
26+
27+
type authzShim auth.HMACBase64Key
28+
29+
func (a authzShim) AuthEnabled() bool {
30+
return len(a) > 0
31+
}
32+
33+
func (a authzShim) SigningKey() auth.HMACBase64Key {
34+
return auth.HMACBase64Key(a)
35+
}
36+
2037
//go:embed htdocs/*.html
2138
var htdocs embed.FS
2239

@@ -34,14 +51,87 @@ type adminServer struct {
3451
data codeData
3552
}
3653

37-
func newAdminServer() *adminServer {
38-
return &adminServer{
54+
type config struct {
55+
basePath string
56+
authz AuthzProvider
57+
openHome bool
58+
profile bool
59+
}
60+
61+
type option func(*config) error
62+
63+
func WithBasePath(basePath string) option {
64+
return func(c *config) error {
65+
if !strings.HasPrefix(basePath, "/") {
66+
return fmt.Errorf("BasePath must start with a /")
67+
}
68+
c.basePath = basePath
69+
return nil
70+
}
71+
}
72+
73+
func WithAuthz(authz AuthzProvider) option {
74+
return func(c *config) error {
75+
if authz == nil {
76+
authz = authzShim{}
77+
}
78+
c.authz = authz
79+
return nil
80+
}
81+
}
82+
83+
func WithOpenHome(openHome bool) option {
84+
return func(c *config) error {
85+
c.openHome = openHome
86+
return nil
87+
}
88+
}
89+
90+
func WithProfiling(profile bool) option {
91+
return func(c *config) error {
92+
c.profile = profile
93+
return nil
94+
}
95+
}
96+
97+
func MustServer(mux *http.ServeMux, options ...option) *adminServer {
98+
s, err := NewServer(mux, options...)
99+
if err != nil {
100+
panic(err)
101+
}
102+
return s
103+
}
104+
105+
func NewServer(mux *http.ServeMux, options ...option) (*adminServer, error) {
106+
c := &config{
107+
basePath: DefaultBasePath,
108+
authz: authzShim{},
109+
}
110+
111+
for _, o := range options {
112+
if err := o(c); err != nil {
113+
slog.Error("AdminServer: Error applying option", "error", err)
114+
return nil, err
115+
}
116+
}
117+
as := &adminServer{
39118
data: codeData{
40119
Commit: version.Commit,
41120
RepoURL: version.RepoURL,
42121
Tag: version.Tag,
43122
},
44123
}
124+
// nil mux provided for tests
125+
if mux != nil {
126+
// home handler is always at root
127+
mux.HandleFunc("/{$}", as.homeHandler(c.authz, c.openHome))
128+
mux.Handle("/assets/", assetsHandler())
129+
if c.profile {
130+
initPProf(mux, c.basePath)
131+
}
132+
mux.HandleFunc(c.basePath+"/settings", as.settingsHandler())
133+
}
134+
return as, nil
45135
}
46136

47137
// mustBaseTemplate returns a template for the base template. The returned template
@@ -83,53 +173,6 @@ func (a *adminServer) mustTemplate(name string, funcs template.FuncMap) *templat
83173
return tmpl
84174
}
85175

86-
// mustHomeTemplate creates a template for the home page.
87-
// To enable usage of the home page without a token when auth is enabled,
88-
// for API endpoint, set openHome to true.
89-
func (a *adminServer) mustHomeTemplate(ss *scrapeServer, openHome bool) *template.Template {
90-
var authTokenF = func() string { return "" }
91-
var showTokenWidget = func() bool {
92-
// when openHome is true don't show the token entry widget
93-
if openHome {
94-
return false
95-
}
96-
return ss.AuthEnabled()
97-
}
98-
if ss.AuthEnabled() && openHome {
99-
authTokenF = func() string {
100-
c, err := auth.NewClaims(
101-
auth.WithSubject("home"),
102-
auth.ExpiresIn(60*time.Minute),
103-
)
104-
if err != nil {
105-
slog.Error("Error creating claims for home view", "error", err)
106-
return ""
107-
}
108-
s, err := c.Sign(ss.SigningKey())
109-
if err != nil {
110-
slog.Error("Error signing claims for home view", "error", err)
111-
return ""
112-
}
113-
return s
114-
}
115-
}
116-
funcMap := template.FuncMap{
117-
"AuthToken": authTokenF,
118-
"ShowTokenWidget": showTokenWidget,
119-
}
120-
tmpl := a.mustTemplate("index.html", funcMap)
121-
return tmpl
122-
}
123-
124-
func (a *adminServer) homeHandler(ss *scrapeServer, openHome bool) http.HandlerFunc {
125-
tmpl := a.mustHomeTemplate(ss, openHome)
126-
return func(w http.ResponseWriter, r *http.Request) {
127-
if err := tmpl.ExecuteTemplate(w, baseTemplateName, a.data); err != nil {
128-
http.Error(w, err.Error(), http.StatusInternalServerError)
129-
}
130-
}
131-
}
132-
133176
func (a *adminServer) settingsHandler() http.HandlerFunc {
134177
tmpl := a.mustTemplate("settings.html", nil)
135178
return func(w http.ResponseWriter, r *http.Request) {

internal/server/admin/admin_test.go

+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package admin
2+
3+
import (
4+
"net/http/httptest"
5+
"testing"
6+
7+
"github.com/efixler/scrape/internal/auth"
8+
"golang.org/x/net/html"
9+
)
10+
11+
func TestMustBaseTemplate(t *testing.T) {
12+
as := MustServer(nil)
13+
tmpl := as.mustBaseTemplate()
14+
if tmpl == nil {
15+
t.Fatal("Expected non-nil template")
16+
}
17+
requiredTemplates := map[string]bool{
18+
"base.html": false,
19+
"menubar.html": false,
20+
// following are blocks expected to be defined
21+
"content": false,
22+
"head": false,
23+
"scripts": false,
24+
"title": false,
25+
}
26+
for _, t := range tmpl.Templates() {
27+
requiredTemplates[t.Name()] = true
28+
}
29+
for k, v := range requiredTemplates {
30+
if !v {
31+
t.Errorf("Expected template %s to be defined", k)
32+
}
33+
}
34+
if tmpl == as.baseTemplate {
35+
t.Error("Expected returned base template to be a clone baseTemplate")
36+
}
37+
}
38+
39+
func TestSettingsHandler(t *testing.T) {
40+
as := MustServer(nil)
41+
handler := as.settingsHandler()
42+
if handler == nil {
43+
t.Fatal("Expected non-nil handler")
44+
}
45+
req := httptest.NewRequest("GET", "http://foo.bar/", nil)
46+
w := httptest.NewRecorder()
47+
handler(w, req)
48+
resp := w.Result()
49+
if resp.StatusCode != 200 {
50+
t.Errorf("Expected 200 status code, got %d", resp.StatusCode)
51+
}
52+
if _, err := html.Parse(resp.Body); err != nil {
53+
t.Errorf("Error parsing settings rendered content body: %s", err)
54+
}
55+
}
56+
57+
func TestWithBasePathOption(t *testing.T) {
58+
tests := []struct {
59+
name string
60+
basePath string
61+
expectErr bool
62+
}{
63+
{
64+
name: "empty base path",
65+
basePath: "",
66+
expectErr: true,
67+
},
68+
{
69+
name: "valid base path",
70+
basePath: "/foo",
71+
expectErr: false,
72+
},
73+
}
74+
for _, test := range tests {
75+
c := &config{}
76+
err := WithBasePath(test.basePath)(c)
77+
if test.expectErr && err == nil {
78+
t.Errorf("[%s] Expected error, got nil", test.name)
79+
}
80+
if !test.expectErr && err != nil {
81+
t.Errorf("[%s] Expected no error, got %s", test.name, err)
82+
}
83+
if c.basePath != test.basePath {
84+
t.Errorf("[%s] Expected base path %s, got %s", test.name, test.basePath, c.basePath)
85+
}
86+
}
87+
}
88+
89+
func TestWithAuthzOption(t *testing.T) {
90+
tests := []struct {
91+
name string
92+
authz AuthzProvider
93+
expectEnabled bool
94+
}{
95+
{
96+
name: "nil authz",
97+
authz: nil,
98+
expectEnabled: false,
99+
},
100+
{
101+
name: "non-nil no authz",
102+
authz: authzShim{},
103+
expectEnabled: false,
104+
},
105+
{
106+
name: "non-nil authz",
107+
authz: authzShim(auth.MustNewHS256SigningKey()),
108+
expectEnabled: true,
109+
},
110+
}
111+
for _, test := range tests {
112+
c := &config{}
113+
err := WithAuthz(test.authz)(c)
114+
if err != nil {
115+
t.Errorf("[%s] Unexpected error: %s", test.name, err)
116+
}
117+
if c.authz == nil {
118+
t.Errorf("[%s] Expected non-nil authz", test.name)
119+
}
120+
if c.authz.AuthEnabled() != test.expectEnabled {
121+
t.Errorf("[%s] Expected auth enabled %t, got %t", test.name, test.expectEnabled, c.authz.AuthEnabled())
122+
}
123+
}
124+
}

internal/server/admin/home.go

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package admin
2+
3+
import (
4+
"html/template"
5+
"log/slog"
6+
"net/http"
7+
"time"
8+
9+
"github.com/efixler/scrape/internal/auth"
10+
)
11+
12+
// mustHomeTemplate creates a template for the home page.
13+
// To enable usage of the home page without a token when auth is enabled,
14+
// for API endpoint, set openHome to true.
15+
func (a *adminServer) mustHomeTemplate(ss AuthzProvider, openHome bool) *template.Template {
16+
var authTokenF = func() string { return "" }
17+
var showTokenWidget = func() bool {
18+
// when openHome is true don't show the token entry widget
19+
if openHome {
20+
return false
21+
}
22+
return ss.AuthEnabled()
23+
}
24+
if ss.AuthEnabled() && openHome {
25+
authTokenF = func() string {
26+
c, err := auth.NewClaims(
27+
auth.WithSubject("home"),
28+
auth.ExpiresIn(60*time.Minute),
29+
)
30+
if err != nil {
31+
slog.Error("Error creating claims for home view", "error", err)
32+
return ""
33+
}
34+
s, err := c.Sign(ss.SigningKey())
35+
if err != nil {
36+
slog.Error("Error signing claims for home view", "error", err)
37+
return ""
38+
}
39+
return s
40+
}
41+
}
42+
funcMap := template.FuncMap{
43+
"AuthToken": authTokenF,
44+
"ShowTokenWidget": showTokenWidget,
45+
}
46+
tmpl := a.mustTemplate("index.html", funcMap)
47+
return tmpl
48+
}
49+
50+
func (a *adminServer) homeHandler(ss AuthzProvider, openHome bool) http.HandlerFunc {
51+
tmpl := a.mustHomeTemplate(ss, openHome)
52+
return func(w http.ResponseWriter, r *http.Request) {
53+
if err := tmpl.ExecuteTemplate(w, baseTemplateName, a.data); err != nil {
54+
http.Error(w, err.Error(), http.StatusInternalServerError)
55+
}
56+
}
57+
}

0 commit comments

Comments
 (0)