Compare commits
10 commits
41949c857a
...
f058177edf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f058177edf | ||
|
|
54fa17ab25 | ||
|
|
920f162695 | ||
|
|
cf950db825 | ||
|
|
9e03bdb2de | ||
|
|
e8d775d96d | ||
|
|
c18537e82f | ||
|
|
46bf383ad2 | ||
|
|
921bf66d20 | ||
|
|
600f105b00 |
4
Makefile
4
Makefile
|
|
@ -1,10 +1,10 @@
|
|||
.PHONY: build
|
||||
|
||||
build:
|
||||
go build backend/bin/shelves.go
|
||||
go build cmd/shelves/main.go
|
||||
|
||||
run:
|
||||
go run backend/bin/shelves.go
|
||||
go run cmd/shelves/main.go
|
||||
|
||||
watch:
|
||||
fd | entr -cr make run
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"shelves/backend/db"
|
||||
"shelves/backend/httpx"
|
||||
"shelves/backend/routes"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Remove("./shelves.db")
|
||||
db, migrateResult, err := db.Open("./shelves.db")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open DB: %v", err)
|
||||
}
|
||||
if migrateResult.MigrationError != nil {
|
||||
log.Printf("An error was encountered while upgrading the database schema. You are on version %v, but the latest is %v. The error was: %v", migrateResult.SchemaVerNew, migrateResult.SchemaVerLatest, migrateResult.MigrationError)
|
||||
}
|
||||
schemaNeedsUpdating := migrateResult.SchemaVerNew != migrateResult.SchemaVerLatest
|
||||
if schemaNeedsUpdating {
|
||||
log.Printf("Your database schema needs to be updated. The application will continue to run, but you may encounter errors.\n")
|
||||
}
|
||||
_ = db
|
||||
|
||||
routes := routes.Routes()
|
||||
fmt.Println("Listening on localhost:8999")
|
||||
http.ListenAndServe("localhost:8999", httpx.Log(httpx.WithCtx(db, routes)))
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func SeeOther(w http.ResponseWriter, location string) {
|
||||
w.Header().Add("Location", location)
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package routes
|
||||
|
||||
import "net/http"
|
||||
|
||||
func HomeGet(w http.ResponseWriter, req *http.Request) {
|
||||
w.Write([]byte("Hello, world"))
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"shelves/backend/httpx"
|
||||
"strings"
|
||||
|
||||
"shelves"
|
||||
)
|
||||
|
||||
func html(w http.ResponseWriter, s template.HTML) {
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
w.Write([]byte(s))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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,84 +0,0 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"shelves/backend/errorsx"
|
||||
"shelves/backend/forms"
|
||||
"shelves/backend/httpx"
|
||||
"shelves/backend/templates"
|
||||
)
|
||||
|
||||
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: "passwordConfirmation",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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,15 +0,0 @@
|
|||
package templates
|
||||
|
||||
import "html/template"
|
||||
|
||||
type PageBase struct {
|
||||
Title string
|
||||
Head template.HTML
|
||||
Body template.HTML
|
||||
BodyBefore template.HTML
|
||||
BodyAfter template.HTML
|
||||
}
|
||||
|
||||
func (pb PageBase) HTML() template.HTML {
|
||||
return tmpls.renderHtml("page_base.tmpl.html", pb)
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed tmpls/*
|
||||
var files embed.FS
|
||||
|
||||
type Template struct {
|
||||
*template.Template
|
||||
}
|
||||
|
||||
var tmpls = Template{template.Must(template.ParseFS(files, "tmpls/*"))}
|
||||
|
||||
func (tmpl Template) renderHtml(name string, data any) template.HTML {
|
||||
writer := &strings.Builder{}
|
||||
err := tmpl.ExecuteTemplate(writer, name, data)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprint(writer, err)
|
||||
}
|
||||
|
||||
return template.HTML(writer.String())
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}} - Shelves</title>
|
||||
<link rel="stylesheet" href="/static/css/mu.css" />
|
||||
{{.Head}}
|
||||
</head>
|
||||
<body>
|
||||
{{.BodyBefore}}
|
||||
{{.Body}}
|
||||
{{.BodyAfter}}
|
||||
</body>
|
||||
</html>
|
||||
28
embeds.go
Normal file
28
embeds.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package shelves
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed frontend/*
|
||||
var fe embed.FS
|
||||
|
||||
var Frontend, _ = fs.Sub(fe, "frontend")
|
||||
|
||||
//go:embed internal/templates/views/*.tmpl.* internal/templates/components/*.tmpl.*
|
||||
var templates embed.FS
|
||||
var Templates, _ = fs.Sub(templates, "internal/templates")
|
||||
|
||||
func printEmbeddedFiles(efs fs.FS) error {
|
||||
return fs.WalkDir(efs, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
fmt.Println(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
11
frontend.go
11
frontend.go
|
|
@ -1,11 +0,0 @@
|
|||
package shelves
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed frontend/*
|
||||
var fe embed.FS
|
||||
|
||||
var Frontend, _ = fs.Sub(fe, "frontend")
|
||||
5203
frontend/js/vendor/htmx-2.0.3.js
vendored
Normal file
5203
frontend/js/vendor/htmx-2.0.3.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
9
go.mod
9
go.mod
|
|
@ -1,9 +1,10 @@
|
|||
module shelves
|
||||
module git.soup.land/soup/shelves
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
golang.org/x/crypto v0.29.0
|
||||
golang.org/x/tools/gopls v0.16.2
|
||||
)
|
||||
|
||||
|
|
@ -12,10 +13,10 @@ require (
|
|||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20221212164502-fae10dda9338 // indirect
|
||||
golang.org/x/mod v0.20.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/sync v0.9.0 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20240829154258-f29ab539cc98 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
golang.org/x/tools v0.22.1-0.20240829175637-39126e24d653 // indirect
|
||||
golang.org/x/vuln v1.0.4 // indirect
|
||||
honnef.co/go/tools v0.4.7 // indirect
|
||||
|
|
|
|||
14
go.sum
14
go.sum
|
|
@ -16,18 +16,20 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW
|
|||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/exp/typeparams v0.0.0-20221212164502-fae10dda9338 h1:2O2DON6y3XMJiQRAS1UWU+54aec2uopH3x7MAiqGW6Y=
|
||||
golang.org/x/exp/typeparams v0.0.0-20221212164502-fae10dda9338/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
|
||||
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240829154258-f29ab539cc98 h1:Wm3cG5X6sZ0RSVRc/H1/sciC4AT6HAKgLCSH2lbpR/c=
|
||||
golang.org/x/telemetry v0.0.0-20240829154258-f29ab539cc98/go.mod h1:m7R/r+o5h7UvF2JD9n2iLSGY4v8v+zNSyTJ6xynLrqs=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/tools v0.22.1-0.20240829175637-39126e24d653 h1:6bJEg2w2kUHWlfdJaESYsmNfI1LKAZQi6zCa7LUn7eI=
|
||||
golang.org/x/tools v0.22.1-0.20240829175637-39126e24d653/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/tools/gopls v0.16.2 h1:K1z03MlikHfaMTtG01cUeL5FAOTJnITuNe0TWOcg8tM=
|
||||
|
|
|
|||
92
internal/auth/auth.go
Normal file
92
internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"math/rand/v2"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
type passwordInfo struct {
|
||||
hash []byte
|
||||
salt []byte
|
||||
}
|
||||
|
||||
func queryGetOwnerSettingsPasswordInfo(db *sql.DB) (passwordInfo, error) {
|
||||
out := passwordInfo{}
|
||||
err := db.QueryRow(`select password_hash, password_salt from owner_settings`).Scan(&out.hash, &out.salt)
|
||||
|
||||
return out, err
|
||||
}
|
||||
|
||||
func queryCreateSession(db *sql.DB, sessionId string) error {
|
||||
_, err := db.Exec(`insert into session (session_id) values (?)`, sessionId)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func HashPassword(password []byte, salt []byte) []byte {
|
||||
passwordHash := argon2.IDKey(password, salt, 10, 7, 1, 64)
|
||||
|
||||
return passwordHash
|
||||
}
|
||||
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const charsetLen = len(charset)
|
||||
|
||||
func randomString(n int) string {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
ciIdx := rand.IntN(charsetLen)
|
||||
|
||||
b[i] = charset[ciIdx]
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func Login(db *sql.DB, password string) (bool, string, error) {
|
||||
info, err := queryGetOwnerSettingsPasswordInfo(db)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
incomingHash := HashPassword([]byte(password), info.salt)
|
||||
if !bytes.Equal(incomingHash, info.hash) {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
sessionId := randomString(256)
|
||||
err = queryCreateSession(db, sessionId)
|
||||
|
||||
return true, sessionId, err
|
||||
}
|
||||
|
||||
func queryDeleteSession(db *sql.DB, sessionId string) error {
|
||||
_, err := db.Exec(`delete from session where session_id = ?`, sessionId)
|
||||
return err
|
||||
}
|
||||
|
||||
func SessionDelete(db *sql.DB, sessionId string) error {
|
||||
return queryDeleteSession(db, sessionId)
|
||||
}
|
||||
|
||||
type SessionInfo struct {
|
||||
IsAdmin bool
|
||||
SessionId string
|
||||
}
|
||||
|
||||
func queryCheckAuth(db *sql.DB, sessionId string) (SessionInfo, error) {
|
||||
sessionCount := 0
|
||||
err := db.QueryRow("select count(*) from session where session_id = ?", sessionId).Scan(&sessionCount)
|
||||
if err != nil {
|
||||
return SessionInfo{}, err
|
||||
}
|
||||
|
||||
return SessionInfo{IsAdmin: sessionCount == 1, SessionId: sessionId}, nil
|
||||
}
|
||||
|
||||
func SessionCheck(db *sql.DB, sessionId string) (SessionInfo, error) {
|
||||
return queryCheckAuth(db, sessionId)
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import (
|
|||
var _MIGRATIONS = [...]string{
|
||||
`
|
||||
create table owner_settings (
|
||||
id integer primary key default 1,
|
||||
id integer primary key check (id = 1),
|
||||
|
||||
display_name text not null,
|
||||
|
||||
43
internal/httpx/httpx.go
Normal file
43
internal/httpx/httpx.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package httpx
|
||||
|
||||
import (
|
||||
"git.soup.land/soup/shelves/internal/errorsx"
|
||||
"git.soup.land/soup/shelves/internal/urls"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func SeeOther(w http.ResponseWriter, location string) {
|
||||
w.Header().Add("Location", location)
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func TemporaryRedirect(w http.ResponseWriter, location string) {
|
||||
w.Header().Add("Location", location)
|
||||
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func Unauthorized(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func BadRequest(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func OK(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func InternalServerError(w http.ResponseWriter, err error) {
|
||||
log.Printf("Internal server error: %v", err)
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(errorsx.String(err)))
|
||||
}
|
||||
|
||||
func LoginRedirect(w http.ResponseWriter, redirectTo url.URL) {
|
||||
location := urls.Login(redirectTo.Path)
|
||||
TemporaryRedirect(w, location)
|
||||
}
|
||||
7
internal/httpx/hx.go
Normal file
7
internal/httpx/hx.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package httpx
|
||||
|
||||
import "net/http"
|
||||
|
||||
func HxRefresh(w http.ResponseWriter) {
|
||||
w.Header().Add("HX-Refresh", "true")
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package httpx
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"git.soup.land/soup/shelves/internal/auth"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
|
@ -31,30 +32,6 @@ func Log(next http.Handler) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
type AuthInfo struct {
|
||||
is_admin bool
|
||||
}
|
||||
|
||||
func checkAuthed(db *sql.DB, r *http.Request) (AuthInfo, error) {
|
||||
cookie, err := r.Cookie("SHELVES_OWNER_SESSION_ID")
|
||||
if err == http.ErrNoCookie {
|
||||
return AuthInfo{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return AuthInfo{}, err
|
||||
}
|
||||
|
||||
sessionId := cookie.Value
|
||||
|
||||
sessionCount := 0
|
||||
err = db.QueryRow("select count(*) from session where session_id = ?", sessionId).Scan(&sessionCount)
|
||||
if err != nil {
|
||||
return AuthInfo{}, err
|
||||
}
|
||||
|
||||
return AuthInfo{is_admin: sessionCount == 1}, nil
|
||||
}
|
||||
|
||||
func queryNeedsOwnerSetup(db *sql.DB) (bool, error) {
|
||||
count := 0
|
||||
err := db.QueryRow("select count(*) from owner_settings").Scan(&count)
|
||||
|
|
@ -65,9 +42,21 @@ func queryNeedsOwnerSetup(db *sql.DB) (bool, error) {
|
|||
return count != 1, nil
|
||||
}
|
||||
|
||||
func sessionInfo(db *sql.DB, r *http.Request) (auth.SessionInfo, error) {
|
||||
cookie, err := r.Cookie("SHELVES_OWNER_SESSION_ID")
|
||||
if err == http.ErrNoCookie {
|
||||
return auth.SessionInfo{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return auth.SessionInfo{}, err
|
||||
}
|
||||
|
||||
return auth.SessionCheck(db, cookie.Value)
|
||||
}
|
||||
|
||||
type Ctx struct {
|
||||
DB *sql.DB
|
||||
Auth AuthInfo
|
||||
SessionInfo auth.SessionInfo
|
||||
NeedsOwnerSetup bool
|
||||
}
|
||||
|
||||
|
|
@ -85,13 +74,13 @@ func WithCtx(db *sql.DB, next http.Handler) http.Handler {
|
|||
}
|
||||
|
||||
ctx := r.Context()
|
||||
auth, err := checkAuthed(db, r)
|
||||
session, err := sessionInfo(db, r)
|
||||
if err != nil {
|
||||
log.Printf("Error while querying auth info: %v\n", err)
|
||||
log.Printf("Error while querying session info: %v\n", err)
|
||||
}
|
||||
ctx = context.WithValue(ctx, "__ctx", Ctx{
|
||||
DB: db,
|
||||
Auth: auth,
|
||||
SessionInfo: session,
|
||||
NeedsOwnerSetup: needsOwnerSetup,
|
||||
})
|
||||
r = r.WithContext(ctx)
|
||||
26
internal/routes/home.go
Normal file
26
internal/routes/home.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.soup.land/soup/shelves/internal/httpx"
|
||||
"git.soup.land/soup/shelves/internal/templates"
|
||||
"git.soup.land/soup/shelves/internal/templates/components"
|
||||
)
|
||||
|
||||
type homeContent struct {
|
||||
}
|
||||
|
||||
var homeTmpl = templates.MustParseEmbed("views/home.tmpl.html")
|
||||
|
||||
func HomeGet(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := httpx.GetCtx(req)
|
||||
|
||||
h := components.Page{
|
||||
Title: "Home",
|
||||
SessionInfo: ctx.SessionInfo,
|
||||
Body: templates.HTML(homeTmpl, "body", homeContent{}),
|
||||
}.HTML()
|
||||
|
||||
html(w, h)
|
||||
}
|
||||
150
internal/routes/login.go
Normal file
150
internal/routes/login.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.soup.land/soup/shelves/internal/auth"
|
||||
"git.soup.land/soup/shelves/internal/errorsx"
|
||||
"git.soup.land/soup/shelves/internal/forms"
|
||||
"git.soup.land/soup/shelves/internal/httpx"
|
||||
"git.soup.land/soup/shelves/internal/templates/components"
|
||||
)
|
||||
|
||||
type loginForm struct {
|
||||
password string
|
||||
redirectTo string
|
||||
}
|
||||
|
||||
type loginFormErrors struct {
|
||||
password error
|
||||
}
|
||||
|
||||
func loginRenderView(f loginForm, e loginFormErrors, ctx httpx.Ctx) template.HTML {
|
||||
formHtml := components.Form{
|
||||
Action: "/login",
|
||||
Fields: []components.Field{
|
||||
components.Field{
|
||||
Label: "Password",
|
||||
Type: "password",
|
||||
Placeholder: "Password",
|
||||
Name: "password",
|
||||
Error: errorsx.String(e.password),
|
||||
},
|
||||
components.Field{
|
||||
Type: "hidden",
|
||||
Name: "redirectTo",
|
||||
Value: f.redirectTo,
|
||||
},
|
||||
},
|
||||
}.HTML()
|
||||
|
||||
page := components.Page{
|
||||
Title: "Login",
|
||||
SessionInfo: ctx.SessionInfo,
|
||||
Body: formHtml,
|
||||
}
|
||||
|
||||
return page.HTML()
|
||||
}
|
||||
|
||||
func getRedirectTo(r *http.Request) string {
|
||||
redirectTo := r.URL.Query().Get("redirectTo")
|
||||
if redirectTo == "" {
|
||||
redirectTo = "/"
|
||||
}
|
||||
|
||||
redirectTo, err := url.QueryUnescape(redirectTo)
|
||||
if err != nil {
|
||||
redirectTo = ""
|
||||
}
|
||||
|
||||
return redirectTo
|
||||
}
|
||||
|
||||
func LoginGet(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := httpx.GetCtx(r)
|
||||
redirectTo := getRedirectTo(r)
|
||||
|
||||
if ctx.SessionInfo.IsAdmin {
|
||||
httpx.SeeOther(w, redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
html(w, loginRenderView(loginForm{redirectTo: redirectTo}, loginFormErrors{}, ctx))
|
||||
}
|
||||
|
||||
func loginParseForm(f *loginForm, e *loginFormErrors, vs url.Values, v *forms.Validator) {
|
||||
f.password = vs.Get("password")
|
||||
f.redirectTo = vs.Get("redirectTo")
|
||||
}
|
||||
|
||||
func LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := httpx.GetCtx(r)
|
||||
|
||||
form := loginForm{}
|
||||
errs := loginFormErrors{}
|
||||
failed := forms.ParseFormData(r, func(vs url.Values, v *forms.Validator) {
|
||||
loginParseForm(&form, &errs, vs, v)
|
||||
})
|
||||
if failed {
|
||||
httpx.BadRequest(w)
|
||||
html(w, loginRenderView(form, errs, ctx))
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.SessionInfo.IsAdmin {
|
||||
httpx.SeeOther(w, form.redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
success, sessionId, err := auth.Login(ctx.DB, form.password)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
if !success {
|
||||
httpx.BadRequest(w)
|
||||
|
||||
errs.password = errors.New("Incorrect password")
|
||||
html(w, loginRenderView(form, errs, ctx))
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{Name: "SHELVES_OWNER_SESSION_ID", Value: sessionId}
|
||||
cookie.Secure = true
|
||||
cookie.HttpOnly = true
|
||||
cookie.SameSite = http.SameSiteStrictMode
|
||||
cookie.MaxAge = 60 * 60 * 24 * 7 // 7 days
|
||||
|
||||
http.SetCookie(w, &cookie)
|
||||
httpx.SeeOther(w, form.redirectTo)
|
||||
}
|
||||
|
||||
func LoginDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := httpx.GetCtx(r)
|
||||
httpx.HxRefresh(w)
|
||||
|
||||
if !ctx.SessionInfo.IsAdmin {
|
||||
httpx.OK(w)
|
||||
return
|
||||
}
|
||||
|
||||
sessionId := ctx.SessionInfo.SessionId
|
||||
err := auth.SessionDelete(ctx.DB, sessionId)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{Name: "SHELVES_OWNER_SESSION_ID", Value: ""}
|
||||
cookie.Secure = true
|
||||
cookie.HttpOnly = true
|
||||
cookie.SameSite = http.SameSiteStrictMode
|
||||
cookie.MaxAge = -1
|
||||
|
||||
http.SetCookie(w, &cookie)
|
||||
httpx.OK(w)
|
||||
}
|
||||
61
internal/routes/routes.go
Normal file
61
internal/routes/routes.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"git.soup.land/soup/shelves"
|
||||
"git.soup.land/soup/shelves/internal/httpx"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func html(w http.ResponseWriter, s template.HTML) {
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
w.Write([]byte(s))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func ServeStatic() http.Handler {
|
||||
inner := http.StripPrefix("/static/", http.FileServerFS(shelves.Frontend))
|
||||
startup := time.Now().UTC()
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ims := r.Header.Get("If-Modified-Since")
|
||||
imsTime, _ := time.Parse(http.TimeFormat, ims)
|
||||
imsTime = imsTime.Add(time.Second * 10)
|
||||
|
||||
if imsTime.Before(startup) {
|
||||
w.Header().Add("Last-Modified", startup.Format(http.TimeFormat))
|
||||
w.Header().Add("Cache-Control", "public, max-age=0, stale-while-revalidate=9999999")
|
||||
inner.ServeHTTP(w, r)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", ServeStatic())
|
||||
mux.HandleFunc("GET /{$}", HomeGet)
|
||||
mux.HandleFunc("GET /settings", SettingsGet)
|
||||
mux.HandleFunc("POST /settings", SettingsPost)
|
||||
mux.HandleFunc("GET /login", LoginGet)
|
||||
mux.HandleFunc("POST /login", LoginPost)
|
||||
mux.HandleFunc("DELETE /login", LoginDelete)
|
||||
|
||||
return redirectSetup(mux)
|
||||
}
|
||||
142
internal/routes/settings.go
Normal file
142
internal/routes/settings.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.soup.land/soup/shelves/internal/auth"
|
||||
"git.soup.land/soup/shelves/internal/errorsx"
|
||||
"git.soup.land/soup/shelves/internal/forms"
|
||||
"git.soup.land/soup/shelves/internal/httpx"
|
||||
"git.soup.land/soup/shelves/internal/templates/components"
|
||||
)
|
||||
|
||||
func settingsRenderView(f settingsForm, e settingsFormErrors, ctx httpx.Ctx) template.HTML {
|
||||
body := components.Form{
|
||||
Action: "/settings",
|
||||
Fields: []components.Field{
|
||||
components.Field{
|
||||
Label: "Display name",
|
||||
Name: "displayName",
|
||||
Placeholder: "Jane Doe",
|
||||
Value: f.displayName,
|
||||
Error: errorsx.String(e.displayName),
|
||||
},
|
||||
components.Field{
|
||||
Label: "Password",
|
||||
Type: "password",
|
||||
Placeholder: "Password",
|
||||
Name: "password",
|
||||
Error: errorsx.String(e.password),
|
||||
},
|
||||
components.Field{
|
||||
Label: "Confirm password",
|
||||
Placeholder: "Confirm Password",
|
||||
Type: "password",
|
||||
Name: "passwordConfirmation",
|
||||
},
|
||||
},
|
||||
}.HTML()
|
||||
|
||||
return components.Page{Title: "Set up", Body: body, SessionInfo: ctx.SessionInfo}.HTML()
|
||||
}
|
||||
|
||||
func queryGetOwnerSettings(db *sql.DB) (settingsForm, error) {
|
||||
form := settingsForm{}
|
||||
err := db.QueryRow(`select display_name from owner_settings`).Scan(&form.displayName)
|
||||
if err == sql.ErrNoRows {
|
||||
err = nil
|
||||
}
|
||||
|
||||
return form, err
|
||||
}
|
||||
|
||||
func SettingsGet(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := httpx.GetCtx(r)
|
||||
if !ctx.SessionInfo.IsAdmin && !ctx.NeedsOwnerSetup {
|
||||
httpx.LoginRedirect(w, *r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
form, err := queryGetOwnerSettings(ctx.DB)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
html(w, settingsRenderView(form, settingsFormErrors{}, ctx))
|
||||
}
|
||||
|
||||
type settingsForm struct {
|
||||
password string
|
||||
displayName string
|
||||
}
|
||||
|
||||
type settingsFormErrors struct {
|
||||
password error
|
||||
displayName error
|
||||
}
|
||||
|
||||
func parseForm(f *settingsForm, e *settingsFormErrors, vs url.Values, v *forms.Validator) {
|
||||
f.password = vs.Get("password")
|
||||
passwordConfirmation := vs.Get("passwordConfirmation")
|
||||
f.displayName = vs.Get("displayName")
|
||||
|
||||
e.password = v.MinLength(f.password, 1)
|
||||
if e.password == nil && f.password != passwordConfirmation {
|
||||
e.password = v.Fail("Passwords did not match")
|
||||
}
|
||||
|
||||
e.displayName = v.MinLength(f.displayName, 1)
|
||||
}
|
||||
|
||||
func queryUpdateOwnerSettings(db *sql.DB, display_name string, password_salt []byte, password_hash []byte) error {
|
||||
_, err := db.Exec(`
|
||||
insert or replace into owner_settings (display_name, password_salt, password_hash)
|
||||
values (?, ?, ?)
|
||||
`, display_name, password_salt, password_hash)
|
||||
return err
|
||||
}
|
||||
|
||||
func updateOwnerSettings(db *sql.DB, f settingsForm) error {
|
||||
salt := make([]byte, 32)
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash := auth.HashPassword([]byte(f.password), salt)
|
||||
|
||||
return queryUpdateOwnerSettings(db, f.displayName, salt, hash)
|
||||
}
|
||||
|
||||
func SettingsPost(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := httpx.GetCtx(r)
|
||||
|
||||
if !ctx.SessionInfo.IsAdmin && !ctx.NeedsOwnerSetup {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
form := settingsForm{}
|
||||
errs := settingsFormErrors{}
|
||||
failed := forms.ParseFormData(r, func(vs url.Values, v *forms.Validator) {
|
||||
parseForm(&form, &errs, vs, v)
|
||||
})
|
||||
|
||||
if failed {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
html(w, settingsRenderView(form, errs, ctx))
|
||||
return
|
||||
}
|
||||
|
||||
err := updateOwnerSettings(ctx.DB, form)
|
||||
if err != nil {
|
||||
httpx.InternalServerError(w, err)
|
||||
} else {
|
||||
httpx.SeeOther(w, "/")
|
||||
}
|
||||
}
|
||||
1
internal/templates/components/component.go
Normal file
1
internal/templates/components/component.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package components
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package templates
|
||||
package components
|
||||
|
||||
import (
|
||||
"git.soup.land/soup/shelves/internal/templates"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
|
|
@ -28,6 +29,8 @@ type Form struct {
|
|||
Fields []Field
|
||||
}
|
||||
|
||||
var formTmpl = templates.MustParseEmbed("components/form.tmpl.html")
|
||||
|
||||
func (f Form) HTML() template.HTML {
|
||||
if f.Method == "" {
|
||||
f.Method = "POST"
|
||||
|
|
@ -37,5 +40,5 @@ func (f Form) HTML() template.HTML {
|
|||
f.Fields[i].init()
|
||||
}
|
||||
|
||||
return tmpls.renderHtml("form.tmpl.html", f)
|
||||
return templates.HTML(formTmpl, "form", f)
|
||||
}
|
||||
39
internal/templates/components/form.tmpl.html
Normal file
39
internal/templates/components/form.tmpl.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{{define "attrs"}}
|
||||
name="{{.Name}}"
|
||||
placeholder="{{.Placeholder}}"
|
||||
aria-invalid="{{not .Valid}}"
|
||||
{{if .Error}}
|
||||
aria-describedby="{{.Name}}-error"
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "field"}}
|
||||
<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}}
|
||||
</mu-field>
|
||||
{{end}}
|
||||
|
||||
{{define "form"}}
|
||||
<form action="{{.Action}}" method="{{.Method}}">
|
||||
{{range .Fields}}
|
||||
{{template "field" .}}
|
||||
{{end}}
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
{{end}}
|
||||
23
internal/templates/components/page.go
Normal file
23
internal/templates/components/page.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"git.soup.land/soup/shelves/internal/auth"
|
||||
"git.soup.land/soup/shelves/internal/templates"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
type Page struct {
|
||||
SessionInfo auth.SessionInfo
|
||||
|
||||
Title string
|
||||
Head template.HTML
|
||||
Body template.HTML
|
||||
BodyBefore template.HTML
|
||||
BodyAfter template.HTML
|
||||
}
|
||||
|
||||
var pageTmpl = templates.MustParseEmbed("components/page.tmpl.html")
|
||||
|
||||
func (p Page) HTML() template.HTML {
|
||||
return templates.HTML(pageTmpl, "page", p)
|
||||
}
|
||||
29
internal/templates/components/page.tmpl.html
Normal file
29
internal/templates/components/page.tmpl.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{{define "page"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{block "Title" .Title}}{{.}}{{end}} - Shelves</title>
|
||||
<link rel="stylesheet" href="/static/css/mu.css" />
|
||||
<script module src="/static/js/vendor/htmx-2.0.3.js"></script>
|
||||
{{block "Head" .Head}}{{.}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<nav aria-label="Main navigation">
|
||||
<ul>
|
||||
<li>
|
||||
{{if eq .SessionInfo.SessionId ""}}
|
||||
<a href="/login">Login</a>
|
||||
{{else}}
|
||||
<button hx-delete="/login">Log out</button>
|
||||
{{end}}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{{block "BodyBefore" .BodyBefore}}{{.}}{{end}}
|
||||
<main>
|
||||
{{block "Body" .Body}}{{.}}{{end}}
|
||||
</main>
|
||||
{{block "BodyAfter" .BodyAfter}}{{.}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
22
internal/templates/templates.go
Normal file
22
internal/templates/templates.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"git.soup.land/soup/shelves"
|
||||
"git.soup.land/soup/shelves/internal/errorsx"
|
||||
"html/template"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var files = shelves.Templates
|
||||
|
||||
func MustParseEmbed(path string) *template.Template {
|
||||
return template.Must(template.ParseFS(files, path))
|
||||
}
|
||||
|
||||
func HTML(t *template.Template, name string, data any) template.HTML {
|
||||
out := strings.Builder{}
|
||||
err := t.ExecuteTemplate(&out, name, data)
|
||||
out.WriteString(errorsx.String(err))
|
||||
|
||||
return template.HTML(out.String())
|
||||
}
|
||||
1
internal/templates/views/home.go
Normal file
1
internal/templates/views/home.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package views
|
||||
11
internal/templates/views/home.tmpl.html
Normal file
11
internal/templates/views/home.tmpl.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{{define "body"}}
|
||||
<section>
|
||||
<h1>Featured Shelves</h1>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Featured Items</h1>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Recent Activity</h1>
|
||||
</section>
|
||||
{{end}}
|
||||
6
internal/templates/views/login.tmpl.html
Normal file
6
internal/templates/views/login.tmpl.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{{template "page.tmpl.html" .}}
|
||||
|
||||
{{define "Title"}}Login{{end}}
|
||||
|
||||
{{define "Body"}}
|
||||
{{end}}
|
||||
15
internal/urls/urls.go
Normal file
15
internal/urls/urls.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package urls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func Login(redirectTo string) string {
|
||||
out := "/login"
|
||||
if redirectTo != "" {
|
||||
out = fmt.Sprintf("%s?redirectTo=%s", out, url.QueryEscape(redirectTo))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
Loading…
Reference in a new issue