From 84e5feeb81a150cdeef7972335f6c930daa21bae Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:12:03 +0200 Subject: [PATCH 1/7] ci: limit goreleaser to two tasks per release --- .woodpecker/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index b24eb15..3630566 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 + - goreleaser release --clean --parallelism=2 From ece8280358f6a21f973b6cc5e8b7c3caf48f03fa Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:32:10 +0200 Subject: [PATCH 2/7] feat: db migrations, encrypted passwords --- README.md | 6 + cmd/butterrobot/main.go | 17 ++- docs/migrations.md | 99 +++++++++++++++ go.mod | 1 + go.sum | 2 + internal/db/db.go | 117 ++++++++--------- internal/migration/migration.go | 211 +++++++++++++++++++++++++++++++ internal/migration/migrations.go | 102 +++++++++++++++ 8 files changed, 490 insertions(+), 65 deletions(-) create mode 100644 docs/migrations.md create mode 100644 internal/migration/migration.go create mode 100644 internal/migration/migrations.go diff --git a/README.md b/README.md index 36ec708..214afa6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ 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 5cf57f9..2982a96 100644 --- a/cmd/butterrobot/main.go +++ b/cmd/butterrobot/main.go @@ -1,8 +1,10 @@ package main import ( + "fmt" "log/slog" "os" + "runtime/debug" "git.nakama.town/fmartingr/butterrobot/internal/app" "git.nakama.town/fmartingr/butterrobot/internal/config" @@ -19,15 +21,26 @@ 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 new file mode 100644 index 0000000..65fcd99 --- /dev/null +++ b/docs/migrations.md @@ -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 +} +``` \ No newline at end of file diff --git a/go.mod b/go.mod index ab85fc8..3f17f0d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ 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 248cd40..f331cb5 100644 --- a/go.sum +++ b/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/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 e288bb3..e1c51e0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,14 +1,15 @@ 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" ) @@ -505,7 +506,10 @@ 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 := hashPassword(password) + hashedPassword, err := hashPassword(password) + if err != nil { + return nil, err + } // Insert user query := ` @@ -555,9 +559,9 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err return nil, err } - // Check password - hashedPassword := hashPassword(password) - if dbPassword != hashedPassword { + // Check password with bcrypt + err = bcrypt.CompareHashAndPassword([]byte(dbPassword), []byte(password)) + if err != nil { 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 -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)) +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 } // Initialize database tables func initDatabase(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 + // Ensure migration table exists + if err := migration.EnsureMigrationTable(db); err != nil { + return fmt.Errorf("failed to create migration table: %w", 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 - ) - `) + + // Get applied migrations + applied, err := migration.GetAppliedMigrations(db) if err != nil { - return err + return fmt.Errorf("failed to get applied migrations: %w", err) } - - // 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 + + // Get all migration versions + allMigrations := make([]int, 0, len(migration.Migrations)) + for version := range migration.Migrations { + allMigrations = append(allMigrations, version) } - - // 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 + + // Create a map of applied migrations for quick lookup + appliedMap := make(map[int]bool) + for _, version := range applied { + appliedMap[version] = true } - - if count == 0 { - hashedPassword := hashPassword("admin") - _, err = db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", "admin", hashedPassword) - if err != nil { - return err + + // Count pending migrations + pendingCount := 0 + for _, version := range allMigrations { + if !appliedMap[version] { + 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 } diff --git a/internal/migration/migration.go b/internal/migration/migration.go new file mode 100644 index 0000000..44096f3 --- /dev/null +++ b/internal/migration/migration.go @@ -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 +} \ No newline at end of file diff --git a/internal/migration/migrations.go b/internal/migration/migrations.go new file mode 100644 index 0000000..2852113 --- /dev/null +++ b/internal/migration/migrations.go @@ -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 +} \ No newline at end of file From 6aedfc794f5d0f3097775de97921f0bb50ee22b5 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:44:45 +0200 Subject: [PATCH 3/7] feat: allow password change --- internal/admin/admin.go | 158 ++++++++++++++++-- internal/admin/templates/_base.html | 18 +- internal/admin/templates/change_password.html | 30 ++++ internal/db/db.go | 19 +++ 4 files changed, 203 insertions(+), 22 deletions(-) create mode 100644 internal/admin/templates/change_password.html diff --git a/internal/admin/admin.go b/internal/admin/admin.go index d590995..045d980 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -2,6 +2,8 @@ package admin import ( "embed" + "encoding/gob" + "fmt" "html/template" "net/http" "strconv" @@ -28,6 +30,11 @@ type FlashMessage struct { Message string } +func init() { + // Register the FlashMessage type with gob package for session serialization + gob.Register(FlashMessage{}) +} + // TemplateData holds data for rendering templates type TemplateData struct { User *model.User @@ -52,8 +59,13 @@ type Admin struct { // New creates a new Admin instance func New(cfg *config.Config, database *db.Database) *Admin { - // Create session store + // Create session store with appropriate options store := sessions.NewCookieStore([]byte(cfg.SecretKey)) + store.Options = &sessions.Options{ + Path: "/admin", + MaxAge: 3600 * 24 * 7, // 1 week + HttpOnly: true, + } // Load templates templates := make(map[string]*template.Template) @@ -79,6 +91,7 @@ func New(cfg *config.Config, database *db.Database) *Admin { templateFiles := []string{ "index.html", "login.html", + "change_password.html", "channel_list.html", "channel_detail.html", "plugin_list.html", @@ -122,6 +135,7 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/admin/", a.handleIndex) mux.HandleFunc("/admin/login", a.handleLogin) mux.HandleFunc("/admin/logout", a.handleLogout) + mux.HandleFunc("/admin/change-password", a.handleChangePassword) mux.HandleFunc("/admin/plugins", a.handlePluginList) mux.HandleFunc("/admin/channels", a.handleChannelList) mux.HandleFunc("/admin/channels/", a.handleChannelDetail) @@ -131,7 +145,11 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) { // getCurrentUser gets the current user from the session func (a *Admin) getCurrentUser(r *http.Request) *model.User { - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + fmt.Printf("Error getting session for user retrieval: %v\n", err) + return nil + } // Check if user is logged in userID, ok := session.Values["user_id"].(int64) @@ -142,6 +160,7 @@ func (a *Admin) getCurrentUser(r *http.Request) *model.User { // Get user from database user, err := a.db.GetUserByID(userID) if err != nil { + fmt.Printf("Error retrieving user from database: %v\n", err) return nil } @@ -150,32 +169,63 @@ func (a *Admin) getCurrentUser(r *http.Request) *model.User { // isLoggedIn checks if the user is logged in func (a *Admin) isLoggedIn(r *http.Request) bool { - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + fmt.Printf("Error getting session for login check: %v\n", err) + return false + } return session.Values["logged_in"] == true } // addFlash adds a flash message to the session func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, category string) { - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + // If there's an error getting the session, create a new one + session = sessions.NewSession(a.store, sessionKey) + session.Options = &sessions.Options{ + Path: "/admin", + MaxAge: 3600 * 24 * 7, // 1 week + HttpOnly: true, + } + } - // Add flash message - flashes := session.Flashes() - if flashes == nil { - flashes = make([]interface{}, 0) + // Map internal categories to Bootstrap alert classes + alertClass := category + switch category { + case "success": + alertClass = "success" + case "danger": + alertClass = "danger" + case "warning": + alertClass = "warning" + case "info": + alertClass = "info" + default: + alertClass = "info" } flash := FlashMessage{ - Category: category, + Category: alertClass, Message: message, } session.AddFlash(flash) - session.Save(r, w) + err = session.Save(r, w) + if err != nil { + // Log the error or handle it appropriately + fmt.Printf("Error saving session: %v\n", err) + } } // getFlashes gets all flash messages from the session func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessage { - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + // If there's an error getting the session, return an empty slice + fmt.Printf("Error getting session for flashes: %v\n", err) + return []FlashMessage{} + } // Get flash messages flashes := session.Flashes() @@ -188,7 +238,10 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag } // Save session to clear flashes - session.Save(r, w) + err = session.Save(r, w) + if err != nil { + fmt.Printf("Error saving session after getting flashes: %v\n", err) + } return messages } @@ -299,10 +352,19 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { // handleLogout handles the logout route func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { // Clear session - session, _ := a.store.Get(r, sessionKey) + session, err := a.store.Get(r, sessionKey) + if err != nil { + fmt.Printf("Error getting session for logout: %v\n", err) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + session.Values = make(map[interface{}]interface{}) session.Options.MaxAge = -1 // Delete session - session.Save(r, w) + err = session.Save(r, w) + if err != nil { + fmt.Printf("Error saving session for logout: %v\n", err) + } a.addFlash(w, r, "You were logged out", "success") @@ -310,6 +372,74 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) } +// handleChangePassword handles the change password route +func (a *Admin) handleChangePassword(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Get current user + user := a.getCurrentUser(r) + if user == nil { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Handle form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Get form values + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + // Validate current password + _, err := a.db.CheckCredentials(user.Username, currentPassword) + if err != nil { + a.addFlash(w, r, "Current password is incorrect", "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + // Validate new password and confirmation + if newPassword == "" { + a.addFlash(w, r, "New password cannot be empty", "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + if newPassword != confirmPassword { + a.addFlash(w, r, "New passwords do not match", "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + // Update password + if err := a.db.UpdateUserPassword(user.ID, newPassword); err != nil { + a.addFlash(w, r, "Failed to update password: "+err.Error(), "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + // Success + a.addFlash(w, r, "Password changed successfully", "success") + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + + // Render change password template + a.render(w, r, "change_password.html", TemplateData{ + Title: "Change Password", + }) +} + // handlePluginList handles the plugin list route func (a *Admin) handlePluginList(w http.ResponseWriter, r *http.Request) { // Check if user is logged in diff --git a/internal/admin/templates/_base.html b/internal/admin/templates/_base.html index d056ab5..4a414e3 100644 --- a/internal/admin/templates/_base.html +++ b/internal/admin/templates/_base.html @@ -28,8 +28,10 @@ Log in {{else}}
-
{{.User.Username}} - Log out
+
{{.User.Username}} - + Change Password | + Log out +
{{end}} @@ -100,14 +102,14 @@ {{end}} - {{range .Flash}} -
-
-
-

{{.Message}}

+
+ {{range .Flash}} + + {{end}}
- {{end}}
diff --git a/internal/admin/templates/change_password.html b/internal/admin/templates/change_password.html new file mode 100644 index 0000000..eed3dc5 --- /dev/null +++ b/internal/admin/templates/change_password.html @@ -0,0 +1,30 @@ +{{define "content"}} +
+
+
+
+

Change Password

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go index e1c51e0..8cdce4a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -572,6 +572,25 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err }, nil } +// UpdateUserPassword updates a user's password +func (d *Database) UpdateUserPassword(userID int64, newPassword string) error { + // Hash the new password + hashedPassword, err := hashPassword(newPassword) + if err != nil { + return err + } + + // Update the user's password + query := ` + UPDATE users + SET password = ? + WHERE id = ? + ` + + _, err = d.db.Exec(query, hashedPassword, userID) + return err +} + // Helper function to hash password func hashPassword(password string) (string, error) { // Use bcrypt for secure password hashing From e0ae0c2a0b5aba03c5f08bf52e96081479113c37 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 15:50:28 +0200 Subject: [PATCH 4/7] fix: missing ca-certs --- .goreleaser.yml | 2 +- cmd/butterrobot/main.go | 2 ++ go.mod | 1 + go.sum | 2 ++ 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index c89e189..a3836e9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -93,7 +93,7 @@ docker_manifests: nfpms: - maintainer: Felipe Martin - description: SMTP server to forward messages to shoutrrr endpoints + description: A chatbot server with customizable commands and triggers homepage: https://git.nakama.town/fmartingr/butterrobot license: AGPL-3.0 formats: diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go index 2982a96..3bc56cb 100644 --- a/cmd/butterrobot/main.go +++ b/cmd/butterrobot/main.go @@ -8,6 +8,8 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/app" "git.nakama.town/fmartingr/butterrobot/internal/config" + + _ "golang.org/x/crypto/x509roots/fallback" ) func main() { diff --git a/go.mod b/go.mod index 3f17f0d..cd1bee5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24 require ( github.com/gorilla/sessions v1.4.0 golang.org/x/crypto v0.37.0 + golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df modernc.org/sqlite v1.37.0 ) diff --git a/go.sum b/go.sum index f331cb5..00c4a3c 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 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/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df h1:SwgTucX8ajPE0La2ELpYOIs8jVMoCMpAvYB6mDqP9vk= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= 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= From c920eb94a035913bf4f5dc98fe6ee26ff0c3f66f Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 18:03:07 +0200 Subject: [PATCH 5/7] feat: added twitter and instagram link expanders --- internal/app/app.go | 3 ++ internal/plugin/social/instagram.go | 76 +++++++++++++++++++++++++++ internal/plugin/social/twitter.go | 79 +++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 internal/plugin/social/instagram.go create mode 100644 internal/plugin/social/twitter.go diff --git a/internal/app/app.go b/internal/app/app.go index 8d4ffcd..2e15bf6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,6 +20,7 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/plugin" "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/social" "git.nakama.town/fmartingr/butterrobot/internal/queue" ) @@ -72,6 +73,8 @@ func (a *App) Run() error { plugin.Register(fun.NewCoin()) plugin.Register(fun.NewDice()) plugin.Register(fun.NewLoquito()) + plugin.Register(social.NewTwitterExpander()) + plugin.Register(social.NewInstagramExpander()) // Initialize routes a.initializeRoutes() diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go new file mode 100644 index 0000000..a4f758a --- /dev/null +++ b/internal/plugin/social/instagram.go @@ -0,0 +1,76 @@ +package social + +import ( + "net/url" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// InstagramExpander transforms instagram.com links to ddinstagram.com links +type InstagramExpander struct { + plugin.BasePlugin +} + +// New creates a new InstagramExpander instance +func NewInstagramExpander() *InstagramExpander { + return &InstagramExpander{ + BasePlugin: plugin.BasePlugin{ + ID: "social.instagram", + Name: "Instagram Link Expander", + Help: "Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters", + }, + } +} + +// OnMessage handles incoming messages +func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Skip empty messages + if strings.TrimSpace(msg.Text) == "" { + return nil + } + + // Regex to match instagram.com links + // Match both http://instagram.com and https://instagram.com formats + // Also match www.instagram.com + instagramRegex := regexp.MustCompile(`https?://(www\.)?(instagram\.com)/[^\s]+`) + + // Check if the message contains an Instagram link + if !instagramRegex.MatchString(msg.Text) { + return nil + } + + // Replace instagram.com with ddinstagram.com in the message and clean query parameters + transformed := instagramRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { + // Parse the URL + parsedURL, err := url.Parse(link) + if err != nil { + // If parsing fails, just do the simple replacement + link = strings.Replace(link, "instagram.com", "ddinstagram.com", 1) + return link + } + + // Change the host + if strings.Contains(parsedURL.Host, "instagram.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) + } + + // Remove query parameters + parsedURL.RawQuery = "" + + // Return the cleaned URL + return parsedURL.String() + }) + + // Create response message + response := &model.Message{ + Text: transformed, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} diff --git a/internal/plugin/social/twitter.go b/internal/plugin/social/twitter.go new file mode 100644 index 0000000..837b6c9 --- /dev/null +++ b/internal/plugin/social/twitter.go @@ -0,0 +1,79 @@ +package social + +import ( + "net/url" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// TwitterExpander transforms twitter.com links to fxtwitter.com links +type TwitterExpander struct { + plugin.BasePlugin +} + +// New creates a new TwitterExpander instance +func NewTwitterExpander() *TwitterExpander { + return &TwitterExpander{ + BasePlugin: plugin.BasePlugin{ + ID: "social.twitter", + Name: "Twitter Link Expander", + Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters", + }, + } +} + +// OnMessage handles incoming messages +func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Skip empty messages + if strings.TrimSpace(msg.Text) == "" { + return nil + } + + // Regex to match twitter.com links + // Match both http://twitter.com and https://twitter.com formats + // Also match www.twitter.com + twitterRegex := regexp.MustCompile(`https?://(www\.)?(twitter\.com|x\.com)/[^\s]+`) + + // Check if the message contains a Twitter link + if !twitterRegex.MatchString(msg.Text) { + return nil + } + + // Replace twitter.com with fxtwitter.com in the message and clean query parameters + transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { + // Parse the URL + parsedURL, err := url.Parse(link) + if err != nil { + // If parsing fails, just do the simple replacement + link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1) + link = strings.Replace(link, "x.com", "fxtwitter.com", 1) + return link + } + + // Change the host + if strings.Contains(parsedURL.Host, "twitter.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1) + } else if strings.Contains(parsedURL.Host, "x.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1) + } + + // Remove query parameters + parsedURL.RawQuery = "" + + // Return the cleaned URL + return parsedURL.String() + }) + + // Create response message + response := &model.Message{ + Text: transformed, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} From a0f12efd65c0d1999cbeaf39b47727a284c02f01 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 18:08:40 +0200 Subject: [PATCH 6/7] feat: show version in admin page --- internal/admin/admin.go | 6 ++++- internal/admin/templates/_base.html | 13 +++++++++++ internal/app/app.go | 36 ++++++++++++++++++----------- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 045d980..822495a 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -46,6 +46,7 @@ type TemplateData struct { Channels []*model.Channel Channel *model.Channel ChannelPlugin *model.ChannelPlugin + Version string } // Admin represents the admin interface @@ -55,10 +56,11 @@ type Admin struct { store *sessions.CookieStore templates map[string]*template.Template baseTemplate *template.Template + version string } // New creates a new Admin instance -func New(cfg *config.Config, database *db.Database) *Admin { +func New(cfg *config.Config, database *db.Database, version string) *Admin { // Create session store with appropriate options store := sessions.NewCookieStore([]byte(cfg.SecretKey)) store.Options = &sessions.Options{ @@ -126,6 +128,7 @@ func New(cfg *config.Config, database *db.Database) *Admin { store: store, templates: templates, baseTemplate: baseTemplate, + version: version, } } @@ -264,6 +267,7 @@ func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName stri data.LoggedIn = a.isLoggedIn(r) data.Path = r.URL.Path data.Flash = a.getFlashes(w, r) + data.Version = a.version // Get template tmpl, ok := a.templates[templateName] diff --git a/internal/admin/templates/_base.html b/internal/admin/templates/_base.html index 4a414e3..3ebdf85 100644 --- a/internal/admin/templates/_base.html +++ b/internal/admin/templates/_base.html @@ -117,6 +117,19 @@
+
+
+
+
+
    +
  • + ButterRobot {{if .Version}}v{{.Version}}{{else}}(development){{end}} +
  • +
+
+
+
+
diff --git a/internal/app/app.go b/internal/app/app.go index 2e15bf6..5126672 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "runtime/debug" "strings" "syscall" "time" @@ -26,12 +27,13 @@ import ( // App represents the application type App struct { - config *config.Config - logger *slog.Logger - db *db.Database - router *http.ServeMux - queue *queue.Queue - admin *admin.Admin + config *config.Config + logger *slog.Logger + db *db.Database + router *http.ServeMux + queue *queue.Queue + admin *admin.Admin + version string } // New creates a new App instance @@ -48,16 +50,24 @@ func New(cfg *config.Config, logger *slog.Logger) (*App, error) { // Initialize message queue messageQueue := queue.New(logger) + // Get version information + version := "" + info, ok := debug.ReadBuildInfo() + if ok { + version = info.Main.Version + } + // Initialize admin interface - adminInterface := admin.New(cfg, database) + adminInterface := admin.New(cfg, database, version) return &App{ - config: cfg, - logger: logger, - db: database, - router: router, - queue: messageQueue, - admin: adminInterface, + config: cfg, + logger: logger, + db: database, + router: router, + queue: messageQueue, + admin: adminInterface, + version: version, }, nil } From 21e4c434fd8ce549ee128ab2b1d1795a4a6007ef Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 21 Apr 2025 18:10:30 +0200 Subject: [PATCH 7/7] docs: updated plugin docs --- docs/creating-a-plugin.md | 105 +++++++++++++++++++++++++++++++++++++- docs/plugins.md | 5 ++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/docs/creating-a-plugin.md b/docs/creating-a-plugin.md index 945d03c..469491a 100644 --- a/docs/creating-a-plugin.md +++ b/docs/creating-a-plugin.md @@ -1,6 +1,18 @@ # Creating a Plugin -## Example +## Plugin Categories + +ButterRobot organizes plugins into different categories: + +- **Development**: Utility plugins like `ping` +- **Fun**: Entertainment plugins like dice rolling, coin flipping +- **Social**: Social media related plugins like URL transformers/expanders + +When creating a new plugin, consider which category it fits into and place it in the appropriate directory. + +## Plugin Examples + +### Basic Example: Marco Polo This simple "Marco Polo" plugin will answer _Polo_ to the user that says _Marco_: @@ -47,6 +59,92 @@ func (p *MarcoPlugin) OnMessage(msg *model.Message, config map[string]interface{ } ``` +### Advanced Example: URL Transformer + +This more complex plugin transforms URLs, useful for improving media embedding in chat platforms: + +```go +package social + +import ( + "net/url" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// TwitterExpander transforms twitter.com links to fxtwitter.com links +type TwitterExpander struct { + plugin.BasePlugin +} + +// New creates a new TwitterExpander instance +func NewTwitter() *TwitterExpander { + return &TwitterExpander{ + BasePlugin: plugin.BasePlugin{ + ID: "social.twitter", + Name: "Twitter Link Expander", + Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters", + }, + } +} + +// OnMessage handles incoming messages +func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Skip empty messages + if strings.TrimSpace(msg.Text) == "" { + return nil + } + + // Regex to match twitter.com links + twitterRegex := regexp.MustCompile(`https?://(www\.)?(twitter\.com|x\.com)/[^\s]+`) + + // Check if the message contains a Twitter link + if !twitterRegex.MatchString(msg.Text) { + return nil + } + + // Transform the URL + transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { + // Parse the URL + parsedURL, err := url.Parse(link) + if err != nil { + // If parsing fails, just do the simple replacement + link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1) + link = strings.Replace(link, "x.com", "fxtwitter.com", 1) + return link + } + + // Change the host + if strings.Contains(parsedURL.Host, "twitter.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1) + } else if strings.Contains(parsedURL.Host, "x.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1) + } + + // Remove query parameters + parsedURL.RawQuery = "" + + // Return the cleaned URL + return parsedURL.String() + }) + + // Create response message + response := &model.Message{ + Text: transformed, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} +``` + +## Registering Plugins + To use the plugin, register it in your application: ```go @@ -55,7 +153,10 @@ func (a *App) Run() error { // ... // Register plugins - plugin.Register(myplugin.New()) + plugin.Register(ping.New()) // Development plugin + plugin.Register(fun.NewCoin()) // Fun plugin + plugin.Register(social.NewTwitter()) // Social media plugin + plugin.Register(myplugin.New()) // Your custom plugin // ... } diff --git a/docs/plugins.md b/docs/plugins.md index 11e3d16..2988f80 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -9,3 +9,8 @@ - Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) - Dice: Put `!dice` and wathever roll you want to perform. - Coin: Flip a coin and get heads or tails. + +### Social Media + +- Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms. +- Instagram Link Expander: Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters. This allows for better media embedding in chat platforms.