diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 3630566..b24eb15 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -13,4 +13,4 @@ steps: - "/var/run/docker.sock:/var/run/docker.sock" commands: - docker login -u fmartingr -p $GITEA_TOKEN git.nakama.town - - goreleaser release --clean --parallelism=2 + - goreleaser release --clean diff --git a/README.md b/README.md index 214afa6..36ec708 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,6 @@ Go framework to create bots for several platforms. [Go to documentation](./docs) -### Database Management - -ButterRobot includes an automatic database migration system. Migrations are applied automatically when the application starts, ensuring your database schema is always up to date. - -[Learn more about migrations](./docs/migrations.md) - ## Installation ### From Source diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go index 2982a96..5cf57f9 100644 --- a/cmd/butterrobot/main.go +++ b/cmd/butterrobot/main.go @@ -1,10 +1,8 @@ package main import ( - "fmt" "log/slog" "os" - "runtime/debug" "git.nakama.town/fmartingr/butterrobot/internal/app" "git.nakama.town/fmartingr/butterrobot/internal/config" @@ -21,26 +19,15 @@ func main() { os.Exit(1) } - // Handle version command - if len(os.Args) > 1 && os.Args[1] == "version" { - info, ok := debug.ReadBuildInfo() - if ok { - fmt.Printf("ButterRobot version %s\n", info.Main.Version) - } else { - fmt.Println("ButterRobot. Can't determine build information.") - } - return - } - // Initialize and run application application, err := app.New(cfg, logger) if err != nil { logger.Error("Failed to initialize application", "error", err) os.Exit(1) } - + if err := application.Run(); err != nil { logger.Error("Application error", "error", err) os.Exit(1) } -} +} \ No newline at end of file diff --git a/docs/migrations.md b/docs/migrations.md deleted file mode 100644 index 65fcd99..0000000 --- a/docs/migrations.md +++ /dev/null @@ -1,99 +0,0 @@ -# Database Migrations - -ButterRobot uses a simple database migration system to manage database schema changes. This document explains how the migration system works and how to extend it. - -## Automatic Migrations - -Migrations in ButterRobot are applied automatically when the application starts. This ensures your database schema is always up to date without requiring manual intervention. - -The migration system: -1. Checks which migrations have been applied -2. Applies any pending migrations in sequential order -3. Records each successful migration in the `schema_migrations` table - -## Initial State - -The initial migration (version 1) sets up the database with the following: - -- `channels` table for chat platforms -- `channel_plugin` table for plugins associated with channels -- `users` table for admin users with bcrypt password hashing -- Default admin user with username "admin" and password "admin" - -This migration represents the current state of the database schema. It is not backwards compatible with previous versions of ButterRobot. - -## Creating New Migrations - -To add a new migration, follow these steps: - -1. Open `/internal/migration/migrations.go` -2. Add a new migration version in the `init()` function: - -```go -Register(2, "Add example table", migrateAddExampleTableUp, migrateAddExampleTableDown) -``` - -3. Implement the up and down functions for your migration: - -```go -// Migration to add example table - version 2 -func migrateAddExampleTableUp(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS example ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `) - return err -} - -func migrateAddExampleTableDown(db *sql.DB) error { - _, err := db.Exec(`DROP TABLE IF EXISTS example`) - return err -} -``` - -## Migration Guidelines - -1. **Incremental Changes**: Each migration should make a small, focused change to the database schema. -2. **Backward Compatibility**: Ensure migrations are backward compatible with existing code when possible. -3. **Test Thoroughly**: Test both up and down migrations before deploying. -4. **Document Changes**: Add comments explaining the purpose of each migration. -5. **Version Numbers**: Use sequential version numbers for migrations. - -## How Migrations Work - -The migration system tracks applied migrations in a `schema_migrations` table. When you run migrations, the system: - -1. Checks which migrations have been applied -2. Applies any pending migrations in order -3. Records each successful migration in the `schema_migrations` table - -When rolling back, it performs the down migrations in reverse order. - -## In Code Usage - -The application automatically runs pending migrations when starting up. This is done in the `initDatabase` function. - -You can also programmatically work with migrations: - -```go -// Get database instance -database, err := db.New(cfg.DatabasePath) -if err != nil { - // Handle error -} -defer database.Close() - -// Run migrations -if err := database.MigrateUp(); err != nil { - // Handle error -} - -// Check migration status -applied, pending, err := database.MigrationStatus() -if err != nil { - // Handle error -} -``` \ No newline at end of file diff --git a/go.mod b/go.mod index 3f17f0d..ab85fc8 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.24 require ( github.com/gorilla/sessions v1.4.0 - golang.org/x/crypto v0.37.0 modernc.org/sqlite v1.37.0 ) diff --git a/go.sum b/go.sum index f331cb5..248cd40 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= diff --git a/internal/db/db.go b/internal/db/db.go index e1c51e0..e288bb3 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,15 +1,14 @@ package db import ( + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" - "fmt" - "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" - "git.nakama.town/fmartingr/butterrobot/internal/migration" "git.nakama.town/fmartingr/butterrobot/internal/model" ) @@ -506,10 +505,7 @@ func (d *Database) GetUserByID(id int64) (*model.User, error) { // CreateUser creates a new user func (d *Database) CreateUser(username, password string) (*model.User, error) { // Hash password - hashedPassword, err := hashPassword(password) - if err != nil { - return nil, err - } + hashedPassword := hashPassword(password) // Insert user query := ` @@ -559,9 +555,9 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err return nil, err } - // Check password with bcrypt - err = bcrypt.CompareHashAndPassword([]byte(dbPassword), []byte(password)) - if err != nil { + // Check password + hashedPassword := hashPassword(password) + if dbPassword != hashedPassword { return nil, errors.New("invalid credentials") } @@ -573,60 +569,73 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err } // Helper function to hash password -func hashPassword(password string) (string, error) { - // Use bcrypt for secure password hashing - // The cost parameter is the computational cost, higher is more secure but slower - // Recommended minimum is 12 - hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), 12) - if err != nil { - return "", err - } - return string(hashedBytes), nil +func hashPassword(password string) string { + // In a real implementation, use a proper password hashing library like bcrypt + // This is a simplified version for demonstration + hasher := sha256.New() + hasher.Write([]byte(password)) + return hex.EncodeToString(hasher.Sum(nil)) } // Initialize database tables func initDatabase(db *sql.DB) error { - // Ensure migration table exists - if err := migration.EnsureMigrationTable(db); err != nil { - return fmt.Errorf("failed to create migration table: %w", err) - } - - // Get applied migrations - applied, err := migration.GetAppliedMigrations(db) + // Create channels table + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_channel_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 0, + channel_raw TEXT NOT NULL, + UNIQUE(platform, platform_channel_id) + ) + `) if err != nil { - return fmt.Errorf("failed to get applied migrations: %w", err) + return err } - - // Get all migration versions - allMigrations := make([]int, 0, len(migration.Migrations)) - for version := range migration.Migrations { - allMigrations = append(allMigrations, version) + + // Create channel_plugin table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS channel_plugin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + UNIQUE(channel_id, plugin_id), + FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE + ) + `) + if err != nil { + return err } - - // Create a map of applied migrations for quick lookup - appliedMap := make(map[int]bool) - for _, version := range applied { - appliedMap[version] = true + + // Create users table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL + ) + `) + if err != nil { + return err } - - // Count pending migrations - pendingCount := 0 - for _, version := range allMigrations { - if !appliedMap[version] { - pendingCount++ + + // Create default admin user if it doesn't exist + var count int + err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if err != nil { + return err + } + + if count == 0 { + hashedPassword := hashPassword("admin") + _, err = db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", "admin", hashedPassword) + if err != nil { + return err } } - - // Run migrations if needed - if pendingCount > 0 { - fmt.Printf("Running %d pending database migrations...\n", pendingCount) - if err := migration.Migrate(db); err != nil { - return fmt.Errorf("migration failed: %w", err) - } - fmt.Println("Database migrations completed successfully.") - } else { - fmt.Println("Database schema is up to date.") - } - + return nil } diff --git a/internal/migration/migration.go b/internal/migration/migration.go deleted file mode 100644 index 44096f3..0000000 --- a/internal/migration/migration.go +++ /dev/null @@ -1,211 +0,0 @@ -package migration - -import ( - "database/sql" - "fmt" - "sort" - "time" -) - -// Migration represents a database migration -type Migration struct { - Version int - Description string - Up func(db *sql.DB) error - Down func(db *sql.DB) error -} - -// Migrations is a collection of registered migrations -var Migrations = make(map[int]Migration) - -// Register adds a migration to the list of available migrations -func Register(version int, description string, up, down func(db *sql.DB) error) { - if _, exists := Migrations[version]; exists { - panic(fmt.Sprintf("migration version %d already exists", version)) - } - - Migrations[version] = Migration{ - Version: version, - Description: description, - Up: up, - Down: down, - } -} - -// EnsureMigrationTable creates the migration table if it doesn't exist -func EnsureMigrationTable(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TIMESTAMP NOT NULL - ) - `) - return err -} - -// GetAppliedMigrations returns a list of applied migration versions -func GetAppliedMigrations(db *sql.DB) ([]int, error) { - rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version") - if err != nil { - return nil, err - } - defer rows.Close() - - var versions []int - for rows.Next() { - var version int - if err := rows.Scan(&version); err != nil { - return nil, err - } - versions = append(versions, version) - } - - return versions, rows.Err() -} - -// IsApplied checks if a migration version has been applied -func IsApplied(db *sql.DB, version int) (bool, error) { - var count int - err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version).Scan(&count) - if err != nil { - return false, err - } - return count > 0, nil -} - -// MarkAsApplied marks a migration as applied -func MarkAsApplied(db *sql.DB, version int) error { - _, err := db.Exec( - "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", - version, time.Now(), - ) - return err -} - -// RemoveApplied removes a migration from the applied list -func RemoveApplied(db *sql.DB, version int) error { - _, err := db.Exec("DELETE FROM schema_migrations WHERE version = ?", version) - return err -} - -// Migrate runs pending migrations up to the latest version -func Migrate(db *sql.DB) error { - // Ensure migration table exists - if err := EnsureMigrationTable(db); err != nil { - return fmt.Errorf("failed to create migration table: %w", err) - } - - // Get applied migrations - applied, err := GetAppliedMigrations(db) - if err != nil { - return fmt.Errorf("failed to get applied migrations: %w", err) - } - - // Create a map of applied migrations for quick lookup - appliedMap := make(map[int]bool) - for _, version := range applied { - appliedMap[version] = true - } - - // Get all migration versions and sort them - var versions []int - for version := range Migrations { - versions = append(versions, version) - } - sort.Ints(versions) - - // Apply each pending migration - for _, version := range versions { - if !appliedMap[version] { - migration := Migrations[version] - fmt.Printf("Applying migration %d: %s...\n", version, migration.Description) - - // Start transaction for the migration - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("failed to begin transaction for migration %d: %w", version, err) - } - - // Apply the migration - if err := migration.Up(db); err != nil { - tx.Rollback() - return fmt.Errorf("failed to apply migration %d: %w", version, err) - } - - // Mark as applied - if _, err := tx.Exec( - "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", - version, time.Now(), - ); err != nil { - tx.Rollback() - return fmt.Errorf("failed to mark migration %d as applied: %w", version, err) - } - - // Commit the transaction - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit migration %d: %w", version, err) - } - - fmt.Printf("Migration %d applied successfully\n", version) - } - } - - return nil -} - -// MigrateDown rolls back migrations down to the specified version -// If version is -1, it will roll back all migrations -func MigrateDown(db *sql.DB, targetVersion int) error { - // Ensure migration table exists - if err := EnsureMigrationTable(db); err != nil { - return fmt.Errorf("failed to create migration table: %w", err) - } - - // Get applied migrations - applied, err := GetAppliedMigrations(db) - if err != nil { - return fmt.Errorf("failed to get applied migrations: %w", err) - } - - // Sort in descending order to roll back newest first - sort.Sort(sort.Reverse(sort.IntSlice(applied))) - - // Roll back each migration until target version - for _, version := range applied { - if targetVersion == -1 || version > targetVersion { - migration, exists := Migrations[version] - if !exists { - return fmt.Errorf("migration %d is applied but not found in codebase", version) - } - - fmt.Printf("Rolling back migration %d: %s...\n", version, migration.Description) - - // Start transaction for the rollback - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("failed to begin transaction for rollback %d: %w", version, err) - } - - // Apply the down migration - if err := migration.Down(db); err != nil { - tx.Rollback() - return fmt.Errorf("failed to roll back migration %d: %w", version, err) - } - - // Remove from applied list - if _, err := tx.Exec("DELETE FROM schema_migrations WHERE version = ?", version); err != nil { - tx.Rollback() - return fmt.Errorf("failed to remove migration %d from applied list: %w", version, err) - } - - // Commit the transaction - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit rollback %d: %w", version, err) - } - - fmt.Printf("Migration %d rolled back successfully\n", version) - } - } - - return nil -} \ No newline at end of file diff --git a/internal/migration/migrations.go b/internal/migration/migrations.go deleted file mode 100644 index 2852113..0000000 --- a/internal/migration/migrations.go +++ /dev/null @@ -1,102 +0,0 @@ -package migration - -import ( - "database/sql" - "golang.org/x/crypto/bcrypt" -) - -func init() { - // Register migrations - Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown) -} - -// Initial schema creation with bcrypt passwords - version 1 -func migrateInitialSchemaUp(db *sql.DB) error { - // Create channels table - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform TEXT NOT NULL, - platform_channel_id TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT 0, - channel_raw TEXT NOT NULL, - UNIQUE(platform, platform_channel_id) - ) - `) - if err != nil { - return err - } - - // Create channel_plugin table - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS channel_plugin ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL, - plugin_id TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT 0, - config TEXT NOT NULL DEFAULT '{}', - UNIQUE(channel_id, plugin_id), - FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE - ) - `) - if err != nil { - return err - } - - // Create users table with bcrypt passwords - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL - ) - `) - if err != nil { - return err - } - - // Create default admin user with bcrypt password - hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), 12) - if err != nil { - return err - } - - // Check if users table is empty before inserting - var count int - err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) - if err != nil { - return err - } - - if count == 0 { - _, err = db.Exec( - "INSERT INTO users (username, password) VALUES (?, ?)", - "admin", string(hashedPassword), - ) - if err != nil { - return err - } - } - - return nil -} - -func migrateInitialSchemaDown(db *sql.DB) error { - // Drop tables in reverse order of dependencies - _, err := db.Exec(`DROP TABLE IF EXISTS channel_plugin`) - if err != nil { - return err - } - - _, err = db.Exec(`DROP TABLE IF EXISTS channels`) - if err != nil { - return err - } - - _, err = db.Exec(`DROP TABLE IF EXISTS users`) - if err != nil { - return err - } - - return nil -} \ No newline at end of file