diff --git a/go.mod b/go.mod index 23ab94b..3bafdee 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.handmade.network/hmn/hmn go 1.18 require ( + github.com/HandmadeNetwork/golorem v0.0.0-20220507185207-414965a3a817 github.com/Masterminds/sprig v2.22.0+incompatible github.com/alecthomas/chroma v0.9.2 github.com/aws/aws-sdk-go-v2 v1.8.1 diff --git a/go.sum b/go.sum index e99a90f..dded924 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/HandmadeNetwork/bbcode v0.0.0-20210623031351-ec0e2e2e39d9 h1:5WhEr56C github.com/HandmadeNetwork/bbcode v0.0.0-20210623031351-ec0e2e2e39d9/go.mod h1:vMiNHD8absjmnO60Do5KCaJBwdbaiI/AzhMmSipMme4= github.com/HandmadeNetwork/goldmark v1.4.1-0.20210707024600-f7e596e26b5e h1:z0GlF2OMmy852mrcMVpjZIzEHYCbUweS8RaWRCPfL1g= github.com/HandmadeNetwork/goldmark v1.4.1-0.20210707024600-f7e596e26b5e/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/HandmadeNetwork/golorem v0.0.0-20220507185207-414965a3a817 h1:cBqVP/sLiK7DPay7Aac1PRUwu3fCVyL5Wc+xLXzqwkE= +github.com/HandmadeNetwork/golorem v0.0.0-20220507185207-414965a3a817/go.mod h1:doKbGBIdiM1nkEfvAeP5hvUmERah9H6StTVfCverqdE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= diff --git a/src/admintools/adminproject.go b/src/admintools/adminproject.go index fe6a525..3cd2b57 100644 --- a/src/admintools/adminproject.go +++ b/src/admintools/adminproject.go @@ -37,8 +37,8 @@ func addCreateProjectCommand(projectCommand *cobra.Command) { descParsed := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown) ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() + conn := db.NewConn() + defer conn.Close(ctx) tx, err := conn.Begin(ctx) if err != nil { @@ -160,8 +160,8 @@ func addProjectTagCommand(projectCommand *cobra.Command) { tag, _ := cmd.Flags().GetString("tag") ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() + conn := db.NewConn() + defer conn.Close(ctx) resultTag, err := hmndata.SetProjectTag(ctx, conn, nil, projectID, tag) if err != nil { diff --git a/src/admintools/admintools.go b/src/admintools/admintools.go index 16f5362..02f5e48 100644 --- a/src/admintools/admintools.go +++ b/src/admintools/admintools.go @@ -42,8 +42,8 @@ func init() { password := args[1] ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() + conn := db.NewConn() + defer conn.Close(ctx) row := conn.QueryRow(ctx, "SELECT id, username FROM hmn_user WHERE lower(username) = lower($1)", username) var id int @@ -83,8 +83,8 @@ func init() { username := args[0] ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() + conn := db.NewConn() + defer conn.Close(ctx) res, err := conn.Exec(ctx, "UPDATE hmn_user SET status = $1 WHERE LOWER(username) = LOWER($2);", models.UserStatusConfirmed, username) if err != nil { @@ -138,8 +138,8 @@ func init() { } ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() + conn := db.NewConn() + defer conn.Close(ctx) res, err := conn.Exec(ctx, "UPDATE hmn_user SET status = $1 WHERE LOWER(username) = LOWER($2);", status, username) if err != nil { @@ -201,8 +201,8 @@ func init() { projectSlug, _ := cmd.Flags().GetString("project_slug") ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() + conn := db.NewConn() + defer conn.Close(ctx) tx, err := conn.Begin(ctx) if err != nil { @@ -280,8 +280,8 @@ func init() { subforumSlug, _ := cmd.Flags().GetString("subforum_slug") ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() + conn := db.NewConn() + defer conn.Close(ctx) tx, err := conn.Begin(ctx) if err != nil { diff --git a/src/auth/auth.go b/src/auth/auth.go index a38461e..fa9aea9 100644 --- a/src/auth/auth.go +++ b/src/auth/auth.go @@ -190,6 +190,11 @@ func UpdatePassword(ctx context.Context, conn db.ConnOrTx, username string, hp H return nil } +func SetPassword(ctx context.Context, conn db.ConnOrTx, username string, password string) error { + hp := HashPassword(password) + return UpdatePassword(ctx, conn, username, hp) +} + func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error) { tag, err := conn.Exec(ctx, ` diff --git a/src/db/db.go b/src/db/db.go index f89a7b7..5d70d99 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -11,6 +11,7 @@ import ( "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/utils" "github.com/google/uuid" "github.com/jackc/pgconn" "github.com/jackc/pgtype" @@ -45,7 +46,18 @@ var connInfo = pgtype.NewConnInfo() // Creates a new connection to the HMN database. // This connection is not safe for concurrent use. func NewConn() *pgx.Conn { - conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN()) + return NewConnWithConfig(config.PostgresConfig{}) +} + +func NewConnWithConfig(cfg config.PostgresConfig) *pgx.Conn { + cfg = overrideDefaultConfig(cfg) + + pgcfg, err := pgx.ParseConfig(cfg.DSN()) + + pgcfg.Logger = zerologadapter.NewLogger(log.Logger) + pgcfg.LogLevel = cfg.LogLevel + + conn, err := pgx.ConnectConfig(context.Background(), pgcfg) if err != nil { panic(oops.New(err, "failed to connect to database")) } @@ -55,15 +67,21 @@ func NewConn() *pgx.Conn { // Creates a connection pool for the HMN database. // The resulting pool is safe for concurrent use. -func NewConnPool(minConns, maxConns int32) *pgxpool.Pool { - cfg, err := pgxpool.ParseConfig(config.Config.Postgres.DSN()) +func NewConnPool() *pgxpool.Pool { + return NewConnPoolWithConfig(config.PostgresConfig{}) +} - cfg.MinConns = minConns - cfg.MaxConns = maxConns - cfg.ConnConfig.Logger = zerologadapter.NewLogger(log.Logger) - cfg.ConnConfig.LogLevel = config.Config.Postgres.LogLevel +func NewConnPoolWithConfig(cfg config.PostgresConfig) *pgxpool.Pool { + cfg = overrideDefaultConfig(cfg) - conn, err := pgxpool.ConnectConfig(context.Background(), cfg) + pgcfg, err := pgxpool.ParseConfig(cfg.DSN()) + + pgcfg.MinConns = cfg.MinConn + pgcfg.MaxConns = cfg.MaxConn + pgcfg.ConnConfig.Logger = zerologadapter.NewLogger(log.Logger) + pgcfg.ConnConfig.LogLevel = cfg.LogLevel + + conn, err := pgxpool.ConnectConfig(context.Background(), pgcfg) if err != nil { panic(oops.New(err, "failed to create database connection pool")) } @@ -71,6 +89,19 @@ func NewConnPool(minConns, maxConns int32) *pgxpool.Pool { return conn } +func overrideDefaultConfig(cfg config.PostgresConfig) config.PostgresConfig { + return config.PostgresConfig{ + User: utils.OrDefault(cfg.User, config.Config.Postgres.User), + Password: utils.OrDefault(cfg.Password, config.Config.Postgres.Password), + Hostname: utils.OrDefault(cfg.Hostname, config.Config.Postgres.Hostname), + Port: utils.OrDefault(cfg.Port, config.Config.Postgres.Port), + DbName: utils.OrDefault(cfg.DbName, config.Config.Postgres.DbName), + LogLevel: utils.OrDefault(cfg.LogLevel, config.Config.Postgres.LogLevel), + MinConn: utils.OrDefault(cfg.MinConn, config.Config.Postgres.MinConn), + MaxConn: utils.OrDefault(cfg.MaxConn, config.Config.Postgres.MaxConn), + } +} + /* Performs a SQL query and returns a slice of all the result rows. The query is just plain SQL, but make sure to read the package documentation for details. You must explicitly provide the type argument - this is how it knows what Go type to map the results to, and it cannot be inferred. diff --git a/src/discord/cmd/cmd.go b/src/discord/cmd/cmd.go index 566ebf7..b2aed10 100644 --- a/src/discord/cmd/cmd.go +++ b/src/discord/cmd/cmd.go @@ -27,7 +27,7 @@ func init() { Long: "Scrape the entire history of Discord channels, saving message content (but not creating snippets)", Run: func(cmd *cobra.Command, args []string) { ctx := context.Background() - conn := db.NewConnPool(1, 1) + conn := db.NewConnPool() defer conn.Close() for _, channelID := range args { @@ -47,8 +47,8 @@ func init() { os.Exit(1) } ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() + conn := db.NewConn() + defer conn.Close(ctx) chanID := args[0] diff --git a/src/discord/commands.go b/src/discord/commands.go index c911d8f..996a06c 100644 --- a/src/discord/commands.go +++ b/src/discord/commands.go @@ -96,11 +96,12 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction FROM discord_user AS duser JOIN hmn_user ON duser.hmn_user_id = hmn_user.id - LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id WHERE duser.userid = $1 + AND hmn_user.status = $2 `, userID, + models.UserStatusApproved, ) if err != nil { if errors.Is(err, db.NotFound) { diff --git a/src/hmndata/project_helper.go b/src/hmndata/project_helper.go index b9d8839..f7cbd27 100644 --- a/src/hmndata/project_helper.go +++ b/src/hmndata/project_helper.go @@ -150,7 +150,7 @@ func FetchProjects( for i, p := range projectRows { projectIds[i] = p.Project.ID } - projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds) + projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, currentUser, projectIds) if err != nil { return nil, err } @@ -316,6 +316,7 @@ type ProjectOwners struct { func FetchMultipleProjectsOwners( ctx context.Context, dbConn db.ConnOrTx, + currentUser *models.User, projectIds []int, ) ([]ProjectOwners, error) { perf := perf.ExtractPerf(ctx) @@ -358,17 +359,9 @@ func FetchMultipleProjectsOwners( userIds = append(userIds, userProject.UserID) } } - users, err := db.Query[models.User](ctx, tx, - ` - SELECT $columns{hmn_user} - FROM - hmn_user - LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id - WHERE - hmn_user.id = ANY($1) - `, - userIds, - ) + users, err := FetchUsers(ctx, tx, currentUser, UsersQuery{ + UserIDs: userIds, + }) if err != nil { return nil, oops.New(err, "failed to fetch users for projects") } @@ -415,13 +408,14 @@ func FetchMultipleProjectsOwners( func FetchProjectOwners( ctx context.Context, dbConn db.ConnOrTx, + currentUser *models.User, projectId int, ) ([]*models.User, error) { perf := perf.ExtractPerf(ctx) perf.StartBlock("SQL", "Fetch owners for project") defer perf.EndBlock() - projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, []int{projectId}) + projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, currentUser, []int{projectId}) if err != nil { return nil, err } diff --git a/src/hmndata/user_helper.go b/src/hmndata/user_helper.go new file mode 100644 index 0000000..4e8d6cb --- /dev/null +++ b/src/hmndata/user_helper.go @@ -0,0 +1,154 @@ +package hmndata + +import ( + "context" + "strings" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/perf" +) + +type UsersQuery struct { + // Ignored when using FetchUser + UserIDs []int // if empty, all users + Usernames []string // if empty, all users + + // Flags to modify behavior + AnyStatus bool // Bypasses shadowban system +} + +/* +Fetches users and related models from the database according to all the given +query params. For the most correct results, provide as much information as you have +on hand. +*/ +func FetchUsers( + ctx context.Context, + dbConn db.ConnOrTx, + currentUser *models.User, + q UsersQuery, +) ([]*models.User, error) { + perf := perf.ExtractPerf(ctx) + perf.StartBlock("SQL", "Fetch users") + defer perf.EndBlock() + + var currentUserID *int + if currentUser != nil { + currentUserID = ¤tUser.ID + } + + for i := range q.Usernames { + q.Usernames[i] = strings.ToLower(q.Usernames[i]) + } + + type userRow struct { + User models.User `db:"hmn_user"` + AvatarAsset *models.Asset `db:"avatar"` + } + + var qb db.QueryBuilder + qb.Add(` + SELECT $columns + FROM + hmn_user + LEFT JOIN asset AS avatar ON avatar.id = hmn_user.avatar_asset_id + WHERE + TRUE + `) + if len(q.UserIDs) > 0 { + qb.Add(`AND hmn_user.id = ANY($?)`, q.UserIDs) + } + if len(q.Usernames) > 0 { + qb.Add(`AND LOWER(hmn_user.username) = ANY($?)`, q.Usernames) + } + if !q.AnyStatus { + if currentUser == nil { + qb.Add(`AND hmn_user.status = $?`, models.UserStatusApproved) + } else if !currentUser.IsStaff { + qb.Add( + ` + AND ( + hmn_user.status = $? -- user is Approved + OR hmn_user.id = $? -- getting self + ) + `, + models.UserStatusApproved, + currentUserID, + ) + } + } + + userRows, err := db.Query[userRow](ctx, dbConn, qb.String(), qb.Args()...) + if err != nil { + return nil, oops.New(err, "failed to fetch users") + } + + result := make([]*models.User, len(userRows)) + for i, row := range userRows { + user := row.User + user.AvatarAsset = row.AvatarAsset + result[i] = &user + } + + return result, nil +} + +/* +Fetches a single user and related data. A wrapper around FetchUsers. +As with FetchUsers, provide as much information as you know to get the +most correct results. + +Returns db.NotFound if no result is found. +*/ +func FetchUser( + ctx context.Context, + dbConn db.ConnOrTx, + currentUser *models.User, + userID int, + q UsersQuery, +) (*models.User, error) { + q.UserIDs = []int{userID} + + res, err := FetchUsers(ctx, dbConn, currentUser, q) + if err != nil { + return nil, oops.New(err, "failed to fetch user") + } + + if len(res) == 0 { + return nil, db.NotFound + } + + return res[0], nil +} + +/* +Fetches a single user and related data. A wrapper around FetchUsers. +As with FetchUsers, provide as much information as you know to get the +most correct results. + +Returns db.NotFound if no result is found. +*/ +func FetchUserByUsername( + ctx context.Context, + dbConn db.ConnOrTx, + currentUser *models.User, + username string, + q UsersQuery, +) (*models.User, error) { + q.Usernames = []string{username} + + res, err := FetchUsers(ctx, dbConn, currentUser, q) + if err != nil { + return nil, oops.New(err, "failed to fetch user") + } + + if len(res) == 0 { + return nil, db.NotFound + } + + return res[0], nil +} + +// NOTE(ben): Someday we can add CountUsers...I don't have a need for it right now. diff --git a/src/migration/migration.go b/src/migration/migration.go index e7641c5..d07100d 100644 --- a/src/migration/migration.go +++ b/src/migration/migration.go @@ -5,7 +5,6 @@ import ( _ "embed" "fmt" "os" - "os/exec" "path/filepath" "sort" "strings" @@ -15,7 +14,6 @@ import ( "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/migration/migrations" "git.handmade.network/hmn/hmn/src/migration/types" - "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/website" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" @@ -162,7 +160,9 @@ func LatestVersion() types.MigrationVersion { func Migrate(targetVersion types.MigrationVersion) { ctx := context.Background() // In the future, this could actually do something cool. - conn := db.NewConn() + conn := db.NewConnWithConfig(config.PostgresConfig{ + LogLevel: pgx.LogLevelWarn, + }) defer conn.Close(ctx) // create migration table @@ -368,113 +368,3 @@ func ResetDB() { } } } - -// Applies a cloned db to the local db. -// Applies the seed after the migration specified in `afterMigration`. -// NOTE(asaf): The db role specified in the config must have the CREATEDB attribute! `ALTER ROLE hmn WITH CREATEDB;` -func SeedFromFile(seedFile string) { - file, err := os.Open(seedFile) - if err != nil { - panic(fmt.Errorf("couldn't open seed file %s: %w", seedFile, err)) - } - file.Close() - - fmt.Println("Executing seed...") - cmd := exec.Command("pg_restore", - "--single-transaction", - "--dbname", config.Config.Postgres.DSN(), - seedFile, - ) - fmt.Println("Running command:", cmd) - if output, err := cmd.CombinedOutput(); err != nil { - fmt.Print(string(output)) - panic(fmt.Errorf("failed to execute seed: %w", err)) - } - - fmt.Println("Done! You may want to migrate forward from here.") - ListMigrations() -} - -// NOTE(asaf): This will be useful for open-sourcing the website, but is not yet necessary. -// Creates only what's necessary for a fresh deployment with no data -// TODO(opensource) -func BareMinimumSeed() { - Migrate(LatestVersion()) - - ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() - - tx, err := conn.Begin(ctx) - if err != nil { - panic(err) - } - defer tx.Rollback(ctx) - - // Create the HMN project - _, err = tx.Exec(ctx, - ` - INSERT INTO project (id, slug, name, blurb, description, personal, lifecycle, color_1, color_2, forum_enabled, blog_enabled, date_created) - VALUES (1, 'hmn', 'Handmade Network', '', '', FALSE, $1, 'ab4c47', 'a5467d', TRUE, TRUE, '2017-01-01T00:00:00Z') - `, - models.ProjectLifecycleActive, - ) - if err != nil { - panic(err) - } - - // Create the base forum - _, err = tx.Exec(ctx, ` - INSERT INTO subforum (id, slug, name, parent_id, project_id) - VALUES (2, '', 'Handmade Network', null, 1) - `) - if err != nil { - panic(err) - } - - // Associate the forum with the HMN project - _, err = tx.Exec(ctx, ` - UPDATE project SET forum_id = 2 WHERE slug = 'hmn' - `) - if err != nil { - panic(err) - } - - err = tx.Commit(ctx) - if err != nil { - panic(err) - } -} - -// NOTE(asaf): This will be useful for open-sourcing the website, but is not yet necessary. -// Creates enough data for development -// TODO(opensource) -func SampleSeed() { - BareMinimumSeed() - - ctx := context.Background() - conn := db.NewConnPool(1, 1) - defer conn.Close() - - tx, err := conn.Begin(ctx) - if err != nil { - panic(err) - } - defer tx.Rollback(ctx) - - // admin := CreateAdminUser("admin", "12345678") - // user := CreateUser("regular_user", "12345678") - // hmnProject := CreateProject("hmn", "Handmade Network") - // Create category - // Create thread - // Create accepted user project - // Create pending user project - // Create showcase items - // Create codelanguages - // Create library and library resources - - err = tx.Commit(ctx) - if err != nil { - panic(err) - } -} diff --git a/src/migration/seed.go b/src/migration/seed.go new file mode 100644 index 0000000..081c916 --- /dev/null +++ b/src/migration/seed.go @@ -0,0 +1,187 @@ +package migration + +import ( + "context" + "fmt" + "math/rand" + "os" + "os/exec" + + "git.handmade.network/hmn/hmn/src/auth" + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/utils" + lorem "github.com/HandmadeNetwork/golorem" + "github.com/jackc/pgx/v4" +) + +// Applies a cloned db to the local db. +// Applies the seed after the migration specified in `afterMigration`. +// NOTE(asaf): The db role specified in the config must have the CREATEDB attribute! `ALTER ROLE hmn WITH CREATEDB;` +func SeedFromFile(seedFile string) { + file, err := os.Open(seedFile) + if err != nil { + panic(fmt.Errorf("couldn't open seed file %s: %w", seedFile, err)) + } + file.Close() + + fmt.Println("Executing seed...") + cmd := exec.Command("pg_restore", + "--single-transaction", + "--dbname", config.Config.Postgres.DSN(), + seedFile, + ) + fmt.Println("Running command:", cmd) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Print(string(output)) + panic(fmt.Errorf("failed to execute seed: %w", err)) + } + + fmt.Println("Done! You may want to migrate forward from here.") + ListMigrations() +} + +// Creates only what's necessary to get the site running. Not really very useful for +// local dev on its own; sample data makes things a lot better. +func BareMinimumSeed() { + Migrate(LatestVersion()) + + ctx := context.Background() + conn := db.NewConnWithConfig(config.PostgresConfig{ + LogLevel: pgx.LogLevelWarn, + }) + defer conn.Close(ctx) + + tx, err := conn.Begin(ctx) + if err != nil { + panic(err) + } + defer tx.Rollback(ctx) + + fmt.Println("Creating HMN project...") + _, err = tx.Exec(ctx, + ` + INSERT INTO project (id, slug, name, blurb, description, personal, lifecycle, color_1, color_2, forum_enabled, blog_enabled, date_created) + VALUES (1, 'hmn', 'Handmade Network', '', '', FALSE, $1, 'ab4c47', 'a5467d', TRUE, TRUE, '2017-01-01T00:00:00Z') + `, + models.ProjectLifecycleActive, + ) + if err != nil { + panic(err) + } + + fmt.Println("Creating main forum...") + _, err = tx.Exec(ctx, ` + INSERT INTO subforum (id, slug, name, parent_id, project_id) + VALUES (2, '', 'Handmade Network', NULL, 1) + `) + if err != nil { + panic(err) + } + _, err = tx.Exec(ctx, ` + UPDATE project SET forum_id = 2 WHERE slug = 'hmn' + `) + if err != nil { + panic(err) + } + + err = tx.Commit(ctx) + if err != nil { + panic(err) + } +} + +// Seeds the database with sample data for local dev. +func SampleSeed() { + BareMinimumSeed() + + ctx := context.Background() + conn := db.NewConnWithConfig(config.PostgresConfig{ + LogLevel: pgx.LogLevelWarn, + }) + defer conn.Close(ctx) + + tx, err := conn.Begin(ctx) + if err != nil { + panic(err) + } + defer tx.Rollback(ctx) + + fmt.Println("Creating admin user (\"admin\"/\"password\")...") + seedUser(ctx, conn, models.User{Username: "admin", Email: "admin@handmade.network", IsStaff: true}) + + fmt.Println("Creating normal users (all with password \"password\")...") + alice := seedUser(ctx, conn, models.User{Username: "alice", Name: "Alice"}) + bob := seedUser(ctx, conn, models.User{Username: "bob", Name: "Bob"}) + charlie := seedUser(ctx, conn, models.User{Username: "charlie", Name: "Charlie"}) + + fmt.Println("Creating a spammer...") + seedUser(ctx, conn, models.User{Username: "spam", Name: "Hot singletons in your local area", Status: models.UserStatusConfirmed}) + + _ = []*models.User{alice, bob, charlie} + + // admin := CreateAdminUser("admin", "12345678") + // user := CreateUser("regular_user", "12345678") + // hmnProject := CreateProject("hmn", "Handmade Network") + // Create category + // Create thread + // Create accepted user project + // Create pending user project + // Create showcase items + // Create codelanguages + // Create library and library resources + + err = tx.Commit(ctx) + if err != nil { + panic(err) + } +} + +func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.User { + user, err := db.QueryOne[models.User](ctx, conn, + ` + INSERT INTO hmn_user ( + username, password, email, + is_staff, + status, + name, bio, blurb, signature, + darktheme, + showemail, edit_library, + date_joined, registration_ip, avatar_asset_id + ) + VALUES ( + $1, $2, $3, + $4, + $5, + $6, $7, $8, $9, + TRUE, + $10, FALSE, + '2017-01-01T00:00:00Z', '192.168.2.1', null + ) + RETURNING $columns + `, + input.Username, "", utils.OrDefault(input.Email, fmt.Sprintf("%s@example.com", input.Username)), + input.IsStaff, + utils.OrDefault(input.Status, models.UserStatusApproved), + utils.OrDefault(input.Name, randomName()), utils.OrDefault(input.Bio, lorem.Paragraph(0, 2)), utils.OrDefault(input.Blurb, lorem.Sentence(0, 14)), utils.OrDefault(input.Signature, lorem.Sentence(0, 16)), + input.ShowEmail, + ) + if err != nil { + panic(err) + } + err = auth.SetPassword(ctx, conn, input.Username, "password") + if err != nil { + panic(err) + } + + return user +} + +func randomName() string { + return "John Doe" // chosen by fair dice roll. guaranteed to be random. +} + +func randomBool() bool { + return rand.Intn(2) == 1 +} diff --git a/src/models/user.go b/src/models/user.go index cddea44..874fbe6 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -36,7 +36,6 @@ type User struct { Blurb string `db:"blurb"` Signature string `db:"signature"` AvatarAssetID *uuid.UUID `db:"avatar_asset_id"` - AvatarAsset *Asset `db:"avatar"` DarkTheme bool `db:"darktheme"` Timezone string `db:"timezone"` @@ -48,6 +47,9 @@ type User struct { DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"` MarkedAllReadAt time.Time `db:"marked_all_read_at"` + + // Non-db fields, to be filled in by fetch helpers + AvatarAsset *Asset } func (u *User) BestName() string { diff --git a/src/utils/utils.go b/src/utils/utils.go index f739e0c..bff8cfe 100644 --- a/src/utils/utils.go +++ b/src/utils/utils.go @@ -10,6 +10,16 @@ import ( "git.handmade.network/hmn/hmn/src/oops" ) +// Returns the provided value, or a default value if the input was zero. +func OrDefault[T comparable](v T, def T) T { + var zero T + if v == zero { + return def + } else { + return v + } +} + func IntMin(a, b int) int { if a < b { return a diff --git a/src/website/admin.go b/src/website/admin.go index 59354f1..43cfca4 100644 --- a/src/website/admin.go +++ b/src/website/admin.go @@ -256,15 +256,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData { return RejectRequest(c, "User id can't be parsed") } - user, err := db.QueryOne[models.User](c.Context(), c.Conn, - ` - SELECT $columns{hmn_user} - FROM hmn_user - LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id - WHERE hmn_user.id = $1 - `, - userId, - ) + user, err := hmndata.FetchUser(c.Context(), c.Conn, c.CurrentUser, userId, hmndata.UsersQuery{}) if err != nil { if errors.Is(err, db.NotFound) { return RejectRequest(c, "User not found") diff --git a/src/website/api.go b/src/website/api.go index c9289c1..4a8710e 100644 --- a/src/website/api.go +++ b/src/website/api.go @@ -21,10 +21,9 @@ func APICheckUsername(c *RequestContext) ResponseData { c.Perf.StartBlock("SQL", "Fetch user") user, err := db.QueryOne[models.User](c.Context(), c.Conn, ` - SELECT $columns{hmn_user} + SELECT $columns FROM hmn_user - LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id WHERE LOWER(hmn_user.username) = LOWER($1) AND status = ANY ($2) diff --git a/src/website/auth.go b/src/website/auth.go index ed98dfa..6ca8e09 100644 --- a/src/website/auth.go +++ b/src/website/auth.go @@ -77,10 +77,8 @@ func Login(c *RequestContext) ResponseData { user, err := db.QueryOne[models.User](c.Context(), c.Conn, ` - SELECT $columns{hmn_user} - FROM - hmn_user - LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id + SELECT $columns + FROM hmn_user WHERE LOWER(username) = LOWER($1) `, username, @@ -457,10 +455,8 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData { } user, err := db.QueryOne[models.User](c.Context(), c.Conn, ` - SELECT $columns{hmn_user} - FROM - hmn_user - LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id + SELECT $columns + FROM hmn_user WHERE LOWER(username) = LOWER($1) AND LOWER(email) = LOWER($2) diff --git a/src/website/blogs.go b/src/website/blogs.go index 3e92e21..c837b99 100644 --- a/src/website/blogs.go +++ b/src/website/blogs.go @@ -77,7 +77,7 @@ func BlogIndex(c *RequestContext) ResponseData { canCreate := false if c.CurrentProject.HasBlog() && c.CurrentUser != nil { isProjectOwner := false - owners, err := hmndata.FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID) + owners, err := hmndata.FetchProjectOwners(c.Context(), c.Conn, c.CurrentUser, c.CurrentProject.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners")) } diff --git a/src/website/projects.go b/src/website/projects.go index 9d37e9b..175d84d 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -181,7 +181,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { // There are no further permission checks to do, because permissions are // checked whatever way we fetch the project. - owners, err := hmndata.FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID) + owners, err := hmndata.FetchProjectOwners(c.Context(), c.Conn, c.CurrentUser, c.CurrentProject.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -818,10 +818,8 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P owners, err := db.Query[models.User](ctx, tx, ` - SELECT $columns{hmn_user} - FROM - hmn_user - LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id + SELECT $columns + FROM hmn_user WHERE LOWER(username) = ANY ($1) `, payload.OwnerUsernames, diff --git a/src/website/routes.go b/src/website/routes.go index 6bd489c..089dc14 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -548,16 +548,9 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User } } - user, err := db.QueryOne[models.User](c.Context(), c.Conn, - ` - SELECT $columns{hmn_user} - FROM - hmn_user - LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id - WHERE username = $1 - `, - session.Username, - ) + user, err := hmndata.FetchUserByUsername(c.Context(), c.Conn, nil, session.Username, hmndata.UsersQuery{ + AnyStatus: true, + }) if err != nil { if errors.Is(err, db.NotFound) { logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found") diff --git a/src/website/user.go b/src/website/user.go index 7c5df24..681e928 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -52,19 +52,7 @@ func UserProfile(c *RequestContext) ResponseData { if c.CurrentUser != nil && strings.ToLower(c.CurrentUser.Username) == username { profileUser = c.CurrentUser } else { - c.Perf.StartBlock("SQL", "Fetch user") - user, err := db.QueryOne[models.User](c.Context(), c.Conn, - ` - SELECT $columns{hmn_user} - FROM - hmn_user - LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id - WHERE - LOWER(hmn_user.username) = $1 - `, - username, - ) - c.Perf.EndBlock() + user, err := hmndata.FetchUserByUsername(c.Context(), c.Conn, c.CurrentUser, username, hmndata.UsersQuery{}) if err != nil { if errors.Is(err, db.NotFound) { return FourOhFour(c) diff --git a/src/website/website.go b/src/website/website.go index 0303a92..7aec3cd 100644 --- a/src/website/website.go +++ b/src/website/website.go @@ -33,7 +33,7 @@ var WebsiteCommand = &cobra.Command{ backgroundJobContext, cancelBackgroundJobs := context.WithCancel(context.Background()) longRequestContext, cancelLongRequests := context.WithCancel(context.Background()) - conn := db.NewConnPool(config.Config.Postgres.MinConn, config.Config.Postgres.MaxConn) + conn := db.NewConnPool() perfCollector := perf.RunPerfCollector(backgroundJobContext) server := http.Server{