diff --git a/.gitignore b/.gitignore index d862823..7e3ee04 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ gin-bin *lishwist.db .env*.local -server/api/db/init_sql.go +init_sql.go .ignored/ diff --git a/core/admin.go b/core/admin.go new file mode 100644 index 0000000..8f87b2a --- /dev/null +++ b/core/admin.go @@ -0,0 +1,13 @@ +package lishwist + +type Admin struct { + session *Session +} + +func (s *Session) Admin() *Admin { + if s.User.IsAdmin { + return &Admin{s} + } else { + return nil + } +} diff --git a/core/debug.go b/core/debug.go new file mode 100644 index 0000000..c0139e4 --- /dev/null +++ b/core/debug.go @@ -0,0 +1,55 @@ +package lishwist + +import ( + "database/sql" + "fmt" + "log" +) + +func PrintViews(d *sql.DB) { + rows, err := d.Query("SELECT name FROM sqlite_master WHERE type = 'view';") + if err != nil { + log.Println("Query failed: %w", err) + return + } + defer rows.Close() + fmt.Println("Printing view names...") + for rows.Next() { + var name string + err = rows.Scan(&name) + if err != nil { + log.Println("Scan failed: %w", err) + return + } + fmt.Printf("name: %s\n", name) + } + err = rows.Err() + if err != nil { + log.Println("Rows returned error: %w", err) + return + } +} + +func PrintTables(d *sql.DB) { + rows, err := d.Query("SELECT name FROM sqlite_master WHERE type = 'table';") + if err != nil { + log.Println("Query failed: %w", err) + return + } + defer rows.Close() + fmt.Println("Printing table names...") + for rows.Next() { + var name string + err = rows.Scan(&name) + if err != nil { + fmt.Println("Scan failed: %w", err) + return + } + fmt.Printf("name: %s\n", name) + } + err = rows.Err() + if err != nil { + log.Println("Rows returned error: %w", err) + return + } +} diff --git a/core/go.mod b/core/go.mod new file mode 100644 index 0000000..d1b9821 --- /dev/null +++ b/core/go.mod @@ -0,0 +1,18 @@ +module lishwist/core + +go 1.23.0 + +toolchain go1.23.3 + +require golang.org/x/crypto v0.39.0 + +require ( + github.com/google/uuid v1.6.0 + github.com/ncruces/go-sqlite3 v0.26.1 +) + +require ( + github.com/ncruces/julianday v1.0.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + golang.org/x/sys v0.33.0 // indirect +) diff --git a/core/go.sum b/core/go.sum new file mode 100644 index 0000000..f2fefe5 --- /dev/null +++ b/core/go.sum @@ -0,0 +1,14 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/ncruces/go-sqlite3 v0.26.1 h1:lBXmbmucH1Bsj57NUQR6T84UoMN7jnNImhF+ibEITJU= +github.com/ncruces/go-sqlite3 v0.26.1/go.mod h1:XFTPtFIo1DmGCh+XVP8KGn9b/o2f+z0WZuT09x2N6eo= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= diff --git a/server/api/db/group.go b/core/group.go similarity index 59% rename from server/api/db/group.go rename to core/group.go index b672c98..230c475 100644 --- a/server/api/db/group.go +++ b/core/group.go @@ -1,9 +1,10 @@ -package db +package lishwist import ( "fmt" - "lishwist/normalize" + "lishwist/core/internal/db" "strconv" + "strings" ) type Group struct { @@ -24,7 +25,7 @@ func (g *Group) MemberIndex(userId string) int { func queryManyGroups(query string, args ...any) ([]Group, error) { groups := []Group{} - rows, err := database.Query(query, args...) + rows, err := db.Connection.Query(query, args...) if err != nil { return nil, fmt.Errorf("Query failed: %w", err) } @@ -64,25 +65,31 @@ func queryManyGroupMembers(groupId string) ([]User, error) { query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live FROM v_user AS user JOIN group_member ON group_member.user_id = user.id JOIN [group] ON [group].id = group_member.group_id WHERE [group].id = ? ORDER BY group_member.user_id" members, err := queryManyUsers(query, groupId) if err != nil { - return members, fmt.Errorf("Failed to get members: %w", err) + return members, err } return members, nil } -func GetGroupByReference(reference string) (*Group, error) { - query := "SELECT [group].id, [group].name, [group].reference FROM [group] WHERE [group].reference = ?" - return queryOneGroup(query, reference) +func (s *Session) GetGroupByReference(reference string) (*Group, error) { + stmt := "SELECT [group].id, [group].name, [group].reference FROM [group] JOIN group_member ON [group].id == group_member.group_id WHERE [group].reference = ? AND group_member.user_id = ?;" + return queryOneGroup(stmt, reference, s.User.Id) } -func GetAllGroups() ([]Group, error) { +func GetGroupByReference(reference string) (*Group, error) { + stmt := "SELECT [group].id, [group].name, [group].reference FROM [group] WHERE [group].reference = ?;" + return queryOneGroup(stmt, reference) +} + +func (a *Admin) ListGroups() ([]Group, error) { query := "SELECT id, name, reference FROM [group];" return queryManyGroups(query) } -func CreateGroup(name string, reference string) (*Group, error) { - name = normalize.Trim(name) +func (a *Admin) CreateGroup(name string, reference string) (*Group, error) { + name = strings.TrimSpace(name) + reference = strings.TrimSpace(reference) stmt := "INSERT INTO [group] (name, reference) VALUES (?, ?)" - result, err := database.Exec(stmt, name, reference) + result, err := db.Connection.Exec(stmt, name, reference) if err != nil { return nil, err } @@ -98,20 +105,26 @@ func CreateGroup(name string, reference string) (*Group, error) { return &group, nil } -func (g *Group) AddUser(userId string) error { - stmt := "INSERT INTO group_member (group_id, user_id) VALUES (?, ?)" - _, err := database.Exec(stmt, g.Id, userId) +func (a *Admin) AddUserToGroup(userId, groupId string) error { + stmt := "INSERT INTO group_member (user_id, group_id) VALUES (?, ?)" + _, err := db.Connection.Exec(stmt, userId, groupId) if err != nil { return err } return nil } -func (g *Group) RemoveUser(userId string) error { +func (a *Admin) RemoveUserFromGroup(userId, groupId string) error { stmt := "DELETE FROM group_member WHERE group_id = ? AND user_id = ?" - _, err := database.Exec(stmt, g.Id, userId) + _, err := db.Connection.Exec(stmt, userId, groupId) if err != nil { return err } return nil } + +// Get the groups the session user belongs to +func (u *Session) GetGroups() ([]Group, error) { + stmt := "SELECT [group].id, [group].name, [group].reference FROM [group] JOIN group_member ON group_member.group_id = [group].id JOIN v_user AS user ON user.id = group_member.user_id WHERE user.id = ?" + return queryManyGroups(stmt, u.Id) +} diff --git a/core/group_test.go b/core/group_test.go new file mode 100644 index 0000000..de96b63 --- /dev/null +++ b/core/group_test.go @@ -0,0 +1,42 @@ +package lishwist_test + +import ( + "testing" + + lishwist "lishwist/core" + "lishwist/core/internal/fixtures" +) + +func TestCreateGroup(t *testing.T) { + s := fixtures.Login(t, "thomas", "123") + + group, err := s.Admin().CreateGroup(" My Friends ", " my-friends ") + fixtures.FailIfErr(t, err, "Failed to create group") + + fixtures.AssertEq(t, "Number of users", "My Friends", group.Name) + fixtures.AssertEq(t, "Number of users", "my-friends", group.Reference) +} + +func TestCantSeeSelfInGroup(t *testing.T) { + s := fixtures.Login(t, "thomas", "123") + + caleb, err := lishwist.Register("caleb", "123") + fixtures.FailIfErr(t, err, "Failed to register caleb") + + group, err := s.Admin().CreateGroup(" My Friends ", " my-friends ") + fixtures.FailIfErr(t, err, "Failed to create group") + + err = s.Admin().AddUserToGroup(s.User.Id, group.Id) + fixtures.FailIfErr(t, err, "Failed to add self to group") + + err = s.Admin().AddUserToGroup(caleb.Id, group.Id) + fixtures.FailIfErr(t, err, "Failed to add caleb to group") + + group, err = s.GetGroupByReference("my-friends") + fixtures.FailIfErr(t, err, "Failed to get group") + fixtures.FatalAssert(t, "Group not nil", group != nil) + + fixtures.AssertEq(t, "Group contains 2 users", 2, len(group.Members)) + fixtures.AssertEq(t, "Group user 1 is thomas", "thomas", group.Members[0].Name) + fixtures.AssertEq(t, "Group user 2 is caleb", "caleb", group.Members[1].Name) +} diff --git a/core/init.go b/core/init.go new file mode 100644 index 0000000..b1d7786 --- /dev/null +++ b/core/init.go @@ -0,0 +1,9 @@ +package lishwist + +import ( + "lishwist/core/internal/db" +) + +func Init(dataSourceName string) error { + return db.Init(dataSourceName) +} diff --git a/core/internal/db/db.go b/core/internal/db/db.go new file mode 100644 index 0000000..938dc12 --- /dev/null +++ b/core/internal/db/db.go @@ -0,0 +1,29 @@ +//go:generate go run gen_init_sql.go + +package db + +import ( + "database/sql" + "fmt" + + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" +) + +var Connection *sql.DB + +func Init(dataSourceName string) error { + db, err := sql.Open("sqlite3", dataSourceName) + if err != nil { + return fmt.Errorf("Failed to open db connection: %w", err) + } + + _, err = db.Exec(initQuery) + if err != nil { + return fmt.Errorf("Failed to initialize db: %w", err) + } + + Connection = db + + return nil +} diff --git a/server/api/db/gen_init_sql.go b/core/internal/db/gen_init_sql.go similarity index 100% rename from server/api/db/gen_init_sql.go rename to core/internal/db/gen_init_sql.go diff --git a/server/api/db/init.sql b/core/internal/db/init.sql similarity index 93% rename from server/api/db/init.sql rename to core/internal/db/init.sql index 2780036..e6d94b6 100644 --- a/server/api/db/init.sql +++ b/core/internal/db/init.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS "user" ( "is_live" INTEGER NOT NULL DEFAULT 1, PRIMARY KEY("id" AUTOINCREMENT) ); -CREATE TABLE IF NOT EXISTS "gift" ( +CREATE TABLE IF NOT EXISTS "wish" ( "id" INTEGER NOT NULL UNIQUE, "name" TEXT NOT NULL, "recipient_id" INTEGER NOT NULL, @@ -49,6 +49,6 @@ SELECT * FROM user WHERE user.is_live = 1; -- DROP VIEW IF EXISTS "v_wish"; -- CREATE VIEW "v_wish" -- AS --- SELECT gift.id, gift.name, gift.sent FROM gift JOIN user AS recipient; +-- SELECT wish.id, wish.name, wish.sent FROM wish JOIN user AS recipient; COMMIT; diff --git a/server/api/db/migration/1.sql b/core/internal/db/migration/1.sql similarity index 100% rename from server/api/db/migration/1.sql rename to core/internal/db/migration/1.sql diff --git a/core/internal/db/migration/2.sql b/core/internal/db/migration/2.sql new file mode 100644 index 0000000..78168a6 --- /dev/null +++ b/core/internal/db/migration/2.sql @@ -0,0 +1,6 @@ +BEGIN TRANSACTION; + +ALTER TABLE gift RENAME TO wish; + +COMMIT; + diff --git a/core/internal/fixtures/assert.go b/core/internal/fixtures/assert.go new file mode 100644 index 0000000..39ea2a1 --- /dev/null +++ b/core/internal/fixtures/assert.go @@ -0,0 +1,27 @@ +package fixtures + +import "testing" + +func AssertEq[C comparable](t *testing.T, context string, expected, actual C) { + if expected != actual { + t.Errorf("%s: %#v != %#v", context, expected, actual) + } +} + +func Assert(t *testing.T, context string, condition bool) { + if !condition { + t.Errorf("%s", context) + } +} + +func FatalAssert(t *testing.T, context string, condition bool) { + if !condition { + t.Fatalf("%s", context) + } +} + +func FailIfErr(t *testing.T, err error, context string) { + if err != nil { + t.Fatalf("%s: %s\n", context, err) + } +} diff --git a/core/internal/fixtures/login.go b/core/internal/fixtures/login.go new file mode 100644 index 0000000..c730027 --- /dev/null +++ b/core/internal/fixtures/login.go @@ -0,0 +1,35 @@ +package fixtures + +import ( + "log" + "testing" + + lishwist "lishwist/core" + + "github.com/ncruces/go-sqlite3/vfs/memdb" +) + +func TestInit(t *testing.T) error { + uri := memdb.TestDB(t) + return lishwist.Init(uri) +} + +func Login(t *testing.T, username, password string) *lishwist.Session { + uri := memdb.TestDB(t) + err := lishwist.Init(uri) + if err != nil { + log.Fatalf("Failed to init db: %s\n", err) + } + + _, err = lishwist.Register(username, password) + if err != nil { + log.Fatalf("Failed to register on login fixture: %s\n", err) + } + + session, err := lishwist.Login(username, password) + if err != nil { + log.Fatalf("Failed to login on fixture: %s\n", err) + } + + return session +} diff --git a/core/internal/normalize/name.go b/core/internal/normalize/name.go new file mode 100644 index 0000000..54a1c59 --- /dev/null +++ b/core/internal/normalize/name.go @@ -0,0 +1,10 @@ +package normalize + +import ( + "strings" +) + +func Name(name string) string { + name = strings.TrimSpace(name) + return strings.ToLower(name) +} diff --git a/core/login.go b/core/login.go new file mode 100644 index 0000000..47659a8 --- /dev/null +++ b/core/login.go @@ -0,0 +1,31 @@ +package lishwist + +import ( + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +type ErrorInvalidCredentials error + +func Login(username, password string) (*Session, error) { + user, err := getUserByName(username) + if err != nil { + return nil, ErrorInvalidCredentials(fmt.Errorf("Failed to fetch user: %w", err)) + } + if user == nil { + return nil, ErrorInvalidCredentials(fmt.Errorf("User not found by name: %s", username)) + } + + passHash, err := user.getPassHash() + if err != nil { + return nil, fmt.Errorf("Failed to get password hash: %w", err) + } + + err = bcrypt.CompareHashAndPassword(passHash, []byte(password)) + if err != nil { + return nil, ErrorInvalidCredentials(fmt.Errorf("Password compare failed: %w", err)) + } + + return &Session{*user}, nil +} diff --git a/core/login_test.go b/core/login_test.go new file mode 100644 index 0000000..9603a09 --- /dev/null +++ b/core/login_test.go @@ -0,0 +1,25 @@ +package lishwist_test + +import ( + "testing" + + lishwist "lishwist/core" + "lishwist/core/internal/fixtures" +) + +func TestLogin(t *testing.T) { + err := fixtures.TestInit(t) + if err != nil { + t.Fatalf("Failed to init db: %s\n", err) + } + + _, err = lishwist.Register("thomas", "123") + if err != nil { + t.Fatalf("Failed to register: %s\n", err) + } + + _, err = lishwist.Login("thomas", "123") + if err != nil { + t.Fatalf("Failed to login: %s\n", err) + } +} diff --git a/core/register.go b/core/register.go new file mode 100644 index 0000000..d8148fc --- /dev/null +++ b/core/register.go @@ -0,0 +1,41 @@ +package lishwist + +import ( + "errors" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +var ErrorUsernameTaken = errors.New("Username is taken") + +func Register(username, newPassword string) (*User, error) { + if username == "" { + return nil, errors.New("Username required") + } + if newPassword == "" { + return nil, errors.New("newPassword required") + } + + existingUser, _ := getUserByName(username) + if existingUser != nil { + return nil, ErrorUsernameTaken + } + + hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost) + if err != nil { + return nil, fmt.Errorf("Failed to hash password: %w", err) + } + + usersExist, err := hasUsers() + if err != nil { + return nil, fmt.Errorf("Failed to count users: %w", err) + } + + user, err := createUser(username, hashedPasswordBytes, !usersExist) + if err != nil { + return nil, fmt.Errorf("Failed to create user: %w\n", err) + } + + return user, nil +} diff --git a/core/sesh b/core/sesh new file mode 100644 index 0000000..392f621 --- /dev/null +++ b/core/sesh @@ -0,0 +1,20 @@ +{ + "__meta__": { + "about": "xh session file", + "xh": "0.24.1" + }, + "auth": { + "type": null, + "raw_auth": null + }, + "cookies": [ + { + "name": "lishwist_user", + "value": "MTc1MDg2NDE2N3xCQXdBQVRjPXw8gaasdVy--TC-_fUb-3ZL58n8UVakTqDm_0_7c50cYA==", + "expires": 1750950567, + "path": "/lists", + "domain": "127.0.0.1" + } + ], + "headers": [] +} diff --git a/core/session.go b/core/session.go new file mode 100644 index 0000000..1a7445b --- /dev/null +++ b/core/session.go @@ -0,0 +1,15 @@ +package lishwist + +import "fmt" + +type Session struct { + User +} + +func SessionFromUsername(username string) (*Session, error) { + user, err := getUserByName(username) + if err != nil { + return nil, fmt.Errorf("Failed to get user: %w", err) + } + return &Session{*user}, nil +} diff --git a/core/session/store.go b/core/session/store.go new file mode 100644 index 0000000..3045fc3 --- /dev/null +++ b/core/session/store.go @@ -0,0 +1,39 @@ +package session + +import ( + "fmt" + "lishwist/core/internal/db" + + "github.com/Teajey/sqlstore" +) + +func NewStore(keyPairs ...[]byte) (*sqlstore.Store, error) { + deleteStmt, err := db.Connection.Prepare("DELETE FROM session WHERE id = ?;") + if err != nil { + return nil, fmt.Errorf("Failed to prepare delete statement: %w", err) + } + + insertStmt, err := db.Connection.Prepare("INSERT INTO session (value) VALUES (?);") + if err != nil { + return nil, fmt.Errorf("Failed to prepare insert statement: %w", err) + } + + selectStmt, err := db.Connection.Prepare("SELECT value FROM session WHERE id = ?;") + if err != nil { + return nil, fmt.Errorf("Failed to prepare select statement: %w", err) + } + + updateStmt, err := db.Connection.Prepare("UPDATE session SET value = ?2 WHERE id = ?1;") + if err != nil { + return nil, fmt.Errorf("Failed to prepare update statement: %w", err) + } + + s := sqlstore.NewSqlStore(db.Connection, sqlstore.Statements{ + Delete: deleteStmt, + Insert: insertStmt, + Select: selectStmt, + Update: updateStmt, + }, keyPairs...) + + return s, nil +} diff --git a/core/user.go b/core/user.go new file mode 100644 index 0000000..b0207cb --- /dev/null +++ b/core/user.go @@ -0,0 +1,165 @@ +package lishwist + +import ( + "fmt" + + "github.com/google/uuid" + + "lishwist/core/internal/db" + "lishwist/core/internal/normalize" +) + +type User struct { + Id string + // TODO: rename to DisplayName + NormalName string + Name string + Reference string + IsAdmin bool + IsLive bool +} + +func queryManyUsers(query string, args ...any) ([]User, error) { + rows, err := db.Connection.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + users := []User{} + for rows.Next() { + var u User + err = rows.Scan(&u.Id, &u.NormalName, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive) + if err != nil { + return nil, err + } + users = append(users, u) + } + err = rows.Err() + if err != nil { + return nil, err + } + return users, nil +} + +func queryOneUser(query string, args ...any) (*User, error) { + users, err := queryManyUsers(query, args...) + if err != nil { + return nil, err + } + if len(users) < 1 { + return nil, nil + } + return &users[0], nil +} + +func getUserByName(username string) (*User, error) { + username = normalize.Name(username) + stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE name = ?" + return queryOneUser(stmt, username) +} + +func createUser(name string, passHash []byte, isAdmin bool) (*User, error) { + username := normalize.Name(name) + stmt := "INSERT INTO user (name, display_name, reference, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)" + reference, err := uuid.NewRandom() + if err != nil { + return nil, fmt.Errorf("Failed to generate reference: %w", err) + } + result, err := db.Connection.Exec(stmt, username, name, reference, passHash, isAdmin) + if err != nil { + return nil, fmt.Errorf("Failed to execute query: %w", err) + } + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("Failed to get last insert id: %w", err) + } + user := User{ + Id: fmt.Sprintf("%d", id), + Name: name, + } + return &user, nil +} + +func (u *User) getPassHash() ([]byte, error) { + stmt := "SELECT password_hash FROM v_user WHERE id = ?" + var passHash string + err := db.Connection.QueryRow(stmt, u.Id).Scan(&passHash) + if err != nil { + return nil, err + } + return []byte(passHash), nil +} + +func getUserByReference(reference string) (*User, error) { + stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE reference = ?" + return queryOneUser(stmt, reference) +} + +func getUserById(id string) (*User, error) { + stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE id = ?" + return queryOneUser(stmt, id) +} + +func hasUsers() (bool, error) { + stmt := "SELECT COUNT(id) FROM v_user LIMIT 1" + var userCount uint + err := db.Connection.QueryRow(stmt).Scan(&userCount) + if err != nil { + return false, err + } + return userCount > 0, nil +} + +func (*Admin) ListUsers() ([]User, error) { + stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM user" + return queryManyUsers(stmt) +} + +func (*Admin) GetUser(id string) (*User, error) { + return getUserById(id) +} + +func GetUserByReference(reference string) (*User, error) { + return getUserByReference(reference) +} + +func (u *User) GetTodo() ([]Wish, error) { + stmt := "SELECT wish.id, wish.name, wish.sent, recipient.name, recipient.reference FROM wish JOIN v_user AS user ON wish.claimant_id = user.id JOIN v_user AS recipient ON wish.recipient_id = recipient.id WHERE user.id = ? ORDER BY wish.sent ASC, wish.name" + rows, err := db.Connection.Query(stmt, u.Id) + if err != nil { + return nil, err + } + defer rows.Close() + wishes := []Wish{} + for rows.Next() { + var id string + var name string + var sent bool + var recipientName string + var recipientRef string + _ = rows.Scan(&id, &name, &sent, &recipientName, &recipientRef) + wish := Wish{ + Id: id, + Name: name, + Sent: sent, + RecipientName: recipientName, + RecipientRef: recipientRef, + } + wishes = append(wishes, wish) + } + err = rows.Err() + if err != nil { + return nil, err + } + return wishes, nil +} + +func (u *Admin) UserSetLive(userReference string, setting bool) error { + query := "UPDATE user SET is_live = ? WHERE reference = ?" + _, err := db.Connection.Exec(query, setting, userReference) + if err != nil { + return err + } + // u.IsLive = setting + return err +} diff --git a/core/user_test.go b/core/user_test.go new file mode 100644 index 0000000..978ec7c --- /dev/null +++ b/core/user_test.go @@ -0,0 +1,22 @@ +package lishwist_test + +import ( + "testing" + + lishwist "lishwist/core" + "lishwist/core/internal/fixtures" +) + +func TestFirstUserIsAdmin(t *testing.T) { + s := fixtures.Login(t, "thomas", "123") + + _, err := lishwist.Register("caleb", "123") + fixtures.FailIfErr(t, err, "Failed to register caleb") + + users, err := s.Admin().ListUsers() + fixtures.FailIfErr(t, err, "Failed to list users") + + fixtures.AssertEq(t, "Number of users", 2, len(users)) + fixtures.Assert(t, "User 1 is admin", users[0].IsAdmin) + fixtures.Assert(t, "User 2 is not admin", !users[1].IsAdmin) +} diff --git a/core/wish.go b/core/wish.go new file mode 100644 index 0000000..3b7dffb --- /dev/null +++ b/core/wish.go @@ -0,0 +1,295 @@ +package lishwist + +import ( + "database/sql" + "errors" + "fmt" + "strings" + + "lishwist/core/internal/db" +) + +type Wish struct { + Id string + Name string + ClaimantId string `json:",omitempty"` + ClaimantName string `json:",omitempty"` + Sent bool + RecipientId string `json:",omitempty"` + RecipientName string `json:",omitempty"` + RecipientRef string `json:",omitempty"` + CreatorId string `json:",omitempty"` + CreatorName string `json:",omitempty"` +} + +func (s *Session) GetWishes() ([]Wish, error) { + stmt := "SELECT wish.id, wish.name, wish.sent FROM wish WHERE wish.creator_id = ?1 AND wish.recipient_id = ?1" + rows, err := db.Connection.Query(stmt, s.User.Id) + if err != nil { + return nil, fmt.Errorf("Query execution failed: %w", err) + } + defer rows.Close() + wishs := []Wish{} + for rows.Next() { + var id string + var name string + var sent bool + err = rows.Scan(&id, &name, &sent) + if err != nil { + return nil, fmt.Errorf("Failed to scan a row: %w", err) + } + wish := Wish{ + Id: id, + Name: name, + Sent: sent, + } + wishs = append(wishs, wish) + } + err = rows.Err() + if err != nil { + return nil, fmt.Errorf("Rows returned an error: %w", err) + } + return wishs, nil +} + +func (s *Session) MakeWish(name string) error { + stmt := "INSERT INTO wish (name, recipient_id, creator_id) VALUES (?, ?, ?)" + _, err := db.Connection.Exec(stmt, strings.TrimSpace(name), s.User.Id, s.User.Id) + if err != nil { + return fmt.Errorf("Query execution failed: %w", err) + } + return nil +} + +func (u *Session) deleteWishes(tx *sql.Tx, ids []string) error { + stmt := "DELETE FROM wish WHERE wish.creator_id = ? AND wish.id = ?" + for _, id := range ids { + r, err := tx.Exec(stmt, u.Id, id) + if err != nil { + return err + } + rE, err := r.RowsAffected() + if err != nil { + return err + } + if rE < 1 { + return fmt.Errorf("Wish deletion failed for '%s'", id) + } + } + return nil +} + +func (s *Session) RevokeWishes(ids ...string) error { + if len(ids) < 1 { + return fmt.Errorf("Attempt to remove zero wishes") + } + + tx, err := db.Connection.Begin() + if err != nil { + return err + } + + err = s.deleteWishes(tx, ids) + if err != nil { + rollBackErr := tx.Rollback() + if rollBackErr != nil { + return err + } + return err + } + + err = tx.Commit() + return err +} + +func (s *Session) GetOthersWishes(userReference string) ([]Wish, error) { + otherUser, err := getUserByReference(userReference) + if err != nil { + return nil, fmt.Errorf("Failed to get other user: %w", err) + } + if otherUser.Id == s.User.Id { + return nil, errors.New("Use (s *Session) GetWishes() to view your own wishes") + } + stmt := "SELECT wish.id, wish.name, claimant.id, claimant.name, wish.sent, wish.creator_id, creator.name, wish.recipient_id FROM wish JOIN v_user AS user ON wish.recipient_id = user.id LEFT JOIN v_user AS claimant ON wish.claimant_id = claimant.id LEFT JOIN v_user AS creator ON wish.creator_id = creator.id WHERE user.id = ?" + rows, err := db.Connection.Query(stmt, otherUser.Id) + if err != nil { + return nil, fmt.Errorf("Failed to execute query: %w", err) + } + defer rows.Close() + wishes := []Wish{} + for rows.Next() { + var id string + var name string + var claimantId sql.NullString + var claimantName sql.NullString + var sent bool + var creatorId string + var creatorName string + var recipientId string + err = rows.Scan(&id, &name, &claimantId, &claimantName, &sent, &creatorId, &creatorName, &recipientId) + if err != nil { + return nil, fmt.Errorf("Failed to scan a row: %w", err) + } + wish := Wish{ + Id: id, + Name: name, + ClaimantId: claimantId.String, + ClaimantName: claimantName.String, + Sent: sent, + CreatorId: creatorId, + CreatorName: creatorName, + RecipientId: recipientId, + } + wishes = append(wishes, wish) + } + err = rows.Err() + if err != nil { + return nil, fmt.Errorf("Rows returned an error: %w", err) + } + return wishes, nil +} + +// NOTE: This could just be a field on the user... but only if we get this often +func (u *User) WishCount() (int, error) { + stmt := "SELECT COUNT(wish.id) AS wish_count FROM wish WHERE wish.creator_id = ?1 AND wish.recipient_id = ?1" + var wishCount int + err := db.Connection.QueryRow(stmt, u.Id).Scan(&wishCount) + if err != nil { + return 0, err + } + return wishCount, nil +} + +func (s *Session) executeClaims(tx *sql.Tx, claims, unclaims []string) error { + claimStmt := "UPDATE wish SET claimant_id = ? WHERE id = ?" + unclaimStmt := "UPDATE wish SET claimant_id = NULL WHERE id = ?" + for _, id := range claims { + r, err := tx.Exec(claimStmt, s.Id, id) + if err != nil { + return err + } + rE, err := r.RowsAffected() + if err != nil { + return err + } + if rE < 1 { + return fmt.Errorf("Wish claim failed for '%s'", id) + } + } + for _, id := range unclaims { + r, err := tx.Exec(unclaimStmt, id) + if err != nil { + return err + } + rE, err := r.RowsAffected() + if err != nil { + return err + } + if rE < 1 { + return fmt.Errorf("Wish unclaim failed for '%s'", id) + } + } + return nil +} + +// Undertake or abandon wishes made by other users +func (s *Session) ClaimWishes(claims, unclaims []string) error { + if len(claims) < 1 && len(unclaims) < 1 { + return fmt.Errorf("Attempt to claim/unclaim zero wishes") + } + + tx, err := db.Connection.Begin() + if err != nil { + return err + } + + err = s.executeClaims(tx, claims, unclaims) + if err != nil { + rollBackErr := tx.Rollback() + if rollBackErr != nil { + return err + } + return err + } + + err = tx.Commit() + return err +} + +func executeCompletions(tx *sql.Tx, claims []string) error { + claimStmt := "UPDATE wish SET sent = 1 WHERE id = ?" + for _, id := range claims { + r, err := tx.Exec(claimStmt, id) + if err != nil { + return err + } + rE, err := r.RowsAffected() + if err != nil { + return err + } + if rE < 1 { + return fmt.Errorf("Wish completion failed for '%s'", id) + } + } + return nil +} + +// TODO: User ought not be able to interact with wishes outside their group network +func (s *Session) CompleteWishes(claims []string) error { + if len(claims) < 1 { + return fmt.Errorf("Attempt to complete zero wishes") + } + + tx, err := db.Connection.Begin() + if err != nil { + return err + } + + err = executeCompletions(tx, claims) + if err != nil { + rollBackErr := tx.Rollback() + if rollBackErr != nil { + return err + } + return err + } + + err = tx.Commit() + return err +} + +func (u *Session) SuggestWishForUser(otherUserReference string, wishName string) error { + otherUser, err := GetUserByReference(otherUserReference) + if err != nil { + return err + } + stmt := "INSERT INTO wish (name, recipient_id, creator_id) VALUES (?, ?, ?)" + _, err = db.Connection.Exec(stmt, wishName, otherUser.Id, u.Id) + if err != nil { + return err + } + return nil +} + +func (s *Session) RecindWishesForUser(ids ...string) error { + if len(ids) < 1 { + return fmt.Errorf("Attempt to remove zero wishes") + } + + tx, err := db.Connection.Begin() + if err != nil { + return err + } + + err = s.deleteWishes(tx, ids) + if err != nil { + rollBackErr := tx.Rollback() + if rollBackErr != nil { + return err + } + return err + } + + err = tx.Commit() + return err +} diff --git a/core/wish_test.go b/core/wish_test.go new file mode 100644 index 0000000..b7a950c --- /dev/null +++ b/core/wish_test.go @@ -0,0 +1,28 @@ +package lishwist_test + +import ( + "testing" + + "lishwist/core/internal/fixtures" +) + +func TestMakeWish(t *testing.T) { + s := fixtures.Login(t, "thomas", "123") + + if err := s.MakeWish("apple"); err != nil { + t.Fatalf("Failed to make wish 1: %s\n", err) + } + + if err := s.MakeWish(" A car "); err != nil { + t.Fatalf("Failed to make wish 2: %s\n", err) + } + + wishes, err := s.GetWishes() + if err != nil { + t.Fatalf("Failed to get wishes: %s\n", err) + } + + fixtures.AssertEq(t, "Number of wishes", 2, len(wishes)) + fixtures.AssertEq(t, "Wish 1 name", wishes[0].Name, "apple") + fixtures.AssertEq(t, "Wish 2 name", wishes[1].Name, "A car") +} diff --git a/go.work b/go.work index bd31234..7328ab8 100644 --- a/go.work +++ b/go.work @@ -1,5 +1,6 @@ -go 1.23 +go 1.23.3 -toolchain go1.23.3 - -use ./server +use ( + ./core + ./http +) diff --git a/go.work.sum b/go.work.sum index 2c1030c..dbc3473 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,12 +1,16 @@ +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +github.com/ncruces/sort v0.1.5/go.mod h1:obJToO4rYr6VWP0Uw5FYymgYGt3Br4RXcs/JdKaXAPk= +github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +lukechampine.com/adiantum v1.1.1/go.mod h1:LrAYVnTYLnUtE/yMp5bQr0HstAf060YUF8nM0B6+rUw= lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= diff --git a/server/api/login.go b/http/api/login.go similarity index 59% rename from server/api/login.go rename to http/api/login.go index d4d9341..9a8b24c 100644 --- a/server/api/login.go +++ b/http/api/login.go @@ -1,11 +1,10 @@ package api import ( - "lishwist/api/db" - "lishwist/templates" "log" - "golang.org/x/crypto/bcrypt" + lishwist "lishwist/core" + "lishwist/http/templates" ) type LoginProps struct { @@ -55,31 +54,19 @@ func Login(username, password string) *LoginProps { return props } - user, err := db.GetUserByName(username) - if err != nil { - log.Printf("Failed to fetch user: %s\n", err) - props.GeneralError = "Username or password invalid" - return props - } - if user == nil { - log.Printf("User not found by name: %q\n", username) - props.GeneralError = "Username or password invalid" - return props + _, err := lishwist.Login(props.Username.Value, props.Password.Value) + if err == nil { + return nil } - passHash, err := user.GetPassHash() - if err != nil { - log.Println("Failed to get password hash: " + err.Error()) - props.GeneralError = "Something went wrong. Error code: Momo" - return props - } - - err = bcrypt.CompareHashAndPassword(passHash, []byte(password)) - if err != nil { - log.Println("Username or password invalid: " + err.Error()) + switch err.(type) { + case lishwist.ErrorInvalidCredentials: + log.Printf("Invalid credentials: %w\n", err) props.GeneralError = "Username or password invalid" return props + default: + log.Printf("Login error: %w\n", err) + props.GeneralError = "Something went wrong." + return props } - - return nil } diff --git a/server/api/register.go b/http/api/register.go similarity index 51% rename from server/api/register.go rename to http/api/register.go index 1d72fa5..c8e4301 100644 --- a/server/api/register.go +++ b/http/api/register.go @@ -1,12 +1,7 @@ package api import ( - "log" - - "lishwist/api/db" - "lishwist/templates" - - "golang.org/x/crypto/bcrypt" + "lishwist/http/templates" ) type RegisterProps struct { @@ -64,37 +59,37 @@ func NewRegisterProps(usernameVal, passwordVal, confirmPassVal string) *Register } } -func Register(username, newPassword, confirmPassword string) *RegisterProps { - props := NewRegisterProps(username, newPassword, confirmPassword) +// func Register(username, newPassword, confirmPassword string) *RegisterProps { +// props := NewRegisterProps(username, newPassword, confirmPassword) - valid := props.Validate() - props.Password.Value = "" - props.ConfirmPassword.Value = "" - if !valid { - log.Printf("Invalid props: %#v\n", props) - return props - } +// valid := props.Validate() +// props.Password.Value = "" +// props.ConfirmPassword.Value = "" +// if !valid { +// log.Printf("Invalid props: %#v\n", props) +// return props +// } - existingUser, _ := db.GetUserByName(username) - if existingUser != nil { - log.Printf("Username is taken: %q\n", existingUser.NormalName) - props.Username.Error = "Username is taken" - return props - } +// existingUser, _ := db.GetUserByName(username) +// if existingUser != nil { +// log.Printf("Username is taken: %q\n", existingUser.NormalName) +// props.Username.Error = "Username is taken" +// return props +// } - hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost) - if err != nil { - log.Printf("Failed to hash password: %s\n", err) - props.GeneralError = "Something went wrong. Error code: Aang" - return props - } +// hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost) +// if err != nil { +// log.Printf("Failed to hash password: %s\n", err) +// props.GeneralError = "Something went wrong. Error code: Aang" +// return props +// } - _, err = db.CreateUser(username, hashedPasswordBytes) - if err != nil { - log.Printf("Failed to create user: %s\n", err) - props.GeneralError = "Something went wrong. Error code: Ozai" - return props - } +// _, err = db.CreateUser(username, hashedPasswordBytes) +// if err != nil { +// log.Printf("Failed to create user: %s\n", err) +// props.GeneralError = "Something went wrong. Error code: Ozai" +// return props +// } - return nil -} +// return nil +// } diff --git a/server/env/env.go b/http/env/env.go similarity index 100% rename from server/env/env.go rename to http/env/env.go diff --git a/server/go.mod b/http/go.mod similarity index 96% rename from server/go.mod rename to http/go.mod index cebcb93..ca1fb6a 100644 --- a/server/go.mod +++ b/http/go.mod @@ -1,4 +1,4 @@ -module lishwist +module lishwist/http go 1.23 diff --git a/server/go.sum b/http/go.sum similarity index 100% rename from server/go.sum rename to http/go.sum diff --git a/server/hashpword/main.go b/http/hashpword/main.go similarity index 100% rename from server/hashpword/main.go rename to http/hashpword/main.go diff --git a/server/main.go b/http/main.go similarity index 60% rename from server/main.go rename to http/main.go index d77f133..ad53076 100644 --- a/server/main.go +++ b/http/main.go @@ -5,28 +5,24 @@ import ( "log" "net/http" - "lishwist/api" - // TODO: lishwist/api/db ought not to be used outside lishwist/api - "lishwist/api/db" - "lishwist/env" - "lishwist/router" - "lishwist/routing" + lishwist "lishwist/core" + "lishwist/core/session" + "lishwist/http/api" + "lishwist/http/env" + "lishwist/http/router" + "lishwist/http/routing" ) func main() { gob.Register(&api.RegisterProps{}) gob.Register(&api.LoginProps{}) - err := db.Open() + err := lishwist.Init(env.DatabaseFile) if err != nil { - log.Fatalf("Failed to open DB: %s\n", err) - } - err = db.Init() - if err != nil { - log.Fatalf("Failed to init DB: %s\n", err) + log.Fatalf("Failed to init Lishwist: %s\n", err) } - store, err := db.NewSessionStore() + store, err := session.NewStore([]byte(env.SessionSecret)) if err != nil { log.Fatalf("Failed to initialize session store: %s\n", err) } @@ -38,27 +34,29 @@ func main() { r.Public.HandleFunc("GET /", routing.Login) r.Public.HandleFunc("GET /groups/{groupReference}", routing.PublicGroup) - r.Public.HandleFunc("GET /list/{userReference}", routing.PublicWishlist) + r.Public.HandleFunc("GET /lists/{userReference}", routing.PublicWishlist) r.Public.HandleFunc("GET /register", routing.Register) r.Public.HandleFunc("POST /", routing.LoginPost) r.Public.HandleFunc("POST /register", routing.RegisterPost) r.Private.HandleFunc("GET /", routing.NotFound) - r.Private.HandleFunc("GET /groups", routing.ExpectUser(routing.Groups)) - r.Private.HandleFunc("GET /groups/{groupReference}", routing.ExpectUser(routing.Group)) - r.Private.HandleFunc("GET /list/{userReference}", routing.ExpectUser(routing.ForeignWishlist)) - r.Private.HandleFunc("GET /users", routing.ExpectUser(routing.Users)) - r.Private.HandleFunc("GET /users/{userReference}", routing.ExpectUser(routing.User)) - r.Private.HandleFunc("GET /{$}", routing.ExpectUser(routing.Home)) - r.Private.HandleFunc("POST /groups/{groupReference}", routing.ExpectUser(routing.GroupPost)) - r.Private.HandleFunc("POST /list/{userReference}", routing.ExpectUser(routing.ForeignWishlistPost)) + r.Private.HandleFunc("GET /groups", routing.ExpectAppSession(routing.Groups)) + r.Private.HandleFunc("GET /groups/{groupReference}", routing.ExpectAppSession(routing.Group)) + r.Private.HandleFunc("GET /lists/{userReference}", routing.ExpectAppSession(routing.ForeignWishlist)) + r.Private.HandleFunc("GET /users", routing.ExpectAppSession(routing.Users)) + r.Private.HandleFunc("GET /users/{userReference}", routing.ExpectAppSession(routing.User)) + r.Private.HandleFunc("GET /{$}", routing.ExpectAppSession(routing.Home)) + r.Private.HandleFunc("POST /groups/{groupReference}", routing.ExpectAppSession(routing.GroupPost)) + r.Private.HandleFunc("POST /list/{userReference}", routing.ExpectAppSession(routing.ForeignWishlistPost)) r.Private.HandleFunc("POST /logout", routing.LogoutPost) - r.Private.HandleFunc("POST /users/{userReference}", routing.ExpectUser(routing.UserPost)) - r.Private.HandleFunc("POST /{$}", routing.ExpectUser(routing.HomePost)) + r.Private.HandleFunc("POST /users/{userReference}", routing.ExpectAppSession(routing.UserPost)) + r.Private.HandleFunc("POST /{$}", routing.ExpectAppSession(routing.HomePost)) // Deprecated + r.Private.HandleFunc("GET /group/{groupReference}", routing.ExpectAppSession(routing.Group)) + r.Private.HandleFunc("GET /list/{userReference}", routing.ExpectAppSession(routing.ForeignWishlist)) r.Public.HandleFunc("GET /group/{groupReference}", routing.PublicGroup) - r.Private.HandleFunc("GET /group/{groupReference}", routing.ExpectUser(routing.Group)) + r.Public.HandleFunc("GET /list/{userReference}", routing.PublicWishlist) http.Handle("/", r) diff --git a/server/normalize/name.go b/http/normalize/name.go similarity index 100% rename from server/normalize/name.go rename to http/normalize/name.go diff --git a/server/router/router.go b/http/router/router.go similarity index 96% rename from server/router/router.go rename to http/router/router.go index 56049d2..8891f68 100644 --- a/server/router/router.go +++ b/http/router/router.go @@ -1,7 +1,7 @@ package router import ( - "lishwist/rsvp" + "lishwist/http/rsvp" "net/http" "github.com/Teajey/sqlstore" diff --git a/server/routing/context.go b/http/routing/context.go similarity index 55% rename from server/routing/context.go rename to http/routing/context.go index 1d48290..a45b9f1 100644 --- a/server/routing/context.go +++ b/http/routing/context.go @@ -1,12 +1,12 @@ package routing import ( - "lishwist/api/db" - "lishwist/rsvp" + lishwist "lishwist/core" + "lishwist/http/rsvp" "net/http" ) -func ExpectUser(next func(*db.User, http.Header, *rsvp.Request) rsvp.Response) rsvp.HandlerFunc { +func ExpectAppSession(next func(*lishwist.Session, http.Header, *rsvp.Request) rsvp.Response) rsvp.HandlerFunc { return func(w http.Header, r *rsvp.Request) rsvp.Response { session := r.GetSession() username, ok := session.GetValue("username").(string) @@ -14,11 +14,11 @@ func ExpectUser(next func(*db.User, http.Header, *rsvp.Request) rsvp.Response) r return rsvp.Error(http.StatusInternalServerError, "Something went wrong.").Log("Failed to get username from session") } - user, err := db.GetUserByName(username) + appSession, err := lishwist.SessionFromUsername(username) if err != nil { - return rsvp.Error(http.StatusInternalServerError, "Something went wrong.").Log("Failed to get user %q: %s", username, err) + return rsvp.Error(http.StatusInternalServerError, "Something went wrong.").Log("Failed to get session by username %q: %s", username, err) } - return next(user, w, r) + return next(appSession, w, r) } } diff --git a/server/routing/error.go b/http/routing/error.go similarity index 100% rename from server/routing/error.go rename to http/routing/error.go diff --git a/server/routing/foreign_wishlist.go b/http/routing/foreign_wishlist.go similarity index 71% rename from server/routing/foreign_wishlist.go rename to http/routing/foreign_wishlist.go index 0eb0123..9a91c88 100644 --- a/server/routing/foreign_wishlist.go +++ b/http/routing/foreign_wishlist.go @@ -1,8 +1,8 @@ package routing import ( - "lishwist/api/db" - "lishwist/rsvp" + lishwist "lishwist/core" + "lishwist/http/rsvp" "net/http" ) @@ -10,26 +10,26 @@ type foreignWishlistProps struct { CurrentUserId string CurrentUserName string Username string - Gifts []db.Gift + Gifts []lishwist.Wish } -func ForeignWishlist(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { +func ForeignWishlist(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { userReference := r.PathValue("userReference") - if currentUser.Reference == userReference { + if app.User.Reference == userReference { return rsvp.SeeOther("/") } - otherUser, err := db.GetUserByReference(userReference) + otherUser, err := lishwist.GetUserByReference(userReference) if err != nil { return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(").Log("Couldn't get user by reference %q: %s", userReference, err) } if otherUser == nil { return rsvp.Error(http.StatusInternalServerError, "User not found") } - gifts, err := currentUser.GetOtherUserGifts(userReference) + wishes, err := app.GetOthersWishes(userReference) if err != nil { - return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(").Log("%q couldn't get wishes of other user %q: %s", currentUser.Name, otherUser.Name, err) + return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(").Log("%q couldn't get wishes of other user %q: %s", app.User.Name, otherUser.Name, err) } - p := foreignWishlistProps{CurrentUserId: currentUser.Id, CurrentUserName: currentUser.Name, Username: otherUser.Name, Gifts: gifts} + p := foreignWishlistProps{CurrentUserId: app.User.Id, CurrentUserName: app.User.Name, Username: otherUser.Name, Gifts: wishes} return rsvp.Data("foreign_wishlist.gotmpl", p) } @@ -40,14 +40,14 @@ type publicWishlistProps struct { func PublicWishlist(h http.Header, r *rsvp.Request) rsvp.Response { userReference := r.PathValue("userReference") - otherUser, err := db.GetUserByReference(userReference) + otherUser, err := lishwist.GetUserByReference(userReference) if err != nil { return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(").Log("Couldn't get user by reference %q on public wishlist: %s", userReference, err) } if otherUser == nil { return rsvp.Error(http.StatusInternalServerError, "User not found") } - giftCount, err := otherUser.CountGifts() + giftCount, err := otherUser.WishCount() if err != nil { return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(").Log("Couldn't get wishes of user %q on public wishlist: %s", otherUser.Name, err) } diff --git a/server/routing/groups.go b/http/routing/groups.go similarity index 72% rename from server/routing/groups.go rename to http/routing/groups.go index a6db5e5..eefdac3 100644 --- a/server/routing/groups.go +++ b/http/routing/groups.go @@ -4,59 +4,59 @@ import ( "net/http" "slices" - "lishwist/api/db" - "lishwist/rsvp" + lishwist "lishwist/core" + "lishwist/http/rsvp" ) type GroupProps struct { - Group *db.Group + Group *lishwist.Group CurrentUsername string } -func AdminGroup(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { +func AdminGroup(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { reference := r.PathValue("groupReference") - group, err := db.GetGroupByReference(reference) + group, err := app.GetGroupByReference(reference) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Couldn't get group: %s", err) } if group == nil { return rsvp.Error(http.StatusNotFound, "Group not found") } - if !currentUser.IsAdmin { - index := group.MemberIndex(currentUser.Id) + if !app.User.IsAdmin { + index := group.MemberIndex(app.User.Id) group.Members = slices.Delete(group.Members, index, index+1) } p := GroupProps{ Group: group, - CurrentUsername: currentUser.Name, + CurrentUsername: app.User.Name, } return rsvp.Data("group_page.gotmpl", p) } -func Group(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { - if currentUser.IsAdmin { - return AdminGroup(currentUser, h, r) +func Group(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { + if app.User.IsAdmin { + return AdminGroup(app, h, r) } groupReference := r.PathValue("groupReference") - group, err := currentUser.GetGroupByReference(groupReference) + group, err := app.GetGroupByReference(groupReference) if err != nil { return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this group :(").Log("Couldn't get group: %s", err) } if group == nil { return rsvp.Error(http.StatusNotFound, "Group not found. (It might be because you're not a member)") } - index := group.MemberIndex(currentUser.Id) + index := group.MemberIndex(app.User.Id) group.Members = slices.Delete(group.Members, index, index+1) p := GroupProps{ Group: group, - CurrentUsername: currentUser.Name, + CurrentUsername: app.User.Name, } return rsvp.Data("group_page.gotmpl", p) } func PublicGroup(h http.Header, r *rsvp.Request) rsvp.Response { groupReference := r.PathValue("groupReference") - group, err := db.GetGroupByReference(groupReference) + group, err := lishwist.GetGroupByReference(groupReference) if err != nil { return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this group :(").Log("Couldn't get group: %s", err) } @@ -66,13 +66,14 @@ func PublicGroup(h http.Header, r *rsvp.Request) rsvp.Response { return rsvp.Data("public_group_page.gotmpl", p) } -func GroupPost(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { - if !currentUser.IsAdmin { +func GroupPost(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { + admin := app.Admin() + if admin == nil { return NotFound(h, r) } form := r.ParseForm() - var group *db.Group + var group *lishwist.Group reference := r.PathValue("groupReference") name := form.Get("name") @@ -80,13 +81,13 @@ func GroupPost(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Respon removeUsers := form["removeUser"] if name != "" { - createdGroup, err := db.CreateGroup(name, reference) + createdGroup, err := admin.CreateGroup(name, reference) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to create group: %s", err) } group = createdGroup } else { - existingGroup, err := db.GetGroupByReference(reference) + existingGroup, err := lishwist.GetGroupByReference(reference) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to get group: %s", err) } @@ -100,7 +101,7 @@ func GroupPost(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Respon if index == -1 { return rsvp.Error(http.StatusBadRequest, "Group %q does not contain a user with id %s", reference, userId) } - err = group.RemoveUser(userId) + err = admin.RemoveUserFromGroup(userId, group.Id) if err != nil { return rsvp.Error(http.StatusInternalServerError, "On group %q failed to remove user with id %s: %s", reference, userId, err) } @@ -109,14 +110,14 @@ func GroupPost(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Respon } for _, userId := range addUsers { - user, err := db.GetUser(userId) + user, err := admin.GetUser(userId) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Groups exists, but a user with id %s could not be fetched: %s", userId, err) } if user == nil { return rsvp.Error(http.StatusInternalServerError, "Groups exists, but a user with id %s does not exist", userId) } - err = group.AddUser(user.Id) + err = admin.AddUserToGroup(user.Id, group.Id) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Groups exists, but failed to add user with id %s: %s", userId, err) } @@ -126,12 +127,13 @@ func GroupPost(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Respon return rsvp.Data("", group) } -func Groups(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { - if !currentUser.IsAdmin { +func Groups(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { + admin := app.Admin() + if admin == nil { return NotFound(h, r) } - groups, err := db.GetAllGroups() + groups, err := admin.ListGroups() if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to get groups: %s", err) } diff --git a/server/routing/home.go b/http/routing/home.go similarity index 52% rename from server/routing/home.go rename to http/routing/home.go index 7f05b79..4e4229c 100644 --- a/server/routing/home.go +++ b/http/routing/home.go @@ -3,45 +3,45 @@ package routing import ( "net/http" - "lishwist/api/db" - "lishwist/env" - "lishwist/rsvp" + lishwist "lishwist/core" + "lishwist/http/env" + "lishwist/http/rsvp" ) type HomeProps struct { Username string - Gifts []db.Gift - Todo []db.Gift + Gifts []lishwist.Wish + Todo []lishwist.Wish Reference string HostUrl string - Groups []db.Group + Groups []lishwist.Group } -func Home(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { - gifts, err := currentUser.GetGifts() +func Home(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { + gifts, err := app.GetWishes() if err != nil { return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(").Log("Failed to get gifts: %s", err) } - todo, err := currentUser.GetTodo() + todo, err := app.GetTodo() if err != nil { return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(").Log("Failed to get todo: %s", err) } - groups, err := currentUser.GetGroups() + groups, err := app.GetGroups() if err != nil { return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(").Log("Failed to get groups: %s", err) } - p := HomeProps{Username: currentUser.Name, Gifts: gifts, Todo: todo, Reference: currentUser.Reference, HostUrl: env.HostUrl.String(), Groups: groups} + p := HomeProps{Username: app.User.Name, Gifts: gifts, Todo: todo, Reference: app.User.Reference, HostUrl: env.HostUrl.String(), Groups: groups} return rsvp.Data("home.gotmpl", p) } -func HomePost(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { +func HomePost(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { form := r.ParseForm() switch form.Get("intent") { case "add_idea": - return WishlistAdd(currentUser, h, r) + return WishlistAdd(app, h, r) case "delete_idea": - return WishlistDelete(currentUser, h, r) + return WishlistDelete(app, h, r) default: - return TodoUpdate(currentUser, h, r) + return TodoUpdate(app, h, r) } } diff --git a/server/routing/login.go b/http/routing/login.go similarity index 55% rename from server/routing/login.go rename to http/routing/login.go index dffb9b1..b738b39 100644 --- a/server/routing/login.go +++ b/http/routing/login.go @@ -1,8 +1,9 @@ package routing import ( - "lishwist/api" - "lishwist/rsvp" + lishwist "lishwist/core" + "lishwist/http/api" + "lishwist/http/rsvp" "net/http" ) @@ -37,15 +38,32 @@ func LoginPost(h http.Header, r *rsvp.Request) rsvp.Response { username := form.Get("username") password := form.Get("password") - props := api.Login(username, password) - if props != nil { + props := api.NewLoginProps(username, password) + + valid := props.Validate() + props.Password.Value = "" + if !valid { session.FlashSet(&props) - return rsvp.SeeOther("/").SaveSession(session) + return rsvp.SeeOther("/").SaveSession(session).Log("Invalid props: %#v\n", props) + } + + app, err := lishwist.Login(username, password) + if err != nil { + switch err.(type) { + case lishwist.ErrorInvalidCredentials: + props.GeneralError = "Username or password invalid" + session.FlashSet(&props) + return rsvp.SeeOther("/").SaveSession(session).Log("Invalid credentials: %#v\n", props) + default: + props.GeneralError = "Something went wrong." + session.FlashSet(&props) + return rsvp.SeeOther("/").SaveSession(session).Log("Login error: %s\n", err) + } } session.SetID("") session.SetValue("authorized", true) - session.SetValue("username", username) + session.SetValue("username", app.User.Name) return rsvp.SeeOther(r.URL().Path).SaveSession(session) } diff --git a/server/routing/logout.go b/http/routing/logout.go similarity index 91% rename from server/routing/logout.go rename to http/routing/logout.go index 5b4b19f..dc1bb66 100644 --- a/server/routing/logout.go +++ b/http/routing/logout.go @@ -1,7 +1,7 @@ package routing import ( - "lishwist/rsvp" + "lishwist/http/rsvp" "net/http" ) diff --git a/server/routing/not_found.go b/http/routing/not_found.go similarity index 88% rename from server/routing/not_found.go rename to http/routing/not_found.go index 6b2ed65..43a0efd 100644 --- a/server/routing/not_found.go +++ b/http/routing/not_found.go @@ -3,7 +3,7 @@ package routing import ( "net/http" - "lishwist/rsvp" + "lishwist/http/rsvp" ) func NotFound(h http.Header, r *rsvp.Request) rsvp.Response { diff --git a/server/routing/register.go b/http/routing/register.go similarity index 56% rename from server/routing/register.go rename to http/routing/register.go index c9118b3..b7a8d4c 100644 --- a/server/routing/register.go +++ b/http/routing/register.go @@ -1,8 +1,10 @@ package routing import ( - "lishwist/api" - "lishwist/rsvp" + "errors" + lishwist "lishwist/core" + "lishwist/http/api" + "lishwist/http/rsvp" "net/http" ) @@ -26,18 +28,31 @@ func Register(h http.Header, r *rsvp.Request) rsvp.Response { func RegisterPost(h http.Header, r *rsvp.Request) rsvp.Response { form := r.ParseForm() + s := r.GetSession() username := form.Get("username") newPassword := form.Get("newPassword") confirmPassword := form.Get("confirmPassword") - props := api.Register(username, newPassword, confirmPassword) + props := api.NewRegisterProps(username, newPassword, confirmPassword) - s := r.GetSession() - - if props != nil { + valid := props.Validate() + props.Password.Value = "" + props.ConfirmPassword.Value = "" + if !valid { s.FlashSet(&props) - return rsvp.SeeOther("/register").SaveSession(s) + return rsvp.SeeOther("/").SaveSession(s).Log("Invalid props: %#v\n", props) + } + + _, err := lishwist.Register(username, newPassword) + if err != nil { + if errors.Is(err, lishwist.ErrorUsernameTaken) { + props.Username.Error = "Username is taken" + } else { + props.GeneralError = "Something went wrong." + } + s.FlashSet(&props) + return rsvp.SeeOther("/register").SaveSession(s).Log("Registration failed: %s\n", err) } s.FlashSet(true) diff --git a/server/routing/todo.go b/http/routing/todo.go similarity index 70% rename from server/routing/todo.go rename to http/routing/todo.go index d856a3e..4f6764a 100644 --- a/server/routing/todo.go +++ b/http/routing/todo.go @@ -1,24 +1,24 @@ package routing import ( - "lishwist/api/db" - "lishwist/rsvp" + lishwist "lishwist/core" + "lishwist/http/rsvp" "net/http" ) -func TodoUpdate(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { +func TodoUpdate(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { form := r.ParseForm() switch form.Get("intent") { case "unclaim_todo": unclaims := form["gift"] - err := currentUser.ClaimGifts([]string{}, unclaims) + err := app.ClaimWishes([]string{}, unclaims) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to update claim...").LogError(err) } case "complete_todo": claims := form["gift"] - err := currentUser.CompleteGifts(claims) + err := app.CompleteWishes(claims) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to complete gifts...").LogError(err) } diff --git a/server/routing/users.go b/http/routing/users.go similarity index 62% rename from server/routing/users.go rename to http/routing/users.go index 826f14b..becb2e3 100644 --- a/server/routing/users.go +++ b/http/routing/users.go @@ -1,17 +1,18 @@ package routing import ( - "lishwist/api/db" - "lishwist/rsvp" + lishwist "lishwist/core" + "lishwist/http/rsvp" "net/http" ) -func Users(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { - if !currentUser.IsAdmin { +func Users(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { + admin := app.Admin() + if admin == nil { return NotFound(h, r) } - users, err := db.GetAllUsers() + users, err := admin.ListUsers() if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to get users: %s", err) } @@ -19,14 +20,15 @@ func Users(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { return rsvp.Data("", users) } -func User(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { - if !currentUser.IsAdmin { +func User(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { + admin := app.Admin() + if admin == nil { return NotFound(h, r) } reference := r.PathValue("userReference") - user, err := db.GetUserByReference(reference) + user, err := lishwist.GetUserByReference(reference) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to get user: %s", err) } @@ -37,19 +39,20 @@ func User(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { return rsvp.Data("", user) } -func UserPost(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { - if !currentUser.IsAdmin { +func UserPost(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { + admin := app.Admin() + if admin == nil { return NotFound(h, r) } form := r.ParseForm() reference := r.PathValue("userReference") - if reference == currentUser.Reference { + if reference == app.User.Reference { return rsvp.Error(http.StatusForbidden, "You cannot delete yourself.") } - user, err := db.GetAnyUserByReference(reference) + user, err := lishwist.GetUserByReference(reference) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to get user: %s", err) } @@ -60,7 +63,7 @@ func UserPost(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Respons intent := form.Get("intent") if intent != "" { - err = user.SetLive(intent != "delete") + err = admin.UserSetLive(reference, intent != "delete") if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to delete user: %s", err) } diff --git a/server/routing/wishlist.go b/http/routing/wishlist.go similarity index 70% rename from server/routing/wishlist.go rename to http/routing/wishlist.go index d8b2c4e..dec0cf3 100644 --- a/server/routing/wishlist.go +++ b/http/routing/wishlist.go @@ -1,32 +1,32 @@ package routing import ( - "lishwist/api/db" - "lishwist/rsvp" + lishwist "lishwist/core" + "lishwist/http/rsvp" "net/http" ) -func WishlistAdd(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { +func WishlistAdd(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { form := r.ParseForm() newGiftName := form.Get("gift_name") - err := currentUser.AddGift(newGiftName) + err := app.MakeWish(newGiftName) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to add gift.").LogError(err) } return rsvp.SeeOther("/") } -func WishlistDelete(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { +func WishlistDelete(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { form := r.ParseForm() targets := form["gift"] - err := currentUser.RemoveGifts(targets...) + err := app.RevokeWishes(targets...) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to remove gifts.").LogError(err) } return rsvp.SeeOther("/") } -func ForeignWishlistPost(currentUser *db.User, h http.Header, r *rsvp.Request) rsvp.Response { +func ForeignWishlistPost(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response { form := r.ParseForm() userReference := r.PathValue("userReference") intent := form.Get("intent") @@ -34,22 +34,22 @@ func ForeignWishlistPost(currentUser *db.User, h http.Header, r *rsvp.Request) r case "claim": claims := form["unclaimed"] unclaims := form["claimed"] - err := currentUser.ClaimGifts(claims, unclaims) + err := app.ClaimWishes(claims, unclaims) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to update claim...").LogError(err) } case "complete": claims := form["claimed"] - err := currentUser.CompleteGifts(claims) + err := app.CompleteWishes(claims) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to complete gifts...").LogError(err) } case "add": - giftName := form.Get("gift_name") - if giftName == "" { + wishName := form.Get("gift_name") + if wishName == "" { return rsvp.Error(http.StatusBadRequest, "Gift name not provided") } - err := currentUser.AddGiftToUser(userReference, giftName) + err := app.SuggestWishForUser(userReference, wishName) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to add gift idea to other user...").LogError(err) } @@ -57,7 +57,7 @@ func ForeignWishlistPost(currentUser *db.User, h http.Header, r *rsvp.Request) r claims := form["unclaimed"] unclaims := form["claimed"] gifts := append(claims, unclaims...) - err := currentUser.RemoveGifts(gifts...) + err := app.RecindWishesForUser(gifts...) if err != nil { return rsvp.Error(http.StatusInternalServerError, "Failed to remove gift idea for other user...").LogError(err) } diff --git a/server/rsvp/handler.go b/http/rsvp/handler.go similarity index 100% rename from server/rsvp/handler.go rename to http/rsvp/handler.go diff --git a/server/rsvp/request.go b/http/rsvp/request.go similarity index 100% rename from server/rsvp/request.go rename to http/rsvp/request.go diff --git a/server/rsvp/response.go b/http/rsvp/response.go similarity index 98% rename from server/rsvp/response.go rename to http/rsvp/response.go index c72536f..2c380b5 100644 --- a/server/rsvp/response.go +++ b/http/rsvp/response.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/json" "fmt" - "lishwist/templates" + "lishwist/http/templates" "log" "net/http" "strings" @@ -20,6 +20,10 @@ type Response struct { } func (res *Response) Write(w http.ResponseWriter, r *http.Request) error { + if res.LogMessage != "" { + log.Printf("%s --- %s\n", res.Data, res.LogMessage) + } + if res.Session != nil { err := res.Session.inner.Save(r, w) if err != nil { @@ -44,10 +48,6 @@ func (res *Response) Write(w http.ResponseWriter, r *http.Request) error { bodyBytes := bytes.NewBuffer([]byte{}) accept := r.Header.Get("Accept") - if res.LogMessage != "" { - log.Printf("%s --- %s\n", res.Data, res.LogMessage) - } - if res.Status != 0 { w.WriteHeader(res.Status) } diff --git a/server/rsvp/session.go b/http/rsvp/session.go similarity index 100% rename from server/rsvp/session.go rename to http/rsvp/session.go diff --git a/server/session/session.go b/http/session/session.go similarity index 100% rename from server/session/session.go rename to http/session/session.go diff --git a/server/templates/base.gotmpl b/http/templates/base.gotmpl similarity index 100% rename from server/templates/base.gotmpl rename to http/templates/base.gotmpl diff --git a/server/templates/error_page.gotmpl b/http/templates/error_page.gotmpl similarity index 100% rename from server/templates/error_page.gotmpl rename to http/templates/error_page.gotmpl diff --git a/server/templates/foreign_wishlist.gotmpl b/http/templates/foreign_wishlist.gotmpl similarity index 100% rename from server/templates/foreign_wishlist.gotmpl rename to http/templates/foreign_wishlist.gotmpl diff --git a/server/templates/group_page.gotmpl b/http/templates/group_page.gotmpl similarity index 100% rename from server/templates/group_page.gotmpl rename to http/templates/group_page.gotmpl diff --git a/server/templates/home.gotmpl b/http/templates/home.gotmpl similarity index 100% rename from server/templates/home.gotmpl rename to http/templates/home.gotmpl diff --git a/server/templates/login.gotmpl b/http/templates/login.gotmpl similarity index 100% rename from server/templates/login.gotmpl rename to http/templates/login.gotmpl diff --git a/server/templates/public_foreign_wishlist.gotmpl b/http/templates/public_foreign_wishlist.gotmpl similarity index 100% rename from server/templates/public_foreign_wishlist.gotmpl rename to http/templates/public_foreign_wishlist.gotmpl diff --git a/server/templates/public_group_page.gotmpl b/http/templates/public_group_page.gotmpl similarity index 100% rename from server/templates/public_group_page.gotmpl rename to http/templates/public_group_page.gotmpl diff --git a/server/templates/register.gotmpl b/http/templates/register.gotmpl similarity index 100% rename from server/templates/register.gotmpl rename to http/templates/register.gotmpl diff --git a/server/templates/templates.go b/http/templates/templates.go similarity index 100% rename from server/templates/templates.go rename to http/templates/templates.go diff --git a/server/api/db/db.go b/server/api/db/db.go deleted file mode 100644 index b564228..0000000 --- a/server/api/db/db.go +++ /dev/null @@ -1,62 +0,0 @@ -//go:generate go run gen_init_sql.go - -package db - -import ( - "database/sql" - "fmt" - "lishwist/env" - - "github.com/Teajey/sqlstore" - _ "github.com/glebarez/go-sqlite" -) - -var database *sql.DB - -func Open() error { - db, err := sql.Open("sqlite", env.DatabaseFile) - if err != nil { - return err - } - database = db - return nil -} - -func Init() error { - _, err := database.Exec(initQuery) - if err != nil { - return err - } - return nil -} - -func NewSessionStore() (*sqlstore.Store, error) { - deleteStmt, err := database.Prepare("DELETE FROM session WHERE id = ?;") - if err != nil { - return nil, fmt.Errorf("Failed to prepare delete statement: %w", err) - } - - insertStmt, err := database.Prepare("INSERT INTO session (value) VALUES (?);") - if err != nil { - return nil, fmt.Errorf("Failed to prepare insert statement: %w", err) - } - - selectStmt, err := database.Prepare("SELECT value FROM session WHERE id = ?;") - if err != nil { - return nil, fmt.Errorf("Failed to prepare select statement: %w", err) - } - - updateStmt, err := database.Prepare("UPDATE session SET value = ?2 WHERE id = ?1;") - if err != nil { - return nil, fmt.Errorf("Failed to prepare update statement: %w", err) - } - - s := sqlstore.NewSqlStore(database, sqlstore.Statements{ - Delete: deleteStmt, - Insert: insertStmt, - Select: selectStmt, - Update: updateStmt, - }, []byte(env.SessionSecret)) - - return s, nil -} diff --git a/server/api/db/user.go b/server/api/db/user.go deleted file mode 100644 index 34d2ba8..0000000 --- a/server/api/db/user.go +++ /dev/null @@ -1,419 +0,0 @@ -package db - -import ( - "database/sql" - "fmt" - "lishwist/normalize" - - "github.com/google/uuid" -) - -type User struct { - Id string - NormalName string - Name string - Reference string - IsAdmin bool - IsLive bool -} - -type Gift struct { - Id string - Name string - ClaimantId string `json:",omitempty"` - ClaimantName string `json:",omitempty"` - Sent bool - RecipientId string `json:",omitempty"` - RecipientName string `json:",omitempty"` - RecipientRef string `json:",omitempty"` - CreatorId string `json:",omitempty"` - CreatorName string `json:",omitempty"` -} - -func queryManyUsers(query string, args ...any) ([]User, error) { - rows, err := database.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - users := []User{} - for rows.Next() { - var u User - err = rows.Scan(&u.Id, &u.NormalName, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive) - if err != nil { - return nil, err - } - users = append(users, u) - } - err = rows.Err() - if err != nil { - return nil, err - } - return users, nil -} - -func queryOneUser(query string, args ...any) (*User, error) { - users, err := queryManyUsers(query, args...) - if err != nil { - return nil, err - } - if len(users) < 1 { - return nil, nil - } - return &users[0], nil -} - -func GetAllUsers() ([]User, error) { - stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM user" - return queryManyUsers(stmt) -} - -func GetUser(id string) (*User, error) { - stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE id = ?" - return queryOneUser(stmt, id) -} - -func GetUserByName(username string) (*User, error) { - username = normalize.Name(username) - stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE name = ?" - return queryOneUser(stmt, username) -} - -func GetUserByReference(reference string) (*User, error) { - stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE reference = ?" - return queryOneUser(stmt, reference) -} - -func GetAnyUserByReference(reference string) (*User, error) { - stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM user WHERE reference = ?" - return queryOneUser(stmt, reference) -} - -func (u *User) SetLive(setting bool) error { - query := "UPDATE user SET is_live = ? WHERE reference = ?" - _, err := database.Exec(query, setting, u.Reference) - if err != nil { - return err - } - u.IsLive = setting - return err -} - -func CreateUser(name string, passHash []byte) (*User, error) { - username := normalize.Name(name) - stmt := "INSERT INTO user (name, display_name, reference, password_hash) VALUES (?, ?, ?, ?)" - reference, err := uuid.NewRandom() - if err != nil { - return nil, err - } - result, err := database.Exec(stmt, username, name, reference, passHash) - if err != nil { - return nil, err - } - id, err := result.LastInsertId() - if err != nil { - return nil, err - } - user := User{ - Id: fmt.Sprintf("%d", id), - Name: name, - } - return &user, nil -} - -func (u *User) GetPassHash() ([]byte, error) { - stmt := "SELECT password_hash FROM v_user WHERE id = ?" - var passHash string - err := database.QueryRow(stmt, u.Id).Scan(&passHash) - if err != nil { - return nil, err - } - return []byte(passHash), nil -} - -func (u *User) CountGifts() (int, error) { - stmt := "SELECT COUNT(gift.id) AS gift_count FROM gift WHERE gift.creator_id = ?1 AND gift.recipient_id = ?1" - var giftCount int - err := database.QueryRow(stmt, u.Id).Scan(&giftCount) - if err != nil { - return 0, err - } - return giftCount, nil -} - -func (u *User) GetGifts() ([]Gift, error) { - stmt := "SELECT gift.id, gift.name, gift.sent FROM gift WHERE gift.creator_id = ?1 AND gift.recipient_id = ?1" - rows, err := database.Query(stmt, u.Id) - if err != nil { - return nil, err - } - defer rows.Close() - gifts := []Gift{} - for rows.Next() { - var id string - var name string - var sent bool - err = rows.Scan(&id, &name, &sent) - if err != nil { - return nil, err - } - gift := Gift{ - Id: id, - Name: name, - Sent: sent, - } - gifts = append(gifts, gift) - } - err = rows.Err() - if err != nil { - return nil, err - } - return gifts, nil -} - -func (u *User) GetOtherUserGifts(otherUserReference string) ([]Gift, error) { - otherUser, err := GetUserByReference(otherUserReference) - if err != nil { - return nil, fmt.Errorf("Failed to get other user: %w", err) - } - if otherUser.Id == u.Id { - return nil, fmt.Errorf("Not allowed to view own foreign wishlist") - } - stmt := "SELECT gift.id, gift.name, claimant.id, claimant.name, gift.sent, gift.creator_id, creator.name, gift.recipient_id FROM gift JOIN v_user AS user ON gift.recipient_id = user.id LEFT JOIN v_user AS claimant ON gift.claimant_id = claimant.id LEFT JOIN v_user AS creator ON gift.creator_id = creator.id WHERE user.id = ?" - rows, err := database.Query(stmt, otherUser.Id) - if err != nil { - return nil, fmt.Errorf("Failed to execute query: %w", err) - } - defer rows.Close() - gifts := []Gift{} - for rows.Next() { - var id string - var name string - var claimantId sql.NullString - var claimantName sql.NullString - var sent bool - var creatorId string - var creatorName string - var recipientId string - err = rows.Scan(&id, &name, &claimantId, &claimantName, &sent, &creatorId, &creatorName, &recipientId) - if err != nil { - return nil, fmt.Errorf("Failed to scan row: %w", err) - } - gift := Gift{ - Id: id, - Name: name, - ClaimantId: claimantId.String, - ClaimantName: claimantName.String, - Sent: sent, - CreatorId: creatorId, - CreatorName: creatorName, - RecipientId: recipientId, - } - gifts = append(gifts, gift) - } - err = rows.Err() - if err != nil { - return nil, fmt.Errorf("Rows returned an error: %w", err) - } - return gifts, nil -} - -func (u *User) GetTodo() ([]Gift, error) { - stmt := "SELECT gift.id, gift.name, gift.sent, recipient.name, recipient.reference FROM gift JOIN v_user AS user ON gift.claimant_id = user.id JOIN v_user AS recipient ON gift.recipient_id = recipient.id WHERE user.id = ? ORDER BY gift.sent ASC, gift.name" - rows, err := database.Query(stmt, u.Id) - if err != nil { - return nil, err - } - defer rows.Close() - gifts := []Gift{} - for rows.Next() { - var id string - var name string - var sent bool - var recipientName string - var recipientRef string - _ = rows.Scan(&id, &name, &sent, &recipientName, &recipientRef) - gift := Gift{ - Id: id, - Name: name, - Sent: sent, - RecipientName: recipientName, - RecipientRef: recipientRef, - } - gifts = append(gifts, gift) - } - err = rows.Err() - if err != nil { - return nil, err - } - return gifts, nil -} - -func (u *User) AddGift(name string) error { - stmt := "INSERT INTO gift (name, recipient_id, creator_id) VALUES (?, ?, ?)" - _, err := database.Exec(stmt, name, u.Id, u.Id) - if err != nil { - return err - } - return nil -} - -func (u *User) deleteGifts(tx *sql.Tx, ids []string) error { - stmt := "DELETE FROM gift WHERE gift.creator_id = ? AND gift.id = ?" - for _, id := range ids { - r, err := tx.Exec(stmt, u.Id, id) - if err != nil { - return err - } - rE, err := r.RowsAffected() - if err != nil { - return err - } - if rE < 1 { - return fmt.Errorf("Gift deletion failed for '%s'", id) - } - } - return nil -} - -func (u *User) RemoveGifts(ids ...string) error { - if len(ids) < 1 { - return fmt.Errorf("Attempt to remove zero gifts") - } - - tx, err := database.Begin() - if err != nil { - return err - } - - err = u.deleteGifts(tx, ids) - if err != nil { - rollBackErr := tx.Rollback() - if rollBackErr != nil { - return err - } - return err - } - - err = tx.Commit() - return err -} - -func (u *User) executeClaims(tx *sql.Tx, claims, unclaims []string) error { - claimStmt := "UPDATE gift SET claimant_id = ? WHERE id = ?" - unclaimStmt := "UPDATE gift SET claimant_id = NULL WHERE id = ?" - for _, id := range claims { - r, err := tx.Exec(claimStmt, u.Id, id) - if err != nil { - return err - } - rE, err := r.RowsAffected() - if err != nil { - return err - } - if rE < 1 { - return fmt.Errorf("Gift claim failed for '%s'", id) - } - } - for _, id := range unclaims { - r, err := tx.Exec(unclaimStmt, id) - if err != nil { - return err - } - rE, err := r.RowsAffected() - if err != nil { - return err - } - if rE < 1 { - return fmt.Errorf("Gift unclaim failed for '%s'", id) - } - } - return nil -} - -func (u *User) ClaimGifts(claims, unclaims []string) error { - if len(claims) < 1 && len(unclaims) < 1 { - return fmt.Errorf("Attempt to claim/unclaim zero gifts") - } - - tx, err := database.Begin() - if err != nil { - return err - } - - err = u.executeClaims(tx, claims, unclaims) - if err != nil { - rollBackErr := tx.Rollback() - if rollBackErr != nil { - return err - } - return err - } - - err = tx.Commit() - return err -} - -func (u *User) executeCompletions(tx *sql.Tx, claims []string) error { - claimStmt := "UPDATE gift SET sent = 1 WHERE id = ?" - for _, id := range claims { - r, err := tx.Exec(claimStmt, id) - if err != nil { - return err - } - rE, err := r.RowsAffected() - if err != nil { - return err - } - if rE < 1 { - return fmt.Errorf("Gift completion failed for '%s'", id) - } - } - return nil -} - -func (u *User) CompleteGifts(claims []string) error { - if len(claims) < 1 { - return fmt.Errorf("Attempt to complete zero gifts") - } - - tx, err := database.Begin() - if err != nil { - return err - } - - err = u.executeCompletions(tx, claims) - if err != nil { - rollBackErr := tx.Rollback() - if rollBackErr != nil { - return err - } - return err - } - - err = tx.Commit() - return err -} - -func (u *User) AddGiftToUser(otherUserReference string, giftName string) error { - otherUser, err := GetUserByReference(otherUserReference) - if err != nil { - return err - } - stmt := "INSERT INTO gift (name, recipient_id, creator_id) VALUES (?, ?, ?)" - _, err = database.Exec(stmt, giftName, otherUser.Id, u.Id) - if err != nil { - return err - } - return nil -} - -func (u *User) GetGroups() ([]Group, error) { - stmt := "SELECT [group].id, [group].name, [group].reference FROM [group] JOIN group_member ON group_member.group_id = [group].id JOIN v_user AS user ON user.id = group_member.user_id WHERE user.id = ?" - return queryManyGroups(stmt, u.Id) -} - -func (u *User) GetGroupByReference(reference string) (*Group, error) { - stmt := "SELECT [group].id, [group].name, [group].reference FROM [group] JOIN group_member ON [group].id == group_member.group_id WHERE [group].reference = ? AND group_member.user_id = ?" - return queryOneGroup(stmt, reference, u.Id) -}