.
This commit is contained in:
parent
1ca54d6114
commit
329f28a737
2
Makefile
2
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
backend/errorsx/errorsx.go
Normal file
10
backend/errorsx/errorsx.go
Normal 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
40
backend/forms/forms.go
Normal 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
10
backend/httpx/httpx.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
20
flake.lock
20
flake.lock
|
|
@ -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": {
|
||||||
|
|
|
||||||
12
flake.nix
12
flake.nix
|
|
@ -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
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue