diff options
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) +} @@ -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 +} @@ -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 ) @@ -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= @@ -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 -} |