From 99875b917619c9e76b7728acd9cf700472e0a6ae Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 01:08:12 +0100 Subject: [PATCH 01/15] almost complete telegram user verification When signing up, the user is given a pin code which they send to a telegram bot. This provides user verification, but more importantly allows the bot to message the user, as the Telegram API requires the user to interact with the bot before it can do the opposite. The bot should recognize the correct language, but a /lang command is also provided to change it. The verification process is pretty much functional but ui is still broken, and it isn't properly integrated yet. --- .gitignore | 1 + api.go | 34 ++++++- config.go | 3 +- config/config-base.json | 55 +++++++++++ css/base.css | 9 ++ email.go | 70 +++++++------- go.mod | 13 ++- go.sum | 26 +++--- html/form-base.html | 3 + html/form.html | 23 ++++- internal.go | 2 +- lang.go | 17 ++++ lang/form/en-us.json | 7 +- main.go | 16 ++++ router.go | 3 + storage.go | 157 +++++++++++++++++++++++++------ telegram.go | 201 ++++++++++++++++++++++++++++++++++++++++ ts/form.ts | 43 ++++++++- views.go | 12 ++- 19 files changed, 598 insertions(+), 97 deletions(-) create mode 100644 telegram.go diff --git a/.gitignore b/.gitignore index 6208b48..228ac42 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ server.pem server.crt instructions-debian.txt cl.md +telegram/ diff --git a/api.go b/api.go index 574dccb..ee09b09 100644 --- a/api.go +++ b/api.go @@ -549,7 +549,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { } if len(addresses) != 0 { go func(reason string, addresses []string) { - var msg *Email + var msg *Message var err error if req.Enabled { msg, err = app.email.constructEnabled(reason, app, false) @@ -1580,7 +1580,7 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { id := gc.Param("id") var content string var err error - var msg *Email + var msg *Message var variables []string var conditionals []string var values map[string]interface{} @@ -1874,6 +1874,36 @@ func (app *appContext) ServeLang(gc *gin.Context) { respondBool(400, false, gc) } +// @Summary Returns true/false on whether or not a telegram PIN was verified. +// @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) TelegramVerified(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 == 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. // @Router /restart [post] // @tags Other diff --git a/config.go b/config.go index 7b3092e..9ef527f 100644 --- a/config.go +++ b/config.go @@ -40,7 +40,7 @@ func (app *appContext) loadConfig() error { key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) } } - for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users"} { + 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.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") @@ -128,6 +128,7 @@ func (app *appContext) loadConfig() error { 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.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) diff --git a/config/config-base.json b/config/config-base.json index 5cd3838..61be322 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -997,6 +997,53 @@ } } }, + "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." + }, + "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." + } + } + + }, "files": { "order": [], "meta": { @@ -1068,6 +1115,14 @@ "type": "text", "value": "", "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." } } } diff --git a/css/base.css b/css/base.css index e666869..95f1b4b 100644 --- a/css/base.css +++ b/css/base.css @@ -121,6 +121,10 @@ div.card:contains(section.banner.footer) { text-align: right; } +.ac { + text-align: center; +} + .inline-block { display: inline-block; } @@ -459,6 +463,11 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) { color: var(--color-urge-200); } +.link-center { + display: block; + text-align: center; +} + .search { max-width: 15rem; min-width: 10rem; diff --git a/email.go b/email.go index 694ff0e..e7c4f65 100644 --- a/email.go +++ b/email.go @@ -25,13 +25,13 @@ import ( ) // implements email sending, right now via smtp or mailgun. -type emailClient interface { - send(fromName, fromAddr string, email *Email, address ...string) error +type EmailClient interface { + 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) return nil } @@ -41,7 +41,7 @@ type Mailgun struct { 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( fmt.Sprintf("%s <%s>", fromName, fromAddr), email.Subject, @@ -67,7 +67,7 @@ type SMTP struct { 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) from := fmt.Sprintf("%s <%s>", fromName, fromAddr) var wg sync.WaitGroup @@ -93,15 +93,15 @@ func (sm *SMTP) send(fromName, fromAddr string, email *Email, address ...string) 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 { fromAddr, fromName string lang emailLang - sender emailClient + sender EmailClient } -// Email stores content. -type Email struct { +// Message stores content. +type Message struct { Subject string `json:"subject"` HTML string `json:"html"` Text string `json:"text"` @@ -154,7 +154,7 @@ func NewEmailer(app *appContext) *Emailer { } else if method == "mailgun" { emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String()) } else if method == "dummy" { - emailer.sender = &dummyClient{} + emailer.sender = &DummyClient{} } return emailer } @@ -267,8 +267,8 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC return template } -func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")), } var err error @@ -290,8 +290,8 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a return email, nil } -func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (*Email, error) { - email := &Email{Subject: subject} +func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (*Message, error) { + email := &Message{Subject: subject} renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) html := markdown.ToHTML([]byte(md), nil, renderer) text := stripMarkdown(md) @@ -338,8 +338,8 @@ func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext return template } -func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")), } template := emailer.inviteValues(code, invite, app, noSub) @@ -377,8 +377,8 @@ func (emailer *Emailer) expiryValues(code string, invite Invite, app *appContext return template } -func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: emailer.lang.InviteExpiry.get("title"), } var err error @@ -431,8 +431,8 @@ func (emailer *Emailer) createdValues(code, username, address string, invite Inv return template } -func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: emailer.lang.UserCreated.get("title"), } template := emailer.createdValues(code, username, address, invite, app, noSub) @@ -505,8 +505,8 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo return template } -func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")), } template := emailer.resetValues(pwr, app, noSub) @@ -546,8 +546,8 @@ func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool return template } -func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")), } var err error @@ -587,8 +587,8 @@ func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub boo return template } -func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")), } var err error @@ -628,8 +628,8 @@ func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool return template } -func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")), } var err error @@ -683,8 +683,8 @@ func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *ap return template } -func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")), } var err error @@ -728,8 +728,8 @@ func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[strin return template } -func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")), } var err error @@ -752,6 +752,6 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Emai } // calls the send method in the underlying emailClient. -func (emailer *Emailer) send(email *Email, address ...string) error { - return emailer.sender.send(emailer.fromName, emailer.fromAddr, email, address...) +func (emailer *Emailer) send(email *Message, address ...string) error { + return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...) } diff --git a/go.mod b/go.mod index 333a4ac..5be7638 100644 --- a/go.mod +++ b/go.mod @@ -17,29 +17,28 @@ require ( github.com/gin-contrib/pprof v1.3.0 github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e 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/swag v0.19.15 // 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/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/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/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/mediabrowser v0.3.3 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/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/pkg/errors v0.9.1 // 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/gin-swagger v1.3.0 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/writeas/go-strip-markdown v2.0.1+incompatible golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect diff --git a/go.sum b/go.sum index 6a79883..5b09672 100644 --- a/go.sum +++ b/go.sum @@ -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.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= 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.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= 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.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= 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/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 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.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= 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-20210208175418-bda154fe17d8/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU= +github.com/gomarkdown/markdown v0.0.0-20210408062403-ad838ccf8cdd h1:0b8AqsWQb6A0jjx80UXLG/uMTXQkGD0IGuXWqsrNz1M= +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.3.0/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/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/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/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= 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/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible h1:CL0ooBNfbNyJTJATno+m0h+zM5bW6v7fKlboKUGP/dI= -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 h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +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/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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/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/mailgun/mailgun-go/v4 v4.3.0 h1:9nAF7LI3k6bfDPbMZQMMl63Q8/vs+dr1FUN8eR1XMhk= -github.com/mailgun/mailgun-go/v4 v4.3.0/go.mod h1:fWuBI2iaS/pSSyo6+EBpHjatQO3lV8onwqcRy7joSJI= +github.com/mailgun/mailgun-go/v4 v4.5.1 h1:XrQQ/ZgqFvINRKy+eBqowLl7k3pQO6OCLpKphliMOFs= +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-20190614124828-94de47d64c63/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/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/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.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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 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.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= 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.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E= 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.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= diff --git a/html/form-base.html b/html/form-base.html index aa98d32..8a4a9ee 100644 --- a/html/form-base.html +++ b/html/form-base.html @@ -14,6 +14,9 @@ window.userExpiryHours = {{ .userExpiryHours }}; window.userExpiryMinutes = {{ .userExpiryMinutes }}; window.userExpiryMessage = {{ .userExpiryMessage }}; + window.telegramEnabled = {{ .telegramEnabled }}; + window.telegramRequired = {{ .telegramRequired }}; + window.telegramPIN = "{{ .telegramPIN }}"; {{ end }} diff --git a/html/form.html b/html/form.html index 9fe6694..7f14330 100644 --- a/html/form.html +++ b/html/form.html @@ -19,6 +19,24 @@

{{ .strings.confirmationRequiredMessage }}

+ {{ if .telegramEnabled }} + + {{ end }} @@ -29,6 +47,7 @@ +
@@ -48,7 +67,9 @@ - + {{ if .telegramEnabled }} + {{ .strings.linkTelegram }} + {{ end }} diff --git a/internal.go b/internal.go index 92732c3..a2fc105 100644 --- a/internal.go +++ b/internal.go @@ -13,7 +13,7 @@ const binaryType = "internal" //go:embed data data/html data/web data/web/css data/web/js 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 langFS rewriteFS diff --git a/lang.go b/lang.go index ca415e1..69c9ef4 100644 --- a/lang.go +++ b/lang.go @@ -136,6 +136,23 @@ func (ls *setupLangs) getOptions() [][2]string { 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 tmpl map[string]string diff --git a/lang/form/en-us.json b/lang/form/en-us.json index e1c0575..c6411e6 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -17,11 +17,14 @@ "successContinueButton": "Continue", "confirmationRequired": "Email confirmation required", "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}.", + "linkTelegram": "Link Telegram", + "sendPIN": "Send the PIN below to the bot, then come back here to link your account." }, "notifications": { "errorUserExists": "User already exists.", - "errorInvalidCode": "Invalid invite code." + "errorInvalidCode": "Invalid invite code.", + "telegramVerified": "Telegram account verified." }, "validationStrings": { "length": { diff --git a/main.go b/main.go index dab521a..70af2e0 100644 --- a/main.go +++ b/main.go @@ -93,6 +93,7 @@ type appContext struct { storage Storage validator Validator email *Emailer + telegram *TelegramDaemon info, debug, err logger.Logger host string port int @@ -257,6 +258,7 @@ func start(asDaemon, firstCall bool) { app.storage.lang.FormPath = "form" app.storage.lang.AdminPath = "admin" app.storage.lang.EmailPath = "email" + app.storage.lang.TelegramPath = "telegram" app.storage.lang.PasswordResetPath = "pwreset" externalLang := app.config.Section("files").Key("lang_files").MustString("") var err error @@ -325,6 +327,10 @@ func start(asDaemon, firstCall bool) { if err := app.storage.loadUsers(); err != nil { 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.loadProfiles() @@ -541,6 +547,16 @@ func start(asDaemon, firstCall bool) { if app.config.Section("updates").Key("enabled").MustBool(false) { go app.checkForUpdates() } + + if app.config.Section("telegram").Key("enabled").MustBool(false) { + 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 { debugMode = false address = "0.0.0.0:8056" diff --git a/router.go b/router.go index 4c15516..63892ad 100644 --- a/router.go +++ b/router.go @@ -118,6 +118,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.POST(p+"/newUser", app.NewUser) router.Use(static.Serve(p+"/invite/", app.webFS)) router.GET(p+"/invite/:invCode", app.InviteProxy) + if app.config.Section("telegram").Key("enabled").MustBool(false) { + router.GET(p+"/invite/:invCode/telegram/verified/:pin", app.TelegramVerified) + } } if *SWAGGER { app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n")) diff --git a/storage.go b/storage.go index 1bc7fef..93dcc1a 100644 --- a/storage.go +++ b/storage.go @@ -15,18 +15,25 @@ import ( ) type Storage struct { - timePattern string - invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path string - users map[string]time.Time - invites Invites - profiles map[string]Profile - defaultProfile string - emails, displayprefs, ombi_template map[string]interface{} - customEmails customEmails - policy mediabrowser.Policy - configuration mediabrowser.Configuration - lang Lang - invitesLock, usersLock sync.Mutex + timePattern string + invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path string + users map[string]time.Time + invites Invites + profiles map[string]Profile + defaultProfile string + emails, displayprefs, ombi_template map[string]interface{} + telegram map[int64]TelegramUser + customEmails customEmails + policy mediabrowser.Policy + configuration mediabrowser.Configuration + lang Lang + invitesLock, usersLock sync.Mutex +} + +type TelegramUser struct { + ChatID int64 + Username string + Lang string } type customEmails struct { @@ -81,23 +88,26 @@ type Invite struct { } type Lang struct { - AdminPath string - chosenAdminLang string - Admin adminLangs - AdminJSON map[string]string - FormPath string - chosenFormLang string - Form formLangs - PasswordResetPath string - chosenPWRLang string - PasswordReset pwrLangs - EmailPath string - chosenEmailLang string - Email emailLangs - CommonPath string - Common commonLangs - SetupPath string - Setup setupLangs + AdminPath string + chosenAdminLang string + Admin adminLangs + AdminJSON map[string]string + FormPath string + chosenFormLang string + Form formLangs + PasswordResetPath string + chosenPWRLang string + PasswordReset pwrLangs + EmailPath string + chosenEmailLang string + Email emailLangs + CommonPath string + Common commonLangs + SetupPath string + Setup setupLangs + chosenTelegramLang string + TelegramPath string + Telegram telegramLangs } func (st *Storage) loadLang(filesystems ...fs.FS) (err error) { @@ -118,6 +128,10 @@ func (st *Storage) loadLang(filesystems ...fs.FS) (err error) { return } err = st.loadLangEmail(filesystems...) + if err != nil { + return + } + err = st.loadLangTelegram(filesystems...) return } @@ -620,6 +634,83 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error { 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 func (st *Storage) loadInvites() error { @@ -665,6 +756,14 @@ func (st *Storage) storeEmails() error { 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 { return loadJSON(st.customEmails_path, &st.customEmails) } diff --git a/telegram.go b/telegram.go new file mode 100644 index 0000000..5d06e93 --- /dev/null +++ b/telegram.go @@ -0,0 +1,201 @@ +package main + +import ( + "fmt" + "math/rand" + "strconv" + "strings" + "time" + + tg "github.com/go-telegram-bot-api/telegram-bot-api" +) + +type TelegramDaemon struct { + Stopped bool + ShutdownChannel chan string + bot *tg.BotAPI + username string + tokens []string + verifiedTokens []string + 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 + } + return &TelegramDaemon{ + Stopped: false, + ShutdownChannel: make(chan string), + bot: bot, + username: bot.Self.UserName, + tokens: []string{}, + verifiedTokens: []string{}, + link: "https://t.me/" + bot.Self.UserName, + app: app, + }, 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 + user, ok := t.app.storage.telegram[upd.Message.Chat.ID] + if !ok { + user := TelegramUser{ + Username: upd.Message.Chat.UserName, + ChatID: upd.Message.Chat.ID, + Lang: "", + } + t.app.storage.telegram[upd.Message.Chat.ID] = user + err := t.app.storage.storeTelegramUsers() + if err != nil { + t.app.err.Printf("Failed to store Telegram users: %v", err) + } + } + if user.Lang != "" { + lang = user.Lang + } else { + for code := range t.app.storage.lang.Telegram { + if code[:2] == upd.Message.From.LanguageCode { + lang = code + break + } + } + } + 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 \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 { + user.Lang = sects[1] + t.app.storage.telegram[upd.Message.Chat.ID] = user + err := t.app.storage.storeTelegramUsers() + if err != nil { + t.app.err.Printf("Failed to store Telegram users: %v", err) + } + } + 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("success")) + 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, upd.Message.Text) + 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.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 adds compatibility with EmailClient, fromName/fromAddr are discarded, message.Text is used, addresses are Chat IDs as strings. +func (t *TelegramDaemon) Send(fromName, fromAddr string, message *Message, address ...string) error { + for _, addr := range address { + ChatID, err := strconv.ParseInt(addr, 10, 64) + if err != nil { + return err + } + msg := tg.NewMessage(ChatID, message.Text) + _, 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) +} diff --git a/ts/form.ts b/ts/form.ts index 7bb56c6..c6a1adb 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -1,15 +1,20 @@ import { Modal } from "./modules/modal.js"; +import { notificationBox } from "./modules/common.js"; import { _get, _post, toggleLoader, toDateString } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; interface formWindow extends Window { validationStrings: pwValStrings; invalidPassword: string; - modal: Modal; + successModal: Modal; + telegramModal: Modal; + confirmationModal: Modal code: string; messages: { [key: string]: string }; confirmation: boolean; - confirmationModal: Modal + telegramEnabled: boolean; + telegramRequired: boolean; + telegramPIN: string; userExpiryEnabled: boolean; userExpiryMonths: number; userExpiryDays: number; @@ -34,7 +39,37 @@ interface pwValStrings { loadLangSelector("form"); -window.modal = new Modal(document.getElementById("modal-success"), true); +window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement); + +window.successModal = new Modal(document.getElementById("modal-success"), true); + +if (window.telegramEnabled) { + window.telegramModal = new Modal(document.getElementById("modal-telegram"), window.telegramRequired); + (document.getElementById("link-telegram") as HTMLSpanElement).onclick = () => { + const waiting = document.getElementById("telegram-waiting") as HTMLSpanElement; + toggleLoader(waiting); + window.telegramModal.show(); + 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.lang.notif("errorInvalidCode")); + return; + } else if (req.status == 200) { + if (req.response["success"] as boolean) { + toggleLoader(waiting); + window.telegramModal.close() + window.notifications.customSuccess("accountVerified", window.lang.notif("telegramVerified")) + } else { + setTimeout(checkVerified, 1500); + } + } + } + }); + checkVerified(); + }; +} + if (window.confirmation) { window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true); } @@ -130,7 +165,7 @@ const create = (event: SubmitEvent) => { if (!vals[type]) { valid = false; } } if (req.status == 200 && valid) { - window.modal.show(); + window.successModal.show(); } else { submitSpan.classList.add("~critical"); submitSpan.classList.remove("~urge"); diff --git a/views.go b/views.go index 8e7e801..f103bab 100644 --- a/views.go +++ b/views.go @@ -259,7 +259,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { if strings.Contains(email, "Failed") { email = "" } - gcHTML(gc, http.StatusOK, "form-loader.html", gin.H{ + data := gin.H{ "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, "contactMessage": app.config.Section("ui").Key("contact_message").String(), @@ -282,7 +282,15 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "userExpiryMinutes": inv.UserMinutes, "userExpiryMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"), "langName": lang, - }) + "telegramEnabled": app.config.Section("telegram").Key("enabled").MustBool(false), + } + 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) { From 2816c6277da54fea187ac818d3f79c99ca969b8f Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 13:22:07 +0100 Subject: [PATCH 02/15] modal: add onopen/onclose --- ts/modules/modal.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ts/modules/modal.ts b/ts/modules/modal.ts index 398df93..95be1c1 100644 --- a/ts/modules/modal.ts +++ b/ts/modules/modal.ts @@ -3,8 +3,12 @@ declare var window: Window; export class Modal implements Modal { modal: HTMLElement; closeButton: HTMLSpanElement; + openEvent: CustomEvent; + closeEvent: CustomEvent; constructor(modal: HTMLElement, important: boolean = false) { this.modal = modal; + 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) { this.closeButton = closeButton as HTMLSpanElement; @@ -26,11 +30,21 @@ export class Modal implements Modal { modal.classList.remove('modal-shown'); modal.classList.remove('modal-hiding'); modal.removeEventListener(window.animationEvent, listenerFunc); + document.dispatchEvent(this.closeEvent); }; 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 = () => { this.modal.classList.add('modal-shown'); + document.dispatchEvent(this.openEvent); } toggle = () => { if (this.modal.classList.contains('modal-shown')) { From 326c2cf70a5bb67c2a02f5a803d6f22830195bd4 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 14:30:30 +0100 Subject: [PATCH 03/15] modal: use arrow function to avoid 'this' naming collision --- ts/modules/modal.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ts/modules/modal.ts b/ts/modules/modal.ts index 95be1c1..7bbc071 100644 --- a/ts/modules/modal.ts +++ b/ts/modules/modal.ts @@ -7,9 +7,9 @@ export class Modal implements Modal { closeEvent: CustomEvent; constructor(modal: HTMLElement, important: boolean = false) { this.modal = modal; - this.openEvent = new CustomEvent("modal-open-" + modal.id) - this.closeEvent = new CustomEvent("modal-close-" + modal.id) - 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) { this.closeButton = closeButton as HTMLSpanElement; this.closeButton.onclick = this.close; @@ -26,7 +26,7 @@ export class Modal implements Modal { } this.modal.classList.add('modal-hiding'); const modal = this.modal; - const listenerFunc = function () { + const listenerFunc = () => { modal.classList.remove('modal-shown'); modal.classList.remove('modal-hiding'); modal.removeEventListener(window.animationEvent, listenerFunc); From 72bf280e2d7212b2404eac5edd7079f9dcbd3d2f Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 14:32:51 +0100 Subject: [PATCH 04/15] telegram: Fix UI and store useful Telegram info Creation now works, and language preferences made before signup are kept. telegram file storage now uses the Jellyfin ID as a key, which makes much more sense. Also added radios to select preferred notification method (email/telegram) as well, which the admin will soon be able to change also. --- api.go | 76 ++++++++++++++++++++++++++++++++++++-------- html/form.html | 12 +++++-- lang/form/en-us.json | 6 +++- models.go | 10 +++--- storage.go | 3 +- telegram.go | 69 +++++++++++++++++++++++++--------------- ts/form.ts | 34 ++++++++++++++++---- 7 files changed, 157 insertions(+), 53 deletions(-) diff --git a/api.go b/api.go index ee09b09..4a00fbb 100644 --- a/api.go +++ b/api.go @@ -330,15 +330,44 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc success = false return } + telegramTokenIndex := -1 + if app.config.Section("telegram").Key("enabled").MustBool(false) { + 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 { claims := jwt.MapClaims{ - "valid": true, - "invite": req.Code, - "email": req.Email, - "username": req.Username, - "password": req.Password, - "exp": strconv.FormatInt(time.Now().Add(time.Hour*12).Unix(), 10), - "type": "confirmation", + "valid": true, + "invite": req.Code, + "email": req.Email, + "username": req.Username, + "password": req.Password, + "telegramPIN": req.TelegramPIN, + "exp": strconv.FormatInt(time.Now().Add(time.Hour*12).Unix(), 10), + "type": "confirmation", } tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) @@ -450,6 +479,27 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc app.err.Printf("Failed to store user duration: %v", err) } } + + if app.config.Section("telegram").Key("enabled").MustBool(false) && 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 + } + 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 != "" { app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email) msg, err := app.email.constructWelcome(req.Username, expiry, app, false) @@ -1891,16 +1941,16 @@ func (app *appContext) TelegramVerified(gc *gin.Context) { pin := gc.Param("pin") tokenIndex := -1 for i, v := range app.telegram.verifiedTokens { - if v == pin { + 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] - } + // 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) } diff --git a/html/form.html b/html/form.html index 7f14330..df3ee14 100644 --- a/html/form.html +++ b/html/form.html @@ -33,7 +33,7 @@ @{{ .telegramUsername }} - . + {{ .strings.success }}
{{ end }} @@ -68,7 +68,15 @@ {{ if .telegramEnabled }} - {{ .strings.linkTelegram }} + {{ .strings.linkTelegram }} +
+ + +
{{ end }} diff --git a/lang/form/en-us.json b/lang/form/en-us.json index c6411e6..36b1089 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -19,11 +19,15 @@ "confirmationRequiredMessage": "Please check your email inbox to verify your address.", "yourAccountIsValidUntil": "Your account will be valid until {date}.", "linkTelegram": "Link Telegram", - "sendPIN": "Send the PIN below to the bot, then come back here to link your account." + "sendPIN": "Send the PIN below to the bot, then come back here to link your account.", + "contactEmail": "Contact through Email", + "contactTelegram": "Contact through Telegram" }, "notifications": { "errorUserExists": "User already exists.", "errorInvalidCode": "Invalid invite code.", + "errorTelegramVerification": "Telegram verification required.", + "errorInvalidPIN": "Telegram PIN is invalid.", "telegramVerified": "Telegram account verified." }, "validationStrings": { diff --git a/models.go b/models.go index 0dd9be3..28a2241 100644 --- a/models.go +++ b/models.go @@ -11,10 +11,12 @@ type boolResponse struct { } type newUserDTO struct { - Username string `json:"username" example:"jeff" binding:"required"` // User's username - Password string `json:"password" example:"guest" binding:"required"` // User's password - Email string `json:"email" example:"jeff@jellyf.in"` // User's email address - Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser) + Username string `json:"username" example:"jeff" binding:"required"` // User's username + Password string `json:"password" example:"guest" binding:"required"` // User's password + Email string `json:"email" example:"jeff@jellyf.in"` // User's email address + 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 { diff --git a/storage.go b/storage.go index 93dcc1a..0fe7333 100644 --- a/storage.go +++ b/storage.go @@ -22,7 +22,7 @@ type Storage struct { profiles map[string]Profile defaultProfile string emails, displayprefs, ombi_template map[string]interface{} - telegram map[int64]TelegramUser + telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. customEmails customEmails policy mediabrowser.Policy configuration mediabrowser.Configuration @@ -34,6 +34,7 @@ type TelegramUser struct { ChatID int64 Username string Lang string + Contact bool // Whether to contact through telegram or not } type customEmails struct { diff --git a/telegram.go b/telegram.go index 5d06e93..f8655f0 100644 --- a/telegram.go +++ b/telegram.go @@ -10,13 +10,20 @@ import ( 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 []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 } @@ -30,16 +37,23 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) { if err != nil { return nil, err } - return &TelegramDaemon{ + td := &TelegramDaemon{ Stopped: false, ShutdownChannel: make(chan string), bot: bot, username: bot.Self.UserName, tokens: []string{}, - verifiedTokens: []string{}, + verifiedTokens: []VerifiedToken{}, + languages: map[int64]string{}, link: "https://t.me/" + bot.Self.UserName, app: app, - }, nil + } + for _, user := range app.storage.telegram { + if user.Lang != "" { + td.languages[user.ChatID] = user.Lang + } + } + return td, nil } var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") @@ -80,28 +94,21 @@ func (t *TelegramDaemon) run() { continue } lang := t.app.storage.lang.chosenTelegramLang - user, ok := t.app.storage.telegram[upd.Message.Chat.ID] + storedLang, ok := t.languages[upd.Message.Chat.ID] if !ok { - user := TelegramUser{ - Username: upd.Message.Chat.UserName, - ChatID: upd.Message.Chat.ID, - Lang: "", - } - t.app.storage.telegram[upd.Message.Chat.ID] = user - err := t.app.storage.storeTelegramUsers() - if err != nil { - t.app.err.Printf("Failed to store Telegram users: %v", err) - } - } - if user.Lang != "" { - lang = user.Lang - } else { + 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": @@ -125,11 +132,17 @@ func (t *TelegramDaemon) run() { continue } if _, ok := t.app.storage.lang.Telegram[sects[1]]; ok { - user.Lang = sects[1] - t.app.storage.telegram[upd.Message.Chat.ID] = user - err := t.app.storage.storeTelegramUsers() - if err != nil { - t.app.err.Printf("Failed to store Telegram users: %v", err) + 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 @@ -148,11 +161,15 @@ func (t *TelegramDaemon) run() { } continue } - err := t.QuoteReply(&upd, t.app.storage.lang.Telegram[lang].Strings.get("success")) + 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, upd.Message.Text) + 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] } diff --git a/ts/form.ts b/ts/form.ts index c6a1adb..5e3a019 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -1,5 +1,5 @@ import { Modal } from "./modules/modal.js"; -import { notificationBox } from "./modules/common.js"; +import { notificationBox, whichAnimationEvent } from "./modules/common.js"; import { _get, _post, toggleLoader, toDateString } from "./modules/common.js"; import { loadLangSelector } from "./modules/lang.js"; @@ -41,26 +41,39 @@ loadLangSelector("form"); 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); - (document.getElementById("link-telegram") as HTMLSpanElement).onclick = () => { + 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; } 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.lang.notif("errorInvalidCode")); + window.notifications.customError("invalidCodeError", window.messages["errorInvalidCode"]); return; } else if (req.status == 200) { if (req.response["success"] as boolean) { + telegramVerified = true; toggleLoader(waiting); - window.telegramModal.close() - window.notifications.customSuccess("accountVerified", window.lang.notif("telegramVerified")) - } else { + 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); } } @@ -145,6 +158,8 @@ interface sendDTO { email: string; username: string; password: string; + telegram_pin?: string; + telegram_contact?: boolean; } const create = (event: SubmitEvent) => { @@ -156,6 +171,13 @@ const create = (event: SubmitEvent) => { email: emailField.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) => { if (req.readyState == 4) { let vals = req.response as respDTO; From 716d6a931a74112af0d36a0a36820017e1ace900 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 16:06:47 +0100 Subject: [PATCH 05/15] Telegram: Send messages via telegram Most messages are now sent as plaintext via telegram when suitable. --- api.go | 104 +++++++++++++++++------------------------ email.go | 27 +++++++++++ html/admin.html | 1 + pwreset.go | 29 ++++++------ telegram.go | 15 ++---- ts/form.ts | 1 - ts/modules/accounts.ts | 4 +- ts/typings/d.ts | 1 + userdaemon.go | 19 ++++---- views.go | 29 ++++++------ 10 files changed, 118 insertions(+), 112 deletions(-) diff --git a/api.go b/api.go index 4a00fbb..494a19e 100644 --- a/api.go +++ b/api.go @@ -282,7 +282,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { } } 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.storeEmails() } @@ -500,15 +500,16 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } } - 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 (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) if err != nil { - app.err.Printf("%s: Failed to construct welcome email: %v", req.Username, err) - } else if err := app.email.send(msg, req.Email); err != nil { - app.err.Printf("%s: Failed to send welcome email: %v", req.Username, err) + app.err.Printf("%s: Failed to construct welcome message: %v", req.Username, err) + } else if err := app.sendByID(msg, user.ID); err != nil { + app.err.Printf("%s: Failed to send welcome message: %v", req.Username, err) } 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() @@ -575,7 +576,20 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { "GetUser": map[string]string{}, "SetPolicy": map[string]string{}, } - var addresses []string + sendMail := emailEnabled || app.config.Section("telegram").Key("enabled").MustBool(false) + 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 { user, status, err := app.jf.UserByID(userID, false) if status != 200 || err != nil { @@ -590,31 +604,13 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err) continue } - if emailEnabled && req.Notify { - addr, ok := app.storage.emails[userID] - if addr != nil && ok { - addresses = append(addresses, addr.(string)) + if sendMail && req.Notify { + if err := app.sendByID(msg, userID); err != nil { + app.err.Printf("Failed to send account enabled/disabled email: %v", err) + continue } } } - if len(addresses) != 0 { - go func(reason string, addresses []string) { - var msg *Message - 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() if len(errors["GetUser"]) != 0 || len(errors["SetPolicy"]) != 0 { gc.JSON(500, errors) @@ -634,10 +630,19 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { // @tags Users func (app *appContext) DeleteUsers(gc *gin.Context) { var req deleteUserDTO - var addresses []string gc.BindJSON(&req) errors := map[string]string{} ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) + sendMail := emailEnabled || app.config.Section("telegram").Key("enabled").MustBool(false) + 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 { if ombiEnabled { ombiUser, code, err := app.getOmbiUser(userID) @@ -660,25 +665,12 @@ func (app *appContext) DeleteUsers(gc *gin.Context) { errors[userID] += msg } } - if emailEnabled && req.Notify { - addr, ok := app.storage.emails[userID] - if addr != nil && ok { - addresses = append(addresses, addr.(string)) + if sendMail && req.Notify { + if err := app.sendByID(msg, userID); err != nil { + app.err.Printf("Failed to send account deletion email: %v", err) } } } - 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() if len(errors) == len(req.Users) { respondBool(500, false, gc) @@ -735,29 +727,21 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) { func (app *appContext) Announce(gc *gin.Context) { var req announcementDTO gc.BindJSON(&req) - if !emailEnabled { + if !(emailEnabled || app.config.Section("telegram").Key("enabled").MustBool(false)) { respondBool(400, false, gc) 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) 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) return - } else if err := app.email.send(msg, addresses...); err != nil { - app.err.Printf("Failed to send announcement emails: %v", err) + } else if err := app.sendByID(msg, req.Users...); err != nil { + app.err.Printf("Failed to send announcement messages: %v", err) respondBool(500, false, gc) return } - app.info.Printf("Sent announcement email to %d users", len(addresses)) + app.info.Println("Sent announcement messages") respondBool(200, true, gc) } diff --git a/email.go b/email.go index e7c4f65..bded1c8 100644 --- a/email.go +++ b/email.go @@ -755,3 +755,30 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Mess func (emailer *Emailer) send(email *Message, address ...string) error { return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...) } + +func (app *appContext) sendByID(email *Message, ID ...string) error { + tgEnabled := app.config.Section("telegram").Key("enabled").MustBool(false) + for _, id := range ID { + var err error + if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && tgEnabled { + 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 { + tgEnabled := app.config.Section("telegram").Key("enabled").MustBool(false) + if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && tgEnabled { + return "@" + tgChat.Username + } + if addr, ok := app.storage.emails[jfID]; ok { + return addr.(string) + } + return "" +} diff --git a/html/admin.html b/html/admin.html index fb5912d..408b2bf 100644 --- a/html/admin.html +++ b/html/admin.html @@ -6,6 +6,7 @@ window.URLBase = "{{ .urlBase }}"; window.notificationsEnabled = {{ .notifications }}; window.emailEnabled = {{ .email_enabled }}; + window.telegramEnabled = {{ .telegram_enabled }}; window.ombiEnabled = {{ .ombiEnabled }}; window.usernameEnabled = {{ .username }}; window.langFile = JSON.parse({{ .language }}); diff --git a/pwreset.go b/pwreset.go index 98ed169..7518c6d 100644 --- a/pwreset.go +++ b/pwreset.go @@ -69,27 +69,24 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) { return } app.storage.loadEmails() - var address string uid := user.ID if uid == "" { app.err.Printf("Couldn't get user ID for user \"%s\"", pwr.Username) return } - addr, ok := app.storage.emails[uid] - if !ok || addr == nil { - app.err.Printf("Couldn't find email for user \"%s\". Make sure it's set", pwr.Username) - return - } - address = addr.(string) - msg, err := app.email.constructReset(pwr, app, false) - if err != nil { - app.err.Printf("Failed to construct password reset email for %s", pwr.Username) - app.debug.Printf("%s: Error: %s", pwr.Username, err) - } else if err := app.email.send(msg, address); err != nil { - app.err.Printf("Failed to send password reset email to \"%s\"", address) - app.debug.Printf("%s: Error: %s", pwr.Username, err) - } else { - app.info.Printf("Sent password reset email to \"%s\"", address) + name := app.getAddressOrName(uid) + if name != "" { + msg, err := app.email.constructReset(pwr, app, false) + + if err != nil { + app.err.Printf("Failed to construct password reset message for %s", pwr.Username) + app.debug.Printf("%s: Error: %s", pwr.Username, err) + } else if err := app.sendByID(msg, uid); err != nil { + app.err.Printf("Failed to send password reset message to \"%s\"", name) + app.debug.Printf("%s: Error: %s", pwr.Username, err) + } else { + app.info.Printf("Sent password reset message to \"%s\"", name) + } } } else { app.err.Printf("Password reset for user \"%s\" has already expired (%s). Check your time settings.", pwr.Username, pwr.Expiry) diff --git a/telegram.go b/telegram.go index f8655f0..1b23c48 100644 --- a/telegram.go +++ b/telegram.go @@ -3,7 +3,6 @@ package main import ( "fmt" "math/rand" - "strconv" "strings" "time" @@ -194,15 +193,11 @@ func (t *TelegramDaemon) QuoteReply(upd *tg.Update, content string) error { return err } -// Send adds compatibility with EmailClient, fromName/fromAddr are discarded, message.Text is used, addresses are Chat IDs as strings. -func (t *TelegramDaemon) Send(fromName, fromAddr string, message *Message, address ...string) error { - for _, addr := range address { - ChatID, err := strconv.ParseInt(addr, 10, 64) - if err != nil { - return err - } - msg := tg.NewMessage(ChatID, message.Text) - _, err = t.bot.Send(msg) +// Send will send a telegram message to a list of chat IDs. message.text is used. +func (t *TelegramDaemon) Send(message *Message, ID ...int64) error { + for _, id := range ID { + msg := tg.NewMessage(id, message.Text) + _, err := t.bot.Send(msg) if err != nil { return err } diff --git a/ts/form.ts b/ts/form.ts index 5e3a019..17c84a1 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -12,7 +12,6 @@ interface formWindow extends Window { code: string; messages: { [key: string]: string }; confirmation: boolean; - telegramEnabled: boolean; telegramRequired: boolean; telegramPIN: string; userExpiryEnabled: boolean; diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index 15d17bb..9db78a5 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -334,7 +334,7 @@ export class accountsList { this._selectAll.checked = false; this._modifySettings.classList.add("unfocused"); this._deleteUser.classList.add("unfocused"); - if (window.emailEnabled) { + if (window.emailEnabled || window.telegramEnabled) { this._announceButton.classList.add("unfocused"); } this._extendExpiry.classList.add("unfocused"); @@ -356,7 +356,7 @@ export class accountsList { this._modifySettings.classList.remove("unfocused"); this._deleteUser.classList.remove("unfocused"); this._deleteUser.textContent = window.lang.quantity("deleteUser", list.length); - if (window.emailEnabled) { + if (window.emailEnabled || window.telegramEnabled) { this._announceButton.classList.remove("unfocused"); } let anyNonExpiries = list.length == 0 ? true : false; diff --git a/ts/typings/d.ts b/ts/typings/d.ts index a6aa1b9..70a260f 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -18,6 +18,7 @@ declare interface Window { jfUsers: Array; notificationsEnabled: boolean; emailEnabled: boolean; + telegramEnabled: boolean; ombiEnabled: boolean; usernameEnabled: boolean; token: string; diff --git a/userdaemon.go b/userdaemon.go index 627f4de..39f46b3 100644 --- a/userdaemon.go +++ b/userdaemon.go @@ -71,9 +71,10 @@ func (app *appContext) checkUsers() { mode = "delete" termPlural = "Deleting" } - email := false - if emailEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) { - email = true + contact := false + if (emailEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true)) || + app.config.Section("telegram").Key("enabled").MustBool(false) { + contact = true } // Use a map to speed up checking for deleted users later userExists := map[string]bool{} @@ -114,18 +115,18 @@ func (app *appContext) checkUsers() { } delete(app.storage.users, id) app.jf.CacheExpiry = time.Now() - if email { - address, ok := app.storage.emails[id] + if contact { if !ok { continue } + name := app.getAddressOrName(user.ID) msg, err := app.email.constructUserExpired(app, false) if err != nil { - app.err.Printf("Failed to construct expiry email for \"%s\": %s", user.Name, err) - } else if err := app.email.send(msg, address.(string)); err != nil { - app.err.Printf("Failed to send expiry email to \"%s\": %s", user.Name, err) + app.err.Printf("Failed to construct expiry message for \"%s\": %s", user.Name, err) + } else if err := app.sendByID(msg, user.ID); err != nil { + app.err.Printf("Failed to send expiry message to \"%s\": %s", name, err) } else { - app.info.Printf("Sent expiry notification to \"%s\"", address.(string)) + app.info.Printf("Sent expiry notification to \"%s\"", name) } } } diff --git a/views.go b/views.go index f103bab..11c751f 100644 --- a/views.go +++ b/views.go @@ -116,20 +116,21 @@ func (app *appContext) AdminPage(gc *gin.Context) { } license = string(l) gcHTML(gc, http.StatusOK, "admin.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "contactMessage": "", - "email_enabled": emailEnabled, - "notifications": notificationsEnabled, - "version": version, - "commit": commit, - "ombiEnabled": ombiEnabled, - "username": !app.config.Section("email").Key("no_username").MustBool(false), - "strings": app.storage.lang.Admin[lang].Strings, - "quantityStrings": app.storage.lang.Admin[lang].QuantityStrings, - "language": app.storage.lang.Admin[lang].JSON, - "langName": lang, - "license": license, + "urlBase": app.getURLBase(gc), + "cssClass": app.cssClass, + "contactMessage": "", + "email_enabled": emailEnabled, + "telegram_enabled": app.config.Section("telegram").Key("enabled").MustBool(false), + "notifications": notificationsEnabled, + "version": version, + "commit": commit, + "ombiEnabled": ombiEnabled, + "username": !app.config.Section("email").Key("no_username").MustBool(false), + "strings": app.storage.lang.Admin[lang].Strings, + "quantityStrings": app.storage.lang.Admin[lang].QuantityStrings, + "language": app.storage.lang.Admin[lang].JSON, + "langName": lang, + "license": license, }) } From 36edd4ab0d083b903ac0168a7a2a1cf84c115de1 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 16:33:44 +0100 Subject: [PATCH 06/15] Telegram: Use markdown for custom emails/announcements Had no idea telegram supported this, pretty cool. --- email.go | 75 ++++++++++++++++++++++++++++++++++++++--------------- telegram.go | 10 +++++-- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/email.go b/email.go index bded1c8..ef9a846 100644 --- a/email.go +++ b/email.go @@ -9,6 +9,7 @@ import ( "fmt" "html/template" "io" + "io/fs" "net/smtp" "os" "strconv" @@ -102,9 +103,10 @@ type Emailer struct { // Message stores content. type Message struct { - Subject string `json:"subject"` - HTML string `json:"html"` - Text string `json:"text"` + Subject string `json:"subject"` + HTML string `json:"html"` + Text string `json:"text"` + Markdown string `json:"markdown"` } func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) { @@ -204,7 +206,7 @@ type templ interface { 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 if substituteStrings == "" { data["jellyfin"] = "Jellyfin" @@ -212,14 +214,31 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data["jellyfin"] = substituteStrings } var keys []string - if app.config.Section("email").Key("plaintext").MustBool(false) { - keys = []string{"text"} - text = "" + plaintext := app.config.Section("email").Key("plaintext").MustBool(false) + telegram := app.config.Section("telegram").Key("enabled").MustBool(false) + if plaintext { + if telegram { + keys = []string{"text"} + text, markdown = "", "" + } else { + keys = []string{"text"} + text = "" + } } else { - keys = []string{"html", "text"} + if telegram { + keys = []string{"html", "text", "markdown"} + } else { + keys = []string{"html", "text"} + } } 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" { tpl, err = template.ParseFS(filesystem, fpath) } else { @@ -228,15 +247,28 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string, if err != nil { 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 err = tpl.Execute(&tplData, data) if err != nil { return } + if foundMarkdown { + data["plaintext"], data["md"] = data["md"], data["plaintext"] + } if key == "html" { html = tplData.String() - } else { + } else if key == "text" { text = tplData.String() + } else { + markdown = tplData.String() } } return @@ -282,7 +314,7 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err @@ -297,10 +329,11 @@ func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) ( text := stripMarkdown(md) message := app.config.Section("email").Key("message").String() 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), "plaintext": text, "message": message, + "md": md, }) if err != nil { return nil, err @@ -353,7 +386,7 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err @@ -392,7 +425,7 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err @@ -446,7 +479,7 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err @@ -520,7 +553,7 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err @@ -561,7 +594,7 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err @@ -602,7 +635,7 @@ func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err @@ -643,7 +676,7 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err @@ -708,7 +741,7 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err @@ -743,7 +776,7 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Mess ) email, err = emailer.constructTemplate(email.Subject, content, app) } 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 { return nil, err diff --git a/telegram.go b/telegram.go index 1b23c48..9e9fcb3 100644 --- a/telegram.go +++ b/telegram.go @@ -193,10 +193,16 @@ func (t *TelegramDaemon) QuoteReply(upd *tg.Update, content string) error { return err } -// Send will send a telegram message to a list of chat IDs. message.text is used. +// 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 { - msg := tg.NewMessage(id, message.Text) + 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 From 0f41d1e6cf9cc946d155e508e1a15f6704de7c11 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 17:01:22 +0100 Subject: [PATCH 07/15] Telegram: Display username on accounts tab --- api.go | 4 +++- html/admin.html | 3 +++ models.go | 1 + ts/modules/accounts.ts | 34 ++++++++++++++++++++++++++++------ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/api.go b/api.go index 494a19e..75a013c 100644 --- a/api.go +++ b/api.go @@ -1171,7 +1171,9 @@ func (app *appContext) GetUsers(gc *gin.Context) { if ok { user.Expiry = expiry.Unix() } - + if tgUser, ok := app.storage.telegram[jfUser.ID]; ok { + user.Telegram = tgUser.Username + } resp.UserList[i] = user i++ } diff --git a/html/admin.html b/html/admin.html index 408b2bf..955e1be 100644 --- a/html/admin.html +++ b/html/admin.html @@ -504,6 +504,9 @@ {{ .strings.username }} {{ .strings.emailAddress }} + {{ if .telegram_enabled }} + Telegram + {{ end }} {{ .strings.expiry }} {{ .strings.lastActiveTime }} diff --git a/models.go b/models.go index 28a2241..8bb844f 100644 --- a/models.go +++ b/models.go @@ -129,6 +129,7 @@ type respUser struct { Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator Expiry int64 `json:"expiry" example:"1617737207510"` // Expiry time of user as Epoch/Unix time. Disabled bool `json:"disabled"` // Whether or not the user is disabled. + Telegram string `json:"telegram"` // Telegram username (if known) } type getUsersDTO struct { diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index 9db78a5..60bef54 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -11,6 +11,7 @@ interface User { admin: boolean; disabled: boolean; expiry: number; + telegram: string; } class user implements User { @@ -22,6 +23,8 @@ class user implements User { private _email: HTMLInputElement; private _emailAddress: string; private _emailEditButton: HTMLElement; + private _telegram: HTMLTableDataCellElement; + private _telegramUsername: string; private _expiry: HTMLTableDataCellElement; private _expiryUnix: number; private _lastActive: HTMLTableDataCellElement; @@ -72,6 +75,18 @@ 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.textContent = ""; + } else { + this._telegram.innerHTML = `@${u}`; + } + } + + get expiry(): number { return this._expiryUnix; } set expiry(unix: number) { this._expiryUnix = unix; @@ -97,13 +112,21 @@ class user implements User { constructor(user: User) { this._row = document.createElement("tr") as HTMLTableRowElement; - this._row.innerHTML = ` + let innerHTML = ` - - `; + if (window.telegramEnabled) { + innerHTML += ` + + `; + } + innerHTML += ` + + + `; + this._row.innerHTML = innerHTML; const emailEditor = ``; this._check = this._row.querySelector("input[type=checkbox]") as HTMLInputElement; this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement; @@ -111,6 +134,7 @@ class user implements User { this._disabled = this._row.querySelector(".accounts-disabled") as HTMLSpanElement; this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement; this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement; + this._telegram = this._row.querySelector(".accounts-telegram") as HTMLTableDataCellElement; this._expiry = this._row.querySelector(".accounts-expiry") as HTMLTableDataCellElement; this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement; this._check.onchange = () => { this.selected = this._check.checked; } @@ -173,6 +197,7 @@ class user implements User { this.id = user.id; this.name = user.name; this.email = user.email || ""; + this.telegram = user.telegram; this.last_active = user.last_active; this.admin = user.admin; this.disabled = user.disabled; @@ -188,9 +213,6 @@ class user implements User { } } - - - export class accountsList { private _table = document.getElementById("accounts-list") as HTMLTableSectionElement; From 2d93b3b7eebbb9a4b214c1ce3cfd3175aed9d756 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 18:20:35 +0100 Subject: [PATCH 08/15] Telegram: Allow admin to add telegram contact Works in the same way as on the form, but can now be done in the accounts tab. --- api.go | 102 ++++++++++++++++++++++++++++++++++++++++- css/base.css | 5 ++ html/admin.html | 18 ++++++++ lang/admin/en-us.json | 4 +- lang/common/en-us.json | 3 ++ lang/form/en-us.json | 5 +- models.go | 10 ++++ router.go | 7 ++- ts/admin.ts | 4 ++ ts/form.ts | 6 ++- ts/modules/accounts.ts | 55 +++++++++++++++++++++- ts/modules/common.ts | 19 ++++++++ ts/typings/d.ts | 3 ++ 13 files changed, 229 insertions(+), 12 deletions(-) diff --git a/api.go b/api.go index 75a013c..6a4e38f 100644 --- a/api.go +++ b/api.go @@ -490,6 +490,9 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc 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 { @@ -1474,6 +1477,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) { // @Param lang query string false "Language for email titles." // @Success 200 {object} emailListDTO // @Router /config/emails [get] +// @Security Bearer // @tags Configuration func (app *appContext) GetCustomEmails(gc *gin.Context) { lang := gc.Query("lang") @@ -1502,6 +1506,7 @@ func (app *appContext) GetCustomEmails(gc *gin.Context) { // @Failure 500 {object} boolResponse // @Param id path string true "ID of email" // @Router /config/emails/{id} [post] +// @Security Bearer // @tags Configuration func (app *appContext) SetCustomEmail(gc *gin.Context) { var req customEmail @@ -1561,6 +1566,7 @@ func (app *appContext) SetCustomEmail(gc *gin.Context) { // @Param enable/disable path string true "enable/disable" // @Param id path string true "ID of email" // @Router /config/emails/{id}/state/{enable/disable} [post] +// @Security Bearer // @tags Configuration func (app *appContext) SetCustomEmailState(gc *gin.Context) { id := gc.Param("id") @@ -1610,6 +1616,7 @@ func (app *appContext) SetCustomEmailState(gc *gin.Context) { // @Failure 500 {object} boolResponse // @Param id path string true "ID of email" // @Router /config/emails/{id} [get] +// @Security Bearer // @tags Configuration func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { lang := app.storage.lang.chosenEmailLang @@ -1798,6 +1805,7 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { // @Produce json // @Success 200 {object} checkUpdateDTO // @Router /config/update [get] +// @Security Bearer // @tags Configuration func (app *appContext) CheckUpdate(gc *gin.Context) { if !app.newUpdate { @@ -1812,6 +1820,7 @@ func (app *appContext) CheckUpdate(gc *gin.Context) { // @Success 400 {object} stringResponse // @Success 500 {object} boolResponse // @Router /config/update [post] +// @Security Bearer // @tags Configuration func (app *appContext) ApplyUpdate(gc *gin.Context) { if !app.update.CanUpdate { @@ -1837,6 +1846,7 @@ func (app *appContext) ApplyUpdate(gc *gin.Context) { // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /logout [post] +// @Security Bearer // @tags Other func (app *appContext) Logout(gc *gin.Context) { cookie, err := gc.Cookie("refresh") @@ -1910,7 +1920,94 @@ func (app *appContext) ServeLang(gc *gin.Context) { respondBool(400, false, gc) } -// @Summary Returns true/false on whether or not a telegram PIN was verified. +// @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 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 @@ -1918,7 +2015,7 @@ func (app *appContext) ServeLang(gc *gin.Context) { // @Param invCode path string true "invite Code" // @Router /invite/{invCode}/telegram/verified/{pin} [get] // @tags Other -func (app *appContext) TelegramVerified(gc *gin.Context) { +func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { code := gc.Param("invCode") if _, ok := app.storage.invites[code]; !ok { respondBool(401, false, gc) @@ -1942,6 +2039,7 @@ func (app *appContext) TelegramVerified(gc *gin.Context) { // @Summary Restarts the program. No response means success. // @Router /restart [post] +// @Security Bearer // @tags Other func (app *appContext) restart(gc *gin.Context) { app.info.Println("Restarting...") diff --git a/css/base.css b/css/base.css index 95f1b4b..976062b 100644 --- a/css/base.css +++ b/css/base.css @@ -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 { margin: calc(-1 * var(--spacing-4,1rem)); } diff --git a/html/admin.html b/html/admin.html index 955e1be..a074ec3 100644 --- a/html/admin.html +++ b/html/admin.html @@ -309,6 +309,24 @@ {{ .strings.update }} + {{ if .telegram_enabled }} + + {{ end }}
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index dcd9d6a..4082881 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -92,7 +92,8 @@ "inviteExpiresInTime": "Expires in {n}", "notifyEvent": "Notify on:", "notifyInviteExpiry": "On expiry", - "notifyUserCreation": "On user creation" + "notifyUserCreation": "On user creation", + "sendPIN": "Ask the user to send the PIN below to the bot." }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", @@ -104,6 +105,7 @@ "setOmbiDefaults": "Stored ombi defaults.", "updateApplied": "Update applied, please restart.", "updateAppliedRefresh": "Update applied, please refresh.", + "telegramVerified": "Telegram account verified.", "errorConnection": "Couldn't connect to jfa-go.", "error401Unauthorized": "Unauthorized. Try refreshing the page.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 8dd076a..9d77a89 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -14,6 +14,9 @@ "copied": "Copied", "time24h": "24h Time", "time12h": "12h Time", + "linkTelegram": "Link Telegram", + "contactEmail": "Contact through Email", + "contactTelegram": "Contact through Telegram", "theme": "Theme" } } diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 36b1089..391d672 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -18,10 +18,7 @@ "confirmationRequired": "Email confirmation required", "confirmationRequiredMessage": "Please check your email inbox to verify your address.", "yourAccountIsValidUntil": "Your account will be valid until {date}.", - "linkTelegram": "Link Telegram", - "sendPIN": "Send the PIN below to the bot, then come back here to link your account.", - "contactEmail": "Contact through Email", - "contactTelegram": "Contact through Telegram" + "sendPIN": "Send the PIN below to the bot, then come back here to link your account." }, "notifications": { "errorUserExists": "User already exists.", diff --git a/models.go b/models.go index 8bb844f..e7adc17 100644 --- a/models.go +++ b/models.go @@ -237,3 +237,13 @@ type checkUpdateDTO struct { New bool `json:"new"` // Whether or not there's a new 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. +} diff --git a/router.go b/router.go index 63892ad..2d17e26 100644 --- a/router.go +++ b/router.go @@ -119,7 +119,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.Use(static.Serve(p+"/invite/", app.webFS)) router.GET(p+"/invite/:invCode", app.InviteProxy) if app.config.Section("telegram").Key("enabled").MustBool(false) { - router.GET(p+"/invite/:invCode/telegram/verified/:pin", app.TelegramVerified) + router.GET(p+"/invite/:invCode/telegram/verified/:pin", app.TelegramVerifiedInvite) } } if *SWAGGER { @@ -158,6 +158,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.GET(p+"/config", app.GetConfig) api.POST(p+"/config", app.ModifyConfig) api.POST(p+"/restart", app.restart) + if app.config.Section("telegram").Key("enabled").MustBool(false) { + api.GET(p+"/telegram/pin", app.TelegramGetPin) + api.GET(p+"/telegram/verified/:pin", app.TelegramVerified) + api.POST(p+"/users/telegram", app.TelegramAddUser) + } if app.config.Section("ombi").Key("enabled").MustBool(false) { api.GET(p+"/ombi/users", app.OmbiUsers) api.POST(p+"/ombi/defaults", app.SetOmbiDefaults) diff --git a/ts/admin.ts b/ts/admin.ts index a7516bb..6c14be7 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -62,6 +62,10 @@ window.availableProfiles = window.availableProfiles || []; window.modals.extendExpiry = new Modal(document.getElementById("modal-extend-expiry")); 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(); diff --git a/ts/form.ts b/ts/form.ts index 17c84a1..0cd1611 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -53,7 +53,10 @@ if (window.telegramEnabled) { toggleLoader(waiting); window.telegramModal.show(); let modalClosed = false; - window.telegramModal.onclose = () => { modalClosed = true; } + 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) { @@ -63,7 +66,6 @@ if (window.telegramEnabled) { } else if (req.status == 200) { if (req.response["success"] as boolean) { telegramVerified = true; - toggleLoader(waiting); waiting.classList.add("~positive"); waiting.classList.remove("~info"); window.notifications.customPositive("telegramVerified", "", window.messages["telegramVerified"]); diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index 60bef54..4201895 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -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 { Marked } from "@ts-stack/markdown"; import { stripMarkdown } from "../modules/stripmd.js"; @@ -14,6 +14,11 @@ interface User { telegram: string; } +interface getPinResponse { + token: string; + username: string; +} + class user implements User { private _row: HTMLTableRowElement; private _check: HTMLInputElement; @@ -80,7 +85,8 @@ class user implements User { if (!window.telegramEnabled) return; this._telegramUsername = u; if (u == "") { - this._telegram.textContent = ""; + this._telegram.innerHTML = `Add`; + (this._telegram.querySelector("span") as HTMLSpanElement).onclick = this._addTelegram; } else { this._telegram.innerHTML = `@${u}`; } @@ -193,6 +199,50 @@ 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) => { this.id = user.id; this.name = user.name; @@ -723,6 +773,7 @@ export class accountsList { this._selectAll.onchange = () => { this.selectAll = this._selectAll.checked; }; + document.addEventListener("accounts-reload", this.reload); document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); }); document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); }); this._addUserButton.onclick = window.modals.addUser.toggle; diff --git a/ts/modules/common.ts b/ts/modules/common.ts index 4142f42..f761a4c 100644 --- a/ts/modules/common.ts +++ b/ts/modules/common.ts @@ -179,3 +179,22 @@ export function toggleLoader(el: HTMLElement, small: boolean = true) { 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(); } + } +} diff --git a/ts/typings/d.ts b/ts/typings/d.ts index 70a260f..7e0bf3e 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -4,6 +4,8 @@ declare interface Modal { show: () => void; close: (event?: Event) => void; toggle: () => void; + onopen: (f: () => void) => void; + onclose: (f: () => void) => void; } interface ArrayConstructor { @@ -98,6 +100,7 @@ declare interface Modals { customizeEmails: Modal; extendExpiry: Modal; updateInfo: Modal; + telegram: Modal; } interface Invite { From 51f2f4cc6aae1ad305e9d2c9e44f97d1d5f18a7d Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 18:29:56 +0100 Subject: [PATCH 09/15] Telegram: close updates channel on restart Also removed some references to email. --- lang/admin/en-us.json | 8 ++++---- lang/admin/fr-fr.json | 2 +- telegram.go | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 4082881..7051a21 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -46,7 +46,7 @@ "unknown": "Unknown", "label": "Label", "announce": "Announce", - "subject": "Email Subject", + "subject": "Subject", "message": "Message", "variables": "Variables", "conditionals": "Conditionals", @@ -56,12 +56,12 @@ "donate": "Donate", "extendExpiry": "Extend expiry", "customizeEmails": "Customize Emails", - "customizeEmailsDescription": "If you don't want to use jfa-go's email templates, you can create your own using Markdown.", + "customizeEmailsDescription": "If you don't want to use jfa-go's message templates, you can create your own using Markdown.", "markdownSupported": "Markdown is supported.", "modifySettings": "Modify Settings", "modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.", "applyHomescreenLayout": "Apply homescreen layout", - "sendDeleteNotificationEmail": "Send notification email", + "sendDeleteNotificationEmail": "Send notification message", "sendDeleteNotifiationExample": "Your account has been deleted.", "settingsRestart": "Restart", "settingsRestarting": "Restarting…", @@ -128,7 +128,7 @@ "errorFailureCheckLogs": "Failed (check console/logs)", "errorPartialFailureCheckLogs": "Partial failure (check console/logs)", "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.", "errorCheckUpdate": "Failed to check for update.", "updateAvailable": "A new update is available, check settings.", diff --git a/lang/admin/fr-fr.json b/lang/admin/fr-fr.json index f3af46b..a036bda 100644 --- a/lang/admin/fr-fr.json +++ b/lang/admin/fr-fr.json @@ -68,7 +68,7 @@ "settingsRestarting": "Redémarrage…", "settingsRestart": "Redémarrer", "announce": "Annoncer", - "subject": "Sujet du courriel", + "subject": "Sujet", "message": "Message", "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.", diff --git a/telegram.go b/telegram.go index 9e9fcb3..cfb92fb 100644 --- a/telegram.go +++ b/telegram.go @@ -174,6 +174,7 @@ func (t *TelegramDaemon) run() { } case <-t.ShutdownChannel: + t.bot.StopReceivingUpdates() t.ShutdownChannel <- "Down" return } From ea0293bd4ebb8a1d13ef365791c2bede13ebd57b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 7 May 2021 21:53:29 +0100 Subject: [PATCH 10/15] Split some settings into new "messages" section Most email dependant sections now depend on this. Also renamed more email things. --- api.go | 22 ++- config.go | 38 ++++- config/config-base.json | 347 +++++++++++++++++++++------------------- email.go | 24 ++- html/admin.html | 4 +- lang/admin/de-de.json | 4 +- lang/admin/el-gr.json | 4 +- lang/admin/en-us.json | 4 +- lang/admin/es-es.json | 4 +- lang/admin/fr-fr.json | 4 +- lang/admin/id-id.json | 4 +- lang/admin/nl-nl.json | 4 +- lang/admin/pt-br.json | 4 +- lang/admin/sv-se.json | 4 +- lang/email/en-us.json | 4 +- main.go | 8 +- router.go | 4 +- setup.go | 2 +- ts/modules/settings.ts | 12 +- userdaemon.go | 3 +- views.go | 4 +- 21 files changed, 289 insertions(+), 219 deletions(-) diff --git a/api.go b/api.go index 6a4e38f..b473c38 100644 --- a/api.go +++ b/api.go @@ -39,9 +39,9 @@ func respondBool(code int, val bool, gc *gin.Context) { } 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` - 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` } return @@ -331,7 +331,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc return } telegramTokenIndex := -1 - if app.config.Section("telegram").Key("enabled").MustBool(false) { + if telegramEnabled { if req.TelegramPIN == "" { if app.config.Section("telegram").Key("required").MustBool(false) { f = func(gc *gin.Context) { @@ -480,7 +480,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } } - if app.config.Section("telegram").Key("enabled").MustBool(false) && telegramTokenIndex != -1 { + if telegramEnabled && telegramTokenIndex != -1 { tgToken := app.telegram.verifiedTokens[telegramTokenIndex] tgUser := TelegramUser{ ChatID: tgToken.ChatID, @@ -579,7 +579,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { "GetUser": map[string]string{}, "SetPolicy": map[string]string{}, } - sendMail := emailEnabled || app.config.Section("telegram").Key("enabled").MustBool(false) + sendMail := messagesEnabled var msg *Message var err error if sendMail { @@ -636,7 +636,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) { gc.BindJSON(&req) errors := map[string]string{} ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) - sendMail := emailEnabled || app.config.Section("telegram").Key("enabled").MustBool(false) + sendMail := messagesEnabled var msg *Message var err error if sendMail { @@ -730,7 +730,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) { func (app *appContext) Announce(gc *gin.Context) { var req announcementDTO gc.BindJSON(&req) - if !(emailEnabled || app.config.Section("telegram").Key("enabled").MustBool(false)) { + if !messagesEnabled { respondBool(400, false, gc) return } @@ -1384,6 +1384,10 @@ func (app *appContext) GetConfig(gc *gin.Context) { el := resp.Sections["email"].Settings["language"] el.Options = emailOptions 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 == "" { delete(resp.Sections, "updates") for i, v := range resp.Order { @@ -1412,6 +1416,7 @@ func (app *appContext) GetConfig(gc *gin.Context) { resp.Sections["ui"].Settings["language-admin"] = al resp.Sections["email"].Settings["language"] = el resp.Sections["password_resets"].Settings["language"] = pl + resp.Sections["telegram"].Settings["language"] = tl gc.JSON(200, resp) } @@ -1436,6 +1441,9 @@ func (app *appContext) ModifyConfig(gc *gin.Context) { tempConfig.NewSection(section) } 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("") { tempConfig.Section(section).Key(setting).SetValue(value.(string)) } diff --git a/config.go b/config.go index 9ef527f..9c60efc 100644 --- a/config.go +++ b/config.go @@ -12,6 +12,8 @@ import ( ) var emailEnabled = false +var messagesEnabled = false +var telegramEnabled = false func (app *appContext) GetPath(sect, key string) (fs.FS, string) { val := app.config.Section(sect).Key(key).MustString("") @@ -83,12 +85,19 @@ func (app *appContext) loadConfig() error { app.config.Section("jellyfin").Key("version").SetValue(version) 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)) - - if app.config.Section("email").Key("method").MustString("") == "" { + messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false) + 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 } else { emailEnabled = true } + if !emailEnabled && !telegramEnabled { + messagesEnabled = false + } app.MustSetValue("updates", "enabled", "true") releaseChannel := app.config.Section("updates").Key("channel").String() @@ -134,3 +143,28 @@ func (app *appContext) loadConfig() error { 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() +} diff --git a/config/config-base.json b/config/config-base.json index 61be322..c572d09 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -345,33 +345,20 @@ } } }, - "email": { + "messages": { "order": [], "meta": { - "name": "Email", - "description": "General email settings." + "name": "Messages/Notifications", + "description": "General settings for emails/messages." }, "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", + "enabled": { + "name": "Enabled", + "required": true, + "requires_restart": true, "type": "bool", - "value": false, - "description": "Use email address from invite form as username on Jellyfin." + "value": true, + "description": "Enable the sending of emails/messages such as password resets, announcements, etc." }, "use_24h": { "name": "Use 24h time", @@ -399,6 +386,37 @@ "type": "text", "value": "Need help? contact me.", "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": { "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." + }, + "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": { "order": [], "meta": { "name": "Password Resets", "description": "Settings for the password reset handler.", - "depends_true": "email|method" + "depends_true": "messages|enabled" }, "settings": { "enabled": { @@ -580,7 +729,7 @@ "meta": { "name": "Notifications", "description": "Notification related settings.", - "depends_true": "email|method" + "depends_true": "messages|enabled" }, "settings": { "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": { "order": [], "meta": { @@ -756,9 +820,9 @@ "welcome_email": { "order": [], "meta": { - "name": "Welcome Emails", - "description": "Optionally send a welcome email to new users with the Jellyfin URL and their username.", - "depends_true": "email|method" + "name": "Welcome Message", + "description": "Optionally send a welcome message to new users with the Jellyfin URL and their username.", + "depends_true": "messages|enabled" }, "settings": { "enabled": { @@ -865,14 +929,14 @@ "requires_restart": false, "type": "bool", "value": true, - "depends_true": "email|method", + "depends_true": "messages|enabled", "description": "Send an email when a user's account expires." }, "subject": { "name": "Email subject", "required": false, "requires_restart": false, - "depends_true": "email|method", + "depends_true": "messages|enabled", "type": "text", "value": "", "description": "Subject of user expiry emails." @@ -882,7 +946,7 @@ "required": false, "requires_restart": false, "advanced": true, - "depends_true": "email|method", + "depends_true": "messages|enabled", "type": "text", "value": "", "description": "Path to custom email html" @@ -892,7 +956,7 @@ "required": false, "requires_restart": false, "advanced": true, - "depends_true": "email|method", + "depends_true": "messages|enabled", "type": "text", "value": "", "description": "Path to custom email in plain text" @@ -904,7 +968,7 @@ "meta": { "name": "Account Disabling/Enabling", "description": "Subject/email files for account disabling/enabling emails.", - "depends_true": "email|method" + "depends_true": "messages|enabled" }, "settings": { "subject_disabled": { @@ -966,7 +1030,7 @@ "meta": { "name": "Account Deletion", "description": "Subject/email files for account deletion emails.", - "depends_true": "email|method" + "depends_true": "messages|enabled" }, "settings": { "subject": { @@ -997,53 +1061,6 @@ } } }, - "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." - }, - "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." - } - } - - }, "files": { "order": [], "meta": { diff --git a/email.go b/email.go index ef9a846..5a7f6bd 100644 --- a/email.go +++ b/email.go @@ -289,7 +289,7 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC template[v] = "{" + v + "}" } } 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 = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key) template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username}) @@ -327,7 +327,7 @@ func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) ( renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) html := markdown.ToHTML([]byte(md), nil, renderer) text := stripMarkdown(md) - message := app.config.Section("email").Key("message").String() + message := app.config.Section("messages").Key("message").String() var err error email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "template_email", "email_", map[string]interface{}{ "text": template.HTML(html), @@ -344,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{} { expiry := invite.ValidTill 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 = fmt.Sprintf("%s/%s", inviteLink, code) template := map[string]interface{}{ @@ -489,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{} { 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{}{ "someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"), "ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"), @@ -574,7 +574,7 @@ func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool } } else { template["reason"] = reason - template["message"] = app.config.Section("email").Key("message").String() + template["message"] = app.config.Section("messages").Key("message").String() } return template } @@ -615,7 +615,7 @@ func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub boo } } else { template["reason"] = reason - template["message"] = app.config.Section("email").Key("message").String() + template["message"] = app.config.Section("messages").Key("message").String() } return template } @@ -656,7 +656,7 @@ func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool } } else { template["reason"] = reason - template["message"] = app.config.Section("email").Key("message").String() + template["message"] = app.config.Section("messages").Key("message").String() } return template } @@ -701,7 +701,7 @@ func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *ap } else { template["jellyfinURL"] = app.config.Section("jellyfin").Key("public_server").String() 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) if !expiry.IsZero() { if custom { @@ -756,7 +756,7 @@ func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[strin "message": "", } if !noSub { - template["message"] = app.config.Section("email").Key("message").String() + template["message"] = app.config.Section("messages").Key("message").String() } return template } @@ -790,10 +790,9 @@ func (emailer *Emailer) send(email *Message, address ...string) error { } func (app *appContext) sendByID(email *Message, ID ...string) error { - tgEnabled := app.config.Section("telegram").Key("enabled").MustBool(false) for _, id := range ID { var err error - if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && tgEnabled { + 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)) @@ -806,8 +805,7 @@ func (app *appContext) sendByID(email *Message, ID ...string) error { } func (app *appContext) getAddressOrName(jfID string) string { - tgEnabled := app.config.Section("telegram").Key("enabled").MustBool(false) - if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && tgEnabled { + if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && telegramEnabled { return "@" + tgChat.Username } if addr, ok := app.storage.emails[jfID]; ok { diff --git a/html/admin.html b/html/admin.html index a074ec3..a5b784f 100644 --- a/html/admin.html +++ b/html/admin.html @@ -180,8 +180,8 @@