diff --git a/api.go b/api.go index 4c1ccb2..d21645b 100644 --- a/api.go +++ b/api.go @@ -354,6 +354,34 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } } } + var matrixUser MatrixUser + matrixVerified := false + if matrixEnabled { + if req.MatrixPIN == "" { + if app.config.Section("matrix").Key("required").MustBool(false) { + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Matrix verification not completed", req.Code) + respond(401, "errorMatrixVerification", gc) + } + success = false + return + } + } else { + user, ok := app.matrix.tokens[req.MatrixPIN] + if !ok || !user.Verified { + matrixVerified = false + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Matrix PIN was invalid", req.Code) + respond(401, "errorInvalidPIN", gc) + } + success = false + return + } + matrixVerified = user.Verified + matrixUser = *user.User + + } + } telegramTokenIndex := -1 if telegramEnabled { if req.TelegramPIN == "" { @@ -536,7 +564,17 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1] } } - + if matrixVerified { + matrixUser.Contact = req.MatrixContact + delete(app.matrix.tokens, req.MatrixPIN) + if app.storage.matrix == nil { + app.storage.matrix = map[string]MatrixUser{} + } + app.storage.matrix[user.ID] = matrixUser + if err := app.storage.storeMatrixUsers(); err != nil { + app.err.Printf("Failed to store Matrix users: %v", err) + } + } if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 || discordVerified { name := app.getAddressOrName(user.ID) app.debug.Printf("%s: Sending welcome message to %s", req.Username, name) @@ -1239,10 +1277,14 @@ func (app *appContext) GetUsers(gc *gin.Context) { user.Telegram = tgUser.Username user.NotifyThroughTelegram = tgUser.Contact } - if dc, ok := app.storage.discord[jfUser.ID]; ok { - user.Discord = dc.Username + "#" + dc.Discriminator - user.DiscordID = dc.ID - user.NotifyThroughDiscord = dc.Contact + if mxUser, ok := app.storage.matrix[jfUser.ID]; ok { + user.Matrix = mxUser.UserID + user.NotifyThroughMatrix = mxUser.Contact + } + if dcUser, ok := app.storage.discord[jfUser.ID]; ok { + user.Discord = dcUser.Username + "#" + dcUser.Discriminator + user.DiscordID = dcUser.ID + user.NotifyThroughDiscord = dcUser.Contact } resp.UserList[i] = user i++ @@ -1498,6 +1540,8 @@ func (app *appContext) GetConfig(gc *gin.Context) { resp.Sections["email"].Settings["language"] = el resp.Sections["password_resets"].Settings["language"] = pl resp.Sections["telegram"].Settings["language"] = tl + resp.Sections["discord"].Settings["language"] = tl + resp.Sections["matrix"].Settings["language"] = tl gc.JSON(200, resp) } @@ -1506,6 +1550,7 @@ func (app *appContext) GetConfig(gc *gin.Context) { // @Produce json // @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings." // @Success 200 {object} boolResponse +// @Failure 500 {object} boolResponse // @Router /config [post] // @Security Bearer // @tags Configuration @@ -1531,7 +1576,11 @@ func (app *appContext) ModifyConfig(gc *gin.Context) { } } } - tempConfig.SaveTo(app.configPath) + if err := tempConfig.SaveTo(app.configPath); err != nil { + app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err) + respondBool(500, false, gc) + return + } app.debug.Println("Config saved") gc.JSON(200, map[string]bool{"success": true}) if req["restart-program"] != nil && req["restart-program"].(bool) { @@ -2089,6 +2138,7 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { return } if tgUser, ok := app.storage.telegram[req.ID]; ok { + change := tgUser.Contact != req.Telegram tgUser.Contact = req.Telegram app.storage.telegram[req.ID] = tgUser if err := app.storage.storeTelegramUsers(); err != nil { @@ -2096,13 +2146,16 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { app.err.Printf("Telegram: Failed to store users: %v", err) return } - msg := "" - if !req.Telegram { - msg = " not" + if change { + msg := "" + if !req.Telegram { + msg = " not" + } + app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg) } - app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg) } if dcUser, ok := app.storage.discord[req.ID]; ok { + change := dcUser.Contact != req.Discord dcUser.Contact = req.Discord app.storage.discord[req.ID] = dcUser if err := app.storage.storeDiscordUsers(); err != nil { @@ -2110,13 +2163,33 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { app.err.Printf("Discord: Failed to store users: %v", err) return } - msg := "" - if !req.Discord { - msg = " not" + if change { + msg := "" + if !req.Discord { + msg = " not" + } + app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg) + } + } + if mxUser, ok := app.storage.matrix[req.ID]; ok { + change := mxUser.Contact != req.Matrix + mxUser.Contact = req.Matrix + app.storage.matrix[req.ID] = mxUser + if err := app.storage.storeMatrixUsers(); err != nil { + respondBool(500, false, gc) + app.err.Printf("Matrix: Failed to store users: %v", err) + return + } + if change { + msg := "" + if !req.Matrix { + msg = " not" + } + app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg) } - app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg) } if email, ok := app.storage.emails[req.ID]; ok { + change := email.Contact != req.Email email.Contact = req.Email app.storage.emails[req.ID] = email if err := app.storage.storeEmails(); err != nil { @@ -2124,11 +2197,13 @@ func (app *appContext) SetContactMethods(gc *gin.Context) { app.err.Printf("Failed to store emails: %v", err) return } - msg := "" - if !req.Email { - msg = " not" + if change { + msg := "" + if !req.Email { + msg = " not" + } + app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg) } - app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg) } respondBool(200, true, gc) } @@ -2233,6 +2308,140 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) { gc.JSON(200, DiscordInviteDTO{invURL, iconURL}) } +// @Summary Generate and send a new PIN to a specified Matrix user. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 400 {object} boolResponse +// @Failure 401 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param invCode path string true "invite Code" +// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID." +// @Router /invite/{invCode}/matrix/user [post] +// @tags Other +func (app *appContext) MatrixSendPIN(gc *gin.Context) { + code := gc.Param("invCode") + if _, ok := app.storage.invites[code]; !ok { + respondBool(401, false, gc) + return + } + var req MatrixSendPINDTO + gc.BindJSON(&req) + if req.UserID == "" { + respondBool(400, false, gc) + return + } + ok := app.matrix.SendStart(req.UserID) + if !ok { + respondBool(500, false, gc) + return + } + respondBool(200, true, gc) +} + +// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 401 {object} boolResponse +// @Param pin path string true "PIN code to check" +// @Param invCode path string true "invite Code" +// @Param userID path string true "Matrix User ID" +// @Router /invite/{invCode}/matrix/verified/{userID}/{pin} [get] +// @tags Other +func (app *appContext) MatrixCheckPIN(gc *gin.Context) { + code := gc.Param("invCode") + if _, ok := app.storage.invites[code]; !ok { + app.debug.Println("Matrix: Invite code was invalid") + respondBool(401, false, gc) + return + } + userID := gc.Param("userID") + pin := gc.Param("pin") + user, ok := app.matrix.tokens[pin] + if !ok { + app.debug.Println("Matrix: PIN not found") + respondBool(200, false, gc) + return + } + if user.User.UserID != userID { + app.debug.Println("Matrix: User ID of PIN didn't match") + respondBool(200, false, gc) + return + } + user.Verified = true + app.matrix.tokens[pin] = user + respondBool(200, true, gc) +} + +// @Summary Generates a Matrix access token from a username and password. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 400 {object} stringResponse +// @Failure 401 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password." +// @Router /matrix/login [post] +// @tags Other +func (app *appContext) MatrixLogin(gc *gin.Context) { + var req MatrixLoginDTO + gc.BindJSON(&req) + if req.Username == "" || req.Password == "" { + respond(400, "errorLoginBlank", gc) + return + } + token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password) + if err != nil { + app.err.Printf("Matrix: Failed to generate token: %v", err) + respond(401, "Unauthorized", gc) + return + } + tempConfig, _ := ini.Load(app.configPath) + matrix := tempConfig.Section("matrix") + matrix.Key("enabled").SetValue("true") + matrix.Key("homeserver").SetValue(req.Homeserver) + matrix.Key("token").SetValue(token) + matrix.Key("user_id").SetValue(req.Username) + if err := tempConfig.SaveTo(app.configPath); err != nil { + app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err) + respondBool(500, false, gc) + return + } + respondBool(200, true, gc) +} + +// @Summary Links a Matrix user to a Jellyfin account via user IDs. Notifications are turned on by default. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 400 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID." +// @Router /users/matrix [post] +// @tags Other +func (app *appContext) MatrixConnect(gc *gin.Context) { + var req MatrixConnectUserDTO + gc.BindJSON(&req) + if app.storage.matrix == nil { + app.storage.matrix = map[string]MatrixUser{} + } + roomID, err := app.matrix.CreateRoom(req.UserID) + if err != nil { + app.err.Printf("Matrix: Failed to create room: %v", err) + respondBool(500, false, gc) + return + } + app.storage.matrix[req.JellyfinID] = MatrixUser{ + UserID: req.UserID, + RoomID: roomID, + Lang: "en-us", + Contact: true, + } + if err := app.storage.storeMatrixUsers(); err != nil { + app.err.Printf("Failed to store Matrix users: %v", err) + respondBool(500, false, gc) + return + } + respondBool(200, true, gc) +} + // @Summary Returns a list of matching users from a Discord guild, given a username (discriminator optional). // @Produce json // @Success 200 {object} DiscordUsersDTO diff --git a/config.go b/config.go index d605ef5..e0e9ba3 100644 --- a/config.go +++ b/config.go @@ -15,6 +15,7 @@ var emailEnabled = false var messagesEnabled = false var telegramEnabled = false var discordEnabled = false +var matrixEnabled = false func (app *appContext) GetPath(sect, key string) (fs.FS, string) { val := app.config.Section(sect).Key(key).MustString("") @@ -43,7 +44,7 @@ func (app *appContext) loadConfig() error { key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) } } - for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users"} { + for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users"} { app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json")))) } app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") @@ -83,22 +84,26 @@ func (app *appContext) loadConfig() error { app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html") app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt") + app.MustSetValue("matrix", "topic", "Jellyfin notifications") + app.config.Section("jellyfin").Key("version").SetValue(version) app.config.Section("jellyfin").Key("device").SetValue("jfa-go") app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit)) messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false) telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false) discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false) + matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false) if !messagesEnabled { emailEnabled = false telegramEnabled = false discordEnabled = false + matrixEnabled = false } else if app.config.Section("email").Key("method").MustString("") == "" { emailEnabled = false } else { emailEnabled = true } - if !emailEnabled && !telegramEnabled && !discordEnabled { + if !emailEnabled && !telegramEnabled && !discordEnabled && !matrixEnabled { messagesEnabled = false } diff --git a/config/config-base.json b/config/config-base.json index 36bad28..d14f886 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -568,7 +568,7 @@ "depends_true": "enabled", "type": "bool", "value": false, - "description": "Require Discord connection on sign-up." + "description": "Require Discord connection on sign-up. See the jfa-go wiki for info on setting this up." }, "token": { "name": "API Token", @@ -633,7 +633,7 @@ "order": [], "meta": { "name": "Telegram", - "description": "Settings for Telegram signup/notifications" + "description": "Settings for Telegram signup/notifications. See the jfa-go wiki for info on setting this up." }, "settings": { "enabled": { @@ -676,6 +676,80 @@ } } }, + "matrix": { + "order": [], + "meta": { + "name": "Matrix", + "description": "Settings for Matrix invites/signup/notifications. See the jfa-go wiki for info on setting this up." + }, + "settings": { + "enabled": { + "name": "Enabled", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Enable signup verification through Matrix and the sending of notifications through it.\nSee the jfa-go wiki for setting up a bot." + }, + "required": { + "name": "Require on sign-up", + "required": false, + "required_restart": true, + "depends_true": "enabled", + "type": "bool", + "value": false, + "description": "Require Matrix connection on sign-up." + }, + "homeserver": { + "name": "Home Server URL", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Matrix Home server URL." + }, + "token": { + "name": "Access Token", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Matrix Bot API Token." + }, + "user_id": { + "name": "Bot User ID", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "User ID of bot account (Example: @jfa-bot:riot.im)" + }, + "topic": { + "name": "Chat topic", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "Jellyfin notifications", + "description": "Topic of Matrix private chats." + }, + "language": { + "name": "Language", + "required": false, + "requires_restart": false, + "depends_true": "enabled", + "type": "select", + "options": [ + ["en-us", "English (US)"] + ], + "value": "en-us", + "description": "Default Matrix message language. Visit weblate if you'd like to translate." + } + } + }, "password_resets": { "order": [], "meta": { @@ -1225,6 +1299,14 @@ "value": "", "description": "Stores telegram user IDs and language preferences." }, + "matrix_users": { + "name": "Matrix users", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Stores matrix user IDs and language preferences." + }, "discord_users": { "name": "Discord users", "required": false, diff --git a/email.go b/email.go index d541a04..88a8bdb 100644 --- a/email.go +++ b/email.go @@ -25,6 +25,8 @@ import ( "github.com/mailgun/mailgun-go/v4" ) +var renderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) + // implements email sending, right now via smtp or mailgun. type EmailClient interface { Send(fromName, fromAddr string, message *Message, address ...string) error @@ -337,7 +339,6 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (*Message, error) { email := &Message{Subject: subject} - renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) html := markdown.ToHTML([]byte(md), nil, renderer) text := stripMarkdown(md) message := app.config.Section("messages").Key("message").String() @@ -817,6 +818,12 @@ func (app *appContext) sendByID(email *Message, ID ...string) error { return err } } + if mxChat, ok := app.storage.matrix[id]; ok && mxChat.Contact && matrixEnabled { + err = app.matrix.Send(email, mxChat.RoomID) + if err != nil { + return err + } + } if address, ok := app.storage.emails[id]; ok && address.Contact && emailEnabled { err = app.email.send(email, address.Addr) if err != nil { diff --git a/go.mod b/go.mod index ec77101..4dfa77f 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/lithammer/shortuuid/v3 v3.0.4 github.com/mailgun/mailgun-go/v4 v4.5.1 github.com/mailru/easyjson v0.7.7 // indirect + github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/smartystreets/goconvey v1.6.4 // indirect diff --git a/go.sum b/go.sum index 9a577fc..f96fe96 100644 --- a/go.sum +++ b/go.sum @@ -189,6 +189,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16 h1:ZtO5uywdd5dLDCud4r0r55eP4j9FuUNpl60Gmntcop4= +github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16/go.mod h1:/gBX06Kw0exX1HrwmoBibFA98yBk/jxKpGVeyQbff+s= github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= diff --git a/html/admin.html b/html/admin.html index 31db2b2..a486455 100644 --- a/html/admin.html +++ b/html/admin.html @@ -8,6 +8,7 @@ window.emailEnabled = {{ .email_enabled }}; window.telegramEnabled = {{ .telegram_enabled }}; window.discordEnabled = {{ .discord_enabled }}; + window.matrixEnabled = {{ .matrix_enabled }}; window.ombiEnabled = {{ .ombiEnabled }}; window.usernameEnabled = {{ .username }}; window.langFile = JSON.parse({{ .language }}); @@ -340,6 +341,19 @@ {{ end }} +
@@ -545,6 +559,9 @@ {{ if .telegram_enabled }} Telegram {{ end }} + {{ if .matrix_enabled }} + Matrix + {{ end }} {{ if .discord_enabled }} Discord {{ end }} diff --git a/html/form-base.html b/html/form-base.html index 855253b..be95c3e 100644 --- a/html/form-base.html +++ b/html/form-base.html @@ -22,6 +22,9 @@ window.discordPIN = "{{ .discordPIN }}"; window.discordInviteLink = {{ .discordInviteLink }}; window.discordServerName = "{{ .discordServerName }}"; + window.matrixEnabled = {{ .matrixEnabled }}; + window.matrixRequired = {{ .matrixRequired }}; + window.matrixUserID = "{{ .matrixUser }}"; {{ end }} diff --git a/html/form.html b/html/form.html index 6b785d0..fecaccd 100644 --- a/html/form.html +++ b/html/form.html @@ -48,6 +48,24 @@ {{ end }} + {{ if .matrixEnabled }} + + {{ end }} @@ -84,7 +102,10 @@ {{ if .discordEnabled }} {{ .strings.linkDiscord }} {{ end }} - {{ if or (.telegramEnabled) (.discordEnabled) }} + {{ if .matrixEnabled }} + {{ .strings.linkMatrix }} + {{ end }} + {{ if or (.telegramEnabled) (or .discordEnabled .matrixEnabled) }}
{{ end }} + {{ if .matrixEnabled }} + + {{ end }}
{{ end }} diff --git a/images/matrix/1.png b/images/matrix/1.png new file mode 100644 index 0000000..8e1ae81 Binary files /dev/null and b/images/matrix/1.png differ diff --git a/images/matrix/2.png b/images/matrix/2.png new file mode 100644 index 0000000..0bf9bba Binary files /dev/null and b/images/matrix/2.png differ diff --git a/images/matrix/3.png b/images/matrix/3.png new file mode 100644 index 0000000..126b4e5 Binary files /dev/null and b/images/matrix/3.png differ diff --git a/images/matrix/4.png b/images/matrix/4.png new file mode 100644 index 0000000..c941c5e Binary files /dev/null and b/images/matrix/4.png differ diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 3936b8c..1c53ded 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -98,7 +98,9 @@ "notifyUserCreation": "On user creation", "sendPIN": "Ask the user to send the PIN below to the bot.", "searchDiscordUser": "Start typing the Discord username to find the user.", - "findDiscordUser": "Find Discord user" + "findDiscordUser": "Find Discord user", + "linkMatrixDescription": "Enter the username and password of the user to use as a bot. Once submitted, the app will restart.", + "matrixHomeServer": "Home server address" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 3e9ef53..5f8ec44 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -8,6 +8,7 @@ "emailAddress": "Email Address", "name": "Name", "submit": "Submit", + "send": "Send", "success": "Success", "error": "Error", "copy": "Copy", @@ -18,6 +19,7 @@ "contactEmail": "Contact through Email", "contactTelegram": "Contact through Telegram", "linkDiscord": "Link Discord", + "linkMatrix": "Link Matrix", "contactDiscord": "Contact through Discord", "theme": "Theme" } diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 6726c0b..7b85cfc 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -19,14 +19,17 @@ "confirmationRequiredMessage": "Please check your email inbox to verify your address.", "yourAccountIsValidUntil": "Your account will be valid until {date}.", "sendPIN": "Send the PIN below to the bot, then come back here to link your account.", - "sendPINDiscord": "Type {command} in {server_channel} on Discord, then send the PIN below via DM to the bot." + "sendPINDiscord": "Type {command} in {server_channel} on Discord, then send the PIN below via DM to the bot.", + "matrixEnterUser": "Enter your User ID, press submit, and a PIN will be sent to you. Enter it here to continue." }, "notifications": { "errorUserExists": "User already exists.", "errorInvalidCode": "Invalid invite code.", "errorTelegramVerification": "Telegram verification required.", "errorDiscordVerification": "Discord verification required.", + "errorMatrixVerification": "Matrix verification required.", "errorInvalidPIN": "PIN is invalid.", + "errorUnknown": "Unknown error.", "verified": "Account verified." }, "validationStrings": { diff --git a/lang/telegram/en-us.json b/lang/telegram/en-us.json index 642399f..e93aed7 100644 --- a/lang/telegram/en-us.json +++ b/lang/telegram/en-us.json @@ -4,6 +4,7 @@ }, "strings": { "startMessage": "Hi!\nEnter your Jellyfin PIN code here to verify your account.", + "matrixStartMessage": "Hi\nEnter the below PIN in the Jellyfin sign-up page to verify your account.", "invalidPIN": "That PIN was invalid, try again.", "pinSuccess": "Success! You can now return to the sign-up page.", "languageMessage": "Note: See available languages with {command}, and set language with {command} ." diff --git a/main.go b/main.go index fa4749d..afaecc0 100644 --- a/main.go +++ b/main.go @@ -100,6 +100,7 @@ type appContext struct { email *Emailer telegram *TelegramDaemon discord *DiscordDaemon + matrix *MatrixDaemon info, debug, err logger.Logger host string port int @@ -353,6 +354,10 @@ func start(asDaemon, firstCall bool) { if err := app.storage.loadDiscordUsers(); err != nil { app.err.Printf("Failed to load Discord users: %v", err) } + app.storage.matrix_path = app.config.Section("files").Key("matrix_users").String() + if err := app.storage.loadMatrixUsers(); err != nil { + app.err.Printf("Failed to load Matrix users: %v", err) + } app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String() app.storage.loadProfiles() @@ -590,6 +595,16 @@ func start(asDaemon, firstCall bool) { defer app.discord.Shutdown() } } + if matrixEnabled { + app.matrix, err = newMatrixDaemon(app) + if err != nil { + app.err.Printf("Failed to initialize Matrix daemon: %v", err) + matrixEnabled = false + } else { + go app.matrix.run() + defer app.matrix.Shutdown() + } + } } else { debugMode = false address = "0.0.0.0:8056" diff --git a/matrix.go b/matrix.go new file mode 100644 index 0000000..58cf55b --- /dev/null +++ b/matrix.go @@ -0,0 +1,238 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/gomarkdown/markdown" + "github.com/matrix-org/gomatrix" +) + +type MatrixDaemon struct { + Stopped bool + ShutdownChannel chan string + bot *gomatrix.Client + userID string + tokens map[string]UnverifiedUser // Map of tokens to users + languages map[string]string // Map of roomIDs to language codes + app *appContext +} + +type UnverifiedUser struct { + Verified bool + User *MatrixUser +} + +type MatrixUser struct { + RoomID string + UserID string + Lang string + Contact bool +} + +type MatrixIdentifier struct { + User string `json:"user"` + IdentType string `json:"type"` +} + +func (m MatrixIdentifier) Type() string { return m.IdentType } + +var matrixFilter = gomatrix.Filter{ + Room: gomatrix.RoomFilter{ + Timeline: gomatrix.FilterPart{ + Types: []string{ + "m.room.message", + "m.room.member", + }, + }, + }, + EventFields: []string{ + "type", + "event_id", + "room_id", + "state_key", + "sender", + "content.body", + "content.membership", + }, +} + +func newMatrixDaemon(app *appContext) (d *MatrixDaemon, err error) { + matrix := app.config.Section("matrix") + homeserver := matrix.Key("homeserver").String() + token := matrix.Key("token").String() + d = &MatrixDaemon{ + ShutdownChannel: make(chan string), + userID: matrix.Key("user_id").String(), + tokens: map[string]UnverifiedUser{}, + languages: map[string]string{}, + app: app, + } + d.bot, err = gomatrix.NewClient(homeserver, d.userID, token) + if err != nil { + return + } + filter, err := json.Marshal(matrixFilter) + if err != nil { + return + } + resp, err := d.bot.CreateFilter(filter) + d.bot.Store.SaveFilterID(d.userID, resp.FilterID) + for _, user := range app.storage.matrix { + if user.Lang != "" { + d.languages[user.RoomID] = user.Lang + } + } + return +} + +func (d *MatrixDaemon) generateAccessToken(homeserver, username, password string) (string, error) { + req := &gomatrix.ReqLogin{ + Type: "m.login.password", + Identifier: MatrixIdentifier{ + User: username, + IdentType: "m.id.user", + }, + Password: password, + DeviceID: "jfa-go-" + commit, + } + bot, err := gomatrix.NewClient(homeserver, username, "") + if err != nil { + return "", err + } + resp, err := bot.Login(req) + if err != nil { + return "", err + } + return resp.AccessToken, nil +} + +func (d *MatrixDaemon) run() { + d.app.info.Println("Starting Matrix bot daemon") + syncer := d.bot.Syncer.(*gomatrix.DefaultSyncer) + syncer.OnEventType("m.room.message", d.handleMessage) + // syncer.OnEventType("m.room.member", d.handleMembership) + if err := d.bot.Sync(); err != nil { + d.app.err.Printf("Matrix sync failed: %v", err) + } +} + +func (d *MatrixDaemon) Shutdown() { + d.bot.StopSync() + d.Stopped = true + close(d.ShutdownChannel) +} + +func (d *MatrixDaemon) handleMessage(event *gomatrix.Event) { + if event.Sender == d.userID { + return + } + lang := "en-us" + if l, ok := d.languages[event.RoomID]; ok { + if _, ok := d.app.storage.lang.Telegram[l]; ok { + lang = l + } + } + sects := strings.Split(event.Content["body"].(string), " ") + switch sects[0] { + case "!lang": + if len(sects) == 2 { + d.commandLang(event, sects[1], lang) + } else { + d.commandLang(event, "", lang) + } + } +} + +func (d *MatrixDaemon) commandLang(event *gomatrix.Event, code, lang string) { + if code == "" { + list := "!lang \n" + for c := range d.app.storage.lang.Telegram { + list += fmt.Sprintf("%s: %s\n", c, d.app.storage.lang.Telegram[c].Meta.Name) + } + _, err := d.bot.SendText( + event.RoomID, + list, + ) + if err != nil { + d.app.err.Printf("Matrix: Failed to send message to \"%s\": %v", event.Sender, err) + } + return + } + if _, ok := d.app.storage.lang.Telegram[code]; !ok { + return + } + d.languages[event.RoomID] = code + if u, ok := d.app.storage.matrix[event.RoomID]; ok { + u.Lang = code + d.app.storage.matrix[event.RoomID] = u + if err := d.app.storage.storeMatrixUsers(); err != nil { + d.app.err.Printf("Matrix: Failed to store Matrix users: %v", err) + } + } +} + +func (d *MatrixDaemon) CreateRoom(userID string) (string, error) { + room, err := d.bot.CreateRoom(&gomatrix.ReqCreateRoom{ + Visibility: "private", + Invite: []string{userID}, + Topic: d.app.config.Section("matrix").Key("topic").String(), + }) + if err != nil { + return "", err + } + return room.RoomID, nil +} + +func (d *MatrixDaemon) SendStart(userID string) (ok bool) { + roomID, err := d.CreateRoom(userID) + if err != nil { + d.app.err.Printf("Failed to create room for user \"%s\": %v", userID, err) + return + } + lang := "en-us" + pin := genAuthToken() + d.tokens[pin] = UnverifiedUser{ + false, + &MatrixUser{ + RoomID: roomID, + UserID: userID, + Lang: lang, + }, + } + _, err = d.bot.SendText( + roomID, + d.app.storage.lang.Telegram[lang].Strings.get("matrixStartMessage")+"\n\n"+pin+"\n\n"+ + d.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "!lang"}), + ) + if err != nil { + d.app.err.Printf("Matrix: Failed to send welcome message to \"%s\": %v", userID, err) + return + } + ok = true + return +} + +func (d *MatrixDaemon) Send(message *Message, roomID ...string) (err error) { + md := "" + if message.Markdown != "" { + // Convert images to links + md = string(markdown.ToHTML([]byte(strings.ReplaceAll(message.Markdown, "![", "[")), nil, renderer)) + } + for _, id := range roomID { + if md != "" { + _, err = d.bot.SendFormattedText(id, message.Text, md) + } else { + _, err = d.bot.SendText(id, message.Text) + } + if err != nil { + return + } + } + return +} + +// User enters ID on sign-up, a PIN is sent to them. They enter it on sign-up. + +// Message the user first, to avoid E2EE by default diff --git a/models.go b/models.go index e14030d..1c7b9cc 100644 --- a/models.go +++ b/models.go @@ -19,6 +19,8 @@ type newUserDTO struct { TelegramContact bool `json:"telegram_contact"` // Whether or not to use telegram for notifications/pwrs DiscordPIN string `json:"discord_pin" example:"A1-B2-3C"` // Discord verification PIN (if used) DiscordContact bool `json:"discord_contact"` // Whether or not to use discord for notifications/pwrs + MatrixPIN string `json:"matrix_pin" example:"A1-B2-3C"` // Matrix verification PIN (if used) + MatrixContact bool `json:"matrix_contact"` // Whether or not to use matrix for notifications/pwrs } type newUserResponse struct { @@ -137,6 +139,8 @@ type respUser struct { Discord string `json:"discord"` // Discord username (if known) DiscordID string `json:"discord_id"` // Discord user ID for creating links. NotifyThroughDiscord bool `json:"notify_discord"` + Matrix string `json:"matrix"` // Matrix ID (if known) + NotifyThroughMatrix bool `json:"notify_matrix"` } type getUsersDTO struct { @@ -260,6 +264,7 @@ type SetContactMethodsDTO struct { Email bool `json:"email"` Discord bool `json:"discord"` Telegram bool `json:"telegram"` + Matrix bool `json:"matrix"` } type DiscordUserDTO struct { @@ -281,3 +286,22 @@ type DiscordInviteDTO struct { InviteURL string `json:"invite"` IconURL string `json:"icon"` } + +type MatrixSendPINDTO struct { + UserID string `json:"user_id"` +} + +type MatrixCheckPINDTO struct { + PIN string `json:"pin"` +} + +type MatrixConnectUserDTO struct { + JellyfinID string `json:"jf_id"` + UserID string `json:"user_id"` +} + +type MatrixLoginDTO struct { + Homeserver string `json:"homeserver"` + Username string `json:"username"` + Password string `json:"password"` +} diff --git a/router.go b/router.go index a717d49..295f7eb 100644 --- a/router.go +++ b/router.go @@ -127,6 +127,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.GET(p+"/invite/:invCode/discord/invite", app.DiscordServerInvite) } } + if matrixEnabled { + router.GET(p+"/invite/:invCode/matrix/verified/:userID/:pin", app.MatrixCheckPIN) + router.POST(p+"/invite/:invCode/matrix/user", app.MatrixSendPIN) + router.POST(p+"/users/matrix", app.MatrixConnect) + } } if *SWAGGER { app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n")) @@ -164,7 +169,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.GET(p+"/config", app.GetConfig) api.POST(p+"/config", app.ModifyConfig) api.POST(p+"/restart", app.restart) - if telegramEnabled || discordEnabled { + if telegramEnabled || discordEnabled || matrixEnabled { api.GET(p+"/telegram/pin", app.TelegramGetPin) api.GET(p+"/telegram/verified/:pin", app.TelegramVerified) api.POST(p+"/users/telegram", app.TelegramAddUser) @@ -178,6 +183,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.GET(p+"/ombi/users", app.OmbiUsers) api.POST(p+"/ombi/defaults", app.SetOmbiDefaults) } + api.POST(p+"/matrix/login", app.MatrixLogin) + } } diff --git a/storage.go b/storage.go index c32fb75..ae2022a 100644 --- a/storage.go +++ b/storage.go @@ -15,21 +15,22 @@ import ( ) type Storage struct { - timePattern string - invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path string - users map[string]time.Time - invites Invites - profiles map[string]Profile - defaultProfile string - displayprefs, ombi_template map[string]interface{} - emails map[string]EmailAddress - telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. - discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users. - customEmails customEmails - policy mediabrowser.Policy - configuration mediabrowser.Configuration - lang Lang - invitesLock, usersLock sync.Mutex + timePattern string + invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path string + users map[string]time.Time + invites Invites + profiles map[string]Profile + defaultProfile string + displayprefs, ombi_template map[string]interface{} + emails map[string]EmailAddress + telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. + discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users. + matrix map[string]MatrixUser // Map of Jellyfin user IDs to Matrix users. + customEmails customEmails + policy mediabrowser.Policy + configuration mediabrowser.Configuration + lang Lang + invitesLock, usersLock sync.Mutex } type TelegramUser struct { @@ -790,6 +791,14 @@ func (st *Storage) storeDiscordUsers() error { return storeJSON(st.discord_path, st.discord) } +func (st *Storage) loadMatrixUsers() error { + return loadJSON(st.matrix_path, &st.matrix) +} + +func (st *Storage) storeMatrixUsers() error { + return storeJSON(st.matrix_path, st.matrix) +} + func (st *Storage) loadCustomEmails() error { return loadJSON(st.customEmails_path, &st.customEmails) } diff --git a/telegram.go b/telegram.go index 4cca16f..654d05b 100644 --- a/telegram.go +++ b/telegram.go @@ -58,7 +58,7 @@ func genAuthToken() string { rand.Seed(time.Now().UnixNano()) pin := make([]rune, 8) for i := range pin { - if i == 2 || i == 5 { + if (i+1)%3 == 0 { pin[i] = '-' } else { pin[i] = runes[rand.Intn(len(runes))] diff --git a/ts/admin.ts b/ts/admin.ts index edfb009..6efaa4e 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -63,6 +63,8 @@ window.availableProfiles = window.availableProfiles || []; window.modals.updateInfo = new Modal(document.getElementById("modal-update")); + window.modals.matrix = new Modal(document.getElementById("modal-matrix")); + if (window.telegramEnabled) { window.modals.telegram = new Modal(document.getElementById("modal-telegram")); } diff --git a/ts/form.ts b/ts/form.ts index 94d3874..b3b0815 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -1,6 +1,6 @@ import { Modal } from "./modules/modal.js"; import { notificationBox, whichAnimationEvent } from "./modules/common.js"; -import { _get, _post, toggleLoader, toDateString } from "./modules/common.js"; +import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; interface formWindow extends Window { @@ -9,6 +9,7 @@ interface formWindow extends Window { successModal: Modal; telegramModal: Modal; discordModal: Modal; + matrixModal: Modal; confirmationModal: Modal code: string; messages: { [key: string]: string }; @@ -20,6 +21,8 @@ interface formWindow extends Window { discordStartCommand: string; discordInviteLink: boolean; discordServerName: string; + matrixRequired: boolean; + matrixUserID: string; userExpiryEnabled: boolean; userExpiryMonths: number; userExpiryDays: number; @@ -150,6 +153,69 @@ if (window.discordEnabled) { }; } +var matrixVerified = false; +var matrixPIN = ""; +if (window.matrixEnabled) { + window.matrixModal = new Modal(document.getElementById("modal-matrix"), window.matrixRequired); + const matrixButton = document.getElementById("link-matrix") as HTMLSpanElement; + matrixButton.onclick = window.matrixModal.show; + const submitButton = document.getElementById("matrix-send") as HTMLSpanElement; + const input = document.getElementById("matrix-userid") as HTMLInputElement; + let userID = ""; + submitButton.onclick = () => { + addLoader(submitButton); + if (userID == "") { + const send = { + user_id: input.value + }; + _post("/invite/" + window.code + "/matrix/user", send, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + removeLoader(submitButton); + userID = input.value; + if (req.status != 200) { + window.notifications.customError("errorUnknown", window.messages["errorUnknown"]); + window.matrixModal.close(); + return; + } + submitButton.classList.add("~positive"); + submitButton.classList.remove("~info"); + setTimeout(() => { + submitButton.classList.add("~info"); + submitButton.classList.remove("~positive"); + }, 2000); + input.placeholder = "PIN"; + input.value = ""; + } + }); + } else { + _get("/invite/" + window.code + "/matrix/verified/" + userID + "/" + input.value, null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + removeLoader(submitButton) + const valid = req.response["success"] as boolean; + if (valid) { + window.matrixModal.close(); + window.notifications.customPositive("successVerified", "", window.messages["verified"]); + matrixVerified = true; + matrixPIN = input.value; + matrixButton.classList.add("unfocused"); + document.getElementById("contact-via").classList.remove("unfocused"); + const radio = document.getElementById("contact-via-discord") as HTMLInputElement; + radio.checked = true; + } else { + window.notifications.customError("errorInvalidPIN", window.messages["errorInvalidPIN"]); + submitButton.classList.add("~critical"); + submitButton.classList.remove("~info"); + setTimeout(() => { + submitButton.classList.add("~info"); + submitButton.classList.remove("~critical"); + }, 800); + } + } + },); + } + }; +} + if (window.confirmation) { window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true); } @@ -229,6 +295,8 @@ interface sendDTO { telegram_contact?: boolean; discord_pin?: string; discord_contact?: boolean; + matrix_pin?: string; + matrix_contact?: boolean; } const create = (event: SubmitEvent) => { @@ -254,6 +322,13 @@ const create = (event: SubmitEvent) => { send.discord_contact = true; } } + if (matrixVerified) { + send.matrix_pin = matrixPIN; + const radio = document.getElementById("contact-via-matrix") as HTMLInputElement; + if (radio.checked) { + send.matrix_contact = true; + } + } _post("/newUser", send, (req: XMLHttpRequest) => { if (req.readyState == 4) { let vals = req.response as respDTO; diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index b00ef97..17d8e06 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -18,6 +18,8 @@ interface User { discord: string; notify_discord: boolean; discord_id: string; + matrix: string; + notify_matrix: boolean; } interface getPinResponse { @@ -44,13 +46,27 @@ class user implements User { private _discordUsername: string; private _discordID: string; private _notifyDiscord: boolean; + private _matrix: HTMLTableDataCellElement; + private _matrixID: string; + private _notifyMatrix: boolean; private _expiry: HTMLTableDataCellElement; private _expiryUnix: number; private _lastActive: HTMLTableDataCellElement; private _lastActiveUnix: number; + private _notifyDropdown: HTMLDivElement; id = ""; private _selected: boolean; + private _lastNotifyMethod = (): string => { + // Telegram, Matrix, Discord + const telegram = this._telegramUsername && this._telegramUsername != ""; + const discord = this._discordUsername && this._discordUsername != ""; + const matrix = this._matrixID && this._matrixID != ""; + if (discord) return "discord"; + if (matrix) return "matrix"; + if (telegram) return "telegram"; + } + get selected(): boolean { return this._selected; } set selected(state: boolean) { this._selected = state; @@ -96,105 +112,179 @@ class user implements User { get notify_email(): boolean { return this._notifyEmail; } set notify_email(s: boolean) { - this._notifyEmail = s; - if (window.telegramEnabled && this._telegramUsername != "") { - const email = this._telegram.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement; - if (email) { - email.checked = s; + if (this._notifyDropdown) { + (this._notifyDropdown.querySelector(".accounts-contact-email") as HTMLInputElement).checked = s; + } + } + + private _constructDropdown = (): HTMLDivElement => { + const el = document.createElement("div") as HTMLDivElement; + const telegram = this._telegramUsername != ""; + const discord = this._discordUsername != ""; + const matrix = this._matrixID != ""; + if (!telegram && !discord && !matrix) return; + let innerHTML = ` + + + `; + el.innerHTML = innerHTML; + const button = el.querySelector("i"); + const dropdown = el.querySelector("div.dropdown") as HTMLDivElement; + const checks = el.querySelectorAll("input") as NodeListOf; + for (let i = 0; i < checks.length; i++) { + checks[i].onclick = () => this._setNotifyMethod(); + } + + button.onclick = () => { + dropdown.classList.add("selected"); + document.addEventListener("click", outerClickListener); + }; + const outerClickListener = (event: Event) => { + if (!(event.target instanceof HTMLElement && (el.contains(event.target) || button.contains(event.target)))) { + dropdown.classList.remove("selected"); + document.removeEventListener("click", outerClickListener); + } + }; + return el; + } + + get matrix(): string { return this._matrixID; } + set matrix(u: string) { + if (!window.matrixEnabled) { + this._notifyDropdown.querySelector(".accounts-area-matrix").classList.add("unfocused"); + return; + } + const lastNotifyMethod = this._lastNotifyMethod() == "matrix"; + this._matrixID = u; + if (!u) { + this._notifyDropdown.querySelector(".accounts-area-matrix").classList.add("unfocused"); + this._matrix.innerHTML = ` + ${window.lang.strings("add")} + + `; + (this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix; + } else { + this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused"); + this._matrix.innerHTML = ` +
+ ${u} +
+ `; + if (lastNotifyMethod) { + (this._matrix.querySelector(".table-inline") as HTMLDivElement).appendChild(this._notifyDropdown); } } - if (window.discordEnabled && this._discordUsername) { - const email = this._discord.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement; - email.checked = s; + } + + private _addMatrix = () => { + const addButton = this._matrix.querySelector(".btn") as HTMLSpanElement; + const icon = this._matrix.querySelector("i"); + const input = this._matrix.querySelector("input.stealth-input") as HTMLInputElement; + if (addButton.classList.contains("chip")) { + input.classList.remove("unfocused"); + addButton.innerHTML = ``; + addButton.classList.remove("chip") + if (icon) { + icon.classList.add("unfocused"); + } + } else { + if (input.value.charAt(0) != "@" || !input.value.includes(":")) return; + const send = { + jf_id: this.id, + user_id: input.value + } + _post("/users/matrix", send, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + document.dispatchEvent(new CustomEvent("accounts-reload")); + if (req.status != 200) { + window.notifications.customError("errorConnectMatrix", window.lang.notif("errorFailureCheckLogs")); + return; + } + window.notifications.customSuccess("connectMatrix", window.lang.notif("accountConnected")); + } + }); + } + } + + get notify_matrix(): boolean { return this._notifyMatrix; } + set notify_matrix(s: boolean) { + if (this._notifyDropdown) { + (this._notifyDropdown.querySelector(".accounts-contact-matrix") as HTMLInputElement).checked = s; } } get telegram(): string { return this._telegramUsername; } set telegram(u: string) { - if (!window.telegramEnabled) return; + if (!window.telegramEnabled) { + this._notifyDropdown.querySelector(".accounts-area-telegram").classList.add("unfocused"); + return; + } + const lastNotifyMethod = this._lastNotifyMethod() == "telegram"; this._telegramUsername = u; - if (u == "") { - this._telegram.innerHTML = `Add`; + if (!u) { + this._notifyDropdown.querySelector(".accounts-area-telegram").classList.add("unfocused"); + this._telegram.innerHTML = `${window.lang.strings("add")}`; (this._telegram.querySelector("span") as HTMLSpanElement).onclick = this._addTelegram; } else { - let innerHTML = ` + this._notifyDropdown.querySelector(".accounts-area-telegram").classList.remove("unfocused"); + this._telegram.innerHTML = `
@${u} +
`; - if (!window.discordEnabled || !this._discordUsername) { - innerHTML += ` - - - `; - } - innerHTML += ""; - this._telegram.innerHTML = innerHTML; - if (!window.discordEnabled || !this._discordUsername) { - // Javascript is necessary as including the button inside the dropdown would make it too wide to display next to the username. - const button = this._telegram.querySelector("i"); - const dropdown = this._telegram.querySelector("div.dropdown") as HTMLDivElement; - const checks = this._telegram.querySelectorAll("input") as NodeListOf; - for (let i = 0; i < checks.length; i++) { - checks[i].onclick = () => this._setNotifyMethod("telegram"); - } - - button.onclick = () => { - dropdown.classList.add("selected"); - document.addEventListener("click", outerClickListener); - }; - const outerClickListener = (event: Event) => { - if (!(event.target instanceof HTMLElement && (this._telegram.contains(event.target) || button.contains(event.target)))) { - dropdown.classList.remove("selected"); - document.removeEventListener("click", outerClickListener); - } - }; + if (lastNotifyMethod) { + (this._telegram.querySelector(".table-inline") as HTMLDivElement).appendChild(this._notifyDropdown); } } } get notify_telegram(): boolean { return this._notifyTelegram; } set notify_telegram(s: boolean) { - if (!window.telegramEnabled || !this._telegramUsername) return; - this._notifyTelegram = s; - const telegram = this._telegram.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement; - if (telegram) { - telegram.checked = s; - } - if (window.discordEnabled && this._discordUsername) { - const telegram = this._discord.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement; - telegram.checked = s; + if (this._notifyDropdown) { + (this._notifyDropdown.querySelector(".accounts-contact-telegram") as HTMLInputElement).checked = s; } } - private _setNotifyMethod = (mode: string = "telegram") => { - let el: HTMLElement; - if (mode == "telegram") { el = this._telegram } - else if (mode == "discord") { el = this._discord } - const email = el.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement; + private _setNotifyMethod = () => { + const email = this._notifyDropdown.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement; let send = { id: this.id, email: email.checked } if (window.telegramEnabled && this._telegramUsername) { - const telegram = el.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement; + const telegram = this._notifyDropdown.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement; send["telegram"] = telegram.checked; } if (window.discordEnabled && this._discordUsername) { - const discord = el.getElementsByClassName("accounts-contact-discord")[0] as HTMLInputElement; + const discord = this._notifyDropdown.getElementsByClassName("accounts-contact-discord")[0] as HTMLInputElement; send["discord"] = discord.checked; } _post("/users/contact", send, (req: XMLHttpRequest) => { @@ -219,62 +309,26 @@ class user implements User { get discord(): string { return this._discordUsername; } set discord(u: string) { - if (!window.discordEnabled) return; + if (!window.discordEnabled) { + this._notifyDropdown.querySelector(".accounts-area-discord").classList.add("unfocused"); + return; + } + const lastNotifyMethod = this._lastNotifyMethod() == "discord"; this._discordUsername = u; - if (u == "") { + if (!u) { this._discord.innerHTML = `Add`; (this._discord.querySelector("span") as HTMLSpanElement).onclick = () => addDiscord(this.id); + this._notifyDropdown.querySelector(".accounts-area-discord").classList.add("unfocused"); } else { - let innerHTML = ` + this._notifyDropdown.querySelector(".accounts-area-discord").classList.remove("unfocused"); + this._discord.innerHTML = `
${u} - -
`; - this._discord.innerHTML = innerHTML; - // Javascript is necessary as including the button inside the dropdown would make it too wide to display next to the username. - const button = this._discord.querySelector("i"); - const dropdown = this._discord.querySelector("div.dropdown") as HTMLDivElement; - const checks = this._discord.querySelectorAll("input") as NodeListOf; - for (let i = 0; i < checks.length; i++) { - checks[i].onclick = () => this._setNotifyMethod("discord"); + if (lastNotifyMethod) { + (this._discord.querySelector(".table-inline") as HTMLDivElement).appendChild(this._notifyDropdown); } - - button.onclick = () => { - dropdown.classList.add("selected"); - document.addEventListener("click", outerClickListener); - }; - const outerClickListener = (event: Event) => { - if (!(event.target instanceof HTMLElement && (this._discord.contains(event.target) || button.contains(event.target)))) { - dropdown.classList.remove("selected"); - document.removeEventListener("click", outerClickListener); - } - }; } } @@ -288,13 +342,8 @@ class user implements User { get notify_discord(): boolean { return this._notifyDiscord; } set notify_discord(s: boolean) { - if (!window.discordEnabled || !this._discordUsername) return; - this._notifyDiscord = s; - const discord = this._discord.getElementsByClassName("accounts-contact-discord")[0] as HTMLInputElement; - discord.checked = s; - if (window.telegramEnabled && this._telegramUsername != "") { - const discord = this._discord.getElementsByClassName("accounts-contact-discord")[0] as HTMLInputElement; - discord.checked = s; + if (this._notifyDropdown) { + (this._notifyDropdown.querySelector(".accounts-contact-discord") as HTMLInputElement).checked = s; } } @@ -333,6 +382,11 @@ class user implements User { `; } + if (window.matrixEnabled) { + innerHTML += ` + + `; + } if (window.discordEnabled) { innerHTML += ` @@ -352,10 +406,13 @@ class user implements User { this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement; this._telegram = this._row.querySelector(".accounts-telegram") as HTMLTableDataCellElement; this._discord = this._row.querySelector(".accounts-discord") as HTMLTableDataCellElement; + this._matrix = this._row.querySelector(".accounts-matrix") as HTMLTableDataCellElement; this._expiry = this._row.querySelector(".accounts-expiry") as HTMLTableDataCellElement; this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement; this._check.onchange = () => { this.selected = this._check.checked; } + this._notifyDropdown = this._constructDropdown(); + const toggleStealthInput = () => { if (this._emailEditButton.classList.contains("ri-edit-line")) { this._email.innerHTML = emailEditor; @@ -458,14 +515,20 @@ class user implements User { this.id = user.id; this.name = user.name; this.email = user.email || ""; + // Little hack to get settings cogs to appear on first load + this._discordUsername = user.discord; + this._telegramUsername = user.telegram; + this._matrixID = user.matrix; this.discord = user.discord; this.telegram = user.telegram; + this.matrix = user.matrix; this.last_active = user.last_active; this.admin = user.admin; this.disabled = user.disabled; this.expiry = user.expiry; this.notify_discord = user.notify_discord; this.notify_telegram = user.notify_telegram; + this.notify_matrix = user.notify_matrix; this.notify_email = user.notify_email; this.discord_id = user.discord_id; } diff --git a/ts/modules/common.ts b/ts/modules/common.ts index f761a4c..05b6bcb 100644 --- a/ts/modules/common.ts +++ b/ts/modules/common.ts @@ -105,7 +105,11 @@ export class notificationBox implements NotificationBox { private _error = (message: string): HTMLElement => { const noti = document.createElement('aside'); noti.classList.add("aside", "~critical", "!normal", "mt-half", "notification-error"); - noti.innerHTML = `${window.lang.strings("error")}: ${message}`; + let error = ""; + if (window.lang) { + error = window.lang.strings("error") + ":" + } + noti.innerHTML = `${error} ${message}`; const closeButton = document.createElement('span') as HTMLSpanElement; closeButton.classList.add("button", "~critical", "!low", "ml-1"); closeButton.innerHTML = ``; diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 6348154..8a639aa 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -1,4 +1,4 @@ -import { _get, _post, toggleLoader } from "../modules/common.js"; +import { _get, _post, toggleLoader, addLoader, removeLoader } from "../modules/common.js"; import { Marked } from "@ts-stack/markdown"; import { stripMarkdown } from "../modules/stripmd.js"; @@ -666,6 +666,40 @@ export class settingsList { } } + private _addMatrix = () => { + // Modify the login modal, why not + const modal = document.getElementById("form-matrix") as HTMLFormElement; + modal.onsubmit = (event: Event) => { + event.preventDefault(); + const button = modal.querySelector("span.submit") as HTMLSpanElement; + addLoader(button); + let send = { + homeserver: (document.getElementById("matrix-homeserver") as HTMLInputElement).value, + username: (document.getElementById("matrix-user") as HTMLInputElement).value, + password: (document.getElementById("matrix-password") as HTMLInputElement).value + } + _post("/matrix/login", send, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + removeLoader(button); + if (req.status == 400) { + window.notifications.customError("errorUnknown", window.lang.notif(req.response["error"] as string)); + return; + } else if (req.status == 401) { + window.notifications.customError("errorUnauthorized", req.response["error"] as string); + return; + } else if (req.status == 500) { + window.notifications.customError("errorAddMatrix", window.lang.notif("errorFailureCheckLogs")); + return; + } + window.modals.matrix.close(); + _post("/restart", null, () => {}); + window.location.reload(); + } + }, true); + }; + window.modals.matrix.show(); + } + reload = () => _get("/config", null, (req: XMLHttpRequest) => { if (req.readyState == 4) { if (req.status != 200) { @@ -698,6 +732,17 @@ export class settingsList { icon.onclick = () => window.updater.checkForUpdates(window.modals.updateInfo.show); } this.addSection(name, settings.sections[name], icon); + } else if (name == "matrix" && !window.matrixEnabled) { + const addButton = document.createElement("div"); + addButton.classList.add("tooltip", "left"); + addButton.innerHTML = ` + + + + ${window.lang.strings("linkMatrix")} + + `; + (addButton.querySelector("span.button") as HTMLSpanElement).onclick = this._addMatrix; + this.addSection(name, settings.sections[name], addButton); } else { this.addSection(name, settings.sections[name]); } diff --git a/ts/typings/d.ts b/ts/typings/d.ts index eb5be55..c06785e 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -22,6 +22,7 @@ declare interface Window { emailEnabled: boolean; telegramEnabled: boolean; discordEnabled: boolean; + matrixEnabled: boolean; ombiEnabled: boolean; usernameEnabled: boolean; token: string; @@ -103,6 +104,7 @@ declare interface Modals { updateInfo: Modal; telegram: Modal; discord: Modal; + matrix: Modal; } interface Invite { diff --git a/views.go b/views.go index 31c69ed..b8efab0 100644 --- a/views.go +++ b/views.go @@ -123,6 +123,7 @@ func (app *appContext) AdminPage(gc *gin.Context) { "email_enabled": emailEnabled, "telegram_enabled": telegramEnabled, "discord_enabled": discordEnabled, + "matrix_enabled": matrixEnabled, "notifications": notificationsEnabled, "version": version, "commit": commit, @@ -295,6 +296,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "langName": lang, "telegramEnabled": telegramEnabled, "discordEnabled": discordEnabled, + "matrixEnabled": matrixEnabled, } if telegramEnabled { data["telegramPIN"] = app.telegram.NewAuthToken() @@ -302,6 +304,10 @@ func (app *appContext) InviteProxy(gc *gin.Context) { data["telegramURL"] = app.telegram.link data["telegramRequired"] = app.config.Section("telegram").Key("required").MustBool(false) } + if matrixEnabled { + data["matrixRequired"] = app.config.Section("matrix").Key("required").MustBool(false) + data["matrixUser"] = app.matrix.userID + } if discordEnabled { data["discordPIN"] = app.discord.NewAuthToken() data["discordUsername"] = app.discord.username