feat: db migrations, encrypted passwords
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
This commit is contained in:
parent
84e5feeb81
commit
ece8280358
8 changed files with 490 additions and 65 deletions
|
@ -22,6 +22,12 @@ Go framework to create bots for several platforms.
|
||||||
|
|
||||||
[Go to documentation](./docs)
|
[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
|
## Installation
|
||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
"git.nakama.town/fmartingr/butterrobot/internal/app"
|
"git.nakama.town/fmartingr/butterrobot/internal/app"
|
||||||
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
||||||
|
@ -19,15 +21,26 @@ func main() {
|
||||||
os.Exit(1)
|
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
|
// Initialize and run application
|
||||||
application, err := app.New(cfg, logger)
|
application, err := app.New(cfg, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to initialize application", "error", err)
|
logger.Error("Failed to initialize application", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := application.Run(); err != nil {
|
if err := application.Run(); err != nil {
|
||||||
logger.Error("Application error", "error", err)
|
logger.Error("Application error", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
99
docs/migrations.md
Normal file
99
docs/migrations.md
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
```
|
1
go.mod
1
go.mod
|
@ -4,6 +4,7 @@ go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
modernc.org/sqlite v1.37.0
|
modernc.org/sqlite v1.37.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -16,6 +16,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/migration"
|
||||||
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -505,7 +506,10 @@ func (d *Database) GetUserByID(id int64) (*model.User, error) {
|
||||||
// CreateUser creates a new user
|
// CreateUser creates a new user
|
||||||
func (d *Database) CreateUser(username, password string) (*model.User, error) {
|
func (d *Database) CreateUser(username, password string) (*model.User, error) {
|
||||||
// Hash password
|
// Hash password
|
||||||
hashedPassword := hashPassword(password)
|
hashedPassword, err := hashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Insert user
|
// Insert user
|
||||||
query := `
|
query := `
|
||||||
|
@ -555,9 +559,9 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check password
|
// Check password with bcrypt
|
||||||
hashedPassword := hashPassword(password)
|
err = bcrypt.CompareHashAndPassword([]byte(dbPassword), []byte(password))
|
||||||
if dbPassword != hashedPassword {
|
if err != nil {
|
||||||
return nil, errors.New("invalid credentials")
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -569,73 +573,60 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to hash password
|
// Helper function to hash password
|
||||||
func hashPassword(password string) string {
|
func hashPassword(password string) (string, error) {
|
||||||
// In a real implementation, use a proper password hashing library like bcrypt
|
// Use bcrypt for secure password hashing
|
||||||
// This is a simplified version for demonstration
|
// The cost parameter is the computational cost, higher is more secure but slower
|
||||||
hasher := sha256.New()
|
// Recommended minimum is 12
|
||||||
hasher.Write([]byte(password))
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||||
return hex.EncodeToString(hasher.Sum(nil))
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashedBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize database tables
|
// Initialize database tables
|
||||||
func initDatabase(db *sql.DB) error {
|
func initDatabase(db *sql.DB) error {
|
||||||
// Create channels table
|
// Ensure migration table exists
|
||||||
_, err := db.Exec(`
|
if err := migration.EnsureMigrationTable(db); err != nil {
|
||||||
CREATE TABLE IF NOT EXISTS channels (
|
return fmt.Errorf("failed to create migration table: %w", err)
|
||||||
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
|
// Get applied migrations
|
||||||
_, err = db.Exec(`
|
applied, err := migration.GetAppliedMigrations(db)
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to get applied migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create users table
|
// Get all migration versions
|
||||||
_, err = db.Exec(`
|
allMigrations := make([]int, 0, len(migration.Migrations))
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
for version := range migration.Migrations {
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
allMigrations = append(allMigrations, version)
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
password TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create default admin user if it doesn't exist
|
// Create a map of applied migrations for quick lookup
|
||||||
var count int
|
appliedMap := make(map[int]bool)
|
||||||
err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
for _, version := range applied {
|
||||||
if err != nil {
|
appliedMap[version] = true
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if count == 0 {
|
// Count pending migrations
|
||||||
hashedPassword := hashPassword("admin")
|
pendingCount := 0
|
||||||
_, err = db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", "admin", hashedPassword)
|
for _, version := range allMigrations {
|
||||||
if err != nil {
|
if !appliedMap[version] {
|
||||||
return err
|
pendingCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
211
internal/migration/migration.go
Normal file
211
internal/migration/migration.go
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
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
|
||||||
|
}
|
102
internal/migration/migrations.go
Normal file
102
internal/migration/migrations.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
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
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue