package admin import ( "embed" "encoding/gob" "fmt" "html/template" "net/http" "strconv" "strings" "git.nakama.town/fmartingr/butterrobot/internal/config" "git.nakama.town/fmartingr/butterrobot/internal/db" "git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/plugin" "github.com/gorilla/sessions" ) //go:embed templates/*.html var templateFS embed.FS const ( // Session store key sessionKey = "butterrobot-session" ) // FlashMessage represents a flash message type FlashMessage struct { Category string 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 LoggedIn bool Title string Path string Flash []FlashMessage Plugins map[string]model.Plugin Channels []*model.Channel Channel *model.Channel ChannelPlugin *model.ChannelPlugin Version string } // Admin represents the admin interface type Admin struct { config *config.Config db *db.Database 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, 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) // Create a template function map with helper functions funcMap := template.FuncMap{ "contains": strings.Contains, } // Read base template from embedded filesystem baseContent, err := templateFS.ReadFile("templates/_base.html") if err != nil { panic(err) } // Create a custom template with functions baseTemplate, err := template.New("_base.html").Funcs(funcMap).Parse(string(baseContent)) if err != nil { panic(err) } // Parse and register all templates templateFiles := []string{ "index.html", "login.html", "change_password.html", "channel_list.html", "channel_detail.html", "plugin_list.html", "channel_plugins_list.html", } for _, tf := range templateFiles { // Read template content from embedded filesystem content, err := templateFS.ReadFile("templates/" + tf) if err != nil { panic(err) } // Create a clone of the base template t, err := baseTemplate.Clone() if err != nil { panic(err) } // Parse the template content t, err = t.Parse(string(content)) if err != nil { panic(err) } templates[tf] = t } return &Admin{ config: cfg, db: database, store: store, templates: templates, baseTemplate: baseTemplate, version: version, } } // RegisterRoutes registers admin routes on the given router func (a *Admin) RegisterRoutes(mux *http.ServeMux) { // Register admin routes 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) mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList) mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete) } // getCurrentUser gets the current user from the session func (a *Admin) getCurrentUser(r *http.Request) *model.User { 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) if !ok { return nil } // 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 } return user } // isLoggedIn checks if the user is logged in func (a *Admin) isLoggedIn(r *http.Request) bool { 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, 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, } } // 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: alertClass, Message: message, } session.AddFlash(flash) 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, 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() messages := make([]FlashMessage, 0, len(flashes)) for _, f := range flashes { if flash, ok := f.(FlashMessage); ok { messages = append(messages, flash) } } // Save session to clear flashes err = session.Save(r, w) if err != nil { fmt.Printf("Error saving session after getting flashes: %v\n", err) } return messages } // requireLogin middleware checks if the user is logged in func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !a.isLoggedIn(r) { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return } next(w, r) } } // render renders a template with the given data func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) { // Add current user data data.User = a.getCurrentUser(r) 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] if !ok { http.Error(w, "Template not found", http.StatusInternalServerError) return } // Render template w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } // handleIndex handles the admin index route func (a *Admin) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/admin/" { http.NotFound(w, r) return } // Redirect to login if not logged in if !a.isLoggedIn(r) { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return } // Redirect to channel list http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) } // handleLogin handles the login route func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { // If already logged in, redirect to index if a.isLoggedIn(r) { http.Redirect(w, r, "/admin/", http.StatusSeeOther) return } // Handle login form submission if r.Method == http.MethodPost { // Parse form if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } // Check credentials username := r.FormValue("username") password := r.FormValue("password") user, err := a.db.CheckCredentials(username, password) if err != nil || user == nil { a.addFlash(w, r, "Incorrect credentials", "danger") http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return } // Set session session, _ := a.store.Get(r, sessionKey) session.Values["logged_in"] = true session.Values["user_id"] = user.ID // Set session expiration session.Options.MaxAge = 3600 * 24 * 7 // 1 week session.Save(r, w) a.addFlash(w, r, "You were logged in", "success") // Redirect to index next := r.URL.Query().Get("next") if next == "" { next = "/admin/" } http.Redirect(w, r, next, http.StatusSeeOther) return } // Render login template a.render(w, r, "login.html", TemplateData{ Title: "Login", }) } // handleLogout handles the logout route func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { // Clear session 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 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") // Redirect to login 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 if !a.isLoggedIn(r) { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return } // Get available plugins plugins := plugin.GetAvailablePlugins() // Render template a.render(w, r, "plugin_list.html", TemplateData{ Title: "Plugins", Plugins: plugins, }) } // handleChannelList handles the channel list route func (a *Admin) handleChannelList(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 all channels channels, err := a.db.GetAllChannels() if err != nil { http.Error(w, "Failed to get channels", http.StatusInternalServerError) return } // Render template a.render(w, r, "channel_list.html", TemplateData{ Title: "Channels", Channels: channels, }) } // handleChannelDetail handles the channel detail route func (a *Admin) handleChannelDetail(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 } // Extract channel ID from path path := r.URL.Path if path == "/admin/channels/" { http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) return } channelID := strings.TrimPrefix(path, "/admin/channels/") if strings.Contains(channelID, "/") { // Handle delete request if strings.HasSuffix(path, "/delete") && r.Method == http.MethodPost { channelID = strings.TrimSuffix(channelID, "/delete") // Delete channel id, err := strconv.ParseInt(channelID, 10, 64) if err != nil { http.Error(w, "Invalid channel ID", http.StatusBadRequest) return } if err := a.db.DeleteChannel(id); err != nil { http.Error(w, "Failed to delete channel", http.StatusInternalServerError) return } a.addFlash(w, r, "Channel removed", "success") http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) return } http.NotFound(w, r) return } // Convert channel ID to int64 id, err := strconv.ParseInt(channelID, 10, 64) if err != nil { http.Error(w, "Invalid channel ID", http.StatusBadRequest) 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 } // Check if the form was submitted if r.FormValue("form_submitted") == "true" { // Update channel enabled := r.FormValue("enabled") == "true" if err := a.db.UpdateChannel(id, enabled); err != nil { http.Error(w, "Failed to update channel", http.StatusInternalServerError) return } a.addFlash(w, r, "Channel updated", "success") http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther) return } } // Get channel channel, err := a.db.GetChannelByID(id) if err != nil { http.Error(w, "Channel not found", http.StatusNotFound) return } // Get available plugins plugins := plugin.GetAvailablePlugins() // Render template a.render(w, r, "channel_detail.html", TemplateData{ Title: "Channel: " + channel.PlatformChannelID, Channel: channel, Plugins: plugins, }) } // handleChannelPluginList handles the channel plugin list route func (a *Admin) handleChannelPluginList(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 } // Handle form submission if r.Method == http.MethodPost { // Parse form if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } // Extract form data channelID, err := strconv.ParseInt(r.FormValue("channel_id"), 10, 64) if err != nil { http.Error(w, "Invalid channel ID", http.StatusBadRequest) return } pluginID := r.FormValue("plugin_id") enabled := r.FormValue("enabled") == "y" // Create channel plugin config := make(map[string]interface{}) _, err = a.db.CreateChannelPlugin(channelID, pluginID, enabled, config) if err == db.ErrDuplicated { a.addFlash(w, r, "Plugin "+pluginID+" is already present on the channel", "danger") } else if err != nil { http.Error(w, "Failed to create channel plugin", http.StatusInternalServerError) return } else { a.addFlash(w, r, "Plugin "+pluginID+" added to the channel", "success") } // Redirect back referer := r.Header.Get("Referer") if referer == "" { referer = "/admin/channelplugins" } http.Redirect(w, r, referer, http.StatusSeeOther) return } // Get all channels channels, err := a.db.GetAllChannels() if err != nil { http.Error(w, "Failed to get channels", http.StatusInternalServerError) return } // Render template a.render(w, r, "channel_plugins_list.html", TemplateData{ Title: "Channel Plugins", Channels: channels, Plugins: plugin.GetAvailablePlugins(), }) } // handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route func (a *Admin) handleChannelPluginDetailOrDelete(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 } // Extract channel plugin ID from path path := r.URL.Path if path == "/admin/channelplugins/" { http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) return } channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/") // Handle delete request if strings.HasSuffix(channelPluginID, "/delete") && r.Method == http.MethodPost { channelPluginID = strings.TrimSuffix(channelPluginID, "/delete") // Delete channel plugin id, err := strconv.ParseInt(channelPluginID, 10, 64) if err != nil { http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest) return } if err := a.db.DeleteChannelPlugin(id); err != nil { http.Error(w, "Failed to delete channel plugin", http.StatusInternalServerError) return } a.addFlash(w, r, "Plugin removed", "success") // Redirect back referer := r.Header.Get("Referer") if referer == "" { referer = "/admin/channelplugins" } http.Redirect(w, r, referer, http.StatusSeeOther) return } // Handle update request if r.Method == http.MethodPost { // Parse form if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } // Convert channel plugin ID to int64 id, err := strconv.ParseInt(channelPluginID, 10, 64) if err != nil { http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest) return } // Update channel plugin enabled := r.FormValue("enabled") == "true" if err := a.db.UpdateChannelPlugin(id, enabled); err != nil { http.Error(w, "Failed to update channel plugin", http.StatusInternalServerError) return } a.addFlash(w, r, "Plugin updated", "success") // Redirect back referer := r.Header.Get("Referer") if referer == "" { referer = "/admin/channelplugins" } http.Redirect(w, r, referer, http.StatusSeeOther) return } // Redirect to channel plugins list http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) }