This commit is contained in:
soup 2024-11-14 17:40:27 -05:00
parent 1ca54d6114
commit 329f28a737
17 changed files with 244 additions and 60 deletions

1
.fdignore Symbolic link
View file

@ -0,0 +1 @@
.gitignore

View file

@ -7,4 +7,4 @@ run:
go run backend/bin/shelves.go go run backend/bin/shelves.go
watch: watch:
fd . . | entr -cr make run fd | entr -cr make run

View file

@ -9,7 +9,7 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"shelves/backend/db" "shelves/backend/db"
shelvesHttp "shelves/backend/http" "shelves/backend/httpx"
"shelves/backend/routes" "shelves/backend/routes"
) )
@ -30,5 +30,5 @@ func main() {
routes := routes.Routes() routes := routes.Routes()
fmt.Println("Listening on localhost:8999") 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)))
} }

View file

@ -0,0 +1,10 @@
package errorsx
func String(err error) string {
out := ""
if err != nil {
out = err.Error()
}
return out
}

40
backend/forms/forms.go Normal file
View file

@ -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
}

10
backend/httpx/httpx.go Normal file
View file

@ -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)
}

View file

@ -1,4 +1,4 @@
package http package httpx
import ( import (
"context" "context"
@ -6,8 +6,6 @@ import (
"log" "log"
"net/http" "net/http"
"time" "time"
"shelves/backend/routes"
) )
type LogWrapper struct { type LogWrapper struct {
@ -68,8 +66,15 @@ func queryNeedsOwnerSetup(db *sql.DB) (bool, error) {
} }
type Ctx struct { type Ctx struct {
DB *sql.DB DB *sql.DB
Auth AuthInfo 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 { 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) 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() ctx := r.Context()
auth, err := checkAuthed(db, r) auth, err := checkAuthed(db, r)
if err != nil { if err != nil {
log.Printf("Error while querying auth info: %v\n", err) log.Printf("Error while querying auth info: %v\n", err)
} }
ctx = context.WithValue(ctx, "__ctx", Ctx{ ctx = context.WithValue(ctx, "__ctx", Ctx{
DB: db, DB: db,
Auth: auth, Auth: auth,
NeedsOwnerSetup: needsOwnerSetup,
}) })
r = r.WithContext(ctx) r = r.WithContext(ctx)

View file

@ -1,9 +1,10 @@
package routes package routes
import ( import (
"embed"
"html/template" "html/template"
"net/http" "net/http"
"shelves/backend/httpx"
"strings"
"shelves" "shelves"
) )
@ -13,10 +14,26 @@ func html(w http.ResponseWriter, s template.HTML) {
w.Write([]byte(s)) w.Write([]byte(s))
} }
func Routes() *http.ServeMux { func redirectSetup(next http.Handler) http.Handler {
mux := http.NewServeMux() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("GET /", HomeGet) ctx := httpx.GetCtx(r)
mux.Handle("GET /static", http.FileServerFS(shelves.Frontend)) 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)
} }

View file

@ -1,24 +1,84 @@
package routes package routes
import ( import (
"html/template"
"net/http" "net/http"
"net/url"
"shelves/backend/errorsx"
"shelves/backend/forms"
"shelves/backend/httpx"
"shelves/backend/templates" "shelves/backend/templates"
) )
func SetupGet(w http.ResponseWriter, r *http.Request) { func renderView(f setupForm, e setupFormErrors) template.HTML {
html(w, templates.PageBase{Title: "Set up", Body: templates.Form{
body := templates.Form{
Action: "/setup", Action: "/setup",
Fields: []templates.Field{ Fields: []templates.Field{
templates.Field{ templates.Field{
Label: "Password", Label: "Password",
Type: "password", Type: "password",
Name: "password", Name: "password",
Error: errorsx.String(e.password),
}, },
templates.Field{ templates.Field{
Label: "Confirm password", Label: "Confirm password",
Type: "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")
}
} }

View file

@ -1,6 +1,8 @@
package templates package templates
import "html/template" import (
"html/template"
)
type Field struct { type Field struct {
Label string Label string
@ -31,8 +33,8 @@ func (f Form) HTML() template.HTML {
f.Method = "POST" f.Method = "POST"
} }
for _, f := range f.Fields { for i := range f.Fields {
f.init() f.Fields[i].init()
} }
return tmpls.renderHtml("form.tmpl.html", f) return tmpls.renderHtml("form.tmpl.html", f)

View file

@ -1,7 +1,8 @@
{{define "attrs"}} {{define "attrs"}}
name="{{.Name}}"
placeholder="{{.Placeholder}}" placeholder="{{.Placeholder}}"
aria-invalid="{{.Valid}}" aria-invalid="{{not .Valid}}"
{{if .Error}} {{if .Error}}
aria-describedby="{{.Name}}-error" aria-describedby="{{.Name}}-error"
{{end}} {{end}}
@ -9,22 +10,25 @@
<form action="{{.Action}}" method="{{.Method}}"> <form action="{{.Action}}" method="{{.Method}}">
{{range .Fields}} {{range .Fields}}
<label> <mu-field>
{{.Label}} <label>
{{if eq .Type "textarea"}} {{.Label}}
<textarea {{if eq .Type "textarea"}}
{{template "attrs" .}} <textarea
>{{.Value}}</textarea> {{template "attrs" .}}
{{else}} >{{.Value}}</textarea>
<input {{else}}
type="{{.Type}}" <input
value="{{.Value}}" type="{{.Type}}"
{{template "attrs" .}} value="{{.Value}}"
> {{template "attrs" .}}
>
{{end}}
</label>
{{if .Error}}
<small id="{{.Name}}-error">{{.Error}}</small>
{{end}} {{end}}
</label> </mu-field>
{{if .Error}}
<small id="{{.Name}}-error">{{.Error}}</small>
{{end}}
{{end}} {{end}}
<input type="submit" value="Submit">
</form> </form>

View file

@ -2,6 +2,7 @@
<html> <html>
<head> <head>
<title>{{.Title}} - Shelves</title> <title>{{.Title}} - Shelves</title>
<link rel="stylesheet" href="/static/css/mu.css" />
{{.Head}} {{.Head}}
</head> </head>
<body> <body>

View file

@ -24,7 +24,7 @@
"flake-utils" "flake-utils"
], ],
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs-unstable"
] ]
}, },
"locked": { "locked": {
@ -54,11 +54,27 @@
"type": "indirect" "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": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"gomod2nix": "gomod2nix", "gomod2nix": "gomod2nix",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable"
} }
}, },
"systems": { "systems": {

View file

@ -1,10 +1,10 @@
{ {
inputs.nixpkgs.url = "nixpkgs"; inputs.nixpkgs.url = "nixpkgs";
inputs.nixpkgs-unstable.url = "github:NixOS/nixpkgs?branch=nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils"; inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.gomod2nix = { inputs.gomod2nix = {
url = "github:nix-community/gomod2nix"; url = "github:nix-community/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs-unstable";
inputs.flake-utils.follows = "flake-utils"; inputs.flake-utils.follows = "flake-utils";
}; };
@ -12,6 +12,7 @@
(inputs.flake-utils.lib.eachDefaultSystem (system: (inputs.flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = inputs.nixpkgs.legacyPackages.${system}; pkgs = inputs.nixpkgs.legacyPackages.${system};
pkgsU = inputs.nixpkgs-unstable.legacyPackages.${system};
pkgsG = inputs.gomod2nix.legacyPackages.${system}; pkgsG = inputs.gomod2nix.legacyPackages.${system};
in { in {
devShells.default = devShells.default =
@ -21,9 +22,12 @@
gnumake gnumake
entr entr
fd fd
goEnv
(sqlite.override { interactive = true; }) (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
]; ];
}; };
} }

View file

@ -1,6 +1,11 @@
package shelves package shelves
import "embed" import (
"embed"
"io/fs"
)
//go:embed frontend/* //go:embed frontend/*
var Frontend embed.FS var fe embed.FS
var Frontend, _ = fs.Sub(fe, "frontend")

View file

@ -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;
}
}

2
go.mod
View file

@ -1,6 +1,6 @@
module shelves module shelves
go 1.22 go 1.23
require ( require (
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24