From 2722e8482df3d39af5608fd66990c127cfc7bbe8 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 1 Feb 2023 15:11:10 +0000 Subject: [PATCH] Invites: unique email/ID requirement "Require unique ..." Settings (`require_unique` in relevant sections of config.ini) are now available for email/discord/telegram/matrix. An error is shown on the invite form if a non-unique address/ID is used. This was on my kanban without a link to an issue, so i'm guessing it was requested on Discord. --- api-messages.go | 30 +++++++- api-users.go | 55 +++++++++++++- config/config-base.json | 32 ++++++++ daemon.go | 160 ++++++++++++++++++++++++++++++++++++++++ invdaemon.go | 50 ------------- lang/form/en-us.json | 2 + storage.go | 2 +- ts/form.ts | 10 +++ 8 files changed, 284 insertions(+), 57 deletions(-) create mode 100644 daemon.go delete mode 100644 invdaemon.go diff --git a/api-messages.go b/api-messages.go index 2ed148c..f91b472 100644 --- a/api-messages.go +++ b/api-messages.go @@ -453,6 +453,14 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { break } } + if app.config.Section("telegram").Key("require_unique").MustBool(false) { + for _, u := range app.storage.telegram { + if app.telegram.verifiedTokens[tokenIndex].Username == u.Username { + respondBool(400, false, gc) + return + } + } + } // if tokenIndex != -1 { // length := len(app.telegram.verifiedTokens) // app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1] @@ -477,6 +485,15 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { } pin := gc.Param("pin") _, ok := app.discord.verifiedTokens[pin] + if app.config.Section("discord").Key("require_unique").MustBool(false) { + for _, u := range app.storage.discord { + if app.discord.verifiedTokens[pin].ID == u.ID { + delete(app.discord.verifiedTokens, pin) + respondBool(400, false, gc) + return + } + } + } respondBool(200, ok, gc) } @@ -510,7 +527,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) { // @Summary Generate and send a new PIN to a specified Matrix user. // @Produce json // @Success 200 {object} boolResponse -// @Failure 400 {object} boolResponse +// @Failure 400 {object} stringResponse // @Failure 401 {object} boolResponse // @Failure 500 {object} boolResponse // @Param invCode path string true "invite Code" @@ -526,9 +543,18 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) { var req MatrixSendPINDTO gc.BindJSON(&req) if req.UserID == "" { - respondBool(400, false, gc) + respond(400, "errorNoUserID", gc) return } + if app.config.Section("matrix").Key("require_unique").MustBool(false) { + for _, u := range app.storage.matrix { + if req.UserID == u.UserID { + respondBool(400, false, gc) + return + } + } + } + ok := app.matrix.SendStart(req.UserID) if !ok { respondBool(500, false, gc) diff --git a/api-users.go b/api-users.go index ada3a7a..2a98073 100644 --- a/api-users.go +++ b/api-users.go @@ -130,6 +130,18 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc success = false return } + if app.config.Section("discord").Key("require_unique").MustBool(false) { + for _, u := range app.storage.discord { + if discordUser.ID == u.ID { + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Discord user already linked", req.Code) + respond(400, "errorAccountLinked", gc) + } + success = false + return + } + } + } err := app.discord.ApplyRole(discordUser.ID) if err != nil { f = func(gc *gin.Context) { @@ -164,6 +176,18 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc success = false return } + if app.config.Section("matrix").Key("require_unique").MustBool(false) { + for _, u := range app.storage.matrix { + if user.User.UserID == u.UserID { + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Matrix user already linked", req.Code) + respond(400, "errorAccountLinked", gc) + } + success = false + return + } + } + } matrixVerified = user.Verified matrixUser = *user.User @@ -195,6 +219,18 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc success = false return } + if app.config.Section("telegram").Key("require_unique").MustBool(false) { + for _, u := range app.storage.telegram { + if app.telegram.verifiedTokens[telegramTokenIndex].Username == u.Username { + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Telegram user already linked", req.Code) + respond(400, "errorAccountLinked", gc) + } + success = false + return + } + } + } } } if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed { @@ -445,10 +481,21 @@ func (app *appContext) NewUser(gc *gin.Context) { gc.JSON(200, validation) return } - if emailEnabled && app.config.Section("email").Key("required").MustBool(false) && !strings.Contains(req.Email, "@") { - app.info.Printf("%s: New user failed: Email Required", req.Code) - respond(400, "errorNoEmail", gc) - return + if emailEnabled { + if app.config.Section("email").Key("required").MustBool(false) && !strings.Contains(req.Email, "@") { + app.info.Printf("%s: New user failed: Email Required", req.Code) + respond(400, "errorNoEmail", gc) + return + } + if app.config.Section("email").Key("require_unique").MustBool(false) && req.Email != "" { + for _, email := range app.storage.emails { + if req.Email == email.Addr { + app.info.Printf("%s: New user failed: Email already in use", req.Code) + respond(400, "errorEmailLinked", gc) + return + } + } + } } f, success := app.newUser(req, false) if !success { diff --git a/config/config-base.json b/config/config-base.json index 1a39edb..17eee6a 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -502,6 +502,14 @@ "type": "bool", "value": false, "description": "Require an email address on sign-up." + }, + "require_unique": { + "name": "Require unique address", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Disables using the same address on multiple accounts." } } }, @@ -641,6 +649,14 @@ "value": false, "description": "Require Discord connection on sign-up. See the jfa-go wiki for info on setting this up." }, + "require_unique": { + "name": "Require unique user", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Disables using the same user on multiple Jellyfin accounts." + }, "token": { "name": "API Token", "required": false, @@ -745,6 +761,14 @@ "value": false, "description": "Require telegram connection on sign-up." }, + "require_unique": { + "name": "Require unique user", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Disables using the same user on multiple Jellyfin accounts." + }, "token": { "name": "API Token", "required": false, @@ -801,6 +825,14 @@ "value": false, "description": "Require Matrix connection on sign-up." }, + "require_unique": { + "name": "Require unique user", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Disables using the same user on multiple Jellyfin accounts." + }, "homeserver": { "name": "Home Server URL", "required": false, diff --git a/daemon.go b/daemon.go new file mode 100644 index 0000000..d7aeb57 --- /dev/null +++ b/daemon.go @@ -0,0 +1,160 @@ +package main + +import "time" + +// clearEmails removes stored emails for users which no longer exist. +// meant to be called with other such housekeeping functions, so assumes +// the user cache is fresh. +func (app *appContext) clearEmails() { + app.debug.Println("Housekeeping: removing unused email addresses") + users, status, err := app.jf.GetUsers(false) + if status != 200 || err != nil || len(users) == 0 { + app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) + return + } + // Rebuild email storage to from existing users to reduce time complexity + emails := map[string]EmailAddress{} + for _, user := range users { + if email, ok := app.storage.emails[user.ID]; ok { + emails[user.ID] = email + } + } + app.storage.emails = emails + app.storage.storeEmails() +} + +// clearDiscord does the same as clearEmails, but for Discord Users. +func (app *appContext) clearDiscord() { + app.debug.Println("Housekeeping: removing unused Discord IDs") + users, status, err := app.jf.GetUsers(false) + if status != 200 || err != nil || len(users) == 0 { + app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) + return + } + // Rebuild discord storage to from existing users to reduce time complexity + dcUsers := map[string]DiscordUser{} + for _, user := range users { + if dcUser, ok := app.storage.discord[user.ID]; ok { + dcUsers[user.ID] = dcUser + } + } + app.storage.discord = dcUsers + app.storage.storeDiscordUsers() +} + +// clearMatrix does the same as clearEmails, but for Matrix Users. +func (app *appContext) clearMatrix() { + app.debug.Println("Housekeeping: removing unused Matrix IDs") + users, status, err := app.jf.GetUsers(false) + if status != 200 || err != nil || len(users) == 0 { + app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) + return + } + // Rebuild matrix storage to from existing users to reduce time complexity + mxUsers := map[string]MatrixUser{} + for _, user := range users { + if mxUser, ok := app.storage.matrix[user.ID]; ok { + mxUsers[user.ID] = mxUser + } + } + app.storage.matrix = mxUsers + app.storage.storeMatrixUsers() +} + +// clearTelegram does the same as clearEmails, but for Telegram Users. +func (app *appContext) clearTelegram() { + app.debug.Println("Housekeeping: removing unused Telegram IDs") + users, status, err := app.jf.GetUsers(false) + if status != 200 || err != nil || len(users) == 0 { + app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) + return + } + // Rebuild telegram storage to from existing users to reduce time complexity + tgUsers := map[string]TelegramUser{} + for _, user := range users { + if tgUser, ok := app.storage.telegram[user.ID]; ok { + tgUsers[user.ID] = tgUser + } + } + app.storage.telegram = tgUsers + app.storage.storeTelegramUsers() +} + +// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS + +type housekeepingDaemon struct { + Stopped bool + ShutdownChannel chan string + Interval time.Duration + period time.Duration + jobs []func(app *appContext) + app *appContext +} + +func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon { + daemon := housekeepingDaemon{ + Stopped: false, + ShutdownChannel: make(chan string), + Interval: interval, + period: interval, + app: app, + } + daemon.jobs = []func(app *appContext){func(app *appContext) { + app.debug.Println("Housekeeping: Checking for expired invites") + app.checkInvites() + }} + + clearEmail := app.config.Section("email").Key("require_unique").MustBool(false) + clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false) + clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false) + clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false) + + if clearEmail || clearDiscord || clearTelegram || clearMatrix { + daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() }) + } + + if clearEmail { + daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() }) + } + if clearDiscord { + daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() }) + } + if clearTelegram { + daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() }) + } + if clearMatrix { + daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() }) + } + + return &daemon +} + +func (rt *housekeepingDaemon) run() { + rt.app.info.Println("Invite daemon started") + for { + select { + case <-rt.ShutdownChannel: + rt.ShutdownChannel <- "Down" + return + case <-time.After(rt.period): + break + } + started := time.Now() + rt.app.storage.loadInvites() + + for _, job := range rt.jobs { + job(rt.app) + } + + finished := time.Now() + duration := finished.Sub(started) + rt.period = rt.Interval - duration + } +} + +func (rt *housekeepingDaemon) Shutdown() { + rt.Stopped = true + rt.ShutdownChannel <- "Down" + <-rt.ShutdownChannel + close(rt.ShutdownChannel) +} diff --git a/invdaemon.go b/invdaemon.go deleted file mode 100644 index f9bd9aa..0000000 --- a/invdaemon.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import "time" - -// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS - -type inviteDaemon struct { - Stopped bool - ShutdownChannel chan string - Interval time.Duration - period time.Duration - app *appContext -} - -func newInviteDaemon(interval time.Duration, app *appContext) *inviteDaemon { - return &inviteDaemon{ - Stopped: false, - ShutdownChannel: make(chan string), - Interval: interval, - period: interval, - app: app, - } -} - -func (rt *inviteDaemon) run() { - rt.app.info.Println("Invite daemon started") - for { - select { - case <-rt.ShutdownChannel: - rt.ShutdownChannel <- "Down" - return - case <-time.After(rt.period): - break - } - started := time.Now() - rt.app.storage.loadInvites() - rt.app.debug.Println("Daemon: Checking invites") - rt.app.checkInvites() - finished := time.Now() - duration := finished.Sub(started) - rt.period = rt.Interval - duration - } -} - -func (rt *inviteDaemon) Shutdown() { - rt.Stopped = true - rt.ShutdownChannel <- "Down" - <-rt.ShutdownChannel - close(rt.ShutdownChannel) -} diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 5724a44..bb64683 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -24,6 +24,8 @@ "notifications": { "errorUserExists": "User already exists.", "errorInvalidCode": "Invalid invite code.", + "errorAccountLinked": "Account already in use.", + "errorEmailLinked": "Email already in use.", "errorTelegramVerification": "Telegram verification required.", "errorDiscordVerification": "Discord verification required.", "errorMatrixVerification": "Matrix verification required.", diff --git a/storage.go b/storage.go index c1fadda..888b9d1 100644 --- a/storage.go +++ b/storage.go @@ -18,7 +18,7 @@ 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, matrix_path, announcements_path, matrix_sql_path string - users map[string]time.Time + users map[string]time.Time // Map of Jellyfin User IDs to their expiry times. invites Invites profiles map[string]Profile defaultProfile string diff --git a/ts/form.ts b/ts/form.ts index 344af5a..24bc186 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -61,6 +61,9 @@ if (window.telegramEnabled) { window.telegramModal.close(); window.notifications.customError("invalidCodeError", window.messages["errorInvalidCode"]); return; + } else if (req.status == 400) { + window.telegramModal.close(); + window.notifications.customError("accountLinkedError", window.messages["errorAccountLinked"]); } else if (req.status == 200) { if (req.response["success"] as boolean) { telegramVerified = true; @@ -124,6 +127,9 @@ if (window.discordEnabled) { window.discordModal.close(); window.notifications.customError("invalidCodeError", window.messages["errorInvalidCode"]); return; + } else if (req.status == 400) { + window.discordModal.close(); + window.notifications.customError("accountLinkedError", window.messages["errorAccountLinked"]); } else if (req.status == 200) { if (req.response["success"] as boolean) { discordVerified = true; @@ -165,6 +171,10 @@ if (window.matrixEnabled) { }; _post("/invite/" + window.code + "/matrix/user", send, (req: XMLHttpRequest) => { if (req.readyState == 4) { + if (req.status == 400 && req.response["error"] == "errorAccountLinked") { + window.matrixModal.close(); + window.notifications.customError("accountLinkedError", window.messages["errorAccountLinked"]); + } removeLoader(submitButton); userID = input.value; if (req.status != 200) {