hmn/src/migration/seed.go

389 lines
12 KiB
Go
Raw Normal View History

package migration
import (
"context"
"fmt"
"math/rand"
"os"
"os/exec"
2022-05-12 03:24:05 +00:00
"strings"
"time"
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
2022-05-07 19:31:37 +00:00
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/models"
2022-05-07 19:31:37 +00:00
"git.handmade.network/hmn/hmn/src/oops"
2022-05-12 03:24:05 +00:00
"git.handmade.network/hmn/hmn/src/parsing"
"git.handmade.network/hmn/hmn/src/utils"
lorem "github.com/HandmadeNetwork/golorem"
2023-01-02 21:52:41 +00:00
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/tracelog"
)
// 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.
2022-05-12 03:24:05 +00:00
func BareMinimumSeed() *models.Project {
Migrate(LatestVersion())
ctx := context.Background()
conn := db.NewConnWithConfig(config.PostgresConfig{
2023-01-02 21:52:41 +00:00
LogLevel: tracelog.LogLevelWarn,
})
defer conn.Close(ctx)
2022-05-12 03:24:05 +00:00
tx := utils.Must1(conn.Begin(ctx))
defer tx.Rollback(ctx)
fmt.Println("Creating HMN project...")
2022-05-12 04:39:43 +00:00
hmn := seedProject(ctx, tx, seedHMN, nil)
utils.Must(tx.Commit(ctx))
2022-05-12 03:24:05 +00:00
return hmn
}
// Seeds the database with sample data for local dev.
func SampleSeed() {
2022-05-12 03:24:05 +00:00
hmn := BareMinimumSeed()
ctx := context.Background()
conn := db.NewConnWithConfig(config.PostgresConfig{
2023-01-02 21:52:41 +00:00
LogLevel: tracelog.LogLevelWarn,
})
defer conn.Close(ctx)
2022-05-12 03:24:05 +00:00
tx := utils.Must1(conn.Begin(ctx))
defer tx.Rollback(ctx)
fmt.Println("Creating admin user (\"admin\"/\"password\")...")
2022-05-07 19:45:21 +00:00
admin := seedUser(ctx, tx, models.User{Username: "admin", Name: "Admin", Email: "admin@handmade.network", IsStaff: true})
fmt.Println("Creating normal users (all with password \"password\")...")
2022-05-07 19:45:21 +00:00
alice := seedUser(ctx, tx, models.User{Username: "alice", Name: "Alice"})
bob := seedUser(ctx, tx, models.User{Username: "bob", Name: "Bob"})
charlie := seedUser(ctx, tx, models.User{Username: "charlie", Name: "Charlie"})
fmt.Println("Creating a spammer...")
2022-05-07 19:45:21 +00:00
spammer := seedUser(ctx, tx, models.User{
2022-05-07 19:31:37 +00:00
Username: "spam",
Status: models.UserStatusConfirmed,
Name: "Hot singletons in your local area",
Bio: "Howdy, everybody I go by Jarva seesharpe from Bangalore. In this way, assuming you need to partake in a shared global instance with me then, at that poi",
})
users := []*models.User{alice, bob, charlie, spammer}
2022-05-12 03:24:05 +00:00
fmt.Println("Creating starter projects...")
2022-05-12 04:39:43 +00:00
hero := seedProject(ctx, tx, seedHandmadeHero, []*models.User{admin})
fourcoder := seedProject(ctx, tx, seed4coder, []*models.User{bob})
for i := 0; i < 5; i++ {
2022-05-12 03:24:05 +00:00
name := fmt.Sprintf("%s %s", lorem.Word(1, 10), lorem.Word(1, 10))
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
2022-05-12 04:39:43 +00:00
possibleOwners := []*models.User{alice, bob, charlie}
var owners []*models.User
for ownerIdx, owner := range possibleOwners {
mask := (i % ((1 << len(possibleOwners)) - 1)) + 1
if (1<<ownerIdx)&mask != 0 {
owners = append(owners, owner)
}
}
2022-05-12 03:24:05 +00:00
seedProject(ctx, tx, models.Project{
Slug: slug,
Name: name,
Blurb: lorem.Sentence(6, 16),
Description: lorem.Paragraph(3, 5),
Personal: true,
2022-05-12 04:39:43 +00:00
}, owners)
2022-05-12 03:24:05 +00:00
}
2022-05-12 04:39:43 +00:00
// spam project!
seedProject(ctx, tx, models.Project{
Slug: "spam",
Name: "Cheap abstraction enhancers",
Blurb: "Get higher than ever before...up the ladder of abstraction.",
Description: "Tired of boring details like the actual problem assigned to you? The sky's the limit with these abstraction enhancers, guaranteed to sweep away all those pesky details so you can focus on what matters: \"architecture\".",
Personal: true,
}, []*models.User{spammer})
2022-05-12 03:24:05 +00:00
2022-05-07 19:45:21 +00:00
fmt.Println("Creating some forum threads...")
2022-05-07 19:31:37 +00:00
for i := 0; i < 5; i++ {
2022-05-12 03:24:05 +00:00
for _, project := range []*models.Project{hmn, hero, fourcoder} {
thread := seedThread(ctx, tx, project, models.Thread{})
populateThread(ctx, tx, thread, users, rand.Intn(5)+1)
}
2022-05-07 19:31:37 +00:00
}
// spam-only thread
{
2022-05-12 03:24:05 +00:00
thread := seedThread(ctx, tx, hmn, models.Thread{})
2022-05-07 19:45:21 +00:00
populateThread(ctx, tx, thread, []*models.User{spammer}, 1)
}
2022-05-12 03:24:05 +00:00
fmt.Println("Creating news posts...")
2022-05-07 19:45:21 +00:00
{
2022-05-12 03:24:05 +00:00
// Main site news posts
2022-05-07 19:45:21 +00:00
for i := 0; i < 3; i++ {
2022-05-12 03:24:05 +00:00
thread := seedThread(ctx, tx, hmn, models.Thread{Type: models.ThreadTypeProjectBlogPost})
2022-05-07 19:45:21 +00:00
populateThread(ctx, tx, thread, []*models.User{admin, alice, bob, charlie}, rand.Intn(5)+1)
}
2022-05-12 03:24:05 +00:00
// 4coder
for i := 0; i < 5; i++ {
thread := seedThread(ctx, tx, fourcoder, models.Thread{Type: models.ThreadTypeProjectBlogPost})
populateThread(ctx, tx, thread, []*models.User{bob}, 1)
}
2022-05-07 19:31:37 +00:00
}
// Finally, set sequence numbers to things that won't conflict
utils.Must1(tx.Exec(ctx, "SELECT setval('project_id_seq', 100, true);"))
utils.Must(tx.Commit(ctx))
}
func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.User {
2022-05-12 03:24:05 +00:00
user := db.MustQueryOne[models.User](ctx, conn,
`
INSERT INTO hmn_user (
username, password, email,
is_staff,
status,
name, bio, blurb, signature,
darktheme,
showemail,
date_joined, registration_ip, avatar_asset_id
)
VALUES (
$1, $2, $3,
$4,
$5,
$6, $7, $8, $9,
TRUE,
$10,
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
'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,
)
utils.Must(auth.SetPassword(ctx, conn, input.Username, "password"))
return user
}
2022-05-12 03:24:05 +00:00
func seedThread(ctx context.Context, tx pgx.Tx, project *models.Project, input models.Thread) *models.Thread {
2022-05-07 19:31:37 +00:00
input.Type = utils.OrDefault(input.Type, models.ThreadTypeForumPost)
var defaultSubforum *int
if input.Type == models.ThreadTypeForumPost {
2022-05-12 03:24:05 +00:00
defaultSubforum = project.ForumID
2022-05-07 19:31:37 +00:00
}
2022-05-07 19:45:21 +00:00
thread, err := db.QueryOne[models.Thread](ctx, tx,
2022-05-07 19:31:37 +00:00
`
INSERT INTO thread (
title,
type, sticky,
project_id, subforum_id,
first_id, last_id
)
VALUES (
$1,
$2, $3,
$4, $5,
$6, $7
)
RETURNING $columns
`,
utils.OrDefault(input.Title, lorem.Sentence(3, 8)),
utils.OrDefault(input.Type, models.ThreadTypeForumPost), false,
2022-05-12 03:24:05 +00:00
project.ID, utils.OrDefault(input.SubforumID, defaultSubforum),
2022-05-07 19:31:37 +00:00
-1, -1,
)
if err != nil {
panic(oops.New(err, "failed to create thread"))
}
return thread
}
2022-05-07 19:45:21 +00:00
func populateThread(ctx context.Context, tx pgx.Tx, thread *models.Thread, users []*models.User, numPosts int) {
2022-05-07 19:31:37 +00:00
var lastPostId int
for i := 0; i < numPosts; i++ {
user := users[i%len(users)]
var replyId *int
if lastPostId != 0 {
if rand.Intn(10) < 3 {
replyId = &lastPostId
}
}
2022-05-07 19:45:21 +00:00
hmndata.CreateNewPost(ctx, tx, thread.ProjectID, thread.ID, thread.Type, user.ID, replyId, lorem.Paragraph(1, 10), "192.168.2.1")
2022-05-07 19:31:37 +00:00
}
}
2022-05-12 03:24:05 +00:00
var latestProjectId int
2022-05-12 04:39:43 +00:00
func seedProject(ctx context.Context, tx pgx.Tx, input models.Project, owners []*models.User) *models.Project {
2022-05-12 03:24:05 +00:00
project := db.MustQueryOne[models.Project](ctx, tx,
`
INSERT INTO project (
id,
slug, name, blurb,
description, descparsed,
color_1, color_2,
featured, personal, lifecycle, hidden,
forum_enabled, blog_enabled,
date_created
)
VALUES (
$1,
$2, $3, $4,
$5, $6,
$7, $8,
$9, $10, $11, $12,
$13, $14,
$15
)
RETURNING $columns
`,
utils.OrDefault(input.ID, latestProjectId+1),
input.Slug, input.Name, input.Blurb,
input.Description, parsing.ParseMarkdown(input.Description, parsing.ForumRealMarkdown),
input.Color1, input.Color2,
input.Featured, input.Personal, utils.OrDefault(input.Lifecycle, models.ProjectLifecycleActive), input.Hidden,
input.ForumEnabled, input.BlogEnabled,
utils.OrDefault(input.DateCreated, time.Now()),
)
latestProjectId = utils.IntMax(latestProjectId, project.ID)
// Create forum (even if unused)
forum := db.MustQueryOne[models.Subforum](ctx, tx,
`
INSERT INTO subforum (
slug, name,
project_id
)
VALUES (
$1, $2,
$3
)
RETURNING $columns
`,
"", project.Name,
project.ID,
)
// Associate forum with project
utils.Must1(tx.Exec(ctx,
`UPDATE project SET forum_id = $1 WHERE id = $2`,
forum.ID, project.ID,
))
project.ForumID = &forum.ID
2022-05-12 04:39:43 +00:00
// Add project owners
for _, owner := range owners {
utils.Must1(tx.Exec(ctx,
`INSERT INTO user_project (user_id, project_id) VALUES ($1, $2)`,
owner.ID, project.ID,
))
}
2022-05-12 03:24:05 +00:00
return project
}
func randomName() string {
return "John Doe" // chosen by fair dice roll. guaranteed to be random.
}
func randomBool() bool {
return rand.Intn(2) == 1
}
2022-05-12 03:24:05 +00:00
var seedHMN = models.Project{
ID: models.HMNProjectID,
Slug: models.HMNProjectSlug,
Name: "Handmade Network",
Blurb: "Changing the way software is written",
Description: `
[project=hero]Originally inspired by Handmade Hero[/project], we're an offshoot of its community, hoping to change the way software is written. To this end we've circulated our [url=https://handmade.network/manifesto]manifesto[/url] and built this website, in the hopes of fostering this community. We invite others to host projects built with same goals in mind and build up or expand their community's reach in our little tree house and hope it proves vibrant soil for the exchange of ideas as well as code.
`,
Color1: "ab4c47", Color2: "a5467d",
Hidden: true,
ForumEnabled: true, BlogEnabled: true,
DateCreated: time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC),
}
var seedHandmadeHero = models.Project{
Slug: "hero",
Name: "Handmade Hero",
Blurb: "An ongoing project to create a complete, professional-quality game accompanied by videos that explain every single line of its source code.",
Description: `
Handmade Hero is an ongoing project by [Casey Muratori](http://mollyrocket.com/casey) to create a complete, professional-quality game accompanied by videos that explain every single line of its source code. The series began on November 17th, 2014, and is estimated to run for at least 600 episodes. Programming sessions are limited to one hour per weekday so it remains manageable for people who practice coding along with the series at home.
For more information, see the official website at https://handmadehero.org
`,
Color1: "19328a", Color2: "f1f0a2",
Featured: true,
ForumEnabled: true, BlogEnabled: false,
DateCreated: time.Date(2017, 1, 10, 0, 0, 0, 0, time.UTC),
}
var seed4coder = models.Project{
Slug: "4coder",
Name: "4coder",
Blurb: "A programmable, cross platform, IDE template",
Description: `
4coder preview video: https://www.youtube.com/watch?v=Nop5UW2kV3I
4coder differentiates from other editors by focusing on powerful C/C++ customization and extension, and ease of cross platform use. This means that 4coder greatly reduces the cost of creating cross platform development tools such as debuggers, code intelligence systems. It means that tools specialized to your particular needs can be programmed in C/C++ or any language that interfaces with C/C++, which is almost all of them.
In other words, 4coder is attempting to live in a space between an IDE and a power editor such as Emacs or Vim.
Want to try it out? [url=https://4coder.itch.io/4coder]Get your alpha build now[/url]!
`,
Color1: "002107", Color2: "cccccc",
ForumEnabled: true, BlogEnabled: true,
DateCreated: time.Date(2017, 1, 10, 0, 0, 0, 0, time.UTC),
}