2026-03-29 12:19:08 +02:00
|
|
|
// seed.go is a standalone tool that populates an OwnCord database with
|
|
|
|
|
// development data (users, channels, messages, DMs). It is idempotent:
|
|
|
|
|
// running it multiple times against the same database is safe.
|
|
|
|
|
//
|
|
|
|
|
// Usage:
|
|
|
|
|
//
|
|
|
|
|
// go run scripts/seed.go # uses ./data/chatserver.db
|
|
|
|
|
// go run scripts/seed.go -db path/to/owncord.db # custom path
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-23 17:03:52 +02:00
|
|
|
"context"
|
2026-03-29 12:19:08 +02:00
|
|
|
"flag"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
"os"
|
|
|
|
|
|
|
|
|
|
"github.com/owncord/server/auth"
|
|
|
|
|
"github.com/owncord/server/db"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ─── Seed data definitions ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// seedUser defines a user to create during seeding.
|
|
|
|
|
type seedUser struct {
|
|
|
|
|
Username string
|
|
|
|
|
Password string
|
|
|
|
|
RoleID int // 1=Owner, 2=Admin, 3=Moderator, 4=Member
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seedChannel defines a channel to create during seeding.
|
|
|
|
|
type seedChannel struct {
|
|
|
|
|
Name string
|
|
|
|
|
Type string // "text" or "voice"
|
|
|
|
|
Category string
|
|
|
|
|
Topic string
|
|
|
|
|
Position int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seedMessage defines a message to insert during seeding.
|
|
|
|
|
// ChannelIdx and UserIdx refer to zero-based indices into the channels and
|
|
|
|
|
// users slices (resolved after creation).
|
|
|
|
|
type seedMessage struct {
|
|
|
|
|
ChannelIdx int
|
|
|
|
|
UserIdx int
|
|
|
|
|
Content string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var seedUsers = []seedUser{
|
|
|
|
|
{Username: "admin", Password: "admin123", RoleID: 1},
|
|
|
|
|
{Username: "alice", Password: "password123", RoleID: 4},
|
|
|
|
|
{Username: "bob", Password: "password123", RoleID: 4},
|
|
|
|
|
{Username: "charlie", Password: "password123", RoleID: 4},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var seedChannels = []seedChannel{
|
|
|
|
|
{Name: "general", Type: "text", Category: "Text Channels", Topic: "General chat", Position: 0},
|
|
|
|
|
{Name: "random", Type: "text", Category: "Text Channels", Topic: "Off-topic discussion", Position: 1},
|
|
|
|
|
{Name: "gaming", Type: "text", Category: "Text Channels", Topic: "Gaming talk", Position: 2},
|
|
|
|
|
{Name: "Voice Lounge", Type: "voice", Category: "Voice Channels", Position: 3},
|
|
|
|
|
{Name: "Gaming Voice", Type: "voice", Category: "Voice Channels", Position: 4},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Channel indices for readability.
|
|
|
|
|
const (
|
|
|
|
|
chGeneral = 0
|
|
|
|
|
chRandom = 1
|
|
|
|
|
chGaming = 2
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// User indices for readability.
|
|
|
|
|
const (
|
|
|
|
|
uAdmin = 0
|
|
|
|
|
uAlice = 1
|
|
|
|
|
uBob = 2
|
|
|
|
|
uCharlie = 3
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var seedMessages = []seedMessage{
|
|
|
|
|
// #general
|
|
|
|
|
{chGeneral, uAdmin, "Welcome to OwnCord! This is the general channel."},
|
|
|
|
|
{chGeneral, uAlice, "Hey everyone! Glad to be here."},
|
|
|
|
|
{chGeneral, uBob, "Hello! This looks great."},
|
|
|
|
|
{chGeneral, uCharlie, "Hi all, what's everyone up to?"},
|
|
|
|
|
{chGeneral, uAdmin, "Feel free to chat about anything here."},
|
|
|
|
|
{chGeneral, uAlice, "Anyone tried the voice chat yet?"},
|
|
|
|
|
{chGeneral, uBob, "Not yet, but I'm about to!"},
|
|
|
|
|
{chGeneral, uCharlie, "The UI looks really clean."},
|
|
|
|
|
{chGeneral, uAdmin, "Thanks! We've been working hard on it."},
|
|
|
|
|
{chGeneral, uAlice, "Can we customize themes?"},
|
|
|
|
|
{chGeneral, uAdmin, "Yes! Check the settings panel."},
|
|
|
|
|
|
|
|
|
|
// #random
|
|
|
|
|
{chRandom, uBob, "Random thought: pineapple on pizza is underrated."},
|
|
|
|
|
{chRandom, uCharlie, "Hard disagree, but I respect your opinion."},
|
|
|
|
|
{chRandom, uAlice, "Let's not start a war here lol"},
|
|
|
|
|
{chRandom, uBob, "Too late, the war has begun!"},
|
|
|
|
|
{chRandom, uAdmin, "Keep it friendly, folks!"},
|
|
|
|
|
{chRandom, uCharlie, "Anyone watching any good shows lately?"},
|
|
|
|
|
{chRandom, uAlice, "I just finished a great series, highly recommend it."},
|
|
|
|
|
{chRandom, uBob, "What series?"},
|
|
|
|
|
{chRandom, uAlice, "I'll share the link later!"},
|
|
|
|
|
|
|
|
|
|
// #gaming
|
|
|
|
|
{chGaming, uCharlie, "Anyone up for some co-op tonight?"},
|
|
|
|
|
{chGaming, uBob, "I'm down! What game?"},
|
|
|
|
|
{chGaming, uCharlie, "Thinking something chill, maybe Minecraft?"},
|
|
|
|
|
{chGaming, uAlice, "Count me in!"},
|
|
|
|
|
{chGaming, uAdmin, "I might join later if I finish some work."},
|
|
|
|
|
{chGaming, uBob, "No pressure, we'll be on for a while."},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seedDMMessages are messages exchanged in the admin<->alice DM channel.
|
|
|
|
|
var seedDMMessages = []struct {
|
|
|
|
|
FromIdx int // index into seedUsers
|
|
|
|
|
Content string
|
|
|
|
|
}{
|
|
|
|
|
{uAdmin, "Hey Alice, welcome to the server!"},
|
|
|
|
|
{uAlice, "Thanks! Everything looks awesome."},
|
|
|
|
|
{uAdmin, "Let me know if you run into any issues."},
|
|
|
|
|
{uAlice, "Will do! One question: how do I change my avatar?"},
|
|
|
|
|
{uAdmin, "Go to Settings > Account, you can upload one there."},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
|
dbPath := flag.String("db", "data/chatserver.db", "path to the SQLite database file")
|
|
|
|
|
confirmDev := flag.Bool("confirm-dev", false, "confirm this is a development database (required)")
|
|
|
|
|
flag.Parse()
|
|
|
|
|
|
|
|
|
|
if !*confirmDev {
|
|
|
|
|
fmt.Fprintln(os.Stderr, "⚠ This script creates users with weak passwords.")
|
|
|
|
|
fmt.Fprintln(os.Stderr, " Pass -confirm-dev to confirm this is a development database.")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.SetFlags(0) // no timestamp prefix — keep output clean
|
|
|
|
|
|
|
|
|
|
database, err := db.Open(*dbPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("failed to open database at %s: %v", *dbPath, err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 11:38:33 +02:00
|
|
|
exitCode := run(database)
|
|
|
|
|
_ = database.Close()
|
|
|
|
|
os.Exit(exitCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func run(database *db.DB) int {
|
2026-03-29 12:19:08 +02:00
|
|
|
if err := db.Migrate(database); err != nil {
|
2026-04-01 11:38:33 +02:00
|
|
|
log.Printf("failed to run migrations: %v", err)
|
|
|
|
|
return 1
|
2026-03-29 12:19:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
userIDs, err := createUsers(database)
|
|
|
|
|
if err != nil {
|
2026-04-01 11:38:33 +02:00
|
|
|
log.Printf("failed to create users: %v", err)
|
|
|
|
|
return 1
|
2026-03-29 12:19:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
channelIDs, err := createChannels(database)
|
|
|
|
|
if err != nil {
|
2026-04-01 11:38:33 +02:00
|
|
|
log.Printf("failed to create channels: %v", err)
|
|
|
|
|
return 1
|
2026-03-29 12:19:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msgCount, err := createMessages(database, channelIDs, userIDs)
|
|
|
|
|
if err != nil {
|
2026-04-01 11:38:33 +02:00
|
|
|
log.Printf("failed to create messages: %v", err)
|
|
|
|
|
return 1
|
2026-03-29 12:19:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dmMsgCount, err := createDMConversation(database, userIDs)
|
|
|
|
|
if err != nil {
|
2026-04-01 11:38:33 +02:00
|
|
|
log.Printf("failed to create DM conversation: %v", err)
|
|
|
|
|
return 1
|
2026-03-29 12:19:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Println("--- Seed complete ---")
|
|
|
|
|
fmt.Printf(" Users: %d\n", len(userIDs))
|
|
|
|
|
fmt.Printf(" Channels: %d\n", len(channelIDs))
|
|
|
|
|
fmt.Printf(" Messages: %d (channel) + %d (DM) = %d total\n",
|
|
|
|
|
msgCount, dmMsgCount, msgCount+dmMsgCount)
|
2026-04-01 11:38:33 +02:00
|
|
|
return 0
|
2026-03-29 12:19:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── User creation ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func createUsers(database *db.DB) ([]int64, error) {
|
|
|
|
|
ids := make([]int64, len(seedUsers))
|
|
|
|
|
|
|
|
|
|
for i, su := range seedUsers {
|
2026-07-23 17:03:52 +02:00
|
|
|
existing, err := database.GetUserByUsername(context.Background(), su.Username)
|
2026-03-29 12:19:08 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("checking user %q: %w", su.Username, err)
|
|
|
|
|
}
|
|
|
|
|
if existing != nil {
|
|
|
|
|
ids[i] = existing.ID
|
|
|
|
|
fmt.Printf("[skip] user %q already exists (id=%d)\n", su.Username, existing.ID)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hash, err := auth.HashPassword(su.Password)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("hashing password for %q: %w", su.Username, err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
id, err := database.CreateUser(context.Background(), su.Username, hash, su.RoleID)
|
2026-03-29 12:19:08 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("creating user %q: %w", su.Username, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ids[i] = id
|
|
|
|
|
roleName := roleNameFromID(su.RoleID)
|
|
|
|
|
fmt.Printf("[created] user %q (id=%d, role=%s)\n", su.Username, id, roleName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ids, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// roleNameFromID returns a human-readable role name for display purposes.
|
|
|
|
|
func roleNameFromID(roleID int) string {
|
|
|
|
|
switch roleID {
|
|
|
|
|
case 1:
|
|
|
|
|
return "owner"
|
|
|
|
|
case 2:
|
|
|
|
|
return "admin"
|
|
|
|
|
case 3:
|
|
|
|
|
return "moderator"
|
|
|
|
|
case 4:
|
|
|
|
|
return "member"
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Sprintf("role_%d", roleID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Channel creation ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func createChannels(database *db.DB) ([]int64, error) {
|
|
|
|
|
ids := make([]int64, len(seedChannels))
|
|
|
|
|
|
|
|
|
|
// Fetch existing channels once to check for duplicates.
|
2026-07-23 17:03:52 +02:00
|
|
|
existing, err := database.ListChannels(context.Background())
|
2026-03-29 12:19:08 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("listing channels: %w", err)
|
|
|
|
|
}
|
|
|
|
|
existingByName := make(map[string]int64, len(existing))
|
2026-04-01 11:38:33 +02:00
|
|
|
for i := range existing {
|
|
|
|
|
existingByName[existing[i].Name] = existing[i].ID
|
2026-03-29 12:19:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i, sc := range seedChannels {
|
|
|
|
|
if id, found := existingByName[sc.Name]; found {
|
|
|
|
|
ids[i] = id
|
|
|
|
|
fmt.Printf("[skip] channel %q already exists (id=%d)\n", sc.Name, id)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
id, err := database.CreateChannel(context.Background(), sc.Name, sc.Type, sc.Category, sc.Topic, sc.Position)
|
2026-03-29 12:19:08 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("creating channel %q: %w", sc.Name, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ids[i] = id
|
|
|
|
|
fmt.Printf("[created] channel %q (id=%d, type=%s)\n", sc.Name, id, sc.Type)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ids, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Message creation ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func createMessages(database *db.DB, channelIDs, userIDs []int64) (int, error) {
|
|
|
|
|
created := 0
|
|
|
|
|
|
|
|
|
|
for _, sm := range seedMessages {
|
|
|
|
|
channelID := channelIDs[sm.ChannelIdx]
|
|
|
|
|
userID := userIDs[sm.UserIdx]
|
|
|
|
|
|
|
|
|
|
// Check if this exact message already exists (content + user + channel).
|
|
|
|
|
exists, err := messageExists(database, channelID, userID, sm.Content)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("checking message existence: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if exists {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if _, err := database.CreateMessage(context.Background(), channelID, userID, sm.Content, nil); err != nil {
|
2026-03-29 12:19:08 +02:00
|
|
|
return 0, fmt.Errorf("creating message in channel %d: %w", channelID, err)
|
|
|
|
|
}
|
|
|
|
|
created++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if created > 0 {
|
|
|
|
|
fmt.Printf("[created] %d channel messages\n", created)
|
|
|
|
|
} else {
|
|
|
|
|
fmt.Println("[skip] channel messages already seeded")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return created, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// messageExists checks whether a message with the given content from the given
|
|
|
|
|
// user already exists in the channel. Used for idempotency.
|
|
|
|
|
func messageExists(database *db.DB, channelID, userID int64, content string) (bool, error) {
|
|
|
|
|
var count int
|
2026-07-23 17:03:52 +02:00
|
|
|
err := database.QueryRowContext(context.Background(),
|
2026-03-29 12:19:08 +02:00
|
|
|
`SELECT COUNT(*) FROM messages WHERE channel_id = ? AND user_id = ? AND content = ? AND deleted = 0`,
|
|
|
|
|
channelID, userID, content,
|
|
|
|
|
).Scan(&count)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
return count > 0, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── DM conversation ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func createDMConversation(database *db.DB, userIDs []int64) (int, error) {
|
|
|
|
|
adminID := userIDs[uAdmin]
|
|
|
|
|
aliceID := userIDs[uAlice]
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
ch, isNew, err := database.GetOrCreateDMChannel(context.Background(), adminID, aliceID)
|
2026-03-29 12:19:08 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("creating DM channel: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if isNew {
|
|
|
|
|
fmt.Printf("[created] DM channel between admin and alice (id=%d)\n", ch.ID)
|
|
|
|
|
} else {
|
|
|
|
|
fmt.Printf("[skip] DM channel between admin and alice already exists (id=%d)\n", ch.ID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
created := 0
|
|
|
|
|
for _, dm := range seedDMMessages {
|
|
|
|
|
senderID := userIDs[dm.FromIdx]
|
|
|
|
|
|
|
|
|
|
exists, err := messageExists(database, ch.ID, senderID, dm.Content)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("checking DM message existence: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if exists {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if _, err := database.CreateMessage(context.Background(), ch.ID, senderID, dm.Content, nil); err != nil {
|
2026-03-29 12:19:08 +02:00
|
|
|
return 0, fmt.Errorf("creating DM message: %w", err)
|
|
|
|
|
}
|
|
|
|
|
created++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if created > 0 {
|
|
|
|
|
fmt.Printf("[created] %d DM messages\n", created)
|
|
|
|
|
} else {
|
|
|
|
|
fmt.Println("[skip] DM messages already seeded")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return created, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Ensure data directory exists ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
|
// The default DB path is data/chatserver.db. Ensure the data directory
|
|
|
|
|
// exists so db.Open doesn't fail on a fresh checkout.
|
2026-04-01 11:38:33 +02:00
|
|
|
if err := os.MkdirAll("data", 0o750); err != nil {
|
2026-03-29 12:19:08 +02:00
|
|
|
log.Printf("warning: could not create data directory: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|