Initial ability to configure accounts from web interface

This commit is contained in:
Alex 2020-02-26 22:49:27 +01:00
parent 775fc7b217
commit 8a5ed3f507
11 changed files with 423 additions and 40 deletions

View file

@ -34,6 +34,9 @@ func SetAccount(mxid string, name string, protocol string, config map[string]str
accounts := registeredAccounts[mxid] accounts := registeredAccounts[mxid]
if prev_acct, ok := accounts[name]; ok { if prev_acct, ok := accounts[name]; ok {
prev_acct.Conn.Close()
prev_acct.JoinedRooms = map[RoomID]bool{}
if protocol != prev_acct.Protocol { if protocol != prev_acct.Protocol {
return fmt.Errorf("Wrong protocol") return fmt.Errorf("Wrong protocol")
} }
@ -112,6 +115,8 @@ func RemoveAccount(mxUser string, name string) {
} }
} }
// ----
func SaveDbAccounts(mxid string, key *[32]byte) { func SaveDbAccounts(mxid string, key *[32]byte) {
accountsLock.Lock() accountsLock.Lock()
defer accountsLock.Unlock() 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{}) { func (a *Account) ezbrMessagef(format string, args ...interface{}) {

View file

@ -43,3 +43,24 @@ func (c Configuration) GetBool(k string, deflt ...bool) (bool, error) {
} }
return false, fmt.Errorf("Missing configuration key: %s", k) 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
}

32
connector/irc/config.go Normal file
View file

@ -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",
},
})
}

View file

@ -127,6 +127,10 @@ func (irc *IRC) SetUserInfo(info *UserInfo) error {
} }
func (irc *IRC) SetRoomInfo(roomId RoomID, info *RoomInfo) error { func (irc *IRC) SetRoomInfo(roomId RoomID, info *RoomInfo) error {
if irc.conn == nil {
return fmt.Errorf("Not connected")
}
ch, err := irc.checkRoomId(roomId) ch, err := irc.checkRoomId(roomId)
if err != nil { if err != nil {
return err return err
@ -145,6 +149,10 @@ func (irc *IRC) SetRoomInfo(roomId RoomID, info *RoomInfo) error {
} }
func (irc *IRC) Join(roomId RoomID) error { func (irc *IRC) Join(roomId RoomID) error {
if irc.conn == nil {
return fmt.Errorf("Not connected")
}
ch, err := irc.checkRoomId(roomId) ch, err := irc.checkRoomId(roomId)
if err != nil { if err != nil {
return err return err
@ -155,6 +163,10 @@ func (irc *IRC) Join(roomId RoomID) error {
} }
func (irc *IRC) Invite(userId UserID, 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) who, err := irc.checkUserId(userId)
if err != nil { if err != nil {
return err return err
@ -174,6 +186,10 @@ func (irc *IRC) Invite(userId UserID, roomId RoomID) error {
} }
func (irc *IRC) Leave(roomId RoomID) { func (irc *IRC) Leave(roomId RoomID) {
if irc.conn == nil {
return
}
ch, err := irc.checkRoomId(roomId) ch, err := irc.checkRoomId(roomId)
if err != nil { if err != nil {
return return
@ -183,6 +199,10 @@ func (irc *IRC) Leave(roomId RoomID) {
} }
func (irc *IRC) Send(event *Event) error { func (irc *IRC) Send(event *Event) error {
if irc.conn == nil {
return fmt.Errorf("Not connected")
}
// Workaround girc bug // Workaround girc bug
if event.Text[0] == ':' { if event.Text[0] == ':' {
event.Text = " " + event.Text event.Text = " " + event.Text
@ -231,7 +251,9 @@ func (irc *IRC) Send(event *Event) error {
func (irc *IRC) Close() { func (irc *IRC) Close() {
conn := irc.conn conn := irc.conn
irc.conn = nil irc.conn = nil
if conn != nil {
conn.Close() conn.Close()
}
} }
func (irc *IRC) connectLoop(c *girc.Client) { func (irc *IRC) connectLoop(c *girc.Client) {

View file

@ -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",
},
})
}

View file

@ -69,7 +69,7 @@ func (mm *Mattermost) Configure(c Configuration) error {
return err return err
} }
mm.initial_members, err = c.GetInt("initial_members", 1000) mm.initial_members, err = c.GetInt("initial_members", 100)
if err != nil { if err != nil {
return err return err
} }
@ -312,7 +312,9 @@ func (mm *Mattermost) Send(event *Event) error {
} }
func (mm *Mattermost) Close() { func (mm *Mattermost) Close() {
if mm.conn != nil {
mm.conn.WsQuit = true mm.conn.WsQuit = true
}
if mm.handlerStopChan != nil { if mm.handlerStopChan != nil {
mm.handlerStopChan <- true mm.handlerStopChan <- true
mm.handlerStopChan = nil mm.handlerStopChan = nil

38
connector/xmpp/config.go Normal file
View file

@ -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",
},
})
}

View file

@ -55,11 +55,6 @@ func (xm *XMPP) Configure(c Configuration) error {
// Parse and validate configuration // Parse and validate configuration
var err error var err error
xm.server, err = c.GetString("server")
if err != nil {
return err
}
xm.port, err = c.GetInt("port", 5222) xm.port, err = c.GetInt("port", 5222)
if err != nil { if err != nil {
return err return err
@ -78,9 +73,7 @@ func (xm *XMPP) Configure(c Configuration) error {
if len(jid_parts) != 2 { if len(jid_parts) != 2 {
return fmt.Errorf("Invalid JID: %s", xm.jid) return fmt.Errorf("Invalid JID: %s", xm.jid)
} }
if jid_parts[1] != xm.server { xm.server = jid_parts[1]
return fmt.Errorf("JID %s not on server %s", xm.jid, xm.server)
}
xm.jid_localpart = jid_parts[0] xm.jid_localpart = jid_parts[0]
xm.nickname, _ = c.GetString("nickname", xm.jid_localpart) xm.nickname, _ = c.GetString("nickname", xm.jid_localpart)
@ -353,7 +346,9 @@ func (xm *XMPP) Send(event *Event) error {
} }
func (xm *XMPP) Close() { func (xm *XMPP) Close() {
if xm.conn != nil {
xm.conn.Close() xm.conn.Close()
}
xm.conn = nil xm.conn = nil
xm.connectorLoopNum += 1 xm.connectorLoopNum += 1
} }

71
templates/config.html Normal file
View file

@ -0,0 +1,71 @@
{{define "title"}}Account configuration |{{end}}
{{define "body"}}
<div class="d-flex">
<h4>Configure account</h4>
<a class="ml-auto btn btn-info" href="/">Go back</a>
</div>
{{if .ErrorMessage}}
<div class="alert alert-danger mt-4">An error occurred.
<div style="font-size: 0.8em">{{ .ErrorMessage }}</div>
</div>
{{end}}
<form method="POST" class="mt-4">
<div class="form-group">
<label for="name">Account name:</label>
<input type="text" {{if .NameEditable}}{{else}}disabled="disabled"{{end}} id="name" name="name" class="form-control" value="{{ .Name }}" />
{{if .InvalidName}}
<div class="alert alert-warning">Invalid name (must not be empty)</div>
{{end}}
</div>
<div class="form-group">
<label>Protocol:</label>
<input type="text" disabled="disabled" class="form-control" value="{{ .Protocol }}" />
</div>
{{$config := .Config}}
{{$errors := .Errors}}
{{range $i, $schema := .Schema}}
<div class="form-group">
<label for="{{$schema.Name}}">{{$schema.Description}}</label>
{{if $schema.FixedValue}}
<input type="text"
disabled="disabled"
class="form-control"
name="{{$schema.Name}}"
id="{{$schema.Name}}"
value="{{index $config $schema.Name}}" />
{{else if $schema.IsBoolean}}
{{$value := index $config $schema.Name}}
<label for="{{$schema.Name}}-true">
<input type="radio" name="{{$schema.Name}}" id="{{$schema.Name}}-true" value="true" {{if eq $value "true"}}checked="checked"{{end}} />
Yes
</label>
<label for="{{$schema.Name}}-false">
<input type="radio" name="{{$schema.Name}}" id="{{$schema.Name}}-false" value="false" {{if eq $value "false"}}checked="checked"{{end}} />
No
</label>
{{else if $schema.IsPassword}}
<input type="password"
class="form-control"
name="{{$schema.Name}}"
id="{{$schema.Name}}"
value="{{index $config $schema.Name}}" />
{{else}}
<input type="text"
class="form-control"
name="{{$schema.Name}}"
id="{{$schema.Name}}"
value="{{index $config $schema.Name}}" />
{{end}}
{{$error := index $errors $schema.Name}}
{{if $error}}
<div class="alert alert-warning mt-2">{{$error}}</div>
{{end}}
</div>
{{end}}
<button type="submit" class="btn btn-primary">Save configuration</button>
</form>
{{end}}

View file

@ -8,7 +8,8 @@
<a class="ml-auto btn btn-sm btn-dark" href="/logout">Log out</a> <a class="ml-auto btn btn-sm btn-dark" href="/logout">Log out</a>
</div> </div>
<table class="table mt-4"> {{ if .Accounts }}
<table class="table mt-4">
<thead> <thead>
<tr> <tr>
<th>Account name</th> <th>Account name</th>
@ -17,14 +18,24 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range $name, $acc := .Accounts}} {{range $i, $acc := .Accounts}}
<tr> <tr>
<td>{{ $name }}</td> <td>{{ $acc.AccountName }}</td>
<td>{{ $acc.Protocol }}</td> <td>{{ $acc.Protocol }}</td>
<td>Modifier etc</td> <td>
<a class="btn btn-sm btn-primary" href="/edit/{{ $acc.AccountName }}">Modify</a>
<a class="btn btn-sm btn-danger ml-4" href="/delete/{{ $acc.AccountName }}">Delete</a>
</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
{{end}}
<h5 class="mt-4">Add account</h5>
<a class="btn btn-sm btn-dark" href="/add/irc">IRC</a>
<a class="btn btn-sm btn-warning ml-4" href="/add/xmpp">XMPP</a>
<a class="btn btn-sm btn-info ml-4" href="/add/mattermost">Mattermost</a>
{{end}} {{end}}

143
web.go
View file

@ -5,12 +5,14 @@ import (
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"git.deuxfleurs.fr/Deuxfleurs/easybridge/connector"
"git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib" "git.deuxfleurs.fr/Deuxfleurs/easybridge/mxlib"
) )
@ -30,6 +32,9 @@ func StartWeb() {
r := mux.NewRouter() r := mux.NewRouter()
r.HandleFunc("/", handleHome) r.HandleFunc("/", handleHome)
r.HandleFunc("/logout", handleLogout) 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")) staticfiles := http.FileServer(http.Dir("static"))
r.Handle("/static/{file:.*}", http.StripPrefix("/static/", staticfiles)) 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) func handleAdd(w http.ResponseWriter, r *http.Request) {
for _, acct := range allAccounts { login := checkLogin(w, r)
config, err := decryptAccountConfig(acct.Config, key) if login == nil {
if err != nil { return
ezbrSystemSendf("Could not decrypt stored configuration for account %s", acct.Name)
continue
} }
err = SetAccount(mxid, acct.Name, acct.Protocol, config) protocol := mux.Vars(r)["protocol"]
if err != nil {
ezbrSystemSendf("Could not setup account %s: %s", acct.Name, err.Error()) 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) {
} }