diff --git a/.fdignore b/.fdignore new file mode 120000 index 0000000..3e4e48b --- /dev/null +++ b/.fdignore @@ -0,0 +1 @@ +.gitignore \ No newline at end of file diff --git a/Makefile b/Makefile index 0b5a80b..38fdeac 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,4 @@ run: go run backend/bin/shelves.go watch: - fd . . | entr -cr make run + fd | entr -cr make run diff --git a/backend/bin/shelves.go b/backend/bin/shelves.go index be32810..38f2ad3 100644 --- a/backend/bin/shelves.go +++ b/backend/bin/shelves.go @@ -9,7 +9,7 @@ import ( _ "github.com/mattn/go-sqlite3" "shelves/backend/db" - shelvesHttp "shelves/backend/http" + "shelves/backend/httpx" "shelves/backend/routes" ) @@ -30,5 +30,5 @@ func main() { routes := routes.Routes() fmt.Println("Listening on localhost:8999") - http.ListenAndServe("localhost:8999", shelvesHttp.Log(shelvesHttp.WithCtx(db, routes))) + http.ListenAndServe("localhost:8999", httpx.Log(httpx.WithCtx(db, routes))) } diff --git a/backend/errorsx/errorsx.go b/backend/errorsx/errorsx.go new file mode 100644 index 0000000..2441657 --- /dev/null +++ b/backend/errorsx/errorsx.go @@ -0,0 +1,10 @@ +package errorsx + +func String(err error) string { + out := "" + if err != nil { + out = err.Error() + } + + return out +} diff --git a/backend/forms/forms.go b/backend/forms/forms.go new file mode 100644 index 0000000..c079ae4 --- /dev/null +++ b/backend/forms/forms.go @@ -0,0 +1,40 @@ +package forms + +import ( + "errors" + "fmt" + "net/http" + "net/url" +) + +type Validator struct { + Failed bool +} + +func (v *Validator) MinLength(s string, min int) error { + var err error = nil + if len(s) < min { + v.Failed = true + err = fmt.Errorf("Minimum length: %v", min) + } + + return err +} + +func (v *Validator) Fail(s string) error { + v.Failed = true + + return errors.New(s) +} + +func ParseFormData(r *http.Request, parse func(url.Values, *Validator)) bool { + v := Validator{} + err := r.ParseForm() + if err != nil { + return true + } + + parse(r.Form, &v) + + return v.Failed +} diff --git a/backend/httpx/httpx.go b/backend/httpx/httpx.go new file mode 100644 index 0000000..c2acabf --- /dev/null +++ b/backend/httpx/httpx.go @@ -0,0 +1,10 @@ +package httpx + +import ( + "net/http" +) + +func SeeOther(w http.ResponseWriter, location string) { + w.Header().Add("Location", location) + w.WriteHeader(http.StatusSeeOther) +} diff --git a/backend/http/middleware.go b/backend/httpx/middleware.go similarity index 86% rename from backend/http/middleware.go rename to backend/httpx/middleware.go index 0379549..cf828f6 100644 --- a/backend/http/middleware.go +++ b/backend/httpx/middleware.go @@ -1,4 +1,4 @@ -package http +package httpx import ( "context" @@ -6,8 +6,6 @@ import ( "log" "net/http" "time" - - "shelves/backend/routes" ) type LogWrapper struct { @@ -68,8 +66,15 @@ func queryNeedsOwnerSetup(db *sql.DB) (bool, error) { } type Ctx struct { - DB *sql.DB - Auth AuthInfo + DB *sql.DB + Auth AuthInfo + NeedsOwnerSetup bool +} + +func GetCtx(r *http.Request) Ctx { + ctx := r.Context() + + return ctx.Value("__ctx").(Ctx) } func WithCtx(db *sql.DB, next http.Handler) http.Handler { @@ -79,25 +84,15 @@ func WithCtx(db *sql.DB, next http.Handler) http.Handler { log.Printf("Error while querying owner_settings: %v\n", err) } - if needsOwnerSetup { - if r.URL.Path != "/setup" { - w.Header().Add("Location", "/setup") - w.WriteHeader(http.StatusSeeOther) - return - } else { - routes.SetupGet(w, r) - return - } - } - ctx := r.Context() auth, err := checkAuthed(db, r) if err != nil { log.Printf("Error while querying auth info: %v\n", err) } ctx = context.WithValue(ctx, "__ctx", Ctx{ - DB: db, - Auth: auth, + DB: db, + Auth: auth, + NeedsOwnerSetup: needsOwnerSetup, }) r = r.WithContext(ctx) diff --git a/backend/routes/routes.go b/backend/routes/routes.go index ce6a782..c9abf06 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -1,9 +1,10 @@ package routes import ( - "embed" "html/template" "net/http" + "shelves/backend/httpx" + "strings" "shelves" ) @@ -13,10 +14,26 @@ func html(w http.ResponseWriter, s template.HTML) { w.Write([]byte(s)) } -func Routes() *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc("GET /", HomeGet) - mux.Handle("GET /static", http.FileServerFS(shelves.Frontend)) +func redirectSetup(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := httpx.GetCtx(r) + if ctx.NeedsOwnerSetup && !strings.HasPrefix(r.URL.Path, "/static") { + if r.URL.Path != "/setup" { + httpx.SeeOther(w, "/setup") + return + } + } - return mux + next.ServeHTTP(w, r) + }) +} + +func Routes() http.Handler { + mux := http.NewServeMux() + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(shelves.Frontend))) + mux.HandleFunc("GET /", HomeGet) + mux.HandleFunc("GET /setup", SetupGet) + mux.HandleFunc("POST /setup", SetupPost) + + return redirectSetup(mux) } diff --git a/backend/routes/setup.go b/backend/routes/setup.go index c7a3db7..6a564dc 100644 --- a/backend/routes/setup.go +++ b/backend/routes/setup.go @@ -1,24 +1,84 @@ package routes import ( + "html/template" "net/http" + "net/url" + "shelves/backend/errorsx" + "shelves/backend/forms" + "shelves/backend/httpx" "shelves/backend/templates" ) -func SetupGet(w http.ResponseWriter, r *http.Request) { - html(w, templates.PageBase{Title: "Set up", Body: templates.Form{ +func renderView(f setupForm, e setupFormErrors) template.HTML { + + body := templates.Form{ Action: "/setup", Fields: []templates.Field{ templates.Field{ Label: "Password", Type: "password", Name: "password", + Error: errorsx.String(e.password), }, templates.Field{ Label: "Confirm password", Type: "password", - Name: "password-confirmation", + Name: "passwordConfirmation", }, }, - }.HTML()}.HTML()) + } + + return templates.PageBase{Title: "Set up", Body: body.HTML()}.HTML() +} + +func SetupGet(w http.ResponseWriter, r *http.Request) { + ctx := httpx.GetCtx(r) + if !ctx.NeedsOwnerSetup { + httpx.SeeOther(w, "/") + return + } + + html(w, renderView(setupForm{}, setupFormErrors{})) +} + +type setupForm struct { + password string +} + +type setupFormErrors struct { + password error +} + +func parseForm(f *setupForm, e *setupFormErrors, vs url.Values, v *forms.Validator) { + f.password = vs.Get("password") + passwordConfirmation := vs.Get("passwordConfirmation") + + if f.password != passwordConfirmation { + e.password = v.Fail("Passwords did not match") + return + } + + e.password = v.MinLength(f.password, 1) +} + +func SetupPost(w http.ResponseWriter, r *http.Request) { + ctx := httpx.GetCtx(r) + if !ctx.NeedsOwnerSetup { + httpx.SeeOther(w, "/") + return + } + + form := setupForm{} + errs := setupFormErrors{} + failed := forms.ParseFormData(r, func(vs url.Values, v *forms.Validator) { + parseForm(&form, &errs, vs, v) + }) + + if failed { + w.WriteHeader(http.StatusBadRequest) + html(w, renderView(form, errs)) + } else { + httpx.SeeOther(w, "/setup") + } } diff --git a/backend/templates/form.go b/backend/templates/form.go index 781e114..ab78c9a 100644 --- a/backend/templates/form.go +++ b/backend/templates/form.go @@ -1,6 +1,8 @@ package templates -import "html/template" +import ( + "html/template" +) type Field struct { Label string @@ -31,8 +33,8 @@ func (f Form) HTML() template.HTML { f.Method = "POST" } - for _, f := range f.Fields { - f.init() + for i := range f.Fields { + f.Fields[i].init() } return tmpls.renderHtml("form.tmpl.html", f) diff --git a/backend/templates/tmpls/form.tmpl.html b/backend/templates/tmpls/form.tmpl.html index ca4d7f0..6042e2d 100644 --- a/backend/templates/tmpls/form.tmpl.html +++ b/backend/templates/tmpls/form.tmpl.html @@ -1,7 +1,8 @@ {{define "attrs"}} + name="{{.Name}}" placeholder="{{.Placeholder}}" - aria-invalid="{{.Valid}}" + aria-invalid="{{not .Valid}}" {{if .Error}} aria-describedby="{{.Name}}-error" {{end}} @@ -9,22 +10,25 @@
{{range .Fields}} - - {{if .Error}} - {{.Error}} - {{end}} + {{end}} +
diff --git a/backend/templates/tmpls/page_base.tmpl.html b/backend/templates/tmpls/page_base.tmpl.html index 72a6b3b..69e8840 100644 --- a/backend/templates/tmpls/page_base.tmpl.html +++ b/backend/templates/tmpls/page_base.tmpl.html @@ -2,6 +2,7 @@ {{.Title}} - Shelves + {{.Head}} diff --git a/flake.lock b/flake.lock index 135500a..d0ce77b 100644 --- a/flake.lock +++ b/flake.lock @@ -24,7 +24,7 @@ "flake-utils" ], "nixpkgs": [ - "nixpkgs" + "nixpkgs-unstable" ] }, "locked": { @@ -54,11 +54,27 @@ "type": "indirect" } }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1731685661, + "narHash": "sha256-5ktjIlkTfWNbbrit9refiRY5rKR4nw9oTe5ck/YUNsQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2d088b482efb193d02d1692697f9dd0b6f8b5012", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", "gomod2nix": "gomod2nix", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nixpkgs-unstable": "nixpkgs-unstable" } }, "systems": { diff --git a/flake.nix b/flake.nix index 92fdd5e..83bc551 100644 --- a/flake.nix +++ b/flake.nix @@ -1,10 +1,10 @@ { - inputs.nixpkgs.url = "nixpkgs"; + inputs.nixpkgs-unstable.url = "github:NixOS/nixpkgs?branch=nixpkgs-unstable"; inputs.flake-utils.url = "github:numtide/flake-utils"; inputs.gomod2nix = { url = "github:nix-community/gomod2nix"; - inputs.nixpkgs.follows = "nixpkgs"; + inputs.nixpkgs.follows = "nixpkgs-unstable"; inputs.flake-utils.follows = "flake-utils"; }; @@ -12,6 +12,7 @@ (inputs.flake-utils.lib.eachDefaultSystem (system: let pkgs = inputs.nixpkgs.legacyPackages.${system}; + pkgsU = inputs.nixpkgs-unstable.legacyPackages.${system}; pkgsG = inputs.gomod2nix.legacyPackages.${system}; in { devShells.default = @@ -21,9 +22,12 @@ gnumake entr fd - goEnv (sqlite.override { interactive = true; }) - pkgsG.gomod2nix + pkgsU.go_1_23 + pkgsU.gopls + # TODO: re-enable this once it works with go 1.23 + # goEnv + # pkgsG.gomod2nix ]; }; } diff --git a/frontend.go b/frontend.go index 42b7464..2f7c500 100644 --- a/frontend.go +++ b/frontend.go @@ -1,6 +1,11 @@ package shelves -import "embed" +import ( + "embed" + "io/fs" +) //go:embed frontend/* -var Frontend embed.FS +var fe embed.FS + +var Frontend, _ = fs.Sub(fe, "frontend") diff --git a/frontend/css/mu.css b/frontend/css/mu.css index e69de29..32faf5d 100644 --- a/frontend/css/mu.css +++ b/frontend/css/mu.css @@ -0,0 +1,19 @@ +form { + width: 100%; + + display: flex; + flex-flow: column nowrap; + gap: 1rem; + + & input { + width: 100%; + + &[aria-invalid="true"] { + border-color: red; + } + } + + & label:has([aria-invalid="true"]) + small { + color: red; + } +} diff --git a/go.mod b/go.mod index 03b2ea1..63280e3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module shelves -go 1.22 +go 1.23 require ( github.com/mattn/go-sqlite3 v1.14.24