Seed users (and rework a lot of user access to use new helpers)

This commit is contained in:
Ben Visness 2022-05-07 13:58:00 -05:00
parent b46f5d8637
commit 3a93aa93e9
22 changed files with 445 additions and 202 deletions

1
go.mod
View File

@ -3,6 +3,7 @@ module git.handmade.network/hmn/hmn
go 1.18 go 1.18
require ( require (
github.com/HandmadeNetwork/golorem v0.0.0-20220507185207-414965a3a817
github.com/Masterminds/sprig v2.22.0+incompatible github.com/Masterminds/sprig v2.22.0+incompatible
github.com/alecthomas/chroma v0.9.2 github.com/alecthomas/chroma v0.9.2
github.com/aws/aws-sdk-go-v2 v1.8.1 github.com/aws/aws-sdk-go-v2 v1.8.1

2
go.sum
View File

@ -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/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 h1:z0GlF2OMmy852mrcMVpjZIzEHYCbUweS8RaWRCPfL1g=
github.com/HandmadeNetwork/goldmark v1.4.1-0.20210707024600-f7e596e26b5e/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 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 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=

View File

@ -37,8 +37,8 @@ func addCreateProjectCommand(projectCommand *cobra.Command) {
descParsed := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown) descParsed := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
ctx := context.Background() ctx := context.Background()
conn := db.NewConnPool(1, 1) conn := db.NewConn()
defer conn.Close() defer conn.Close(ctx)
tx, err := conn.Begin(ctx) tx, err := conn.Begin(ctx)
if err != nil { if err != nil {
@ -160,8 +160,8 @@ func addProjectTagCommand(projectCommand *cobra.Command) {
tag, _ := cmd.Flags().GetString("tag") tag, _ := cmd.Flags().GetString("tag")
ctx := context.Background() ctx := context.Background()
conn := db.NewConnPool(1, 1) conn := db.NewConn()
defer conn.Close() defer conn.Close(ctx)
resultTag, err := hmndata.SetProjectTag(ctx, conn, nil, projectID, tag) resultTag, err := hmndata.SetProjectTag(ctx, conn, nil, projectID, tag)
if err != nil { if err != nil {

View File

@ -42,8 +42,8 @@ func init() {
password := args[1] password := args[1]
ctx := context.Background() ctx := context.Background()
conn := db.NewConnPool(1, 1) conn := db.NewConn()
defer conn.Close() defer conn.Close(ctx)
row := conn.QueryRow(ctx, "SELECT id, username FROM hmn_user WHERE lower(username) = lower($1)", username) row := conn.QueryRow(ctx, "SELECT id, username FROM hmn_user WHERE lower(username) = lower($1)", username)
var id int var id int
@ -83,8 +83,8 @@ func init() {
username := args[0] username := args[0]
ctx := context.Background() ctx := context.Background()
conn := db.NewConnPool(1, 1) conn := db.NewConn()
defer conn.Close() defer conn.Close(ctx)
res, err := conn.Exec(ctx, "UPDATE hmn_user SET status = $1 WHERE LOWER(username) = LOWER($2);", models.UserStatusConfirmed, username) res, err := conn.Exec(ctx, "UPDATE hmn_user SET status = $1 WHERE LOWER(username) = LOWER($2);", models.UserStatusConfirmed, username)
if err != nil { if err != nil {
@ -138,8 +138,8 @@ func init() {
} }
ctx := context.Background() ctx := context.Background()
conn := db.NewConnPool(1, 1) conn := db.NewConn()
defer conn.Close() defer conn.Close(ctx)
res, err := conn.Exec(ctx, "UPDATE hmn_user SET status = $1 WHERE LOWER(username) = LOWER($2);", status, username) res, err := conn.Exec(ctx, "UPDATE hmn_user SET status = $1 WHERE LOWER(username) = LOWER($2);", status, username)
if err != nil { if err != nil {
@ -201,8 +201,8 @@ func init() {
projectSlug, _ := cmd.Flags().GetString("project_slug") projectSlug, _ := cmd.Flags().GetString("project_slug")
ctx := context.Background() ctx := context.Background()
conn := db.NewConnPool(1, 1) conn := db.NewConn()
defer conn.Close() defer conn.Close(ctx)
tx, err := conn.Begin(ctx) tx, err := conn.Begin(ctx)
if err != nil { if err != nil {
@ -280,8 +280,8 @@ func init() {
subforumSlug, _ := cmd.Flags().GetString("subforum_slug") subforumSlug, _ := cmd.Flags().GetString("subforum_slug")
ctx := context.Background() ctx := context.Background()
conn := db.NewConnPool(1, 1) conn := db.NewConn()
defer conn.Close() defer conn.Close(ctx)
tx, err := conn.Begin(ctx) tx, err := conn.Begin(ctx)
if err != nil { if err != nil {

View File

@ -190,6 +190,11 @@ func UpdatePassword(ctx context.Context, conn db.ConnOrTx, username string, hp H
return nil 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) { func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error) {
tag, err := conn.Exec(ctx, tag, err := conn.Exec(ctx,
` `

View File

@ -11,6 +11,7 @@ import (
"git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgconn" "github.com/jackc/pgconn"
"github.com/jackc/pgtype" "github.com/jackc/pgtype"
@ -45,7 +46,18 @@ var connInfo = pgtype.NewConnInfo()
// Creates a new connection to the HMN database. // Creates a new connection to the HMN database.
// This connection is not safe for concurrent use. // This connection is not safe for concurrent use.
func NewConn() *pgx.Conn { 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 { if err != nil {
panic(oops.New(err, "failed to connect to database")) 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. // Creates a connection pool for the HMN database.
// The resulting pool is safe for concurrent use. // The resulting pool is safe for concurrent use.
func NewConnPool(minConns, maxConns int32) *pgxpool.Pool { func NewConnPool() *pgxpool.Pool {
cfg, err := pgxpool.ParseConfig(config.Config.Postgres.DSN()) return NewConnPoolWithConfig(config.PostgresConfig{})
}
cfg.MinConns = minConns func NewConnPoolWithConfig(cfg config.PostgresConfig) *pgxpool.Pool {
cfg.MaxConns = maxConns cfg = overrideDefaultConfig(cfg)
cfg.ConnConfig.Logger = zerologadapter.NewLogger(log.Logger)
cfg.ConnConfig.LogLevel = config.Config.Postgres.LogLevel
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 { if err != nil {
panic(oops.New(err, "failed to create database connection pool")) panic(oops.New(err, "failed to create database connection pool"))
} }
@ -71,6 +89,19 @@ func NewConnPool(minConns, maxConns int32) *pgxpool.Pool {
return conn 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. 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.

View File

@ -27,7 +27,7 @@ func init() {
Long: "Scrape the entire history of Discord channels, saving message content (but not creating snippets)", Long: "Scrape the entire history of Discord channels, saving message content (but not creating snippets)",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background() ctx := context.Background()
conn := db.NewConnPool(1, 1) conn := db.NewConnPool()
defer conn.Close() defer conn.Close()
for _, channelID := range args { for _, channelID := range args {
@ -47,8 +47,8 @@ func init() {
os.Exit(1) os.Exit(1)
} }
ctx := context.Background() ctx := context.Background()
conn := db.NewConnPool(1, 1) conn := db.NewConn()
defer conn.Close() defer conn.Close(ctx)
chanID := args[0] chanID := args[0]

View File

@ -96,11 +96,12 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction
FROM FROM
discord_user AS duser discord_user AS duser
JOIN hmn_user ON duser.hmn_user_id = hmn_user.id 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 WHERE
duser.userid = $1 duser.userid = $1
AND hmn_user.status = $2
`, `,
userID, userID,
models.UserStatusApproved,
) )
if err != nil { if err != nil {
if errors.Is(err, db.NotFound) { if errors.Is(err, db.NotFound) {

View File

@ -150,7 +150,7 @@ func FetchProjects(
for i, p := range projectRows { for i, p := range projectRows {
projectIds[i] = p.Project.ID projectIds[i] = p.Project.ID
} }
projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds) projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, currentUser, projectIds)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -316,6 +316,7 @@ type ProjectOwners struct {
func FetchMultipleProjectsOwners( func FetchMultipleProjectsOwners(
ctx context.Context, ctx context.Context,
dbConn db.ConnOrTx, dbConn db.ConnOrTx,
currentUser *models.User,
projectIds []int, projectIds []int,
) ([]ProjectOwners, error) { ) ([]ProjectOwners, error) {
perf := perf.ExtractPerf(ctx) perf := perf.ExtractPerf(ctx)
@ -358,17 +359,9 @@ func FetchMultipleProjectsOwners(
userIds = append(userIds, userProject.UserID) userIds = append(userIds, userProject.UserID)
} }
} }
users, err := db.Query[models.User](ctx, tx, users, err := FetchUsers(ctx, tx, currentUser, UsersQuery{
` UserIDs: userIds,
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,
)
if err != nil { if err != nil {
return nil, oops.New(err, "failed to fetch users for projects") return nil, oops.New(err, "failed to fetch users for projects")
} }
@ -415,13 +408,14 @@ func FetchMultipleProjectsOwners(
func FetchProjectOwners( func FetchProjectOwners(
ctx context.Context, ctx context.Context,
dbConn db.ConnOrTx, dbConn db.ConnOrTx,
currentUser *models.User,
projectId int, projectId int,
) ([]*models.User, error) { ) ([]*models.User, error) {
perf := perf.ExtractPerf(ctx) perf := perf.ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch owners for project") perf.StartBlock("SQL", "Fetch owners for project")
defer perf.EndBlock() defer perf.EndBlock()
projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, []int{projectId}) projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, currentUser, []int{projectId})
if err != nil { if err != nil {
return nil, err return nil, err
} }

154
src/hmndata/user_helper.go Normal file
View File

@ -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 = &currentUser.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.

View File

@ -5,7 +5,6 @@ import (
_ "embed" _ "embed"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@ -15,7 +14,6 @@ import (
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/migration/migrations" "git.handmade.network/hmn/hmn/src/migration/migrations"
"git.handmade.network/hmn/hmn/src/migration/types" "git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/website" "git.handmade.network/hmn/hmn/src/website"
"github.com/jackc/pgconn" "github.com/jackc/pgconn"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
@ -162,7 +160,9 @@ func LatestVersion() types.MigrationVersion {
func Migrate(targetVersion types.MigrationVersion) { func Migrate(targetVersion types.MigrationVersion) {
ctx := context.Background() // In the future, this could actually do something cool. 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) defer conn.Close(ctx)
// create migration table // 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)
}
}

187
src/migration/seed.go Normal file
View File

@ -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
}

View File

@ -36,7 +36,6 @@ type User struct {
Blurb string `db:"blurb"` Blurb string `db:"blurb"`
Signature string `db:"signature"` Signature string `db:"signature"`
AvatarAssetID *uuid.UUID `db:"avatar_asset_id"` AvatarAssetID *uuid.UUID `db:"avatar_asset_id"`
AvatarAsset *Asset `db:"avatar"`
DarkTheme bool `db:"darktheme"` DarkTheme bool `db:"darktheme"`
Timezone string `db:"timezone"` Timezone string `db:"timezone"`
@ -48,6 +47,9 @@ type User struct {
DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"` DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"`
MarkedAllReadAt time.Time `db:"marked_all_read_at"` 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 { func (u *User) BestName() string {

View File

@ -10,6 +10,16 @@ import (
"git.handmade.network/hmn/hmn/src/oops" "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 { func IntMin(a, b int) int {
if a < b { if a < b {
return a return a

View File

@ -256,15 +256,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
return RejectRequest(c, "User id can't be parsed") return RejectRequest(c, "User id can't be parsed")
} }
user, err := db.QueryOne[models.User](c.Context(), c.Conn, user, err := hmndata.FetchUser(c.Context(), c.Conn, c.CurrentUser, userId, hmndata.UsersQuery{})
`
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,
)
if err != nil { if err != nil {
if errors.Is(err, db.NotFound) { if errors.Is(err, db.NotFound) {
return RejectRequest(c, "User not found") return RejectRequest(c, "User not found")

View File

@ -21,10 +21,9 @@ func APICheckUsername(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch user") c.Perf.StartBlock("SQL", "Fetch user")
user, err := db.QueryOne[models.User](c.Context(), c.Conn, user, err := db.QueryOne[models.User](c.Context(), c.Conn,
` `
SELECT $columns{hmn_user} SELECT $columns
FROM FROM
hmn_user hmn_user
LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id
WHERE WHERE
LOWER(hmn_user.username) = LOWER($1) LOWER(hmn_user.username) = LOWER($1)
AND status = ANY ($2) AND status = ANY ($2)

View File

@ -77,10 +77,8 @@ func Login(c *RequestContext) ResponseData {
user, err := db.QueryOne[models.User](c.Context(), c.Conn, user, err := db.QueryOne[models.User](c.Context(), c.Conn,
` `
SELECT $columns{hmn_user} SELECT $columns
FROM FROM hmn_user
hmn_user
LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id
WHERE LOWER(username) = LOWER($1) WHERE LOWER(username) = LOWER($1)
`, `,
username, username,
@ -457,10 +455,8 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
} }
user, err := db.QueryOne[models.User](c.Context(), c.Conn, user, err := db.QueryOne[models.User](c.Context(), c.Conn,
` `
SELECT $columns{hmn_user} SELECT $columns
FROM FROM hmn_user
hmn_user
LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id
WHERE WHERE
LOWER(username) = LOWER($1) LOWER(username) = LOWER($1)
AND LOWER(email) = LOWER($2) AND LOWER(email) = LOWER($2)

View File

@ -77,7 +77,7 @@ func BlogIndex(c *RequestContext) ResponseData {
canCreate := false canCreate := false
if c.CurrentProject.HasBlog() && c.CurrentUser != nil { if c.CurrentProject.HasBlog() && c.CurrentUser != nil {
isProjectOwner := false 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 { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners"))
} }

View File

@ -181,7 +181,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
// There are no further permission checks to do, because permissions are // There are no further permission checks to do, because permissions are
// checked whatever way we fetch the project. // 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 { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err) 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, owners, err := db.Query[models.User](ctx, tx,
` `
SELECT $columns{hmn_user} SELECT $columns
FROM FROM hmn_user
hmn_user
LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id
WHERE LOWER(username) = ANY ($1) WHERE LOWER(username) = ANY ($1)
`, `,
payload.OwnerUsernames, payload.OwnerUsernames,

View File

@ -548,16 +548,9 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User
} }
} }
user, err := db.QueryOne[models.User](c.Context(), c.Conn, user, err := hmndata.FetchUserByUsername(c.Context(), c.Conn, nil, session.Username, hmndata.UsersQuery{
` AnyStatus: true,
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,
)
if err != nil { if err != nil {
if errors.Is(err, db.NotFound) { 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") logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")

View File

@ -52,19 +52,7 @@ func UserProfile(c *RequestContext) ResponseData {
if c.CurrentUser != nil && strings.ToLower(c.CurrentUser.Username) == username { if c.CurrentUser != nil && strings.ToLower(c.CurrentUser.Username) == username {
profileUser = c.CurrentUser profileUser = c.CurrentUser
} else { } else {
c.Perf.StartBlock("SQL", "Fetch user") user, err := hmndata.FetchUserByUsername(c.Context(), c.Conn, c.CurrentUser, username, hmndata.UsersQuery{})
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()
if err != nil { if err != nil {
if errors.Is(err, db.NotFound) { if errors.Is(err, db.NotFound) {
return FourOhFour(c) return FourOhFour(c)

View File

@ -33,7 +33,7 @@ var WebsiteCommand = &cobra.Command{
backgroundJobContext, cancelBackgroundJobs := context.WithCancel(context.Background()) backgroundJobContext, cancelBackgroundJobs := context.WithCancel(context.Background())
longRequestContext, cancelLongRequests := 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) perfCollector := perf.RunPerfCollector(backgroundJobContext)
server := http.Server{ server := http.Server{