diff --git a/README.md.old b/README.md.old deleted file mode 100644 index c27a77e..0000000 --- a/README.md.old +++ /dev/null @@ -1,38 +0,0 @@ -This branch is for experimenting with [a17t](https://a17t.miles.land/) to replace bootstrap. Page structure is pretty much done (except setup.html), so i'm currently integrating this with the main app and existing web code. - -#### todo -**general** -* [x] modal implementation -* [x] animations -* [x] utilities -* [x] CSS for light & dark - -**admin** -* [x] invites tab -* [x] accounts tab -* [x] settings tab -* [x] modals -* [ ] integration with existing code - -**invites** -* [x] page design -* [ ] integration with existing code - -#### screenshots -##### dark -
- - - - - -
- -##### light -- - - - - -
diff --git a/api.go b/api.go index ee19501..65aaab5 100644 --- a/api.go +++ b/api.go @@ -3,10 +3,13 @@ package main import ( "encoding/json" "fmt" + "os" "strconv" "strings" + "sync" "time" + "github.com/dgrijalva/jwt-go" "github.com/gin-gonic/gin" "github.com/knz/strtime" "github.com/lithammer/shortuuid/v3" @@ -115,23 +118,27 @@ func (app *appContext) checkInvites() { notify := data.Notify if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 { app.debug.Printf("%s: Expiry notification", code) + var wait sync.WaitGroup for address, settings := range notify { if !settings["notify-expiry"] { continue } - go func() { + wait.Add(1) + go func(addr string) { + defer wait.Done() msg, err := app.email.constructExpiry(code, data, app) if err != nil { app.err.Printf("%s: Failed to construct expiry notification", code) app.debug.Printf("Error: %s", err) - } else if err := app.email.send(address, msg); err != nil { + } else if err := app.email.send(addr, msg); err != nil { app.err.Printf("%s: Failed to send expiry notification", code) app.debug.Printf("Error: %s", err) } else { - app.info.Printf("Sent expiry notification to %s", address) + app.info.Printf("Sent expiry notification to %s", addr) } - }() + }(address) } + wait.Wait() } changed = true delete(app.storage.invites, code) @@ -184,7 +191,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool delete(app.storage.invites, code) } else if newInv.RemainingUses != 0 { // 0 means infinite i guess? - newInv.RemainingUses -= 1 + newInv.RemainingUses-- } newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(currentTime)}) if !del { @@ -312,47 +319,67 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { respondUser(200, true, true, "", gc) } -// @Summary Creates a new Jellyfin user via invite code -// @Produce json -// @Param newUserDTO body newUserDTO true "New user request object" -// @Success 200 {object} PasswordValidation -// @Failure 400 {object} PasswordValidation -// @Router /newUser [post] -// @tags Users -func (app *appContext) NewUser(gc *gin.Context) { - var req newUserDTO - gc.BindJSON(&req) - app.debug.Printf("%s: New user attempt", req.Code) - if !app.checkInvite(req.Code, false, "") { - app.info.Printf("%s New user failed: invalid code", req.Code) - respond(401, "errorInvalidCode", gc) - return - } - validation := app.validator.validate(req.Password) - valid := true - for _, val := range validation { - if !val { - valid = false +type errorFunc func(gc *gin.Context) + +func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, success bool) { + existingUser, _, _ := app.jf.UserByName(req.Username, false) + if existingUser != nil { + f = func(gc *gin.Context) { + msg := fmt.Sprintf("User %s already exists", req.Username) + app.info.Printf("%s: New user failed: %s", req.Code, msg) + respond(401, "errorUserExists", gc) } - } - if !valid { - // 200 bcs idk what i did in js - app.info.Printf("%s New user failed: Invalid password", req.Code) - gc.JSON(200, validation) - gc.Abort() + success = false return } - existingUser, _, _ := app.jf.UserByName(req.Username, false) - if existingUser != nil { - msg := fmt.Sprintf("User %s", req.Username) - app.info.Printf("%s New user failed: %s", req.Code, msg) - respond(401, "errorUserExists", gc) + if 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", + } + tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) + if err != nil { + f = func(gc *gin.Context) { + app.info.Printf("Failed to generate confirmation token: %v", err) + respond(500, "errorUnknown", gc) + } + success = false + return + } + inv := app.storage.invites[req.Code] + inv.Keys = append(inv.Keys, key) + app.storage.invites[req.Code] = inv + app.storage.storeInvites() + f = func(gc *gin.Context) { + app.debug.Printf("%s: Email confirmation required", req.Code) + respond(401, "confirmEmail", gc) + msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app) + if err != nil { + app.err.Printf("%s: Failed to construct confirmation email", req.Code) + app.debug.Printf("%s: Error: %s", req.Code, err) + } else if err := app.email.send(req.Email, msg); err != nil { + app.err.Printf("%s: Failed to send user confirmation email: %s", req.Code, err) + } else { + app.info.Printf("%s: Sent user confirmation email to %s", req.Code, req.Email) + } + } + success = false return } + user, status, err := app.jf.NewUser(req.Username, req.Password) if !(status == 200 || status == 204) || err != nil { - app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status) - respond(401, app.storage.lang.Admin[app.storage.lang.chosenAdminLang].Notifications.get("errorUnknown"), gc) + f = func(gc *gin.Context) { + app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status) + respond(401, app.storage.lang.Admin[app.storage.lang.chosenAdminLang].Notifications.get("errorUnknown"), gc) + } + success = false return } app.storage.loadProfiles() @@ -434,6 +461,44 @@ func (app *appContext) NewUser(gc *gin.Context) { app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email) } } + success = true + return +} + +// @Summary Creates a new Jellyfin user via invite code +// @Produce json +// @Param newUserDTO body newUserDTO true "New user request object" +// @Success 200 {object} PasswordValidation +// @Failure 400 {object} PasswordValidation +// @Router /newUser [post] +// @tags Users +func (app *appContext) NewUser(gc *gin.Context) { + var req newUserDTO + gc.BindJSON(&req) + app.debug.Printf("%s: New user attempt", req.Code) + if !app.checkInvite(req.Code, false, "") { + app.info.Printf("%s New user failed: invalid code", req.Code) + respond(401, "errorInvalidCode", gc) + return + } + validation := app.validator.validate(req.Password) + valid := true + for _, val := range validation { + if !val { + valid = false + } + } + if !valid { + // 200 bcs idk what i did in js + app.info.Printf("%s New user failed: Invalid password", req.Code) + gc.JSON(200, validation) + return + } + f, success := app.newUser(req, false) + if !success { + f(gc) + return + } code := 200 for _, val := range validation { if !val { @@ -1317,54 +1382,6 @@ func (app *appContext) ServeLang(gc *gin.Context) { respondBool(400, false, gc) } -// func Restart() error { -// defer func() { -// if r := recover(); r != nil { -// os.Exit(0) -// } -// }() -// cwd, err := os.Getwd() -// if err != nil { -// return err -// } -// args := os.Args -// // for _, key := range args { -// // fmt.Println(key) -// // } -// cmd := exec.Command(args[0], args[1:]...) -// cmd.Stdout = os.Stdout -// cmd.Stderr = os.Stderr -// cmd.Dir = cwd -// err = cmd.Start() -// if err != nil { -// return err -// } -// // cmd.Process.Release() -// panic(fmt.Errorf("restarting")) -// } - -// func (app *appContext) Restart() error { -// defer func() { -// if r := recover(); r != nil { -// signal.Notify(app.quit, os.Interrupt) -// <-app.quit -// } -// }() -// args := os.Args -// // After a single restart, args[0] gets messed up and isnt the real executable. -// // JFA_DEEP tells the new process its a child, and JFA_EXEC is the real executable -// if os.Getenv("JFA_DEEP") == "" { -// os.Setenv("JFA_DEEP", "1") -// os.Setenv("JFA_EXEC", args[0]) -// } -// env := os.Environ() -// err := syscall.Exec(os.Getenv("JFA_EXEC"), []string{""}, env) -// if err != nil { -// return err -// } -// panic(fmt.Errorf("restarting")) -// } - // no need to syscall.exec anymore! func (app *appContext) Restart() error { RESTART <- true diff --git a/config.go b/config.go index f4dc955..e429d02 100644 --- a/config.go +++ b/config.go @@ -35,6 +35,10 @@ func (app *appContext) loadConfig() error { app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(app.localPath, "invite-email.html"))) app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(app.localPath, "invite-email.txt"))) + app.config.Section("email_confirmation").Key("email_html").SetValue(app.config.Section("email_confirmation").Key("email_html").MustString(filepath.Join(app.localPath, "confirmation.html"))) + fmt.Println(app.config.Section("email_confirmation").Key("email_html").String()) + app.config.Section("email_confirmation").Key("email_text").SetValue(app.config.Section("email_confirmation").Key("email_text").MustString(filepath.Join(app.localPath, "confirmation.txt"))) + app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(app.localPath, "expired.html"))) app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.localPath, "expired.txt"))) diff --git a/config/config-base.json b/config/config-base.json index c25d7b5..06d02bc 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -708,6 +708,46 @@ } } }, + "email_confirmation": { + "order": [], + "meta": { + "name": "Email confirmation", + "description": "If enabled, a user will be sent an email confirmation link to ensure their password is right before they can make an account." + }, + "settings": { + "enabled": { + "name": "Enabled", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false + }, + "subject": { + "name": "Email subject", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Subject of email confirmation emails." + }, + "email_html": { + "name": "Custom email (HTML)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Path to custom email html" + }, + "email_text": { + "name": "Custom email (plaintext)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Path to custom email in plain text" + } + } + }, "deletion": { "order": [], "meta": { diff --git a/email.go b/email.go index 2fbbce3..2c1f95b 100644 --- a/email.go +++ b/email.go @@ -153,9 +153,44 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri } } +func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext) (*Email, error) { + email := &Email{ + subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")), + } + message := app.config.Section("email").Key("message").String() + inviteLink := app.config.Section("invite_emails").Key("url_base").String() + inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key) + + for _, key := range []string{"html", "text"} { + fpath := app.config.Section("email_confirmation").Key("email_" + key).String() + tpl, err := template.ParseFiles(fpath) + if err != nil { + return nil, err + } + var tplData bytes.Buffer + err = tpl.Execute(&tplData, map[string]string{ + "helloUser": emailer.lang.Strings.format("helloUser", username), + "clickBelow": emailer.lang.EmailConfirmation.get("clickBelow"), + "ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"), + "urlVal": inviteLink, + "confirmEmail": emailer.lang.EmailConfirmation.get("confirmEmail"), + "message": message, + }) + if err != nil { + return nil, err + } + if key == "html" { + email.html = tplData.String() + } else { + email.text = tplData.String() + } + } + return email, nil +} + func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) { email := &Email{ - subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")), + subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.InviteEmail.get("title")), } expiry := invite.ValidTill d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern) @@ -240,8 +275,8 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite var tplData bytes.Buffer err = tpl.Execute(&tplData, map[string]string{ "aUserWasCreated": emailer.lang.UserCreated.format("aUserWasCreated", "\""+code+"\""), - "name": emailer.lang.UserCreated.get("name"), - "address": emailer.lang.UserCreated.get("emailAddress"), + "name": emailer.lang.Strings.get("name"), + "address": emailer.lang.Strings.get("emailAddress"), "time": emailer.lang.UserCreated.get("time"), "nameVal": username, "addressVal": tplAddress, @@ -274,11 +309,11 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Ema } var tplData bytes.Buffer err = tpl.Execute(&tplData, map[string]string{ - "helloUser": emailer.lang.PasswordReset.format("helloUser", pwr.Username), + "helloUser": emailer.lang.Strings.format("helloUser", pwr.Username), "someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"), "ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"), "codeExpiry": emailer.lang.PasswordReset.format("codeExpiry", d, t, expiresIn), - "ifItWasNotYou": emailer.lang.PasswordReset.get("ifItWasNotYou"), + "ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"), "pin": emailer.lang.PasswordReset.get("pin"), "pinVal": pwr.Pin, "message": message, @@ -339,7 +374,7 @@ func (emailer *Emailer) constructWelcome(username string, app *appContext) (*Ema "youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"), "jellyfinURL": emailer.lang.WelcomeEmail.get("jellyfinURL"), "jellyfinURLVal": app.config.Section("jellyfin").Key("public_server").String(), - "username": emailer.lang.WelcomeEmail.get("username"), + "username": emailer.lang.Strings.get("username"), "usernameVal": username, "message": app.config.Section("email").Key("message").String(), }) diff --git a/html/create-success.html b/html/create-success.html new file mode 100644 index 0000000..f7a69ef --- /dev/null +++ b/html/create-success.html @@ -0,0 +1,18 @@ + + + + + {{ template "header.html" . }} +