Merge Discord branch

Discord Integration, Accounts UI improvements
matrix
Harvey Tindall 3 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
email := ""
if e, ok := app.storage.emails[jfID]; ok {
email = e.(string)
email = e.Addr
}
for _, ombiUser := range ombiUsers {
ombiAddr := ""
@ -283,7 +283,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
}
app.jf.CacheExpiry = time.Now()
if emailEnabled {
app.storage.emails[id] = req.Email
app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true}
app.storage.storeEmails()
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
@ -330,6 +330,30 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
success = false
return
}
var discordUser DiscordUser
discordVerified := false
if discordEnabled {
if req.DiscordPIN == "" {
if app.config.Section("discord").Key("required").MustBool(false) {
f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Discord verification not completed", req.Code)
respond(401, "errorDiscordVerification", gc)
}
success = false
return
}
} else {
discordUser, discordVerified = app.discord.verifiedTokens[req.DiscordPIN]
if !discordVerified {
f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Discord PIN was invalid", req.Code)
respond(401, "errorInvalidPIN", gc)
}
success = false
return
}
}
}
telegramTokenIndex := -1
if telegramEnabled {
if req.TelegramPIN == "" {
@ -454,7 +478,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
}
// if app.config.Section("password_resets").Key("enabled").MustBool(false) {
if req.Email != "" {
app.storage.emails[id] = req.Email
app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true}
app.storage.storeEmails()
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
@ -479,7 +503,18 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
app.err.Printf("Failed to store user duration: %v", err)
}
}
if discordEnabled && discordVerified {
discordUser.Contact = req.DiscordContact
if app.storage.discord == nil {
app.storage.discord = map[string]DiscordUser{}
}
app.storage.discord[user.ID] = discordUser
if err := app.storage.storeDiscordUsers(); err != nil {
app.err.Printf("Failed to store Discord users: %v", err)
} else {
delete(app.discord.verifiedTokens, req.DiscordPIN)
}
}
if telegramEnabled && telegramTokenIndex != -1 {
tgToken := app.telegram.verifiedTokens[telegramTokenIndex]
tgUser := TelegramUser{
@ -494,8 +529,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
app.storage.telegram = map[string]TelegramUser{}
}
app.storage.telegram[user.ID] = tgUser
err := app.storage.storeTelegramUsers()
if err != nil {
if err := app.storage.storeTelegramUsers(); err != nil {
app.err.Printf("Failed to store Telegram users: %v", err)
} else {
app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[telegramTokenIndex] = app.telegram.verifiedTokens[telegramTokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1]
@ -503,7 +537,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
}
}
if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 {
if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 || discordVerified {
name := app.getAddressOrName(user.ID)
app.debug.Printf("%s: Sending welcome message to %s", req.Username, name)
msg, err := app.email.constructWelcome(req.Username, expiry, app, false)
@ -792,18 +826,44 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.UserMinutes = req.UserMinutes
}
invite.ValidTill = validTill
if emailEnabled && req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
app.debug.Printf("%s: Sending invite email", inviteCode)
invite.Email = req.Email
msg, err := app.email.constructInvite(inviteCode, invite, app, false)
if err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
app.err.Printf("%s: Failed to construct invite email: %v", inviteCode, err)
} else if err := app.email.send(msg, req.Email); err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
app.err.Printf("%s: %s: %v", inviteCode, invite.Email, err)
} else {
app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.Email)
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
addressValid := false
discord := ""
app.debug.Printf("%s: Sending invite message", inviteCode)
if discordEnabled && !strings.Contains(req.SendTo, "@") {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
} else if len(users) > 1 {
invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo)
} else {
invite.SendTo = req.SendTo
addressValid = true
discord = users[0].User.ID
}
} else if emailEnabled {
addressValid = true
invite.SendTo = req.SendTo
}
if addressValid {
msg, err := app.email.constructInvite(inviteCode, invite, app, false)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
app.err.Printf("%s: Failed to construct invite message: %v", inviteCode, err)
} else {
var err error
if discord != "" {
err = app.discord.SendDM(msg, discord)
} else {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
app.err.Printf("%s: %s: %v", inviteCode, invite.SendTo, err)
} else {
app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.SendTo)
}
}
}
}
if req.Profile != "" {
@ -867,15 +927,15 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.RemainingUses != 0 {
invite.RemainingUses = inv.RemainingUses
}
if inv.Email != "" {
invite.Email = inv.Email
if inv.SendTo != "" {
invite.SendTo = inv.SendTo
}
if len(inv.Notify) != 0 {
var address string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
app.storage.loadEmails()
if addr := app.storage.emails[gc.GetString("jfId")]; addr != nil {
address = addr.(string)
if addr, ok := app.storage.emails[gc.GetString("jfId")]; ok && addr.Addr != "" {
address = addr.Addr
}
} else {
address = app.config.Section("ui").Key("email").String()
@ -1074,14 +1134,14 @@ func (app *appContext) SetNotify(gc *gin.Context) {
}
var address string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
var ok bool
address, ok = app.storage.emails[gc.GetString("jfId")].(string)
addr, ok := app.storage.emails[gc.GetString("jfId")]
if !ok {
app.err.Printf("%s: Couldn't find email address. Make sure it's set", code)
app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
respond(500, "Missing user email", gc)
return
}
address = addr.Addr
} else {
address = app.config.Section("ui").Key("email").String()
}
@ -1168,7 +1228,8 @@ func (app *appContext) GetUsers(gc *gin.Context) {
user.LastActive = jfUser.LastActivityDate.Unix()
}
if email, ok := app.storage.emails[jfUser.ID]; ok {
user.Email = email.(string)
user.Email = email.Addr
user.NotifyThroughEmail = email.Contact
}
expiry, ok := app.storage.users[jfUser.ID]
if ok {
@ -1178,6 +1239,11 @@ func (app *appContext) GetUsers(gc *gin.Context) {
user.Telegram = tgUser.Username
user.NotifyThroughTelegram = tgUser.Contact
}
if dc, ok := app.storage.discord[jfUser.ID]; ok {
user.Discord = dc.Username + "#" + dc.Discriminator
user.DiscordID = dc.ID
user.NotifyThroughDiscord = dc.Contact
}
resp.UserList[i] = user
i++
}
@ -1253,7 +1319,11 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
for _, jfUser := range users {
id := jfUser.ID
if address, ok := req[id]; ok {
app.storage.emails[id] = address
contact := true
if oldAddr, ok := app.storage.emails[id]; ok {
contact = oldAddr.Contact
}
app.storage.emails[id] = EmailAddress{Addr: address, Contact: contact}
if ombiEnabled {
ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil {
@ -2004,38 +2074,63 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
// @Summary Sets whether to notify a user through telegram or not.
// @Produce json
// @Param telegramNotifyDTO body telegramNotifyDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
// @Router /users/telegram/notify [post]
// @Security Bearer
// @tags Other
func (app *appContext) TelegramSetNotify(gc *gin.Context) {
var req telegramNotifyDTO
func (app *appContext) SetContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO
gc.BindJSON(&req)
if req.ID == "" {
respondBool(400, false, gc)
return
}
if tgUser, ok := app.storage.telegram[req.ID]; ok {
tgUser.Contact = req.Enabled
tgUser.Contact = req.Telegram
app.storage.telegram[req.ID] = tgUser
if err := app.storage.storeTelegramUsers(); err != nil {
respondBool(500, false, gc)
app.err.Printf("Telegram: Failed to store users: %v", err)
return
}
respondBool(200, true, gc)
msg := ""
if !req.Enabled {
msg = "not"
if !req.Telegram {
msg = " not"
}
app.debug.Printf("Telegram: User \"%s\" will %s be notified through Telegram.", tgUser.Username, msg)
return
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
}
app.err.Printf("Telegram: User \"%s\" does not have a telegram account registered.", req.ID)
respondBool(400, false, gc)
if dcUser, ok := app.storage.discord[req.ID]; ok {
dcUser.Contact = req.Discord
app.storage.discord[req.ID] = dcUser
if err := app.storage.storeDiscordUsers(); err != nil {
respondBool(500, false, gc)
app.err.Printf("Discord: Failed to store users: %v", err)
return
}
msg := ""
if !req.Discord {
msg = " not"
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
}
if email, ok := app.storage.emails[req.ID]; ok {
email.Contact = req.Email
app.storage.emails[req.ID] = email
if err := app.storage.storeEmails(); err != nil {
respondBool(500, false, gc)
app.err.Printf("Failed to store emails: %v", err)
return
}
msg := ""
if !req.Email {
msg = " not"
}
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
}
respondBool(200, true, gc)
}
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires bearer auth.
@ -2092,6 +2187,107 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
respondBool(200, tokenIndex != -1, gc)
}
// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Param invCode path string true "invite Code"
// @Router /invite/{invCode}/discord/verified/{pin} [get]
// @tags Other
func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok {
respondBool(401, false, gc)
return
}
pin := gc.Param("pin")
_, ok := app.discord.verifiedTokens[pin]
respondBool(200, ok, gc)
}
// @Summary Returns a 10-minute, one-use Discord server invite
// @Produce json
// @Success 200 {object} DiscordInviteDTO
// @Failure 400 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param invCode path string true "invite Code"
// @Router /invite/{invCode}/discord/invite [get]
// @tags Other
func (app *appContext) DiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" {
respondBool(400, false, gc)
return
}
code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok {
respondBool(401, false, gc)
return
}
invURL, iconURL := app.discord.NewTempInvite(10*60, 1)
if invURL == "" {
respondBool(500, false, gc)
return
}
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
}
// @Summary Returns a list of matching users from a Discord guild, given a username (discriminator optional).
// @Produce json
// @Success 200 {object} DiscordUsersDTO
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param username path string true "username to search."
// @Router /users/discord/{username} [get]
// @tags Other
func (app *appContext) DiscordGetUsers(gc *gin.Context) {
name := gc.Param("username")
if name == "" {
respondBool(400, false, gc)
return
}
users := app.discord.GetUsers(name)
resp := DiscordUsersDTO{Users: make([]DiscordUserDTO, len(users))}
for i, u := range users {
resp.Users[i] = DiscordUserDTO{
Name: u.User.Username + "#" + u.User.Discriminator,
ID: u.User.ID,
AvatarURL: u.User.AvatarURL("32"),
}
}
gc.JSON(200, resp)
}
// @Summary Links a Discord account to a Jellyfin account via user IDs. Notifications are turned on by default.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
// @Router /users/discord [post]
// @tags Other
func (app *appContext) DiscordConnect(gc *gin.Context) {
var req DiscordConnectUserDTO
gc.BindJSON(&req)
if req.JellyfinID == "" || req.DiscordID == "" {
respondBool(400, false, gc)
return
}
user, ok := app.discord.NewUser(req.DiscordID)
if !ok {
respondBool(500, false, gc)
return
}
app.storage.discord[req.JellyfinID] = user
if err := app.storage.storeDiscordUsers(); err != nil {
app.err.Printf("Failed to store Discord users: %v", err)
respondBool(500, false, gc)
return
}
respondBool(200, true, gc)
}
// @Summary Restarts the program. No response means success.
// @Router /restart [post]
// @Security Bearer

@ -14,6 +14,7 @@ import (
var emailEnabled = false
var messagesEnabled = false
var telegramEnabled = false
var discordEnabled = false
func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
val := app.config.Section(sect).Key(key).MustString("")
@ -42,7 +43,7 @@ func (app *appContext) loadConfig() error {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users"} {
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
}
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
@ -87,15 +88,17 @@ func (app *appContext) loadConfig() error {
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
if !messagesEnabled {
emailEnabled = false
telegramEnabled = false
discordEnabled = false
} else if app.config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false
} else {
emailEnabled = true
}
if !emailEnabled && !telegramEnabled {
if !emailEnabled && !telegramEnabled && !discordEnabled {
messagesEnabled = false
}
@ -168,3 +171,28 @@ func (app *appContext) migrateEmailConfig() {
}
app.loadConfig()
}
func (app *appContext) migrateEmailStorage() error {
var emails map[string]interface{}
err := loadJSON(app.storage.emails_path, &emails)
if err != nil {
return err
}
newEmails := map[string]EmailAddress{}
for jfID, addr := range emails {
newEmails[jfID] = EmailAddress{
Addr: addr.(string),
Contact: true,
}
}
err = storeJSON(app.storage.emails_path+".bak", emails)
if err != nil {
return err
}
err = storeJSON(app.storage.emails_path, newEmails)
if err != nil {
return err
}
app.info.Println("Migrated to new email format. A backup has also been made.")
return nil
}

@ -546,6 +546,89 @@
}
}
},
"discord": {
"order": [],
"meta": {
"name": "Discord",
"description": "Settings for Discord invites/signup/notifications"
},
"settings": {
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Enable signup verification through Discord and the sending of notifications through it.\nSee the jfa-go wiki for setting up a bot."
},
"required": {
"name": "Require on sign-up",
"required": false,
"required_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": false,
"description": "Require Discord connection on sign-up."
},
"token": {
"name": "API Token",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Discord Bot API Token."
},
"start_command": {
"name": "Start command",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "text",
"value": "!start",
"description": "Command to start the user verification process."
},
"channel": {
"name": "Channel to monitor",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Only listen to commands in specified channel. Leave blank to monitor all."
},
"provide_invite": {
"name": "Provide server invite",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": false,
"description": "Generate a one-time discord server invite for the account creation form. Required Bot permission \"Create instant invite\", you may need to re-add the bot to your server after."
},
"invite_channel": {
"name": "Invite channel",
"required": false,
"requires_restart": true,
"depends_true": "provide_invite",
"type": "text",
"value": "",
"description": "Channel to invite new users to."
},
"language": {
"name": "Language",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "select",
"options": [
["en-us", "English (US)"]
],
"value": "en-us",
"description": "Default Discord message language. Visit weblate if you'd like to translate."
}
}
},
"telegram": {
"order": [],
"meta": {
@ -565,6 +648,7 @@
"name": "Require on sign-up",
"required": false,
"required_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": false,
"description": "Require telegram connection on sign-up."
@ -1140,6 +1224,14 @@
"type": "text",
"value": "",
"description": "Stores telegram user IDs and language preferences."
},
"discord_users": {
"name": "Discord users",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Stores discord user IDs and language preferences."
}
}
}

@ -30,12 +30,12 @@
}
}
@media screen and (max-width: 750px) {
@media screen and (max-width: 1000px) {
:root {
font-size: 0.9rem;
}
.table-responsive table {
min-width: 660px;
min-width: 800px;
}
}
@ -130,6 +130,10 @@ div.card:contains(section.banner.footer) {
text-align: center;
}
.w-100 {
width: 100%;
}
.inline-block {
display: inline-block;
}
@ -172,7 +176,7 @@ div.card:contains(section.banner.footer) {
}
p.sm,
span.sm {
span.sm:not(.heading) {
font-size: 0.75rem;
}
@ -424,6 +428,7 @@ p.top {
.table-responsive {
overflow-x: auto;
font-size: 0.9rem;
}
#notification-box {
@ -438,6 +443,10 @@ p.top {
margin-bottom: -0.5rem;
}
.dropdown-display.lg {
white-space: nowrap;
}
pre {
white-space: pre-wrap; /* css-3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
@ -483,3 +492,32 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) {
max-width: 15rem;
min-width: 10rem;
}
td.img-circle {
width: 32px;
height: 32px;
}
span.img-circle.lg {
width: 64px;
height: 64px;
}
span.shield.img-circle {
padding: 0.2rem;
}
img.img-circle {
border-radius: 50%;
vertical-align: middle;
}
.table td.sm {
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
.table-inline {
display: flex !important;
align-items: center;
}

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

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

@ -11,6 +11,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4=
github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
@ -148,6 +150,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hrfee/mediabrowser v0.3.3 h1:7E05uiol8hh2ytKn3WVLrUIvHAyifYEIy3Y5qtuNh8I=
github.com/hrfee/mediabrowser v0.3.3/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/itchyny/timefmt-go v0.1.2 h1:q0Xa4P5it6K6D7ISsbLAMwx1PnWlixDcJL6/sFs93Hs=
@ -265,6 +269,7 @@ github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead h1:jeP6FgaSLNTMP+Yri3qjlACywQLye+huGLmNGhBzm6k=
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

@ -7,6 +7,7 @@
window.notificationsEnabled = {{ .notifications }};
window.emailEnabled = {{ .email_enabled }};
window.telegramEnabled = {{ .telegram_enabled }};
window.discordEnabled = {{ .discord_enabled }};
window.ombiEnabled = {{ .ombiEnabled }};
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
@ -327,6 +328,18 @@
</div>
</div>
{{ 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>
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
@ -488,7 +501,14 @@
<div id="create-send-to-container">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<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">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral !normal">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
@ -525,6 +545,9 @@
{{ if .telegram_enabled }}
<th>Telegram</th>
{{ end }}
{{ if .discord_enabled }}
<th>Discord</th>
{{ end }}
<th>{{ .strings.expiry }}</th>
<th>{{ .strings.lastActiveTime }}</th>
</tr>

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

@ -37,6 +37,17 @@
</div>
</div>
{{ 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="button ~urge dropdown-button">
<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 }}">
{{ if .telegramEnabled }}
<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">
<label class="row switch pb-1">
<input type="radio" name="contact-via" value="email"><span>Contact through Email</span>
</label>
{{ if .telegramEnabled }}
<label class="row switch pb-1">
<input type="radio" name="contact-via" value="telegram" id="contact-via-telegram"><span>Contact through Telegram</span>
</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>
{{ end }}
<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",
"apply": "Apply",
"delete": "Delete",
"add": "Add",
"select": "Select",
"name": "Name",
"date": "Date",
"enabled": "Enabled",
@ -94,7 +96,9 @@
"notifyEvent": "Notify on:",
"notifyInviteExpiry": "On expiry",
"notifyUserCreation": "On user creation",
"sendPIN": "Ask the user to send the PIN below to the bot."
"sendPIN": "Ask the user to send the PIN below to the bot.",
"searchDiscordUser": "Start typing the Discord username to find the user.",
"findDiscordUser": "Find Discord user"
},
"notifications": {
"changedEmailAddress": "Changed email address of {n}.",
@ -107,6 +111,7 @@
"updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.",
"errorConnection": "Couldn't connect to jfa-go.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",

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

@ -18,14 +18,16 @@
"confirmationRequired": "Email confirmation required",
"confirmationRequiredMessage": "Please check your email inbox to verify your address.",
"yourAccountIsValidUntil": "Your account will be valid until {date}.",
"sendPIN": "Send the PIN below to the bot, then come back here to link your account."
"sendPIN": "Send the PIN below to the bot, then come back here to link your account.",
"sendPINDiscord": "Type {command} in {server_channel} on Discord, then send the PIN below via DM to the bot."
},
"notifications": {
"errorUserExists": "User already exists.",
"errorInvalidCode": "Invalid invite code.",
"errorTelegramVerification": "Telegram verification required.",
"errorInvalidPIN": "Telegram PIN is invalid.",
"telegramVerified": "Telegram account verified."
"errorDiscordVerification": "Discord verification required.",
"errorInvalidPIN": "PIN is invalid.",
"verified": "Account verified."
},
"validationStrings": {
"length": {

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

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

@ -17,6 +17,8 @@ type newUserDTO struct {
Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser)
TelegramPIN string `json:"telegram_pin" example:"A1-B2-3C"` // Telegram verification PIN (if used)
TelegramContact bool `json:"telegram_contact"` // Whether or not to use telegram for notifications/pwrs
DiscordPIN string `json:"discord_pin" example:"A1-B2-3C"` // Discord verification PIN (if used)
DiscordContact bool `json:"discord_contact"` // Whether or not to use discord for notifications/pwrs
}
type newUserResponse struct {
@ -48,7 +50,7 @@ type generateInviteDTO struct {
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
Email string `json:"email" example:"jeff@jellyf.in"` // Send invite to this address
SendTo string `json:"send-to" example:"jeff@jellyf.in"` // Send invite to this address or discord name
MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses
NoLimit bool `json:"no-limit" example:"false"` // No invite use limit
RemainingUses int `json:"remaining-uses" example:"5"` // Remaining invite uses
@ -98,7 +100,7 @@ type inviteDTO struct {
UsedBy map[string]int64 `json:"used-by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time
NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times
RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable)
Email string `json:"email,omitempty"` // Email the invite was sent to (if applicable)
SendTo string `json:"send_to,omitempty"` // Email/Discord username the invite was sent to (if applicable)
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
Label string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite
@ -125,12 +127,16 @@ type respUser struct {
ID string `json:"id" example:"fdgsdfg45534fa"` // userID of user
Name string `json:"name" example:"jeff"` // Username of user
Email string `json:"email,omitempty" example:"jeff@jellyf.in"` // Email address of user (if available)
LastActive int64 `json:"last_active" example:"1617737207510"` // Time of last activity on Jellyfin
Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator
Expiry int64 `json:"expiry" example:"1617737207510"` // Expiry time of user as Epoch/Unix time.
Disabled bool `json:"disabled"` // Whether or not the user is disabled.
Telegram string `json:"telegram"` // Telegram username (if known)
NotifyThroughEmail bool `json:"notify_email"`
LastActive int64 `json:"last_active" example:"1617737207510"` // Time of last activity on Jellyfin
Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator
Expiry int64 `json:"expiry" example:"1617737207510"` // Expiry time of user as Epoch/Unix time.
Disabled bool `json:"disabled"` // Whether or not the user is disabled.
Telegram string `json:"telegram"` // Telegram username (if known)
NotifyThroughTelegram bool `json:"notify_telegram"`
Discord string `json:"discord"` // Discord username (if known)
DiscordID string `json:"discord_id"` // Discord user ID for creating links.
NotifyThroughDiscord bool `json:"notify_discord"`
}
type getUsersDTO struct {
@ -249,7 +255,29 @@ type telegramSetDTO struct {
ID string `json:"id"` // Jellyfin ID of user.
}
type telegramNotifyDTO struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
type SetContactMethodsDTO struct {
ID string `json:"id"`
Email bool `json:"email"`
Discord bool `json:"discord"`
Telegram bool `json:"telegram"`
}
type DiscordUserDTO struct {
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
ID string `json:"id"`
}
type DiscordUsersDTO struct {
Users []DiscordUserDTO `json:"users"`
}
type DiscordConnectUserDTO struct {
JellyfinID string `json:"jf_id"`
DiscordID string `json:"discord_id"`
}
type DiscordInviteDTO struct {
InviteURL string `json:"invite"`
IconURL string `json:"icon"`
}

@ -121,6 +121,12 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
if telegramEnabled {
router.GET(p+"/invite/:invCode/telegram/verified/:pin", app.TelegramVerifiedInvite)
}
if discordEnabled {
router.GET(p+"/invite/:invCode/discord/verified/:pin", app.DiscordVerifiedInvite)
if app.config.Section("discord").Key("provide_invite").MustBool(false) {
router.GET(p+"/invite/:invCode/discord/invite", app.DiscordServerInvite)
}
}
}
if *SWAGGER {
app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n"))
@ -158,11 +164,15 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/config", app.GetConfig)
api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart)
if telegramEnabled {
if telegramEnabled || discordEnabled {
api.GET(p+"/telegram/pin", app.TelegramGetPin)
api.GET(p+"/telegram/verified/:pin", app.TelegramVerified)
api.POST(p+"/users/telegram", app.TelegramAddUser)
api.POST(p+"/users/telegram/notify", app.TelegramSetNotify)
api.POST(p+"/users/contact", app.SetContactMethods)
}
if discordEnabled {
api.GET(p+"/users/discord/:username", app.DiscordGetUsers)
api.POST(p+"/users/discord", app.DiscordConnect)
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
api.GET(p+"/ombi/users", app.OmbiUsers)

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

@ -3,22 +3,57 @@ package main
import (
"strings"
dg "github.com/bwmarrin/discordgo"
stripmd "github.com/writeas/go-strip-markdown"
)
type Link struct {
Alt, URL string
}
// StripAltText removes Markdown alt text from links and images and replaces them with just the URL.
// Currently uses the deepest alt text when links/images are nested.
func StripAltText(md string) string {
// If links = true, links are completely removed, and a list of URLs and their alt text is also returned.
func StripAltText(md string, links bool) (string, []*dg.MessageEmbed) {
altTextStart := -1 // Start of alt text (between '[' & ']')
URLStart := -1 // Start of url (between '(' & ')')
URLEnd := -1
previousURLEnd := -2
out := ""
embeds := []*dg.MessageEmbed{}
for i := range md {
if altTextStart != -1 && URLStart != -1 && md[i] == ')' {
URLEnd = i - 1
out += md[previousURLEnd+2:altTextStart-1] + md[URLStart:URLEnd+1]
out += md[previousURLEnd+2 : altTextStart-1]
if links {
embed := &dg.MessageEmbed{
Type: dg.EmbedTypeLink,
Title: md[altTextStart : URLStart-2],
}
if md[altTextStart-1] == '!' {
embed.Title = md[altTextStart+1 : URLStart-2]
embed.Type = dg.EmbedTypeImage
embed.Image = &dg.MessageEmbedImage{
URL: md[URLStart : URLEnd+1],
}
} else {
embed.URL = md[URLStart : URLEnd+1]
}
embeds = append(embeds, embed)
} else {
out += md[URLStart : URLEnd+1]
}
previousURLEnd = URLEnd
// Removing links often leaves a load of extra newlines which look weird, this removes them.
if links {
next := 2
for md[URLEnd+next] == '\n' {
next++
}
if next >= 3 {
previousURLEnd += next - 2
}
}
altTextStart, URLStart, URLEnd = -1, -1, -1
continue
}
@ -36,11 +71,12 @@ func StripAltText(md string) string {
out += md[previousURLEnd+2:]
}
if out == "" {
return md
return md, embeds
}
return out
return out, embeds
}
func stripMarkdown(md string) string {
return strings.TrimPrefix(strings.TrimSuffix(stripmd.Strip(StripAltText(md)), "</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"
)
type VerifiedToken struct {
type TelegramVerifiedToken struct {
Token string
ChatID int64
Username string
@ -21,7 +21,7 @@ type TelegramDaemon struct {
bot *tg.BotAPI
username string
tokens []string
verifiedTokens []VerifiedToken
verifiedTokens []TelegramVerifiedToken
languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start.
link string
app *appContext
@ -37,12 +37,11 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) {
return nil, err
}
td := &TelegramDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
bot: bot,
username: bot.Self.UserName,
tokens: []string{},
verifiedTokens: []VerifiedToken{},
verifiedTokens: []TelegramVerifiedToken{},
languages: map[int64]string{},
link: "https://t.me/" + bot.Self.UserName,
app: app,
@ -55,10 +54,7 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) {
return td, nil
}
var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (t *TelegramDaemon) NewAuthToken() string {
func genAuthToken() string {
rand.Seed(time.Now().UnixNano())
pin := make([]rune, 8)
for i := range pin {
@ -68,10 +64,18 @@ func (t *TelegramDaemon) NewAuthToken() string {
pin[i] = runes[rand.Intn(len(runes))]
}
}
t.tokens = append(t.tokens, string(pin))
return string(pin)
}
var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (t *TelegramDaemon) NewAuthToken() string {
pin := genAuthToken()
t.tokens = append(t.tokens, pin)
return pin
}
func (t *TelegramDaemon) run() {
t.app.info.Println("Starting Telegram bot daemon")
u := tg.NewUpdate(0)
@ -79,6 +83,7 @@ func (t *TelegramDaemon) run() {
updates, err := t.bot.GetUpdatesChan(u)
if err != nil {
t.app.err.Printf("Failed to start Telegram daemon: %v", err)
telegramEnabled = false
return
}
for {
@ -171,7 +176,7 @@ func (t *TelegramDaemon) Shutdown() {
func (t *TelegramDaemon) commandStart(upd *tg.Update, sects []string, lang string) {
content := t.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n"
content += t.app.storage.lang.Telegram[lang].Strings.get("languageMessage")
content += t.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "/lang"})
err := t.Reply(upd, content)
if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
@ -180,9 +185,9 @@ func (t *TelegramDaemon) commandStart(upd *tg.Update, sects []string, lang strin
func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string) {
if len(sects) == 1 {
list := "/lang <lang>\n"
list := "/lang `<lang>`\n"
for code := range t.app.storage.lang.Telegram {
list += fmt.Sprintf("%s: %s\n", code, t.app.storage.lang.Telegram[code].Meta.Name)
list += fmt.Sprintf("`%s`: %s\n", code, t.app.storage.lang.Telegram[code].Meta.Name)
}
err := t.Reply(upd, list)
if err != nil {
@ -196,8 +201,7 @@ func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string
if user.ChatID == upd.Message.Chat.ID {
user.Lang = sects[1]
t.app.storage.telegram[jfID] = user
err := t.app.storage.storeTelegramUsers()
if err != nil {
if err := t.app.storage.storeTelegramUsers(); err != nil {
t.app.err.Printf("Failed to store Telegram users: %v", err)
}
break
@ -225,7 +229,7 @@ func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string)
if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
}
t.verifiedTokens = append(t.verifiedTokens, VerifiedToken{
t.verifiedTokens = append(t.verifiedTokens, TelegramVerifiedToken{
Token: upd.Message.Text,
ChatID: upd.Message.Chat.ID,
Username: upd.Message.Chat.UserName,

@ -66,6 +66,10 @@ window.availableProfiles = window.availableProfiles || [];
if (window.telegramEnabled) {
window.modals.telegram = new Modal(document.getElementById("modal-telegram"));
}
if (window.discordEnabled) {
window.modals.discord = new Modal(document.getElementById("modal-discord"));
}
})();
var inviteCreator = new createInvite();

@ -8,12 +8,18 @@ interface formWindow extends Window {
invalidPassword: string;
successModal: Modal;
telegramModal: Modal;
discordModal: Modal;
confirmationModal: Modal
code: string;
messages: { [key: string]: string };
confirmation: boolean;
telegramRequired: boolean;
telegramPIN: string;
discordRequired: boolean;
discordPIN: string;
discordStartCommand: string;
discordInviteLink: boolean;
discordServerName: string;
userExpiryEnabled: boolean;
userExpiryMonths: number;
userExpiryDays: number;
@ -68,7 +74,7 @@ if (window.telegramEnabled) {
telegramVerified = true;
waiting.classList.add("~positive");
waiting.classList.remove("~info");
window.notifications.customPositive("telegramVerified", "", window.messages["telegramVerified"]);
window.notifications.customPositive("telegramVerified", "", window.messages["verified"]);
setTimeout(window.telegramModal.close, 2000);
telegramButton.classList.add("unfocused");
document.getElementById("contact-via").classList.remove("unfocused");
@ -84,6 +90,66 @@ if (window.telegramEnabled) {
};
}
interface DiscordInvite {
invite: string;
icon: string;
}
var discordVerified = false;
if (window.discordEnabled) {
window.discordModal = new Modal(document.getElementById("modal-discord"), window.discordRequired);
const discordButton = document.getElementById("link-discord") as HTMLSpanElement;
if (window.discordInviteLink) {
_get("/invite/" + window.code + "/discord/invite", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
return;
}
const inv = req.response as DiscordInvite;
const link = document.getElementById("discord-invite") as HTMLAnchorElement;
link.classList.add("subheading", "link-center");
link.href = inv.invite;
link.target = "_blank";
link.innerHTML = `<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) {
window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true);
}
@ -161,6 +227,8 @@ interface sendDTO {
password: string;
telegram_pin?: string;
telegram_contact?: boolean;
discord_pin?: string;
discord_contact?: boolean;
}
const create = (event: SubmitEvent) => {
@ -179,6 +247,13 @@ const create = (event: SubmitEvent) => {
send.telegram_contact = true;
}
}
if (discordVerified) {
send.discord_pin = window.discordPIN;
const radio = document.getElementById("contact-via-discord") as HTMLInputElement;
if (radio.checked) {
send.discord_contact = true;
}
}
_post("/newUser", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let vals = req.response as respDTO;

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

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

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

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

Loading…
Cancel
Save