From ece74ea36a441ab61408d7457f50597a1cff6ffa Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Thu, 7 Sep 2023 11:45:16 +0100 Subject: [PATCH] Improve underlying types and start writing edit page --- cmd/tulip/serve.go | 2 +- database/db-scanner.go | 28 +++++++++++-- database/db-types.go | 86 +++++++++++++-------------------------- database/db-types_test.go | 77 ----------------------------------- database/init.sql | 6 +-- database/tx.go | 35 ++++++---------- database/tx_test.go | 11 ++--- pages/edit.go.html | 66 ++++++++++++++++++++++++++++++ pages/index.go.html | 8 +++- server/db.go | 5 ++- server/server.go | 60 ++++++++++++++------------- 11 files changed, 181 insertions(+), 203 deletions(-) delete mode 100644 database/db-types_test.go create mode 100644 pages/edit.go.html diff --git a/cmd/tulip/serve.go b/cmd/tulip/serve.go index 8fcf189..84045a7 100644 --- a/cmd/tulip/serve.go +++ b/cmd/tulip/serve.go @@ -114,7 +114,7 @@ func checkDbHasUser(db *database.DB) error { } if err := tx.HasUser(); err != nil { if errors.Is(err, sql.ErrNoRows) { - err := tx.InsertUser("admin", "admin", "admin@localhost") + err := tx.InsertUser("Admin", "admin", "admin", "admin@localhost") if err != nil { return fmt.Errorf("failed to add user: %w", err) } diff --git a/database/db-scanner.go b/database/db-scanner.go index 176eab5..c0dd7eb 100644 --- a/database/db-scanner.go +++ b/database/db-scanner.go @@ -25,7 +25,7 @@ func marshalValueOrNull(null bool, data any) ([]byte, error) { type NullStringScanner struct{ sql.NullString } func (s *NullStringScanner) Null() bool { return !s.Valid } -func (s *NullStringScanner) Scan(src any) error { return s.Scan(src) } +func (s *NullStringScanner) Scan(src any) error { return s.NullString.Scan(src) } func (s NullStringScanner) MarshalJSON() ([]byte, error) { return marshalValueOrNull(s.Null(), s.String) } @@ -75,6 +75,14 @@ func (l *LocationScanner) Scan(src any) error { return nil } func (l LocationScanner) MarshalJSON() ([]byte, error) { return json.Marshal(l.Location.String()) } +func (l *LocationScanner) UnmarshalJSON(bytes []byte) error { + var a string + err := json.Unmarshal(bytes, &a) + if err != nil { + return err + } + return l.Scan(a) +} type LocaleScanner struct{ language.Tag } @@ -90,8 +98,14 @@ func (l *LocaleScanner) Scan(src any) error { l.Tag = lang return nil } -func (l LocaleScanner) MarshalJSON() ([]byte, error) { - return json.Marshal(l.Tag.String()) +func (l LocaleScanner) MarshalJSON() ([]byte, error) { return json.Marshal(l.Tag.String()) } +func (l *LocaleScanner) UnmarshalJSON(bytes []byte) error { + var a string + err := json.Unmarshal(bytes, &a) + if err != nil { + return err + } + return l.Scan(a) } type PronounScanner struct{ pronouns.Pronoun } @@ -109,3 +123,11 @@ func (p *PronounScanner) Scan(src any) error { return nil } func (p PronounScanner) MarshalJSON() ([]byte, error) { return json.Marshal(p.Pronoun.String()) } +func (p *PronounScanner) UnmarshalJSON(bytes []byte) error { + var a string + err := json.Unmarshal(bytes, &a) + if err != nil { + return err + } + return p.Scan(a) +} diff --git a/database/db-types.go b/database/db-types.go index bd183cb..4ed05b3 100644 --- a/database/db-types.go +++ b/database/db-types.go @@ -1,7 +1,7 @@ package database import ( - "encoding/json" + "database/sql" "github.com/MrMelon54/pronouns" "github.com/google/uuid" "golang.org/x/text/language" @@ -27,79 +27,51 @@ type User struct { } type UserPatch struct { - Name NullStringScanner `json:"name"` - Picture NullStringScanner `json:"picture"` - Website NullStringScanner `json:"website"` - Pronouns PronounScanner `json:"pronouns"` - Birthdate NullDateScanner `json:"birthdate"` - ZoneInfo *time.Location `json:"zoneinfo"` - Locale *language.Tag `json:"locale"` + Name string + Picture string + Website string + Pronouns pronouns.Pronoun + Birthdate sql.NullTime + ZoneInfo *time.Location + Locale language.Tag } -func (u *UserPatch) UnmarshalJSON(bytes []byte) error { - var m struct { - Name string `json:"name"` - Picture string `json:"picture"` - Website string `json:"website"` - Pronouns string `json:"pronouns"` - Birthdate string `json:"birthdate"` - ZoneInfo string `json:"zoneinfo"` - Locale string `json:"locale"` - } - err := json.Unmarshal(bytes, &m) - if err != nil { - return err - } - u.Name = m.Name - - // only parse the picture address if included - if m.Picture != "" { - u.Picture, err = url.Parse(m.Picture) +func (u *UserPatch) ParseFromForm(v url.Values) (err error) { + u.Name = v.Get("name") + u.Picture = v.Get("picture") + u.Website = v.Get("website") + if v.Has("reset_pronouns") { + u.Pronouns = pronouns.TheyThem + } else { + u.Pronouns, err = pronouns.FindPronoun(v.Get("pronouns")) if err != nil { return err } } - - // only parse the website address if included - if m.Website != "" { - u.Website, err = url.Parse(m.Website) + if v.Has("reset_birthdate") { + u.Birthdate = sql.NullTime{} + } else { + u.Birthdate = sql.NullTime{Valid: true} + u.Birthdate.Time, err = time.Parse(time.DateOnly, v.Get("birthdate")) if err != nil { return err } } - - // only parse the pronouns if included - if m.Pronouns != "" { - u.Pronouns, err = pronouns.FindPronoun(m.Pronouns) + if v.Has("reset_zoneinfo") { + u.ZoneInfo = time.UTC + } else { + u.ZoneInfo, err = time.LoadLocation(v.Get("zoneinfo")) if err != nil { return err } } - - // only parse the birthdate if included - if m.Birthdate != "" { - u.Birthdate, err = time.Parse(time.DateOnly, m.Birthdate) + if v.Has("reset_locale") { + u.Locale = language.AmericanEnglish + } else { + u.Locale, err = language.Parse(v.Get("locale")) if err != nil { return err } } - - // only parse the zoneinfo if included - if m.ZoneInfo != "" { - u.ZoneInfo, err = time.LoadLocation(m.ZoneInfo) - if err != nil { - return err - } - } - - if m.Locale != "" { - locale, err := language.Parse(m.Locale) - if err != nil { - return err - } - u.Locale = &locale - } return nil } - -var _ json.Unmarshaler = &UserPatch{} diff --git a/database/db-types_test.go b/database/db-types_test.go deleted file mode 100644 index b129f00..0000000 --- a/database/db-types_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package database - -import ( - "encoding/json" - "github.com/MrMelon54/pronouns" - "github.com/stretchr/testify/assert" - "maps" - "testing" - "time" -) - -func TestUserPatch_UnmarshalJSON(t *testing.T) { - const a = `{ - "name": "Test", - "picture": "https://example.com/logo.png", - "website": "https://example.com", - "gender": "robot", - "pronouns": "they/them", - "birthdate": "3070-01-01", - "zoneinfo": "Europe/London", - "locale": "en-GB" -}` - var p UserPatch - assert.NoError(t, json.Unmarshal([]byte(a), &p)) - assert.Equal(t, "Test", p.Name) - assert.Equal(t, "https://example.com/logo.png", p.Picture.String()) - assert.Equal(t, "https://example.com", p.Website.String()) - assert.Equal(t, pronouns.TheyThem, p.Pronouns) - assert.Equal(t, time.Date(3070, time.January, 1, 0, 0, 0, 0, time.UTC), p.Birthdate) - location, err := time.LoadLocation("Europe/London") - assert.NoError(t, err) - assert.Equal(t, location, p.ZoneInfo) - assert.Equal(t, "en-GB", p.Locale.String()) -} - -func TestUserPatch_UnmarshalJSON2(t *testing.T) { - var userModifyChecks = map[string]struct{ valid, invalid []string }{ - "picture": {valid: []string{"https://example.com/icon.png"}, invalid: []string{"%/icon.png"}}, - "website": {valid: []string{"https://example.com"}, invalid: []string{"%/example.com"}}, - "pronouns": {valid: []string{"he/him", "she/her"}, invalid: []string{"a/a"}}, - "birthdate": {valid: []string{"2023-08-07", "2023-01-01"}, invalid: []string{"2023-00-00", "hello"}}, - "zoneinfo": { - valid: []string{"Europe/London", "Europe/Berlin", "America/Los_Angeles", "America/Edmonton", "America/Montreal"}, - invalid: []string{"Europe/York", "Canada/Edmonton", "hello"}, - }, - "locale": {valid: []string{"en-GB", "en-US", "zh-CN"}, invalid: []string{"en-YY"}}, - } - m := map[string]string{ - "name": "Test", - "picture": "https://example.com/logo.png", - "website": "https://example.com", - "gender": "robot", - "pronouns": "they/them", - "birthdate": "3070-01-01", - "zoneinfo": "Europe/London", - "locale": "en-GB", - } - for k, v := range userModifyChecks { - t.Run(k, func(t *testing.T) { - m2 := maps.Clone(m) - for _, i := range v.valid { - m2[k] = i - marshal, err := json.Marshal(m2) - assert.NoError(t, err) - var m3 UserPatch - assert.NoError(t, json.Unmarshal(marshal, &m3)) - } - for _, i := range v.invalid { - m2[k] = i - marshal, err := json.Marshal(m2) - assert.NoError(t, err) - var m3 UserPatch - assert.Error(t, json.Unmarshal(marshal, &m3)) - } - }) - } -} diff --git a/database/init.sql b/database/init.sql index b133162..40bdbd1 100644 --- a/database/init.sql +++ b/database/init.sql @@ -4,13 +4,13 @@ CREATE TABLE IF NOT EXISTS users name TEXT NOT NULL, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, - picture TEXT, - website TEXT, + picture TEXT DEFAULT "" NOT NULL, + website TEXT DEFAULT "" NOT NULL, email TEXT NOT NULL, email_verified INTEGER DEFAULT 0 NOT NULL, pronouns TEXT DEFAULT "they/them" NOT NULL, birthdate DATE, - zoneinfo TEXT DEFAULT "" NOT NULL, + zoneinfo TEXT DEFAULT "UTC" NOT NULL, locale TEXT DEFAULT "en-US" NOT NULL, updated_at DATETIME, active INTEGER DEFAULT 1 diff --git a/database/tx.go b/database/tx.go index a451f6c..17c27c5 100644 --- a/database/tx.go +++ b/database/tx.go @@ -32,12 +32,12 @@ func (t *Tx) HasUser() error { return nil } -func (t *Tx) InsertUser(un, pw, email string) error { +func (t *Tx) InsertUser(name, un, pw, email string) error { pwHash, err := password.HashPassword(pw) if err != nil { return err } - _, err = t.tx.Exec(`INSERT INTO users (subject, username, password, email) VALUES (?, ?, ?, ?)`, uuid.NewString(), un, pwHash, email) + _, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email) VALUES (?, ?, ?, ?, ?)`, uuid.NewString(), name, un, pwHash, email) return err } @@ -113,20 +113,20 @@ func (t *Tx) ChangeUserPassword(sub uuid.UUID, pwOld, pwNew string) error { func (t *Tx) ModifyUser(sub uuid.UUID, v *UserPatch) error { exec, err := t.tx.Exec( `UPDATE users -SET name = ifnull(?, name), - picture = ifnull(?, picture), - website = ifnull(?, website), - pronouns = ifnull(?, pronouns), - birthdate = ifnull(?, birthdate), - zoneinfo = ifnull(?, zoneinfo), - locale = ifnull(?, locale), +SET name = ?, + picture = ?, + website = ?, + pronouns = ?, + birthdate = ?, + zoneinfo = ?, + locale = ?, updated_at = ? WHERE subject = ?`, v.Name, - stringify(v.Picture), - stringify(v.Website), + v.Picture, + v.Website, v.Pronouns.String(), - sql.NullTime{Time: v.Birthdate, Valid: !v.Birthdate.IsZero()}, + v.Birthdate, v.ZoneInfo.String(), v.Locale.String(), time.Now().Format(time.DateTime), @@ -164,14 +164,3 @@ func (c *clientInfoDbOutput) GetDomain() string { return c.domain } func (c *clientInfoDbOutput) IsPublic() bool { return false } func (c *clientInfoDbOutput) GetUserID() string { return "" } func (c *clientInfoDbOutput) IsSSO() bool { return c.sso } - -func stringify(stringer fmt.Stringer) sql.NullString { - if stringer == nil { - return sql.NullString{} - } - return emptyToNull(stringer.String()) -} - -func emptyToNull(a string) sql.NullString { - return sql.NullString{String: a, Valid: a != ""} -} diff --git a/database/tx_test.go b/database/tx_test.go index ee972e9..5a87ff1 100644 --- a/database/tx_test.go +++ b/database/tx_test.go @@ -44,12 +44,9 @@ func TestTx_ModifyUser(t *testing.T) { tx, err := d.Begin() assert.NoError(t, err) assert.NoError(t, tx.ModifyUser(u, &UserPatch{ - Name: "example", - Picture: nil, - Website: nil, - Pronouns: pronouns.Pronoun{}, - Birthdate: time.Time{}, - ZoneInfo: nil, - Locale: &language.Tag{}, + Name: "example", + Pronouns: pronouns.TheyThem, + ZoneInfo: time.UTC, + Locale: language.AmericanEnglish, })) } diff --git a/pages/edit.go.html b/pages/edit.go.html new file mode 100644 index 0000000..107ec54 --- /dev/null +++ b/pages/edit.go.html @@ -0,0 +1,66 @@ + + + + 1f349 ID + + +
+

1f349 ID

+
+
+
Logged in as: {{.User.Name}} ({{.User.Sub}})
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + +
+
+ + + + + + + +
+
+ + + + + + + +
+ +
+
+
+ + diff --git a/pages/index.go.html b/pages/index.go.html index 96cae19..153a75e 100644 --- a/pages/index.go.html +++ b/pages/index.go.html @@ -8,9 +8,13 @@

1f349 ID

-
Logged in as: {{.User.Name}} ({{.User.ID}})
+
Logged in as: {{.User.Name}} ({{.User.Sub}})
-
+ +
+
+ +
diff --git a/server/db.go b/server/db.go index f409d80..4836f93 100644 --- a/server/db.go +++ b/server/db.go @@ -6,7 +6,10 @@ import ( "net/http" ) -func (h *HttpServer) dbTx(rw http.ResponseWriter, action func(tx *database.Tx) error) bool { +// DbTx wraps a database transaction with http error messages and a simple action +// function. If the action function returns an error the transaction will be +// rolled back. If there is no error then the transaction is committed. +func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Tx) error) bool { tx, err := h.db.Begin() if err != nil { http.Error(rw, "Failed to begin database transaction", http.StatusInternalServerError) diff --git a/server/server.go b/server/server.go index 2b967fc..7f41875 100644 --- a/server/server.go +++ b/server/server.go @@ -93,9 +93,6 @@ func NewHttpServer(listen, domain string, db *database.DB, privKey []byte, clien return "openid", nil }) - newUserUuid := uuid.New() - fmt.Println("New User Uuid:", newUserUuid.String()) - r.GET("/.well-known/openid-configuration", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { rw.WriteHeader(http.StatusOK) _, _ = rw.Write(openIdBytes) @@ -115,16 +112,18 @@ func NewHttpServer(listen, domain string, db *database.DB, privKey []byte, clien return } - hs.dbTx(rw, func(tx *database.Tx) error { + hs.DbTx(rw, func(tx *database.Tx) error { userWithName, err := tx.GetUserDisplayName(auth.ID) if err != nil { return fmt.Errorf("failed to get user display name: %w", err) } - _ = pages.RenderPageTemplate(rw, "index", map[string]any{ + if err := pages.RenderPageTemplate(rw, "index", map[string]any{ "Auth": auth, "User": userWithName, "Nonce": lNonce, - }) + }); err != nil { + log.Printf("Failed to render page: edit: %s\n", err) + } return nil }) })) @@ -152,13 +151,15 @@ func NewHttpServer(listen, domain string, db *database.DB, privKey []byte, clien } rw.Header().Set("Content-Type", "text/html") rw.WriteHeader(http.StatusOK) - _ = pages.RenderPageTemplate(rw, "login", nil) + if err := pages.RenderPageTemplate(rw, "login", nil); err != nil { + log.Printf("Failed to render page: edit: %s\n", err) + } })) r.POST("/login", hs.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { un := req.FormValue("username") pw := req.FormValue("password") var userSub uuid.UUID - if hs.dbTx(rw, func(tx *database.Tx) error { + if hs.DbTx(rw, func(tx *database.Tx) error { loginUser, err := tx.CheckLogin(un, pw) if err != nil { if errors2.Is(err, sql.ErrNoRows) || errors2.Is(err, bcrypt.ErrMismatchedHashAndPassword) { @@ -207,13 +208,16 @@ func NewHttpServer(listen, domain string, db *database.DB, privKey []byte, clien } }) r.GET("/edit", hs.RequireAuthentication("403 Forbidden", http.StatusForbidden, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - begin, err := db.Begin() - if err != nil { - return - } - user, err := begin.GetUser(auth.ID) - if err != nil { - http.Error(rw, "Failed to read user data", http.StatusInternalServerError) + var user *database.User + + if hs.DbTx(rw, func(tx *database.Tx) error { + var err error + user, err = tx.GetUser(auth.ID) + if err != nil { + return fmt.Errorf("failed to read user data: %w", err) + } + return nil + }) { return } @@ -223,33 +227,31 @@ func NewHttpServer(listen, domain string, db *database.DB, privKey []byte, clien http.Error(rw, "Failed to save session", http.StatusInternalServerError) return } - _ = pages.RenderPageTemplate(rw, "edit", map[string]any{ + if err := pages.RenderPageTemplate(rw, "edit", map[string]any{ "User": user, "Nonce": lNonce, - }) + }); err != nil { + log.Printf("Failed to render page: edit: %s\n", err) + } })) r.POST("/edit", hs.RequireAuthentication("403 Forbidden", http.StatusForbidden, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { if req.ParseForm() != nil { rw.WriteHeader(http.StatusBadRequest) return } - // TODO: parse user patch from form - req.Form.Get("") + var patch database.UserPatch - decoder := json.NewDecoder(req.Body) - decoder.DisallowUnknownFields() - err := decoder.Decode(&patch) + err := patch.ParseFromForm(req.Form) if err != nil { rw.WriteHeader(http.StatusBadRequest) return } - begin, err := db.Begin() - if err != nil { - rw.WriteHeader(http.StatusBadRequest) - return - } - if begin.ModifyUser(auth.ID, &patch) != nil { - http.Error(rw, "Failed to modify user info", http.StatusInternalServerError) + if hs.DbTx(rw, func(tx *database.Tx) error { + if err := tx.ModifyUser(auth.ID, &patch); err != nil { + return fmt.Errorf("failed to modify user info: %w", err) + } + return nil + }) { return } http.Redirect(rw, req, "/", http.StatusFound)