From 8a5ed3f507d37c52e2a68a23ced6942cc752221d Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 26 Feb 2020 22:49:27 +0100 Subject: [PATCH] Initial ability to configure accounts from web interface --- account.go | 22 +++++ connector/config.go | 21 +++++ connector/irc/config.go | 32 +++++++ connector/irc/irc.go | 24 ++++- connector/mattermost/config.go | 52 +++++++++++ connector/mattermost/mattermost.go | 6 +- connector/xmpp/config.go | 38 ++++++++ connector/xmpp/xmpp.go | 13 +-- templates/config.html | 71 +++++++++++++++ templates/home.html | 43 +++++---- web.go | 141 ++++++++++++++++++++++++++--- 11 files changed, 423 insertions(+), 40 deletions(-) create mode 100644 connector/irc/config.go create mode 100644 connector/mattermost/config.go create mode 100644 connector/xmpp/config.go create mode 100644 templates/config.html diff --git a/account.go b/account.go index 6785fb7..0d7f94c 100644 --- a/account.go +++ b/account.go @@ -34,6 +34,9 @@ func SetAccount(mxid string, name string, protocol string, config map[string]str accounts := registeredAccounts[mxid] if prev_acct, ok := accounts[name]; ok { + prev_acct.Conn.Close() + prev_acct.JoinedRooms = map[RoomID]bool{} + if protocol != prev_acct.Protocol { return fmt.Errorf("Wrong protocol") } @@ -112,6 +115,8 @@ func RemoveAccount(mxUser string, name string) { } } +// ---- + func SaveDbAccounts(mxid string, key *[32]byte) { accountsLock.Lock() defer accountsLock.Unlock() @@ -130,6 +135,23 @@ func SaveDbAccounts(mxid string, key *[32]byte) { } } +func LoadDbAccounts(mxid string, key *[32]byte) { + var allAccounts []DbAccountConfig + db.Where(&DbAccountConfig{MxUserID: mxid}).Find(&allAccounts) + for _, acct := range allAccounts { + config, err := decryptAccountConfig(acct.Config, key) + if err != nil { + ezbrSystemSendf("Could not decrypt stored configuration for account %s", acct.Name) + continue + } + + err = SetAccount(mxid, acct.Name, acct.Protocol, config) + if err != nil { + ezbrSystemSendf("Could not setup account %s: %s", acct.Name, err.Error()) + } + } +} + // ---- func (a *Account) ezbrMessagef(format string, args ...interface{}) { diff --git a/connector/config.go b/connector/config.go index e0fcf17..97e4556 100644 --- a/connector/config.go +++ b/connector/config.go @@ -43,3 +43,24 @@ func (c Configuration) GetBool(k string, deflt ...bool) (bool, error) { } return false, fmt.Errorf("Missing configuration key: %s", k) } + +// ---- + +type ConfigSchema []*ConfigEntry + +type ConfigEntry struct { + Name string + Description string + Default string + FixedValue string + Required bool + IsPassword bool + IsNumeric bool + IsBoolean bool +} + +var Protocols = map[string]ConfigSchema{} + +func Register(name string, schema ConfigSchema) { + Protocols[name] = schema +} diff --git a/connector/irc/config.go b/connector/irc/config.go new file mode 100644 index 0000000..26d9a63 --- /dev/null +++ b/connector/irc/config.go @@ -0,0 +1,32 @@ +package irc + +import ( + . "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector" +) + +func init() { + Register("irc", ConfigSchema{ + &ConfigEntry{ + Name: "nick", + Description: "Nickname", + Required: true, + }, + &ConfigEntry{ + Name: "server", + Description: "Server", + Required: true, + }, + &ConfigEntry{ + Name: "port", + Description: "Port", + IsNumeric: true, + Default: "6667", + }, + &ConfigEntry{ + Name: "ssl", + Description: "Use SSL", + IsBoolean: true, + Default: "false", + }, + }) +} diff --git a/connector/irc/irc.go b/connector/irc/irc.go index 2ed3923..d69884e 100644 --- a/connector/irc/irc.go +++ b/connector/irc/irc.go @@ -127,6 +127,10 @@ func (irc *IRC) SetUserInfo(info *UserInfo) error { } func (irc *IRC) SetRoomInfo(roomId RoomID, info *RoomInfo) error { + if irc.conn == nil { + return fmt.Errorf("Not connected") + } + ch, err := irc.checkRoomId(roomId) if err != nil { return err @@ -145,6 +149,10 @@ func (irc *IRC) SetRoomInfo(roomId RoomID, info *RoomInfo) error { } func (irc *IRC) Join(roomId RoomID) error { + if irc.conn == nil { + return fmt.Errorf("Not connected") + } + ch, err := irc.checkRoomId(roomId) if err != nil { return err @@ -155,6 +163,10 @@ func (irc *IRC) Join(roomId RoomID) error { } func (irc *IRC) Invite(userId UserID, roomId RoomID) error { + if irc.conn == nil { + return fmt.Errorf("Not connected") + } + who, err := irc.checkUserId(userId) if err != nil { return err @@ -174,6 +186,10 @@ func (irc *IRC) Invite(userId UserID, roomId RoomID) error { } func (irc *IRC) Leave(roomId RoomID) { + if irc.conn == nil { + return + } + ch, err := irc.checkRoomId(roomId) if err != nil { return @@ -183,6 +199,10 @@ func (irc *IRC) Leave(roomId RoomID) { } func (irc *IRC) Send(event *Event) error { + if irc.conn == nil { + return fmt.Errorf("Not connected") + } + // Workaround girc bug if event.Text[0] == ':' { event.Text = " " + event.Text @@ -231,7 +251,9 @@ func (irc *IRC) Send(event *Event) error { func (irc *IRC) Close() { conn := irc.conn irc.conn = nil - conn.Close() + if conn != nil { + conn.Close() + } } func (irc *IRC) connectLoop(c *girc.Client) { diff --git a/connector/mattermost/config.go b/connector/mattermost/config.go new file mode 100644 index 0000000..b7c4ba8 --- /dev/null +++ b/connector/mattermost/config.go @@ -0,0 +1,52 @@ +package mattermost + +import ( + . "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector" +) + +func init() { + Register("mattermost", ConfigSchema{ + &ConfigEntry{ + Name: "server", + Description: "Server", + Required: true, + }, + &ConfigEntry{ + Name: "username", + Description: "Username", + Required: true, + }, + &ConfigEntry{ + Name: "password", + Description: "Password", + IsPassword: true, + }, + &ConfigEntry{ + Name: "token", + Description: "Authentification token (replaces password if set)", + }, + &ConfigEntry{ + Name: "teams", + Description: "Comma-separated list of teams to follow", + Required: true, + }, + &ConfigEntry{ + Name: "no_tls", + Description: "Disable SSL/TLS", + IsBoolean: true, + Default: "false", + }, + &ConfigEntry{ + Name: "initial_backlog", + Description: "Maximum number of messages to load when joining a channel", + IsNumeric: true, + Default: "1000", + }, + &ConfigEntry{ + Name: "initial_members", + Description: "Maximum number of members to load when joining a channel", + IsNumeric: true, + Default: "100", + }, + }) +} diff --git a/connector/mattermost/mattermost.go b/connector/mattermost/mattermost.go index 0b863fb..e3a6429 100644 --- a/connector/mattermost/mattermost.go +++ b/connector/mattermost/mattermost.go @@ -69,7 +69,7 @@ func (mm *Mattermost) Configure(c Configuration) error { return err } - mm.initial_members, err = c.GetInt("initial_members", 1000) + mm.initial_members, err = c.GetInt("initial_members", 100) if err != nil { return err } @@ -312,7 +312,9 @@ func (mm *Mattermost) Send(event *Event) error { } func (mm *Mattermost) Close() { - mm.conn.WsQuit = true + if mm.conn != nil { + mm.conn.WsQuit = true + } if mm.handlerStopChan != nil { mm.handlerStopChan <- true mm.handlerStopChan = nil diff --git a/connector/xmpp/config.go b/connector/xmpp/config.go new file mode 100644 index 0000000..6fd5f9b --- /dev/null +++ b/connector/xmpp/config.go @@ -0,0 +1,38 @@ +package xmpp + +import ( + . "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector" +) + +func init() { + Register("xmpp", ConfigSchema{ + &ConfigEntry{ + Name: "jid", + Description: "JID", + Required: true, + }, + &ConfigEntry{ + Name: "password", + Description: "Password", + Required: true, + IsPassword: true, + }, + &ConfigEntry{ + Name: "nickname", + Description: "Nickname in MUCs", + Required: true, + }, + &ConfigEntry{ + Name: "port", + Description: "Port", + IsNumeric: true, + Default: "6667", + }, + &ConfigEntry{ + Name: "ssl", + Description: "Use SSL", + IsBoolean: true, + Default: "true", + }, + }) +} diff --git a/connector/xmpp/xmpp.go b/connector/xmpp/xmpp.go index 698016f..f1a75b2 100644 --- a/connector/xmpp/xmpp.go +++ b/connector/xmpp/xmpp.go @@ -55,11 +55,6 @@ func (xm *XMPP) Configure(c Configuration) error { // Parse and validate configuration var err error - xm.server, err = c.GetString("server") - if err != nil { - return err - } - xm.port, err = c.GetInt("port", 5222) if err != nil { return err @@ -78,9 +73,7 @@ func (xm *XMPP) Configure(c Configuration) error { if len(jid_parts) != 2 { return fmt.Errorf("Invalid JID: %s", xm.jid) } - if jid_parts[1] != xm.server { - return fmt.Errorf("JID %s not on server %s", xm.jid, xm.server) - } + xm.server = jid_parts[1] xm.jid_localpart = jid_parts[0] xm.nickname, _ = c.GetString("nickname", xm.jid_localpart) @@ -353,7 +346,9 @@ func (xm *XMPP) Send(event *Event) error { } func (xm *XMPP) Close() { - xm.conn.Close() + if xm.conn != nil { + xm.conn.Close() + } xm.conn = nil xm.connectorLoopNum += 1 } diff --git a/templates/config.html b/templates/config.html new file mode 100644 index 0000000..2d64444 --- /dev/null +++ b/templates/config.html @@ -0,0 +1,71 @@ +{{define "title"}}Account configuration |{{end}} + +{{define "body"}} +
+

Configure account

+ Go back +
+ +{{if .ErrorMessage}} +
An error occurred. +
{{ .ErrorMessage }}
+
+{{end}} + +
+
+ + + {{if .InvalidName}} +
Invalid name (must not be empty)
+ {{end}} +
+
+ + +
+ {{$config := .Config}} + {{$errors := .Errors}} + {{range $i, $schema := .Schema}} +
+ + {{if $schema.FixedValue}} + + {{else if $schema.IsBoolean}} + {{$value := index $config $schema.Name}} + + + {{else if $schema.IsPassword}} + + {{else}} + + {{end}} + {{$error := index $errors $schema.Name}} + {{if $error}} +
{{$error}}
+ {{end}} +
+ {{end}} + +
+ +{{end}} diff --git a/templates/home.html b/templates/home.html index 40a0e5c..da3e478 100644 --- a/templates/home.html +++ b/templates/home.html @@ -8,23 +8,34 @@ Log out - - - - - - - - - - {{range $name, $acc := .Accounts}} +{{ if .Accounts }} +
Account nameProtocol
+ - - - + + + - {{end}} - -
{{ $name }}{{ $acc.Protocol }}Modifier etcAccount nameProtocol
+ + + {{range $i, $acc := .Accounts}} + + {{ $acc.AccountName }} + {{ $acc.Protocol }} + + Modify + Delete + + + {{end}} + + +{{end}} + +
Add account
+ +IRC +XMPP +Mattermost {{end}} diff --git a/web.go b/web.go index 74dd1f8..83d3283 100644 --- a/web.go +++ b/web.go @@ -5,12 +5,14 @@ import ( "html/template" "log" "net/http" + "strconv" "strings" "github.com/gorilla/mux" "github.com/gorilla/sessions" "golang.org/x/crypto/argon2" + "git.deuxfleurs.fr/Deuxfleurs/easybridge/connector" "git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib" ) @@ -30,6 +32,9 @@ func StartWeb() { r := mux.NewRouter() r.HandleFunc("/", handleHome) r.HandleFunc("/logout", handleLogout) + r.HandleFunc("/add/{protocol}", handleAdd) + r.HandleFunc("/edit/{account}", handleEdit) + r.HandleFunc("/delete/{account}", handleDelete) staticfiles := http.FileServer(http.Dir("static")) r.Handle("/static/{file:.*}", http.StripPrefix("/static/", staticfiles)) @@ -178,19 +183,131 @@ func handleLogin(w http.ResponseWriter, r *http.Request) *LoginInfo { } } -func LoadDbAccounts(mxid string, key *[32]byte) { - var allAccounts []DbAccountConfig - db.Where(&DbAccountConfig{MxUserID: mxid}).Find(&allAccounts) - for _, acct := range allAccounts { - config, err := decryptAccountConfig(acct.Config, key) - if err != nil { - ezbrSystemSendf("Could not decrypt stored configuration for account %s", acct.Name) - continue - } +// ---- - err = SetAccount(mxid, acct.Name, acct.Protocol, config) - if err != nil { - ezbrSystemSendf("Could not setup account %s: %s", acct.Name, err.Error()) +func handleAdd(w http.ResponseWriter, r *http.Request) { + login := checkLogin(w, r) + if login == nil { + return + } + + protocol := mux.Vars(r)["protocol"] + + configForm(w, r, login, "", protocol, map[string]string{}) +} + +func handleEdit(w http.ResponseWriter, r *http.Request) { + login := checkLogin(w, r) + if login == nil { + return + } + + account := mux.Vars(r)["account"] + acct := FindAccount(login.MxId, account) + if acct == nil { + http.Error(w, "No such account", http.StatusNotFound) + return + } + + configForm(w, r, login, account, acct.Protocol, acct.Config) +} + +type ConfigFormData struct { + ErrorMessage string + + Name string + NameEditable bool + InvalidName bool + + Protocol string + + Config map[string]string + Errors map[string]string + Schema connector.ConfigSchema +} + +func configForm(w http.ResponseWriter, r *http.Request, + login *LoginInfo, name string, protocol string, + prevConfig map[string]string) { + templateConfig := template.Must(template.ParseFiles("templates/layout.html", "templates/config.html")) + + data := &ConfigFormData{ + Name: name, + NameEditable: (name == ""), + Protocol: protocol, + Config: map[string]string{}, + Errors: map[string]string{}, + Schema: connector.Protocols[protocol], + } + for k, v := range prevConfig { + data.Config[k] = v + } + for _, sch := range data.Schema { + if _, ok := data.Config[sch.Name]; !ok && sch.Default != "" { + data.Config[sch.Name] = sch.Default } } + + if r.Method == "POST" { + ok := true + r.ParseForm() + + if data.NameEditable { + data.Name = strings.Join(r.Form["name"], "") + if data.Name == "" { + ok = false + data.InvalidName = true + } + } + + for _, schema := range data.Schema { + field := schema.Name + data.Config[field] = strings.Join(r.Form[field], "") + if data.Config[field] == "" { + if schema.Required { + ok = false + data.Errors[field] = "This field is required" + } + } else if schema.FixedValue != "" { + if data.Config[field] != schema.FixedValue { + ok = false + data.Errors[field] = "This field must be equal to " + schema.FixedValue + } + } else if schema.IsBoolean { + if data.Config[field] != "false" && data.Config[field] != "true" { + ok = false + data.Errors[field] = "This field must be 'true' or 'false'" + } + } else if schema.IsNumeric { + _, err := strconv.Atoi(data.Config[field]) + if err != nil { + ok = false + data.Errors[field] = "This field must be a valid number" + } + } + } + + if ok { + var entry DbAccountConfig + db.Where(&DbAccountConfig{ + MxUserID: login.MxId, + Name: data.Name, + }).Assign(&DbAccountConfig{ + Protocol: protocol, + Config: encryptAccountConfig(data.Config, userKeys[login.MxId]), + }).FirstOrCreate(&entry) + + err := SetAccount(login.MxId, data.Name, protocol, data.Config) + if err == nil { + http.Redirect(w, r, "/", http.StatusFound) + return + } + data.ErrorMessage = err.Error() + } + } + + templateConfig.Execute(w, data) +} + +func handleDelete(w http.ResponseWriter, r *http.Request) { }