1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-22 17:10:10 +00:00

Merge Telegram support

For #94.
This commit is contained in:
Harvey Tindall 2021-05-08 16:15:41 +01:00 committed by GitHub
commit fe5cf69b7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1514 additions and 409 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ server.pem
server.crt server.crt
instructions-debian.txt instructions-debian.txt
cl.md cl.md
./telegram/

View File

@ -17,11 +17,12 @@ jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jelly
* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too. * ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions. * 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions.
* Account management: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason. * Account management: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason.
* Telegram Integration: Verify users via telegram, and send Password Resets, Announcements, etc. through it.
* 📨 Email storage: Add your existing users email addresses through the UI, and jfa-go will ask new users for them on account creation. * 📨 Email storage: Add your existing users email addresses through the UI, and jfa-go will ask new users for them on account creation.
* Email addresses can optionally be used instead of usernames * Email addresses can optionally be used instead of usernames
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to the user via email. * 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to the user via email/telegram.
* Notifications: Get notified when someone creates an account, or an invite expires. * Notifications: Get notified when someone creates an account, or an invite expires.
* 📣 Announcements: Bulk email your users with announcements about your server. * 📣 Announcements: Bulk message your users with announcements about your server.
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider. * Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
* Enables the usage of jfa-go by multiple people * Enables the usage of jfa-go by multiple people
* 🌓 Customizations * 🌓 Customizations

351
api.go
View File

@ -39,9 +39,9 @@ func respondBool(code int, val bool, gc *gin.Context) {
} }
func (app *appContext) loadStrftime() { func (app *appContext) loadStrftime() {
app.datePattern = app.config.Section("email").Key("date_format").String() app.datePattern = app.config.Section("messages").Key("date_format").String()
app.timePattern = `%H:%M` app.timePattern = `%H:%M`
if val, _ := app.config.Section("email").Key("use_24h").Bool(); !val { if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val {
app.timePattern = `%I:%M %p` app.timePattern = `%I:%M %p`
} }
return return
@ -282,7 +282,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
} }
} }
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()
if app.config.Section("password_resets").Key("enabled").MustBool(false) { if emailEnabled {
app.storage.emails[id] = req.Email app.storage.emails[id] = req.Email
app.storage.storeEmails() app.storage.storeEmails()
} }
@ -330,15 +330,44 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
success = false success = false
return return
} }
telegramTokenIndex := -1
if telegramEnabled {
if req.TelegramPIN == "" {
if app.config.Section("telegram").Key("required").MustBool(false) {
f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Telegram verification not completed", req.Code)
respond(401, "errorTelegramVerification", gc)
}
success = false
return
}
} else {
for i, v := range app.telegram.verifiedTokens {
if v.Token == req.TelegramPIN {
telegramTokenIndex = i
break
}
}
if telegramTokenIndex == -1 {
f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Telegram PIN was invalid", req.Code)
respond(401, "errorInvalidPIN", gc)
}
success = false
return
}
}
}
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed { if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed {
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"valid": true, "valid": true,
"invite": req.Code, "invite": req.Code,
"email": req.Email, "email": req.Email,
"username": req.Username, "username": req.Username,
"password": req.Password, "password": req.Password,
"exp": strconv.FormatInt(time.Now().Add(time.Hour*12).Unix(), 10), "telegramPIN": req.TelegramPIN,
"type": "confirmation", "exp": strconv.FormatInt(time.Now().Add(time.Hour*12).Unix(), 10),
"type": "confirmation",
} }
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
@ -450,15 +479,40 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
app.err.Printf("Failed to store user duration: %v", err) app.err.Printf("Failed to store user duration: %v", err)
} }
} }
if emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" {
app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email) if telegramEnabled && telegramTokenIndex != -1 {
tgToken := app.telegram.verifiedTokens[telegramTokenIndex]
tgUser := TelegramUser{
ChatID: tgToken.ChatID,
Username: tgToken.Username,
Contact: req.TelegramContact,
}
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
tgUser.Lang = lang
}
if app.storage.telegram == nil {
app.storage.telegram = map[string]TelegramUser{}
}
app.storage.telegram[user.ID] = tgUser
err := app.storage.storeTelegramUsers()
if 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]
app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1]
}
}
if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 {
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) msg, err := app.email.constructWelcome(req.Username, expiry, app, false)
if err != nil { if err != nil {
app.err.Printf("%s: Failed to construct welcome email: %v", req.Username, err) app.err.Printf("%s: Failed to construct welcome message: %v", req.Username, err)
} else if err := app.email.send(msg, req.Email); err != nil { } else if err := app.sendByID(msg, user.ID); err != nil {
app.err.Printf("%s: Failed to send welcome email: %v", req.Username, err) app.err.Printf("%s: Failed to send welcome message: %v", req.Username, err)
} else { } else {
app.info.Printf("%s: Sent welcome email to \"%s\"", req.Username, req.Email) app.info.Printf("%s: Sent welcome message to \"%s\"", req.Username, name)
} }
} }
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()
@ -525,7 +579,20 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
"GetUser": map[string]string{}, "GetUser": map[string]string{},
"SetPolicy": map[string]string{}, "SetPolicy": map[string]string{},
} }
var addresses []string sendMail := messagesEnabled
var msg *Message
var err error
if sendMail {
if req.Enabled {
msg, err = app.email.constructEnabled(req.Reason, app, false)
} else {
msg, err = app.email.constructDisabled(req.Reason, app, false)
}
if err != nil {
app.err.Printf("Failed to construct account enabled/disabled emails: %v", err)
sendMail = false
}
}
for _, userID := range req.Users { for _, userID := range req.Users {
user, status, err := app.jf.UserByID(userID, false) user, status, err := app.jf.UserByID(userID, false)
if status != 200 || err != nil { if status != 200 || err != nil {
@ -540,31 +607,13 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err) app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err)
continue continue
} }
if emailEnabled && req.Notify { if sendMail && req.Notify {
addr, ok := app.storage.emails[userID] if err := app.sendByID(msg, userID); err != nil {
if addr != nil && ok { app.err.Printf("Failed to send account enabled/disabled email: %v", err)
addresses = append(addresses, addr.(string)) continue
} }
} }
} }
if len(addresses) != 0 {
go func(reason string, addresses []string) {
var msg *Email
var err error
if req.Enabled {
msg, err = app.email.constructEnabled(reason, app, false)
} else {
msg, err = app.email.constructDisabled(reason, app, false)
}
if err != nil {
app.err.Printf("Failed to construct account enabled/disabled emails: %v", err)
} else if err := app.email.send(msg, addresses...); err != nil {
app.err.Printf("Failed to send account enabled/disabled emails: %v", err)
} else {
app.info.Println("Sent account enabled/disabled emails")
}
}(req.Reason, addresses)
}
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()
if len(errors["GetUser"]) != 0 || len(errors["SetPolicy"]) != 0 { if len(errors["GetUser"]) != 0 || len(errors["SetPolicy"]) != 0 {
gc.JSON(500, errors) gc.JSON(500, errors)
@ -584,10 +633,19 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
// @tags Users // @tags Users
func (app *appContext) DeleteUsers(gc *gin.Context) { func (app *appContext) DeleteUsers(gc *gin.Context) {
var req deleteUserDTO var req deleteUserDTO
var addresses []string
gc.BindJSON(&req) gc.BindJSON(&req)
errors := map[string]string{} errors := map[string]string{}
ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
sendMail := messagesEnabled
var msg *Message
var err error
if sendMail {
msg, err = app.email.constructDeleted(req.Reason, app, false)
if err != nil {
app.err.Printf("Failed to construct account deletion emails: %v", err)
sendMail = false
}
}
for _, userID := range req.Users { for _, userID := range req.Users {
if ombiEnabled { if ombiEnabled {
ombiUser, code, err := app.getOmbiUser(userID) ombiUser, code, err := app.getOmbiUser(userID)
@ -610,25 +668,12 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
errors[userID] += msg errors[userID] += msg
} }
} }
if emailEnabled && req.Notify { if sendMail && req.Notify {
addr, ok := app.storage.emails[userID] if err := app.sendByID(msg, userID); err != nil {
if addr != nil && ok { app.err.Printf("Failed to send account deletion email: %v", err)
addresses = append(addresses, addr.(string))
} }
} }
} }
if len(addresses) != 0 {
go func(reason string, addresses []string) {
msg, err := app.email.constructDeleted(reason, app, false)
if err != nil {
app.err.Printf("Failed to construct account deletion emails: %v", err)
} else if err := app.email.send(msg, addresses...); err != nil {
app.err.Printf("Failed to send account deletion emails: %v", err)
} else {
app.info.Println("Sent account deletion emails")
}
}(req.Reason, addresses)
}
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()
if len(errors) == len(req.Users) { if len(errors) == len(req.Users) {
respondBool(500, false, gc) respondBool(500, false, gc)
@ -685,29 +730,21 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
func (app *appContext) Announce(gc *gin.Context) { func (app *appContext) Announce(gc *gin.Context) {
var req announcementDTO var req announcementDTO
gc.BindJSON(&req) gc.BindJSON(&req)
if !emailEnabled { if !messagesEnabled {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
addresses := []string{}
for _, userID := range req.Users {
addr, ok := app.storage.emails[userID]
if !ok || addr == "" {
continue
}
addresses = append(addresses, addr.(string))
}
msg, err := app.email.constructTemplate(req.Subject, req.Message, app) msg, err := app.email.constructTemplate(req.Subject, req.Message, app)
if err != nil { if err != nil {
app.err.Printf("Failed to construct announcement emails: %v", err) app.err.Printf("Failed to construct announcement messages: %v", err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} else if err := app.email.send(msg, addresses...); err != nil { } else if err := app.sendByID(msg, req.Users...); err != nil {
app.err.Printf("Failed to send announcement emails: %v", err) app.err.Printf("Failed to send announcement messages: %v", err)
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
app.info.Printf("Sent announcement email to %d users", len(addresses)) app.info.Println("Sent announcement messages")
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -1137,7 +1174,10 @@ func (app *appContext) GetUsers(gc *gin.Context) {
if ok { if ok {
user.Expiry = expiry.Unix() user.Expiry = expiry.Unix()
} }
if tgUser, ok := app.storage.telegram[jfUser.ID]; ok {
user.Telegram = tgUser.Username
user.NotifyThroughTelegram = tgUser.Contact
}
resp.UserList[i] = user resp.UserList[i] = user
i++ i++
} }
@ -1345,6 +1385,10 @@ func (app *appContext) GetConfig(gc *gin.Context) {
el := resp.Sections["email"].Settings["language"] el := resp.Sections["email"].Settings["language"]
el.Options = emailOptions el.Options = emailOptions
el.Value = app.config.Section("email").Key("language").MustString("en-us") el.Value = app.config.Section("email").Key("language").MustString("en-us")
telegramOptions := app.storage.lang.Email.getOptions()
tl := resp.Sections["telegram"].Settings["language"]
tl.Options = telegramOptions
tl.Value = app.config.Section("telegram").Key("language").MustString("en-us")
if updater == "" { if updater == "" {
delete(resp.Sections, "updates") delete(resp.Sections, "updates")
for i, v := range resp.Order { for i, v := range resp.Order {
@ -1373,6 +1417,7 @@ func (app *appContext) GetConfig(gc *gin.Context) {
resp.Sections["ui"].Settings["language-admin"] = al resp.Sections["ui"].Settings["language-admin"] = al
resp.Sections["email"].Settings["language"] = el resp.Sections["email"].Settings["language"] = el
resp.Sections["password_resets"].Settings["language"] = pl resp.Sections["password_resets"].Settings["language"] = pl
resp.Sections["telegram"].Settings["language"] = tl
gc.JSON(200, resp) gc.JSON(200, resp)
} }
@ -1397,6 +1442,9 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
tempConfig.NewSection(section) tempConfig.NewSection(section)
} }
for setting, value := range settings.(map[string]interface{}) { for setting, value := range settings.(map[string]interface{}) {
if section == "email" && setting == "method" && value == "disabled" {
value = ""
}
if value.(string) != app.config.Section(section).Key(setting).MustString("") { if value.(string) != app.config.Section(section).Key(setting).MustString("") {
tempConfig.Section(section).Key(setting).SetValue(value.(string)) tempConfig.Section(section).Key(setting).SetValue(value.(string))
} }
@ -1438,6 +1486,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
// @Param lang query string false "Language for email titles." // @Param lang query string false "Language for email titles."
// @Success 200 {object} emailListDTO // @Success 200 {object} emailListDTO
// @Router /config/emails [get] // @Router /config/emails [get]
// @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) GetCustomEmails(gc *gin.Context) { func (app *appContext) GetCustomEmails(gc *gin.Context) {
lang := gc.Query("lang") lang := gc.Query("lang")
@ -1466,6 +1515,7 @@ func (app *appContext) GetCustomEmails(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param id path string true "ID of email" // @Param id path string true "ID of email"
// @Router /config/emails/{id} [post] // @Router /config/emails/{id} [post]
// @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) SetCustomEmail(gc *gin.Context) { func (app *appContext) SetCustomEmail(gc *gin.Context) {
var req customEmail var req customEmail
@ -1525,6 +1575,7 @@ func (app *appContext) SetCustomEmail(gc *gin.Context) {
// @Param enable/disable path string true "enable/disable" // @Param enable/disable path string true "enable/disable"
// @Param id path string true "ID of email" // @Param id path string true "ID of email"
// @Router /config/emails/{id}/state/{enable/disable} [post] // @Router /config/emails/{id}/state/{enable/disable} [post]
// @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) SetCustomEmailState(gc *gin.Context) { func (app *appContext) SetCustomEmailState(gc *gin.Context) {
id := gc.Param("id") id := gc.Param("id")
@ -1574,13 +1625,14 @@ func (app *appContext) SetCustomEmailState(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param id path string true "ID of email" // @Param id path string true "ID of email"
// @Router /config/emails/{id} [get] // @Router /config/emails/{id} [get]
// @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
lang := app.storage.lang.chosenEmailLang lang := app.storage.lang.chosenEmailLang
id := gc.Param("id") id := gc.Param("id")
var content string var content string
var err error var err error
var msg *Email var msg *Message
var variables []string var variables []string
var conditionals []string var conditionals []string
var values map[string]interface{} var values map[string]interface{}
@ -1762,6 +1814,7 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
// @Produce json // @Produce json
// @Success 200 {object} checkUpdateDTO // @Success 200 {object} checkUpdateDTO
// @Router /config/update [get] // @Router /config/update [get]
// @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) CheckUpdate(gc *gin.Context) { func (app *appContext) CheckUpdate(gc *gin.Context) {
if !app.newUpdate { if !app.newUpdate {
@ -1776,6 +1829,7 @@ func (app *appContext) CheckUpdate(gc *gin.Context) {
// @Success 400 {object} stringResponse // @Success 400 {object} stringResponse
// @Success 500 {object} boolResponse // @Success 500 {object} boolResponse
// @Router /config/update [post] // @Router /config/update [post]
// @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) ApplyUpdate(gc *gin.Context) { func (app *appContext) ApplyUpdate(gc *gin.Context) {
if !app.update.CanUpdate { if !app.update.CanUpdate {
@ -1801,6 +1855,7 @@ func (app *appContext) ApplyUpdate(gc *gin.Context) {
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse // @Failure 500 {object} stringResponse
// @Router /logout [post] // @Router /logout [post]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) Logout(gc *gin.Context) { func (app *appContext) Logout(gc *gin.Context) {
cookie, err := gc.Cookie("refresh") cookie, err := gc.Cookie("refresh")
@ -1874,8 +1929,162 @@ func (app *appContext) ServeLang(gc *gin.Context) {
respondBool(400, false, gc) respondBool(400, false, gc)
} }
// @Summary Returns a new Telegram verification PIN, and the bot username.
// @Produce json
// @Success 200 {object} telegramPinDTO
// @Router /telegram/pin [get]
// @Security Bearer
// @tags Other
func (app *appContext) TelegramGetPin(gc *gin.Context) {
gc.JSON(200, telegramPinDTO{
Token: app.telegram.NewAuthToken(),
Username: app.telegram.username,
})
}
// @Summary Link a Jellyfin & Telegram user together via a verification PIN.
// @Produce json
// @Param telegramSetDTO body telegramSetDTO true "Token and user's Jellyfin ID."
// @Success 200 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Router /users/telegram [post]
// @Security Bearer
// @tags Other
func (app *appContext) TelegramAddUser(gc *gin.Context) {
var req telegramSetDTO
gc.BindJSON(&req)
if req.Token == "" || req.ID == "" {
respondBool(400, false, gc)
return
}
tokenIndex := -1
for i, v := range app.telegram.verifiedTokens {
if v.Token == req.Token {
tokenIndex = i
break
}
}
if tokenIndex == -1 {
respondBool(500, false, gc)
return
}
tgToken := app.telegram.verifiedTokens[tokenIndex]
tgUser := TelegramUser{
ChatID: tgToken.ChatID,
Username: tgToken.Username,
Contact: true,
}
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
tgUser.Lang = lang
}
if app.storage.telegram == nil {
app.storage.telegram = map[string]TelegramUser{}
}
app.storage.telegram[req.ID] = tgUser
err := app.storage.storeTelegramUsers()
if err != nil {
app.err.Printf("Failed to store Telegram users: %v", err)
} else {
app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1]
app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1]
}
respondBool(200, true, gc)
}
// @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."
// @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
gc.BindJSON(&req)
if req.ID == "" {
respondBool(400, false, gc)
return
}
if tgUser, ok := app.storage.telegram[req.ID]; ok {
tgUser.Contact = req.Enabled
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"
}
app.debug.Printf("Telegram: User \"%s\" will %s be notified through Telegram.", tgUser.Username, msg)
return
}
app.err.Printf("Telegram: User \"%s\" does not have a telegram account registered.", req.ID)
respondBool(400, false, gc)
}
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires bearer auth.
// @Produce json
// @Success 200 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Router /telegram/verified/{pin} [get]
// @Security Bearer
// @tags Other
func (app *appContext) TelegramVerified(gc *gin.Context) {
pin := gc.Param("pin")
tokenIndex := -1
for i, v := range app.telegram.verifiedTokens {
if v.Token == pin {
tokenIndex = i
break
}
}
// if tokenIndex != -1 {
// length := len(app.telegram.verifiedTokens)
// app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1]
// app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1]
// }
respondBool(200, tokenIndex != -1, gc)
}
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code.
// @Produce json
// @Success 200 {object} boolResponse
// @Success 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Param invCode path string true "invite Code"
// @Router /invite/{invCode}/telegram/verified/{pin} [get]
// @tags Other
func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok {
respondBool(401, false, gc)
return
}
pin := gc.Param("pin")
tokenIndex := -1
for i, v := range app.telegram.verifiedTokens {
if v.Token == pin {
tokenIndex = i
break
}
}
// if tokenIndex != -1 {
// length := len(app.telegram.verifiedTokens)
// app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1]
// app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1]
// }
respondBool(200, tokenIndex != -1, gc)
}
// @Summary Restarts the program. No response means success. // @Summary Restarts the program. No response means success.
// @Router /restart [post] // @Router /restart [post]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) restart(gc *gin.Context) { func (app *appContext) restart(gc *gin.Context) {
app.info.Println("Restarting...") app.info.Println("Restarting...")

View File

@ -12,6 +12,8 @@ import (
) )
var emailEnabled = false var emailEnabled = false
var messagesEnabled = false
var telegramEnabled = false
func (app *appContext) GetPath(sect, key string) (fs.FS, string) { func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
val := app.config.Section(sect).Key(key).MustString("") val := app.config.Section(sect).Key(key).MustString("")
@ -40,7 +42,7 @@ func (app *appContext) loadConfig() error {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
} }
} }
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users"} { for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json")))) app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
} }
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
@ -83,12 +85,19 @@ func (app *appContext) loadConfig() error {
app.config.Section("jellyfin").Key("version").SetValue(version) app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go") app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit)) 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)
if app.config.Section("email").Key("method").MustString("") == "" { telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
if !messagesEnabled {
emailEnabled = false
telegramEnabled = false
} else if app.config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false emailEnabled = false
} else { } else {
emailEnabled = true emailEnabled = true
} }
if !emailEnabled && !telegramEnabled {
messagesEnabled = false
}
app.MustSetValue("updates", "enabled", "true") app.MustSetValue("updates", "enabled", "true")
releaseChannel := app.config.Section("updates").Key("channel").String() releaseChannel := app.config.Section("updates").Key("channel").String()
@ -128,8 +137,34 @@ func (app *appContext) loadConfig() error {
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us") app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us") app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us") app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us")
app.storage.lang.chosenTelegramLang = app.config.Section("telegram").Key("language").MustString("en-us")
app.email = NewEmailer(app) app.email = NewEmailer(app)
return nil return nil
} }
func (app *appContext) migrateEmailConfig() {
tempConfig, _ := ini.Load(app.configPath)
fmt.Println(warning("Part of your email configuration will be migrated to the new \"messages\" section.\nA backup will be made."))
err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak")
if err != nil {
app.err.Fatalf("Failed to backup config: %v", err)
return
}
for _, setting := range []string{"use_24h", "date_format", "message"} {
if val := app.config.Section("email").Key(setting).Value(); val != "" {
tempConfig.Section("email").Key(setting).SetValue("")
tempConfig.Section("messages").Key(setting).SetValue(val)
}
}
if app.config.Section("messages").Key("enabled").MustBool(false) || app.config.Section("telegram").Key("enabled").MustBool(false) {
tempConfig.Section("messages").Key("enabled").SetValue("true")
}
err = tempConfig.SaveTo(app.configPath)
if err != nil {
app.err.Fatalf("Failed to save config: %v", err)
return
}
app.loadConfig()
}

View File

@ -345,33 +345,20 @@
} }
} }
}, },
"email": { "messages": {
"order": [], "order": [],
"meta": { "meta": {
"name": "Email", "name": "Messages/Notifications",
"description": "General email settings." "description": "General settings for emails/messages."
}, },
"settings": { "settings": {
"language": { "enabled": {
"name": "Email Language", "name": "Enabled",
"required": false, "required": true,
"requires_restart": false, "requires_restart": true,
"depends_true": "method",
"type": "select",
"options": [
["en-us", "English (US)"]
],
"value": "en-us",
"description": "Default email language. Submit a PR on github if you'd like to translate."
},
"no_username": {
"name": "Use email addresses as username",
"required": false,
"requires_restart": false,
"depends_true": "method",
"type": "bool", "type": "bool",
"value": false, "value": true,
"description": "Use email address from invite form as username on Jellyfin." "description": "Enable the sending of emails/messages such as password resets, announcements, etc."
}, },
"use_24h": { "use_24h": {
"name": "Use 24h time", "name": "Use 24h time",
@ -399,6 +386,37 @@
"type": "text", "type": "text",
"value": "Need help? contact me.", "value": "Need help? contact me.",
"description": "Message displayed at bottom of emails." "description": "Message displayed at bottom of emails."
}
}
},
"email": {
"order": [],
"meta": {
"name": "Email",
"description": "General email settings.",
"depends_true": "messages|enabled"
},
"settings": {
"language": {
"name": "Email Language",
"required": false,
"requires_restart": false,
"depends_true": "method",
"type": "select",
"options": [
["en-us", "English (US)"]
],
"value": "en-us",
"description": "Default email language. Submit a PR on github if you'd like to translate."
},
"no_username": {
"name": "Use email addresses as username",
"required": false,
"requires_restart": false,
"depends_true": "method",
"type": "bool",
"value": false,
"description": "Use email address from invite form as username on Jellyfin."
}, },
"method": { "method": {
"name": "Email method", "name": "Email method",
@ -443,12 +461,143 @@
} }
} }
}, },
"mailgun": {
"order": [],
"meta": {
"name": "Mailgun (Email)",
"description": "Mailgun API connection settings",
"depends_true": "email|method"
},
"settings": {
"api_url": {
"name": "API URL",
"required": false,
"requires_restart": false,
"type": "text",
"value": "https://api.mailgun.net..."
},
"api_key": {
"name": "API Key",
"required": false,
"requires_restart": false,
"type": "text",
"value": "your api key"
}
}
},
"smtp": {
"order": [],
"meta": {
"name": "SMTP (Email)",
"description": "SMTP Server connection settings.",
"depends_true": "email|method"
},
"settings": {
"username": {
"name": "Username",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Username for SMTP. Leave blank to user send from address as username."
},
"encryption": {
"name": "Encryption Method",
"required": false,
"requires_restart": false,
"type": "select",
"options": [
["ssl_tls", "SSL/TLS"],
["starttls", "STARTTLS"]
],
"value": "starttls",
"description": "Your email provider should provide different ports for each encryption method. Generally 465 for ssl_tls, 587 for starttls."
},
"server": {
"name": "Server address",
"required": false,
"requires_restart": false,
"type": "text",
"value": "smtp.jellyf.in",
"description": "SMTP Server address."
},
"port": {
"name": "Port",
"required": false,
"requires_restart": false,
"type": "number",
"value": 465
},
"password": {
"name": "Password",
"required": false,
"requires_restart": false,
"type": "password",
"value": "smtp password"
},
"ssl_cert": {
"name": "Path to custom SSL certificate",
"required": false,
"requires_restart": false,
"advanced": true,
"type": "text",
"value": "",
"description": "Use if your SMTP server's SSL Certificate is not trusted by the system."
}
}
},
"telegram": {
"order": [],
"meta": {
"name": "Telegram",
"description": "Settings for Telegram signup/notifications"
},
"settings": {
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Enable signup verification through Telegram 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,
"type": "bool",
"value": false,
"description": "Require telegram connection on sign-up."
},
"token": {
"name": "API Token",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Telegram Bot API Token."
},
"language": {
"name": "Language",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "select",
"options": [
["en-us", "English (US)"]
],
"value": "en-us",
"description": "Default telegram message language. Visit weblate if you'd like to translate."
}
}
},
"password_resets": { "password_resets": {
"order": [], "order": [],
"meta": { "meta": {
"name": "Password Resets", "name": "Password Resets",
"description": "Settings for the password reset handler.", "description": "Settings for the password reset handler.",
"depends_true": "email|method" "depends_true": "messages|enabled"
}, },
"settings": { "settings": {
"enabled": { "enabled": {
@ -580,7 +729,7 @@
"meta": { "meta": {
"name": "Notifications", "name": "Notifications",
"description": "Notification related settings.", "description": "Notification related settings.",
"depends_true": "email|method" "depends_true": "messages|enabled"
}, },
"settings": { "settings": {
"enabled": { "enabled": {
@ -633,91 +782,6 @@
} }
} }
}, },
"mailgun": {
"order": [],
"meta": {
"name": "Mailgun (Email)",
"description": "Mailgun API connection settings",
"depends_true": "email|method"
},
"settings": {
"api_url": {
"name": "API URL",
"required": false,
"requires_restart": false,
"type": "text",
"value": "https://api.mailgun.net..."
},
"api_key": {
"name": "API Key",
"required": false,
"requires_restart": false,
"type": "text",
"value": "your api key"
}
}
},
"smtp": {
"order": [],
"meta": {
"name": "SMTP (Email)",
"description": "SMTP Server connection settings.",
"depends_true": "email|method"
},
"settings": {
"username": {
"name": "Username",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Username for SMTP. Leave blank to user send from address as username."
},
"encryption": {
"name": "Encryption Method",
"required": false,
"requires_restart": false,
"type": "select",
"options": [
["ssl_tls", "SSL/TLS"],
["starttls", "STARTTLS"]
],
"value": "starttls",
"description": "Your email provider should provide different ports for each encryption method. Generally 465 for ssl_tls, 587 for starttls."
},
"server": {
"name": "Server address",
"required": false,
"requires_restart": false,
"type": "text",
"value": "smtp.jellyf.in",
"description": "SMTP Server address."
},
"port": {
"name": "Port",
"required": false,
"requires_restart": false,
"type": "number",
"value": 465
},
"password": {
"name": "Password",
"required": false,
"requires_restart": false,
"type": "password",
"value": "smtp password"
},
"ssl_cert": {
"name": "Path to custom SSL certificate",
"required": false,
"requires_restart": false,
"advanced": true,
"type": "text",
"value": "",
"description": "Use if your SMTP server's SSL Certificate is not trusted by the system."
}
}
},
"ombi": { "ombi": {
"order": [], "order": [],
"meta": { "meta": {
@ -756,9 +820,9 @@
"welcome_email": { "welcome_email": {
"order": [], "order": [],
"meta": { "meta": {
"name": "Welcome Emails", "name": "Welcome Message",
"description": "Optionally send a welcome email to new users with the Jellyfin URL and their username.", "description": "Optionally send a welcome message to new users with the Jellyfin URL and their username.",
"depends_true": "email|method" "depends_true": "messages|enabled"
}, },
"settings": { "settings": {
"enabled": { "enabled": {
@ -865,14 +929,14 @@
"requires_restart": false, "requires_restart": false,
"type": "bool", "type": "bool",
"value": true, "value": true,
"depends_true": "email|method", "depends_true": "messages|enabled",
"description": "Send an email when a user's account expires." "description": "Send an email when a user's account expires."
}, },
"subject": { "subject": {
"name": "Email subject", "name": "Email subject",
"required": false, "required": false,
"requires_restart": false, "requires_restart": false,
"depends_true": "email|method", "depends_true": "messages|enabled",
"type": "text", "type": "text",
"value": "", "value": "",
"description": "Subject of user expiry emails." "description": "Subject of user expiry emails."
@ -882,7 +946,7 @@
"required": false, "required": false,
"requires_restart": false, "requires_restart": false,
"advanced": true, "advanced": true,
"depends_true": "email|method", "depends_true": "messages|enabled",
"type": "text", "type": "text",
"value": "", "value": "",
"description": "Path to custom email html" "description": "Path to custom email html"
@ -892,7 +956,7 @@
"required": false, "required": false,
"requires_restart": false, "requires_restart": false,
"advanced": true, "advanced": true,
"depends_true": "email|method", "depends_true": "messages|enabled",
"type": "text", "type": "text",
"value": "", "value": "",
"description": "Path to custom email in plain text" "description": "Path to custom email in plain text"
@ -904,7 +968,7 @@
"meta": { "meta": {
"name": "Account Disabling/Enabling", "name": "Account Disabling/Enabling",
"description": "Subject/email files for account disabling/enabling emails.", "description": "Subject/email files for account disabling/enabling emails.",
"depends_true": "email|method" "depends_true": "messages|enabled"
}, },
"settings": { "settings": {
"subject_disabled": { "subject_disabled": {
@ -966,7 +1030,7 @@
"meta": { "meta": {
"name": "Account Deletion", "name": "Account Deletion",
"description": "Subject/email files for account deletion emails.", "description": "Subject/email files for account deletion emails.",
"depends_true": "email|method" "depends_true": "messages|enabled"
}, },
"settings": { "settings": {
"subject": { "subject": {
@ -1068,6 +1132,14 @@
"type": "text", "type": "text",
"value": "", "value": "",
"description": "JSON file generated by program in settings, different from email_html/email_text. See wiki for more info." "description": "JSON file generated by program in settings, different from email_html/email_text. See wiki for more info."
},
"telegram_users": {
"name": "Telegram users",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Stores telegram user IDs and language preferences."
} }
} }
} }

View File

@ -39,6 +39,11 @@
} }
} }
.chip.btn:hover:not([disabled]):not(.textarea),
.chip.btn:focus:not([disabled]):not(.textarea) {
filter: brightness(var(--button-filter-brightness,95%));
}
.banner { .banner {
margin: calc(-1 * var(--spacing-4,1rem)); margin: calc(-1 * var(--spacing-4,1rem));
} }
@ -121,6 +126,10 @@ div.card:contains(section.banner.footer) {
text-align: right; text-align: right;
} }
.ac {
text-align: center;
}
.inline-block { .inline-block {
display: inline-block; display: inline-block;
} }
@ -162,6 +171,12 @@ div.card:contains(section.banner.footer) {
margin: 0.5rem; margin: 0.5rem;
} }
p.sm,
span.sm {
font-size: 0.75rem;
}
.col.sm { .col.sm {
margin: .25rem; margin: .25rem;
} }
@ -459,6 +474,11 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) {
color: var(--color-urge-200); color: var(--color-urge-200);
} }
.link-center {
display: block;
text-align: center;
}
.search { .search {
max-width: 15rem; max-width: 15rem;
min-width: 10rem; min-width: 10rem;

188
email.go
View File

@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
"io/fs"
"net/smtp" "net/smtp"
"os" "os"
"strconv" "strconv"
@ -25,13 +26,13 @@ import (
) )
// implements email sending, right now via smtp or mailgun. // implements email sending, right now via smtp or mailgun.
type emailClient interface { type EmailClient interface {
send(fromName, fromAddr string, email *Email, address ...string) error Send(fromName, fromAddr string, message *Message, address ...string) error
} }
type dummyClient struct{} type DummyClient struct{}
func (dc *dummyClient) send(fromName, fromAddr string, email *Email, address ...string) error { func (dc *DummyClient) Send(fromName, fromAddr string, email *Message, address ...string) error {
fmt.Printf("FROM: %s <%s>\nTO: %s\nTEXT: %s\n", fromName, fromAddr, strings.Join(address, ", "), email.Text) fmt.Printf("FROM: %s <%s>\nTO: %s\nTEXT: %s\n", fromName, fromAddr, strings.Join(address, ", "), email.Text)
return nil return nil
} }
@ -41,7 +42,7 @@ type Mailgun struct {
client *mailgun.MailgunImpl client *mailgun.MailgunImpl
} }
func (mg *Mailgun) send(fromName, fromAddr string, email *Email, address ...string) error { func (mg *Mailgun) Send(fromName, fromAddr string, email *Message, address ...string) error {
message := mg.client.NewMessage( message := mg.client.NewMessage(
fmt.Sprintf("%s <%s>", fromName, fromAddr), fmt.Sprintf("%s <%s>", fromName, fromAddr),
email.Subject, email.Subject,
@ -67,7 +68,7 @@ type SMTP struct {
tlsConfig *tls.Config tlsConfig *tls.Config
} }
func (sm *SMTP) send(fromName, fromAddr string, email *Email, address ...string) error { func (sm *SMTP) Send(fromName, fromAddr string, email *Message, address ...string) error {
server := fmt.Sprintf("%s:%d", sm.server, sm.port) server := fmt.Sprintf("%s:%d", sm.server, sm.port)
from := fmt.Sprintf("%s <%s>", fromName, fromAddr) from := fmt.Sprintf("%s <%s>", fromName, fromAddr)
var wg sync.WaitGroup var wg sync.WaitGroup
@ -93,18 +94,19 @@ func (sm *SMTP) send(fromName, fromAddr string, email *Email, address ...string)
return err return err
} }
// Emailer contains the email sender, email content, and methods to construct message content. // Emailer contains the email sender, translations, and methods to construct messages.
type Emailer struct { type Emailer struct {
fromAddr, fromName string fromAddr, fromName string
lang emailLang lang emailLang
sender emailClient sender EmailClient
} }
// Email stores content. // Message stores content.
type Email struct { type Message struct {
Subject string `json:"subject"` Subject string `json:"subject"`
HTML string `json:"html"` HTML string `json:"html"`
Text string `json:"text"` Text string `json:"text"`
Markdown string `json:"markdown"`
} }
func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) { func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) {
@ -154,7 +156,7 @@ func NewEmailer(app *appContext) *Emailer {
} else if method == "mailgun" { } else if method == "mailgun" {
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String()) emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String())
} else if method == "dummy" { } else if method == "dummy" {
emailer.sender = &dummyClient{} emailer.sender = &DummyClient{}
} }
return emailer return emailer
} }
@ -204,7 +206,7 @@ type templ interface {
Execute(wr io.Writer, data interface{}) error Execute(wr io.Writer, data interface{}) error
} }
func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data map[string]interface{}) (html, text string, err error) { func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data map[string]interface{}) (html, text, markdown string, err error) {
var tpl templ var tpl templ
if substituteStrings == "" { if substituteStrings == "" {
data["jellyfin"] = "Jellyfin" data["jellyfin"] = "Jellyfin"
@ -212,14 +214,31 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
data["jellyfin"] = substituteStrings data["jellyfin"] = substituteStrings
} }
var keys []string var keys []string
if app.config.Section("email").Key("plaintext").MustBool(false) { plaintext := app.config.Section("email").Key("plaintext").MustBool(false)
keys = []string{"text"} telegram := app.config.Section("telegram").Key("enabled").MustBool(false)
text = "" if plaintext {
if telegram {
keys = []string{"text"}
text, markdown = "", ""
} else {
keys = []string{"text"}
text = ""
}
} else { } else {
keys = []string{"html", "text"} if telegram {
keys = []string{"html", "text", "markdown"}
} else {
keys = []string{"html", "text"}
}
} }
for _, key := range keys { for _, key := range keys {
filesystem, fpath := app.GetPath(section, keyFragment+key) var filesystem fs.FS
var fpath string
if key == "markdown" {
filesystem, fpath = app.GetPath(section, keyFragment+"text")
} else {
filesystem, fpath = app.GetPath(section, keyFragment+key)
}
if key == "html" { if key == "html" {
tpl, err = template.ParseFS(filesystem, fpath) tpl, err = template.ParseFS(filesystem, fpath)
} else { } else {
@ -228,15 +247,28 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
if err != nil { if err != nil {
return return
} }
// For constructTemplate, if "md" is found in data it's used in stead of "text".
foundMarkdown := false
if key == "markdown" {
_, foundMarkdown = data["md"]
if foundMarkdown {
data["plaintext"], data["md"] = data["md"], data["plaintext"]
}
}
var tplData bytes.Buffer var tplData bytes.Buffer
err = tpl.Execute(&tplData, data) err = tpl.Execute(&tplData, data)
if err != nil { if err != nil {
return return
} }
if foundMarkdown {
data["plaintext"], data["md"] = data["md"], data["plaintext"]
}
if key == "html" { if key == "html" {
html = tplData.String() html = tplData.String()
} else { } else if key == "text" {
text = tplData.String() text = tplData.String()
} else {
markdown = tplData.String()
} }
} }
return return
@ -257,7 +289,7 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC
template[v] = "{" + v + "}" template[v] = "{" + v + "}"
} }
} else { } else {
message := app.config.Section("email").Key("message").String() message := app.config.Section("messages").Key("message").String()
inviteLink := app.config.Section("invite_emails").Key("url_base").String() inviteLink := app.config.Section("invite_emails").Key("url_base").String()
inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key) inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key)
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username}) template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
@ -267,8 +299,8 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC
return template return template
} }
func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")), Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")),
} }
var err error var err error
@ -282,7 +314,7 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "email_confirmation", "email_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "email_confirmation", "email_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -290,17 +322,18 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a
return email, nil return email, nil
} }
func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (*Email, error) { func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (*Message, error) {
email := &Email{Subject: subject} email := &Message{Subject: subject}
renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
html := markdown.ToHTML([]byte(md), nil, renderer) html := markdown.ToHTML([]byte(md), nil, renderer)
text := stripMarkdown(md) text := stripMarkdown(md)
message := app.config.Section("email").Key("message").String() message := app.config.Section("messages").Key("message").String()
var err error var err error
email.HTML, email.Text, err = emailer.construct(app, "template_email", "email_", map[string]interface{}{ email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "template_email", "email_", map[string]interface{}{
"text": template.HTML(html), "text": template.HTML(html),
"plaintext": text, "plaintext": text,
"message": message, "message": message,
"md": md,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -311,7 +344,7 @@ func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (
func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} { func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
expiry := invite.ValidTill expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern) d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String() message := app.config.Section("messages").Key("message").String()
inviteLink := app.config.Section("invite_emails").Key("url_base").String() inviteLink := app.config.Section("invite_emails").Key("url_base").String()
inviteLink = fmt.Sprintf("%s/%s", inviteLink, code) inviteLink = fmt.Sprintf("%s/%s", inviteLink, code)
template := map[string]interface{}{ template := map[string]interface{}{
@ -338,8 +371,8 @@ func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext
return template return template
} }
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")), Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
} }
template := emailer.inviteValues(code, invite, app, noSub) template := emailer.inviteValues(code, invite, app, noSub)
@ -353,7 +386,7 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "invite_emails", "email_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "invite_emails", "email_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -377,8 +410,8 @@ func (emailer *Emailer) expiryValues(code string, invite Invite, app *appContext
return template return template
} }
func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: emailer.lang.InviteExpiry.get("title"), Subject: emailer.lang.InviteExpiry.get("title"),
} }
var err error var err error
@ -392,7 +425,7 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "notifications", "expiry_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "expiry_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -431,8 +464,8 @@ func (emailer *Emailer) createdValues(code, username, address string, invite Inv
return template return template
} }
func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: emailer.lang.UserCreated.get("title"), Subject: emailer.lang.UserCreated.get("title"),
} }
template := emailer.createdValues(code, username, address, invite, app, noSub) template := emailer.createdValues(code, username, address, invite, app, noSub)
@ -446,7 +479,7 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "notifications", "created_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "created_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -456,7 +489,7 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} { func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} {
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern) d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String() message := app.config.Section("messages").Key("message").String()
template := map[string]interface{}{ template := map[string]interface{}{
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"), "someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"), "ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
@ -505,8 +538,8 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo
return template return template
} }
func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")), Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")),
} }
template := emailer.resetValues(pwr, app, noSub) template := emailer.resetValues(pwr, app, noSub)
@ -520,7 +553,7 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "password_resets", "email_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "password_resets", "email_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -541,13 +574,13 @@ func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool
} }
} else { } else {
template["reason"] = reason template["reason"] = reason
template["message"] = app.config.Section("email").Key("message").String() template["message"] = app.config.Section("messages").Key("message").String()
} }
return template return template
} }
func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")), Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")),
} }
var err error var err error
@ -561,7 +594,7 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "deletion", "email_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "deletion", "email_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -582,13 +615,13 @@ func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub boo
} }
} else { } else {
template["reason"] = reason template["reason"] = reason
template["message"] = app.config.Section("email").Key("message").String() template["message"] = app.config.Section("messages").Key("message").String()
} }
return template return template
} }
func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")), Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")),
} }
var err error var err error
@ -602,7 +635,7 @@ func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "disable_enable", "disabled_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "disabled_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -623,13 +656,13 @@ func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool
} }
} else { } else {
template["reason"] = reason template["reason"] = reason
template["message"] = app.config.Section("email").Key("message").String() template["message"] = app.config.Section("messages").Key("message").String()
} }
return template return template
} }
func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")), Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")),
} }
var err error var err error
@ -643,7 +676,7 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "disable_enable", "enabled_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "enabled_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -668,7 +701,7 @@ func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *ap
} else { } else {
template["jellyfinURL"] = app.config.Section("jellyfin").Key("public_server").String() template["jellyfinURL"] = app.config.Section("jellyfin").Key("public_server").String()
template["username"] = username template["username"] = username
template["message"] = app.config.Section("email").Key("message").String() template["message"] = app.config.Section("messages").Key("message").String()
exp := app.formatDatetime(expiry) exp := app.formatDatetime(expiry)
if !expiry.IsZero() { if !expiry.IsZero() {
if custom { if custom {
@ -683,8 +716,8 @@ func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *ap
return template return template
} }
func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")), Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
} }
var err error var err error
@ -708,7 +741,7 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "welcome_email", "email_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "welcome_email", "email_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -723,13 +756,13 @@ func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[strin
"message": "", "message": "",
} }
if !noSub { if !noSub {
template["message"] = app.config.Section("email").Key("message").String() template["message"] = app.config.Section("messages").Key("message").String()
} }
return template return template
} }
func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Email, error) { func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Message, error) {
email := &Email{ email := &Message{
Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")), Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")),
} }
var err error var err error
@ -743,7 +776,7 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Emai
) )
email, err = emailer.constructTemplate(email.Subject, content, app) email, err = emailer.constructTemplate(email.Subject, content, app)
} else { } else {
email.HTML, email.Text, err = emailer.construct(app, "user_expiry", "email_", template) email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "email_", template)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -752,6 +785,31 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Emai
} }
// calls the send method in the underlying emailClient. // calls the send method in the underlying emailClient.
func (emailer *Emailer) send(email *Email, address ...string) error { func (emailer *Emailer) send(email *Message, address ...string) error {
return emailer.sender.send(emailer.fromName, emailer.fromAddr, email, address...) return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...)
}
func (app *appContext) sendByID(email *Message, ID ...string) error {
for _, id := range ID {
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
}
}
return nil
}
func (app *appContext) getAddressOrName(jfID string) string {
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 ""
} }

13
go.mod
View File

@ -17,29 +17,28 @@ require (
github.com/gin-contrib/pprof v1.3.0 github.com/gin-contrib/pprof v1.3.0
github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e
github.com/gin-gonic/gin v1.6.3 github.com/gin-gonic/gin v1.6.3
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-openapi/spec v0.20.3 // indirect github.com/go-openapi/spec v0.20.3 // indirect
github.com/go-openapi/swag v0.19.15 // indirect github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/validator/v10 v10.4.1 // indirect github.com/go-playground/validator/v10 v10.4.1 // indirect
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect
github.com/golang/protobuf v1.4.3 // indirect github.com/golang/protobuf v1.4.3 // indirect
github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8 github.com/gomarkdown/markdown v0.0.0-20210408062403-ad838ccf8cdd
github.com/google/uuid v1.1.2 // indirect github.com/google/uuid v1.1.2 // indirect
github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc
github.com/hrfee/jfa-go/docs v0.0.0-20201112212552-b6f3cd7c1f71 github.com/hrfee/jfa-go/docs v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/jfa-go/logger v0.0.0-00010101000000-000000000000 // indirect github.com/hrfee/jfa-go/logger v0.0.0-00010101000000-000000000000
github.com/hrfee/jfa-go/ombi v0.0.0-20201112212552-b6f3cd7c1f71 github.com/hrfee/jfa-go/ombi v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/mediabrowser v0.3.3 github.com/hrfee/mediabrowser v0.3.3
github.com/itchyny/timefmt-go v0.1.2 github.com/itchyny/timefmt-go v0.1.2
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/lithammer/shortuuid/v3 v3.0.4 github.com/lithammer/shortuuid/v3 v3.0.4
github.com/mailgun/mailgun-go/v4 v4.3.0 github.com/mailgun/mailgun-go/v4 v4.5.1
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/smartystreets/goconvey v1.6.4 // indirect github.com/smartystreets/goconvey v1.6.4 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14
github.com/swaggo/gin-swagger v1.3.0 github.com/swaggo/gin-swagger v1.3.0
github.com/swaggo/swag v1.7.0 // indirect github.com/swaggo/swag v1.7.0 // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/ugorji/go v1.2.0 // indirect github.com/ugorji/go v1.2.0 // indirect
github.com/writeas/go-strip-markdown v2.0.1+incompatible github.com/writeas/go-strip-markdown v2.0.1+incompatible
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect

26
go.sum
View File

@ -58,9 +58,6 @@ github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmC
github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-chi/chi v4.0.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@ -96,6 +93,8 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8=
@ -112,8 +111,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8 h1:nWU6p08f1VgIalT6iZyqXi4o5cZsz4X6qa87nusfcsc= github.com/gomarkdown/markdown v0.0.0-20210408062403-ad838ccf8cdd h1:0b8AqsWQb6A0jjx80UXLG/uMTXQkGD0IGuXWqsrNz1M=
github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU= github.com/gomarkdown/markdown v0.0.0-20210408062403-ad838ccf8cdd/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@ -127,12 +126,14 @@ github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hrfee/mediabrowser v0.3.3 h1:7E05uiol8hh2ytKn3WVLrUIvHAyifYEIy3Y5qtuNh8I= github.com/hrfee/mediabrowser v0.3.3 h1:7E05uiol8hh2ytKn3WVLrUIvHAyifYEIy3Y5qtuNh8I=
github.com/hrfee/mediabrowser v0.3.3/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/hrfee/mediabrowser v0.3.3/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/itchyny/timefmt-go v0.1.2 h1:q0Xa4P5it6K6D7ISsbLAMwx1PnWlixDcJL6/sFs93Hs= github.com/itchyny/timefmt-go v0.1.2 h1:q0Xa4P5it6K6D7ISsbLAMwx1PnWlixDcJL6/sFs93Hs=
github.com/itchyny/timefmt-go v0.1.2/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A= github.com/itchyny/timefmt-go v0.1.2/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible h1:CL0ooBNfbNyJTJATno+m0h+zM5bW6v7fKlboKUGP/dI= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@ -156,8 +157,8 @@ github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lithammer/shortuuid/v3 v3.0.4 h1:uj4xhotfY92Y1Oa6n6HUiFn87CdoEHYUlTy0+IgbLrs= github.com/lithammer/shortuuid/v3 v3.0.4 h1:uj4xhotfY92Y1Oa6n6HUiFn87CdoEHYUlTy0+IgbLrs=
github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw= github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw=
github.com/mailgun/mailgun-go/v4 v4.3.0 h1:9nAF7LI3k6bfDPbMZQMMl63Q8/vs+dr1FUN8eR1XMhk= github.com/mailgun/mailgun-go/v4 v4.5.1 h1:XrQQ/ZgqFvINRKy+eBqowLl7k3pQO6OCLpKphliMOFs=
github.com/mailgun/mailgun-go/v4 v4.3.0/go.mod h1:fWuBI2iaS/pSSyo6+EBpHjatQO3lV8onwqcRy7joSJI= github.com/mailgun/mailgun-go/v4 v4.5.1/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@ -180,9 +181,8 @@ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM=
@ -197,8 +197,6 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykE
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
@ -216,6 +214,8 @@ github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+t
github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc= github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc=
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E= github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo= github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0= github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=

View File

@ -6,6 +6,7 @@
window.URLBase = "{{ .urlBase }}"; window.URLBase = "{{ .urlBase }}";
window.notificationsEnabled = {{ .notifications }}; window.notificationsEnabled = {{ .notifications }};
window.emailEnabled = {{ .email_enabled }}; window.emailEnabled = {{ .email_enabled }};
window.telegramEnabled = {{ .telegram_enabled }};
window.ombiEnabled = {{ .ombiEnabled }}; window.ombiEnabled = {{ .ombiEnabled }};
window.usernameEnabled = {{ .username }}; window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }}); window.langFile = JSON.parse({{ .language }});
@ -179,8 +180,8 @@
</div> </div>
<div id="modal-customize" class="modal"> <div id="modal-customize" class="modal">
<div class="modal-content card"> <div class="modal-content card">
<span class="heading">{{ .strings.customizeEmails }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.customizeEmailsDescription }}</p> <p class="content">{{ .strings.customizeMessagesDescription }}</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table"> <table class="table">
<thead> <thead>
@ -308,6 +309,24 @@
<span class="button ~urge !normal full-width center" id="update-update">{{ .strings.update }}</span> <span class="button ~urge !normal full-width center" id="update-update">{{ .strings.update }}</span>
</div> </div>
</div> </div>
{{ if .telegram_enabled }}
<div id="modal-telegram" class="modal">
<div class="modal-content card">
<span class="heading mb-1">{{ .strings.linkTelegram }}</span>
<p class="content mb-1">{{ .strings.sendPIN }}</p>
<h1 class="ac" id="telegram-pin"></h1>
<a class="subheading link-center" id="telegram-link" target="_blank">
<span class="shield ~info mr-1">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;<span id="telegram-username">
</a>
<span class="button ~info !normal full-width center mt-1" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
<div id="notification-box"></div> <div id="notification-box"></div>
<span class="dropdown" tabindex="0" id="lang-dropdown"> <span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
@ -503,6 +522,9 @@
<th><input type="checkbox" value="" id="accounts-select-all"></th> <th><input type="checkbox" value="" id="accounts-select-all"></th>
<th>{{ .strings.username }}</th> <th>{{ .strings.username }}</th>
<th>{{ .strings.emailAddress }}</th> <th>{{ .strings.emailAddress }}</th>
{{ if .telegram_enabled }}
<th>Telegram</th>
{{ end }}
<th>{{ .strings.expiry }}</th> <th>{{ .strings.expiry }}</th>
<th>{{ .strings.lastActiveTime }}</th> <th>{{ .strings.lastActiveTime }}</th>
</tr> </tr>

View File

@ -14,6 +14,9 @@
window.userExpiryHours = {{ .userExpiryHours }}; window.userExpiryHours = {{ .userExpiryHours }};
window.userExpiryMinutes = {{ .userExpiryMinutes }}; window.userExpiryMinutes = {{ .userExpiryMinutes }};
window.userExpiryMessage = {{ .userExpiryMessage }}; window.userExpiryMessage = {{ .userExpiryMessage }};
window.telegramEnabled = {{ .telegramEnabled }};
window.telegramRequired = {{ .telegramRequired }};
window.telegramPIN = "{{ .telegramPIN }}";
</script> </script>
<script src="js/form.js" type="module"></script> <script src="js/form.js" type="module"></script>
{{ end }} {{ end }}

View File

@ -19,6 +19,24 @@
<p class="content mb-1">{{ .strings.confirmationRequiredMessage }}</p> <p class="content mb-1">{{ .strings.confirmationRequiredMessage }}</p>
</div> </div>
</div> </div>
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="modal-content card">
<span class="heading mb-1">{{ .strings.linkTelegram }}</span>
<p class="content mb-1">{{ .strings.sendPIN }}</p>
<h1 class="ac">{{ .telegramPIN }}</h1>
<a class="subheading link-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info mr-1">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;{{ .telegramUsername }}
</a>
<span class="button ~info !normal full-width center mt-1" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
<span class="dropdown" tabindex="0" id="lang-dropdown"> <span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
<i class="ri-global-line"></i> <i class="ri-global-line"></i>
@ -29,6 +47,7 @@
</div> </div>
</div> </div>
</span> </span>
<div id="notification-box"></div>
<div class="page-container"> <div class="page-container">
<div class="card ~neutral !low"> <div class="card ~neutral !low">
<div class="row baseline"> <div class="row baseline">
@ -48,7 +67,17 @@
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label> <label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
<input type="email" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}"> <input type="email" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
{{ if .telegramEnabled }}
<span class="button ~info !normal full-width center mb-1" id="link-telegram">{{ .strings.linkTelegram }}</span>
<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>
<label class="row switch pb-1">
<input type="radio" name="contact-via" value="telegram" id="contact-via-telegram"><span>Contact through Telegram</span>
</label>
</div>
{{ end }}
<label class="label supra" for="create-password">{{ .strings.password }}</label> <label class="label supra" for="create-password">{{ .strings.password }}</label>
<input type="password" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.password }}" id="create-password" aria-label="{{ .strings.password }}"> <input type="password" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.password }}" id="create-password" aria-label="{{ .strings.password }}">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -13,7 +13,7 @@ const binaryType = "internal"
//go:embed data data/html data/web data/web/css data/web/js //go:embed data data/html data/web data/web/css data/web/js
var loFS embed.FS var loFS embed.FS
//go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset //go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram
var laFS embed.FS var laFS embed.FS
var langFS rewriteFS var langFS rewriteFS

17
lang.go
View File

@ -136,6 +136,23 @@ func (ls *setupLangs) getOptions() [][2]string {
return opts return opts
} }
type telegramLangs map[string]telegramLang
type telegramLang struct {
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
}
func (ts *telegramLangs) getOptions() [][2]string {
opts := make([][2]string, len(*ts))
i := 0
for key, lang := range *ts {
opts[i] = [2]string{key, lang.Meta.Name}
i++
}
return opts
}
type langSection map[string]string type langSection map[string]string
type tmpl map[string]string type tmpl map[string]string

View File

@ -69,8 +69,8 @@
"preview": "Vorschau", "preview": "Vorschau",
"reset": "Zurücksetzen", "reset": "Zurücksetzen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"customizeEmails": "E-Mails anpassen", "customizeMessages": "E-Mails anpassen",
"customizeEmailsDescription": "Wenn du jfa-go's E-Mail-Vorlagen nicht benutzen willst, kannst du deinen eigenen unter Verwendung von Markdown erstellen.", "customizeMessagesDescription": "Wenn du jfa-go's E-Mail-Vorlagen nicht benutzen willst, kannst du deinen eigenen unter Verwendung von Markdown erstellen.",
"announce": "Ankündigen", "announce": "Ankündigen",
"subject": "E-Mail-Betreff", "subject": "E-Mail-Betreff",
"message": "Nachricht", "message": "Nachricht",

View File

@ -69,9 +69,9 @@
"preview": "Προεπισκόπηση", "preview": "Προεπισκόπηση",
"reset": "Επαναφορά", "reset": "Επαναφορά",
"edit": "Επεξεργασία", "edit": "Επεξεργασία",
"customizeEmails": "Παραμετροποίηση Emails", "customizeMessages": "Παραμετροποίηση Emails",
"advancedSettings": "Προχωρημένες Ρυθμίσεις", "advancedSettings": "Προχωρημένες Ρυθμίσεις",
"customizeEmailsDescription": "Αν δεν θέλετε να ζρησιμοποιήσετε τα πρότυπα email του jfa-go, μπορείτε να δημιουργήσετε τα δικά σας με χρήση Markdown.", "customizeMessagesDescription": "Αν δεν θέλετε να ζρησιμοποιήσετε τα πρότυπα email του jfa-go, μπορείτε να δημιουργήσετε τα δικά σας με χρήση Markdown.",
"updates": "Ενημερώσεις", "updates": "Ενημερώσεις",
"update": "Ενημέρωση", "update": "Ενημέρωση",
"download": "Λήψη", "download": "Λήψη",

View File

@ -46,7 +46,7 @@
"unknown": "Unknown", "unknown": "Unknown",
"label": "Label", "label": "Label",
"announce": "Announce", "announce": "Announce",
"subject": "Email Subject", "subject": "Subject",
"message": "Message", "message": "Message",
"variables": "Variables", "variables": "Variables",
"conditionals": "Conditionals", "conditionals": "Conditionals",
@ -54,14 +54,15 @@
"reset": "Reset", "reset": "Reset",
"edit": "Edit", "edit": "Edit",
"donate": "Donate", "donate": "Donate",
"contactThrough": "Contact through:",
"extendExpiry": "Extend expiry", "extendExpiry": "Extend expiry",
"customizeEmails": "Customize Emails", "customizeMessages": "Customize Messages",
"customizeEmailsDescription": "If you don't want to use jfa-go's email templates, you can create your own using Markdown.", "customizeMessagesDescription": "If you don't want to use jfa-go's message templates, you can create your own using Markdown.",
"markdownSupported": "Markdown is supported.", "markdownSupported": "Markdown is supported.",
"modifySettings": "Modify Settings", "modifySettings": "Modify Settings",
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.", "modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
"applyHomescreenLayout": "Apply homescreen layout", "applyHomescreenLayout": "Apply homescreen layout",
"sendDeleteNotificationEmail": "Send notification email", "sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.", "sendDeleteNotifiationExample": "Your account has been deleted.",
"settingsRestart": "Restart", "settingsRestart": "Restart",
"settingsRestarting": "Restarting…", "settingsRestarting": "Restarting…",
@ -92,7 +93,8 @@
"inviteExpiresInTime": "Expires in {n}", "inviteExpiresInTime": "Expires in {n}",
"notifyEvent": "Notify on:", "notifyEvent": "Notify on:",
"notifyInviteExpiry": "On expiry", "notifyInviteExpiry": "On expiry",
"notifyUserCreation": "On user creation" "notifyUserCreation": "On user creation",
"sendPIN": "Ask the user to send the PIN below to the bot."
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Changed email address of {n}.", "changedEmailAddress": "Changed email address of {n}.",
@ -104,6 +106,7 @@
"setOmbiDefaults": "Stored ombi defaults.", "setOmbiDefaults": "Stored ombi defaults.",
"updateApplied": "Update applied, please restart.", "updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.", "updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.",
"errorConnection": "Couldn't connect to jfa-go.", "errorConnection": "Couldn't connect to jfa-go.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.", "error401Unauthorized": "Unauthorized. Try refreshing the page.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
@ -126,7 +129,7 @@
"errorFailureCheckLogs": "Failed (check console/logs)", "errorFailureCheckLogs": "Failed (check console/logs)",
"errorPartialFailureCheckLogs": "Partial failure (check console/logs)", "errorPartialFailureCheckLogs": "Partial failure (check console/logs)",
"errorUserCreated": "Failed to create user {n}.", "errorUserCreated": "Failed to create user {n}.",
"errorSendWelcomeEmail": "Failed to send welcome email (check console/logs)", "errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)",
"errorApplyUpdate": "Failed to apply update, try manually.", "errorApplyUpdate": "Failed to apply update, try manually.",
"errorCheckUpdate": "Failed to check for update.", "errorCheckUpdate": "Failed to check for update.",
"updateAvailable": "A new update is available, check settings.", "updateAvailable": "A new update is available, check settings.",

View File

@ -53,8 +53,8 @@
"reset": "Reiniciar", "reset": "Reiniciar",
"edit": "Editar", "edit": "Editar",
"extendExpiry": "Extender el vencimiento", "extendExpiry": "Extender el vencimiento",
"customizeEmails": "Personalizar emails", "customizeMessages": "Personalizar emails",
"customizeEmailsDescription": "Si no desea utilizar las plantillas de correo electrónico de jfa-go, puede crear las suyas propias con Markdown.", "customizeMessagesDescription": "Si no desea utilizar las plantillas de correo electrónico de jfa-go, puede crear las suyas propias con Markdown.",
"markdownSupported": "Se admite Markdown.", "markdownSupported": "Se admite Markdown.",
"modifySettings": "Modificar configuración", "modifySettings": "Modificar configuración",
"modifySettingsDescription": "Aplique la configuración de un perfil existente u obténgalos directamente de un usuario.", "modifySettingsDescription": "Aplique la configuración de un perfil existente u obténgalos directamente de un usuario.",

View File

@ -68,15 +68,15 @@
"settingsRestarting": "Redémarrage…", "settingsRestarting": "Redémarrage…",
"settingsRestart": "Redémarrer", "settingsRestart": "Redémarrer",
"announce": "Annoncer", "announce": "Annoncer",
"subject": "Sujet du courriel", "subject": "Sujet",
"message": "Message", "message": "Message",
"markdownSupported": "Markdown est pris en charge.", "markdownSupported": "Markdown est pris en charge.",
"customizeEmailsDescription": "Si vous ne souhaitez pas utiliser les modèles d'e-mails de jfa-go, vous pouvez créer les vôtres à l'aide de Markdown.", "customizeMessagesDescription": "Si vous ne souhaitez pas utiliser les modèles d'e-mails de jfa-go, vous pouvez créer les vôtres à l'aide de Markdown.",
"variables": "Variables", "variables": "Variables",
"preview": "Aperçu", "preview": "Aperçu",
"reset": "Réinitialiser", "reset": "Réinitialiser",
"edit": "Éditer", "edit": "Éditer",
"customizeEmails": "Personnaliser les e-mails", "customizeMessages": "Personnaliser les e-mails",
"inviteDuration": "Durée de l'invitation", "inviteDuration": "Durée de l'invitation",
"enabled": "Activé", "enabled": "Activé",
"disabled": "Désactivé", "disabled": "Désactivé",

View File

@ -69,8 +69,8 @@
"preview": "Pratinjau", "preview": "Pratinjau",
"reset": "Setel ulang", "reset": "Setel ulang",
"edit": "Edit", "edit": "Edit",
"customizeEmails": "Sesuaikan Email", "customizeMessages": "Sesuaikan Email",
"customizeEmailsDescription": "Jika Anda tidak ingin menggunakan templat email jfa-go, Anda dapat membuatnya sendiri menggunakan Markdown.", "customizeMessagesDescription": "Jika Anda tidak ingin menggunakan templat email jfa-go, Anda dapat membuatnya sendiri menggunakan Markdown.",
"announce": "Mengumumkan", "announce": "Mengumumkan",
"subject": "Subjek Email", "subject": "Subjek Email",
"message": "Pesan", "message": "Pesan",

View File

@ -70,11 +70,11 @@
"subject": "E-mailonderwerp", "subject": "E-mailonderwerp",
"message": "Bericht", "message": "Bericht",
"variables": "Variabelen", "variables": "Variabelen",
"customizeEmailsDescription": "Als je de e-mailsjablonen van jfa-go niet wilt gebruiken, kun je met gebruik van Markdown je eigen aanmaken.", "customizeMessagesDescription": "Als je de e-mailsjablonen van jfa-go niet wilt gebruiken, kun je met gebruik van Markdown je eigen aanmaken.",
"preview": "Voorbeeld", "preview": "Voorbeeld",
"reset": "Resetten", "reset": "Resetten",
"edit": "Bewerken", "edit": "Bewerken",
"customizeEmails": "E-mails aanpassen", "customizeMessages": "E-mails aanpassen",
"inviteDuration": "Geldigheidsduur uitnodiging", "inviteDuration": "Geldigheidsduur uitnodiging",
"userExpiryDescription": "Een bepaalde tijd na elke aanmelding, wordt de account verwijderd/uitgeschakeld door jfa-go. Dit kan aangepast worden in de instellingen.", "userExpiryDescription": "Een bepaalde tijd na elke aanmelding, wordt de account verwijderd/uitgeschakeld door jfa-go. Dit kan aangepast worden in de instellingen.",
"enabled": "Ingeschakeld", "enabled": "Ingeschakeld",

View File

@ -69,12 +69,12 @@
"subject": "Assunto do email", "subject": "Assunto do email",
"message": "Mensagem", "message": "Mensagem",
"markdownSupported": "Suporte a Markdown.", "markdownSupported": "Suporte a Markdown.",
"customizeEmailsDescription": "Se não quiser usar os modelos de email do jfa-go, você pode criar o seu próprio usando o Markdown.", "customizeMessagesDescription": "Se não quiser usar os modelos de email do jfa-go, você pode criar o seu próprio usando o Markdown.",
"variables": "Variáveis", "variables": "Variáveis",
"preview": "Pre-visualizar", "preview": "Pre-visualizar",
"reset": "Reiniciar", "reset": "Reiniciar",
"edit": "Editar", "edit": "Editar",
"customizeEmails": "Customizar Emails", "customizeMessages": "Customizar Emails",
"disabled": "Desativado", "disabled": "Desativado",
"userExpiryDescription": "Após um determinado período de tempo de cada inscrição, o jfa-go apagará/desabilitará a conta. Você pode alterar essa opção nas configurações.", "userExpiryDescription": "Após um determinado período de tempo de cada inscrição, o jfa-go apagará/desabilitará a conta. Você pode alterar essa opção nas configurações.",
"inviteDuration": "Duração do Convite", "inviteDuration": "Duração do Convite",

View File

@ -37,8 +37,8 @@
"preview": "Förhandsvisning", "preview": "Förhandsvisning",
"reset": "Återställ", "reset": "Återställ",
"edit": "Redigera", "edit": "Redigera",
"customizeEmails": "Anpassa e-post", "customizeMessages": "Anpassa e-post",
"customizeEmailsDescription": "Om du inte vill använda jfa-go's e-postmallar, så kan du skapa dina egna med Markdown.", "customizeMessagesDescription": "Om du inte vill använda jfa-go's e-postmallar, så kan du skapa dina egna med Markdown.",
"markdownSupported": "Markdown stöds.", "markdownSupported": "Markdown stöds.",
"modifySettings": "Ändra inställningar", "modifySettings": "Ändra inställningar",
"modifySettingsDescription": "Tillämpa inställningar från en befintlig profil eller kopiera dem direkt från en användare.", "modifySettingsDescription": "Tillämpa inställningar från en befintlig profil eller kopiera dem direkt från en användare.",

View File

@ -14,6 +14,9 @@
"copied": "Copied", "copied": "Copied",
"time24h": "24h Time", "time24h": "24h Time",
"time12h": "12h Time", "time12h": "12h Time",
"linkTelegram": "Link Telegram",
"contactEmail": "Contact through Email",
"contactTelegram": "Contact through Telegram",
"theme": "Theme" "theme": "Theme"
} }
} }

View File

@ -3,7 +3,7 @@
"name": "English (US)" "name": "English (US)"
}, },
"strings": { "strings": {
"ifItWasNotYou": "If this wasn't you, please ignore this email.", "ifItWasNotYou": "If this wasn't you, please ignore this.",
"helloUser": "Hi {username},", "helloUser": "Hi {username},",
"reason": "Reason" "reason": "Reason"
}, },
@ -55,7 +55,7 @@
"linkButton": "Setup your account" "linkButton": "Setup your account"
}, },
"welcomeEmail": { "welcomeEmail": {
"name": "Welcome email", "name": "Welcome",
"title": "Welcome to Jellyfin", "title": "Welcome to Jellyfin",
"welcome": "Welcome to Jellyfin!", "welcome": "Welcome to Jellyfin!",
"youCanLoginWith": "You can login with the details below", "youCanLoginWith": "You can login with the details below",

View File

@ -17,11 +17,15 @@
"successContinueButton": "Continue", "successContinueButton": "Continue",
"confirmationRequired": "Email confirmation required", "confirmationRequired": "Email confirmation required",
"confirmationRequiredMessage": "Please check your email inbox to verify your address.", "confirmationRequiredMessage": "Please check your email inbox to verify your address.",
"yourAccountIsValidUntil": "Your account will be valid until {date}." "yourAccountIsValidUntil": "Your account will be valid until {date}.",
"sendPIN": "Send the PIN below to the bot, then come back here to link your account."
}, },
"notifications": { "notifications": {
"errorUserExists": "User already exists.", "errorUserExists": "User already exists.",
"errorInvalidCode": "Invalid invite code." "errorInvalidCode": "Invalid invite code.",
"errorTelegramVerification": "Telegram verification required.",
"errorInvalidPIN": "Telegram PIN is invalid.",
"telegramVerified": "Telegram account verified."
}, },
"validationStrings": { "validationStrings": {
"length": { "length": {

22
main.go
View File

@ -93,6 +93,7 @@ type appContext struct {
storage Storage storage Storage
validator Validator validator Validator
email *Emailer email *Emailer
telegram *TelegramDaemon
info, debug, err logger.Logger info, debug, err logger.Logger
host string host string
port int port int
@ -198,6 +199,12 @@ func start(asDaemon, firstCall bool) {
if err := app.loadConfig(); err != nil { if err := app.loadConfig(); err != nil {
app.err.Fatalf("Failed to load config file \"%s\": %v", app.configPath, err) app.err.Fatalf("Failed to load config file \"%s\": %v", app.configPath, err)
} }
// Some message settings have been moved from "email" to "messages", this will switch them.
if app.config.Section("email").Key("use_24h").Value() != "" {
app.migrateEmailConfig()
}
app.version = app.config.Section("jellyfin").Key("version").String() app.version = app.config.Section("jellyfin").Key("version").String()
// read from config... // read from config...
debugMode = app.config.Section("ui").Key("debug").MustBool(false) debugMode = app.config.Section("ui").Key("debug").MustBool(false)
@ -257,6 +264,7 @@ func start(asDaemon, firstCall bool) {
app.storage.lang.FormPath = "form" app.storage.lang.FormPath = "form"
app.storage.lang.AdminPath = "admin" app.storage.lang.AdminPath = "admin"
app.storage.lang.EmailPath = "email" app.storage.lang.EmailPath = "email"
app.storage.lang.TelegramPath = "telegram"
app.storage.lang.PasswordResetPath = "pwreset" app.storage.lang.PasswordResetPath = "pwreset"
externalLang := app.config.Section("files").Key("lang_files").MustString("") externalLang := app.config.Section("files").Key("lang_files").MustString("")
var err error var err error
@ -325,6 +333,10 @@ func start(asDaemon, firstCall bool) {
if err := app.storage.loadUsers(); err != nil { if err := app.storage.loadUsers(); err != nil {
app.err.Printf("Failed to load Users: %v", err) app.err.Printf("Failed to load Users: %v", err)
} }
app.storage.telegram_path = app.config.Section("files").Key("telegram_users").String()
if err := app.storage.loadTelegramUsers(); err != nil {
app.err.Printf("Failed to load Telegram users: %v", err)
}
app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String() app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles() app.storage.loadProfiles()
@ -541,6 +553,16 @@ func start(asDaemon, firstCall bool) {
if app.config.Section("updates").Key("enabled").MustBool(false) { if app.config.Section("updates").Key("enabled").MustBool(false) {
go app.checkForUpdates() go app.checkForUpdates()
} }
if telegramEnabled {
app.telegram, err = newTelegramDaemon(app)
if err != nil {
app.err.Printf("Failed to authenticate with Telegram: %v", err)
} else {
go app.telegram.run()
defer app.telegram.Shutdown()
}
}
} else { } else {
debugMode = false debugMode = false
address = "0.0.0.0:8056" address = "0.0.0.0:8056"

View File

@ -11,10 +11,12 @@ type boolResponse struct {
} }
type newUserDTO struct { type newUserDTO struct {
Username string `json:"username" example:"jeff" binding:"required"` // User's username Username string `json:"username" example:"jeff" binding:"required"` // User's username
Password string `json:"password" example:"guest" binding:"required"` // User's password Password string `json:"password" example:"guest" binding:"required"` // User's password
Email string `json:"email" example:"jeff@jellyf.in"` // User's email address Email string `json:"email" example:"jeff@jellyf.in"` // User's email address
Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser) Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser)
TelegramPIN string `json:"telegram_pin" example:"A1-B2-3C"` // Telegram verification PIN (if used)
TelegramContact bool `json:"telegram_contact"` // Whether or not to use telegram for notifications/pwrs
} }
type newUserResponse struct { type newUserResponse struct {
@ -120,13 +122,15 @@ type deleteInviteDTO struct {
} }
type respUser struct { type respUser struct {
ID string `json:"id" example:"fdgsdfg45534fa"` // userID of user ID string `json:"id" example:"fdgsdfg45534fa"` // userID of user
Name string `json:"name" example:"jeff"` // Username of user Name string `json:"name" example:"jeff"` // Username of user
Email string `json:"email,omitempty" example:"jeff@jellyf.in"` // Email address of user (if available) Email string `json:"email,omitempty" example:"jeff@jellyf.in"` // Email address of user (if available)
LastActive int64 `json:"last_active" example:"1617737207510"` // Time of last activity on Jellyfin 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 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. 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. Disabled bool `json:"disabled"` // Whether or not the user is disabled.
Telegram string `json:"telegram"` // Telegram username (if known)
NotifyThroughTelegram bool `json:"notify_telegram"`
} }
type getUsersDTO struct { type getUsersDTO struct {
@ -234,3 +238,18 @@ type checkUpdateDTO struct {
New bool `json:"new"` // Whether or not there's a new update. New bool `json:"new"` // Whether or not there's a new update.
Update Update `json:"update"` Update Update `json:"update"`
} }
type telegramPinDTO struct {
Token string `json:"token" example:"A1-B2-3C"`
Username string `json:"username"`
}
type telegramSetDTO struct {
Token string `json:"token" example:"A1-B2-3C"`
ID string `json:"id"` // Jellyfin ID of user.
}
type telegramNotifyDTO struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
}

View File

@ -69,27 +69,24 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
return return
} }
app.storage.loadEmails() app.storage.loadEmails()
var address string
uid := user.ID uid := user.ID
if uid == "" { if uid == "" {
app.err.Printf("Couldn't get user ID for user \"%s\"", pwr.Username) app.err.Printf("Couldn't get user ID for user \"%s\"", pwr.Username)
return return
} }
addr, ok := app.storage.emails[uid] name := app.getAddressOrName(uid)
if !ok || addr == nil { if name != "" {
app.err.Printf("Couldn't find email for user \"%s\". Make sure it's set", pwr.Username) msg, err := app.email.constructReset(pwr, app, false)
return
} if err != nil {
address = addr.(string) app.err.Printf("Failed to construct password reset message for %s", pwr.Username)
msg, err := app.email.constructReset(pwr, app, false) app.debug.Printf("%s: Error: %s", pwr.Username, err)
if err != nil { } else if err := app.sendByID(msg, uid); err != nil {
app.err.Printf("Failed to construct password reset email for %s", pwr.Username) app.err.Printf("Failed to send password reset message to \"%s\"", name)
app.debug.Printf("%s: Error: %s", pwr.Username, err) app.debug.Printf("%s: Error: %s", pwr.Username, err)
} else if err := app.email.send(msg, address); err != nil { } else {
app.err.Printf("Failed to send password reset email to \"%s\"", address) app.info.Printf("Sent password reset message to \"%s\"", name)
app.debug.Printf("%s: Error: %s", pwr.Username, err) }
} else {
app.info.Printf("Sent password reset email to \"%s\"", address)
} }
} else { } else {
app.err.Printf("Password reset for user \"%s\" has already expired (%s). Check your time settings.", pwr.Username, pwr.Expiry) app.err.Printf("Password reset for user \"%s\" has already expired (%s). Check your time settings.", pwr.Username, pwr.Expiry)

View File

@ -118,6 +118,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.POST(p+"/newUser", app.NewUser) router.POST(p+"/newUser", app.NewUser)
router.Use(static.Serve(p+"/invite/", app.webFS)) router.Use(static.Serve(p+"/invite/", app.webFS))
router.GET(p+"/invite/:invCode", app.InviteProxy) router.GET(p+"/invite/:invCode", app.InviteProxy)
if telegramEnabled {
router.GET(p+"/invite/:invCode/telegram/verified/:pin", app.TelegramVerifiedInvite)
}
} }
if *SWAGGER { if *SWAGGER {
app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n")) app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n"))
@ -155,6 +158,12 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/config", app.GetConfig) api.GET(p+"/config", app.GetConfig)
api.POST(p+"/config", app.ModifyConfig) api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart) api.POST(p+"/restart", app.restart)
if telegramEnabled {
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)
}
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
api.GET(p+"/ombi/users", app.OmbiUsers) api.GET(p+"/ombi/users", app.OmbiUsers)
api.POST(p+"/ombi/defaults", app.SetOmbiDefaults) api.POST(p+"/ombi/defaults", app.SetOmbiDefaults)

View File

@ -29,7 +29,7 @@ func (app *appContext) ServeSetup(gc *gin.Context) {
"success_message": app.config.Section("ui").Key("success_message").String(), "success_message": app.config.Section("ui").Key("success_message").String(),
}, },
"email": { "email": {
"message": app.config.Section("email").Key("message").String(), "message": app.config.Section("messages").Key("message").String(),
}, },
} }
msg, err := json.Marshal(messages) msg, err := json.Marshal(messages)

View File

@ -15,18 +15,26 @@ import (
) )
type Storage struct { type Storage struct {
timePattern string timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path 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 users map[string]time.Time
invites Invites invites Invites
profiles map[string]Profile profiles map[string]Profile
defaultProfile string defaultProfile string
emails, displayprefs, ombi_template map[string]interface{} emails, displayprefs, ombi_template map[string]interface{}
customEmails customEmails telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users.
policy mediabrowser.Policy customEmails customEmails
configuration mediabrowser.Configuration policy mediabrowser.Policy
lang Lang configuration mediabrowser.Configuration
invitesLock, usersLock sync.Mutex lang Lang
invitesLock, usersLock sync.Mutex
}
type TelegramUser struct {
ChatID int64
Username string
Lang string
Contact bool // Whether to contact through telegram or not
} }
type customEmails struct { type customEmails struct {
@ -81,23 +89,26 @@ type Invite struct {
} }
type Lang struct { type Lang struct {
AdminPath string AdminPath string
chosenAdminLang string chosenAdminLang string
Admin adminLangs Admin adminLangs
AdminJSON map[string]string AdminJSON map[string]string
FormPath string FormPath string
chosenFormLang string chosenFormLang string
Form formLangs Form formLangs
PasswordResetPath string PasswordResetPath string
chosenPWRLang string chosenPWRLang string
PasswordReset pwrLangs PasswordReset pwrLangs
EmailPath string EmailPath string
chosenEmailLang string chosenEmailLang string
Email emailLangs Email emailLangs
CommonPath string CommonPath string
Common commonLangs Common commonLangs
SetupPath string SetupPath string
Setup setupLangs Setup setupLangs
chosenTelegramLang string
TelegramPath string
Telegram telegramLangs
} }
func (st *Storage) loadLang(filesystems ...fs.FS) (err error) { func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
@ -118,6 +129,10 @@ func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
return return
} }
err = st.loadLangEmail(filesystems...) err = st.loadLangEmail(filesystems...)
if err != nil {
return
}
err = st.loadLangTelegram(filesystems...)
return return
} }
@ -620,6 +635,83 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
return nil return nil
} }
func (st *Storage) loadLangTelegram(filesystems ...fs.FS) error {
st.lang.Telegram = map[string]telegramLang{}
var english telegramLang
loadedLangs := make([]map[string]bool, len(filesystems))
var load loadLangFunc
load = func(fsIndex int, fname string) error {
filesystem := filesystems[fsIndex]
index := strings.TrimSuffix(fname, filepath.Ext(fname))
lang := telegramLang{}
f, err := fs.ReadFile(filesystem, FSJoin(st.lang.TelegramPath, fname))
if err != nil {
return err
}
if substituteStrings != "" {
f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings))
}
err = json.Unmarshal(f, &lang)
if err != nil {
return err
}
st.lang.Common.patchCommon(&lang.Strings, index)
if fname != "en-us.json" {
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Telegram[lang.Meta.Fallback]
err = nil
if !ok {
err = load(fsIndex, lang.Meta.Fallback+".json")
fallback = st.lang.Telegram[lang.Meta.Fallback]
}
if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
}
}
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.Strings, &english.Strings)
}
}
st.lang.Telegram[index] = lang
return nil
}
engFound := false
var err error
for i := range filesystems {
loadedLangs[i] = map[string]bool{}
err = load(i, "en-us.json")
if err == nil {
engFound = true
}
loadedLangs[i]["en-us.json"] = true
}
if !engFound {
return err
}
english = st.lang.Telegram["en-us"]
telegramLoaded := false
for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.TelegramPath)
if err != nil {
continue
}
for _, f := range files {
if !loadedLangs[i][f.Name()] {
err = load(i, f.Name())
if err == nil {
telegramLoaded = true
loadedLangs[i][f.Name()] = true
}
}
}
}
if !telegramLoaded {
return err
}
return nil
}
type Invites map[string]Invite type Invites map[string]Invite
func (st *Storage) loadInvites() error { func (st *Storage) loadInvites() error {
@ -665,6 +757,14 @@ func (st *Storage) storeEmails() error {
return storeJSON(st.emails_path, st.emails) return storeJSON(st.emails_path, st.emails)
} }
func (st *Storage) loadTelegramUsers() error {
return loadJSON(st.telegram_path, &st.telegram)
}
func (st *Storage) storeTelegramUsers() error {
return storeJSON(st.telegram_path, st.telegram)
}
func (st *Storage) loadCustomEmails() error { func (st *Storage) loadCustomEmails() error {
return loadJSON(st.customEmails_path, &st.customEmails) return loadJSON(st.customEmails_path, &st.customEmails)
} }

220
telegram.go Normal file
View File

@ -0,0 +1,220 @@
package main
import (
"fmt"
"math/rand"
"strings"
"time"
tg "github.com/go-telegram-bot-api/telegram-bot-api"
)
type VerifiedToken struct {
Token string
ChatID int64
Username string
}
type TelegramDaemon struct {
Stopped bool
ShutdownChannel chan string
bot *tg.BotAPI
username string
tokens []string
verifiedTokens []VerifiedToken
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
}
func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) {
token := app.config.Section("telegram").Key("token").String()
if token == "" {
return nil, fmt.Errorf("token was blank")
}
bot, err := tg.NewBotAPI(token)
if err != nil {
return nil, err
}
td := &TelegramDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
bot: bot,
username: bot.Self.UserName,
tokens: []string{},
verifiedTokens: []VerifiedToken{},
languages: map[int64]string{},
link: "https://t.me/" + bot.Self.UserName,
app: app,
}
for _, user := range app.storage.telegram {
if user.Lang != "" {
td.languages[user.ChatID] = user.Lang
}
}
return td, nil
}
var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (t *TelegramDaemon) NewAuthToken() string {
rand.Seed(time.Now().UnixNano())
pin := make([]rune, 8)
for i := range pin {
if i == 2 || i == 5 {
pin[i] = '-'
} else {
pin[i] = runes[rand.Intn(len(runes))]
}
}
t.tokens = append(t.tokens, string(pin))
return string(pin)
}
func (t *TelegramDaemon) run() {
t.app.info.Println("Starting Telegram bot daemon")
u := tg.NewUpdate(0)
u.Timeout = 60
updates, err := t.bot.GetUpdatesChan(u)
if err != nil {
t.app.err.Printf("Failed to start Telegram daemon: %v", err)
return
}
for {
var upd tg.Update
select {
case upd = <-updates:
if upd.Message == nil {
continue
}
sects := strings.Split(upd.Message.Text, " ")
if len(sects) == 0 {
continue
}
lang := t.app.storage.lang.chosenTelegramLang
storedLang, ok := t.languages[upd.Message.Chat.ID]
if !ok {
found := false
for code := range t.app.storage.lang.Telegram {
if code[:2] == upd.Message.From.LanguageCode {
lang = code
found = true
break
}
}
if found {
t.languages[upd.Message.Chat.ID] = lang
}
} else {
lang = storedLang
}
switch msg := sects[0]; msg {
case "/start":
content := t.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n"
content += t.app.storage.lang.Telegram[lang].Strings.get("languageMessage")
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)
}
continue
case "/lang":
if len(sects) == 1 {
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)
}
err := t.Reply(&upd, list)
if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
}
continue
}
if _, ok := t.app.storage.lang.Telegram[sects[1]]; ok {
t.languages[upd.Message.Chat.ID] = sects[1]
for jfID, user := range t.app.storage.telegram {
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 {
t.app.err.Printf("Failed to store Telegram users: %v", err)
}
break
}
}
}
continue
default:
tokenIndex := -1
for i, token := range t.tokens {
if upd.Message.Text == token {
tokenIndex = i
break
}
}
if tokenIndex == -1 {
err := t.QuoteReply(&upd, t.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"))
if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
}
continue
}
err := t.QuoteReply(&upd, t.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"))
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{
Token: upd.Message.Text,
ChatID: upd.Message.Chat.ID,
Username: upd.Message.Chat.UserName,
})
t.tokens[len(t.tokens)-1], t.tokens[tokenIndex] = t.tokens[tokenIndex], t.tokens[len(t.tokens)-1]
t.tokens = t.tokens[:len(t.tokens)-1]
}
case <-t.ShutdownChannel:
t.bot.StopReceivingUpdates()
t.ShutdownChannel <- "Down"
return
}
}
}
func (t *TelegramDaemon) Reply(upd *tg.Update, content string) error {
msg := tg.NewMessage((*upd).Message.Chat.ID, content)
_, err := t.bot.Send(msg)
return err
}
func (t *TelegramDaemon) QuoteReply(upd *tg.Update, content string) error {
msg := tg.NewMessage((*upd).Message.Chat.ID, content)
msg.ReplyToMessageID = (*upd).Message.MessageID
_, err := t.bot.Send(msg)
return err
}
// Send will send a telegram message to a list of chat IDs. message.text is used if no markdown is given.
func (t *TelegramDaemon) Send(message *Message, ID ...int64) error {
for _, id := range ID {
var msg tg.MessageConfig
if message.Markdown == "" {
msg = tg.NewMessage(id, message.Text)
} else {
msg = tg.NewMessage(id, strings.ReplaceAll(message.Markdown, ".", "\\."))
msg.ParseMode = "MarkdownV2"
}
_, err := t.bot.Send(msg)
if err != nil {
return err
}
}
return nil
}
func (t *TelegramDaemon) Shutdown() {
t.Stopped = true
t.ShutdownChannel <- "Down"
<-t.ShutdownChannel
close(t.ShutdownChannel)
}

View File

@ -62,6 +62,10 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.extendExpiry = new Modal(document.getElementById("modal-extend-expiry")); window.modals.extendExpiry = new Modal(document.getElementById("modal-extend-expiry"));
window.modals.updateInfo = new Modal(document.getElementById("modal-update")); window.modals.updateInfo = new Modal(document.getElementById("modal-update"));
if (window.telegramEnabled) {
window.modals.telegram = new Modal(document.getElementById("modal-telegram"));
}
})(); })();
var inviteCreator = new createInvite(); var inviteCreator = new createInvite();

View File

@ -1,15 +1,19 @@
import { Modal } from "./modules/modal.js"; import { Modal } from "./modules/modal.js";
import { notificationBox, whichAnimationEvent } from "./modules/common.js";
import { _get, _post, toggleLoader, toDateString } from "./modules/common.js"; import { _get, _post, toggleLoader, toDateString } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js"; import { loadLangSelector } from "./modules/lang.js";
interface formWindow extends Window { interface formWindow extends Window {
validationStrings: pwValStrings; validationStrings: pwValStrings;
invalidPassword: string; invalidPassword: string;
modal: Modal; successModal: Modal;
telegramModal: Modal;
confirmationModal: Modal
code: string; code: string;
messages: { [key: string]: string }; messages: { [key: string]: string };
confirmation: boolean; confirmation: boolean;
confirmationModal: Modal telegramRequired: boolean;
telegramPIN: string;
userExpiryEnabled: boolean; userExpiryEnabled: boolean;
userExpiryMonths: number; userExpiryMonths: number;
userExpiryDays: number; userExpiryDays: number;
@ -34,7 +38,52 @@ interface pwValStrings {
loadLangSelector("form"); loadLangSelector("form");
window.modal = new Modal(document.getElementById("modal-success"), true); window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement);
window.animationEvent = whichAnimationEvent();
window.successModal = new Modal(document.getElementById("modal-success"), true);
var telegramVerified = false;
if (window.telegramEnabled) {
window.telegramModal = new Modal(document.getElementById("modal-telegram"), window.telegramRequired);
const telegramButton = document.getElementById("link-telegram") as HTMLSpanElement;
telegramButton.onclick = () => {
const waiting = document.getElementById("telegram-waiting") as HTMLSpanElement;
toggleLoader(waiting);
window.telegramModal.show();
let modalClosed = false;
window.telegramModal.onclose = () => {
modalClosed = true;
toggleLoader(waiting);
}
const checkVerified = () => _get("/invite/" + window.code + "/telegram/verified/" + window.telegramPIN, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 401) {
window.telegramModal.close();
window.notifications.customError("invalidCodeError", window.messages["errorInvalidCode"]);
return;
} else if (req.status == 200) {
if (req.response["success"] as boolean) {
telegramVerified = true;
waiting.classList.add("~positive");
waiting.classList.remove("~info");
window.notifications.customPositive("telegramVerified", "", window.messages["telegramVerified"]);
setTimeout(window.telegramModal.close, 2000);
telegramButton.classList.add("unfocused");
document.getElementById("contact-via").classList.remove("unfocused");
const radio = document.getElementById("contact-via-telegram") as HTMLInputElement;
radio.checked = true;
} else if (!modalClosed) {
setTimeout(checkVerified, 1500);
}
}
}
});
checkVerified();
};
}
if (window.confirmation) { if (window.confirmation) {
window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true); window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true);
} }
@ -110,6 +159,8 @@ interface sendDTO {
email: string; email: string;
username: string; username: string;
password: string; password: string;
telegram_pin?: string;
telegram_contact?: boolean;
} }
const create = (event: SubmitEvent) => { const create = (event: SubmitEvent) => {
@ -121,6 +172,13 @@ const create = (event: SubmitEvent) => {
email: emailField.value, email: emailField.value,
password: passwordField.value password: passwordField.value
}; };
if (telegramVerified) {
send.telegram_pin = window.telegramPIN;
const radio = document.getElementById("contact-via-telegram") as HTMLInputElement;
if (radio.checked) {
send.telegram_contact = true;
}
}
_post("/newUser", send, (req: XMLHttpRequest) => { _post("/newUser", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) { if (req.readyState == 4) {
let vals = req.response as respDTO; let vals = req.response as respDTO;
@ -130,7 +188,7 @@ const create = (event: SubmitEvent) => {
if (!vals[type]) { valid = false; } if (!vals[type]) { valid = false; }
} }
if (req.status == 200 && valid) { if (req.status == 200 && valid) {
window.modal.show(); window.successModal.show();
} else { } else {
submitSpan.classList.add("~critical"); submitSpan.classList.add("~critical");
submitSpan.classList.remove("~urge"); submitSpan.classList.remove("~urge");

View File

@ -1,4 +1,4 @@
import { _get, _post, _delete, toggleLoader, toDateString } from "../modules/common.js"; import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, toDateString } from "../modules/common.js";
import { templateEmail } from "../modules/settings.js"; import { templateEmail } from "../modules/settings.js";
import { Marked } from "@ts-stack/markdown"; import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js"; import { stripMarkdown } from "../modules/stripmd.js";
@ -11,6 +11,13 @@ interface User {
admin: boolean; admin: boolean;
disabled: boolean; disabled: boolean;
expiry: number; expiry: number;
telegram: string;
notify_telegram: boolean;
}
interface getPinResponse {
token: string;
username: string;
} }
class user implements User { class user implements User {
@ -22,6 +29,9 @@ class user implements User {
private _email: HTMLInputElement; private _email: HTMLInputElement;
private _emailAddress: string; private _emailAddress: string;
private _emailEditButton: HTMLElement; private _emailEditButton: HTMLElement;
private _telegram: HTMLTableDataCellElement;
private _telegramUsername: string;
private _notifyTelegram: boolean;
private _expiry: HTMLTableDataCellElement; private _expiry: HTMLTableDataCellElement;
private _expiryUnix: number; private _expiryUnix: number;
private _lastActive: HTMLTableDataCellElement; private _lastActive: HTMLTableDataCellElement;
@ -72,6 +82,89 @@ class user implements User {
} }
} }
get telegram(): string { return this._telegramUsername; }
set telegram(u: string) {
if (!window.telegramEnabled) return;
this._telegramUsername = u;
if (u == "") {
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>
`;
// 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;
}
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);
}
};
}
}
get notify_telegram(): boolean { return this._notifyTelegram; }
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;
}
private _setTelegramNotify = () => {
const radios = this._telegram.querySelectorAll("input") as NodeListOf<HTMLInputElement>;
let send = {
id: this.id,
enabled: radios[1].checked
};
_post("/users/telegram/notify", 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;
return;
}
}
}, false, (req: XMLHttpRequest) => {
if (req.status == 0) {
window.notifications.connectionError();
radios[0].checked, radios[1].checked= radios[1].checked, radios[0].checked;
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"));
}
});
}
get expiry(): number { return this._expiryUnix; } get expiry(): number { return this._expiryUnix; }
set expiry(unix: number) { set expiry(unix: number) {
this._expiryUnix = unix; this._expiryUnix = unix;
@ -97,13 +190,21 @@ class user implements User {
constructor(user: User) { constructor(user: User) {
this._row = document.createElement("tr") as HTMLTableRowElement; this._row = document.createElement("tr") as HTMLTableRowElement;
this._row.innerHTML = ` let innerHTML = `
<td><input type="checkbox" value=""></td> <td><input type="checkbox" value=""></td>
<td><span class="accounts-username"></span> <span class="accounts-admin"></span> <span class="accounts-disabled"></span></td> <td><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><i class="icon ri-edit-line accounts-email-edit"></i><span class="accounts-email-container ml-half"></span></td>
<td class="accounts-expiry"></td>
<td class="accounts-last-active"></td>
`; `;
if (window.telegramEnabled) {
innerHTML += `
<td class="accounts-telegram"></td>
`;
}
innerHTML += `
<td class="accounts-expiry"></td>
<td class="accounts-last-active"></td>
`;
this._row.innerHTML = innerHTML;
const emailEditor = `<input type="email" class="input ~neutral !normal stealth-input">`; const emailEditor = `<input type="email" class="input ~neutral !normal stealth-input">`;
this._check = this._row.querySelector("input[type=checkbox]") as HTMLInputElement; this._check = this._row.querySelector("input[type=checkbox]") as HTMLInputElement;
this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement; this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement;
@ -111,6 +212,7 @@ class user implements User {
this._disabled = this._row.querySelector(".accounts-disabled") as HTMLSpanElement; this._disabled = this._row.querySelector(".accounts-disabled") as HTMLSpanElement;
this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement; this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement;
this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement; this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement;
this._telegram = this._row.querySelector(".accounts-telegram") as HTMLTableDataCellElement;
this._expiry = this._row.querySelector(".accounts-expiry") as HTMLTableDataCellElement; this._expiry = this._row.querySelector(".accounts-expiry") as HTMLTableDataCellElement;
this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement; this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement;
this._check.onchange = () => { this.selected = this._check.checked; } this._check.onchange = () => { this.selected = this._check.checked; }
@ -169,14 +271,60 @@ 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");
const link = document.getElementById("telegram-link") as HTMLAnchorElement;
const username = document.getElementById("telegram-username") as HTMLSpanElement;
const waiting = document.getElementById("telegram-waiting") as HTMLSpanElement;
let resp = req.response as getPinResponse;
pin.textContent = resp.token;
link.href = "https://t.me/" + resp.username;
username.textContent = resp.username;
addLoader(waiting);
let modalClosed = false;
window.modals.telegram.onclose = () => {
modalClosed = true;
removeLoader(waiting);
}
let send = {
token: resp.token,
id: this.id
};
const checkVerified = () => _post("/users/telegram", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200 && req.response["success"] as boolean) {
removeLoader(waiting);
waiting.classList.add("~positive");
waiting.classList.remove("~info");
window.notifications.customSuccess("telegramVerified", window.lang.notif("telegramVerified"));
setTimeout(() => {
window.modals.telegram.close();
waiting.classList.add("~info");
waiting.classList.remove("~positive");
}, 2000);
document.dispatchEvent(new CustomEvent("accounts-reload"));
} else if (!modalClosed) {
setTimeout(checkVerified, 1500);
}
}
}, true);
window.modals.telegram.show();
checkVerified();
}
});
update = (user: User) => { update = (user: User) => {
this.id = user.id; this.id = user.id;
this.name = user.name; this.name = user.name;
this.email = user.email || ""; this.email = user.email || "";
this.telegram = user.telegram;
this.last_active = user.last_active; this.last_active = user.last_active;
this.admin = user.admin; this.admin = user.admin;
this.disabled = user.disabled; this.disabled = user.disabled;
this.expiry = user.expiry; this.expiry = user.expiry;
this.notify_telegram = user.notify_telegram;
} }
asElement = (): HTMLTableRowElement => { return this._row; } asElement = (): HTMLTableRowElement => { return this._row; }
@ -188,9 +336,6 @@ class user implements User {
} }
} }
export class accountsList { export class accountsList {
private _table = document.getElementById("accounts-list") as HTMLTableSectionElement; private _table = document.getElementById("accounts-list") as HTMLTableSectionElement;
@ -334,7 +479,7 @@ export class accountsList {
this._selectAll.checked = false; this._selectAll.checked = false;
this._modifySettings.classList.add("unfocused"); this._modifySettings.classList.add("unfocused");
this._deleteUser.classList.add("unfocused"); this._deleteUser.classList.add("unfocused");
if (window.emailEnabled) { if (window.emailEnabled || window.telegramEnabled) {
this._announceButton.classList.add("unfocused"); this._announceButton.classList.add("unfocused");
} }
this._extendExpiry.classList.add("unfocused"); this._extendExpiry.classList.add("unfocused");
@ -356,7 +501,7 @@ export class accountsList {
this._modifySettings.classList.remove("unfocused"); this._modifySettings.classList.remove("unfocused");
this._deleteUser.classList.remove("unfocused"); this._deleteUser.classList.remove("unfocused");
this._deleteUser.textContent = window.lang.quantity("deleteUser", list.length); this._deleteUser.textContent = window.lang.quantity("deleteUser", list.length);
if (window.emailEnabled) { if (window.emailEnabled || window.telegramEnabled) {
this._announceButton.classList.remove("unfocused"); this._announceButton.classList.remove("unfocused");
} }
let anyNonExpiries = list.length == 0 ? true : false; let anyNonExpiries = list.length == 0 ? true : false;
@ -701,6 +846,7 @@ export class accountsList {
this._selectAll.onchange = () => { this._selectAll.onchange = () => {
this.selectAll = this._selectAll.checked; this.selectAll = this._selectAll.checked;
}; };
document.addEventListener("accounts-reload", this.reload);
document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); }); document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); });
document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); }); document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); });
this._addUserButton.onclick = window.modals.addUser.toggle; this._addUserButton.onclick = window.modals.addUser.toggle;

View File

@ -179,3 +179,22 @@ export function toggleLoader(el: HTMLElement, small: boolean = true) {
el.appendChild(dot); el.appendChild(dot);
} }
} }
export function addLoader(el: HTMLElement, small: boolean = true) {
if (!el.classList.contains("loader")) {
el.classList.add("loader");
if (small) { el.classList.add("loader-sm"); }
const dot = document.createElement("span") as HTMLSpanElement;
dot.classList.add("dot")
el.appendChild(dot);
}
}
export function removeLoader(el: HTMLElement, small: boolean = true) {
if (el.classList.contains("loader")) {
el.classList.remove("loader");
el.classList.remove("loader-sm");
const dot = el.querySelector("span.dot");
if (dot) { dot.remove(); }
}
}

View File

@ -3,9 +3,13 @@ declare var window: Window;
export class Modal implements Modal { export class Modal implements Modal {
modal: HTMLElement; modal: HTMLElement;
closeButton: HTMLSpanElement; closeButton: HTMLSpanElement;
openEvent: CustomEvent;
closeEvent: CustomEvent;
constructor(modal: HTMLElement, important: boolean = false) { constructor(modal: HTMLElement, important: boolean = false) {
this.modal = modal; this.modal = modal;
const closeButton = this.modal.querySelector('span.modal-close') this.openEvent = new CustomEvent("modal-open-" + modal.id);
this.closeEvent = new CustomEvent("modal-close-" + modal.id);
const closeButton = this.modal.querySelector('span.modal-close');
if (closeButton !== null) { if (closeButton !== null) {
this.closeButton = closeButton as HTMLSpanElement; this.closeButton = closeButton as HTMLSpanElement;
this.closeButton.onclick = this.close; this.closeButton.onclick = this.close;
@ -22,15 +26,25 @@ export class Modal implements Modal {
} }
this.modal.classList.add('modal-hiding'); this.modal.classList.add('modal-hiding');
const modal = this.modal; const modal = this.modal;
const listenerFunc = function () { const listenerFunc = () => {
modal.classList.remove('modal-shown'); modal.classList.remove('modal-shown');
modal.classList.remove('modal-hiding'); modal.classList.remove('modal-hiding');
modal.removeEventListener(window.animationEvent, listenerFunc); modal.removeEventListener(window.animationEvent, listenerFunc);
document.dispatchEvent(this.closeEvent);
}; };
this.modal.addEventListener(window.animationEvent, listenerFunc, false); this.modal.addEventListener(window.animationEvent, listenerFunc, false);
} }
set onopen(f: () => void) {
document.addEventListener("modal-open-"+this.modal.id, f);
}
set onclose(f: () => void) {
document.addEventListener("modal-close-"+this.modal.id, f);
}
show = () => { show = () => {
this.modal.classList.add('modal-shown'); this.modal.classList.add('modal-shown');
document.dispatchEvent(this.openEvent);
} }
toggle = () => { toggle = () => {
if (this.modal.classList.contains('modal-shown')) { if (this.modal.classList.contains('modal-shown')) {

View File

@ -560,8 +560,16 @@ export class settingsList {
document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) { if (Boolean(event.detail) !== state) {
button.classList.add("unfocused"); button.classList.add("unfocused");
document.dispatchEvent(new CustomEvent(`settings-${name}`, { detail: false }));
} else { } else {
button.classList.remove("unfocused"); button.classList.remove("unfocused");
document.dispatchEvent(new CustomEvent(`settings-${name}`, { detail: true }));
}
});
document.addEventListener(`settings-${dependant[0]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) {
button.classList.add("unfocused");
document.dispatchEvent(new CustomEvent(`settings-${name}`, { detail: false }));
} }
}); });
} }
@ -669,7 +677,7 @@ export class settingsList {
if (name in this._sections) { if (name in this._sections) {
this._sections[name].update(settings.sections[name]); this._sections[name].update(settings.sections[name]);
} else { } else {
if (name == "email") { if (name == "messages") {
const editButton = document.createElement("div"); const editButton = document.createElement("div");
editButton.classList.add("tooltip", "left"); editButton.classList.add("tooltip", "left");
editButton.innerHTML = ` editButton.innerHTML = `
@ -677,7 +685,7 @@ export class settingsList {
<i class="icon ri-edit-line"></i> <i class="icon ri-edit-line"></i>
</span> </span>
<span class="content sm"> <span class="content sm">
${window.lang.get("strings", "customizeEmails")} ${window.lang.get("strings", "customizeMessages")}
</span> </span>
`; `;
(editButton.querySelector("span.button") as HTMLSpanElement).onclick = this._emailEditor.showList; (editButton.querySelector("span.button") as HTMLSpanElement).onclick = this._emailEditor.showList;

View File

@ -4,6 +4,8 @@ declare interface Modal {
show: () => void; show: () => void;
close: (event?: Event) => void; close: (event?: Event) => void;
toggle: () => void; toggle: () => void;
onopen: (f: () => void) => void;
onclose: (f: () => void) => void;
} }
interface ArrayConstructor { interface ArrayConstructor {
@ -18,6 +20,7 @@ declare interface Window {
jfUsers: Array<Object>; jfUsers: Array<Object>;
notificationsEnabled: boolean; notificationsEnabled: boolean;
emailEnabled: boolean; emailEnabled: boolean;
telegramEnabled: boolean;
ombiEnabled: boolean; ombiEnabled: boolean;
usernameEnabled: boolean; usernameEnabled: boolean;
token: string; token: string;
@ -97,6 +100,7 @@ declare interface Modals {
customizeEmails: Modal; customizeEmails: Modal;
extendExpiry: Modal; extendExpiry: Modal;
updateInfo: Modal; updateInfo: Modal;
telegram: Modal;
} }
interface Invite { interface Invite {

View File

@ -71,9 +71,9 @@ func (app *appContext) checkUsers() {
mode = "delete" mode = "delete"
termPlural = "Deleting" termPlural = "Deleting"
} }
email := false contact := false
if emailEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) { if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
email = true contact = true
} }
// Use a map to speed up checking for deleted users later // Use a map to speed up checking for deleted users later
userExists := map[string]bool{} userExists := map[string]bool{}
@ -114,18 +114,18 @@ func (app *appContext) checkUsers() {
} }
delete(app.storage.users, id) delete(app.storage.users, id)
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()
if email { if contact {
address, ok := app.storage.emails[id]
if !ok { if !ok {
continue continue
} }
name := app.getAddressOrName(user.ID)
msg, err := app.email.constructUserExpired(app, false) msg, err := app.email.constructUserExpired(app, false)
if err != nil { if err != nil {
app.err.Printf("Failed to construct expiry email for \"%s\": %s", user.Name, err) app.err.Printf("Failed to construct expiry message for \"%s\": %s", user.Name, err)
} else if err := app.email.send(msg, address.(string)); err != nil { } else if err := app.sendByID(msg, user.ID); err != nil {
app.err.Printf("Failed to send expiry email to \"%s\": %s", user.Name, err) app.err.Printf("Failed to send expiry message to \"%s\": %s", name, err)
} else { } else {
app.info.Printf("Sent expiry notification to \"%s\"", address.(string)) app.info.Printf("Sent expiry notification to \"%s\"", name)
} }
} }
} }

View File

@ -116,20 +116,21 @@ func (app *appContext) AdminPage(gc *gin.Context) {
} }
license = string(l) license = string(l)
gcHTML(gc, http.StatusOK, "admin.html", gin.H{ gcHTML(gc, http.StatusOK, "admin.html", gin.H{
"urlBase": app.getURLBase(gc), "urlBase": app.getURLBase(gc),
"cssClass": app.cssClass, "cssClass": app.cssClass,
"contactMessage": "", "contactMessage": "",
"email_enabled": emailEnabled, "email_enabled": emailEnabled,
"notifications": notificationsEnabled, "telegram_enabled": telegramEnabled,
"version": version, "notifications": notificationsEnabled,
"commit": commit, "version": version,
"ombiEnabled": ombiEnabled, "commit": commit,
"username": !app.config.Section("email").Key("no_username").MustBool(false), "ombiEnabled": ombiEnabled,
"strings": app.storage.lang.Admin[lang].Strings, "username": !app.config.Section("email").Key("no_username").MustBool(false),
"quantityStrings": app.storage.lang.Admin[lang].QuantityStrings, "strings": app.storage.lang.Admin[lang].Strings,
"language": app.storage.lang.Admin[lang].JSON, "quantityStrings": app.storage.lang.Admin[lang].QuantityStrings,
"langName": lang, "language": app.storage.lang.Admin[lang].JSON,
"license": license, "langName": lang,
"license": license,
}) })
} }
@ -259,7 +260,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
if strings.Contains(email, "Failed") { if strings.Contains(email, "Failed") {
email = "" email = ""
} }
gcHTML(gc, http.StatusOK, "form-loader.html", gin.H{ data := gin.H{
"urlBase": app.getURLBase(gc), "urlBase": app.getURLBase(gc),
"cssClass": app.cssClass, "cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(), "contactMessage": app.config.Section("ui").Key("contact_message").String(),
@ -282,7 +283,15 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"userExpiryMinutes": inv.UserMinutes, "userExpiryMinutes": inv.UserMinutes,
"userExpiryMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"), "userExpiryMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"),
"langName": lang, "langName": lang,
}) "telegramEnabled": telegramEnabled,
}
if data["telegramEnabled"].(bool) {
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)
}
gcHTML(gc, http.StatusOK, "form-loader.html", data)
} }
func (app *appContext) NoRouteHandler(gc *gin.Context) { func (app *appContext) NoRouteHandler(gc *gin.Context) {