Compare commits
7 commits
Author | SHA1 | Date | |
---|---|---|---|
21e4c434fd | |||
a0f12efd65 | |||
c920eb94a0 | |||
e0ae0c2a0b | |||
6aedfc794f | |||
ece8280358 | |||
84e5feeb81 |
18 changed files with 1007 additions and 105 deletions
|
@ -93,7 +93,7 @@ docker_manifests:
|
|||
|
||||
nfpms:
|
||||
- maintainer: Felipe Martin <me@fmartingr.com>
|
||||
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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/app"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
||||
|
||||
_ "golang.org/x/crypto/x509roots/fallback"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
@ -19,15 +23,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
// ...
|
||||
}
|
||||
|
|
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
|
||||
}
|
||||
```
|
|
@ -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.
|
||||
|
|
2
go.mod
2
go.mod
|
@ -4,6 +4,8 @@ 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
|
||||
)
|
||||
|
||||
|
|
4
go.sum
4
go.sum
|
@ -16,6 +16,10 @@ 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/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=
|
||||
|
|
|
@ -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
|
||||
|
@ -39,6 +46,7 @@ type TemplateData struct {
|
|||
Channels []*model.Channel
|
||||
Channel *model.Channel
|
||||
ChannelPlugin *model.ChannelPlugin
|
||||
Version string
|
||||
}
|
||||
|
||||
// Admin represents the admin interface
|
||||
|
@ -48,12 +56,18 @@ 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 {
|
||||
// Create session store
|
||||
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{
|
||||
Path: "/admin",
|
||||
MaxAge: 3600 * 24 * 7, // 1 week
|
||||
HttpOnly: true,
|
||||
}
|
||||
|
||||
// Load templates
|
||||
templates := make(map[string]*template.Template)
|
||||
|
@ -79,6 +93,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",
|
||||
|
@ -113,6 +128,7 @@ func New(cfg *config.Config, database *db.Database) *Admin {
|
|||
store: store,
|
||||
templates: templates,
|
||||
baseTemplate: baseTemplate,
|
||||
version: version,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -122,6 +138,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 +148,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 +163,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 +172,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 +241,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
|
||||
}
|
||||
|
@ -211,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]
|
||||
|
@ -299,10 +356,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 +376,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
|
||||
|
|
|
@ -28,8 +28,10 @@
|
|||
<a href="/admin/login">Log in</a>
|
||||
{{else}}
|
||||
<div class="d-none d-xl-block pl-2">
|
||||
<div>{{.User.Username}} - <a class="mt-1 small"
|
||||
href="/admin/logout">Log out</a></div>
|
||||
<div>{{.User.Username}} -
|
||||
<a class="mt-1 small" href="/admin/change-password">Change Password</a> |
|
||||
<a class="mt-1 small" href="/admin/logout">Log out</a>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
|
@ -100,14 +102,14 @@
|
|||
{{end}}
|
||||
</div>
|
||||
|
||||
{{range .Flash}}
|
||||
<div class="card">
|
||||
<div class="card-status-top bg-{{.Category}}"></div>
|
||||
<div class="card-body">
|
||||
<p>{{.Message}}</p>
|
||||
<div class="container-xl mt-3">
|
||||
{{range .Flash}}
|
||||
<div class="alert alert-{{.Category}} alert-dismissible" role="alert">
|
||||
{{.Message}}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="content">
|
||||
<div class="container-xl">
|
||||
|
@ -115,6 +117,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer footer-transparent d-print-none">
|
||||
<div class="container-xl">
|
||||
<div class="row text-center align-items-center flex-row-reverse">
|
||||
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
|
||||
<ul class="list-inline list-inline-dots mb-0">
|
||||
<li class="list-inline-item">
|
||||
ButterRobot {{if .Version}}v{{.Version}}{{else}}(development){{end}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script>
|
||||
|
|
30
internal/admin/templates/change_password.html
Normal file
30
internal/admin/templates/change_password.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Change Password</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/change-password">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current Password</label>
|
||||
<input type="password" name="current_password" class="form-control" placeholder="Current Password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" name="new_password" class="form-control" placeholder="New Password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm New Password" required>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
|
@ -9,6 +9,7 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
@ -20,17 +21,19 @@ 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"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
@ -47,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
|
||||
}
|
||||
|
||||
|
@ -72,6 +83,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()
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
@ -568,74 +572,80 @@ 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 {
|
||||
// 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
|
||||
}
|
||||
|
|
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
|
||||
}
|
76
internal/plugin/social/instagram.go
Normal file
76
internal/plugin/social/instagram.go
Normal file
|
@ -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}
|
||||
}
|
79
internal/plugin/social/twitter.go
Normal file
79
internal/plugin/social/twitter.go
Normal file
|
@ -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}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue