summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.vscode/launch.json15
-rw-r--r--auth.go129
-rw-r--r--csrf.go27
-rw-r--r--csrf/csrf.go36
-rw-r--r--form/decode.go (renamed from form.go)24
-rw-r--r--form/errors.go42
-rw-r--r--form/field.templ42
-rw-r--r--form/field_templ.go239
-rw-r--r--form/form.go21
-rw-r--r--form/reflect.go139
-rw-r--r--go.mod25
-rw-r--r--go.sum46
-rw-r--r--main.go66
-rw-r--r--model/categories.sql.go48
-rw-r--r--model/models.go9
-rw-r--r--model/rexps.sql.go51
-rw-r--r--model/sexps.sql.go27
-rw-r--r--model/users.sql.go39
-rw-r--r--pages/base.templ101
-rw-r--r--pages/base_templ.go351
-rw-r--r--pages/chpw.go84
-rw-r--r--pages/chpw.templ25
-rw-r--r--pages/chpw_templ.go89
-rw-r--r--pages/login.go140
-rw-r--r--pages/login.templ18
-rw-r--r--pages/login_templ.go83
-rw-r--r--pages/logout.go15
-rw-r--r--pages/page.go86
-rw-r--r--pages/pages.go21
-rw-r--r--pages/pages.templ46
-rw-r--r--pages/pages_templ.go269
-rw-r--r--session.go78
-rw-r--r--session/session.go87
-rw-r--r--sql/categories.sql5
-rw-r--r--sql/ddl/pgsql.sql74
-rw-r--r--sql/rexps.sql4
-rw-r--r--sql/sexps.sql2
-rw-r--r--sql/users.sql12
-rw-r--r--static/custom.css8
-rw-r--r--templ/base.tpl19
-rw-r--r--templ/form.tpl22
-rw-r--r--templ/index.tpl3
-rw-r--r--templ/login.tpl10
-rw-r--r--templ/template.go75
44 files changed, 2237 insertions, 515 deletions
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..0f8103e
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,15 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Launch Package",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "program": "${workspaceFolder}"
+ }
+ ]
+} \ No newline at end of file
diff --git a/auth.go b/auth.go
deleted file mode 100644
index c9797f7..0000000
--- a/auth.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package main
-
-import (
- "context"
- "database/sql"
- "errors"
- "log"
- "net/http"
- "net/url"
-
- "golang.org/x/crypto/bcrypt"
-)
-
-const (
- userContextKey = "_user"
- sessionDuration = 86400 * 7 // 7 days
- loginQueryMarker = "next"
-)
-
-func RequireAuth(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- s := session(r)
-
- if !s.s.IsNew && s.Authenticated {
- u, err := Q.GetUserById(r.Context(), s.UserID)
- if err == nil {
- // authenticated --> done
- ctx := context.WithValue(r.Context(), userContextKey, u.ID)
- next.ServeHTTP(w, r.WithContext(ctx))
- return
- }
-
- s.Invalidate()
- s.Save(w, r)
- }
-
- // redirect to login with next-param
- v := url.Values{}
- v.Set(loginQueryMarker, r.URL.Path)
- redirPath := "/login?" + v.Encode()
- http.Redirect(w, r, redirPath, http.StatusFound)
- })
-}
-
-func checkLogin(ctx context.Context, user User) (bool, int32) {
- dbUser, err := Q.GetUserByName(ctx, user.Name)
- if err == nil {
- hash := []byte(dbUser.Pwd)
- pwd := []byte(user.Password)
-
- if bcrypt.CompareHashAndPassword(hash, pwd) != nil {
- return false, 0
- }
- } else if errors.Is(err, sql.ErrNoRows) {
- return false, 0
- } else {
- log.Panicf("Could not load user '%s': %v", user.Name, err)
- }
-
- return true, dbUser.ID
-}
-
-func handleLogin(w http.ResponseWriter, r *http.Request) {
- u := User{}
- parseForm(r, &u)
-
- ok, userId := checkLogin(r.Context(), u)
-
- if !ok {
- u.Errors = []error{fieldError{"Password", "Invalid"}}
- showLoginPage(w, u)
- return
- }
-
- s := session(r)
- if u.RememberMe {
- s.MaxAge(sessionDuration) // 1 week
- } else {
- s.MaxAge(0)
- }
-
- s.UserID = userId
- s.Authenticated = true
- s.Save(w, r)
-
- // redirect
- next := r.URL.Query().Get(loginQueryMarker)
- if next == "" {
- next = "/"
- }
- http.Redirect(w, r, next, http.StatusFound)
-}
-
-func handleLogout() http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- s := session(r)
- s.Invalidate()
- s.Save(w, r)
-
- http.Redirect(w, r, "/", http.StatusFound)
- }
-}
-
-type User struct {
- Name string `form:"options=required"`
- Password string `form:"type=password;options=required"`
- RememberMe bool `form:"type=checkbox;value=y;options=checked"`
- Errors []error `form:"-"`
- Csrf
-}
-
-func showLoginPage(w http.ResponseWriter, u User) {
- showTemplate(w, "login", u)
-}
-
-func loginPage() http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if session(r).Authenticated {
- http.Redirect(w, r, "/", http.StatusFound)
- }
- showLoginPage(w, User{
- Csrf: CsrfField(r),
- })
- }
-}
-
-func userId(r *http.Request) int32 {
- return r.Context().Value(userContextKey).(int32)
-}
diff --git a/csrf.go b/csrf.go
deleted file mode 100644
index 962a2a0..0000000
--- a/csrf.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package main
-
-import (
- "html/template"
- "net/http"
-
- "github.com/gorilla/csrf"
- "github.com/gorilla/securecookie"
-)
-
-func csrfHandler(next http.Handler) http.Handler {
- return csrf.Protect(
- securecookie.GenerateRandomKey(32),
- csrf.SameSite(csrf.SameSiteStrictMode),
- csrf.FieldName("csrf.csrffield"), // should match the structure in `Csrf`
- )(next)
-}
-
-// Csrf handles the CSRF data for a form.
-// Include it verbatim and then use `{{.CsrfField}}` in templates.
-type Csrf struct {
- CsrfField template.HTML `form:"-"`
-}
-
-func CsrfField(r *http.Request) Csrf {
- return Csrf{CsrfField: csrf.TemplateField(r)}
-}
diff --git a/csrf/csrf.go b/csrf/csrf.go
new file mode 100644
index 0000000..fd73c0d
--- /dev/null
+++ b/csrf/csrf.go
@@ -0,0 +1,36 @@
+package csrf
+
+import (
+ "html/template"
+ "net/http"
+
+ "github.com/a-h/templ"
+ "github.com/gorilla/csrf"
+ "github.com/gorilla/securecookie"
+)
+
+func Handler() func(http.Handler) http.Handler {
+ return csrf.Protect(
+ securecookie.GenerateRandomKey(32),
+ csrf.SameSite(csrf.SameSiteStrictMode),
+ csrf.FieldName("csrffield.field"), // should match the structure in `Csrf`
+ )
+}
+
+// CsrfField handles the CSRF data for a form.
+// Include it verbatim and then use `{{.CsrfField}}` in templates.
+type CsrfField struct {
+ field template.HTML `form:"-" schema:"-"`
+}
+
+func (c *CsrfField) SetCsrfField(r *http.Request) {
+ c.field = csrf.TemplateField(r)
+}
+
+func (c *CsrfField) Csrf() templ.Component {
+ return templ.Raw(c.field)
+}
+
+type Enabled interface {
+ SetCsrfField(r *http.Request)
+}
diff --git a/form.go b/form/decode.go
index c5dffa0..7ebdfc2 100644
--- a/form.go
+++ b/form/decode.go
@@ -1,7 +1,7 @@
-package main
+package form
import (
- "fmt"
+ "gosten/csrf"
"log"
"net/http"
@@ -12,26 +12,18 @@ var schemaDecoder *schema.Decoder
func init() {
schemaDecoder = schema.NewDecoder()
+ schemaDecoder.IgnoreUnknownKeys(true)
}
-type fieldError struct {
- Field string
- Issue string
-}
-
-func (fe fieldError) Error() string {
- return fmt.Sprintf("%s: %v", fe.Field, fe.Issue)
-}
-
-func (fe fieldError) FieldError() (field, err string) {
- return fe.Field, fe.Issue
-}
-
-func parseForm[T any](r *http.Request, data *T) {
+func Parse[T any](r *http.Request, data *T) {
if err := r.ParseForm(); err != nil {
log.Panic("Parsing form: ", err)
}
if err := schemaDecoder.Decode(data, r.PostForm); err != nil {
log.Panic("Decoding form: ", err)
}
+
+ if withCsrf, ok := any(data).(csrf.Enabled); ok {
+ withCsrf.SetCsrfField(r)
+ }
}
diff --git a/form/errors.go b/form/errors.go
new file mode 100644
index 0000000..ab9abd4
--- /dev/null
+++ b/form/errors.go
@@ -0,0 +1,42 @@
+package form
+
+import (
+ "errors"
+ "fmt"
+)
+
+type FormErrors struct {
+ Errors []error `form:"-"`
+}
+
+func (f *FormErrors) AddError(field, issue string) {
+ f.Errors = append(f.Errors, FieldError{field, issue})
+}
+
+func (f *FormErrors) HasError() bool {
+ return len(f.Errors) > 0
+}
+
+type FieldError struct {
+ Field string
+ Issue string
+}
+
+func (fe FieldError) Error() string {
+ return fmt.Sprintf("%s: %v", fe.Field, fe.Issue)
+}
+
+// errors will build a map where each key is the field name, and each
+// value is a slice of strings representing errors with that field.
+func fieldErrors(errs []error) map[string][]string {
+ ret := make(map[string][]string)
+ for _, err := range errs {
+ var fe FieldError
+ if !errors.As(err, &fe) {
+ fmt.Println(err, "isnt field error")
+ continue
+ }
+ ret[fe.Field] = append(ret[fe.Field], fe.Issue)
+ }
+ return ret
+}
diff --git a/form/field.templ b/form/field.templ
new file mode 100644
index 0000000..e9ce33e
--- /dev/null
+++ b/form/field.templ
@@ -0,0 +1,42 @@
+package form
+
+import "fmt"
+
+func (f *field) isCheckbox() bool {
+ return f.Type == "checkbox"
+}
+
+func (f *field) optionAttributes() templ.Attributes {
+ attrs := make(map[string]any)
+ for _, o := range f.Options {
+ attrs[o] = true
+ }
+ return templ.Attributes(attrs)
+}
+
+templ (f *field) item(errors []string) {
+ <div class={"mb-3",
+ templ.KV("form-floating", !f.isCheckbox()),
+ templ.KV("form-check form-switch", f.isCheckbox())}>
+ <input
+ if f.ID != "" {id={f.ID}}
+ type={f.Type}
+ name={f.Name}
+ placeholder={f.Placeholder}
+ if f.Value != nil {value={fmt.Sprint(f.Value)}}
+ if f.isCheckbox() {
+ class="form-check-input"
+ } else {
+ class="form-control"
+ }
+ {f.optionAttributes()...}
+ >
+ <label if f.ID != "" {for={f.ID}}
+ if f.isCheckbox() {class="form-check-label"}>
+ {f.Label}
+ </label>
+ for _, e := range errors {
+ <p style="color:red">{e}</p>
+ }
+ </div>
+} \ No newline at end of file
diff --git a/form/field_templ.go b/form/field_templ.go
new file mode 100644
index 0000000..34266e8
--- /dev/null
+++ b/form/field_templ.go
@@ -0,0 +1,239 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package form
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "fmt"
+
+func (f *field) isCheckbox() bool {
+ return f.Type == "checkbox"
+}
+
+func (f *field) optionAttributes() templ.Attributes {
+ attrs := make(map[string]any)
+ for _, o := range f.Options {
+ attrs[o] = true
+ }
+ return templ.Attributes(attrs)
+}
+
+func (f *field) item(errors []string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var2 = []any{"mb-3",
+ templ.KV("form-floating", !f.isCheckbox()),
+ templ.KV("form-check form-switch", f.isCheckbox())}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `form/field.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><input")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if f.ID != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(f.ID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `form/field.templ`, Line: 22, Col: 35}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" type=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(f.Type)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `form/field.templ`, Line: 23, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(f.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `form/field.templ`, Line: 24, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" placeholder=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(f.Placeholder)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `form/field.templ`, Line: 25, Col: 38}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if f.Value != nil {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(f.Value))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `form/field.templ`, Line: 26, Col: 57}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if f.isCheckbox() {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" class=\"form-check-input\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" class=\"form-control\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, f.optionAttributes())
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("> <label")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if f.ID != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" for=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(f.ID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `form/field.templ`, Line: 34, Col: 39}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if f.isCheckbox() {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" class=\"form-check-label\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(f.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `form/field.templ`, Line: 36, Col: 23}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</label> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, e := range errors {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p style=\"color:red\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(e)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `form/field.templ`, Line: 39, Col: 35}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/form/form.go b/form/form.go
new file mode 100644
index 0000000..84758f5
--- /dev/null
+++ b/form/form.go
@@ -0,0 +1,21 @@
+package form
+
+import (
+ "context"
+ "io"
+
+ "github.com/a-h/templ"
+)
+
+func Form(v any, errs []error) templ.Component {
+ fields := fields(v)
+ errors := fieldErrors(errs)
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
+ for _, field := range fields {
+ if err := field.item(errors[field.Name]).Render(ctx, w); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
diff --git a/form/reflect.go b/form/reflect.go
new file mode 100644
index 0000000..604c9e1
--- /dev/null
+++ b/form/reflect.go
@@ -0,0 +1,139 @@
+package form
+
+import (
+ "reflect"
+ "strings"
+)
+
+// valueOf is basically just reflect.ValueOf, but if the Kind() of the
+// value is a pointer or interface it will try to get the reflect.Value
+// of the underlying element, and if the pointer is nil it will
+// create a new instance of the type and return the reflect.Value of it.
+//
+// This is used to make the rest of the fields function simpler.
+func valueOf(v interface{}) reflect.Value {
+ rv := reflect.ValueOf(v)
+ // If a nil pointer is passed in but has a type we can recover, but I
+ // really should just panic and tell people to fix their shitty code.
+ if rv.Type().Kind() == reflect.Pointer && rv.IsNil() {
+ rv = reflect.Zero(rv.Type().Elem())
+ }
+ // If we have a pointer or interface let's try to get the underlying
+ // element
+ for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface {
+ rv = rv.Elem()
+ }
+ return rv
+}
+
+func fields(v interface{}, names ...string) []field {
+ rv := valueOf(v)
+ if rv.Kind() != reflect.Struct {
+ // We can't really do much with a non-struct type. I suppose this
+ // could eventually support maps as well, but for now it does not.
+ panic("invalid value; only structs are supported")
+ }
+
+ t := rv.Type()
+ vFields := reflect.VisibleFields(t)
+ ret := make([]field, 0, len(vFields))
+ for _, tf := range vFields {
+ if !tf.IsExported() {
+ continue
+ }
+
+ rf := rv.FieldByIndex(tf.Index)
+ // If this is a nil pointer, create a new instance of the element.
+ if tf.Type.Kind() == reflect.Pointer && rf.IsNil() {
+ rf = reflect.Zero(tf.Type.Elem())
+ }
+
+ // If this is a struct it has nested fields we need to add. The
+ // simplest way to do this is to recursively call `fields` but
+ // to provide the name of this struct field to be added as a prefix
+ // to the fields.
+ // This does not apply to anonymous structs, because their fields are
+ // seen as "inlined".
+ if reflect.Indirect(rf).Kind() == reflect.Struct {
+ if !tf.Anonymous {
+ ret = append(ret, fields(rf.Interface(), append(names, tf.Name)...)...)
+ }
+ continue
+ }
+
+ // If we are still in this loop then we aren't dealing with a nested
+ // struct and need to add the field. First we check to see if the
+ // ignore tag is present, then we set default values, then finally
+ // we overwrite defaults with any provided tags.
+ tags, ignored := parseTags(tf.Tag.Get("form"))
+ if ignored {
+ continue
+ }
+ name := append(names, tf.Name)
+ f := field{
+ Name: strings.Join(name, "."),
+ Label: tf.Name,
+ Placeholder: tf.Name,
+ Type: "text",
+ Value: rf.Interface(),
+ }
+ f.applyTags(tags)
+ ret = append(ret, f)
+ }
+ return ret
+}
+
+func (f *field) applyTags(tags map[string]string) {
+ if v, ok := tags["name"]; ok {
+ f.Name = v
+ }
+ if v, ok := tags["label"]; ok {
+ f.Label = v
+ // DO NOT move this label check after the placeholder check or
+ // this will cause issues.
+ f.Placeholder = v
+ }
+ if v, ok := tags["placeholder"]; ok {
+ f.Placeholder = v
+ }
+ if v, ok := tags["type"]; ok {
+ f.Type = v
+ }
+ if v, ok := tags["id"]; ok {
+ f.ID = v
+ }
+ if v, ok := tags["options"]; ok {
+ f.Options = strings.Split(v, ",")
+ }
+}
+
+func parseTags(tags string) (map[string]string, bool) {
+ tags = strings.TrimSpace(tags)
+ if len(tags) == 0 {
+ return map[string]string{}, false
+ }
+ split := strings.Split(tags, ";")
+ ret := make(map[string]string, len(split))
+ for _, tag := range split {
+ kv := strings.Split(tag, "=")
+ if len(kv) < 2 {
+ if kv[0] == "-" {
+ return nil, true
+ }
+ continue
+ }
+ k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
+ ret[k] = v
+ }
+ return ret, false
+}
+
+type field struct {
+ Name string
+ Label string
+ Placeholder string
+ Type string
+ ID string
+ Options []string
+ Value any
+}
diff --git a/go.mod b/go.mod
index 9115043..f9a9cdb 100644
--- a/go.mod
+++ b/go.mod
@@ -1,24 +1,25 @@
module gosten
-go 1.22
+go 1.23
+
+toolchain go1.23.1
require (
- github.com/Necoro/form v0.0.0-20240211223301-6fa9f8196e1e
+ github.com/a-h/templ v0.2.778
+ github.com/go-chi/chi/v5 v5.1.0
github.com/gorilla/csrf v1.7.2
- github.com/gorilla/handlers v1.5.2
- github.com/gorilla/schema v1.2.1
+ github.com/gorilla/schema v1.4.1
github.com/gorilla/securecookie v1.1.2
- github.com/gorilla/sessions v1.2.2
- github.com/jackc/pgx/v5 v5.5.3
+ github.com/gorilla/sessions v1.4.0
+ github.com/jackc/pgx/v5 v5.7.1
github.com/joho/godotenv v1.5.1
- golang.org/x/crypto v0.19.0
+ golang.org/x/crypto v0.28.0
)
require (
- github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
- github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
- github.com/jackc/puddle/v2 v2.2.1 // indirect
- golang.org/x/sync v0.1.0 // indirect
- golang.org/x/text v0.14.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/sync v0.8.0 // indirect
+ golang.org/x/text v0.19.0 // indirect
)
diff --git a/go.sum b/go.sum
index 0b455dd..07d430c 100644
--- a/go.sum
+++ b/go.sum
@@ -1,32 +1,30 @@
-github.com/Necoro/form v0.0.0-20240211223301-6fa9f8196e1e h1:v3DDTGBMt9pclCdG7jRyNAABmtJw3uky/Xoi/DfbWNs=
-github.com/Necoro/form v0.0.0-20240211223301-6fa9f8196e1e/go.mod h1:JxpmgZ5hjL6fyhBoZ4HAUadkp7DNqWlHbFL7l8oic4Y=
+github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM=
+github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
-github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
-github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
-github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
-github.com/gorilla/schema v1.2.1 h1:tjDxcmdb+siIqkTNoV+qRH2mjYdr2hHe5MKXbp61ziM=
-github.com/gorilla/schema v1.2.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
+github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
+github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
-github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
-github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
+github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
-github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
-github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s=
-github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
-github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
-github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
+github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -34,14 +32,14 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
+golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
+golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
+golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/main.go b/main.go
index 8ed57bf..352ed56 100644
--- a/main.go
+++ b/main.go
@@ -6,22 +6,16 @@ import (
"net/http"
"os"
- "github.com/gorilla/handlers"
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
- "gosten/model"
- "gosten/templ"
+ "gosten/csrf"
+ "gosten/pages"
+ "gosten/session"
)
-// flags
-var (
- port uint64
- host string
-)
-
-var Q *model.Queries
-
func checkEnvEntry(e string) {
if os.Getenv(e) == "" {
log.Fatalf("Variable '%s' not set", e)
@@ -49,39 +43,31 @@ func main() {
}
defer db.Close()
- Q = model.New(db)
+ pages.Connect(db)
- mux := http.NewServeMux()
+ router := chi.NewRouter()
- mux.Handle("GET /login", loginPage())
- mux.HandleFunc("POST /login", handleLogin)
- mux.Handle("GET /logout", handleLogout())
- mux.Handle("/static/", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
- mux.Handle("/favicon.ico", http.NotFoundHandler())
+ // A good base middleware stack
+ router.Use(middleware.RequestID)
+ router.Use(middleware.RealIP)
+ router.Use(middleware.CleanPath)
+ router.Use(middleware.Logger)
+ router.Use(middleware.Recoverer)
- handler := sessionHandler(csrfHandler(mux))
- handler = handlers.CombinedLoggingHandler(os.Stderr, handler)
- handler = handlers.ProxyHeaders(handler)
+ // handlers that DO NOT require authentification
+ router.Handle("/static/*", http.StripPrefix("/static", http.FileServer(http.Dir("static"))))
+ router.Get("/favicon.ico", http.NotFound)
- // the real content, needing authentification
- authMux := http.NewServeMux()
- mux.Handle("/", RequireAuth(authMux))
+ appRouter := router.With(csrf.Handler(), session.Handler())
+ appRouter.Mount("/login", pages.Login())
+ appRouter.Get("/logout", pages.Logout())
- authMux.Handle("GET /{$}", indexPage())
-
- log.Fatal(http.ListenAndServe(os.Getenv("GOSTEN_ADDRESS"), handler))
-}
+ authRouter := appRouter.With(pages.RequireAuth)
+ authRouter.Mount("/", pages.Init())
+ authRouter.Mount("/recur", pages.Recur())
+ authRouter.Mount("/categories", pages.Categories())
+ authRouter.Mount("/cpw", pages.ChangePassword())
+ authRouter.NotFound(pages.NotFound())
-func showTemplate(w http.ResponseWriter, tpl string, data any) {
- if err := templ.Lookup(tpl).Execute(w, data); err != nil {
- log.Panicf("Executing '%s' with %+v: %v", tpl, data, err)
- }
-}
-
-func indexPage() http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- uid := userId(r)
- u, _ := Q.GetUserById(r.Context(), uid)
- showTemplate(w, "index", u.Name)
- }
+ log.Fatal(http.ListenAndServe(os.Getenv("GOSTEN_ADDRESS"), router))
}
diff --git a/model/categories.sql.go b/model/categories.sql.go
new file mode 100644
index 0000000..c67e20d
--- /dev/null
+++ b/model/categories.sql.go
@@ -0,0 +1,48 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.25.0
+// source: categories.sql
+
+package model
+
+import (
+ "context"
+)
+
+const getCategoriesOrdered = `-- name: GetCategoriesOrdered :many
+SELECT id, name, parent_id, user_id
+ FROM categories
+ WHERE user_id = $1
+ ORDER BY name ASC
+`
+
+// GetCategoriesOrdered
+//
+// SELECT id, name, parent_id, user_id
+// FROM categories
+// WHERE user_id = $1
+// ORDER BY name ASC
+func (q *Queries) GetCategoriesOrdered(ctx context.Context, userID int32) ([]Category, error) {
+ rows, err := q.db.Query(ctx, getCategoriesOrdered, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Category
+ for rows.Next() {
+ var i Category
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.ParentID,
+ &i.UserID,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/model/models.go b/model/models.go
index eba1207..b2a47dc 100644
--- a/model/models.go
+++ b/model/models.go
@@ -15,11 +15,11 @@ type Category struct {
UserID int32
}
-type ConstExpense struct {
+type RecurExpense struct {
ID int32
Description pgtype.Text
Expense pgtype.Numeric
- Months int16
+ Duration pgtype.Interval
Start pgtype.Date
End pgtype.Date
PrevID pgtype.Int4
@@ -31,9 +31,8 @@ type SingleExpense struct {
ID int64
Description pgtype.Text
Expense pgtype.Numeric
- Year int16
- Month int16
- Day int16
+ Date pgtype.Date
+ CorrMonth pgtype.Date
CategoryID int32
UserID int32
}
diff --git a/model/rexps.sql.go b/model/rexps.sql.go
new file mode 100644
index 0000000..9c3f1c0
--- /dev/null
+++ b/model/rexps.sql.go
@@ -0,0 +1,51 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.25.0
+// source: rexps.sql
+
+package model
+
+import (
+ "context"
+)
+
+const getRecurExpenses = `-- name: GetRecurExpenses :many
+SELECT id, description, expense, duration, start, "end", prev_id, category_id, user_id
+ FROM recur_expenses
+ WHERE user_id = $1
+`
+
+// GetRecurExpenses
+//
+// SELECT id, description, expense, duration, start, "end", prev_id, category_id, user_id
+// FROM recur_expenses
+// WHERE user_id = $1
+func (q *Queries) GetRecurExpenses(ctx context.Context, userID int32) ([]RecurExpense, error) {
+ rows, err := q.db.Query(ctx, getRecurExpenses, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []RecurExpense
+ for rows.Next() {
+ var i RecurExpense
+ if err := rows.Scan(
+ &i.ID,
+ &i.Description,
+ &i.Expense,
+ &i.Duration,
+ &i.Start,
+ &i.End,
+ &i.PrevID,
+ &i.CategoryID,
+ &i.UserID,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/model/sexps.sql.go b/model/sexps.sql.go
index 8a54443..33b9f57 100644
--- a/model/sexps.sql.go
+++ b/model/sexps.sql.go
@@ -7,36 +7,37 @@ package model
import (
"context"
-
- "github.com/jackc/pgx/v5/pgtype"
)
const getSingleExpenses = `-- name: GetSingleExpenses :many
-SELECT id, description
+SELECT id, description, expense, date, corr_month, category_id, user_id
FROM single_expenses
WHERE user_id = $1
`
-type GetSingleExpensesRow struct {
- ID int64
- Description pgtype.Text
-}
-
// GetSingleExpenses
//
-// SELECT id, description
+// SELECT id, description, expense, date, corr_month, category_id, user_id
// FROM single_expenses
// WHERE user_id = $1
-func (q *Queries) GetSingleExpenses(ctx context.Context, userID int32) ([]GetSingleExpensesRow, error) {
+func (q *Queries) GetSingleExpenses(ctx context.Context, userID int32) ([]SingleExpense, error) {
rows, err := q.db.Query(ctx, getSingleExpenses, userID)
if err != nil {
return nil, err
}
defer rows.Close()
- var items []GetSingleExpensesRow
+ var items []SingleExpense
for rows.Next() {
- var i GetSingleExpensesRow
- if err := rows.Scan(&i.ID, &i.Description); err != nil {
+ var i SingleExpense
+ if err := rows.Scan(
+ &i.ID,
+ &i.Description,
+ &i.Expense,
+ &i.Date,
+ &i.CorrMonth,
+ &i.CategoryID,
+ &i.UserID,
+ ); err != nil {
return nil, err
}
items = append(items, i)
diff --git a/model/users.sql.go b/model/users.sql.go
index e63dacc..bd881b7 100644
--- a/model/users.sql.go
+++ b/model/users.sql.go
@@ -9,6 +9,24 @@ import (
"context"
)
+const getPwdById = `-- name: GetPwdById :one
+ SELECT pwd
+ FROM users
+ WHERE id = $1
+`
+
+// GetPwdById
+//
+// SELECT pwd
+// FROM users
+// WHERE id = $1
+func (q *Queries) GetPwdById(ctx context.Context, id int32) (string, error) {
+ row := q.db.QueryRow(ctx, getPwdById, id)
+ var pwd string
+ err := row.Scan(&pwd)
+ return pwd, err
+}
+
const getUserById = `-- name: GetUserById :one
SELECT id, name, pwd, description
FROM users
@@ -88,3 +106,24 @@ func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
}
return items, nil
}
+
+const updatePwd = `-- name: UpdatePwd :exec
+ UPDATE users
+ SET pwd = $1
+ WHERE id = $2
+`
+
+type UpdatePwdParams struct {
+ Pwd string
+ ID int32
+}
+
+// UpdatePwd
+//
+// UPDATE users
+// SET pwd = $1
+// WHERE id = $2
+func (q *Queries) UpdatePwd(ctx context.Context, arg UpdatePwdParams) error {
+ _, err := q.db.Exec(ctx, updatePwd, arg.Pwd, arg.ID)
+ return err
+}
diff --git a/pages/base.templ b/pages/base.templ
new file mode 100644
index 0000000..47de938
--- /dev/null
+++ b/pages/base.templ
@@ -0,0 +1,101 @@
+package pages
+
+templ bootstrap() {
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
+}
+
+templ baseWithHeader(header templ.Component) {
+ <!DOCTYPE html>
+ <html lang="de">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" sizes="16x16 32x32 48x48">
+ <title>Kosten</title>
+ @bootstrap()
+ <link href="/static/custom.css" rel="stylesheet">
+ if header != nil {
+ @header
+ }
+ </head>
+ <body>
+ { children... }
+ </body>
+ </html>
+}
+
+func base() templ.Component {
+ return baseWithHeader(nil)
+}
+
+templ navlink(path, descr string) {
+ <li class="nav-item">
+ <a class={"nav-link", templ.KV("active", isCurrPath(ctx, path))}
+ href={templ.URL(path)}>{descr}</a>
+ </li>
+}
+
+templ navlinks() {
+ @navlink("/categories", "Kategorien")
+ @navlink("/recur", "RegelmĂ¤ĂŸig")
+}
+
+templ userlinks() {
+ <li><h4 class="dropdown-header">{getUser(ctx).Name}</h4></li>
+ <li><a class="dropdown-item" href="/cpw">Passwort Ă„ndern</a></li>
+ <li><a class="dropdown-item" href="/logout">Logout</a></li>
+}
+
+templ navbar() {
+ /* different inline svgs */
+ <svg xmlns="http://www.w3.org/2000/svg" class="d-none">
+ <symbol id="search" viewBox="0 0 16 16">
+ <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"></path>
+ </symbol>
+ <symbol id="person" viewBox="0 0 16 16">
+ <path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/>
+ </symbol>
+ <symbol id="check-circle-fill" viewBox="0 0 16 16">
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
+ </symbol>
+ </svg>
+
+ /* The Navbar */
+ <nav class="navbar sticky-top bg-dark-subtle navbar-expand-lg shadow mb-2">
+ <div class="container-fluid" >
+ <a class="navbar-brand" href="/">
+ <img src="/static/euro-money.svg" alt="Logo" width="32" height="32" class="d-inline-block"/> Kosten
+ </a>
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ <div class="collapse navbar-collapse" id="navbarNav">
+ <ul class="navbar-nav nav-underline me-auto">
+ @navlinks()
+ </ul>
+ <form class="d-flex mt-3 mt-lg-0" role="search">
+ <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
+ <button class="btn btn-outline-dark" type="submit"><svg class="svg"><use xlink:href="#search"></use></svg></button>
+ </form>
+ <div class="dropdown mt-3 ms-lg-3 mt-lg-0">
+ <button class="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ <svg class="svg"><use xlink:href="#person"></use></svg>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-lg-end">
+ @userlinks()
+ </ul>
+ </div>
+ </div>
+ </div>
+ </nav>
+}
+
+templ content() {
+ @base() {
+ @navbar()
+ <main class="container-lg">
+ {children...}
+ </main>
+ }
+} \ No newline at end of file
diff --git a/pages/base_templ.go b/pages/base_templ.go
new file mode 100644
index 0000000..f355b61
--- /dev/null
+++ b/pages/base_templ.go
@@ -0,0 +1,351 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package pages
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func bootstrap() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH\" crossorigin=\"anonymous\"><script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js\" integrity=\"sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz\" crossorigin=\"anonymous\"></script>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func baseWithHeader(header templ.Component) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var2 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var2 == nil {
+ templ_7745c5c3_Var2 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html lang=\"de\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\" sizes=\"16x16 32x32 48x48\"><title>Kosten</title>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = bootstrap().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<link href=\"/static/custom.css\" rel=\"stylesheet\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if header != nil {
+ templ_7745c5c3_Err = header.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</head><body>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ_7745c5c3_Var2.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body></html>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func base() templ.Component {
+ return baseWithHeader(nil)
+}
+
+func navlink(path, descr string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var3 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var3 == nil {
+ templ_7745c5c3_Var3 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"nav-item\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 = []any{"nav-link", templ.KV("active", isCurrPath(ctx, path))}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<a class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/base.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 templ.SafeURL = templ.URL(path)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var6)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(descr)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/base.templ`, Line: 35, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func navlinks() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var8 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var8 == nil {
+ templ_7745c5c3_Var8 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = navlink("/categories", "Kategorien").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = navlink("/recur", "RegelmĂ¤ĂŸig").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func userlinks() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var9 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var9 == nil {
+ templ_7745c5c3_Var9 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li><h4 class=\"dropdown-header\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(getUser(ctx).Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/base.templ`, Line: 45, Col: 54}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h4></li><li><a class=\"dropdown-item\" href=\"/cpw\">Passwort Ă„ndern</a></li><li><a class=\"dropdown-item\" href=\"/logout\">Logout</a></li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func navbar() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var11 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var11 == nil {
+ templ_7745c5c3_Var11 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"d-none\"><symbol id=\"search\" viewBox=\"0 0 16 16\"><path d=\"M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z\"></path></symbol> <symbol id=\"person\" viewBox=\"0 0 16 16\"><path d=\"M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z\"></path></symbol> <symbol id=\"check-circle-fill\" viewBox=\"0 0 16 16\"><path d=\"M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z\"></path></symbol></svg><nav class=\"navbar sticky-top bg-dark-subtle navbar-expand-lg shadow mb-2\"><div class=\"container-fluid\"><a class=\"navbar-brand\" href=\"/\"><img src=\"/static/euro-money.svg\" alt=\"Logo\" width=\"32\" height=\"32\" class=\"d-inline-block\"> Kosten</a> <button class=\"navbar-toggler\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#navbarNav\" aria-controls=\"navbarNav\" aria-expanded=\"false\" aria-label=\"Toggle navigation\"><span class=\"navbar-toggler-icon\"></span></button><div class=\"collapse navbar-collapse\" id=\"navbarNav\"><ul class=\"navbar-nav nav-underline me-auto\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = navlinks().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul><form class=\"d-flex mt-3 mt-lg-0\" role=\"search\"><input class=\"form-control me-2\" type=\"search\" placeholder=\"Search\" aria-label=\"Search\"> <button class=\"btn btn-outline-dark\" type=\"submit\"><svg class=\"svg\"><use xlink:href=\"#search\"></use></svg></button></form><div class=\"dropdown mt-3 ms-lg-3 mt-lg-0\"><button class=\"btn btn-outline-primary dropdown-toggle\" type=\"button\" data-bs-toggle=\"dropdown\" aria-expanded=\"false\"><svg class=\"svg\"><use xlink:href=\"#person\"></use></svg></button><ul class=\"dropdown-menu dropdown-menu-lg-end\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = userlinks().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul></div></div></div></nav>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func content() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var12 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var12 == nil {
+ templ_7745c5c3_Var12 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = navbar().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <main class=\"container-lg\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ_7745c5c3_Var12.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</main>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = base().Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/pages/chpw.go b/pages/chpw.go
new file mode 100644
index 0000000..671c180
--- /dev/null
+++ b/pages/chpw.go
@@ -0,0 +1,84 @@
+package pages
+
+import (
+ "context"
+ "fmt"
+ "gosten/csrf"
+ "gosten/form"
+ "gosten/model"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type chpw struct {
+ Password string `form:"type=password;options=required"`
+ NewPw1 string `form:"label=Neues Password;type=password;options=required"`
+ NewPw2 string `form:"label=Wiederholung;type=password;options=required"`
+ Success bool `form:"-"`
+ form.FormErrors
+ csrf.CsrfField
+}
+
+func ChangePassword() Page {
+ r := chi.NewRouter()
+
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+ c := chpw{}
+ c.SetCsrfField(r)
+ render(changePassword(c))(w, r)
+ })
+
+ r.Post("/", handleChPw)
+
+ return r
+}
+
+func handleChPw(w http.ResponseWriter, r *http.Request) {
+ c := chpw{}
+ form.Parse(r, &c)
+
+ ctx := r.Context()
+ userId := getUser(ctx).ID
+ dbPwd, err := Q.GetPwdById(ctx, userId)
+ if err != nil {
+ panic(fmt.Sprintf("Q.GetPwdById: %v", err))
+ }
+
+ if c.NewPw1 != c.NewPw2 {
+ c.AddError("NewPw2", "Neues Passwort stimmt nicht Ă¼berein!")
+ }
+
+ if !validatePwd(dbPwd, c.Password) {
+ c.AddError("Password", "Passwort falsch!")
+ }
+
+ if !c.HasError() {
+ updatePwd(ctx, userId, c.NewPw1)
+
+ // update context
+ ctx, _ = setUserInContext(ctx, userId)
+ r = r.WithContext(ctx)
+
+ // reset form
+ c = chpw{Success: true}
+ }
+
+ c.SetCsrfField(r)
+ render(changePassword(c))(w, r)
+}
+
+func updatePwd(ctx context.Context, userId int32, pwd string) {
+ hash, err := bcrypt.GenerateFromPassword([]byte(pwd), -1)
+ if err != nil {
+ panic(fmt.Sprintf("Generating password hash: %v", err))
+ }
+
+ err = Q.UpdatePwd(ctx, model.UpdatePwdParams{
+ Pwd: string(hash),
+ ID: userId})
+ if err != nil {
+ panic(fmt.Sprintf("Updating password: %v", err))
+ }
+}
diff --git a/pages/chpw.templ b/pages/chpw.templ
new file mode 100644
index 0000000..3cf5ded
--- /dev/null
+++ b/pages/chpw.templ
@@ -0,0 +1,25 @@
+package pages
+
+import (
+ "gosten/form"
+)
+
+templ changePassword(chpw chpw) {
+ @content(){
+ if chpw.Success {
+ <div class="alert alert-success d-flex align-items-center" role="alert">
+ <svg class="svg me-2" role="img" aria-label="Success:"><use xlink:href="#check-circle-fill"/></svg>
+ <div>
+ Passwort ändern erfolgreich!
+ </div>
+ </div>
+ }
+
+ <form action={templ.SafeURL(getCurrPath(ctx))} method="post" class="container mx-auto" style="max-width: 440px;">
+ <h3>Passwort Ă„ndern</h3>
+ @form.Form(chpw, chpw.Errors)
+ @chpw.Csrf()
+ <input class="btn btn-primary w-100" type="submit" />
+ </form>
+ }
+} \ No newline at end of file
diff --git a/pages/chpw_templ.go b/pages/chpw_templ.go
new file mode 100644
index 0000000..0f402c2
--- /dev/null
+++ b/pages/chpw_templ.go
@@ -0,0 +1,89 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package pages
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "gosten/form"
+)
+
+func changePassword(chpw chpw) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ if chpw.Success {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"alert alert-success d-flex align-items-center\" role=\"alert\"><svg class=\"svg me-2\" role=\"img\" aria-label=\"Success:\"><use xlink:href=\"#check-circle-fill\"></use></svg><div>Passwort ändern erfolgreich!</div></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <form action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 templ.SafeURL = templ.SafeURL(getCurrPath(ctx))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" method=\"post\" class=\"container mx-auto\" style=\"max-width: 440px;\"><h3>Passwort Ă„ndern</h3>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = form.Form(chpw, chpw.Errors).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = chpw.Csrf().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<input class=\"btn btn-primary w-100\" type=\"submit\"></form>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = content().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/pages/login.go b/pages/login.go
new file mode 100644
index 0000000..c781f3c
--- /dev/null
+++ b/pages/login.go
@@ -0,0 +1,140 @@
+package pages
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "gosten/csrf"
+ "gosten/form"
+ "gosten/model"
+ "gosten/session"
+ "net/http"
+ "net/url"
+
+ "github.com/go-chi/chi/v5"
+ "golang.org/x/crypto/bcrypt"
+)
+
+const (
+ sessionDuration = 86400 * 7 // 7 days
+ loginQueryMarker = "next"
+)
+
+func RequireAuth(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s := session.From(r)
+
+ if !s.IsNew() && s.Authenticated {
+ ctx, err := setUserInContext(r.Context(), s.UserID)
+ if err == nil {
+ // authenticated --> done
+ next.ServeHTTP(w, r.WithContext(ctx))
+ return
+ }
+
+ s.Invalidate()
+ s.Save(w, r)
+ }
+
+ // redirect to login with next-param
+ v := url.Values{}
+ v.Set(loginQueryMarker, r.URL.Path)
+ redirPath := "/login?" + v.Encode()
+ http.Redirect(w, r, redirPath, http.StatusFound)
+ })
+}
+
+type user struct {
+ Name string `form:"options=required,autofocus"`
+ Password string `form:"type=password;options=required"`
+ RememberMe bool `form:"type=checkbox;value=y;options=checked"`
+ form.FormErrors
+ csrf.CsrfField
+}
+
+func Login() Page {
+ r := chi.NewRouter()
+
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+ if session.From(r).Authenticated {
+ http.Redirect(w, r, "/", http.StatusFound)
+ }
+ u := user{}
+ u.SetCsrfField(r)
+ render(login(u))(w, r)
+ })
+
+ r.Post("/", handleLogin)
+
+ return r
+}
+
+func validatePwd(hash, pwd string) bool {
+ hashB := []byte(hash)
+ pwdB := []byte(pwd)
+
+ return bcrypt.CompareHashAndPassword(hashB, pwdB) == nil
+}
+
+func checkLogin(ctx context.Context, user user) (bool, int32) {
+ dbUser, err := Q.GetUserByName(ctx, user.Name)
+ if err == nil {
+ if !validatePwd(dbUser.Pwd, user.Password) {
+ return false, 0
+ }
+ } else if errors.Is(err, sql.ErrNoRows) {
+ return false, 0
+ } else {
+ panic(fmt.Sprintf("Could not load user '%s': %v", user.Name, err))
+ }
+
+ return true, dbUser.ID
+}
+
+func handleLogin(w http.ResponseWriter, r *http.Request) {
+ u := user{}
+ form.Parse(r, &u)
+
+ ok, userId := checkLogin(r.Context(), u)
+
+ if !ok {
+ u.AddError("Password", "Username oder Passwort falsch.")
+ render(login(u))(w, r)
+ return
+ }
+
+ s := session.From(r)
+ if u.RememberMe {
+ s.MaxAge(sessionDuration) // 1 week
+ } else {
+ s.MaxAge(0)
+ }
+
+ s.UserID = userId
+ s.Authenticated = true
+ s.Save(w, r)
+
+ // redirect
+ next := r.URL.Query().Get(loginQueryMarker)
+ if next == "" {
+ next = "/"
+ }
+ http.Redirect(w, r, next, http.StatusFound)
+}
+
+type userContextKey struct{}
+
+func getUser(ctx context.Context) model.User {
+ return ctx.Value(userContextKey{}).(model.User)
+}
+
+func setUserInContext(ctx context.Context, uid int32) (context.Context, error) {
+ u, err := Q.GetUserById(ctx, uid)
+ if err != nil {
+ return ctx, err
+ }
+
+ u.Pwd = "" // don't carry pwd around
+ return context.WithValue(ctx, userContextKey{}, u), nil
+}
diff --git a/pages/login.templ b/pages/login.templ
new file mode 100644
index 0000000..a97d979
--- /dev/null
+++ b/pages/login.templ
@@ -0,0 +1,18 @@
+package pages
+
+import (
+ "gosten/form"
+)
+
+templ login(u user) {
+ @base() {
+ <main class="d-flex align-items-center min-vh-100">
+ <form action={templ.SafeURL(getCurrPath(ctx))} method="post" class="container mx-auto" style="max-width: 440px;">
+ <img src="/static/euro-money.svg" width="96" height="96" class="mb-4"/>
+ @form.Form(u, u.Errors)
+ @u.Csrf()
+ <button class="btn btn-primary w-100" type="submit">Log In!</button>
+ </form>
+ </main>
+ }
+} \ No newline at end of file
diff --git a/pages/login_templ.go b/pages/login_templ.go
new file mode 100644
index 0000000..f04b654
--- /dev/null
+++ b/pages/login_templ.go
@@ -0,0 +1,83 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package pages
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "gosten/form"
+)
+
+func login(u user) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<main class=\"d-flex align-items-center min-vh-100\"><form action=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 templ.SafeURL = templ.SafeURL(getCurrPath(ctx))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" method=\"post\" class=\"container mx-auto\" style=\"max-width: 440px;\"><img src=\"/static/euro-money.svg\" width=\"96\" height=\"96\" class=\"mb-4\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = form.Form(u, u.Errors).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = u.Csrf().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<button class=\"btn btn-primary w-100\" type=\"submit\">Log In!</button></form></main>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = base().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/pages/logout.go b/pages/logout.go
new file mode 100644
index 0000000..dad0e1a
--- /dev/null
+++ b/pages/logout.go
@@ -0,0 +1,15 @@
+package pages
+
+import (
+ "gosten/session"
+ "net/http"
+)
+
+func Logout() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ s := session.From(r)
+ s.Invalidate()
+ s.Save(w, r)
+ http.Redirect(w, r, "/", http.StatusFound)
+ }
+}
diff --git a/pages/page.go b/pages/page.go
new file mode 100644
index 0000000..f606205
--- /dev/null
+++ b/pages/page.go
@@ -0,0 +1,86 @@
+package pages
+
+import (
+ "context"
+ "gosten/model"
+ "net/http"
+
+ "github.com/a-h/templ"
+ "github.com/go-chi/chi/v5"
+)
+
+var Q *model.Queries
+
+func Connect(tx model.DBTX) {
+ Q = model.New(tx)
+}
+
+type Page interface {
+ http.Handler
+}
+
+type dataFunc[T any] func(ctx context.Context) T
+type tplFunc[T any] func(T) templ.Component
+
+func simple(c templ.Component) Page {
+ r := chi.NewRouter()
+ r.Get("/", render(c))
+ return r
+}
+
+func simpleWithData[T any](tpl tplFunc[T], dataFn dataFunc[T]) Page {
+ r := chi.NewRouter()
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+ input := dataFn(r.Context())
+ c := tpl(input)
+ render(c)(w, r)
+ })
+ return r
+}
+
+func simpleByQuery[T any](tpl tplFunc[T], query func(context.Context, int32) (T, error)) Page {
+ dataFn := func(ctx context.Context) T {
+ d, err := query(ctx, getUser(ctx).ID)
+ if err != nil {
+ panic(err.Error())
+ }
+ return d
+ }
+ return simpleWithData(tpl, dataFn)
+}
+
+type ctxPath struct{}
+
+func render(c templ.Component) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := context.WithValue(r.Context(), ctxPath{}, r.URL.Path)
+ if err := c.Render(ctx, w); err != nil {
+ panic(err.Error())
+ }
+ }
+}
+
+func getCurrPath(ctx context.Context) string {
+ return ctx.Value(ctxPath{}).(string)
+}
+
+func isCurrPath(ctx context.Context, path string) bool {
+ currPath := getCurrPath(ctx)
+ if path[0] != '/' {
+ path = "/" + path
+ }
+
+ if currPath == "/" {
+ return path == "/"
+ }
+
+ if currPath[len(currPath)-1] == '/' {
+ currPath = currPath[:len(currPath)-1]
+ }
+
+ if path[len(path)-1] == '/' {
+ path = path[:len(path)-1]
+ }
+
+ return currPath == path
+}
diff --git a/pages/pages.go b/pages/pages.go
new file mode 100644
index 0000000..0f03cdd
--- /dev/null
+++ b/pages/pages.go
@@ -0,0 +1,21 @@
+package pages
+
+import (
+ "net/http"
+)
+
+func Init() Page {
+ return simple(index())
+}
+
+func Recur() Page {
+ return simpleByQuery(recur, Q.GetRecurExpenses)
+}
+
+func Categories() Page {
+ return simpleByQuery(categories, Q.GetCategoriesOrdered)
+}
+
+func NotFound() http.HandlerFunc {
+ return render(notfound())
+}
diff --git a/pages/pages.templ b/pages/pages.templ
new file mode 100644
index 0000000..67e2792
--- /dev/null
+++ b/pages/pages.templ
@@ -0,0 +1,46 @@
+package pages
+
+import "gosten/model"
+
+templ notfound() {
+ @content() {
+ <div class="alert alert-danger d-flex align-items-center" role="alert">
+ <svg class="me-2" role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
+ <path d="M9.05.435c-.58-.58-1.52-.58-2.1 0L.436 6.95c-.58.58-.58 1.519 0 2.098l6.516 6.516c.58.58 1.519.58 2.098 0l6.516-6.516c.58-.58.58-1.519 0-2.098zM8 4c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995A.905.905 0 0 1 8 4m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
+ </svg>
+ <div>
+ Seite <span class="fst-italic">{getCurrPath(ctx)}</span> nicht gefunden!
+ </div>
+ </div>
+ }
+}
+
+templ index() {
+ @content() {
+ Logged in with user: {getUser(ctx).Name}
+ }
+}
+
+templ recur(rows []model.RecurExpense) {
+ @content() {
+ <ul class="list-group">
+ for _, r := range rows {
+ <li class="list-group-item">
+ {r.Description.String}
+ </li>
+ }
+ </ul>
+ }
+}
+
+templ categories(rows []model.Category) {
+ @content() {
+ <ul class="list-group">
+ for _, r := range rows {
+ <li class="list-group-item">
+ {r.Name}
+ </li>
+ }
+ </ul>
+ }
+} \ No newline at end of file
diff --git a/pages/pages_templ.go b/pages/pages_templ.go
new file mode 100644
index 0000000..1f526ca
--- /dev/null
+++ b/pages/pages_templ.go
@@ -0,0 +1,269 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package pages
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "gosten/model"
+
+func notfound() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"alert alert-danger d-flex align-items-center\" role=\"alert\"><svg class=\"me-2\" role=\"img\" xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M9.05.435c-.58-.58-1.52-.58-2.1 0L.436 6.95c-.58.58-.58 1.519 0 2.098l6.516 6.516c.58.58 1.519.58 2.098 0l6.516-6.516c.58-.58.58-1.519 0-2.098zM8 4c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995A.905.905 0 0 1 8 4m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2\"></path></svg><div>Seite <span class=\"fst-italic\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(getCurrPath(ctx))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/pages.templ`, Line: 12, Col: 60}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span> nicht gefunden!</div></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = content().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func index() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var4 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var4 == nil {
+ templ_7745c5c3_Var4 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Logged in with user: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(getUser(ctx).Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/pages.templ`, Line: 20, Col: 43}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = content().Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func recur(rows []model.RecurExpense) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var7 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var7 == nil {
+ templ_7745c5c3_Var7 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<ul class=\"list-group\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, r := range rows {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"list-group-item\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Description.String)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/pages.templ`, Line: 29, Col: 29}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = content().Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func categories(rows []model.Category) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var10 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var10 == nil {
+ templ_7745c5c3_Var10 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var11 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<ul class=\"list-group\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, r := range rows {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"list-group-item\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/pages.templ`, Line: 41, Col: 15}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = content().Render(templ.WithChildren(ctx, templ_7745c5c3_Var11), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/session.go b/session.go
deleted file mode 100644
index 3718623..0000000
--- a/session.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package main
-
-import (
- "context"
- "encoding/gob"
- "log"
- "net/http"
- "os"
-
- "github.com/gorilla/securecookie"
- "github.com/gorilla/sessions"
-)
-
-const (
- sessionCookie = "sessionKeks"
- sessionContextKey = "_session"
- dataKey = "data"
-)
-
-func init() {
- gob.Register(SessionData{})
-}
-
-type Session struct {
- *SessionData
- s *sessions.Session
-}
-
-type SessionData struct {
- UserID int32
- Authenticated bool
-}
-
-func (s *Session) Save(w http.ResponseWriter, r *http.Request) {
- s.s.Values[dataKey] = *s.SessionData
- if err := s.s.Save(r, w); err != nil {
- log.Panic("Storing session: ", err)
- }
-}
-
-func (s *Session) MaxAge(maxAge int) {
- s.s.Options.MaxAge = maxAge
-}
-
-func (s *Session) Invalidate() {
- s.MaxAge(-1)
- s.Authenticated = false
-}
-
-func session(r *http.Request) Session {
- s := r.Context().Value(sessionContextKey).(*sessions.Session)
- s.Options.HttpOnly = true
-
- sd, ok := s.Values[dataKey].(SessionData)
- if !ok {
- sd = SessionData{}
- }
- return Session{&sd, s}
-}
-
-func sessionHandler(next http.Handler) http.Handler {
- var key []byte
-
- if envKey := os.Getenv("GOSTEN_SECRET"); len(envKey) >= 32 {
- key = []byte(envKey)
- } else {
- key = securecookie.GenerateRandomKey(32)
- }
-
- sessionStore := sessions.NewCookieStore(key)
-
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session, _ := sessionStore.Get(r, sessionCookie)
-
- ctx := context.WithValue(r.Context(), sessionContextKey, session)
- next.ServeHTTP(w, r.WithContext(ctx))
- })
-}
diff --git a/session/session.go b/session/session.go
new file mode 100644
index 0000000..5ffd5cd
--- /dev/null
+++ b/session/session.go
@@ -0,0 +1,87 @@
+package session
+
+import (
+ "context"
+ "encoding/gob"
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/gorilla/securecookie"
+ "github.com/gorilla/sessions"
+)
+
+type sessionContextKey struct{}
+
+const (
+ sessionCookie = "sessionKeks"
+ dataKey = "data"
+)
+
+func init() {
+ gob.Register(sessionData{})
+}
+
+type Session struct {
+ *sessionData
+ s *sessions.Session
+}
+
+type sessionData struct {
+ UserID int32
+ Authenticated bool
+}
+
+func (s *Session) Save(w http.ResponseWriter, r *http.Request) {
+ s.s.Values[dataKey] = *s.sessionData
+ if err := s.s.Save(r, w); err != nil {
+ log.Panic("Storing session: ", err)
+ }
+}
+
+func (s *Session) MaxAge(maxAge int) {
+ s.s.Options.MaxAge = maxAge
+}
+
+func (s *Session) Invalidate() {
+ s.MaxAge(-1)
+ s.Authenticated = false
+}
+
+func (s *Session) IsNew() bool {
+ return s.s.IsNew
+}
+
+// From extracts the `Session` from the `Request`.
+func From(r *http.Request) Session {
+ s := r.Context().Value(sessionContextKey{}).(*sessions.Session)
+ s.Options.HttpOnly = true
+
+ sd, ok := s.Values[dataKey].(sessionData)
+ if !ok {
+ sd = sessionData{}
+ }
+ return Session{&sd, s}
+}
+
+func Handler() func(next http.Handler) http.Handler {
+ var key []byte
+
+ if envKey := os.Getenv("GOSTEN_SECRET"); len(envKey) >= 32 {
+ key = []byte(envKey)
+ } else {
+ key = securecookie.GenerateRandomKey(32)
+ }
+
+ sessionStore := sessions.NewCookieStore(key)
+
+ return func(next http.Handler) http.Handler {
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ session, _ := sessionStore.Get(r, sessionCookie)
+
+ ctx := context.WithValue(r.Context(), sessionContextKey{}, session)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ }
+ return http.HandlerFunc(fn)
+ }
+}
diff --git a/sql/categories.sql b/sql/categories.sql
new file mode 100644
index 0000000..2694adc
--- /dev/null
+++ b/sql/categories.sql
@@ -0,0 +1,5 @@
+-- name: GetCategoriesOrdered :many
+SELECT *
+ FROM categories
+ WHERE user_id = $1
+ ORDER BY name ASC; \ No newline at end of file
diff --git a/sql/ddl/pgsql.sql b/sql/ddl/pgsql.sql
index 18c0661..18dc388 100644
--- a/sql/ddl/pgsql.sql
+++ b/sql/ddl/pgsql.sql
@@ -1,51 +1,45 @@
-create table users
-(
- id serial primary key,
- name text not null,
- pwd text not null,
- description text
+CREATE TABLE users (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ pwd TEXT NOT NULL,
+ description TEXT
);
-create unique index users_name ON users(name);
-
-create table categories
-(
- id serial primary key,
- name text not null,
- parent_id integer references categories,
- user_id integer not null references users
+CREATE TABLE categories (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ parent_id INTEGER REFERENCES categories,
+ user_id INTEGER NOT NULL REFERENCES users
);
-create index parent_id
- on categories (parent_id);
+CREATE INDEX parent_id
+ ON categories (parent_id);
-create index user_id
- on categories (user_id);
+CREATE INDEX user_id
+ ON categories (user_id);
-create table const_expenses
-(
- id serial primary key,
- description text,
- expense numeric(10, 2) not null,
- months smallint not null,
- start date not null,
- "end" date not null,
- prev_id integer references const_expenses,
- category_id integer not null references categories,
- user_id integer not null references users
+CREATE TABLE recur_expenses (
+ id SERIAL PRIMARY KEY,
+ description TEXT,
+ expense NUMERIC(10, 2) NOT NULL,
+ duration INTERVAL NOT NULL,
+ start DATE NOT NULL,
+ "end" DATE NOT NULL,
+ prev_id INTEGER REFERENCES recur_expenses,
+ category_id INTEGER NOT NULL REFERENCES categories,
+ user_id INTEGER NOT NULL REFERENCES users
);
-create table single_expenses
-(
- id bigserial primary key,
- description text,
- expense numeric(10, 2) not null,
- year smallint not null,
- month smallint not null,
- day smallint not null,
- category_id integer not null references categories,
- user_id integer not null references users
+CREATE TABLE single_expenses (
+ id BIGSERIAL PRIMARY KEY,
+ description TEXT,
+ expense NUMERIC(10, 2) NOT NULL,
+ date DATE NOT NULL,
+ -- we need the cast to timestamp, because it is only considered immutable then
+ corr_month DATE GENERATED ALWAYS AS (DATE_TRUNC('month', date::timestamp)) STORED,
+ category_id INTEGER NOT NULL REFERENCES categories,
+ user_id INTEGER NOT NULL REFERENCES users
);
-CREATE INDEX idx_single_date ON single_expenses(user_id, year, month); \ No newline at end of file
+CREATE INDEX idx_single_month ON single_expenses (user_id, corr_month); \ No newline at end of file
diff --git a/sql/rexps.sql b/sql/rexps.sql
new file mode 100644
index 0000000..e76c809
--- /dev/null
+++ b/sql/rexps.sql
@@ -0,0 +1,4 @@
+-- name: GetRecurExpenses :many
+SELECT *
+ FROM recur_expenses
+ WHERE user_id = $1; \ No newline at end of file
diff --git a/sql/sexps.sql b/sql/sexps.sql
index 8279326..884e1f3 100644
--- a/sql/sexps.sql
+++ b/sql/sexps.sql
@@ -1,4 +1,4 @@
-- name: GetSingleExpenses :many
-SELECT id, description
+SELECT *
FROM single_expenses
WHERE user_id = $1; \ No newline at end of file
diff --git a/sql/users.sql b/sql/users.sql
index 2e1d997..e37e782 100644
--- a/sql/users.sql
+++ b/sql/users.sql
@@ -10,4 +10,14 @@ SELECT *
-- name: GetUserById :one
SELECT *
FROM users
- WHERE id = $1; \ No newline at end of file
+ WHERE id = $1;
+
+-- name: GetPwdById :one
+ SELECT pwd
+ FROM users
+ WHERE id = $1;
+
+-- name: UpdatePwd :exec
+ UPDATE users
+ SET pwd = $1
+ WHERE id = $2; \ No newline at end of file
diff --git a/static/custom.css b/static/custom.css
new file mode 100644
index 0000000..2ca6346
--- /dev/null
+++ b/static/custom.css
@@ -0,0 +1,8 @@
+/* Class used to draw SVGs */
+.svg {
+ display: inline-block;
+ width: 1rem;
+ height: 1rem;
+ vertical-align: -.125em;
+ fill: currentColor;
+} \ No newline at end of file
diff --git a/templ/base.tpl b/templ/base.tpl
deleted file mode 100644
index eddc987..0000000
--- a/templ/base.tpl
+++ /dev/null
@@ -1,19 +0,0 @@
-<!DOCTYPE html>
-<html lang="de">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" sizes="16x16 32x32 48x48">
- <title>{{block "title" .}}Kosten{{end}}</title>
- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
- <script defer src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
-</head>
-<body>
-{{block "body" .}}
- Dummy Text
-{{end}}
-{{block "js" .}}
- <script src="/static/index.js"></script>
-{{end}}
-</body>
-</html> \ No newline at end of file
diff --git a/templ/form.tpl b/templ/form.tpl
deleted file mode 100644
index 4d20557..0000000
--- a/templ/form.tpl
+++ /dev/null
@@ -1,22 +0,0 @@
-{{define "formItem"}}
- {{- $cb := eq .Type "checkbox" -}}
- <div class="{{if $cb}}form-check form-switch{{else}}form-floating{{end}} mb-3">
- <input
- {{with .ID}}id="{{.}}"{{end}}
- type="{{.Type}}"
- name="{{.Name}}"
- placeholder="{{.Placeholder}}"
- {{with .Value}}value="{{.}}"{{end}}
- {{with .Class}}class="{{.}}"{{end}}
- class="{{if $cb}}form-check-input{{else}}form-control{{end}}"
- {{range .Options}} {{.}} {{end}}
- >
- <label {{with .ID}}for="{{.}}"{{end}}
- {{- if $cb}}class="form-check-label"{{end}}>{{.Label}}</label>
-
- {{range errors}}
- <p style="color:red">{{.}}</p>
- {{end}}
- {{with .Footer}}<p>{{.}}</p>{{end}}
- </div>
-{{end}} \ No newline at end of file
diff --git a/templ/index.tpl b/templ/index.tpl
deleted file mode 100644
index 013c5ea..0000000
--- a/templ/index.tpl
+++ /dev/null
@@ -1,3 +0,0 @@
-{{define "body"}}
- Logged in with user: {{.}}
-{{end}} \ No newline at end of file
diff --git a/templ/login.tpl b/templ/login.tpl
deleted file mode 100644
index 7a9a049..0000000
--- a/templ/login.tpl
+++ /dev/null
@@ -1,10 +0,0 @@
-{{define "body"}}
- <main class="d-flex align-items-center min-vh-100">
- <form action="/login" method="post" class="container mx-auto" style="max-width: 440px;">
- <img src="/static/euro-money.svg" width="96" height="96" class="mb-4"/>
- {{inputs_and_errors_for . .Errors}}
- {{.CsrfField}}
- <button class="btn btn-primary w-100" type="submit">Log In!</button>
- </form>
- </main>
-{{end}} \ No newline at end of file
diff --git a/templ/template.go b/templ/template.go
deleted file mode 100644
index 9def1b8..0000000
--- a/templ/template.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package templ
-
-import (
- "embed"
- "html/template"
- "io/fs"
- "os"
- "sync"
-
- "github.com/Necoro/form"
-)
-
-//go:embed *.tpl
-var fsEmbed embed.FS
-
-var templates = make(map[string]*template.Template)
-var muTpl sync.RWMutex
-
-var baseTpl *template.Template
-var formBuilder form.Builder
-
-var isLive = sync.OnceValue(checkLive)
-
-func init() {
- loadBase(fsEmbed)
-}
-
-func checkLive() bool {
- return os.Getenv("GOSTEN_LIVE") != ""
-}
-
-func loadBase(fs fs.FS) {
- baseTpl = template.Must(template.New("base.tpl").
- Funcs(form.FuncMap()).
- ParseFS(fs, "base.tpl", "form.tpl"))
- formBuilder = form.Builder{InputTemplate: baseTpl.Lookup("formItem")}
- baseTpl.Funcs(formBuilder.FuncMap())
-}
-
-func Lookup(name string) *template.Template {
- if isLive() {
- fs := os.DirFS("templ/")
- loadBase(fs)
- return getTemplate(name, fs)
- }
-
- muTpl.RLock()
- tpl := templates[name]
- muTpl.RUnlock()
-
- if tpl == nil {
- return parse(name)
- }
- return tpl
-}
-
-func parse(name string) *template.Template {
- muTpl.Lock()
- defer muTpl.Unlock()
-
- if tpl := templates[name]; tpl != nil {
- // might've been created by another goroutine
- return tpl
- }
-
- t := getTemplate(name, fsEmbed)
- templates[name] = t
- return t
-}
-
-func getTemplate(name string, fs fs.FS) *template.Template {
- b := template.Must(baseTpl.Clone())
- t := template.Must(b.ParseFS(fs, name+".tpl"))
- return t
-}