diff --git a/api/auth/auth.go b/api/auth/auth.go index dbb64a3..d0a0648 100644 --- a/api/auth/auth.go +++ b/api/auth/auth.go @@ -1,28 +1,43 @@ package auth import ( + "fmt" "github.com/evanebb/gobble/api/response" "net/http" ) -func BasicAuth(db ApiUserRepository) func(next http.Handler) http.Handler { +// ApiBasicAuth will check the basic auth credentials sent in the request against the known users, +// and return a JSON response if authentication has failed. +func ApiBasicAuth(db ApiUserRepository) func(next http.Handler) http.Handler { + return basicAuth(db, sendBasicAuthFailedResponse) +} + +// BrowserBasicAuth will check the basic auth credentials sent in the request against the known users, +// and (re)-request basic auth credentials if authentication has failed. +func BrowserBasicAuth(db ApiUserRepository) func(next http.Handler) http.Handler { + return basicAuth(db, requestBasicAuth) +} + +// basicAuth will check the basic auth credentials sent in the request against the known users, +// and execute the passed callback if authentication has failed. +func basicAuth(db ApiUserRepository, authFailureCallback func(w http.ResponseWriter)) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() if !ok { - basicAuthFailed(w) + authFailureCallback(w) return } apiUser, err := db.GetApiUserByName(user) if err != nil { - basicAuthFailed(w) + authFailureCallback(w) return } err = apiUser.CheckPassword(pass) if err != nil { - basicAuthFailed(w) + authFailureCallback(w) return } @@ -31,9 +46,17 @@ func BasicAuth(db ApiUserRepository) func(next http.Handler) http.Handler { } } -func basicAuthFailed(w http.ResponseWriter) { +// sendBasicAuthFailedResponse will write a JSON response indicating authentication failure to the passed http.ResponseWriter variable. +func sendBasicAuthFailedResponse(w http.ResponseWriter) { err := response.Error(w, http.StatusUnauthorized, "authentication failed") if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError) } } + +// sendBasicAuthFailedResponse will request basic authentication by sending the 'WWW-Authenticate' header with a 401 status code. +func requestBasicAuth(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="gobble"`) + w.WriteHeader(401) + _, _ = fmt.Fprint(w, "Unauthorised") +} diff --git a/kernelparameters/kernel_parameters.go b/kernelparameters/kernel_parameters.go index 93ece4d..f2caa18 100644 --- a/kernelparameters/kernel_parameters.go +++ b/kernelparameters/kernel_parameters.go @@ -8,7 +8,33 @@ import ( type KernelParameters map[string]string -func New(s []string) (KernelParameters, error) { +// String returns the string representation of the KernelParameters, e.g. 'initrd=initrd quiet splash' +func (k KernelParameters) String() string { + return strings.Join(k.StringSlice(), " ") +} + +// StringSlice returns the set of KernelParameters as a slice of strings, e.g. ["initrd=initrd", "quiet", "splash"] +func (k KernelParameters) StringSlice() []string { + var s []string + + for k, v := range k { + if len(v) > 0 { + s = append(s, fmt.Sprintf("%s=%s", k, v)) + } else { + s = append(s, k) + } + } + + return s +} + +// ParseString will parse and validate s into a set of KernelParameters, and return an error if it encounters an invalid kernel parameter. +func ParseString(s string) (KernelParameters, error) { + return ParseStringSlice(strings.Split(s, " ")) +} + +// ParseStringSlice will parse and validate s into a set of KernelParameters, and return an error if it encounters an invalid kernel parameter. +func ParseStringSlice(s []string) (KernelParameters, error) { kp := make(KernelParameters) for _, v := range s { @@ -19,10 +45,10 @@ func New(s []string) (KernelParameters, error) { splitStr := strings.Split(v, "=") if len(splitStr) > 1 { - // key value parameter, e.g. initrd=initrd.img + // key value parameter, e.g. 'initrd=initrd' kp[splitStr[0]] = splitStr[1] } else { - // just a value, e.g. noquiet + // just a value, e.g. 'quiet' kp[splitStr[0]] = "" } } @@ -43,21 +69,7 @@ func validateParameter(v string) error { return nil } -func FormatKernelParameters(kp KernelParameters) []string { - var s []string - - for k, v := range kp { - if len(v) > 0 { - s = append(s, fmt.Sprintf("%s=%s", k, v)) - } else { - s = append(s, k) - } - } - - return s -} - -// MergeKernelParameters merges multiple KernelParameter maps, with values from maps being overwritten in the order that they're passed into the function +// MergeKernelParameters merges multiple sets of KernelParameters, with values from maps being overwritten in the order that they're passed into the function func MergeKernelParameters(kp1 KernelParameters, kp2 ...KernelParameters) KernelParameters { for _, kpMap := range kp2 { for k, v := range kpMap { diff --git a/kernelparameters/kernel_parameters_test.go b/kernelparameters/kernel_parameters_test.go index 018ec2f..c2ee184 100644 --- a/kernelparameters/kernel_parameters_test.go +++ b/kernelparameters/kernel_parameters_test.go @@ -3,11 +3,38 @@ package kernelparameters import ( "errors" "reflect" - "sort" "testing" ) -func TestNewKernelParameters(t *testing.T) { +func TestParseString(t *testing.T) { + v := "test1 test2=value" + + expected := KernelParameters{ + "test1": "", + "test2": "value", + } + + actual, err := ParseString(v) + if !reflect.DeepEqual(actual, expected) || err != nil { + t.Fatalf(`ParseString() = %v, %v, expected: %v, nil`, actual, err, expected) + } +} + +func TestParseStringInvalidParameter(t *testing.T) { + var actualErr *InvalidParameterError + expectedErr := NewInvalidParameterError("===invalid") + + v := "===invalid test2=value" + + expectedValue := make(KernelParameters) + + actualValue, err := ParseString(v) + if err == nil || !errors.As(err, &actualErr) { + t.Fatalf(`ParseString() = %v, %v, expected: %v, %v`, actualValue, actualErr, expectedValue, expectedErr) + } +} + +func TestParseStringSlice(t *testing.T) { v := []string{ "test1", "test2=value", @@ -18,31 +45,26 @@ func TestNewKernelParameters(t *testing.T) { "test2": "value", } - actual, err := New(v) + actual, err := ParseStringSlice(v) if !reflect.DeepEqual(actual, expected) || err != nil { - t.Fatalf(`New() = %v, %v, expected: %v, nil`, actual, err, expected) + t.Fatalf(`ParseStringSlice() = %v, %v, expected: %v, nil`, actual, err, expected) } } -func TestFormatKernelParameters(t *testing.T) { - v := KernelParameters{ - "test1": "", - "test2": "value", - } +func TestParseStringSliceInvalidParameter(t *testing.T) { + var actualErr *InvalidParameterError + expectedErr := NewInvalidParameterError("this is invalid") - expected := []string{ - "test1", + v := []string{ + "this is invalid", "test2=value", } - actual := FormatKernelParameters(v) - - // Sort the slices, since the order is not guaranteed and I don't care about it either - sort.Strings(actual) - sort.Strings(expected) + expectedValue := make(KernelParameters) - if !reflect.DeepEqual(actual, expected) { - t.Fatalf(`FormatKernelParameters() = %v, expected: %v`, actual, expected) + actualValue, err := ParseStringSlice(v) + if err == nil || !errors.As(err, &actualErr) { + t.Fatalf(`ParseStringSlice() = %v, %v, expected: %v, %v`, actualValue, actualErr, expectedValue, expectedErr) } } @@ -70,20 +92,3 @@ func TestMergeKernelParameters(t *testing.T) { t.Fatalf(`MergeKernelParameters() = %v, expected: %v`, actual, expected) } } - -func TestInvalidParameterError(t *testing.T) { - var actualErr *InvalidParameterError - expectedErr := NewInvalidParameterError("this is invalid") - - v := []string{ - "this is invalid", - "test2=value", - } - - expectedValue := make(KernelParameters) - - actualValue, err := New(v) - if err == nil || !errors.As(err, &actualErr) { - t.Fatalf(`New() = %v, %v, expected: %v, %v`, actualValue, actualErr, expectedValue, expectedErr) - } -} diff --git a/repository/postgres/profile_repository.go b/repository/postgres/profile_repository.go index 55619e4..35790fe 100644 --- a/repository/postgres/profile_repository.go +++ b/repository/postgres/profile_repository.go @@ -47,7 +47,7 @@ func (r ProfileRepository) GetProfiles() ([]profile.Profile, error) { return profiles, err } - kp, err := kernelparameters.New(pp.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(pp.KernelParameters) if err != nil { return profiles, err } @@ -77,7 +77,7 @@ func (r ProfileRepository) GetProfileById(id uuid.UUID) (profile.Profile, error) } // If this errors someone directly inserted garbage into the database :( - kp, err := kernelparameters.New(pp.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(pp.KernelParameters) if err != nil { return pr, err } @@ -87,7 +87,7 @@ func (r ProfileRepository) GetProfileById(id uuid.UUID) (profile.Profile, error) func (r ProfileRepository) SetProfile(p profile.Profile) error { stmt := "INSERT INTO profile (uuid, name, description, kernel, initrd, kernelParameters) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (uuid) DO UPDATE set name = $2, description = $3, kernel = $4, initrd = $5, kernelParameters = $6" - _, err := r.db.Exec(context.Background(), stmt, p.Id, p.Name, p.Description, p.Kernel, p.Initrd, kernelparameters.FormatKernelParameters(p.KernelParameters)) + _, err := r.db.Exec(context.Background(), stmt, p.Id, p.Name, p.Description, p.Kernel, p.Initrd, p.KernelParameters.StringSlice()) if err != nil { return err } diff --git a/repository/postgres/system_repository.go b/repository/postgres/system_repository.go index 21f74fb..1e56403 100644 --- a/repository/postgres/system_repository.go +++ b/repository/postgres/system_repository.go @@ -48,7 +48,7 @@ func (r SystemRepository) GetSystems() ([]system.System, error) { return systems, err } - kp, err := kernelparameters.New(ps.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(ps.KernelParameters) if err != nil { return systems, err } @@ -78,7 +78,7 @@ func (r SystemRepository) GetSystemByMacAddress(mac net.HardwareAddr) (system.Sy } // If this errors someone directly inserted garbage into the database :( - kp, err := kernelparameters.New(ps.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(ps.KernelParameters) if err != nil { return sys, err } @@ -100,7 +100,7 @@ func (r SystemRepository) GetSystemById(id uuid.UUID) (system.System, error) { } // If this errors someone directly inserted garbage into the database :( - kp, err := kernelparameters.New(ps.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(ps.KernelParameters) if err != nil { return sys, err } @@ -110,7 +110,7 @@ func (r SystemRepository) GetSystemById(id uuid.UUID) (system.System, error) { func (r SystemRepository) SetSystem(s system.System) error { stmt := "INSERT INTO system (uuid, name, description, profile, mac, kernelParameters) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (uuid) DO UPDATE set name = $2, description = $3, profile = $4, mac = $5, kernelParameters = $6" - _, err := r.db.Exec(context.Background(), stmt, s.Id, s.Name, s.Description, s.Profile, s.Mac, kernelparameters.FormatKernelParameters(s.KernelParameters)) + _, err := r.db.Exec(context.Background(), stmt, s.Id, s.Name, s.Description, s.Profile, s.Mac, s.KernelParameters.StringSlice()) if err != nil { return err } diff --git a/resources/resources.go b/resources/resources.go new file mode 100644 index 0000000..d284591 --- /dev/null +++ b/resources/resources.go @@ -0,0 +1,9 @@ +package resources + +import "embed" + +//go:embed templates +var Templates embed.FS + +//go:embed static +var Static embed.FS diff --git a/resources/static/css/style.css b/resources/static/css/style.css new file mode 100644 index 0000000..4e29e78 --- /dev/null +++ b/resources/static/css/style.css @@ -0,0 +1,7 @@ +.alternate-bg-color .row:nth-of-type(odd) { + background-color: var(--bs-card-cap-bg); +} + +.table-fixed-width { + table-layout: fixed; +} diff --git a/resources/templates/base.gohtml b/resources/templates/base.gohtml new file mode 100644 index 0000000..ec873ef --- /dev/null +++ b/resources/templates/base.gohtml @@ -0,0 +1,56 @@ +{{ define "base" }} + +
+Error occurred: {{ . }}
+An unexpected error occurred. Please check the logs for more information.
+ Go to homepage +Welcome to Gobble!
+| ID | +Name | +Description | +
|---|---|---|
| {{$val.Id}} | +{{$val.Name}} | +{{$val.Description}} | +
| ID | +Name | +Description | +
|---|---|---|
| {{$val.Id}} | +{{$val.Name}} | +{{$val.Description}} | +
Refer to the documentation for the available API endpoints.
" - return response.HTML(w, http.StatusOK, html) -} diff --git a/server/handlers/profile_handlers.go b/server/handlers/api_handlers/profile_handlers.go similarity index 90% rename from server/handlers/profile_handlers.go rename to server/handlers/api_handlers/profile_handlers.go index 21b73f4..ee68b59 100644 --- a/server/handlers/profile_handlers.go +++ b/server/handlers/api_handlers/profile_handlers.go @@ -1,4 +1,4 @@ -package handlers +package api_handlers import ( "encoding/json" @@ -7,6 +7,7 @@ import ( "github.com/evanebb/gobble/kernelparameters" "github.com/evanebb/gobble/profile" "github.com/evanebb/gobble/repository" + "github.com/evanebb/gobble/server/handlers" "github.com/google/uuid" "net/http" ) @@ -42,7 +43,7 @@ func newProfileResponse(p profile.Profile) profileResponse { Description: p.Description, Kernel: p.Kernel, Initrd: p.Initrd, - KernelParameters: kernelparameters.FormatKernelParameters(p.KernelParameters), + KernelParameters: p.KernelParameters.StringSlice(), } } @@ -74,7 +75,7 @@ func (h ProfileHandlerGroup) GetProfiles(w http.ResponseWriter, r *http.Request) } func (h ProfileHandlerGroup) GetProfile(w http.ResponseWriter, r *http.Request) error { - profileId, err := getUUIDFromRequest(r) + profileId, err := handlers.GetUUIDFromRequest(r) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -101,7 +102,7 @@ func (h ProfileHandlerGroup) CreateProfile(w http.ResponseWriter, r *http.Reques profileId := uuid.New() - kp, err := kernelparameters.New(req.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(req.KernelParameters) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -122,7 +123,7 @@ func (h ProfileHandlerGroup) CreateProfile(w http.ResponseWriter, r *http.Reques func (h ProfileHandlerGroup) PutProfile(w http.ResponseWriter, r *http.Request) error { var req profileRequest - profileId, err := getUUIDFromRequest(r) + profileId, err := handlers.GetUUIDFromRequest(r) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -134,7 +135,7 @@ func (h ProfileHandlerGroup) PutProfile(w http.ResponseWriter, r *http.Request) return NewHTTPError(err, http.StatusBadRequest) } - kp, err := kernelparameters.New(req.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(req.KernelParameters) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -153,7 +154,7 @@ func (h ProfileHandlerGroup) PutProfile(w http.ResponseWriter, r *http.Request) } func (h ProfileHandlerGroup) PatchProfile(w http.ResponseWriter, r *http.Request) error { - profileId, err := getUUIDFromRequest(r) + profileId, err := handlers.GetUUIDFromRequest(r) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -169,7 +170,7 @@ func (h ProfileHandlerGroup) PatchProfile(w http.ResponseWriter, r *http.Request Description: p.Description, Kernel: p.Kernel, Initrd: p.Initrd, - KernelParameters: kernelparameters.FormatKernelParameters(p.KernelParameters), + KernelParameters: p.KernelParameters.StringSlice(), } // Decode the request body into the current profile; @@ -182,7 +183,7 @@ func (h ProfileHandlerGroup) PatchProfile(w http.ResponseWriter, r *http.Request return NewHTTPError(err, http.StatusBadRequest) } - kp, err := kernelparameters.New(req.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(req.KernelParameters) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -202,7 +203,7 @@ func (h ProfileHandlerGroup) PatchProfile(w http.ResponseWriter, r *http.Request } func (h ProfileHandlerGroup) DeleteProfile(w http.ResponseWriter, r *http.Request) error { - profileId, err := getUUIDFromRequest(r) + profileId, err := handlers.GetUUIDFromRequest(r) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } diff --git a/server/handlers/pxe_config_handlers.go b/server/handlers/api_handlers/pxe_config_handlers.go similarity index 98% rename from server/handlers/pxe_config_handlers.go rename to server/handlers/api_handlers/pxe_config_handlers.go index a98f249..e978ca1 100644 --- a/server/handlers/pxe_config_handlers.go +++ b/server/handlers/api_handlers/pxe_config_handlers.go @@ -1,4 +1,4 @@ -package handlers +package api_handlers import ( "errors" diff --git a/server/handlers/system_handlers.go b/server/handlers/api_handlers/system_handlers.go similarity index 91% rename from server/handlers/system_handlers.go rename to server/handlers/api_handlers/system_handlers.go index b8901a6..08bb14f 100644 --- a/server/handlers/system_handlers.go +++ b/server/handlers/api_handlers/system_handlers.go @@ -1,4 +1,4 @@ -package handlers +package api_handlers import ( "encoding/json" @@ -6,6 +6,7 @@ import ( "github.com/evanebb/gobble/api/response" "github.com/evanebb/gobble/kernelparameters" "github.com/evanebb/gobble/repository" + "github.com/evanebb/gobble/server/handlers" "github.com/evanebb/gobble/system" "github.com/google/uuid" "net" @@ -43,7 +44,7 @@ func newSystemResponse(sys system.System) systemResponse { Description: sys.Description, Profile: sys.Profile, Mac: sys.Mac.String(), - KernelParameters: kernelparameters.FormatKernelParameters(sys.KernelParameters), + KernelParameters: sys.KernelParameters.StringSlice(), } } @@ -75,7 +76,7 @@ func (h SystemHandlerGroup) GetSystems(w http.ResponseWriter, r *http.Request) e } func (h SystemHandlerGroup) GetSystem(w http.ResponseWriter, r *http.Request) error { - systemId, err := getUUIDFromRequest(r) + systemId, err := handlers.GetUUIDFromRequest(r) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -102,7 +103,7 @@ func (h SystemHandlerGroup) CreateSystem(w http.ResponseWriter, r *http.Request) systemId := uuid.New() - kp, err := kernelparameters.New(req.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(req.KernelParameters) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -128,7 +129,7 @@ func (h SystemHandlerGroup) CreateSystem(w http.ResponseWriter, r *http.Request) func (h SystemHandlerGroup) PutSystem(w http.ResponseWriter, r *http.Request) error { var req systemRequest - systemId, err := getUUIDFromRequest(r) + systemId, err := handlers.GetUUIDFromRequest(r) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -140,7 +141,7 @@ func (h SystemHandlerGroup) PutSystem(w http.ResponseWriter, r *http.Request) er return NewHTTPError(err, http.StatusBadRequest) } - kp, err := kernelparameters.New(req.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(req.KernelParameters) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -164,7 +165,7 @@ func (h SystemHandlerGroup) PutSystem(w http.ResponseWriter, r *http.Request) er } func (h SystemHandlerGroup) PatchSystem(w http.ResponseWriter, r *http.Request) error { - systemId, err := getUUIDFromRequest(r) + systemId, err := handlers.GetUUIDFromRequest(r) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -180,7 +181,7 @@ func (h SystemHandlerGroup) PatchSystem(w http.ResponseWriter, r *http.Request) Description: sys.Description, Profile: sys.Profile, Mac: sys.Mac.String(), - KernelParameters: kernelparameters.FormatKernelParameters(sys.KernelParameters), + KernelParameters: sys.KernelParameters.StringSlice(), } // Decode the request body into the current system; @@ -193,7 +194,7 @@ func (h SystemHandlerGroup) PatchSystem(w http.ResponseWriter, r *http.Request) return NewHTTPError(err, http.StatusBadRequest) } - kp, err := kernelparameters.New(req.KernelParameters) + kp, err := kernelparameters.ParseStringSlice(req.KernelParameters) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } @@ -218,7 +219,7 @@ func (h SystemHandlerGroup) PatchSystem(w http.ResponseWriter, r *http.Request) } func (h SystemHandlerGroup) DeleteSystem(w http.ResponseWriter, r *http.Request) error { - systemId, err := getUUIDFromRequest(r) + systemId, err := handlers.GetUUIDFromRequest(r) if err != nil { return NewHTTPError(err, http.StatusBadRequest) } diff --git a/server/handlers/common.go b/server/handlers/common.go index 4a297a2..ed58059 100644 --- a/server/handlers/common.go +++ b/server/handlers/common.go @@ -7,8 +7,8 @@ import ( "net/http" ) -// getUUIDFromRequest gets and parses the UUID from the request. If it's not a valid UUID, an error is returned. -func getUUIDFromRequest(r *http.Request) (uuid.UUID, error) { +// GetUUIDFromRequest gets and parses the UUID from the request. If it's not a valid UUID, an error is returned. +func GetUUIDFromRequest(r *http.Request) (uuid.UUID, error) { uuidString := chi.URLParam(r, "uuid") UUID, err := uuid.Parse(uuidString) if err != nil { diff --git a/server/handlers/middleware.go b/server/handlers/middleware.go new file mode 100644 index 0000000..822b27d --- /dev/null +++ b/server/handlers/middleware.go @@ -0,0 +1,19 @@ +package handlers + +import "net/http" + +// MethodOverride will check for a hidden "_method" input in the POST form, +// so that PUT, PATCH and DELETE requests can be supported +func MethodOverride(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + method := r.PostFormValue("_method") + + if method == "PUT" || method == "PATCH" || method == "DELETE" { + r.Method = method + } + } + + next.ServeHTTP(w, r) + }) +} diff --git a/server/handlers/ui_handlers/handlers.go b/server/handlers/ui_handlers/handlers.go new file mode 100644 index 0000000..ae34148 --- /dev/null +++ b/server/handlers/ui_handlers/handlers.go @@ -0,0 +1,13 @@ +package ui_handlers + +import "net/http" + +func PageNotFound(w http.ResponseWriter, r *http.Request) { + d := templateData{Title: "Page not found", DisableNavbar: true} + renderTemplate(w, "errors/404", d) +} + +func HomePage(w http.ResponseWriter, r *http.Request) { + d := templateData{Title: "Home"} + renderTemplate(w, "home", d) +} diff --git a/server/handlers/ui_handlers/profile_handlers.go b/server/handlers/ui_handlers/profile_handlers.go new file mode 100644 index 0000000..a38b69a --- /dev/null +++ b/server/handlers/ui_handlers/profile_handlers.go @@ -0,0 +1,164 @@ +package ui_handlers + +import ( + "errors" + "github.com/evanebb/gobble/kernelparameters" + "github.com/evanebb/gobble/profile" + "github.com/evanebb/gobble/server/handlers" + "github.com/google/uuid" + "net/http" +) + +func parseProfileFromPostForm(r *http.Request) (profile.Profile, error) { + var p profile.Profile + + err := r.ParseForm() + if err != nil { + return p, err + } + + requiredKeys := []string{"name", "description", "kernel", "initrd", "kernelParameters"} + for _, v := range requiredKeys { + if !r.PostForm.Has(v) { + return p, errors.New("missing value " + v + " in POST form") + } + } + + kp, err := kernelparameters.ParseString(r.PostFormValue("kernelParameters")) + if err != nil { + return p, err + } + + return profile.New( + // the UUID needs to be set properly afterward by the caller, depending on whether we are creating a new one or updating an existing one + uuid.Nil, + r.PostFormValue("name"), + r.PostFormValue("description"), + r.PostFormValue("kernel"), + r.PostFormValue("initrd"), + kp, + ) +} + +type UiProfileHandlerGroup struct { + profileRepo profile.Repository +} + +func NewUiProfileHandlerGroup(pr profile.Repository) UiProfileHandlerGroup { + return UiProfileHandlerGroup{pr} +} + +// Overview will list all profiles. +func (h UiProfileHandlerGroup) Overview(w http.ResponseWriter, r *http.Request) { + profiles, err := h.profileRepo.GetProfiles() + if err != nil { + renderError(w) + return + } + + d := templateData{Title: "Profiles", Data: profiles} + renderTemplate(w, "profiles/overview", d) +} + +// Show will show information about a single profile. +func (h UiProfileHandlerGroup) Show(w http.ResponseWriter, r *http.Request) { + profileId, err := handlers.GetUUIDFromRequest(r) + if err != nil { + renderError(w) + return + } + + p, err := h.profileRepo.GetProfileById(profileId) + if err != nil { + renderError(w) + return + } + + d := templateData{Title: "Profile Information", Data: p} + renderTemplate(w, "profiles/show", d) +} + +// Create shows the page for creating a new profile. +func (h UiProfileHandlerGroup) Create(w http.ResponseWriter, r *http.Request) { + d := templateData{Title: "Create profile"} + renderTemplate(w, "profiles/create", d) +} + +// Store will store a newly created profile. +func (h UiProfileHandlerGroup) Store(w http.ResponseWriter, r *http.Request) { + p, err := parseProfileFromPostForm(r) + if err != nil { + renderError(w) + return + } + + p.Id = uuid.New() + + err = h.profileRepo.SetProfile(p) + if err != nil { + renderError(w) + return + } + + http.Redirect(w, r, "/ui/profiles/"+p.Id.String(), http.StatusSeeOther) +} + +// Edit shows the page for editing an existing profile. +func (h UiProfileHandlerGroup) Edit(w http.ResponseWriter, r *http.Request) { + profileId, err := handlers.GetUUIDFromRequest(r) + if err != nil { + renderError(w) + return + } + + p, err := h.profileRepo.GetProfileById(profileId) + if err != nil { + renderError(w) + return + } + + d := templateData{Title: "Edit Profile", Data: p} + renderTemplate(w, "profiles/edit", d) +} + +// Update will update the specified profile. +func (h UiProfileHandlerGroup) Update(w http.ResponseWriter, r *http.Request) { + profileId, err := handlers.GetUUIDFromRequest(r) + if err != nil { + renderError(w) + return + } + + p, err := parseProfileFromPostForm(r) + if err != nil { + renderError(w) + return + } + + p.Id = profileId + + err = h.profileRepo.SetProfile(p) + if err != nil { + renderError(w) + return + } + + http.Redirect(w, r, "/ui/profiles/"+p.Id.String(), http.StatusSeeOther) +} + +// Delete will delete the specified profile. +func (h UiProfileHandlerGroup) Delete(w http.ResponseWriter, r *http.Request) { + profileId, err := handlers.GetUUIDFromRequest(r) + if err != nil { + renderError(w) + return + } + + err = h.profileRepo.DeleteProfileById(profileId) + if err != nil { + renderError(w) + return + } + + http.Redirect(w, r, "/ui/profiles", http.StatusSeeOther) +} diff --git a/server/handlers/ui_handlers/system_handlers.go b/server/handlers/ui_handlers/system_handlers.go new file mode 100644 index 0000000..0c7e12e --- /dev/null +++ b/server/handlers/ui_handlers/system_handlers.go @@ -0,0 +1,211 @@ +package ui_handlers + +import ( + "errors" + "github.com/evanebb/gobble/kernelparameters" + "github.com/evanebb/gobble/profile" + "github.com/evanebb/gobble/server/handlers" + "github.com/evanebb/gobble/system" + "github.com/google/uuid" + "net" + "net/http" +) + +func parseSystemFromPostForm(r *http.Request) (system.System, error) { + var s system.System + + err := r.ParseForm() + if err != nil { + return s, err + } + + requiredKeys := []string{"name", "description", "profile", "mac", "kernelParameters"} + for _, v := range requiredKeys { + if !r.PostForm.Has(v) { + return s, errors.New("missing value " + v + " in POST form") + } + } + + kp, err := kernelparameters.ParseString(r.PostFormValue("kernelParameters")) + if err != nil { + return s, err + } + + profileId, err := uuid.Parse(r.PostFormValue("profile")) + if err != nil { + return s, err + } + + macAddress, err := net.ParseMAC(r.PostFormValue("mac")) + if err != nil { + return s, err + } + + return system.New( + // the UUID needs to be set properly afterward by the caller, depending on whether we are creating a new one or updating an existing one + uuid.Nil, + r.PostFormValue("name"), + r.PostFormValue("description"), + profileId, + macAddress, + kp, + ) +} + +type UiSystemHandlerGroup struct { + systemRepo system.Repository + profileRepo profile.Repository +} + +func NewUiSystemHandlerGroup(sr system.Repository, pr profile.Repository) UiSystemHandlerGroup { + return UiSystemHandlerGroup{sr, pr} +} + +// Overview will list all systems. +func (h UiSystemHandlerGroup) Overview(w http.ResponseWriter, r *http.Request) { + systems, err := h.systemRepo.GetSystems() + if err != nil { + renderError(w) + return + } + + d := templateData{Title: "Systems", Data: systems} + renderTemplate(w, "systems/overview", d) +} + +// Show will show information about a single system. +func (h UiSystemHandlerGroup) Show(w http.ResponseWriter, r *http.Request) { + systemId, err := handlers.GetUUIDFromRequest(r) + if err != nil { + renderError(w) + return + } + + s, err := h.systemRepo.GetSystemById(systemId) + if err != nil { + renderError(w) + return + } + + p, err := h.profileRepo.GetProfileById(s.Profile) + if err != nil { + renderError(w) + return + } + + d := templateData{Title: "System information", Data: struct { + System system.System + Profile profile.Profile + }{ + System: s, + Profile: p, + }} + renderTemplate(w, "systems/show", d) +} + +// Create shows the page for creating a new system. +func (h UiSystemHandlerGroup) Create(w http.ResponseWriter, r *http.Request) { + profiles, err := h.profileRepo.GetProfiles() + if err != nil { + renderError(w) + return + } + + d := templateData{Title: "Create system", Data: struct { + Profiles []profile.Profile + }{ + Profiles: profiles, + }} + renderTemplate(w, "systems/create", d) +} + +// Store will store a newly created system. +func (h UiSystemHandlerGroup) Store(w http.ResponseWriter, r *http.Request) { + s, err := parseSystemFromPostForm(r) + if err != nil { + renderError(w) + return + } + + s.Id = uuid.New() + + err = h.systemRepo.SetSystem(s) + if err != nil { + renderError(w) + return + } + + http.Redirect(w, r, "/ui/systems/"+s.Id.String(), http.StatusSeeOther) +} + +// Edit shows the page for editing an existing system. +func (h UiSystemHandlerGroup) Edit(w http.ResponseWriter, r *http.Request) { + systemId, err := handlers.GetUUIDFromRequest(r) + if err != nil { + renderError(w) + return + } + + s, err := h.systemRepo.GetSystemById(systemId) + if err != nil { + renderError(w) + return + } + + profiles, err := h.profileRepo.GetProfiles() + if err != nil { + renderError(w) + return + } + + d := templateData{Title: "Edit system", Data: struct { + System system.System + Profiles []profile.Profile + }{ + System: s, + Profiles: profiles, + }} + renderTemplate(w, "systems/edit", d) +} + +// Update will update the specified system. +func (h UiSystemHandlerGroup) Update(w http.ResponseWriter, r *http.Request) { + systemId, err := handlers.GetUUIDFromRequest(r) + if err != nil { + renderError(w) + return + } + + s, err := parseSystemFromPostForm(r) + if err != nil { + renderError(w) + return + } + + s.Id = systemId + + err = h.systemRepo.SetSystem(s) + if err != nil { + renderError(w) + return + } + + http.Redirect(w, r, "/ui/systems/"+s.Id.String(), http.StatusSeeOther) +} + +// Delete will delete the specified system. +func (h UiSystemHandlerGroup) Delete(w http.ResponseWriter, r *http.Request) { + systemId, err := handlers.GetUUIDFromRequest(r) + if err != nil { + renderError(w) + return + } + + err = h.systemRepo.DeleteSystemById(systemId) + if err != nil { + renderError(w) + return + } + + http.Redirect(w, r, "/ui/systems", http.StatusSeeOther) +} diff --git a/server/handlers/ui_handlers/template.go b/server/handlers/ui_handlers/template.go new file mode 100644 index 0000000..e68a475 --- /dev/null +++ b/server/handlers/ui_handlers/template.go @@ -0,0 +1,48 @@ +package ui_handlers + +import ( + "github.com/evanebb/gobble/resources" + "html/template" + "io" +) + +type templateData struct { + Title string + Data any + DisableNavbar bool +} + +// renderTemplateErr will render the template referenced by the passed name and return an error if it encounters one +func renderTemplateErr(w io.Writer, templateName string, d templateData) error { + var err error + + templateFile := "templates/" + templateName + ".gohtml" + tmpl, err := template.New("base").ParseFS(resources.Templates, "templates/base.gohtml", templateFile) + if err != nil { + return err + } + + err = tmpl.Execute(w, d) + if err != nil { + return err + } + + return nil +} + +// renderTemplate will render the template referenced by the passed name and render an error page if an error occurs +func renderTemplate(w io.Writer, templateName string, d templateData) { + err := renderTemplateErr(w, templateName, d) + if err != nil { + renderError(w) + } +} + +// renderError will render a generic internal server error page. +func renderError(w io.Writer) { + // If we get to the point that we can't even render the error template, just do nothing + _ = renderTemplateErr(w, "errors/500", templateData{ + Title: "Error", + DisableNavbar: true, + }) +} diff --git a/server/routes.go b/server/routes.go index e97f535..62f37cf 100644 --- a/server/routes.go +++ b/server/routes.go @@ -2,58 +2,105 @@ package server import ( "github.com/evanebb/gobble/api/auth" + "github.com/evanebb/gobble/resources" "github.com/evanebb/gobble/server/handlers" + "github.com/evanebb/gobble/server/handlers/api_handlers" + "github.com/evanebb/gobble/server/handlers/ui_handlers" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "net/http" ) func (s *Server) routes() { - s.router.Use(middleware.Logger) + s.router.Use(handlers.MethodOverride, middleware.Logger) - s.router.Get("/", handlers.ErrorHandler(handlers.IndexHandler)) + // API route group s.router.Route("/api", func(r chi.Router) { - r.Use(auth.BasicAuth(s.apiUserRepo)) - r.NotFound(handlers.ErrorHandler(handlers.UnknownEndpointHandler)) + r.Use(auth.ApiBasicAuth(s.apiUserRepo)) + r.NotFound(api_handlers.ErrorHandler(api_handlers.UnknownEndpointHandler)) r.Route("/profiles", func(r chi.Router) { - h := handlers.NewProfileHandlerGroup(s.profileRepo) + h := api_handlers.NewProfileHandlerGroup(s.profileRepo) - r.Get("/", handlers.ErrorHandler(h.GetProfiles)) - r.Post("/", handlers.ErrorHandler(h.CreateProfile)) + r.Get("/", api_handlers.ErrorHandler(h.GetProfiles)) + r.Post("/", api_handlers.ErrorHandler(h.CreateProfile)) r.Route("/{uuid}", func(r chi.Router) { - r.Get("/", handlers.ErrorHandler(h.GetProfile)) - r.Put("/", handlers.ErrorHandler(h.PutProfile)) - r.Patch("/", handlers.ErrorHandler(h.PatchProfile)) - r.Delete("/", handlers.ErrorHandler(h.DeleteProfile)) + r.Get("/", api_handlers.ErrorHandler(h.GetProfile)) + r.Put("/", api_handlers.ErrorHandler(h.PutProfile)) + r.Patch("/", api_handlers.ErrorHandler(h.PatchProfile)) + r.Delete("/", api_handlers.ErrorHandler(h.DeleteProfile)) }) }) r.Route("/systems", func(r chi.Router) { - h := handlers.NewSystemHandlerGroup(s.systemRepo) + h := api_handlers.NewSystemHandlerGroup(s.systemRepo) - r.Get("/", handlers.ErrorHandler(h.GetSystems)) - r.Post("/", handlers.ErrorHandler(h.CreateSystem)) + r.Get("/", api_handlers.ErrorHandler(h.GetSystems)) + r.Post("/", api_handlers.ErrorHandler(h.CreateSystem)) r.Route("/{uuid}", func(r chi.Router) { - r.Get("/", handlers.ErrorHandler(h.GetSystem)) - r.Put("/", handlers.ErrorHandler(h.PutSystem)) - r.Patch("/", handlers.ErrorHandler(h.PatchSystem)) - r.Delete("/", handlers.ErrorHandler(h.DeleteSystem)) + r.Get("/", api_handlers.ErrorHandler(h.GetSystem)) + r.Put("/", api_handlers.ErrorHandler(h.PutSystem)) + r.Patch("/", api_handlers.ErrorHandler(h.PatchSystem)) + r.Delete("/", api_handlers.ErrorHandler(h.DeleteSystem)) }) }) r.Route("/users", func(r chi.Router) { - h := handlers.NewApiUserHandlerGroup(s.apiUserRepo) + h := api_handlers.NewApiUserHandlerGroup(s.apiUserRepo) - r.Get("/", handlers.ErrorHandler(h.GetUsers)) - r.Post("/", handlers.ErrorHandler(h.CreateUser)) + r.Get("/", api_handlers.ErrorHandler(h.GetUsers)) + r.Post("/", api_handlers.ErrorHandler(h.CreateUser)) r.Route("/{uuid}", func(r chi.Router) { - r.Get("/", handlers.ErrorHandler(h.GetUser)) - r.Put("/", handlers.ErrorHandler(h.PutUser)) - r.Delete("/", handlers.ErrorHandler(h.DeleteUser)) + r.Get("/", api_handlers.ErrorHandler(h.GetUser)) + r.Put("/", api_handlers.ErrorHandler(h.PutUser)) + r.Delete("/", api_handlers.ErrorHandler(h.DeleteUser)) }) }) }) + // This endpoint should not have authentication, so it lives outside the /api group above - h := handlers.NewPxeConfigHandlerGroup(s.systemRepo, s.profileRepo) - s.router.Get("/api/pxe-config", handlers.ErrorHandler(h.GetPxeConfig)) + h := api_handlers.NewPxeConfigHandlerGroup(s.systemRepo, s.profileRepo) + s.router.Get("/api/pxe-config", api_handlers.ErrorHandler(h.GetPxeConfig)) + + // Redirect the index to the UI by default + s.router.Handle("/", http.RedirectHandler("ui/", http.StatusMovedPermanently)) + + // Front-end (UI) routes + s.router.Route("/ui/", func(r chi.Router) { + r.Use(auth.BrowserBasicAuth(s.apiUserRepo)) + + r.NotFound(ui_handlers.PageNotFound) + r.Get("/", ui_handlers.HomePage) + r.Handle("/static/*", http.StripPrefix("/ui/", http.FileServer(http.FS(resources.Static)))) + + r.Route("/profiles", func(r chi.Router) { + h := ui_handlers.NewUiProfileHandlerGroup(s.profileRepo) + + r.Get("/", h.Overview) + r.Get("/create", h.Create) + r.Post("/", h.Store) + + r.Route("/{uuid}", func(r chi.Router) { + r.Get("/", h.Show) + r.Get("/edit", h.Edit) + r.Put("/", h.Update) + r.Delete("/", h.Delete) + }) + }) + + r.Route("/systems", func(r chi.Router) { + h := ui_handlers.NewUiSystemHandlerGroup(s.systemRepo, s.profileRepo) + + r.Get("/", h.Overview) + r.Get("/create", h.Create) + r.Post("/", h.Store) + + r.Route("/{uuid}", func(r chi.Router) { + r.Get("/", h.Show) + r.Get("/edit", h.Edit) + r.Put("/", h.Update) + r.Delete("/", h.Delete) + }) + }) + }) } diff --git a/system/pxe_config.go b/system/pxe_config.go index 7a5a8ea..836fb5a 100644 --- a/system/pxe_config.go +++ b/system/pxe_config.go @@ -3,7 +3,6 @@ package system import ( "fmt" "github.com/evanebb/gobble/kernelparameters" - "strings" ) // TODO: create a PxeConfig interface with a Render() method, have the System struct implement it @@ -41,9 +40,8 @@ func NewPxeConfig(kernel string, initrd string, kernelParameters kernelparameter } func (p PxeConfig) Render() string { - kp := kernelparameters.FormatKernelParameters(p.KernelParameters) - kpStr := strings.Join(kp, " ") - return fmt.Sprintf(template, p.Kernel, kpStr, p.Initrd) + kp := p.KernelParameters.String() + return fmt.Sprintf(template, p.Kernel, kp, p.Initrd) } func RenderNotFound() string { diff --git a/system/pxe_config_test.go b/system/pxe_config_test.go index 1497397..eac429f 100644 --- a/system/pxe_config_test.go +++ b/system/pxe_config_test.go @@ -14,7 +14,7 @@ initrd testinitrd boot ` - kp, err := kernelparameters.New([]string{"param1"}) + kp, err := kernelparameters.ParseStringSlice([]string{"param1"}) if err != nil { t.Fatalf(`NewPxeConfig(): failed to instantiate KernelParameters, error: %v`, err) }