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
watch:
fd . . | entr -cr make run
fd | entr -cr make run

View file

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

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 (
"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)

View file

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

View file

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

View file

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

View file

@ -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 @@
<form action="{{.Action}}" method="{{.Method}}">
{{range .Fields}}
<label>
{{.Label}}
{{if eq .Type "textarea"}}
<textarea
{{template "attrs" .}}
>{{.Value}}</textarea>
{{else}}
<input
type="{{.Type}}"
value="{{.Value}}"
{{template "attrs" .}}
>
<mu-field>
<label>
{{.Label}}
{{if eq .Type "textarea"}}
<textarea
{{template "attrs" .}}
>{{.Value}}</textarea>
{{else}}
<input
type="{{.Type}}"
value="{{.Value}}"
{{template "attrs" .}}
>
{{end}}
</label>
{{if .Error}}
<small id="{{.Name}}-error">{{.Error}}</small>
{{end}}
</label>
{{if .Error}}
<small id="{{.Name}}-error">{{.Error}}</small>
{{end}}
</mu-field>
{{end}}
<input type="submit" value="Submit">
</form>

View file

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

View file

@ -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": {

View file

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

View file

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

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
go 1.22
go 1.23
require (
github.com/mattn/go-sqlite3 v1.14.24