Merge Discord branch

Discord Integration, Accounts UI improvements
matrix
Harvey Tindall 4 years ago committed by GitHub
commit 5a9bc1c66f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

272
api.go

@ -217,7 +217,7 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
username := jfUser.Name username := jfUser.Name
email := "" email := ""
if e, ok := app.storage.emails[jfID]; ok { if e, ok := app.storage.emails[jfID]; ok {
email = e.(string) email = e.Addr
} }
for _, ombiUser := range ombiUsers { for _, ombiUser := range ombiUsers {
ombiAddr := "" ombiAddr := ""
@ -283,7 +283,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
} }
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()
if emailEnabled { if emailEnabled {
app.storage.emails[id] = req.Email app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true}
app.storage.storeEmails() app.storage.storeEmails()
} }
if app.config.Section("ombi").Key("enabled").MustBool(false) { 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 success = false
return 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 telegramTokenIndex := -1
if telegramEnabled { if telegramEnabled {
if req.TelegramPIN == "" { 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 app.config.Section("password_resets").Key("enabled").MustBool(false) {
if req.Email != "" { if req.Email != "" {
app.storage.emails[id] = req.Email app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true}
app.storage.storeEmails() app.storage.storeEmails()
} }
if app.config.Section("ombi").Key("enabled").MustBool(false) { 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) 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 { if telegramEnabled && telegramTokenIndex != -1 {
tgToken := app.telegram.verifiedTokens[telegramTokenIndex] tgToken := app.telegram.verifiedTokens[telegramTokenIndex]
tgUser := TelegramUser{ 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 = map[string]TelegramUser{}
} }
app.storage.telegram[user.ID] = tgUser app.storage.telegram[user.ID] = tgUser
err := app.storage.storeTelegramUsers() if err := app.storage.storeTelegramUsers(); err != nil {
if err != nil {
app.err.Printf("Failed to store Telegram users: %v", err) app.err.Printf("Failed to store Telegram users: %v", err)
} else { } 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] 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) name := app.getAddressOrName(user.ID)
app.debug.Printf("%s: Sending welcome message to %s", req.Username, name) app.debug.Printf("%s: Sending welcome message to %s", req.Username, name)
msg, err := app.email.constructWelcome(req.Username, expiry, app, false) 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.UserMinutes = req.UserMinutes
} }
invite.ValidTill = validTill invite.ValidTill = validTill
if emailEnabled && req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) { if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
app.debug.Printf("%s: Sending invite email", inviteCode) addressValid := false
invite.Email = req.Email discord := ""
msg, err := app.email.constructInvite(inviteCode, invite, app, false) app.debug.Printf("%s: Sending invite message", inviteCode)
if err != nil { if discordEnabled && !strings.Contains(req.SendTo, "@") {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) users := app.discord.GetUsers(req.SendTo)
app.err.Printf("%s: Failed to construct invite email: %v", inviteCode, err) if len(users) == 0 {
} else if err := app.email.send(msg, req.Email); err != nil { invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) } else if len(users) > 1 {
app.err.Printf("%s: %s: %v", inviteCode, invite.Email, err) invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo)
} else { } else {
app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.Email) 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 != "" { if req.Profile != "" {
@ -867,15 +927,15 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.RemainingUses != 0 { if inv.RemainingUses != 0 {
invite.RemainingUses = inv.RemainingUses invite.RemainingUses = inv.RemainingUses
} }
if inv.Email != "" { if inv.SendTo != "" {
invite.Email = inv.Email invite.SendTo = inv.SendTo
} }
if len(inv.Notify) != 0 { if len(inv.Notify) != 0 {
var address string var address string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
app.storage.loadEmails() app.storage.loadEmails()
if addr := app.storage.emails[gc.GetString("jfId")]; addr != nil { if addr, ok := app.storage.emails[gc.GetString("jfId")]; ok && addr.Addr != "" {
address = addr.(string) address = addr.Addr
} }
} else { } else {
address = app.config.Section("ui").Key("email").String() address = app.config.Section("ui").Key("email").String()
@ -1074,14 +1134,14 @@ func (app *appContext) SetNotify(gc *gin.Context) {
} }
var address string var address string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
var ok bool addr, ok := app.storage.emails[gc.GetString("jfId")]
address, ok = app.storage.emails[gc.GetString("jfId")].(string)
if !ok { if !ok {
app.err.Printf("%s: Couldn't find email address. Make sure it's set", code) 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")) app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
respond(500, "Missing user email", gc) respond(500, "Missing user email", gc)
return return
} }
address = addr.Addr
} else { } else {
address = app.config.Section("ui").Key("email").String() address = app.config.Section("ui").Key("email").String()
} }
@ -1168,7 +1228,8 @@ func (app *appContext) GetUsers(gc *gin.Context) {
user.LastActive = jfUser.LastActivityDate.Unix() user.LastActive = jfUser.LastActivityDate.Unix()
} }
if email, ok := app.storage.emails[jfUser.ID]; ok { 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] expiry, ok := app.storage.users[jfUser.ID]
if ok { if ok {
@ -1178,6 +1239,11 @@ func (app *appContext) GetUsers(gc *gin.Context) {
user.Telegram = tgUser.Username user.Telegram = tgUser.Username
user.NotifyThroughTelegram = tgUser.Contact 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 resp.UserList[i] = user
i++ i++
} }
@ -1253,7 +1319,11 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
for _, jfUser := range users { for _, jfUser := range users {
id := jfUser.ID id := jfUser.ID
if address, ok := req[id]; ok { 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 { if ombiEnabled {
ombiUser, code, err := app.getOmbiUser(id) ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil { 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. // @Summary Sets whether to notify a user through telegram or not.
// @Produce json // @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 200 {object} boolResponse
// @Success 400 {object} boolResponse // @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse // @Success 500 {object} boolResponse
// @Router /users/telegram/notify [post] // @Router /users/telegram/notify [post]
// @Security Bearer // @Security Bearer
// @tags Other // @tags Other
func (app *appContext) TelegramSetNotify(gc *gin.Context) { func (app *appContext) SetContactMethods(gc *gin.Context) {
var req telegramNotifyDTO var req SetContactMethodsDTO
gc.BindJSON(&req) gc.BindJSON(&req)
if req.ID == "" { if req.ID == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
if tgUser, ok := app.storage.telegram[req.ID]; ok { if tgUser, ok := app.storage.telegram[req.ID]; ok {
tgUser.Contact = req.Enabled tgUser.Contact = req.Telegram
app.storage.telegram[req.ID] = tgUser app.storage.telegram[req.ID] = tgUser
if err := app.storage.storeTelegramUsers(); err != nil { if err := app.storage.storeTelegramUsers(); err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
app.err.Printf("Telegram: Failed to store users: %v", err) app.err.Printf("Telegram: Failed to store users: %v", err)
return return
} }
respondBool(200, true, gc)
msg := "" msg := ""
if !req.Enabled { if !req.Telegram {
msg = "not" msg = " not"
} }
app.debug.Printf("Telegram: User \"%s\" will %s be notified through Telegram.", tgUser.Username, msg) app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
return
} }
app.err.Printf("Telegram: User \"%s\" does not have a telegram account registered.", req.ID) if dcUser, ok := app.storage.discord[req.ID]; ok {
respondBool(400, false, gc) 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. // @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) 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. // @Summary Restarts the program. No response means success.
// @Router /restart [post] // @Router /restart [post]
// @Security Bearer // @Security Bearer

@ -14,6 +14,7 @@ import (
var emailEnabled = false var emailEnabled = false
var messagesEnabled = false var messagesEnabled = false
var telegramEnabled = false var telegramEnabled = false
var discordEnabled = false
func (app *appContext) GetPath(sect, key string) (fs.FS, string) { func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
val := app.config.Section(sect).Key(key).MustString("") 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")))) 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.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(""), "/") 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)) 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) messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false) telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
if !messagesEnabled { if !messagesEnabled {
emailEnabled = false emailEnabled = false
telegramEnabled = false telegramEnabled = false
discordEnabled = false
} else if app.config.Section("email").Key("method").MustString("") == "" { } else if app.config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false emailEnabled = false
} else { } else {
emailEnabled = true emailEnabled = true
} }
if !emailEnabled && !telegramEnabled { if !emailEnabled && !telegramEnabled && !discordEnabled {
messagesEnabled = false messagesEnabled = false
} }
@ -168,3 +171,28 @@ func (app *appContext) migrateEmailConfig() {
} }
app.loadConfig() 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
}

@ -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": { "telegram": {
"order": [], "order": [],
"meta": { "meta": {
@ -565,6 +648,7 @@
"name": "Require on sign-up", "name": "Require on sign-up",
"required": false, "required": false,
"required_restart": true, "required_restart": true,
"depends_true": "enabled",
"type": "bool", "type": "bool",
"value": false, "value": false,
"description": "Require telegram connection on sign-up." "description": "Require telegram connection on sign-up."
@ -1140,6 +1224,14 @@
"type": "text", "type": "text",
"value": "", "value": "",
"description": "Stores telegram user IDs and language preferences." "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."
} }
} }
} }

@ -30,12 +30,12 @@
} }
} }
@media screen and (max-width: 750px) { @media screen and (max-width: 1000px) {
:root { :root {
font-size: 0.9rem; font-size: 0.9rem;
} }
.table-responsive table { .table-responsive table {
min-width: 660px; min-width: 800px;
} }
} }
@ -130,6 +130,10 @@ div.card:contains(section.banner.footer) {
text-align: center; text-align: center;
} }
.w-100 {
width: 100%;
}
.inline-block { .inline-block {
display: inline-block; display: inline-block;
} }
@ -172,7 +176,7 @@ div.card:contains(section.banner.footer) {
} }
p.sm, p.sm,
span.sm { span.sm:not(.heading) {
font-size: 0.75rem; font-size: 0.75rem;
} }
@ -424,6 +428,7 @@ p.top {
.table-responsive { .table-responsive {
overflow-x: auto; overflow-x: auto;
font-size: 0.9rem;
} }
#notification-box { #notification-box {
@ -438,6 +443,10 @@ p.top {
margin-bottom: -0.5rem; margin-bottom: -0.5rem;
} }
.dropdown-display.lg {
white-space: nowrap;
}
pre { pre {
white-space: pre-wrap; /* css-3 */ white-space: pre-wrap; /* css-3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ 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; max-width: 15rem;
min-width: 10rem; 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;
}

@ -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 <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
}

@ -230,7 +230,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
var keys []string var keys []string
plaintext := app.config.Section("email").Key("plaintext").MustBool(false) plaintext := app.config.Section("email").Key("plaintext").MustBool(false)
if plaintext { if plaintext {
if telegramEnabled { if telegramEnabled || discordEnabled {
keys = []string{"text"} keys = []string{"text"}
text, markdown = "", "" text, markdown = "", ""
} else { } else {
@ -238,7 +238,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
text = "" text = ""
} }
} else { } else {
if telegramEnabled { if telegramEnabled || discordEnabled {
keys = []string{"html", "text", "markdown"} keys = []string{"html", "text", "markdown"}
} else { } else {
keys = []string{"html", "text"} keys = []string{"html", "text"}
@ -807,8 +807,21 @@ func (app *appContext) sendByID(email *Message, ID ...string) error {
var err error var err error
if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && telegramEnabled { if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && telegramEnabled {
err = app.telegram.Send(email, tgChat.ChatID) err = app.telegram.Send(email, tgChat.ChatID)
} else if address, ok := app.storage.emails[id]; ok { if err != nil {
err = app.email.send(email, address.(string)) 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 { if err != nil {
return err return err
@ -818,11 +831,14 @@ func (app *appContext) sendByID(email *Message, ID ...string) error {
} }
func (app *appContext) getAddressOrName(jfID string) string { 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 { if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && telegramEnabled {
return "@" + tgChat.Username return "@" + tgChat.Username
} }
if addr, ok := app.storage.emails[jfID]; ok { if addr, ok := app.storage.emails[jfID]; ok {
return addr.(string) return addr.Addr
} }
return "" return ""
} }

@ -11,6 +11,7 @@ replace github.com/hrfee/jfa-go/ombi => ./ombi
replace github.com/hrfee/jfa-go/logger => ./logger replace github.com/hrfee/jfa-go/logger => ./logger
require ( require (
github.com/bwmarrin/discordgo v0.23.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a // indirect github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a // indirect

@ -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/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 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 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 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= 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/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 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 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 h1:7E05uiol8hh2ytKn3WVLrUIvHAyifYEIy3Y5qtuNh8I=
github.com/hrfee/mediabrowser v0.3.3/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/hrfee/mediabrowser v0.3.3/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/itchyny/timefmt-go v0.1.2 h1:q0Xa4P5it6K6D7ISsbLAMwx1PnWlixDcJL6/sFs93Hs= 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= 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 h1:jeP6FgaSLNTMP+Yri3qjlACywQLye+huGLmNGhBzm6k=
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= 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-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-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

@ -7,6 +7,7 @@
window.notificationsEnabled = {{ .notifications }}; window.notificationsEnabled = {{ .notifications }};
window.emailEnabled = {{ .email_enabled }}; window.emailEnabled = {{ .email_enabled }};
window.telegramEnabled = {{ .telegram_enabled }}; window.telegramEnabled = {{ .telegram_enabled }};
window.discordEnabled = {{ .discord_enabled }};
window.ombiEnabled = {{ .ombiEnabled }}; window.ombiEnabled = {{ .ombiEnabled }};
window.usernameEnabled = {{ .username }}; window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }}); window.langFile = JSON.parse({{ .language }});
@ -327,6 +328,18 @@
</div> </div>
</div> </div>
{{ end }} {{ end }}
{{ if .discord_enabled }}
<div id="modal-discord" class="modal">
<div class="modal-content card">
<span class="heading mb-1"><span id="discord-header"></span><span class="modal-close">&times;</span></span>
<p class="content mb-1" id="discord-description"></p>
<div class="row">
<input type="search" class="col sm field ~neutral !normal input" id="discord-search" placeholder="user#1234">
</div>
<table class="table"><tbody id="discord-list"></tbody></table>
</div>
</div>
{{ end }}
<div id="notification-box"></div> <div id="notification-box"></div>
<span class="dropdown" tabindex="0" id="lang-dropdown"> <span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
@ -488,7 +501,14 @@
<div id="create-send-to-container"> <div id="create-send-to-container">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label> <label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex-expand mb-1 mt-half"> <div class="flex-expand mb-1 mt-half">
{{ if .discord_enabled }}
<input type="text" id="create-send-to" class="input ~neutral !normal mr-1" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral !normal mr-1">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<input type="email" id="create-send-to" class="input ~neutral !normal mr-1" placeholder="example@example.com"> <input type="email" id="create-send-to" class="input ~neutral !normal mr-1" placeholder="example@example.com">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral !normal"> <label for="create-send-to-enabled" class="button ~neutral !normal">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled"> <input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label> </label>
@ -525,6 +545,9 @@
{{ if .telegram_enabled }} {{ if .telegram_enabled }}
<th>Telegram</th> <th>Telegram</th>
{{ end }} {{ end }}
{{ if .discord_enabled }}
<th>Discord</th>
{{ end }}
<th>{{ .strings.expiry }}</th> <th>{{ .strings.expiry }}</th>
<th>{{ .strings.lastActiveTime }}</th> <th>{{ .strings.lastActiveTime }}</th>
</tr> </tr>

@ -17,6 +17,11 @@
window.telegramEnabled = {{ .telegramEnabled }}; window.telegramEnabled = {{ .telegramEnabled }};
window.telegramRequired = {{ .telegramRequired }}; window.telegramRequired = {{ .telegramRequired }};
window.telegramPIN = "{{ .telegramPIN }}"; window.telegramPIN = "{{ .telegramPIN }}";
window.discordEnabled = {{ .discordEnabled }};
window.discordRequired = {{ .discordRequired }};
window.discordPIN = "{{ .discordPIN }}";
window.discordInviteLink = {{ .discordInviteLink }};
window.discordServerName = "{{ .discordServerName }}";
</script> </script>
<script src="js/form.js" type="module"></script> <script src="js/form.js" type="module"></script>
{{ end }} {{ end }}

@ -37,6 +37,17 @@
</div> </div>
</div> </div>
{{ end }} {{ end }}
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="modal-content card">
<span class="heading mb-1">{{ .strings.linkDiscord }}</span>
<p class="content mb-1"> {{ .discordSendPINMessage }}</p>
<h1 class="ac">{{ .discordPIN }}</h1>
<a id="discord-invite"></a>
<span class="button ~info !normal full-width center mt-1" id="discord-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
<span class="dropdown" tabindex="0" id="lang-dropdown"> <span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
<i class="ri-global-line"></i> <i class="ri-global-line"></i>
@ -69,13 +80,25 @@
<input type="email" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}"> <input type="email" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
{{ if .telegramEnabled }} {{ if .telegramEnabled }}
<span class="button ~info !normal full-width center mb-1" id="link-telegram">{{ .strings.linkTelegram }}</span> <span class="button ~info !normal full-width center mb-1" id="link-telegram">{{ .strings.linkTelegram }}</span>
{{ end }}
{{ if .discordEnabled }}
<span class="button ~info !normal full-width center mb-1" id="link-discord">{{ .strings.linkDiscord }}</span>
{{ end }}
{{ if or (.telegramEnabled) (.discordEnabled) }}
<div id="contact-via" class="unfocused"> <div id="contact-via" class="unfocused">
<label class="row switch pb-1"> <label class="row switch pb-1">
<input type="radio" name="contact-via" value="email"><span>Contact through Email</span> <input type="radio" name="contact-via" value="email"><span>Contact through Email</span>
</label> </label>
{{ if .telegramEnabled }}
<label class="row switch pb-1"> <label class="row switch pb-1">
<input type="radio" name="contact-via" value="telegram" id="contact-via-telegram"><span>Contact through Telegram</span> <input type="radio" name="contact-via" value="telegram" id="contact-via-telegram"><span>Contact through Telegram</span>
</label> </label>
{{ end }}
{{ if .discordEnabled }}
<label class="row switch pb-1">
<input type="radio" name="contact-via" value="discord" id="contact-via-discord"><span>Contact through Discord</span>
</label>
{{ end }}
</div> </div>
{{ end }} {{ end }}
<label class="label supra" for="create-password">{{ .strings.password }}</label> <label class="label supra" for="create-password">{{ .strings.password }}</label>

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@ -20,6 +20,8 @@
"create": "Create", "create": "Create",
"apply": "Apply", "apply": "Apply",
"delete": "Delete", "delete": "Delete",
"add": "Add",
"select": "Select",
"name": "Name", "name": "Name",
"date": "Date", "date": "Date",
"enabled": "Enabled", "enabled": "Enabled",
@ -94,7 +96,9 @@
"notifyEvent": "Notify on:", "notifyEvent": "Notify on:",
"notifyInviteExpiry": "On expiry", "notifyInviteExpiry": "On expiry",
"notifyUserCreation": "On user creation", "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": { "notifications": {
"changedEmailAddress": "Changed email address of {n}.", "changedEmailAddress": "Changed email address of {n}.",
@ -107,6 +111,7 @@
"updateApplied": "Update applied, please restart.", "updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.", "updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.", "telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.",
"errorConnection": "Couldn't connect to jfa-go.", "errorConnection": "Couldn't connect to jfa-go.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.", "error401Unauthorized": "Unauthorized. Try refreshing the page.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",

@ -17,6 +17,8 @@
"linkTelegram": "Link Telegram", "linkTelegram": "Link Telegram",
"contactEmail": "Contact through Email", "contactEmail": "Contact through Email",
"contactTelegram": "Contact through Telegram", "contactTelegram": "Contact through Telegram",
"linkDiscord": "Link Discord",
"contactDiscord": "Contact through Discord",
"theme": "Theme" "theme": "Theme"
} }
} }

@ -18,14 +18,16 @@
"confirmationRequired": "Email confirmation required", "confirmationRequired": "Email confirmation required",
"confirmationRequiredMessage": "Please check your email inbox to verify your address.", "confirmationRequiredMessage": "Please check your email inbox to verify your address.",
"yourAccountIsValidUntil": "Your account will be valid until {date}.", "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": { "notifications": {
"errorUserExists": "User already exists.", "errorUserExists": "User already exists.",
"errorInvalidCode": "Invalid invite code.", "errorInvalidCode": "Invalid invite code.",
"errorTelegramVerification": "Telegram verification required.", "errorTelegramVerification": "Telegram verification required.",
"errorInvalidPIN": "Telegram PIN is invalid.", "errorDiscordVerification": "Discord verification required.",
"telegramVerified": "Telegram account verified." "errorInvalidPIN": "PIN is invalid.",
"verified": "Account verified."
}, },
"validationStrings": { "validationStrings": {
"length": { "length": {

@ -6,6 +6,6 @@
"startMessage": "Hi!\nEnter your Jellyfin PIN code here to verify your account.", "startMessage": "Hi!\nEnter your Jellyfin PIN code here to verify your account.",
"invalidPIN": "That PIN was invalid, try again.", "invalidPIN": "That PIN was invalid, try again.",
"pinSuccess": "Success! You can now return to the sign-up page.", "pinSuccess": "Success! You can now return to the sign-up page.",
"languageMessage": "Note: See available languages with /lang, and set language with /lang <language code>." "languageMessage": "Note: See available languages with {command}, and set language with {command} <language code>."
} }
} }

@ -16,7 +16,6 @@ import (
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings" "strings"
"time" "time"
@ -96,6 +95,7 @@ type appContext struct {
validator Validator validator Validator
email *Emailer email *Emailer
telegram *TelegramDaemon telegram *TelegramDaemon
discord *DiscordDaemon
info, debug, err logger.Logger info, debug, err logger.Logger
host string host string
port int port int
@ -320,6 +320,10 @@ func start(asDaemon, firstCall bool) {
app.storage.emails_path = app.config.Section("files").Key("emails").String() app.storage.emails_path = app.config.Section("files").Key("emails").String()
if err := app.storage.loadEmails(); err != nil { if err := app.storage.loadEmails(); err != nil {
app.err.Printf("Failed to load Emails: %v", err) 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() app.storage.policy_path = app.config.Section("files").Key("user_template").String()
if err := app.storage.loadPolicy(); err != nil { if err := app.storage.loadPolicy(); err != nil {
@ -341,6 +345,10 @@ func start(asDaemon, firstCall bool) {
if err := app.storage.loadTelegramUsers(); err != nil { if err := app.storage.loadTelegramUsers(); err != nil {
app.err.Printf("Failed to load Telegram users: %v", err) 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.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles() 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.err.Fatalf("Failed to authenticate with Jellyfin @ %s (%d): %v", server, status, err)
} }
app.info.Printf("Authenticated with %s", server) app.info.Printf("Authenticated with %s", server)
/* A couple of unstable Jellyfin 10.7.0 releases decided to hyphenate user IDs. // /* A couple of unstable Jellyfin 10.7.0 releases decided to hyphenate user IDs.
This checks if the version is equal or higher. */ // This checks if the version is equal or higher. */
checkVersion := func(version string) int { // checkVersion := func(version string) int {
numberStrings := strings.Split(version, ".") // numberStrings := strings.Split(version, ".")
n := 0 // n := 0
for _, s := range numberStrings { // for _, s := range numberStrings {
num, err := strconv.Atoi(s) // num, err := strconv.Atoi(s)
if err == nil { // if err == nil {
n += num // n += num
} // }
} // }
return n // return n
} // }
if serverType == mediabrowser.JellyfinServer && checkVersion(app.jf.ServerInfo.Version) >= checkVersion("10.7.0") { // if serverType == mediabrowser.JellyfinServer && checkVersion(app.jf.ServerInfo.Version) >= checkVersion("10.7.0") {
// Get users to check if server uses hyphenated userIDs // // Get users to check if server uses hyphenated userIDs
app.jf.GetUsers(false) // app.jf.GetUsers(false)
noHyphens := true // noHyphens := true
for id := range app.storage.emails { // for id := range app.storage.emails {
if strings.Contains(id, "-") { // if strings.Contains(id, "-") {
noHyphens = false // noHyphens = false
break // break
} // }
} // }
if noHyphens == app.jf.Hyphens { // if noHyphens == app.jf.Hyphens {
var newEmails map[string]interface{} // var newEmails map[string]interface{}
var newUsers map[string]time.Time // var newUsers map[string]time.Time
var status, status2 int // var status, status2 int
var err, err2 error // var err, err2 error
if app.jf.Hyphens { // 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.")) // 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)) // time.Sleep(time.Second * time.Duration(3))
newEmails, status, err = app.hyphenateEmailStorage(app.storage.emails) // newEmails, status, err = app.hyphenateEmailStorage(app.storage.emails)
newUsers, status2, err2 = app.hyphenateUserStorage(app.storage.users) // newUsers, status2, err2 = app.hyphenateUserStorage(app.storage.users)
} else { // } else {
app.info.Println(info("Your emails.json/users.json file uses hyphens, but the Jellyfin server no longer does. It will be modified.")) // 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)) // time.Sleep(time.Second * time.Duration(3))
newEmails, status, err = app.deHyphenateEmailStorage(app.storage.emails) // newEmails, status, err = app.deHyphenateEmailStorage(app.storage.emails)
newUsers, status2, err2 = app.deHyphenateUserStorage(app.storage.users) // newUsers, status2, err2 = app.deHyphenateUserStorage(app.storage.users)
} // }
if status != 200 || err != nil { // if status != 200 || err != nil {
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) // app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
app.err.Fatalf("Couldn't upgrade emails.json") // app.err.Fatalf("Couldn't upgrade emails.json")
} // }
if status2 != 200 || err2 != nil { // if status2 != 200 || err2 != nil {
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) // app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
app.err.Fatalf("Couldn't upgrade users.json") // app.err.Fatalf("Couldn't upgrade users.json")
} // }
emailBakFile := app.storage.emails_path + ".bak" // emailBakFile := app.storage.emails_path + ".bak"
usersBakFile := app.storage.users_path + ".bak" // usersBakFile := app.storage.users_path + ".bak"
err = storeJSON(emailBakFile, app.storage.emails) // err = storeJSON(emailBakFile, app.storage.emails)
err2 = storeJSON(usersBakFile, app.storage.users) // err2 = storeJSON(usersBakFile, app.storage.users)
if err != nil { // if err != nil {
app.err.Fatalf("couldn't store emails.json backup: %v", err) // app.err.Fatalf("couldn't store emails.json backup: %v", err)
} // }
if err2 != nil { // if err2 != nil {
app.err.Fatalf("couldn't store users.json backup: %v", err) // app.err.Fatalf("couldn't store users.json backup: %v", err)
} // }
app.storage.emails = newEmails // app.storage.emails = newEmails
app.storage.users = newUsers // app.storage.users = newUsers
err = app.storage.storeEmails() // err = app.storage.storeEmails()
err2 = app.storage.storeUsers() // err2 = app.storage.storeUsers()
if err != nil { // if err != nil {
app.err.Fatalf("couldn't store emails.json: %v", err) // app.err.Fatalf("couldn't store emails.json: %v", err)
} // }
if err2 != nil { // if err2 != nil {
app.err.Fatalf("couldn't store users.json: %v", err) // app.err.Fatalf("couldn't store users.json: %v", err)
} // }
} // }
} // }
// Auth (manual user/pass or jellyfin) // Auth (manual user/pass or jellyfin)
app.jellyfinLogin = true app.jellyfinLogin = true
@ -562,11 +570,22 @@ func start(asDaemon, firstCall bool) {
app.telegram, err = newTelegramDaemon(app) app.telegram, err = newTelegramDaemon(app)
if err != nil { if err != nil {
app.err.Printf("Failed to authenticate with Telegram: %v", err) app.err.Printf("Failed to authenticate with Telegram: %v", err)
telegramEnabled = false
} else { } else {
go app.telegram.run() go app.telegram.run()
defer app.telegram.Shutdown() 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 { } else {
debugMode = false debugMode = false
address = "0.0.0.0:8056" address = "0.0.0.0:8056"

@ -17,6 +17,8 @@ type newUserDTO struct {
Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser) 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) 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 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 { type newUserResponse struct {
@ -48,7 +50,7 @@ type generateInviteDTO struct {
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry 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 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 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 MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses
NoLimit bool `json:"no-limit" example:"false"` // No invite use limit NoLimit bool `json:"no-limit" example:"false"` // No invite use limit
RemainingUses int `json:"remaining-uses" example:"5"` // Remaining invite uses 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 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 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) 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 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 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 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 ID string `json:"id" example:"fdgsdfg45534fa"` // userID of user
Name string `json:"name" example:"jeff"` // Username 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) 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 NotifyThroughEmail bool `json:"notify_email"`
Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator LastActive int64 `json:"last_active" example:"1617737207510"` // Time of last activity on Jellyfin
Expiry int64 `json:"expiry" example:"1617737207510"` // Expiry time of user as Epoch/Unix time. Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator
Disabled bool `json:"disabled"` // Whether or not the user is disabled. Expiry int64 `json:"expiry" example:"1617737207510"` // Expiry time of user as Epoch/Unix time.
Telegram string `json:"telegram"` // Telegram username (if known) Disabled bool `json:"disabled"` // Whether or not the user is disabled.
Telegram string `json:"telegram"` // Telegram username (if known)
NotifyThroughTelegram bool `json:"notify_telegram"` 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 { type getUsersDTO struct {
@ -249,7 +255,29 @@ type telegramSetDTO struct {
ID string `json:"id"` // Jellyfin ID of user. ID string `json:"id"` // Jellyfin ID of user.
} }
type telegramNotifyDTO struct { type SetContactMethodsDTO struct {
ID string `json:"id"` ID string `json:"id"`
Enabled bool `json:"enabled"` 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"`
} }

@ -121,6 +121,12 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
if telegramEnabled { if telegramEnabled {
router.GET(p+"/invite/:invCode/telegram/verified/:pin", app.TelegramVerifiedInvite) 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 { if *SWAGGER {
app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n")) 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.GET(p+"/config", app.GetConfig)
api.POST(p+"/config", app.ModifyConfig) api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart) api.POST(p+"/restart", app.restart)
if telegramEnabled { if telegramEnabled || discordEnabled {
api.GET(p+"/telegram/pin", app.TelegramGetPin) api.GET(p+"/telegram/pin", app.TelegramGetPin)
api.GET(p+"/telegram/verified/:pin", app.TelegramVerified) api.GET(p+"/telegram/verified/:pin", app.TelegramVerified)
api.POST(p+"/users/telegram", app.TelegramAddUser) 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) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
api.GET(p+"/ombi/users", app.OmbiUsers) api.GET(p+"/ombi/users", app.OmbiUsers)

@ -15,19 +15,21 @@ import (
) )
type Storage struct { type Storage struct {
timePattern string timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path 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 users map[string]time.Time
invites Invites invites Invites
profiles map[string]Profile profiles map[string]Profile
defaultProfile string defaultProfile string
emails, displayprefs, ombi_template map[string]interface{} displayprefs, ombi_template map[string]interface{}
telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. emails map[string]EmailAddress
customEmails customEmails telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users.
policy mediabrowser.Policy discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users.
configuration mediabrowser.Configuration customEmails customEmails
lang Lang policy mediabrowser.Policy
invitesLock, usersLock sync.Mutex configuration mediabrowser.Configuration
lang Lang
invitesLock, usersLock sync.Mutex
} }
type TelegramUser struct { type TelegramUser struct {
@ -37,6 +39,20 @@ type TelegramUser struct {
Contact bool // Whether to contact through telegram or not 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 { type customEmails struct {
UserCreated customEmail `json:"userCreated"` UserCreated customEmail `json:"userCreated"`
InviteExpiry customEmail `json:"inviteExpiry"` InviteExpiry customEmail `json:"inviteExpiry"`
@ -79,7 +95,7 @@ type Invite struct {
UserDays int `json:"user-days,omitempty"` UserDays int `json:"user-days,omitempty"`
UserHours int `json:"user-hours,omitempty"` UserHours int `json:"user-hours,omitempty"`
UserMinutes int `json:"user-minutes,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. // Used to be stored as formatted time, now as Unix.
UsedBy [][]string `json:"used-by"` UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"` Notify map[string]map[string]bool `json:"notify"`
@ -89,23 +105,24 @@ type Invite struct {
} }
type Lang struct { type Lang struct {
AdminPath string AdminPath string
chosenAdminLang string chosenAdminLang string
Admin adminLangs Admin adminLangs
AdminJSON map[string]string AdminJSON map[string]string
FormPath string FormPath string
chosenFormLang string chosenFormLang string
Form formLangs Form formLangs
PasswordResetPath string PasswordResetPath string
chosenPWRLang string chosenPWRLang string
PasswordReset pwrLangs PasswordReset pwrLangs
EmailPath string EmailPath string
chosenEmailLang string chosenEmailLang string
Email emailLangs Email emailLangs
CommonPath string CommonPath string
Common commonLangs Common commonLangs
SetupPath string SetupPath string
Setup setupLangs Setup setupLangs
// Telegram translations are also used for Discord bots (and likely future ones).
chosenTelegramLang string chosenTelegramLang string
TelegramPath string TelegramPath string
Telegram telegramLangs Telegram telegramLangs
@ -765,6 +782,14 @@ func (st *Storage) storeTelegramUsers() error {
return storeJSON(st.telegram_path, st.telegram) 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 { func (st *Storage) loadCustomEmails() error {
return loadJSON(st.customEmails_path, &st.customEmails) return loadJSON(st.customEmails_path, &st.customEmails)
} }
@ -884,85 +909,85 @@ func storeJSON(path string, obj interface{}) error {
return err 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. // // 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 { // func hyphenate(userID string) string {
if userID[8] == '-' { // if userID[8] == '-' {
return userID // return userID
} // }
return userID[:8] + "-" + userID[8:12] + "-" + userID[12:16] + "-" + userID[16:20] + "-" + userID[20:] // 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) { // func (app *appContext) deHyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
jfUsers, status, err := app.jf.GetUsers(false) // jfUsers, status, err := app.jf.GetUsers(false)
if status != 200 || err != nil { // if status != 200 || err != nil {
return nil, status, err // return nil, status, err
} // }
newEmails := map[string]interface{}{} // newEmails := map[string]interface{}{}
for _, user := range jfUsers { // for _, user := range jfUsers {
unHyphenated := user.ID // unHyphenated := user.ID
hyphenated := hyphenate(unHyphenated) // hyphenated := hyphenate(unHyphenated)
val, ok := old[hyphenated] // val, ok := old[hyphenated]
if ok { // if ok {
newEmails[unHyphenated] = val // newEmails[unHyphenated] = val
} // }
} // }
return newEmails, status, err // return newEmails, status, err
} // }
//
func (app *appContext) hyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) { // func (app *appContext) hyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
jfUsers, status, err := app.jf.GetUsers(false) // jfUsers, status, err := app.jf.GetUsers(false)
if status != 200 || err != nil { // if status != 200 || err != nil {
return nil, status, err // return nil, status, err
} // }
newEmails := map[string]interface{}{} // newEmails := map[string]interface{}{}
for _, user := range jfUsers { // for _, user := range jfUsers {
unstripped := user.ID // unstripped := user.ID
stripped := strings.ReplaceAll(unstripped, "-", "") // stripped := strings.ReplaceAll(unstripped, "-", "")
val, ok := old[stripped] // val, ok := old[stripped]
if ok { // if ok {
newEmails[unstripped] = val // newEmails[unstripped] = val
} // }
} // }
return newEmails, status, err // return newEmails, status, err
} // }
//
func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { // func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
return app.hyphenateStorage(old) // return app.hyphenateStorage(old)
} // }
//
func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { // func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
return app.deHyphenateStorage(old) // return app.deHyphenateStorage(old)
} // }
//
func (app *appContext) hyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) { // func (app *appContext) hyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) {
asInterface := map[string]interface{}{} // asInterface := map[string]interface{}{}
for k, v := range old { // for k, v := range old {
asInterface[k] = v // asInterface[k] = v
} // }
fixed, status, err := app.hyphenateStorage(asInterface) // fixed, status, err := app.hyphenateStorage(asInterface)
if err != nil { // if err != nil {
return nil, status, err // return nil, status, err
} // }
out := map[string]time.Time{} // out := map[string]time.Time{}
for k, v := range fixed { // for k, v := range fixed {
out[k] = v.(time.Time) // out[k] = v.(time.Time)
} // }
return out, status, err // return out, status, err
} // }
//
func (app *appContext) deHyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) { // func (app *appContext) deHyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) {
asInterface := map[string]interface{}{} // asInterface := map[string]interface{}{}
for k, v := range old { // for k, v := range old {
asInterface[k] = v // asInterface[k] = v
} // }
fixed, status, err := app.deHyphenateStorage(asInterface) // fixed, status, err := app.deHyphenateStorage(asInterface)
if err != nil { // if err != nil {
return nil, status, err // return nil, status, err
} // }
out := map[string]time.Time{} // out := map[string]time.Time{}
for k, v := range fixed { // for k, v := range fixed {
out[k] = v.(time.Time) // out[k] = v.(time.Time)
} // }
return out, status, err // return out, status, err
} // }

@ -3,22 +3,57 @@ package main
import ( import (
"strings" "strings"
dg "github.com/bwmarrin/discordgo"
stripmd "github.com/writeas/go-strip-markdown" 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. // 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. // 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 '[' & ']') altTextStart := -1 // Start of alt text (between '[' & ']')
URLStart := -1 // Start of url (between '(' & ')') URLStart := -1 // Start of url (between '(' & ')')
URLEnd := -1 URLEnd := -1
previousURLEnd := -2 previousURLEnd := -2
out := "" out := ""
embeds := []*dg.MessageEmbed{}
for i := range md { for i := range md {
if altTextStart != -1 && URLStart != -1 && md[i] == ')' { if altTextStart != -1 && URLStart != -1 && md[i] == ')' {
URLEnd = i - 1 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 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 altTextStart, URLStart, URLEnd = -1, -1, -1
continue continue
} }
@ -36,11 +71,12 @@ func StripAltText(md string) string {
out += md[previousURLEnd+2:] out += md[previousURLEnd+2:]
} }
if out == "" { if out == "" {
return md return md, embeds
} }
return out return out, embeds
} }
func stripMarkdown(md string) string { func stripMarkdown(md string) string {
return strings.TrimPrefix(strings.TrimSuffix(stripmd.Strip(StripAltText(md)), "</p>"), "<p>") stripped, _ := StripAltText(md, false)
return strings.TrimPrefix(strings.TrimSuffix(stripmd.Strip(stripped), "</p>"), "<p>")
} }

@ -9,7 +9,7 @@ import (
tg "github.com/go-telegram-bot-api/telegram-bot-api" tg "github.com/go-telegram-bot-api/telegram-bot-api"
) )
type VerifiedToken struct { type TelegramVerifiedToken struct {
Token string Token string
ChatID int64 ChatID int64
Username string Username string
@ -21,7 +21,7 @@ type TelegramDaemon struct {
bot *tg.BotAPI bot *tg.BotAPI
username string username string
tokens []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. languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start.
link string link string
app *appContext app *appContext
@ -37,12 +37,11 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) {
return nil, err return nil, err
} }
td := &TelegramDaemon{ td := &TelegramDaemon{
Stopped: false,
ShutdownChannel: make(chan string), ShutdownChannel: make(chan string),
bot: bot, bot: bot,
username: bot.Self.UserName, username: bot.Self.UserName,
tokens: []string{}, tokens: []string{},
verifiedTokens: []VerifiedToken{}, verifiedTokens: []TelegramVerifiedToken{},
languages: map[int64]string{}, languages: map[int64]string{},
link: "https://t.me/" + bot.Self.UserName, link: "https://t.me/" + bot.Self.UserName,
app: app, app: app,
@ -55,10 +54,7 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) {
return td, nil return td, nil
} }
var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") func genAuthToken() string {
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (t *TelegramDaemon) NewAuthToken() string {
rand.Seed(time.Now().UnixNano()) rand.Seed(time.Now().UnixNano())
pin := make([]rune, 8) pin := make([]rune, 8)
for i := range pin { for i := range pin {
@ -68,10 +64,18 @@ func (t *TelegramDaemon) NewAuthToken() string {
pin[i] = runes[rand.Intn(len(runes))] pin[i] = runes[rand.Intn(len(runes))]
} }
} }
t.tokens = append(t.tokens, string(pin))
return 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() { func (t *TelegramDaemon) run() {
t.app.info.Println("Starting Telegram bot daemon") t.app.info.Println("Starting Telegram bot daemon")
u := tg.NewUpdate(0) u := tg.NewUpdate(0)
@ -79,6 +83,7 @@ func (t *TelegramDaemon) run() {
updates, err := t.bot.GetUpdatesChan(u) updates, err := t.bot.GetUpdatesChan(u)
if err != nil { if err != nil {
t.app.err.Printf("Failed to start Telegram daemon: %v", err) t.app.err.Printf("Failed to start Telegram daemon: %v", err)
telegramEnabled = false
return return
} }
for { for {
@ -171,7 +176,7 @@ func (t *TelegramDaemon) Shutdown() {
func (t *TelegramDaemon) commandStart(upd *tg.Update, sects []string, lang string) { 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("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) err := t.Reply(upd, content)
if err != nil { if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) 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) { func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string) {
if len(sects) == 1 { if len(sects) == 1 {
list := "/lang <lang>\n" list := "/lang `<lang>`\n"
for code := range t.app.storage.lang.Telegram { 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) err := t.Reply(upd, list)
if err != nil { 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 { if user.ChatID == upd.Message.Chat.ID {
user.Lang = sects[1] user.Lang = sects[1]
t.app.storage.telegram[jfID] = user t.app.storage.telegram[jfID] = user
err := t.app.storage.storeTelegramUsers() if err := t.app.storage.storeTelegramUsers(); err != nil {
if err != nil {
t.app.err.Printf("Failed to store Telegram users: %v", err) t.app.err.Printf("Failed to store Telegram users: %v", err)
} }
break break
@ -225,7 +229,7 @@ func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string)
if err != nil { if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) 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, Token: upd.Message.Text,
ChatID: upd.Message.Chat.ID, ChatID: upd.Message.Chat.ID,
Username: upd.Message.Chat.UserName, Username: upd.Message.Chat.UserName,

@ -66,6 +66,10 @@ window.availableProfiles = window.availableProfiles || [];
if (window.telegramEnabled) { if (window.telegramEnabled) {
window.modals.telegram = new Modal(document.getElementById("modal-telegram")); 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(); var inviteCreator = new createInvite();

@ -8,12 +8,18 @@ interface formWindow extends Window {
invalidPassword: string; invalidPassword: string;
successModal: Modal; successModal: Modal;
telegramModal: Modal; telegramModal: Modal;
discordModal: Modal;
confirmationModal: Modal confirmationModal: Modal
code: string; code: string;
messages: { [key: string]: string }; messages: { [key: string]: string };
confirmation: boolean; confirmation: boolean;
telegramRequired: boolean; telegramRequired: boolean;
telegramPIN: string; telegramPIN: string;
discordRequired: boolean;
discordPIN: string;
discordStartCommand: string;
discordInviteLink: boolean;
discordServerName: string;
userExpiryEnabled: boolean; userExpiryEnabled: boolean;
userExpiryMonths: number; userExpiryMonths: number;
userExpiryDays: number; userExpiryDays: number;
@ -68,7 +74,7 @@ if (window.telegramEnabled) {
telegramVerified = true; telegramVerified = true;
waiting.classList.add("~positive"); waiting.classList.add("~positive");
waiting.classList.remove("~info"); waiting.classList.remove("~info");
window.notifications.customPositive("telegramVerified", "", window.messages["telegramVerified"]); window.notifications.customPositive("telegramVerified", "", window.messages["verified"]);
setTimeout(window.telegramModal.close, 2000); setTimeout(window.telegramModal.close, 2000);
telegramButton.classList.add("unfocused"); telegramButton.classList.add("unfocused");
document.getElementById("contact-via").classList.remove("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 = `<span class="img-circle lg mr-1"><img class="img-circle" src="${inv.icon}" width="64" height="64"></span>${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) { if (window.confirmation) {
window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true); window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true);
} }
@ -161,6 +227,8 @@ interface sendDTO {
password: string; password: string;
telegram_pin?: string; telegram_pin?: string;
telegram_contact?: boolean; telegram_contact?: boolean;
discord_pin?: string;
discord_contact?: boolean;
} }
const create = (event: SubmitEvent) => { const create = (event: SubmitEvent) => {
@ -179,6 +247,13 @@ const create = (event: SubmitEvent) => {
send.telegram_contact = true; 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) => { _post("/newUser", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) { if (req.readyState == 4) {
let vals = req.response as respDTO; let vals = req.response as respDTO;

@ -2,23 +2,30 @@ import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, toDateStri
import { templateEmail } from "../modules/settings.js"; import { templateEmail } from "../modules/settings.js";
import { Marked } from "@ts-stack/markdown"; import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js"; import { stripMarkdown } from "../modules/stripmd.js";
import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
interface User { interface User {
id: string; id: string;
name: string; name: string;
email: string | undefined; email: string | undefined;
notify_email: boolean;
last_active: number; last_active: number;
admin: boolean; admin: boolean;
disabled: boolean; disabled: boolean;
expiry: number; expiry: number;
telegram: string; telegram: string;
notify_telegram: boolean; notify_telegram: boolean;
discord: string;
notify_discord: boolean;
discord_id: string;
} }
interface getPinResponse { interface getPinResponse {
token: string; token: string;
username: string; username: string;
} }
var addDiscord: (passData: string) => void;
class user implements User { class user implements User {
private _row: HTMLTableRowElement; private _row: HTMLTableRowElement;
@ -27,16 +34,21 @@ class user implements User {
private _admin: HTMLSpanElement; private _admin: HTMLSpanElement;
private _disabled: HTMLSpanElement; private _disabled: HTMLSpanElement;
private _email: HTMLInputElement; private _email: HTMLInputElement;
private _notifyEmail: boolean;
private _emailAddress: string; private _emailAddress: string;
private _emailEditButton: HTMLElement; private _emailEditButton: HTMLElement;
private _telegram: HTMLTableDataCellElement; private _telegram: HTMLTableDataCellElement;
private _telegramUsername: string; private _telegramUsername: string;
private _notifyTelegram: boolean; private _notifyTelegram: boolean;
private _discord: HTMLTableDataCellElement;
private _discordUsername: string;
private _discordID: string;
private _notifyDiscord: boolean;
private _expiry: HTMLTableDataCellElement; private _expiry: HTMLTableDataCellElement;
private _expiryUnix: number; private _expiryUnix: number;
private _lastActive: HTMLTableDataCellElement; private _lastActive: HTMLTableDataCellElement;
private _lastActiveUnix: number; private _lastActiveUnix: number;
id: string; id = "";
private _selected: boolean; private _selected: boolean;
get selected(): boolean { return this._selected; } get selected(): boolean { return this._selected; }
@ -81,6 +93,21 @@ class user implements User {
this._email.textContent = value; 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; } get telegram(): string { return this._telegramUsername; }
set telegram(u: string) { set telegram(u: string) {
@ -90,43 +117,52 @@ class user implements User {
this._telegram.innerHTML = `<span class="chip btn !low">Add</span>`; this._telegram.innerHTML = `<span class="chip btn !low">Add</span>`;
(this._telegram.querySelector("span") as HTMLSpanElement).onclick = this._addTelegram; (this._telegram.querySelector("span") as HTMLSpanElement).onclick = this._addTelegram;
} else { } else {
this._telegram.innerHTML = ` let innerHTML = `
<a href="https://t.me/${u}" target="_blank">@${u}</a> <div class="table-inline">
<i class="icon ri-settings-2-line ml-half dropdown-button"></i> <a href="https://t.me/${u}" target="_blank">@${u}</a>
<div class="dropdown manual">
<div class="dropdown-display">
<div class="card ~neutral !low">
<span class="supra sm">${window.lang.strings("contactThrough")}</span>
<label class="switch pb-1 mt-half">
<input type="radio" name="accounts-contact-${this.id}" class="accounts-contact-email">
<span>Email</span>
</label>
<label class="switch pb-1">
<input type="radio" name="accounts-contact-${this.id}">
<span>Telegram</span>
</label>
</div>
</div>
</div>
`; `;
// Javascript is necessary as including the button inside the dropdown would make it too wide to display next to the username. if (!window.discordEnabled || !this._discordUsername) {
const button = this._telegram.querySelector("i"); innerHTML += `
const dropdown = this._telegram.querySelector("div.dropdown") as HTMLDivElement; <i class="icon ri-settings-2-line ml-half dropdown-button"></i>
const radios = this._telegram.querySelectorAll("input") as NodeListOf<HTMLInputElement>; <div class="dropdown manual">
for (let i = 0; i < radios.length; i++) { <div class="dropdown-display lg">
radios[i].onclick = this._setTelegramNotify; <div class="card ~neutral !low">
<span class="supra sm">${window.lang.strings("contactThrough")}</span>
<label class="row switch pb-1 mt-half">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-email">
<span>Email</span>
</label>
<label class="row switch pb-1">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-telegram">
<span>Telegram</span>
</label>
</div>
</div>
</div>
`;
} }
innerHTML += "</div>";
button.onclick = () => { this._telegram.innerHTML = innerHTML;
dropdown.classList.add("selected"); if (!window.discordEnabled || !this._discordUsername) {
document.addEventListener("click", outerClickListener); // 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 outerClickListener = (event: Event) => { const dropdown = this._telegram.querySelector("div.dropdown") as HTMLDivElement;
if (!(event.target instanceof HTMLElement && (this._telegram.contains(event.target) || button.contains(event.target)))) { const checks = this._telegram.querySelectorAll("input") as NodeListOf<HTMLInputElement>;
dropdown.classList.remove("selected"); for (let i = 0; i < checks.length; i++) {
document.removeEventListener("click", outerClickListener); 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) { set notify_telegram(s: boolean) {
if (!window.telegramEnabled || !this._telegramUsername) return; if (!window.telegramEnabled || !this._telegramUsername) return;
this._notifyTelegram = s; this._notifyTelegram = s;
const radios = this._telegram.querySelectorAll("input") as NodeListOf<HTMLInputElement>; const telegram = this._telegram.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement;
radios[0].checked = !s; if (telegram) {
radios[1].checked = s; telegram.checked = s;
}
if (window.discordEnabled && this._discordUsername) {
const telegram = this._discord.getElementsByClassName("accounts-contact-telegram")[0] as HTMLInputElement;
telegram.checked = s;
}
} }
private _setTelegramNotify = () => { private _setNotifyMethod = (mode: string = "telegram") => {
const radios = this._telegram.querySelectorAll("input") as NodeListOf<HTMLInputElement>; 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 = { let send = {
id: this.id, id: this.id,
enabled: radios[1].checked email: email.checked
}; }
_post("/users/telegram/notify", send, (req: XMLHttpRequest) => { 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.readyState == 4) {
if (req.status != 200) { if (req.status != 200) {
window.notifications.customError("errorSetTelegramNotify", window.lang.notif("errorSaveSettings")); window.notifications.customError("errorSetNotify", window.lang.notif("errorSaveSettings"));
radios[0].checked, radios[1].checked= radios[1].checked, radios[0].checked; document.dispatchEvent(new CustomEvent("accounts-reload"));
return; return;
} }
} }
}, false, (req: XMLHttpRequest) => { }, false, (req: XMLHttpRequest) => {
if (req.status == 0) { if (req.status == 0) {
window.notifications.connectionError(); window.notifications.connectionError();
radios[0].checked, radios[1].checked= radios[1].checked, radios[0].checked; document.dispatchEvent(new CustomEvent("accounts-reload"));
return; return;
} else if (req.status == 401) { } 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")); 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 = `<span class="chip btn !low">Add</span>`;
(this._discord.querySelector("span") as HTMLSpanElement).onclick = () => addDiscord(this.id);
} else {
let innerHTML = `
<div class="table-inline">
<a href="https://discord.com/users/${this._discordID}" class="discord-link" target="_blank">${u}</a>
<i class="icon ri-settings-2-line ml-half dropdown-button"></i>
<div class="dropdown manual">
<div class="dropdown-display lg">
<div class="card ~neutral !low">
<span class="supra sm">${window.lang.strings("contactThrough")}</span>
<label class="row switch pb-1 mt-half">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-email">
<span>Email</span>
</label>
<label class="row switch pb-1">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-discord">
<span>Discord</span>
</label>
`;
if (window.telegramEnabled && this._telegramUsername != "") {
innerHTML += `
<label class="row switch pb-1">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-telegram">
<span>Telegram</span>
</label>
`;
}
innerHTML += `
</div>
</div>
</div>
</div>
`;
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<HTMLInputElement>;
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; } get expiry(): number { return this._expiryUnix; }
set expiry(unix: number) { set expiry(unix: number) {
@ -192,14 +325,19 @@ class user implements User {
this._row = document.createElement("tr") as HTMLTableRowElement; this._row = document.createElement("tr") as HTMLTableRowElement;
let innerHTML = ` let innerHTML = `
<td><input type="checkbox" value=""></td> <td><input type="checkbox" value=""></td>
<td><span class="accounts-username"></span> <span class="accounts-admin"></span> <span class="accounts-disabled"></span></td> <td><div class="table-inline"><span class="accounts-username"></span> <span class="accounts-admin"></span> <span class="accounts-disabled"></span></span></td>
<td><i class="icon ri-edit-line accounts-email-edit"></i><span class="accounts-email-container ml-half"></span></td> <td><div class="table-inline"><i class="icon ri-edit-line accounts-email-edit"></i><span class="accounts-email-container ml-half"></span></div></td>
`; `;
if (window.telegramEnabled) { if (window.telegramEnabled) {
innerHTML += ` innerHTML += `
<td class="accounts-telegram"></td> <td class="accounts-telegram"></td>
`; `;
} }
if (window.discordEnabled) {
innerHTML += `
<td class="accounts-discord"></td>
`;
}
innerHTML += ` innerHTML += `
<td class="accounts-expiry"></td> <td class="accounts-expiry"></td>
<td class="accounts-last-active"></td> <td class="accounts-last-active"></td>
@ -213,6 +351,7 @@ class user implements User {
this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement; this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement;
this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement; this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement;
this._telegram = this._row.querySelector(".accounts-telegram") as HTMLTableDataCellElement; 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._expiry = this._row.querySelector(".accounts-expiry") as HTMLTableDataCellElement;
this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement; this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement;
this._check.onchange = () => { this.selected = this._check.checked; } this._check.onchange = () => { this.selected = this._check.checked; }
@ -270,7 +409,7 @@ class user implements User {
} }
}); });
} }
private _addTelegram = () => _get("/telegram/pin", null, (req: XMLHttpRequest) => { private _addTelegram = () => _get("/telegram/pin", null, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 200) { if (req.readyState == 4 && req.status == 200) {
const pin = document.getElementById("telegram-pin"); const pin = document.getElementById("telegram-pin");
@ -319,12 +458,16 @@ class user implements User {
this.id = user.id; this.id = user.id;
this.name = user.name; this.name = user.name;
this.email = user.email || ""; this.email = user.email || "";
this.discord = user.discord;
this.telegram = user.telegram; this.telegram = user.telegram;
this.last_active = user.last_active; this.last_active = user.last_active;
this.admin = user.admin; this.admin = user.admin;
this.disabled = user.disabled; this.disabled = user.disabled;
this.expiry = user.expiry; this.expiry = user.expiry;
this.notify_discord = user.notify_discord;
this.notify_telegram = user.notify_telegram; this.notify_telegram = user.notify_telegram;
this.notify_email = user.notify_email;
this.discord_id = user.discord_id;
} }
asElement = (): HTMLTableRowElement => { return this._row; } asElement = (): HTMLTableRowElement => { return this._row; }
@ -935,6 +1078,19 @@ export class accountsList {
}; };
this._announceTextarea.onkeyup = this.loadPreview; 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) => { reload = () => _get("/users", null, (req: XMLHttpRequest) => {

@ -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<DiscordUser>;
let innerHTML = ``;
for (let i = 0; i < users.length; i++) {
innerHTML += `
<tr>
<td class="img-circle sm">
<img class="img-circle" src="${users[i].avatar_url}" width="32" height="32">
</td>
<td class="w-100 sm">
<p class="content">${users[i].name}</p>
</td>
<td class="sm">
<span id="discord-user-${users[i].id}" class="button ~info !high">${buttonText}</span>
</td>
</tr>
`;
}
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();
}
}

@ -1,4 +1,5 @@
import { _get, _post, _delete, toClipboard, toggleLoader, toDateString } from "../modules/common.js"; import { _get, _post, _delete, toClipboard, toggleLoader, toDateString } from "../modules/common.js";
import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
class DOMInvite implements Invite { class DOMInvite implements Invite {
updateNotify = (checkbox: HTMLInputElement) => { updateNotify = (checkbox: HTMLInputElement) => {
@ -25,6 +26,7 @@ class DOMInvite implements Invite {
document.dispatchEvent(inviteDeletedEvent); document.dispatchEvent(inviteDeletedEvent);
} }
}) })
private _label: string = ""; private _label: string = "";
get label(): string { return this._label; } get label(): string { return this._label; }
set label(label: string) { set label(label: string) {
@ -82,10 +84,10 @@ class DOMInvite implements Invite {
this._middle.querySelector("strong.inv-remaining").textContent = remaining; this._middle.querySelector("strong.inv-remaining").textContent = remaining;
} }
private _email: string = ""; private _send_to: string = "";
get email(): string { return this._email }; get send_to(): string { return this._send_to };
set email(address: string) { set send_to(address: string) {
this._email = address; this._send_to = address;
const container = this._infoArea.querySelector(".tooltip") as HTMLDivElement; const container = this._infoArea.querySelector(".tooltip") as HTMLDivElement;
const icon = container.querySelector("i"); const icon = container.querySelector("i");
const chip = container.querySelector("span.inv-email-chip"); const chip = container.querySelector("span.inv-email-chip");
@ -100,7 +102,7 @@ class DOMInvite implements Invite {
} else { } else {
container.classList.add("mr-1"); container.classList.add("mr-1");
chip.classList.add("chip"); chip.classList.add("chip");
if (address.includes("Failed to send to")) { if (address.includes("Failed")) {
icon.classList.remove("ri-mail-line"); icon.classList.remove("ri-mail-line");
icon.classList.add("ri-mail-close-line"); icon.classList.add("ri-mail-close-line");
chip.classList.remove("~neutral"); chip.classList.remove("~neutral");
@ -372,7 +374,7 @@ class DOMInvite implements Invite {
update = (invite: Invite) => { update = (invite: Invite) => {
this.code = invite.code; this.code = invite.code;
this.created = invite.created; this.created = invite.created;
this.email = invite.email; this.send_to = invite.send_to;
this.expiresIn = invite.expiresIn; this.expiresIn = invite.expiresIn;
if (window.notificationsEnabled) { if (window.notificationsEnabled) {
this.notifyCreation = invite.notifyCreation; 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 { function parseInvite(invite: { [f: string]: string | number | { [name: string]: number } | boolean }): Invite {
let parsed: Invite = {}; let parsed: Invite = {};
parsed.code = invite["code"] as string; 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 || ""; parsed.label = invite["label"] as string || "";
let time = ""; let time = "";
let userExpiryTime = ""; let userExpiryTime = "";
@ -520,6 +522,7 @@ function parseInvite(invite: { [f: string]: string | number | { [name: string]:
export class createInvite { export class createInvite {
private _sendToEnabled = document.getElementById("create-send-to-enabled") as HTMLInputElement; private _sendToEnabled = document.getElementById("create-send-to-enabled") as HTMLInputElement;
private _sendTo = document.getElementById("create-send-to") 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 _userExpiryToggle = document.getElementById("create-user-expiry-enabled") as HTMLInputElement;
private _uses = document.getElementById('create-uses') as HTMLInputElement; private _uses = document.getElementById('create-uses') as HTMLInputElement;
private _infUses = document.getElementById("create-inf-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 _invDuration = document.getElementById('inv-duration');
private _userExpiry = document.getElementById('user-expiry'); private _userExpiry = document.getElementById('user-expiry');
private _sendToDiscord: (passData: string) => void;
// Broadcast when new invite created // Broadcast when new invite created
private _newInviteEvent = new CustomEvent("newInviteEvent"); private _newInviteEvent = new CustomEvent("newInviteEvent");
private _firstLoad = true; private _firstLoad = true;
@ -576,9 +581,19 @@ export class createInvite {
if (state) { if (state) {
this._sendToEnabled.parentElement.classList.remove("~neutral"); this._sendToEnabled.parentElement.classList.remove("~neutral");
this._sendToEnabled.parentElement.classList.add("~urge"); 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 { } else {
this._sendToEnabled.parentElement.classList.remove("~urge"); this._sendToEnabled.parentElement.classList.remove("~urge");
this._sendToEnabled.parentElement.classList.add("~neutral"); 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), "multiple-uses": (this.uses > 1 || this.infiniteUses),
"no-limit": this.infiniteUses, "no-limit": this.infiniteUses,
"remaining-uses": this.uses, "remaining-uses": this.uses,
"email": this.sendToEnabled ? this.sendTo : "", "send-to": this.sendToEnabled ? this.sendTo : "",
"profile": this.profile, "profile": this.profile,
"label": this.label "label": this.label
}; };
@ -761,7 +776,6 @@ export class createInvite {
this._userDays.disabled = true; this._userDays.disabled = true;
this._userHours.disabled = true; this._userHours.disabled = true;
this._userMinutes.disabled = true; this._userMinutes.disabled = true;
this.sendToEnabled = false;
this._createButton.onclick = this.create; this._createButton.onclick = this.create;
this.sendTo = ""; this.sendTo = "";
this.uses = 1; this.uses = 1;
@ -798,11 +812,22 @@ export class createInvite {
this._minutes.onchange = this._checkDurationValidity; this._minutes.onchange = this._checkDurationValidity;
document.addEventListener("profileLoadEvent", () => { this.loadProfiles(); }, false); document.addEventListener("profileLoadEvent", () => { this.loadProfiles(); }, false);
if (!window.emailEnabled) { if (!window.emailEnabled && !window.discordEnabled) {
document.getElementById("create-send-to-container").classList.add("unfocused"); 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;
} }
} }

@ -21,6 +21,7 @@ declare interface Window {
notificationsEnabled: boolean; notificationsEnabled: boolean;
emailEnabled: boolean; emailEnabled: boolean;
telegramEnabled: boolean; telegramEnabled: boolean;
discordEnabled: boolean;
ombiEnabled: boolean; ombiEnabled: boolean;
usernameEnabled: boolean; usernameEnabled: boolean;
token: string; token: string;
@ -101,13 +102,14 @@ declare interface Modals {
extendExpiry: Modal; extendExpiry: Modal;
updateInfo: Modal; updateInfo: Modal;
telegram: Modal; telegram: Modal;
discord: Modal;
} }
interface Invite { interface Invite {
code?: string; code?: string;
expiresIn?: string; expiresIn?: string;
remainingUses?: string; remainingUses?: string;
email?: string; send_to?: string;
usedBy?: { [name: string]: number }; usedBy?: { [name: string]: number };
created?: number; created?: number;
notifyExpiry?: boolean; notifyExpiry?: boolean;

@ -1,6 +1,7 @@
package main package main
import ( import (
"html/template"
"io/fs" "io/fs"
"net/http" "net/http"
"strconv" "strconv"
@ -121,6 +122,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
"contactMessage": "", "contactMessage": "",
"email_enabled": emailEnabled, "email_enabled": emailEnabled,
"telegram_enabled": telegramEnabled, "telegram_enabled": telegramEnabled,
"discord_enabled": discordEnabled,
"notifications": notificationsEnabled, "notifications": notificationsEnabled,
"version": version, "version": version,
"commit": commit, "commit": commit,
@ -256,8 +258,8 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
} }
return return
} }
email := app.storage.invites[code].Email email := app.storage.invites[code].SendTo
if strings.Contains(email, "Failed") { if strings.Contains(email, "Failed") || !strings.Contains(email, "@") {
email = "" email = ""
} }
data := gin.H{ data := gin.H{
@ -284,13 +286,30 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"userExpiryMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"), "userExpiryMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"),
"langName": lang, "langName": lang,
"telegramEnabled": telegramEnabled, "telegramEnabled": telegramEnabled,
"discordEnabled": discordEnabled,
} }
if data["telegramEnabled"].(bool) { if telegramEnabled {
data["telegramPIN"] = app.telegram.NewAuthToken() data["telegramPIN"] = app.telegram.NewAuthToken()
data["telegramUsername"] = app.telegram.username data["telegramUsername"] = app.telegram.username
data["telegramURL"] = app.telegram.link data["telegramURL"] = app.telegram.link
data["telegramRequired"] = app.config.Section("telegram").Key("required").MustBool(false) 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": `<code class="code">` + app.config.Section("discord").Key("start_command").MustString("!start") + `</code>`,
"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) gcHTML(gc, http.StatusOK, "form-loader.html", data)
} }

Loading…
Cancel
Save