diff --git a/api.go b/api.go index 6888868..4c1ccb2 100644 --- a/api.go +++ b/api.go @@ -217,7 +217,7 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er username := jfUser.Name email := "" if e, ok := app.storage.emails[jfID]; ok { - email = e.(string) + email = e.Addr } for _, ombiUser := range ombiUsers { ombiAddr := "" @@ -283,7 +283,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { } app.jf.CacheExpiry = time.Now() if emailEnabled { - app.storage.emails[id] = req.Email + app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true} app.storage.storeEmails() } if app.config.Section("ombi").Key("enabled").MustBool(false) { @@ -330,6 +330,30 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc success = false return } + var discordUser DiscordUser + discordVerified := false + if discordEnabled { + if req.DiscordPIN == "" { + if app.config.Section("discord").Key("required").MustBool(false) { + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Discord verification not completed", req.Code) + respond(401, "errorDiscordVerification", gc) + } + success = false + return + } + } else { + discordUser, discordVerified = app.discord.verifiedTokens[req.DiscordPIN] + if !discordVerified { + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Discord PIN was invalid", req.Code) + respond(401, "errorInvalidPIN", gc) + } + success = false + return + } + } + } telegramTokenIndex := -1 if telegramEnabled { if req.TelegramPIN == "" { @@ -454,7 +478,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } // if app.config.Section("password_resets").Key("enabled").MustBool(false) { if req.Email != "" { - app.storage.emails[id] = req.Email + app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true} app.storage.storeEmails() } if app.config.Section("ombi").Key("enabled").MustBool(false) { @@ -479,7 +503,18 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc app.err.Printf("Failed to store user duration: %v", err) } } - + if discordEnabled && discordVerified { + discordUser.Contact = req.DiscordContact + if app.storage.discord == nil { + app.storage.discord = map[string]DiscordUser{} + } + app.storage.discord[user.ID] = discordUser + if err := app.storage.storeDiscordUsers(); err != nil { + app.err.Printf("Failed to store Discord users: %v", err) + } else { + delete(app.discord.verifiedTokens, req.DiscordPIN) + } + } if telegramEnabled && telegramTokenIndex != -1 { tgToken := app.telegram.verifiedTokens[telegramTokenIndex] tgUser := TelegramUser{ @@ -494,8 +529,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc app.storage.telegram = map[string]TelegramUser{} } app.storage.telegram[user.ID] = tgUser - err := app.storage.storeTelegramUsers() - if err != nil { + if err := app.storage.storeTelegramUsers(); err != nil { app.err.Printf("Failed to store Telegram users: %v", err) } else { app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[telegramTokenIndex] = app.telegram.verifiedTokens[telegramTokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1] @@ -503,7 +537,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } } - if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 { + 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) msg, err := app.email.constructWelcome(req.Username, expiry, app, false) @@ -792,18 +826,44 @@ func (app *appContext) GenerateInvite(gc *gin.Context) { invite.UserMinutes = req.UserMinutes } invite.ValidTill = validTill - if emailEnabled && req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) { - app.debug.Printf("%s: Sending invite email", inviteCode) - invite.Email = req.Email - msg, err := app.email.constructInvite(inviteCode, invite, app, false) - if err != nil { - invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) - app.err.Printf("%s: Failed to construct invite email: %v", inviteCode, err) - } else if err := app.email.send(msg, req.Email); err != nil { - invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) - app.err.Printf("%s: %s: %v", inviteCode, invite.Email, err) - } else { - app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.Email) + if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) { + addressValid := false + discord := "" + app.debug.Printf("%s: Sending invite message", inviteCode) + if discordEnabled && !strings.Contains(req.SendTo, "@") { + users := app.discord.GetUsers(req.SendTo) + if len(users) == 0 { + invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo) + } else if len(users) > 1 { + invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo) + } else { + invite.SendTo = req.SendTo + addressValid = true + discord = users[0].User.ID + } + } else if emailEnabled { + addressValid = true + invite.SendTo = req.SendTo + } + if addressValid { + msg, err := app.email.constructInvite(inviteCode, invite, app, false) + if err != nil { + invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo) + app.err.Printf("%s: Failed to construct invite message: %v", inviteCode, err) + } else { + var err error + if discord != "" { + err = app.discord.SendDM(msg, discord) + } else { + err = app.email.send(msg, req.SendTo) + } + if err != nil { + invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo) + app.err.Printf("%s: %s: %v", inviteCode, invite.SendTo, err) + } else { + app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.SendTo) + } + } } } if req.Profile != "" { @@ -867,15 +927,15 @@ func (app *appContext) GetInvites(gc *gin.Context) { if inv.RemainingUses != 0 { invite.RemainingUses = inv.RemainingUses } - if inv.Email != "" { - invite.Email = inv.Email + if inv.SendTo != "" { + invite.SendTo = inv.SendTo } if len(inv.Notify) != 0 { var address string if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { app.storage.loadEmails() - if addr := app.storage.emails[gc.GetString("jfId")]; addr != nil { - address = addr.(string) + if addr, ok := app.storage.emails[gc.GetString("jfId")]; ok && addr.Addr != "" { + address = addr.Addr } } else { address = app.config.Section("ui").Key("email").String() @@ -1074,14 +1134,14 @@ func (app *appContext) SetNotify(gc *gin.Context) { } var address string if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { - var ok bool - address, ok = app.storage.emails[gc.GetString("jfId")].(string) + addr, ok := app.storage.emails[gc.GetString("jfId")] if !ok { app.err.Printf("%s: Couldn't find email address. Make sure it's set", code) app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId")) respond(500, "Missing user email", gc) return } + address = addr.Addr } else { address = app.config.Section("ui").Key("email").String() } @@ -1168,7 +1228,8 @@ func (app *appContext) GetUsers(gc *gin.Context) { user.LastActive = jfUser.LastActivityDate.Unix() } if email, ok := app.storage.emails[jfUser.ID]; ok { - user.Email = email.(string) + user.Email = email.Addr + user.NotifyThroughEmail = email.Contact } expiry, ok := app.storage.users[jfUser.ID] if ok { @@ -1178,6 +1239,11 @@ 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 + } resp.UserList[i] = user i++ } @@ -1253,7 +1319,11 @@ func (app *appContext) ModifyEmails(gc *gin.Context) { for _, jfUser := range users { id := jfUser.ID if address, ok := req[id]; ok { - app.storage.emails[id] = address + contact := true + if oldAddr, ok := app.storage.emails[id]; ok { + contact = oldAddr.Contact + } + app.storage.emails[id] = EmailAddress{Addr: address, Contact: contact} if ombiEnabled { ombiUser, code, err := app.getOmbiUser(id) if code == 200 && err == nil { @@ -2004,38 +2074,63 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) { // @Summary Sets whether to notify a user through telegram or not. // @Produce json -// @Param telegramNotifyDTO body telegramNotifyDTO true "User's Jellyfin ID and whether or not to notify then through Telegram." +// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram." // @Success 200 {object} boolResponse // @Success 400 {object} boolResponse // @Success 500 {object} boolResponse // @Router /users/telegram/notify [post] // @Security Bearer // @tags Other -func (app *appContext) TelegramSetNotify(gc *gin.Context) { - var req telegramNotifyDTO +func (app *appContext) SetContactMethods(gc *gin.Context) { + var req SetContactMethodsDTO gc.BindJSON(&req) if req.ID == "" { respondBool(400, false, gc) return } if tgUser, ok := app.storage.telegram[req.ID]; ok { - tgUser.Contact = req.Enabled + tgUser.Contact = req.Telegram app.storage.telegram[req.ID] = tgUser if err := app.storage.storeTelegramUsers(); err != nil { respondBool(500, false, gc) app.err.Printf("Telegram: Failed to store users: %v", err) return } - respondBool(200, true, gc) msg := "" - if !req.Enabled { - msg = "not" + if !req.Telegram { + msg = " not" } - app.debug.Printf("Telegram: User \"%s\" will %s be notified through Telegram.", tgUser.Username, msg) - return + app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg) } - app.err.Printf("Telegram: User \"%s\" does not have a telegram account registered.", req.ID) - respondBool(400, false, gc) + if dcUser, ok := app.storage.discord[req.ID]; ok { + dcUser.Contact = req.Discord + app.storage.discord[req.ID] = dcUser + if err := app.storage.storeDiscordUsers(); err != nil { + respondBool(500, false, gc) + app.err.Printf("Discord: Failed to store users: %v", err) + return + } + msg := "" + if !req.Discord { + msg = " not" + } + app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg) + } + if email, ok := app.storage.emails[req.ID]; ok { + email.Contact = req.Email + app.storage.emails[req.ID] = email + if err := app.storage.storeEmails(); err != nil { + respondBool(500, false, gc) + app.err.Printf("Failed to store emails: %v", err) + return + } + msg := "" + if !req.Email { + msg = " not" + } + app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg) + } + respondBool(200, true, gc) } // @Summary Returns true/false on whether or not a telegram PIN was verified. Requires bearer auth. @@ -2092,6 +2187,107 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { respondBool(200, tokenIndex != -1, gc) } +// @Summary Returns true/false on whether or not a discord PIN was verified. 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" +// @Router /invite/{invCode}/discord/verified/{pin} [get] +// @tags Other +func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { + code := gc.Param("invCode") + if _, ok := app.storage.invites[code]; !ok { + respondBool(401, false, gc) + return + } + pin := gc.Param("pin") + _, ok := app.discord.verifiedTokens[pin] + respondBool(200, ok, gc) +} + +// @Summary Returns a 10-minute, one-use Discord server invite +// @Produce json +// @Success 200 {object} DiscordInviteDTO +// @Failure 400 {object} boolResponse +// @Failure 401 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param invCode path string true "invite Code" +// @Router /invite/{invCode}/discord/invite [get] +// @tags Other +func (app *appContext) DiscordServerInvite(gc *gin.Context) { + if app.discord.inviteChannelName == "" { + respondBool(400, false, gc) + return + } + code := gc.Param("invCode") + if _, ok := app.storage.invites[code]; !ok { + respondBool(401, false, gc) + return + } + invURL, iconURL := app.discord.NewTempInvite(10*60, 1) + if invURL == "" { + respondBool(500, false, gc) + return + } + gc.JSON(200, DiscordInviteDTO{invURL, iconURL}) +} + +// @Summary Returns a list of matching users from a Discord guild, given a username (discriminator optional). +// @Produce json +// @Success 200 {object} DiscordUsersDTO +// @Failure 400 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param username path string true "username to search." +// @Router /users/discord/{username} [get] +// @tags Other +func (app *appContext) DiscordGetUsers(gc *gin.Context) { + name := gc.Param("username") + if name == "" { + respondBool(400, false, gc) + return + } + users := app.discord.GetUsers(name) + resp := DiscordUsersDTO{Users: make([]DiscordUserDTO, len(users))} + for i, u := range users { + resp.Users[i] = DiscordUserDTO{ + Name: u.User.Username + "#" + u.User.Discriminator, + ID: u.User.ID, + AvatarURL: u.User.AvatarURL("32"), + } + } + gc.JSON(200, resp) +} + +// @Summary Links a Discord account 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 DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID." +// @Router /users/discord [post] +// @tags Other +func (app *appContext) DiscordConnect(gc *gin.Context) { + var req DiscordConnectUserDTO + gc.BindJSON(&req) + if req.JellyfinID == "" || req.DiscordID == "" { + respondBool(400, false, gc) + return + } + user, ok := app.discord.NewUser(req.DiscordID) + if !ok { + respondBool(500, false, gc) + return + } + app.storage.discord[req.JellyfinID] = user + if err := app.storage.storeDiscordUsers(); err != nil { + app.err.Printf("Failed to store Discord users: %v", err) + respondBool(500, false, gc) + return + } + respondBool(200, true, gc) +} + // @Summary Restarts the program. No response means success. // @Router /restart [post] // @Security Bearer diff --git a/config.go b/config.go index 9c60efc..d605ef5 100644 --- a/config.go +++ b/config.go @@ -14,6 +14,7 @@ import ( var emailEnabled = false var messagesEnabled = false var telegramEnabled = false +var discordEnabled = false func (app *appContext) GetPath(sect, key string) (fs.FS, string) { val := app.config.Section(sect).Key(key).MustString("") @@ -42,7 +43,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"} { + for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_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(""), "/") @@ -87,15 +88,17 @@ func (app *appContext) loadConfig() error { 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) if !messagesEnabled { emailEnabled = false telegramEnabled = false + discordEnabled = false } else if app.config.Section("email").Key("method").MustString("") == "" { emailEnabled = false } else { emailEnabled = true } - if !emailEnabled && !telegramEnabled { + if !emailEnabled && !telegramEnabled && !discordEnabled { messagesEnabled = false } @@ -168,3 +171,28 @@ func (app *appContext) migrateEmailConfig() { } app.loadConfig() } + +func (app *appContext) migrateEmailStorage() error { + var emails map[string]interface{} + err := loadJSON(app.storage.emails_path, &emails) + if err != nil { + return err + } + newEmails := map[string]EmailAddress{} + for jfID, addr := range emails { + newEmails[jfID] = EmailAddress{ + Addr: addr.(string), + Contact: true, + } + } + err = storeJSON(app.storage.emails_path+".bak", emails) + if err != nil { + return err + } + err = storeJSON(app.storage.emails_path, newEmails) + if err != nil { + return err + } + app.info.Println("Migrated to new email format. A backup has also been made.") + return nil +} diff --git a/config/config-base.json b/config/config-base.json index 12f6861..36bad28 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -546,6 +546,89 @@ } } }, + "discord": { + "order": [], + "meta": { + "name": "Discord", + "description": "Settings for Discord invites/signup/notifications" + }, + "settings": { + "enabled": { + "name": "Enabled", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Enable signup verification through Discord 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 Discord connection on sign-up." + }, + "token": { + "name": "API Token", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Discord Bot API Token." + }, + "start_command": { + "name": "Start command", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "!start", + "description": "Command to start the user verification process." + }, + "channel": { + "name": "Channel to monitor", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Only listen to commands in specified channel. Leave blank to monitor all." + }, + "provide_invite": { + "name": "Provide server invite", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "bool", + "value": false, + "description": "Generate a one-time discord server invite for the account creation form. Required Bot permission \"Create instant invite\", you may need to re-add the bot to your server after." + }, + "invite_channel": { + "name": "Invite channel", + "required": false, + "requires_restart": true, + "depends_true": "provide_invite", + "type": "text", + "value": "", + "description": "Channel to invite new users to." + }, + "language": { + "name": "Language", + "required": false, + "requires_restart": false, + "depends_true": "enabled", + "type": "select", + "options": [ + ["en-us", "English (US)"] + ], + "value": "en-us", + "description": "Default Discord message language. Visit weblate if you'd like to translate." + } + } + }, "telegram": { "order": [], "meta": { @@ -565,6 +648,7 @@ "name": "Require on sign-up", "required": false, "required_restart": true, + "depends_true": "enabled", "type": "bool", "value": false, "description": "Require telegram connection on sign-up." @@ -1140,6 +1224,14 @@ "type": "text", "value": "", "description": "Stores telegram user IDs and language preferences." + }, + "discord_users": { + "name": "Discord users", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Stores discord user IDs and language preferences." } } } diff --git a/css/base.css b/css/base.css index 4b1e040..7d96684 100644 --- a/css/base.css +++ b/css/base.css @@ -30,12 +30,12 @@ } } -@media screen and (max-width: 750px) { +@media screen and (max-width: 1000px) { :root { font-size: 0.9rem; } .table-responsive table { - min-width: 660px; + min-width: 800px; } } @@ -130,6 +130,10 @@ div.card:contains(section.banner.footer) { text-align: center; } +.w-100 { + width: 100%; +} + .inline-block { display: inline-block; } @@ -172,7 +176,7 @@ div.card:contains(section.banner.footer) { } p.sm, -span.sm { +span.sm:not(.heading) { font-size: 0.75rem; } @@ -424,6 +428,7 @@ p.top { .table-responsive { overflow-x: auto; + font-size: 0.9rem; } #notification-box { @@ -438,6 +443,10 @@ p.top { margin-bottom: -0.5rem; } +.dropdown-display.lg { + white-space: nowrap; +} + pre { white-space: pre-wrap; /* css-3 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ @@ -483,3 +492,32 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) { max-width: 15rem; min-width: 10rem; } + +td.img-circle { + width: 32px; + height: 32px; +} + +span.img-circle.lg { + width: 64px; + height: 64px; +} + +span.shield.img-circle { + padding: 0.2rem; +} + +img.img-circle { + border-radius: 50%; + vertical-align: middle; +} + +.table td.sm { + padding-top: 0.1rem; + padding-bottom: 0.1rem; +} + +.table-inline { + display: flex !important; + align-items: center; +} diff --git a/discord.go b/discord.go new file mode 100644 index 0000000..43c2ebf --- /dev/null +++ b/discord.go @@ -0,0 +1,404 @@ +package main + +import ( + "fmt" + "strings" + + dg "github.com/bwmarrin/discordgo" +) + +type DiscordDaemon struct { + Stopped bool + ShutdownChannel chan string + bot *dg.Session + username string + tokens []string + verifiedTokens map[string]DiscordUser // Map of tokens to discord users. + channelID, channelName, inviteChannelID, inviteChannelName string + guildID string + serverChannelName, serverName string + users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start. + app *appContext +} + +func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { + token := app.config.Section("discord").Key("token").String() + if token == "" { + return nil, fmt.Errorf("token was blank") + } + bot, err := dg.New("Bot " + token) + if err != nil { + return nil, err + } + dd := &DiscordDaemon{ + Stopped: false, + ShutdownChannel: make(chan string), + bot: bot, + tokens: []string{}, + verifiedTokens: map[string]DiscordUser{}, + users: map[string]DiscordUser{}, + app: app, + } + for _, user := range app.storage.discord { + dd.users[user.ID] = user + } + + return dd, nil +} + +// NewAuthToken generates an 8-character pin in the form "A1-2B-CD". +func (d *DiscordDaemon) NewAuthToken() string { + pin := genAuthToken() + d.tokens = append(d.tokens, pin) + return pin +} + +func (d *DiscordDaemon) NewUnknownUser(channelID, userID, discrim, username string) DiscordUser { + user := DiscordUser{ + ChannelID: channelID, + ID: userID, + Username: username, + Discriminator: discrim, + } + return user +} + +func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string) DiscordUser { + if user, ok := d.users[userID]; ok { + return user + } + return d.NewUnknownUser(channelID, userID, discrim, username) +} + +func (d *DiscordDaemon) run() { + d.bot.AddHandler(d.messageHandler) + d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites + if err := d.bot.Open(); err != nil { + d.app.err.Printf("Discord: Failed to start daemon: %v", err) + return + } + // Sometimes bot.State isn't populated quick enough + for d.bot.State == nil { + continue + } + d.username = d.bot.State.User.Username + // Choose the last guild (server), for now we don't really support multiple anyway + d.guildID = d.bot.State.Guilds[len(d.bot.State.Guilds)-1].ID + guild, err := d.bot.Guild(d.guildID) + if err != nil { + d.app.err.Printf("Discord: Failed to get guild: %v", err) + } + d.serverChannelName = guild.Name + d.serverName = guild.Name + if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" { + d.channelName = channel + d.serverChannelName += "/" + channel + } + if d.app.config.Section("discord").Key("provide_invite").MustBool(false) { + if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" { + d.inviteChannelName = invChannel + } + } + defer d.bot.Close() + <-d.ShutdownChannel + d.ShutdownChannel <- "Down" + return +} + +// NewTempInvite creates an invite link, and returns the invite URL, as well as the URL for the server icon. +func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) { + var inv *dg.Invite + var err error + if d.inviteChannelName == "" { + d.app.err.Println("Discord: Cannot create invite without channel specified in settings.") + return + } + if d.inviteChannelID == "" { + channels, err := d.bot.GuildChannels(d.guildID) + if err != nil { + d.app.err.Printf("Discord: Couldn't get channel list: %v", err) + return + } + found := false + for _, channel := range channels { + // channel, err := d.bot.Channel(ch.ID) + // if err != nil { + // d.app.err.Printf("Discord: Couldn't get channel: %v", err) + // return + // } + if channel.Name == d.inviteChannelName { + d.inviteChannelID = channel.ID + found = true + break + } + } + if !found { + d.app.err.Printf("Discord: Couldn't find invite channel \"%s\"", d.inviteChannelName) + return + } + } + // channel, err := d.bot.Channel(d.inviteChannelID) + // if err != nil { + // d.app.err.Printf("Discord: Couldn't get invite channel: %v", err) + // return + // } + inv, err = d.bot.ChannelInviteCreate(d.inviteChannelID, dg.Invite{ + // Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1], + // Channel: channel, + // Inviter: d.bot.State.User, + MaxAge: ageSeconds, + MaxUses: maxUses, + Temporary: false, + }) + if err != nil { + d.app.err.Printf("Discord: Failed to create invite: %v", err) + return + } + inviteURL = "https://discord.gg/" + inv.Code + guild, err := d.bot.Guild(d.guildID) + if err != nil { + d.app.err.Printf("Discord: Failed to get guild: %v", err) + return + } + iconURL = guild.IconURL() + return +} + +// Returns the user(s) roughly corresponding to the username (if they are in the guild). +// if no discriminator (#xxxx) is given in the username and there are multiple corresponding users, a list of all matching users is returned. +func (d *DiscordDaemon) GetUsers(username string) []*dg.Member { + members, err := d.bot.GuildMembers( + d.guildID, + "", + 1000, + ) + if err != nil { + d.app.err.Printf("Discord: Failed to get members: %v", err) + return nil + } + hasDiscriminator := strings.Contains(username, "#") + var users []*dg.Member + for _, member := range members { + if hasDiscriminator { + if member.User.Username+"#"+member.User.Discriminator == username { + return []*dg.Member{member} + } + } + if strings.Contains(member.User.Username, username) { + users = append(users, member) + } + } + return users +} + +func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) { + u, err := d.bot.User(ID) + if err != nil { + d.app.err.Printf("Discord: Failed to get user: %v", err) + return + } + user.ID = ID + user.Username = u.Username + user.Contact = true + user.Discriminator = u.Discriminator + channel, err := d.bot.UserChannelCreate(ID) + if err != nil { + d.app.err.Printf("Discord: Failed to create DM channel: %v", err) + return + } + user.ChannelID = channel.ID + ok = true + return +} + +func (d *DiscordDaemon) Shutdown() { + d.Stopped = true + d.ShutdownChannel <- "Down" + <-d.ShutdownChannel + close(d.ShutdownChannel) +} + +func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) { + if m.GuildID != "" && d.channelName != "" { + if d.channelID == "" { + channel, err := s.Channel(m.ChannelID) + if err != nil { + d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err) + d.channelName = "" + } + if channel.Name == d.channelName { + d.channelID = channel.ID + } + } + if d.channelID != m.ChannelID { + d.app.debug.Printf("Discord: Ignoring message as not in specified channel") + return + } + } + if m.Author.ID == s.State.User.ID { + return + } + sects := strings.Split(m.Content, " ") + if len(sects) == 0 { + return + } + lang := d.app.storage.lang.chosenTelegramLang + if user, ok := d.users[m.Author.ID]; ok { + if _, ok := d.app.storage.lang.Telegram[user.Lang]; ok { + lang = user.Lang + } + } + switch msg := sects[0]; msg { + case d.app.config.Section("discord").Key("start_command").MustString("!start"): + d.commandStart(s, m, lang) + case "!lang": + d.commandLang(s, m, sects, lang) + default: + d.commandPIN(s, m, sects, lang) + } +} + +func (d *DiscordDaemon) commandStart(s *dg.Session, m *dg.MessageCreate, lang string) { + channel, err := s.UserChannelCreate(m.Author.ID) + if err != nil { + d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", m.Author.Username, err) + return + } + user := d.MustGetUser(channel.ID, m.Author.ID, m.Author.Discriminator, m.Author.Username) + d.users[m.Author.ID] = user + content := d.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n" + content += d.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "!lang"}) + _, err = s.ChannelMessageSend(channel.ID, content) + if err != nil { + d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) + return + } +} + +func (d *DiscordDaemon) commandLang(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) { + if len(sects) == 1 { + list := "!lang \n" + for code := range d.app.storage.lang.Telegram { + list += fmt.Sprintf("%s: %s\n", code, d.app.storage.lang.Telegram[code].Meta.Name) + } + _, err := s.ChannelMessageSendReply( + m.ChannelID, + list, + m.Reference(), + ) + if err != nil { + d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) + } + return + } + if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok { + var user DiscordUser + for jfID, user := range d.app.storage.discord { + if user.ID == m.Author.ID { + user.Lang = sects[1] + d.app.storage.discord[jfID] = user + if err := d.app.storage.storeDiscordUsers(); err != nil { + d.app.err.Printf("Failed to store Discord users: %v", err) + } + break + } + } + d.users[m.Author.ID] = user + } +} + +func (d *DiscordDaemon) commandPIN(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) { + if _, ok := d.users[m.Author.ID]; ok { + channel, err := s.Channel(m.ChannelID) + if err != nil { + d.app.err.Printf("Discord: Failed to get channel: %v", err) + return + } + if channel.Type != dg.ChannelTypeDM { + d.app.debug.Println("Discord: Ignoring message as not a DM") + return + } + } else { + d.app.debug.Println("Discord: Ignoring message as user was not found") + return + } + tokenIndex := -1 + for i, token := range d.tokens { + if sects[0] == token { + tokenIndex = i + break + } + } + if tokenIndex == -1 { + _, err := s.ChannelMessageSend( + m.ChannelID, + d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"), + ) + if err != nil { + d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) + } + return + } + _, err := s.ChannelMessageSend( + m.ChannelID, + d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"), + ) + if err != nil { + d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) + } + d.verifiedTokens[sects[0]] = d.users[m.Author.ID] + d.tokens[len(d.tokens)-1], d.tokens[tokenIndex] = d.tokens[tokenIndex], d.tokens[len(d.tokens)-1] + d.tokens = d.tokens[:len(d.tokens)-1] +} + +func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error { + channels := make([]string, len(userID)) + for i, id := range userID { + channel, err := d.bot.UserChannelCreate(id) + if err != nil { + return err + } + channels[i] = channel.ID + } + return d.Send(message, channels...) +} + +func (d *DiscordDaemon) Send(message *Message, channelID ...string) error { + msg := "" + var embeds []*dg.MessageEmbed + if message.Markdown != "" { + msg, embeds = StripAltText(message.Markdown, true) + } else { + msg = message.Text + } + for _, id := range channelID { + var err error + if len(embeds) != 0 { + _, err = d.bot.ChannelMessageSendComplex( + id, + &dg.MessageSend{ + Content: msg, + Embed: embeds[0], + }, + ) + if err != nil { + return err + } + for i := 1; i < len(embeds); i++ { + _, err := d.bot.ChannelMessageSendEmbed(id, embeds[i]) + if err != nil { + return err + } + } + } else { + _, err := d.bot.ChannelMessageSend( + id, + msg, + ) + if err != nil { + return err + } + } + } + return nil +} diff --git a/email.go b/email.go index e81d6b5..8d4399b 100644 --- a/email.go +++ b/email.go @@ -230,7 +230,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string, var keys []string plaintext := app.config.Section("email").Key("plaintext").MustBool(false) if plaintext { - if telegramEnabled { + if telegramEnabled || discordEnabled { keys = []string{"text"} text, markdown = "", "" } else { @@ -238,7 +238,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string, text = "" } } else { - if telegramEnabled { + if telegramEnabled || discordEnabled { keys = []string{"html", "text", "markdown"} } else { keys = []string{"html", "text"} @@ -807,8 +807,21 @@ func (app *appContext) sendByID(email *Message, ID ...string) error { var err error if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && telegramEnabled { err = app.telegram.Send(email, tgChat.ChatID) - } else if address, ok := app.storage.emails[id]; ok { - err = app.email.send(email, address.(string)) + if err != nil { + return err + } + } + if dcChat, ok := app.storage.discord[id]; ok && dcChat.Contact && discordEnabled { + err = app.discord.Send(email, dcChat.ChannelID) + 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 { + return err + } } if err != nil { return err @@ -818,11 +831,14 @@ func (app *appContext) sendByID(email *Message, ID ...string) error { } func (app *appContext) getAddressOrName(jfID string) string { + if dcChat, ok := app.storage.discord[jfID]; ok && dcChat.Contact && discordEnabled { + return dcChat.Username + "#" + dcChat.Discriminator + } if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && telegramEnabled { return "@" + tgChat.Username } if addr, ok := app.storage.emails[jfID]; ok { - return addr.(string) + return addr.Addr } return "" } diff --git a/go.mod b/go.mod index 2fe4e0e..1ca27fc 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ replace github.com/hrfee/jfa-go/ombi => ./ombi replace github.com/hrfee/jfa-go/logger => ./logger require ( + github.com/bwmarrin/discordgo v0.23.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a // indirect diff --git a/go.sum b/go.sum index 610d893..8c9ae47 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4= +github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M= github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= @@ -148,6 +150,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/hrfee/mediabrowser v0.3.3 h1:7E05uiol8hh2ytKn3WVLrUIvHAyifYEIy3Y5qtuNh8I= github.com/hrfee/mediabrowser v0.3.3/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/itchyny/timefmt-go v0.1.2 h1:q0Xa4P5it6K6D7ISsbLAMwx1PnWlixDcJL6/sFs93Hs= @@ -265,6 +269,7 @@ github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= golang.org/dl v0.0.0-20190829154251-82a15e2f2ead h1:jeP6FgaSLNTMP+Yri3qjlACywQLye+huGLmNGhBzm6k= golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/html/admin.html b/html/admin.html index a5b784f..31db2b2 100644 --- a/html/admin.html +++ b/html/admin.html @@ -7,6 +7,7 @@ window.notificationsEnabled = {{ .notifications }}; window.emailEnabled = {{ .email_enabled }}; window.telegramEnabled = {{ .telegram_enabled }}; + window.discordEnabled = {{ .discord_enabled }}; window.ombiEnabled = {{ .ombiEnabled }}; window.usernameEnabled = {{ .username }}; window.langFile = JSON.parse({{ .language }}); @@ -327,6 +328,18 @@ {{ end }} + {{ if .discord_enabled }} + + {{ end }}
@@ -488,7 +501,14 @@
+ {{ if .discord_enabled }} + + + + + {{ else }} + {{ end }} @@ -525,6 +545,9 @@ {{ if .telegram_enabled }} Telegram {{ end }} + {{ if .discord_enabled }} + Discord + {{ end }} {{ .strings.expiry }} {{ .strings.lastActiveTime }} diff --git a/html/form-base.html b/html/form-base.html index 8a4a9ee..855253b 100644 --- a/html/form-base.html +++ b/html/form-base.html @@ -17,6 +17,11 @@ window.telegramEnabled = {{ .telegramEnabled }}; window.telegramRequired = {{ .telegramRequired }}; window.telegramPIN = "{{ .telegramPIN }}"; + window.discordEnabled = {{ .discordEnabled }}; + window.discordRequired = {{ .discordRequired }}; + window.discordPIN = "{{ .discordPIN }}"; + window.discordInviteLink = {{ .discordInviteLink }}; + window.discordServerName = "{{ .discordServerName }}"; {{ end }} diff --git a/html/form.html b/html/form.html index df3ee14..6b785d0 100644 --- a/html/form.html +++ b/html/form.html @@ -37,6 +37,17 @@
{{ end }} + {{ if .discordEnabled }} + + {{ end }} @@ -69,13 +80,25 @@ {{ if .telegramEnabled }} {{ .strings.linkTelegram }} + {{ end }} + {{ if .discordEnabled }} + {{ .strings.linkDiscord }} + {{ end }} + {{ if or (.telegramEnabled) (.discordEnabled) }}
+ {{ if .telegramEnabled }} + {{ end }} + {{ if .discordEnabled }} + + {{ end }}
{{ end }} diff --git a/images/discord/1.jpg b/images/discord/1.jpg new file mode 100644 index 0000000..2293e37 Binary files /dev/null and b/images/discord/1.jpg differ diff --git a/images/discord/2.jpg b/images/discord/2.jpg new file mode 100644 index 0000000..2ee00d3 Binary files /dev/null and b/images/discord/2.jpg differ diff --git a/images/discord/3.jpg b/images/discord/3.jpg new file mode 100644 index 0000000..4629c1f Binary files /dev/null and b/images/discord/3.jpg differ diff --git a/images/discord/4.jpg b/images/discord/4.jpg new file mode 100644 index 0000000..6163b33 Binary files /dev/null and b/images/discord/4.jpg differ diff --git a/images/discord/5.jpg b/images/discord/5.jpg new file mode 100644 index 0000000..59ef369 Binary files /dev/null and b/images/discord/5.jpg differ diff --git a/images/discord/6.jpg b/images/discord/6.jpg new file mode 100644 index 0000000..aed80e1 Binary files /dev/null and b/images/discord/6.jpg differ diff --git a/images/discord/7.jpg b/images/discord/7.jpg new file mode 100644 index 0000000..759a731 Binary files /dev/null and b/images/discord/7.jpg differ diff --git a/images/discord/8.jpg b/images/discord/8.jpg new file mode 100644 index 0000000..1cc9e6f Binary files /dev/null and b/images/discord/8.jpg differ diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 8fb42d1..3936b8c 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -20,6 +20,8 @@ "create": "Create", "apply": "Apply", "delete": "Delete", + "add": "Add", + "select": "Select", "name": "Name", "date": "Date", "enabled": "Enabled", @@ -94,7 +96,9 @@ "notifyEvent": "Notify on:", "notifyInviteExpiry": "On expiry", "notifyUserCreation": "On user creation", - "sendPIN": "Ask the user to send the PIN below to the bot." + "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" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", @@ -107,6 +111,7 @@ "updateApplied": "Update applied, please restart.", "updateAppliedRefresh": "Update applied, please refresh.", "telegramVerified": "Telegram account verified.", + "accountConnected": "Account connected.", "errorConnection": "Couldn't connect to jfa-go.", "error401Unauthorized": "Unauthorized. Try refreshing the page.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 9d77a89..3e9ef53 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -17,6 +17,8 @@ "linkTelegram": "Link Telegram", "contactEmail": "Contact through Email", "contactTelegram": "Contact through Telegram", + "linkDiscord": "Link Discord", + "contactDiscord": "Contact through Discord", "theme": "Theme" } } diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 391d672..6726c0b 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -18,14 +18,16 @@ "confirmationRequired": "Email confirmation required", "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." + "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." }, "notifications": { "errorUserExists": "User already exists.", "errorInvalidCode": "Invalid invite code.", "errorTelegramVerification": "Telegram verification required.", - "errorInvalidPIN": "Telegram PIN is invalid.", - "telegramVerified": "Telegram account verified." + "errorDiscordVerification": "Discord verification required.", + "errorInvalidPIN": "PIN is invalid.", + "verified": "Account verified." }, "validationStrings": { "length": { diff --git a/lang/telegram/en-us.json b/lang/telegram/en-us.json index 749587d..642399f 100644 --- a/lang/telegram/en-us.json +++ b/lang/telegram/en-us.json @@ -6,6 +6,6 @@ "startMessage": "Hi!\nEnter your Jellyfin PIN code here 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 /lang, and set language with /lang ." + "languageMessage": "Note: See available languages with {command}, and set language with {command} ." } } diff --git a/main.go b/main.go index a0f12a9..88c4b7a 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,6 @@ import ( "os/signal" "path/filepath" "runtime" - "strconv" "strings" "time" @@ -96,6 +95,7 @@ type appContext struct { validator Validator email *Emailer telegram *TelegramDaemon + discord *DiscordDaemon info, debug, err logger.Logger host string port int @@ -320,6 +320,10 @@ func start(asDaemon, firstCall bool) { app.storage.emails_path = app.config.Section("files").Key("emails").String() if err := app.storage.loadEmails(); err != nil { app.err.Printf("Failed to load Emails: %v", err) + err := app.migrateEmailStorage() + if err != nil { + app.err.Printf("Failed to migrate Email storage: %v", err) + } } app.storage.policy_path = app.config.Section("files").Key("user_template").String() if err := app.storage.loadPolicy(); err != nil { @@ -341,6 +345,10 @@ func start(asDaemon, firstCall bool) { if err := app.storage.loadTelegramUsers(); err != nil { app.err.Printf("Failed to load Telegram users: %v", err) } + app.storage.discord_path = app.config.Section("files").Key("discord_users").String() + if err := app.storage.loadDiscordUsers(); err != nil { + app.err.Printf("Failed to load Discord users: %v", err) + } app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String() app.storage.loadProfiles() @@ -429,76 +437,76 @@ func start(asDaemon, firstCall bool) { app.err.Fatalf("Failed to authenticate with Jellyfin @ %s (%d): %v", server, status, err) } app.info.Printf("Authenticated with %s", server) - /* A couple of unstable Jellyfin 10.7.0 releases decided to hyphenate user IDs. - This checks if the version is equal or higher. */ - checkVersion := func(version string) int { - numberStrings := strings.Split(version, ".") - n := 0 - for _, s := range numberStrings { - num, err := strconv.Atoi(s) - if err == nil { - n += num - } - } - return n - } - if serverType == mediabrowser.JellyfinServer && checkVersion(app.jf.ServerInfo.Version) >= checkVersion("10.7.0") { - // Get users to check if server uses hyphenated userIDs - app.jf.GetUsers(false) + // /* A couple of unstable Jellyfin 10.7.0 releases decided to hyphenate user IDs. + // This checks if the version is equal or higher. */ + // checkVersion := func(version string) int { + // numberStrings := strings.Split(version, ".") + // n := 0 + // for _, s := range numberStrings { + // num, err := strconv.Atoi(s) + // if err == nil { + // n += num + // } + // } + // return n + // } + // if serverType == mediabrowser.JellyfinServer && checkVersion(app.jf.ServerInfo.Version) >= checkVersion("10.7.0") { + // // Get users to check if server uses hyphenated userIDs + // app.jf.GetUsers(false) - noHyphens := true - for id := range app.storage.emails { - if strings.Contains(id, "-") { - noHyphens = false - break - } - } - if noHyphens == app.jf.Hyphens { - var newEmails map[string]interface{} - var newUsers map[string]time.Time - var status, status2 int - var err, err2 error - if app.jf.Hyphens { - app.info.Println(info("Your build of Jellyfin appears to hypenate user IDs. Your emails.json/users.json file will be modified to match.")) - time.Sleep(time.Second * time.Duration(3)) - newEmails, status, err = app.hyphenateEmailStorage(app.storage.emails) - newUsers, status2, err2 = app.hyphenateUserStorage(app.storage.users) - } else { - app.info.Println(info("Your emails.json/users.json file uses hyphens, but the Jellyfin server no longer does. It will be modified.")) - time.Sleep(time.Second * time.Duration(3)) - newEmails, status, err = app.deHyphenateEmailStorage(app.storage.emails) - newUsers, status2, err2 = app.deHyphenateUserStorage(app.storage.users) - } - if status != 200 || err != nil { - app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) - app.err.Fatalf("Couldn't upgrade emails.json") - } - if status2 != 200 || err2 != nil { - app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) - app.err.Fatalf("Couldn't upgrade users.json") - } - emailBakFile := app.storage.emails_path + ".bak" - usersBakFile := app.storage.users_path + ".bak" - err = storeJSON(emailBakFile, app.storage.emails) - err2 = storeJSON(usersBakFile, app.storage.users) - if err != nil { - app.err.Fatalf("couldn't store emails.json backup: %v", err) - } - if err2 != nil { - app.err.Fatalf("couldn't store users.json backup: %v", err) - } - app.storage.emails = newEmails - app.storage.users = newUsers - err = app.storage.storeEmails() - err2 = app.storage.storeUsers() - if err != nil { - app.err.Fatalf("couldn't store emails.json: %v", err) - } - if err2 != nil { - app.err.Fatalf("couldn't store users.json: %v", err) - } - } - } + // noHyphens := true + // for id := range app.storage.emails { + // if strings.Contains(id, "-") { + // noHyphens = false + // break + // } + // } + // if noHyphens == app.jf.Hyphens { + // var newEmails map[string]interface{} + // var newUsers map[string]time.Time + // var status, status2 int + // var err, err2 error + // if app.jf.Hyphens { + // app.info.Println(info("Your build of Jellyfin appears to hypenate user IDs. Your emails.json/users.json file will be modified to match.")) + // time.Sleep(time.Second * time.Duration(3)) + // newEmails, status, err = app.hyphenateEmailStorage(app.storage.emails) + // newUsers, status2, err2 = app.hyphenateUserStorage(app.storage.users) + // } else { + // app.info.Println(info("Your emails.json/users.json file uses hyphens, but the Jellyfin server no longer does. It will be modified.")) + // time.Sleep(time.Second * time.Duration(3)) + // newEmails, status, err = app.deHyphenateEmailStorage(app.storage.emails) + // newUsers, status2, err2 = app.deHyphenateUserStorage(app.storage.users) + // } + // if status != 200 || err != nil { + // app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) + // app.err.Fatalf("Couldn't upgrade emails.json") + // } + // if status2 != 200 || err2 != nil { + // app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) + // app.err.Fatalf("Couldn't upgrade users.json") + // } + // emailBakFile := app.storage.emails_path + ".bak" + // usersBakFile := app.storage.users_path + ".bak" + // err = storeJSON(emailBakFile, app.storage.emails) + // err2 = storeJSON(usersBakFile, app.storage.users) + // if err != nil { + // app.err.Fatalf("couldn't store emails.json backup: %v", err) + // } + // if err2 != nil { + // app.err.Fatalf("couldn't store users.json backup: %v", err) + // } + // app.storage.emails = newEmails + // app.storage.users = newUsers + // err = app.storage.storeEmails() + // err2 = app.storage.storeUsers() + // if err != nil { + // app.err.Fatalf("couldn't store emails.json: %v", err) + // } + // if err2 != nil { + // app.err.Fatalf("couldn't store users.json: %v", err) + // } + // } + // } // Auth (manual user/pass or jellyfin) app.jellyfinLogin = true @@ -562,11 +570,22 @@ func start(asDaemon, firstCall bool) { app.telegram, err = newTelegramDaemon(app) if err != nil { app.err.Printf("Failed to authenticate with Telegram: %v", err) + telegramEnabled = false } else { go app.telegram.run() defer app.telegram.Shutdown() } } + if discordEnabled { + app.discord, err = newDiscordDaemon(app) + if err != nil { + app.err.Printf("Failed to authenticate with Discord: %v", err) + discordEnabled = false + } else { + go app.discord.run() + defer app.discord.Shutdown() + } + } } else { debugMode = false address = "0.0.0.0:8056" diff --git a/models.go b/models.go index 772238b..e14030d 100644 --- a/models.go +++ b/models.go @@ -17,6 +17,8 @@ type newUserDTO struct { Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser) TelegramPIN string `json:"telegram_pin" example:"A1-B2-3C"` // Telegram verification PIN (if used) 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 } type newUserResponse struct { @@ -48,7 +50,7 @@ type generateInviteDTO struct { UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry - Email string `json:"email" example:"jeff@jellyf.in"` // Send invite to this address + SendTo string `json:"send-to" example:"jeff@jellyf.in"` // Send invite to this address or discord name MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses NoLimit bool `json:"no-limit" example:"false"` // No invite use limit RemainingUses int `json:"remaining-uses" example:"5"` // Remaining invite uses @@ -98,7 +100,7 @@ type inviteDTO struct { UsedBy map[string]int64 `json:"used-by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable) - Email string `json:"email,omitempty"` // Email the invite was sent to (if applicable) + SendTo string `json:"send_to,omitempty"` // Email/Discord username the invite was sent to (if applicable) NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not Label string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite @@ -125,12 +127,16 @@ type respUser struct { ID string `json:"id" example:"fdgsdfg45534fa"` // userID of user Name string `json:"name" example:"jeff"` // Username of user Email string `json:"email,omitempty" example:"jeff@jellyf.in"` // Email address of user (if available) - LastActive int64 `json:"last_active" example:"1617737207510"` // Time of last activity on Jellyfin - Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator - Expiry int64 `json:"expiry" example:"1617737207510"` // Expiry time of user as Epoch/Unix time. - Disabled bool `json:"disabled"` // Whether or not the user is disabled. - Telegram string `json:"telegram"` // Telegram username (if known) + NotifyThroughEmail bool `json:"notify_email"` + LastActive int64 `json:"last_active" example:"1617737207510"` // Time of last activity on Jellyfin + Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator + Expiry int64 `json:"expiry" example:"1617737207510"` // Expiry time of user as Epoch/Unix time. + Disabled bool `json:"disabled"` // Whether or not the user is disabled. + Telegram string `json:"telegram"` // Telegram username (if known) NotifyThroughTelegram bool `json:"notify_telegram"` + Discord string `json:"discord"` // Discord username (if known) + DiscordID string `json:"discord_id"` // Discord user ID for creating links. + NotifyThroughDiscord bool `json:"notify_discord"` } type getUsersDTO struct { @@ -249,7 +255,29 @@ type telegramSetDTO struct { ID string `json:"id"` // Jellyfin ID of user. } -type telegramNotifyDTO struct { - ID string `json:"id"` - Enabled bool `json:"enabled"` +type SetContactMethodsDTO struct { + ID string `json:"id"` + Email bool `json:"email"` + Discord bool `json:"discord"` + Telegram bool `json:"telegram"` +} + +type DiscordUserDTO struct { + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + ID string `json:"id"` +} + +type DiscordUsersDTO struct { + Users []DiscordUserDTO `json:"users"` +} + +type DiscordConnectUserDTO struct { + JellyfinID string `json:"jf_id"` + DiscordID string `json:"discord_id"` +} + +type DiscordInviteDTO struct { + InviteURL string `json:"invite"` + IconURL string `json:"icon"` } diff --git a/router.go b/router.go index 355fb3f..a717d49 100644 --- a/router.go +++ b/router.go @@ -121,6 +121,12 @@ func (app *appContext) loadRoutes(router *gin.Engine) { if telegramEnabled { router.GET(p+"/invite/:invCode/telegram/verified/:pin", app.TelegramVerifiedInvite) } + if discordEnabled { + router.GET(p+"/invite/:invCode/discord/verified/:pin", app.DiscordVerifiedInvite) + if app.config.Section("discord").Key("provide_invite").MustBool(false) { + router.GET(p+"/invite/:invCode/discord/invite", app.DiscordServerInvite) + } + } } if *SWAGGER { app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n")) @@ -158,11 +164,15 @@ 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 { + if telegramEnabled || discordEnabled { api.GET(p+"/telegram/pin", app.TelegramGetPin) api.GET(p+"/telegram/verified/:pin", app.TelegramVerified) api.POST(p+"/users/telegram", app.TelegramAddUser) - api.POST(p+"/users/telegram/notify", app.TelegramSetNotify) + api.POST(p+"/users/contact", app.SetContactMethods) + } + if discordEnabled { + api.GET(p+"/users/discord/:username", app.DiscordGetUsers) + api.POST(p+"/users/discord", app.DiscordConnect) } if app.config.Section("ombi").Key("enabled").MustBool(false) { api.GET(p+"/ombi/users", app.OmbiUsers) diff --git a/storage.go b/storage.go index 0fe7333..c32fb75 100644 --- a/storage.go +++ b/storage.go @@ -15,19 +15,21 @@ 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 string - users map[string]time.Time - invites Invites - profiles map[string]Profile - defaultProfile string - emails, displayprefs, ombi_template map[string]interface{} - telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram 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 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 } type TelegramUser struct { @@ -37,6 +39,20 @@ type TelegramUser struct { Contact bool // Whether to contact through telegram or not } +type DiscordUser struct { + ChannelID string + ID string + Username string + Discriminator string + Lang string + Contact bool +} + +type EmailAddress struct { + Addr string + Contact bool +} + type customEmails struct { UserCreated customEmail `json:"userCreated"` InviteExpiry customEmail `json:"inviteExpiry"` @@ -79,7 +95,7 @@ type Invite struct { UserDays int `json:"user-days,omitempty"` UserHours int `json:"user-hours,omitempty"` UserMinutes int `json:"user-minutes,omitempty"` - Email string `json:"email"` + SendTo string `json:"email"` // Used to be stored as formatted time, now as Unix. UsedBy [][]string `json:"used-by"` Notify map[string]map[string]bool `json:"notify"` @@ -89,23 +105,24 @@ type Invite struct { } type Lang struct { - AdminPath string - chosenAdminLang string - Admin adminLangs - AdminJSON map[string]string - FormPath string - chosenFormLang string - Form formLangs - PasswordResetPath string - chosenPWRLang string - PasswordReset pwrLangs - EmailPath string - chosenEmailLang string - Email emailLangs - CommonPath string - Common commonLangs - SetupPath string - Setup setupLangs + AdminPath string + chosenAdminLang string + Admin adminLangs + AdminJSON map[string]string + FormPath string + chosenFormLang string + Form formLangs + PasswordResetPath string + chosenPWRLang string + PasswordReset pwrLangs + EmailPath string + chosenEmailLang string + Email emailLangs + CommonPath string + Common commonLangs + SetupPath string + Setup setupLangs + // Telegram translations are also used for Discord bots (and likely future ones). chosenTelegramLang string TelegramPath string Telegram telegramLangs @@ -765,6 +782,14 @@ func (st *Storage) storeTelegramUsers() error { return storeJSON(st.telegram_path, st.telegram) } +func (st *Storage) loadDiscordUsers() error { + return loadJSON(st.discord_path, &st.discord) +} + +func (st *Storage) storeDiscordUsers() error { + return storeJSON(st.discord_path, st.discord) +} + func (st *Storage) loadCustomEmails() error { return loadJSON(st.customEmails_path, &st.customEmails) } @@ -884,85 +909,85 @@ func storeJSON(path string, obj interface{}) error { return err } -// One build of JF 10.7.0 hyphenated user IDs while another one later didn't. These functions will hyphenate/de-hyphenate email storage. - -func hyphenate(userID string) string { - if userID[8] == '-' { - return userID - } - return userID[:8] + "-" + userID[8:12] + "-" + userID[12:16] + "-" + userID[16:20] + "-" + userID[20:] -} - -func (app *appContext) deHyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) { - jfUsers, status, err := app.jf.GetUsers(false) - if status != 200 || err != nil { - return nil, status, err - } - newEmails := map[string]interface{}{} - for _, user := range jfUsers { - unHyphenated := user.ID - hyphenated := hyphenate(unHyphenated) - val, ok := old[hyphenated] - if ok { - newEmails[unHyphenated] = val - } - } - return newEmails, status, err -} - -func (app *appContext) hyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) { - jfUsers, status, err := app.jf.GetUsers(false) - if status != 200 || err != nil { - return nil, status, err - } - newEmails := map[string]interface{}{} - for _, user := range jfUsers { - unstripped := user.ID - stripped := strings.ReplaceAll(unstripped, "-", "") - val, ok := old[stripped] - if ok { - newEmails[unstripped] = val - } - } - return newEmails, status, err -} - -func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { - return app.hyphenateStorage(old) -} - -func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { - return app.deHyphenateStorage(old) -} - -func (app *appContext) hyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) { - asInterface := map[string]interface{}{} - for k, v := range old { - asInterface[k] = v - } - fixed, status, err := app.hyphenateStorage(asInterface) - if err != nil { - return nil, status, err - } - out := map[string]time.Time{} - for k, v := range fixed { - out[k] = v.(time.Time) - } - return out, status, err -} - -func (app *appContext) deHyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) { - asInterface := map[string]interface{}{} - for k, v := range old { - asInterface[k] = v - } - fixed, status, err := app.deHyphenateStorage(asInterface) - if err != nil { - return nil, status, err - } - out := map[string]time.Time{} - for k, v := range fixed { - out[k] = v.(time.Time) - } - return out, status, err -} +// // One build of JF 10.7.0 hyphenated user IDs while another one later didn't. These functions will hyphenate/de-hyphenate email storage. +// +// func hyphenate(userID string) string { +// if userID[8] == '-' { +// return userID +// } +// return userID[:8] + "-" + userID[8:12] + "-" + userID[12:16] + "-" + userID[16:20] + "-" + userID[20:] +// } +// +// func (app *appContext) deHyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) { +// jfUsers, status, err := app.jf.GetUsers(false) +// if status != 200 || err != nil { +// return nil, status, err +// } +// newEmails := map[string]interface{}{} +// for _, user := range jfUsers { +// unHyphenated := user.ID +// hyphenated := hyphenate(unHyphenated) +// val, ok := old[hyphenated] +// if ok { +// newEmails[unHyphenated] = val +// } +// } +// return newEmails, status, err +// } +// +// func (app *appContext) hyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) { +// jfUsers, status, err := app.jf.GetUsers(false) +// if status != 200 || err != nil { +// return nil, status, err +// } +// newEmails := map[string]interface{}{} +// for _, user := range jfUsers { +// unstripped := user.ID +// stripped := strings.ReplaceAll(unstripped, "-", "") +// val, ok := old[stripped] +// if ok { +// newEmails[unstripped] = val +// } +// } +// return newEmails, status, err +// } +// +// func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { +// return app.hyphenateStorage(old) +// } +// +// func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { +// return app.deHyphenateStorage(old) +// } +// +// func (app *appContext) hyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) { +// asInterface := map[string]interface{}{} +// for k, v := range old { +// asInterface[k] = v +// } +// fixed, status, err := app.hyphenateStorage(asInterface) +// if err != nil { +// return nil, status, err +// } +// out := map[string]time.Time{} +// for k, v := range fixed { +// out[k] = v.(time.Time) +// } +// return out, status, err +// } +// +// func (app *appContext) deHyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) { +// asInterface := map[string]interface{}{} +// for k, v := range old { +// asInterface[k] = v +// } +// fixed, status, err := app.deHyphenateStorage(asInterface) +// if err != nil { +// return nil, status, err +// } +// out := map[string]time.Time{} +// for k, v := range fixed { +// out[k] = v.(time.Time) +// } +// return out, status, err +// } diff --git a/stripmd.go b/stripmd.go index 0568067..46a9a15 100644 --- a/stripmd.go +++ b/stripmd.go @@ -3,22 +3,57 @@ package main import ( "strings" + dg "github.com/bwmarrin/discordgo" stripmd "github.com/writeas/go-strip-markdown" ) +type Link struct { + Alt, URL string +} + // StripAltText removes Markdown alt text from links and images and replaces them with just the URL. // Currently uses the deepest alt text when links/images are nested. -func StripAltText(md string) string { +// If links = true, links are completely removed, and a list of URLs and their alt text is also returned. +func StripAltText(md string, links bool) (string, []*dg.MessageEmbed) { altTextStart := -1 // Start of alt text (between '[' & ']') URLStart := -1 // Start of url (between '(' & ')') URLEnd := -1 previousURLEnd := -2 out := "" + embeds := []*dg.MessageEmbed{} for i := range md { if altTextStart != -1 && URLStart != -1 && md[i] == ')' { URLEnd = i - 1 - out += md[previousURLEnd+2:altTextStart-1] + md[URLStart:URLEnd+1] + out += md[previousURLEnd+2 : altTextStart-1] + if links { + embed := &dg.MessageEmbed{ + Type: dg.EmbedTypeLink, + Title: md[altTextStart : URLStart-2], + } + if md[altTextStart-1] == '!' { + embed.Title = md[altTextStart+1 : URLStart-2] + embed.Type = dg.EmbedTypeImage + embed.Image = &dg.MessageEmbedImage{ + URL: md[URLStart : URLEnd+1], + } + } else { + embed.URL = md[URLStart : URLEnd+1] + } + embeds = append(embeds, embed) + } else { + out += md[URLStart : URLEnd+1] + } previousURLEnd = URLEnd + // Removing links often leaves a load of extra newlines which look weird, this removes them. + if links { + next := 2 + for md[URLEnd+next] == '\n' { + next++ + } + if next >= 3 { + previousURLEnd += next - 2 + } + } altTextStart, URLStart, URLEnd = -1, -1, -1 continue } @@ -36,11 +71,12 @@ func StripAltText(md string) string { out += md[previousURLEnd+2:] } if out == "" { - return md + return md, embeds } - return out + return out, embeds } func stripMarkdown(md string) string { - return strings.TrimPrefix(strings.TrimSuffix(stripmd.Strip(StripAltText(md)), "

"), "

") + stripped, _ := StripAltText(md, false) + return strings.TrimPrefix(strings.TrimSuffix(stripmd.Strip(stripped), "

"), "

") } diff --git a/telegram.go b/telegram.go index 050086c..1975757 100644 --- a/telegram.go +++ b/telegram.go @@ -9,7 +9,7 @@ import ( tg "github.com/go-telegram-bot-api/telegram-bot-api" ) -type VerifiedToken struct { +type TelegramVerifiedToken struct { Token string ChatID int64 Username string @@ -21,7 +21,7 @@ type TelegramDaemon struct { bot *tg.BotAPI username string tokens []string - verifiedTokens []VerifiedToken + verifiedTokens []TelegramVerifiedToken languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start. link string app *appContext @@ -37,12 +37,11 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) { return nil, err } td := &TelegramDaemon{ - Stopped: false, ShutdownChannel: make(chan string), bot: bot, username: bot.Self.UserName, tokens: []string{}, - verifiedTokens: []VerifiedToken{}, + verifiedTokens: []TelegramVerifiedToken{}, languages: map[int64]string{}, link: "https://t.me/" + bot.Self.UserName, app: app, @@ -55,10 +54,7 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) { return td, nil } -var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -// NewAuthToken generates an 8-character pin in the form "A1-2B-CD". -func (t *TelegramDaemon) NewAuthToken() string { +func genAuthToken() string { rand.Seed(time.Now().UnixNano()) pin := make([]rune, 8) for i := range pin { @@ -68,10 +64,18 @@ func (t *TelegramDaemon) NewAuthToken() string { pin[i] = runes[rand.Intn(len(runes))] } } - t.tokens = append(t.tokens, string(pin)) return string(pin) } +var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +// NewAuthToken generates an 8-character pin in the form "A1-2B-CD". +func (t *TelegramDaemon) NewAuthToken() string { + pin := genAuthToken() + t.tokens = append(t.tokens, pin) + return pin +} + func (t *TelegramDaemon) run() { t.app.info.Println("Starting Telegram bot daemon") u := tg.NewUpdate(0) @@ -79,6 +83,7 @@ func (t *TelegramDaemon) run() { updates, err := t.bot.GetUpdatesChan(u) if err != nil { t.app.err.Printf("Failed to start Telegram daemon: %v", err) + telegramEnabled = false return } for { @@ -171,7 +176,7 @@ func (t *TelegramDaemon) Shutdown() { func (t *TelegramDaemon) commandStart(upd *tg.Update, sects []string, lang string) { content := t.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n" - content += t.app.storage.lang.Telegram[lang].Strings.get("languageMessage") + content += t.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "/lang"}) err := t.Reply(upd, content) if err != nil { t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) @@ -180,9 +185,9 @@ func (t *TelegramDaemon) commandStart(upd *tg.Update, sects []string, lang strin func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string) { if len(sects) == 1 { - list := "/lang \n" + list := "/lang ``\n" for code := range t.app.storage.lang.Telegram { - list += fmt.Sprintf("%s: %s\n", code, t.app.storage.lang.Telegram[code].Meta.Name) + list += fmt.Sprintf("`%s`: %s\n", code, t.app.storage.lang.Telegram[code].Meta.Name) } err := t.Reply(upd, list) if err != nil { @@ -196,8 +201,7 @@ func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string if user.ChatID == upd.Message.Chat.ID { user.Lang = sects[1] t.app.storage.telegram[jfID] = user - err := t.app.storage.storeTelegramUsers() - if err != nil { + if err := t.app.storage.storeTelegramUsers(); err != nil { t.app.err.Printf("Failed to store Telegram users: %v", err) } break @@ -225,7 +229,7 @@ func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string) if err != nil { t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) } - t.verifiedTokens = append(t.verifiedTokens, VerifiedToken{ + t.verifiedTokens = append(t.verifiedTokens, TelegramVerifiedToken{ Token: upd.Message.Text, ChatID: upd.Message.Chat.ID, Username: upd.Message.Chat.UserName, diff --git a/ts/admin.ts b/ts/admin.ts index 6c14be7..edfb009 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -66,6 +66,10 @@ window.availableProfiles = window.availableProfiles || []; if (window.telegramEnabled) { window.modals.telegram = new Modal(document.getElementById("modal-telegram")); } + + if (window.discordEnabled) { + window.modals.discord = new Modal(document.getElementById("modal-discord")); + } })(); var inviteCreator = new createInvite(); diff --git a/ts/form.ts b/ts/form.ts index 0cd1611..94d3874 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -8,12 +8,18 @@ interface formWindow extends Window { invalidPassword: string; successModal: Modal; telegramModal: Modal; + discordModal: Modal; confirmationModal: Modal code: string; messages: { [key: string]: string }; confirmation: boolean; telegramRequired: boolean; telegramPIN: string; + discordRequired: boolean; + discordPIN: string; + discordStartCommand: string; + discordInviteLink: boolean; + discordServerName: string; userExpiryEnabled: boolean; userExpiryMonths: number; userExpiryDays: number; @@ -68,7 +74,7 @@ if (window.telegramEnabled) { telegramVerified = true; waiting.classList.add("~positive"); waiting.classList.remove("~info"); - window.notifications.customPositive("telegramVerified", "", window.messages["telegramVerified"]); + window.notifications.customPositive("telegramVerified", "", window.messages["verified"]); setTimeout(window.telegramModal.close, 2000); telegramButton.classList.add("unfocused"); document.getElementById("contact-via").classList.remove("unfocused"); @@ -84,6 +90,66 @@ if (window.telegramEnabled) { }; } +interface DiscordInvite { + invite: string; + icon: string; +} + +var discordVerified = false; +if (window.discordEnabled) { + window.discordModal = new Modal(document.getElementById("modal-discord"), window.discordRequired); + const discordButton = document.getElementById("link-discord") as HTMLSpanElement; + if (window.discordInviteLink) { + _get("/invite/" + window.code + "/discord/invite", null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status != 200) { + return; + } + const inv = req.response as DiscordInvite; + const link = document.getElementById("discord-invite") as HTMLAnchorElement; + link.classList.add("subheading", "link-center"); + link.href = inv.invite; + link.target = "_blank"; + link.innerHTML = `${window.discordServerName}`; + } + }); + } + discordButton.onclick = () => { + const waiting = document.getElementById("discord-waiting") as HTMLSpanElement; + toggleLoader(waiting); + window.discordModal.show(); + let modalClosed = false; + window.discordModal.onclose = () => { + modalClosed = true; + toggleLoader(waiting); + } + const checkVerified = () => _get("/invite/" + window.code + "/discord/verified/" + window.discordPIN, null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status == 401) { + window.discordModal.close(); + window.notifications.customError("invalidCodeError", window.messages["errorInvalidCode"]); + return; + } else if (req.status == 200) { + if (req.response["success"] as boolean) { + discordVerified = true; + waiting.classList.add("~positive"); + waiting.classList.remove("~info"); + window.notifications.customPositive("discordVerified", "", window.messages["verified"]); + setTimeout(window.discordModal.close, 2000); + discordButton.classList.add("unfocused"); + document.getElementById("contact-via").classList.remove("unfocused"); + const radio = document.getElementById("contact-via-discord") as HTMLInputElement; + radio.checked = true; + } else if (!modalClosed) { + setTimeout(checkVerified, 1500); + } + } + } + }); + checkVerified(); + }; +} + if (window.confirmation) { window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true); } @@ -161,6 +227,8 @@ interface sendDTO { password: string; telegram_pin?: string; telegram_contact?: boolean; + discord_pin?: string; + discord_contact?: boolean; } const create = (event: SubmitEvent) => { @@ -179,6 +247,13 @@ const create = (event: SubmitEvent) => { send.telegram_contact = true; } } + if (discordVerified) { + send.discord_pin = window.discordPIN; + const radio = document.getElementById("contact-via-discord") as HTMLInputElement; + if (radio.checked) { + send.discord_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 e383b4a..b00ef97 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -2,23 +2,30 @@ import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, toDateStri import { templateEmail } from "../modules/settings.js"; import { Marked } from "@ts-stack/markdown"; import { stripMarkdown } from "../modules/stripmd.js"; +import { DiscordUser, newDiscordSearch } from "../modules/discord.js"; interface User { id: string; name: string; email: string | undefined; + notify_email: boolean; last_active: number; admin: boolean; disabled: boolean; expiry: number; telegram: string; notify_telegram: boolean; + discord: string; + notify_discord: boolean; + discord_id: string; } interface getPinResponse { token: string; username: string; } + +var addDiscord: (passData: string) => void; class user implements User { private _row: HTMLTableRowElement; @@ -27,16 +34,21 @@ class user implements User { private _admin: HTMLSpanElement; private _disabled: HTMLSpanElement; private _email: HTMLInputElement; + private _notifyEmail: boolean; private _emailAddress: string; private _emailEditButton: HTMLElement; private _telegram: HTMLTableDataCellElement; private _telegramUsername: string; private _notifyTelegram: boolean; + private _discord: HTMLTableDataCellElement; + private _discordUsername: string; + private _discordID: string; + private _notifyDiscord: boolean; private _expiry: HTMLTableDataCellElement; private _expiryUnix: number; private _lastActive: HTMLTableDataCellElement; private _lastActiveUnix: number; - id: string; + id = ""; private _selected: boolean; get selected(): boolean { return this._selected; } @@ -81,6 +93,21 @@ class user implements User { this._email.textContent = value; } } + + 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 (window.discordEnabled && this._discordUsername) { + const email = this._discord.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement; + email.checked = s; + } + } get telegram(): string { return this._telegramUsername; } set telegram(u: string) { @@ -90,43 +117,52 @@ class user implements User { this._telegram.innerHTML = `Add`; (this._telegram.querySelector("span") as HTMLSpanElement).onclick = this._addTelegram; } else { - this._telegram.innerHTML = ` - @${u} - -

+ let innerHTML = ` +
+ @${u} `; - // 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 radios = this._telegram.querySelectorAll("input") as NodeListOf; - for (let i = 0; i < radios.length; i++) { - radios[i].onclick = this._setTelegramNotify; + if (!window.discordEnabled || !this._discordUsername) { + innerHTML += ` + + + `; } - - 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); + 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); + } + }; + } } } @@ -134,36 +170,133 @@ class user implements User { set notify_telegram(s: boolean) { if (!window.telegramEnabled || !this._telegramUsername) return; this._notifyTelegram = s; - const radios = this._telegram.querySelectorAll("input") as NodeListOf; - radios[0].checked = !s; - radios[1].checked = 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; + } } - private _setTelegramNotify = () => { - const radios = this._telegram.querySelectorAll("input") as NodeListOf; + 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; let send = { id: this.id, - enabled: radios[1].checked - }; - _post("/users/telegram/notify", send, (req: XMLHttpRequest) => { + email: email.checked + } + if (window.telegramEnabled && this._telegramUsername) { + const telegram = el.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; + send["discord"] = discord.checked; + } + _post("/users/contact", send, (req: XMLHttpRequest) => { if (req.readyState == 4) { if (req.status != 200) { - window.notifications.customError("errorSetTelegramNotify", window.lang.notif("errorSaveSettings")); - radios[0].checked, radios[1].checked= radios[1].checked, radios[0].checked; + window.notifications.customError("errorSetNotify", window.lang.notif("errorSaveSettings")); + document.dispatchEvent(new CustomEvent("accounts-reload")); return; } } }, false, (req: XMLHttpRequest) => { if (req.status == 0) { window.notifications.connectionError(); - radios[0].checked, radios[1].checked= radios[1].checked, radios[0].checked; + document.dispatchEvent(new CustomEvent("accounts-reload")); return; } else if (req.status == 401) { - radios[0].checked, radios[1].checked= radios[1].checked, radios[0].checked; window.notifications.customError("401Error", window.lang.notif("error401Unauthorized")); + document.dispatchEvent(new CustomEvent("accounts-reload")); } }); } + + get discord(): string { return this._discordUsername; } + set discord(u: string) { + if (!window.discordEnabled) return; + this._discordUsername = u; + if (u == "") { + this._discord.innerHTML = `Add`; + (this._discord.querySelector("span") as HTMLSpanElement).onclick = () => addDiscord(this.id); + } else { + let 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"); + } + + 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); + } + }; + } + } + + get discord_id(): string { return this._discordID; } + set discord_id(id: string) { + if (!window.discordEnabled || this._discordUsername == "") return; + this._discordID = id; + const link = this._discord.getElementsByClassName("discord-link")[0] as HTMLAnchorElement; + link.href = `https://discord.com/users/${id}`; + } + + 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; + } + } get expiry(): number { return this._expiryUnix; } set expiry(unix: number) { @@ -192,14 +325,19 @@ class user implements User { this._row = document.createElement("tr") as HTMLTableRowElement; let innerHTML = ` - - +
+
`; if (window.telegramEnabled) { innerHTML += ` `; } + if (window.discordEnabled) { + innerHTML += ` + + `; + } innerHTML += ` @@ -213,6 +351,7 @@ class user implements User { this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement; 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._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; } @@ -270,7 +409,7 @@ class user implements User { } }); } - + private _addTelegram = () => _get("/telegram/pin", null, (req: XMLHttpRequest) => { if (req.readyState == 4 && req.status == 200) { const pin = document.getElementById("telegram-pin"); @@ -319,12 +458,16 @@ class user implements User { this.id = user.id; this.name = user.name; this.email = user.email || ""; + this.discord = user.discord; this.telegram = user.telegram; 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_email = user.notify_email; + this.discord_id = user.discord_id; } asElement = (): HTMLTableRowElement => { return this._row; } @@ -935,6 +1078,19 @@ export class accountsList { }; this._announceTextarea.onkeyup = this.loadPreview; + addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => { + _post("/users/discord", {jf_id: id, discord_id: user.id}, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + document.dispatchEvent(new CustomEvent("accounts-reload")); + if (req.status != 200) { + window.notifications.customError("errorConnectDiscord", window.lang.notif("errorFailureCheckLogs")); + return + } + window.notifications.customSuccess("discordConnected", window.lang.notif("accountConnected")); + window.modals.discord.close() + } + }); + }); } reload = () => _get("/users", null, (req: XMLHttpRequest) => { diff --git a/ts/modules/discord.ts b/ts/modules/discord.ts new file mode 100644 index 0000000..ea8138a --- /dev/null +++ b/ts/modules/discord.ts @@ -0,0 +1,79 @@ +import {addLoader, removeLoader, _get} from "../modules/common.js"; + +export interface DiscordUser { + name: string; + avatar_url: string; + id: string; +} + +var listeners: { [buttonText: string]: (event: CustomEvent) => void } = {}; + +export function newDiscordSearch(title: string, description: string, buttonText: string, buttonFunction: (user: DiscordUser, passData: string) => void): (passData: string) => void { + if (!window.discordEnabled) { + return () => {}; + } + let timer: NodeJS.Timer; + listeners[buttonText] = (event: CustomEvent) => { + clearTimeout(timer); + const list = document.getElementById("discord-list") as HTMLTableElement; + const input = document.getElementById("discord-search") as HTMLInputElement; + if (input.value.length < 2) { + return; + } + list.innerHTML = ``; + addLoader(list); + list.parentElement.classList.add("mb-1", "mt-1"); + timer = setTimeout(() => { + _get("/users/discord/" + input.value, null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status != 200) { + removeLoader(list); + list.parentElement.classList.remove("mb-1", "mt-1"); + return; + } + const users = req.response["users"] as Array; + let innerHTML = ``; + for (let i = 0; i < users.length; i++) { + innerHTML += ` + + + + + +

${users[i].name}

+ + + ${buttonText} + + + `; + } + list.innerHTML = innerHTML; + removeLoader(list); + list.parentElement.classList.remove("mb-1", "mt-1"); + for (let i = 0; i < users.length; i++) { + const button = document.getElementById(`discord-user-${users[i].id}`) as HTMLInputElement; + button.onclick = () => buttonFunction(users[i], event.detail); + } + } + }); + }, 750); + } + + return (passData: string) => { + const input = document.getElementById("discord-search") as HTMLInputElement; + const list = document.getElementById("discord-list") as HTMLDivElement; + const header = document.getElementById("discord-header") as HTMLSpanElement; + const desc = document.getElementById("discord-description") as HTMLParagraphElement; + desc.textContent = description; + header.textContent = title; + list.innerHTML = ``; + input.value = ""; + for (let key in listeners) { + input.removeEventListener("keyup", listeners[key]); + } + input.addEventListener("keyup", listeners[buttonText].bind(null, { detail: passData })); + + window.modals.discord.show(); + } +} diff --git a/ts/modules/invites.ts b/ts/modules/invites.ts index 4645755..e43cd50 100644 --- a/ts/modules/invites.ts +++ b/ts/modules/invites.ts @@ -1,4 +1,5 @@ import { _get, _post, _delete, toClipboard, toggleLoader, toDateString } from "../modules/common.js"; +import { DiscordUser, newDiscordSearch } from "../modules/discord.js"; class DOMInvite implements Invite { updateNotify = (checkbox: HTMLInputElement) => { @@ -25,6 +26,7 @@ class DOMInvite implements Invite { document.dispatchEvent(inviteDeletedEvent); } }) + private _label: string = ""; get label(): string { return this._label; } set label(label: string) { @@ -82,10 +84,10 @@ class DOMInvite implements Invite { this._middle.querySelector("strong.inv-remaining").textContent = remaining; } - private _email: string = ""; - get email(): string { return this._email }; - set email(address: string) { - this._email = address; + private _send_to: string = ""; + get send_to(): string { return this._send_to }; + set send_to(address: string) { + this._send_to = address; const container = this._infoArea.querySelector(".tooltip") as HTMLDivElement; const icon = container.querySelector("i"); const chip = container.querySelector("span.inv-email-chip"); @@ -100,7 +102,7 @@ class DOMInvite implements Invite { } else { container.classList.add("mr-1"); chip.classList.add("chip"); - if (address.includes("Failed to send to")) { + if (address.includes("Failed")) { icon.classList.remove("ri-mail-line"); icon.classList.add("ri-mail-close-line"); chip.classList.remove("~neutral"); @@ -372,7 +374,7 @@ class DOMInvite implements Invite { update = (invite: Invite) => { this.code = invite.code; this.created = invite.created; - this.email = invite.email; + this.send_to = invite.send_to; this.expiresIn = invite.expiresIn; if (window.notificationsEnabled) { this.notifyCreation = invite.notifyCreation; @@ -482,7 +484,7 @@ export class inviteList implements inviteList { function parseInvite(invite: { [f: string]: string | number | { [name: string]: number } | boolean }): Invite { let parsed: Invite = {}; parsed.code = invite["code"] as string; - parsed.email = invite["email"] as string || ""; + parsed.send_to = invite["send_to"] as string || ""; parsed.label = invite["label"] as string || ""; let time = ""; let userExpiryTime = ""; @@ -520,6 +522,7 @@ function parseInvite(invite: { [f: string]: string | number | { [name: string]: export class createInvite { private _sendToEnabled = document.getElementById("create-send-to-enabled") as HTMLInputElement; private _sendTo = document.getElementById("create-send-to") as HTMLInputElement; + private _discordSearch: HTMLSpanElement; private _userExpiryToggle = document.getElementById("create-user-expiry-enabled") as HTMLInputElement; private _uses = document.getElementById('create-uses') as HTMLInputElement; private _infUses = document.getElementById("create-inf-uses") as HTMLInputElement; @@ -542,6 +545,8 @@ export class createInvite { private _invDuration = document.getElementById('inv-duration'); private _userExpiry = document.getElementById('user-expiry'); + private _sendToDiscord: (passData: string) => void; + // Broadcast when new invite created private _newInviteEvent = new CustomEvent("newInviteEvent"); private _firstLoad = true; @@ -576,9 +581,19 @@ export class createInvite { if (state) { this._sendToEnabled.parentElement.classList.remove("~neutral"); this._sendToEnabled.parentElement.classList.add("~urge"); + if (window.discordEnabled) { + this._discordSearch.classList.remove("~neutral"); + this._discordSearch.classList.add("~urge"); + this._discordSearch.onclick = () => this._sendToDiscord(""); + } } else { this._sendToEnabled.parentElement.classList.remove("~urge"); this._sendToEnabled.parentElement.classList.add("~neutral"); + if (window.discordEnabled) { + this._discordSearch.classList.remove("~urge"); + this._discordSearch.classList.add("~neutral"); + this._discordSearch.onclick = null; + } } } @@ -732,7 +747,7 @@ export class createInvite { "multiple-uses": (this.uses > 1 || this.infiniteUses), "no-limit": this.infiniteUses, "remaining-uses": this.uses, - "email": this.sendToEnabled ? this.sendTo : "", + "send-to": this.sendToEnabled ? this.sendTo : "", "profile": this.profile, "label": this.label }; @@ -761,7 +776,6 @@ export class createInvite { this._userDays.disabled = true; this._userHours.disabled = true; this._userMinutes.disabled = true; - this.sendToEnabled = false; this._createButton.onclick = this.create; this.sendTo = ""; this.uses = 1; @@ -798,11 +812,22 @@ export class createInvite { this._minutes.onchange = this._checkDurationValidity; document.addEventListener("profileLoadEvent", () => { this.loadProfiles(); }, false); - if (!window.emailEnabled) { + if (!window.emailEnabled && !window.discordEnabled) { document.getElementById("create-send-to-container").classList.add("unfocused"); } + + if (window.discordEnabled) { + this._discordSearch = document.getElementById("create-send-to-search") as HTMLSpanElement; + this._sendToDiscord = newDiscordSearch( + window.lang.strings("findDiscordUser"), + window.lang.strings("searchDiscordUser"), + window.lang.strings("select"), + (user: DiscordUser) => { + this.sendTo = user.name; + window.modals.discord.close(); + } + ); + } + this.sendToEnabled = false; } } - - - diff --git a/ts/typings/d.ts b/ts/typings/d.ts index 7e0bf3e..eb5be55 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -21,6 +21,7 @@ declare interface Window { notificationsEnabled: boolean; emailEnabled: boolean; telegramEnabled: boolean; + discordEnabled: boolean; ombiEnabled: boolean; usernameEnabled: boolean; token: string; @@ -101,13 +102,14 @@ declare interface Modals { extendExpiry: Modal; updateInfo: Modal; telegram: Modal; + discord: Modal; } interface Invite { code?: string; expiresIn?: string; remainingUses?: string; - email?: string; + send_to?: string; usedBy?: { [name: string]: number }; created?: number; notifyExpiry?: boolean; diff --git a/views.go b/views.go index 8841c09..6d8e306 100644 --- a/views.go +++ b/views.go @@ -1,6 +1,7 @@ package main import ( + "html/template" "io/fs" "net/http" "strconv" @@ -121,6 +122,7 @@ func (app *appContext) AdminPage(gc *gin.Context) { "contactMessage": "", "email_enabled": emailEnabled, "telegram_enabled": telegramEnabled, + "discord_enabled": discordEnabled, "notifications": notificationsEnabled, "version": version, "commit": commit, @@ -256,8 +258,8 @@ func (app *appContext) InviteProxy(gc *gin.Context) { } return } - email := app.storage.invites[code].Email - if strings.Contains(email, "Failed") { + email := app.storage.invites[code].SendTo + if strings.Contains(email, "Failed") || !strings.Contains(email, "@") { email = "" } data := gin.H{ @@ -284,13 +286,30 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "userExpiryMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"), "langName": lang, "telegramEnabled": telegramEnabled, + "discordEnabled": discordEnabled, } - if data["telegramEnabled"].(bool) { + if telegramEnabled { data["telegramPIN"] = app.telegram.NewAuthToken() data["telegramUsername"] = app.telegram.username data["telegramURL"] = app.telegram.link data["telegramRequired"] = app.config.Section("telegram").Key("required").MustBool(false) } + if discordEnabled { + data["discordPIN"] = app.discord.NewAuthToken() + data["discordUsername"] = app.discord.username + data["discordRequired"] = app.config.Section("discord").Key("required").MustBool(false) + data["discordSendPINMessage"] = template.HTML(app.storage.lang.Form[lang].Strings.template("sendPINDiscord", tmpl{ + "command": `` + app.config.Section("discord").Key("start_command").MustString("!start") + ``, + "server_channel": app.discord.serverChannelName, + })) + data["discordServerName"] = app.discord.serverName + data["discordInviteLink"] = app.discord.inviteChannelName != "" + } + + // if discordEnabled { + // pin := "" + // for _, token := range app.discord.tokens { + // if gcHTML(gc, http.StatusOK, "form-loader.html", data) }