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

users: huge cleanup/dedupe, interface-based third-party services

shared "newUser" method is now "NewUserPostVerification", and is shared
between all routes which create a jellyfin account. The new
"NewUserFromInvite", "NewUserFromAdmin" and "NewUserFromConfirmationKey"
are smaller as a result. Discord, Telegram, and Matrix now implement the
"ContactMethodLinker" and "ContactMethodUser" interfaces, meaning code
is shared a lot between them in the NewUser methods, and the specifics
are now in their own files. Ombi/Jellyseerr similarly implement a
simpler interface "ThirdPartyService", which simply has ImportUser and
AddContactMethod routes. Note these new interface methods are only used
for user creation as of yet, but could likely be used in other places.
This commit is contained in:
Harvey Tindall 2024-08-03 21:23:59 +01:00
parent 711394232b
commit 54e4a51a7f
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
20 changed files with 947 additions and 676 deletions

View File

@ -1,10 +1,12 @@
package main package main
import ( import (
"fmt"
"net/url" "net/url"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
) )
@ -97,3 +99,67 @@ func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) {
app.storage.SetProfileKey(profileName, profile) app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc) respondBool(204, true, gc)
} }
type JellyseerrWrapper struct {
*jellyseerr.Jellyseerr
}
func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
// Gets existing user (not possible) or imports the given user.
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
ok = true
err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err)
return
}
err = js.ApplyNotificationsTemplateToUser(jellyfinID, profile.Jellyseerr.Notifications)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, jellyfinID, err)
return
}
return
}
func (js *JellyseerrWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
contactMethods := map[jellyseerr.NotificationsField]any{}
if emailEnabled {
err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: req.Email})
if err != nil {
// FIXME: This is a little ugly, considering all other errors are unformatted
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
return
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
}
}
if discordEnabled && discord != nil {
contactMethods[jellyseerr.FieldDiscord] = discord.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
}
if telegramEnabled && discord != nil {
contactMethods[jellyseerr.FieldTelegram] = telegram.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
}
if len(contactMethods) > 0 {
err = js.ModifyNotifications(jellyfinID, contactMethods)
if err != nil {
// app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
return
}
}
return
}
func (js *JellyseerrWrapper) Name() string { return lm.Jellyseerr }
func (js *JellyseerrWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Jellyseerr.Enabled && app.config.Section("jellyseerr").Key("enabled").MustBool(false)
}

View File

@ -431,7 +431,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
token, ok := app.telegram.TokenVerified(pin) token, ok := app.telegram.TokenVerified(pin)
if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) { if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
app.discord.DeleteVerifiedUser(pin) app.discord.DeleteVerifiedToken(pin)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -454,7 +454,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
} }
pin := gc.Param("pin") pin := gc.Param("pin")
user, ok := app.discord.UserVerified(pin) user, ok := app.discord.UserVerified(pin)
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) { if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.MethodID().(string)) {
delete(app.discord.verifiedTokens, pin) delete(app.discord.verifiedTokens, pin)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
@ -472,7 +472,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
// @Router /invite/{invCode}/discord/invite [get] // @Router /invite/{invCode}/discord/invite [get]
// @tags Other // @tags Other
func (app *appContext) DiscordServerInvite(gc *gin.Context) { func (app *appContext) DiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" { if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }

View File

@ -1,19 +1,18 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"net/url" "net/url"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
) )
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) { func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) {
ombiUsers, code, err := app.ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
}
jfUser, code, err := app.jf.UserByID(jfID, false) jfUser, code, err := app.jf.UserByID(jfID, false)
if err != nil || code != 200 { if err != nil || code != 200 {
return nil, code, err return nil, code, err
@ -23,6 +22,14 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
if e, ok := app.storage.GetEmailsKey(jfID); ok { if e, ok := app.storage.GetEmailsKey(jfID); ok {
email = e.Addr email = e.Addr
} }
return app.ombi.getUser(username, email)
}
func (ombi *OmbiWrapper) getUser(username string, email string) (map[string]interface{}, int, error) {
ombiUsers, code, err := ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
}
for _, ombiUser := range ombiUsers { for _, ombiUser := range ombiUsers {
ombiAddr := "" ombiAddr := ""
if a, ok := ombiUser["emailAddress"]; ok && a != nil { if a, ok := ombiUser["emailAddress"]; ok && a != nil {
@ -32,13 +39,13 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
return ombiUser, code, err return ombiUser, code, err
} }
} }
return nil, 400, fmt.Errorf("couldn't find user") return nil, 400, errors.New(lm.NotFound)
} }
// Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi // Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi
func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{}, int, error) { func (ombi *OmbiWrapper) getImportedUser(name string) (map[string]interface{}, int, error) {
// Ombi User Types: 3/4 = Emby, 5 = Jellyfin // Ombi User Types: 3/4 = Emby, 5 = Jellyfin
ombiUsers, code, err := app.ombi.GetUsers() ombiUsers, code, err := ombi.GetUsers()
if err != nil || code != 200 { if err != nil || code != 200 {
return nil, code, err return nil, code, err
} }
@ -136,7 +143,11 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
respondBool(204, true, gc) respondBool(204, true, gc)
} }
func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map[string]interface{}) (status int, err error) { type OmbiWrapper struct {
*ombi.Ombi
}
func (ombi *OmbiWrapper) applyProfile(user map[string]interface{}, profile map[string]interface{}) (status int, err error) {
for k, v := range profile { for k, v := range profile {
switch v.(type) { switch v.(type) {
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}:
@ -147,6 +158,66 @@ func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map
} }
} }
} }
status, err = app.ombi.ModifyUser(user) status, err = ombi.ModifyUser(user)
return return
} }
func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
errors, code, err := ombi.NewUser(req.Username, req.Password, req.Email, profile.Ombi)
var ombiUser map[string]interface{}
var status int
if err != nil || code != 200 {
// Check if on the off chance, Ombi's user importer has already added the account.
ombiUser, status, err = ombi.getImportedUser(req.Username)
if status == 200 && err == nil {
// app.info.Println(lm.Ombi + " " + lm.UserExists)
profile.Ombi["password"] = req.Password
status, err = ombi.applyProfile(ombiUser, profile.Ombi)
if status != 200 || err != nil {
err = fmt.Errorf(lm.FailedApplyProfile, lm.Ombi, req.Username, err)
}
} else {
if len(errors) != 0 {
err = fmt.Errorf("%v, %s", err, strings.Join(errors, ", "))
}
return
}
}
ok = true
return
}
func (ombi *OmbiWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
var ombiUser map[string]interface{}
var status int
ombiUser, status, err = ombi.getUser(req.Username, req.Email)
if status != 200 || err != nil {
return
}
if discordEnabled || telegramEnabled {
dID := ""
tUser := ""
if discord != nil {
dID = discord.ID
}
if telegram != nil {
tUser = telegram.Username
}
var resp string
var status int
resp, status, err = ombi.SetNotificationPrefs(ombiUser, dID, tUser)
if !(status == 200 || status == 204) || err != nil {
if resp != "" {
err = fmt.Errorf("%v, %s", err, resp)
}
return
}
}
return
}
func (ombi *OmbiWrapper) Name() string { return lm.Ombi }
func (ombi *OmbiWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Ombi != nil && len(profile.Ombi) != 0 && app.config.Section("ombi").Key("enabled").MustBool(false)
}

View File

@ -292,7 +292,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags User Page // @tags User Page
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) { func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" { if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -340,7 +340,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) {
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId")) dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
app.discord.DeleteVerifiedUser(pin) app.discord.DeleteVerifiedToken(pin)
if !ok { if !ok {
respondBool(200, false, gc) respondBool(200, false, gc)
return return

View File

@ -23,7 +23,7 @@ import (
// @Router /users [post] // @Router /users [post]
// @Security Bearer // @Security Bearer
// @tags Users // @tags Users
func (app *appContext) NewUserAdmin(gc *gin.Context) { func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
respondUser := func(code int, user, email bool, msg string, gc *gin.Context) { respondUser := func(code int, user, email bool, msg string, gc *gin.Context) {
resp := newUserResponse{ resp := newUserResponse{
User: user, User: user,
@ -35,263 +35,143 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
} }
var req newUserDTO var req newUserDTO
gc.BindJSON(&req) gc.BindJSON(&req)
existingUser, _, _ := app.jf.UserByName(req.Username, false)
if existingUser.Name != "" {
msg := lm.UserExists
app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, msg)
respondUser(401, false, false, msg, gc)
return
}
user, status, err := app.jf.NewUser(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, err)
respondUser(401, false, false, err.Error(), gc)
return
}
id := user.ID
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreation,
UserID: id,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: user.Name,
Time: time.Now(),
}, gc, false)
profile := app.storage.GetDefaultProfile() profile := app.storage.GetDefaultProfile()
if req.Profile != "" && req.Profile != "none" { nu := app.NewUserPostVerification(NewUserParams{
if p, ok := app.storage.GetProfileKey(req.Profile); ok { Req: req,
profile = p SourceType: ActivityAdmin,
} else { Source: gc.GetString("jfId"),
app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, req.Profile) ContextForIPLogging: gc,
} Profile: &profile,
})
if !nu.Success {
nu.Log()
}
status, err = app.jf.SetPolicy(id, profile.Policy) welcomeMessageSentIfNecessary := true
if !(status == 200 || status == 204 || err == nil) { if nu.Created {
app.err.Printf(lm.FailedApplyTemplate, "policy", lm.Jellyfin, req.Username, err) welcomeMessageSentIfNecessary = app.WelcomeNewUser(nu.User, time.Time{})
}
status, err = app.jf.SetConfiguration(id, profile.Configuration)
if (status == 200 || status == 204) && err == nil {
status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs)
}
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf(lm.FailedApplyTemplate, "configuration", lm.Jellyfin, req.Username, err)
}
} }
app.jf.CacheExpiry = time.Now()
if emailEnabled { respondUser(nu.Status, nu.Created, welcomeMessageSentIfNecessary, nu.Message, gc)
app.storage.SetEmailsKey(id, EmailAddress{Addr: req.Email, Contact: true})
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
if profile.Ombi == nil {
profile.Ombi = map[string]interface{}{}
}
errors, code, err := app.ombi.NewUser(req.Username, req.Password, req.Email, profile.Ombi)
if err != nil || code != 200 {
app.err.Printf(lm.FailedCreateUser, lm.Ombi, req.Username, err)
app.debug.Printf(lm.AdditionalErrors, lm.Ombi, strings.Join(errors, ", "))
} else {
app.info.Printf(lm.CreateUser, lm.Ombi, req.Username)
}
}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
// Gets existing user (not possible) or imports the given user.
_, err := app.js.MustGetUser(id)
if err != nil {
app.err.Printf(lm.FailedCreateUser, lm.Jellyseerr, req.Username, err)
} else {
app.info.Printf(lm.CreateUser, lm.Jellyseerr, req.Username)
}
err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User)
if err != nil {
app.err.Printf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, req.Username, err)
}
err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications)
if err != nil {
app.err.Printf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, req.Username, err)
}
if emailEnabled {
err = app.js.ModifyUser(id, map[jellyseerr.UserField]any{jellyseerr.FieldEmail: req.Email})
if err != nil {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, id, err)
}
}
}
if emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" {
msg, err := app.email.constructWelcome(req.Username, time.Time{}, app, false)
if err != nil {
app.err.Printf(lm.FailedConstructWelcomeMessage, id, err)
respondUser(500, true, false, err.Error(), gc)
return
} else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf(lm.FailedSendWelcomeMessage, req.Username, req.Email, err)
respondUser(500, true, false, err.Error(), gc)
return
} else {
app.info.Printf(lm.SentWelcomeMessage, req.Username, req.Email)
}
}
respondUser(200, true, true, "", gc)
} }
type errorFunc func(gc *gin.Context) // @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) NewUserFromInvite(gc *gin.Context) {
/*
-- STEPS --
- Validate CAPTCHA
- Validate Invite
- Validate Password
- a) Discord (Require, Verify, ExistingUser, ApplyRole)
b) Telegram (Require, Verify, ExistingUser)
c) Matrix (Require, Verify, ExistingUser)
d) Email (Require, Verify, ExistingUser) (only occurs here, PWRs are sent by mail so do this on their own, kinda)
*/
var req newUserDTO
gc.BindJSON(&req)
// Used on the form & when a users email has been confirmed. // Validate CAPTCHA
func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context) (f errorFunc, success bool) { if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText, false) {
existingUser, _, _ := app.jf.UserByName(req.Username, false) app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, lm.IncorrectCaptcha)
if existingUser.Name != "" { respond(400, "errorCaptcha", gc)
f = func(gc *gin.Context) {
msg := lm.UserExists
app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, msg)
respond(401, "errorUserExists", gc)
}
success = false
return return
} }
var discordUser DiscordUser // Validate Invite
discordVerified := false if !app.checkInvite(req.Code, false, "") {
if discordEnabled { app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, fmt.Sprintf(lm.InvalidInviteCode, req.Code))
if req.DiscordPIN == "" { respond(401, "errorInvalidCode", gc)
if app.config.Section("discord").Key("required").MustBool(false) { return
f = func(gc *gin.Context) { }
app.info.Printf(lm.FailedLinkUser, lm.Discord, "?", req.Code, lm.AccountUnverified) // Validate Password
respond(401, "errorDiscordVerification", gc) validation := app.validator.validate(req.Password)
} valid := true
success = false for _, val := range validation {
if !val {
valid = false
break
}
}
if !valid {
// 200 bcs idk what i did in js
gc.JSON(200, validation)
return
}
completeContactMethods := make([]struct {
Verified bool
PIN string
User ContactMethodUser
}, len(app.contactMethods))
for i, cm := range app.contactMethods {
completeContactMethods[i].PIN = cm.PIN(req)
if completeContactMethods[i].PIN == "" {
if cm.Required() {
app.info.Printf(lm.FailedLinkUser, cm.Name(), "?", req.Code, lm.AccountUnverified)
respond(401, fmt.Sprintf("error%sVerification", cm.Name()), gc)
return return
} }
} else { } else {
discordUser, discordVerified = app.discord.UserVerified(req.DiscordPIN) completeContactMethods[i].User, completeContactMethods[i].Verified = cm.UserVerified(completeContactMethods[i].PIN)
if !discordVerified { if !completeContactMethods[i].Verified {
f = func(gc *gin.Context) { app.info.Printf(lm.FailedLinkUser, cm.Name(), "?", req.Code, fmt.Sprintf(lm.InvalidPIN, completeContactMethods[i].PIN))
app.info.Printf(lm.FailedLinkUser, lm.Discord, "?", req.Code, fmt.Sprintf(lm.InvalidPIN, req.DiscordPIN)) respond(401, "errorInvalidPIN", gc)
respond(401, "errorInvalidPIN", gc)
}
success = false
return return
} }
if app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(discordUser.ID) { if cm.UniqueRequired() && cm.Exists(completeContactMethods[i].User) {
f = func(gc *gin.Context) { app.debug.Printf(lm.FailedLinkUser, cm.Name(), completeContactMethods[i].User.Name(), req.Code, lm.AccountLinked)
app.debug.Printf(lm.FailedLinkUser, lm.Discord, discordUser.ID, req.Code, lm.AccountLinked) respond(400, "errorAccountLinked", gc)
respond(400, "errorAccountLinked", gc)
}
success = false
return return
} }
err := app.discord.ApplyRole(discordUser.ID) if err := cm.PostVerificationTasks(completeContactMethods[i].PIN, completeContactMethods[i].User); err != nil {
if err != nil { app.err.Printf(lm.FailedLinkUser, cm.Name(), completeContactMethods[i].User.Name(), req.Code, err)
f = func(gc *gin.Context) {
app.err.Printf(lm.FailedLinkUser, lm.Discord, discordUser.ID, req.Code, fmt.Sprintf(lm.FailedSetDiscordMemberRole, err))
respond(401, "error", gc)
}
success = false
return
} }
} }
} }
var matrixUser MatrixUser // Email (FIXME: Potentially make this a ContactMethodLinker)
matrixVerified := false if emailEnabled {
if matrixEnabled { // Require
if req.MatrixPIN == "" { if app.config.Section("email").Key("required").MustBool(false) && !strings.Contains(req.Email, "@") {
if app.config.Section("matrix").Key("required").MustBool(false) { respond(400, "errorNoEmail", gc)
f = func(gc *gin.Context) {
app.info.Printf(lm.FailedLinkUser, lm.Matrix, "?", req.Code, lm.AccountUnverified)
respond(401, "errorMatrixVerification", gc)
}
success = false
return
}
} else {
user, ok := app.matrix.tokens[req.MatrixPIN]
if !ok || !user.Verified {
matrixVerified = false
f = func(gc *gin.Context) {
uid := ""
if ok {
uid = user.User.UserID
}
app.info.Printf(lm.FailedLinkUser, lm.Matrix, uid, req.Code, fmt.Sprintf(lm.InvalidPIN, req.MatrixPIN))
respond(401, "errorInvalidPIN", gc)
}
success = false
return
}
if app.config.Section("matrix").Key("require_unique").MustBool(false) && app.matrix.UserExists(user.User.UserID) {
f = func(gc *gin.Context) {
app.debug.Printf(lm.FailedLinkUser, lm.Matrix, user.User.UserID, req.Code, lm.AccountLinked)
respond(400, "errorAccountLinked", gc)
}
success = false
return
}
matrixVerified = user.Verified
matrixUser = *user.User
}
}
var tgToken TelegramVerifiedToken
telegramVerified := false
if telegramEnabled {
if req.TelegramPIN == "" {
if app.config.Section("telegram").Key("required").MustBool(false) {
f = func(gc *gin.Context) {
app.info.Printf(lm.FailedLinkUser, lm.Telegram, "?", req.Code, lm.AccountUnverified)
respond(401, "errorTelegramVerification", gc)
}
success = false
return
}
} else {
tgToken, telegramVerified = app.telegram.TokenVerified(req.TelegramPIN)
if !telegramVerified {
f = func(gc *gin.Context) {
app.info.Printf(lm.FailedLinkUser, lm.Telegram, tgToken.Username, req.Code, fmt.Sprintf(lm.InvalidPIN, req.TelegramPIN))
respond(401, "errorInvalidPIN", gc)
}
success = false
return
}
if app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(tgToken.Username) {
f = func(gc *gin.Context) {
app.debug.Printf(lm.FailedLinkUser, lm.Telegram, tgToken.Username, req.Code, lm.AccountLinked)
respond(400, "errorAccountLinked", gc)
}
success = false
return
}
}
}
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed {
claims := jwt.MapClaims{
"valid": true,
"invite": req.Code,
"exp": time.Now().Add(30 * time.Minute).Unix(),
"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(lm.FailedSignJWT, err)
respond(500, "errorUnknown", gc)
}
success = false
return return
} }
if app.ConfirmationKeys == nil { // ExistingUser
app.ConfirmationKeys = map[string]map[string]newUserDTO{} if app.config.Section("email").Key("require_unique").MustBool(false) && req.Email != "" && app.EmailAddressExists(req.Email) {
respond(400, "errorEmailLinked", gc)
return
} }
cKeys, ok := app.ConfirmationKeys[req.Code] // Verify
if !ok { if app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
cKeys = map[string]newUserDTO{} claims := jwt.MapClaims{
} "valid": true,
cKeys[key] = req "invite": req.Code,
app.confirmationKeysLock.Lock() "exp": time.Now().Add(30 * time.Minute).Unix(),
app.ConfirmationKeys[req.Code] = cKeys "type": "confirmation",
app.confirmationKeysLock.Unlock() }
f = func(gc *gin.Context) { tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil {
app.err.Printf(lm.FailedSignJWT, err)
respond(500, "errorUnknown", gc)
return
}
if app.ConfirmationKeys == nil {
app.ConfirmationKeys = map[string]map[string]newUserDTO{}
}
cKeys, ok := app.ConfirmationKeys[req.Code]
if !ok {
cKeys = map[string]newUserDTO{}
}
cKeys[key] = req
app.confirmationKeysLock.Lock()
app.ConfirmationKeys[req.Code] = cKeys
app.confirmationKeysLock.Unlock()
app.debug.Printf(lm.EmailConfirmationRequired, req.Username) app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
respond(401, "confirmEmail", gc) respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false) msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false)
@ -302,25 +182,76 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context)
} else { } else {
app.err.Printf(lm.SentConfirmationEmail, req.Code, req.Email) app.err.Printf(lm.SentConfirmationEmail, req.Code, req.Email)
} }
return
} }
success = false
return
} }
user, status, err := app.jf.NewUser(req.Username, req.Password) invite, _ := app.storage.GetInvitesKey(req.Code)
if !(status == 200 || status == 204) || err != nil {
f = func(gc *gin.Context) { sourceType, source := invite.Source()
app.err.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, err)
respond(401, app.storage.lang.Admin[app.storage.lang.chosenAdminLang].Notifications.get("errorUnknown"), gc) var profile *Profile = nil
if invite.Profile != "" {
p, ok := app.storage.GetProfileKey(invite.Profile)
if !ok {
app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, invite.Profile)
p = app.storage.GetDefaultProfile()
} }
success = false profile = &p
}
// FIXME: Use NewUserPostVerification
nu := app.NewUserPostVerification(NewUserParams{
Req: req,
SourceType: sourceType,
Source: source,
ContextForIPLogging: gc,
Profile: profile,
})
if !nu.Success {
nu.Log()
}
if !nu.Created {
respond(nu.Status, nu.Message, gc)
return return
} }
invite, _ := app.storage.GetInvitesKey(req.Code)
app.checkInvite(req.Code, true, req.Username) app.checkInvite(req.Code, true, req.Username)
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) {
for address, settings := range invite.Notify { nonEmailContactMethodEnabled := false
if settings["notify-creation"] { for i, c := range completeContactMethods {
if c.Verified {
c.User.SetAllowContactFromDTO(req)
if c.User.AllowContact() {
nonEmailContactMethodEnabled = true
}
app.contactMethods[i].DeleteVerifiedToken(c.PIN)
c.User.Store(&(app.storage))
}
}
referralsEnabled := profile != nil && profile.ReferralTemplateKey != "" && app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false)
if (emailEnabled && req.Email != "") || invite.UserLabel != "" || referralsEnabled {
emailStore := EmailAddress{
Addr: req.Email,
Contact: (req.Email != ""),
Label: invite.UserLabel,
ReferralTemplateKey: profile.ReferralTemplateKey,
}
/// Ensures at least one contact method is enabled.
if nonEmailContactMethodEnabled {
emailStore.Contact = req.EmailContact
}
app.storage.SetEmailsKey(nu.User.ID, emailStore)
}
if emailEnabled {
if app.config.Section("notifications").Key("enabled").MustBool(false) {
for address, settings := range invite.Notify {
if !settings["notify-creation"] {
continue
}
// FIXME: Forgot how "go" works, but these might get killed if not finished
// before we do?
go func(addr string) { go func(addr string) {
msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false) msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false)
if err != nil { if err != nil {
@ -342,275 +273,62 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context)
} }
} }
} }
id := user.ID
// Record activity if referralsEnabled {
sourceType := ActivityAnon refInv := Invite{}
source := "" /*err := */ app.storage.db.Get(profile.ReferralTemplateKey, &refInv)
if invite.ReferrerJellyfinID != "" { // If UseReferralExpiry is enabled, create the ref now so the clock starts ticking
sourceType = ActivityUser if refInv.UseReferralExpiry {
source = invite.ReferrerJellyfinID refInv.Code = GenerateInviteCode()
} expiryDelta := refInv.ValidTill.Sub(refInv.Created)
refInv.Created = time.Now()
app.storage.SetActivityKey(shortuuid.New(), Activity{ refInv.ValidTill = refInv.Created.Add(expiryDelta)
Type: ActivityCreation, refInv.IsReferral = true
UserID: id, refInv.ReferrerJellyfinID = nu.User.ID
SourceType: sourceType, app.storage.SetInvitesKey(refInv.Code, refInv)
Source: source,
InviteCode: invite.Code,
Value: user.Name,
Time: time.Now(),
}, gc, true)
emailStore := EmailAddress{
Addr: req.Email,
Contact: (req.Email != ""),
}
// Only allow disabling of email contact if some other method is available.
if req.DiscordContact || req.TelegramContact || req.MatrixContact {
emailStore.Contact = req.EmailContact
}
if invite.UserLabel != "" {
emailStore.Label = invite.UserLabel
}
var profile Profile
if invite.Profile != "" {
app.debug.Printf(lm.ApplyProfile, invite.Profile)
var ok bool
profile, ok = app.storage.GetProfileKey(invite.Profile)
if !ok {
profile = app.storage.GetDefaultProfile()
}
status, err = app.jf.SetPolicy(id, profile.Policy)
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf(lm.FailedApplyTemplate, "policy", lm.Jellyfin, id, err)
}
status, err = app.jf.SetConfiguration(id, profile.Configuration)
if (status == 200 || status == 204) && err == nil {
status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs)
}
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf(lm.FailedApplyTemplate, "configuration", lm.Jellyfin, id, err)
}
if app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false) && profile.ReferralTemplateKey != "" {
emailStore.ReferralTemplateKey = profile.ReferralTemplateKey
// Store here, just incase email are disabled (whether this is even possible, i don't know)
app.storage.SetEmailsKey(id, emailStore)
// If UseReferralExpiry is enabled, create the ref now so the clock starts ticking
refInv := Invite{}
err = app.storage.db.Get(profile.ReferralTemplateKey, &refInv)
if refInv.UseReferralExpiry {
refInv.Code = GenerateInviteCode()
expiryDelta := refInv.ValidTill.Sub(refInv.Created)
refInv.Created = time.Now()
refInv.ValidTill = refInv.Created.Add(expiryDelta)
refInv.IsReferral = true
refInv.ReferrerJellyfinID = id
app.storage.SetInvitesKey(refInv.Code, refInv)
}
} }
} }
// if app.config.Section("password_resets").Key("enabled").MustBool(false) {
if req.Email != "" || invite.UserLabel != "" {
app.storage.SetEmailsKey(id, emailStore)
}
expiry := time.Time{} expiry := time.Time{}
if invite.UserExpiry { if invite.UserExpiry {
expiry = time.Now().AddDate(0, invite.UserMonths, invite.UserDays).Add(time.Duration((60*invite.UserHours)+invite.UserMinutes) * time.Minute) expiry = time.Now().AddDate(0, invite.UserMonths, invite.UserDays).Add(time.Duration((60*invite.UserHours)+invite.UserMinutes) * time.Minute)
app.storage.SetUserExpiryKey(id, UserExpiry{Expiry: expiry}) app.storage.SetUserExpiryKey(nu.User.ID, UserExpiry{Expiry: expiry})
} }
if discordVerified {
discordUser.Contact = req.DiscordContact
if app.storage.deprecatedDiscord == nil {
app.storage.deprecatedDiscord = discordStore{}
}
// Note we don't log an activity here, since it's part of creating a user.
app.storage.SetDiscordKey(user.ID, discordUser)
delete(app.discord.verifiedTokens, req.DiscordPIN)
}
if telegramVerified {
tgUser := TelegramUser{
ChatID: tgToken.ChatID,
Username: tgToken.Username,
Contact: req.TelegramContact,
}
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
tgUser.Lang = lang
}
if app.storage.deprecatedTelegram == nil {
app.storage.deprecatedTelegram = telegramStore{}
}
app.telegram.DeleteVerifiedToken(req.TelegramPIN)
app.storage.SetTelegramKey(user.ID, tgUser)
}
if invite.Profile != "" && app.config.Section("ombi").Key("enabled").MustBool(false) {
if profile.Ombi != nil && len(profile.Ombi) != 0 {
template := profile.Ombi
errors, code, err := app.ombi.NewUser(req.Username, req.Password, req.Email, template)
accountExists := false
var ombiUser map[string]interface{}
if err != nil || code != 200 {
// Check if on the off chance, Ombi's user importer has already added the account.
ombiUser, status, err = app.getOmbiImportedUser(req.Username)
if status == 200 && err == nil {
app.info.Println(lm.Ombi + " " + lm.UserExists)
accountExists = true
template["password"] = req.Password
status, err = app.applyOmbiProfile(ombiUser, template)
if status != 200 || err != nil {
app.err.Printf(lm.FailedApplyProfile, lm.Ombi, req.Username, err)
}
} else {
app.info.Printf(lm.FailedCreateUser, lm.Ombi, req.Username, err)
app.debug.Printf(lm.AdditionalErrors, lm.Ombi, strings.Join(errors, ", "))
}
} else {
ombiUser, status, err = app.getOmbiUser(id)
if status != 200 || err != nil {
app.err.Printf(lm.FailedGetUser, id, lm.Ombi, err)
} else {
app.info.Println(lm.CreateUser, lm.Ombi, id)
accountExists = true
}
}
if accountExists {
if discordVerified || telegramVerified {
dID := ""
tUser := ""
if discordVerified {
dID = discordUser.ID
}
if telegramVerified {
u, _ := app.storage.GetTelegramKey(user.ID)
tUser = u.Username
}
resp, status, err := app.ombi.SetNotificationPrefs(ombiUser, dID, tUser)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Ombi, err)
app.debug.Printf(lm.AdditionalErrors, lm.Ombi, resp)
}
}
}
}
}
if invite.Profile != "" && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
if profile.Jellyseerr.Enabled {
// Gets existing user (not possible) or imports the given user.
_, err := app.js.MustGetUser(id)
if err != nil {
app.err.Printf(lm.FailedCreateUser, lm.Jellyseerr, id, err)
} else {
app.info.Printf(lm.CreateUser, lm.Jellyseerr, id)
}
err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User)
if err != nil {
app.err.Printf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, id, err)
}
err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications)
if err != nil {
app.err.Printf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, id, err)
}
contactMethods := map[jellyseerr.NotificationsField]any{}
if emailEnabled {
err = app.js.ModifyMainUserSettings(id, jellyseerr.MainUserSettings{Email: req.Email})
if err != nil {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, id, err)
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
}
}
if discordVerified {
contactMethods[jellyseerr.FieldDiscord] = discordUser.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
}
if telegramVerified {
u, _ := app.storage.GetTelegramKey(user.ID)
contactMethods[jellyseerr.FieldTelegram] = u.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
}
if emailEnabled || discordVerified || telegramVerified {
err := app.js.ModifyNotifications(id, contactMethods)
if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
}
}
}
if matrixVerified {
matrixUser.Contact = req.MatrixContact
delete(app.matrix.tokens, req.MatrixPIN)
if app.storage.deprecatedMatrix == nil {
app.storage.deprecatedMatrix = matrixStore{}
}
app.storage.SetMatrixKey(user.ID, matrixUser)
}
if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramVerified || discordVerified || matrixVerified {
name := app.getAddressOrName(user.ID)
msg, err := app.email.constructWelcome(req.Username, expiry, app, false)
if err != nil {
app.err.Printf(lm.FailedConstructWelcomeMessage, id, err)
} else if err := app.sendByID(msg, user.ID); err != nil {
app.err.Printf(lm.FailedSendWelcomeMessage, id, req.Email, err)
} else {
app.info.Printf(lm.SentWelcomeMessage, id, req.Email)
}
}
app.jf.CacheExpiry = time.Now()
success = true
return
}
// @Summary Creates a new Jellyfin user via invite code var discordUser *DiscordUser = nil
// @Produce json var telegramUser *TelegramUser = nil
// @Param newUserDTO body newUserDTO true "New user request object" if app.ombi.Enabled(app, profile) || app.js.Enabled(app, profile) {
// @Success 200 {object} PasswordValidation // FIXME: figure these out in a nicer way? this relies on the current ordering,
// @Failure 400 {object} PasswordValidation // which may not be fixed.
// @Router /newUser [post] if discordEnabled {
// @tags Users discordUser = completeContactMethods[0].User.(*DiscordUser)
func (app *appContext) NewUser(gc *gin.Context) { if telegramEnabled {
var req newUserDTO telegramUser = completeContactMethods[1].User.(*TelegramUser)
gc.BindJSON(&req) }
if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText, false) { } else if telegramEnabled {
app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, lm.IncorrectCaptcha) telegramUser = completeContactMethods[0].User.(*TelegramUser)
respond(400, "errorCaptcha", gc)
return
}
if !app.checkInvite(req.Code, false, "") {
app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, fmt.Sprintf(lm.InvalidInviteCode, req.Code))
respond(401, "errorInvalidCode", gc)
return
}
validation := app.validator.validate(req.Password)
valid := true
for _, val := range validation {
if !val {
valid = false
break
} }
} }
if !valid {
// 200 bcs idk what i did in js for _, tps := range app.thirdPartyServices {
gc.JSON(200, validation) if !tps.Enabled(app, profile) {
return continue
}
if emailEnabled {
if app.config.Section("email").Key("required").MustBool(false) && !strings.Contains(req.Email, "@") {
respond(400, "errorNoEmail", gc)
return
} }
if app.config.Section("email").Key("require_unique").MustBool(false) && req.Email != "" && app.EmailAddressExists(req.Email) { // User already created, now we can link contact methods
respond(400, "errorEmailLinked", gc) err := tps.AddContactMethods(nu.User.ID, req, discordUser, telegramUser)
return if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
} }
} }
f, success := app.newUser(req, false, gc)
app.WelcomeNewUser(nu.User, expiry)
/*responseFunc, logFunc, success := app.NewUser(req, false, gc)
if !success { if !success {
f(gc) logFunc()
responseFunc(gc)
return return
} }*/
code := 200 code := 200
for _, val := range validation { for _, val := range validation {
if !val { if !val {
@ -954,7 +672,7 @@ func (app *appContext) Announce(gc *gin.Context) {
return return
} }
} }
app.info.Printf(lm.SentAnnouncementMessage, userID, "?") // app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
} else { } else {
msg, err := app.email.constructTemplate(req.Subject, req.Message, app) msg, err := app.email.constructTemplate(req.Subject, req.Message, app)
if err != nil { if err != nil {
@ -966,8 +684,9 @@ func (app *appContext) Announce(gc *gin.Context) {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
app.info.Printf(lm.SentAnnouncementMessage, "*", "?") // app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
} }
app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -1455,7 +1174,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
// newUser["userName"] = user["userName"] // newUser["userName"] = user["userName"]
// newUser["alias"] = user["alias"] // newUser["alias"] = user["alias"]
// newUser["emailAddress"] = user["emailAddress"] // newUser["emailAddress"] = user["emailAddress"]
status, err = app.applyOmbiProfile(user, ombi) status, err = app.ombi.applyProfile(user, ombi)
if status != 200 || err != nil { if status != 200 || err != nil {
errorString += fmt.Sprintf("Apply %d: %v ", status, err) errorString += fmt.Sprintf("Apply %d: %v ", status, err)
} }

View File

@ -11,21 +11,21 @@ import (
) )
type DiscordDaemon struct { type DiscordDaemon struct {
Stopped bool Stopped bool
ShutdownChannel chan string ShutdownChannel chan string
bot *dg.Session bot *dg.Session
username string username string
tokens map[string]VerifToken // Map of pins to tokens. tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of token pins to discord users. verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
channelID, channelName, inviteChannelID, inviteChannelName string Channel, InviteChannel struct{ ID, Name string }
guildID string guildID string
serverChannelName, serverName string serverChannelName, serverName string
users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start. users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start.
roleID string roleID string
app *appContext app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string) commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string commandIDs []string
commandDescriptions []*dg.ApplicationCommand commandDescriptions []*dg.ApplicationCommand
} }
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@ -120,12 +120,12 @@ func (d *DiscordDaemon) run() {
d.serverChannelName = guild.Name d.serverChannelName = guild.Name
d.serverName = guild.Name d.serverName = guild.Name
if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" { if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" {
d.channelName = channel d.Channel.Name = channel
d.serverChannelName += "/" + channel d.serverChannelName += "/" + channel
} }
if d.app.config.Section("discord").Key("provide_invite").MustBool(false) { if d.app.config.Section("discord").Key("provide_invite").MustBool(false) {
if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" { if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" {
d.inviteChannelName = invChannel d.InviteChannel.Name = invChannel
} }
} }
err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start")) err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
@ -171,11 +171,11 @@ func (d *DiscordDaemon) ApplyRole(userID string) error {
func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) { func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) {
var inv *dg.Invite var inv *dg.Invite
var err error var err error
if d.inviteChannelName == "" { if d.InviteChannel.Name == "" {
d.app.err.Println(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty) d.app.err.Println(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty)
return return
} }
if d.inviteChannelID == "" { if d.InviteChannel.ID == "" {
channels, err := d.bot.GuildChannels(d.guildID) channels, err := d.bot.GuildChannels(d.guildID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetDiscordChannels, err) d.app.err.Printf(lm.FailedGetDiscordChannels, err)
@ -188,14 +188,14 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
// d.app.err.Printf(lm.FailedGetDiscordChannel, ch.ID, err) // d.app.err.Printf(lm.FailedGetDiscordChannel, ch.ID, err)
// return // return
// } // }
if channel.Name == d.inviteChannelName { if channel.Name == d.InviteChannel.Name {
d.inviteChannelID = channel.ID d.InviteChannel.ID = channel.ID
found = true found = true
break break
} }
} }
if !found { if !found {
d.app.err.Printf(lm.FailedGetDiscordChannel, d.InviteChannelName, lm.NotFound) d.app.err.Printf(lm.FailedGetDiscordChannel, d.InviteChannel.Name, lm.NotFound)
return return
} }
} }
@ -204,7 +204,7 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
// d.app.err.Printf(lm.FailedGetDiscordChannel, d.inviteChannelID, err) // d.app.err.Printf(lm.FailedGetDiscordChannel, d.inviteChannelID, err)
// return // return
// } // }
inv, err = d.bot.ChannelInviteCreate(d.inviteChannelID, dg.Invite{ inv, err = d.bot.ChannelInviteCreate(d.InviteChannel.ID, dg.Invite{
// Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1], // Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1],
// Channel: channel, // Channel: channel,
// Inviter: d.bot.State.User, // Inviter: d.bot.State.User,
@ -451,19 +451,19 @@ func (d *DiscordDaemon) UpdateCommands() {
func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) { func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok { if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok {
if i.GuildID != "" && d.channelName != "" { if i.GuildID != "" && d.Channel.Name != "" {
if d.channelID == "" { if d.Channel.ID == "" {
channel, err := s.Channel(i.ChannelID) channel, err := s.Channel(i.ChannelID)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err) d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err)
d.app.err.Println(lm.MonitorAllDiscordChannels) d.app.err.Println(lm.MonitorAllDiscordChannels)
d.channelName = "" d.Channel.Name = ""
} }
if channel.Name == d.channelName { if channel.Name == d.Channel.Name {
d.channelID = channel.ID d.Channel.ID = channel.ID
} }
} }
if d.channelID != i.ChannelID { if d.Channel.ID != i.ChannelID {
d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord) d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord)
return return
} }
@ -739,10 +739,10 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error {
} }
// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself. // UserVerified returns whether or not a token with the given PIN has been verified, and the user itself.
func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) { func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) {
user, ok = d.verifiedTokens[pin] u, ok := d.verifiedTokens[pin]
// delete(d.verifiedTokens, pin) // delete(d.verifiedTokens, pin)
return return &u, ok
} }
// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself. // AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself.
@ -762,7 +762,44 @@ func (d *DiscordDaemon) UserExists(id string) bool {
return err != nil || c > 0 return err != nil || c > 0
} }
// DeleteVerifiedUser removes the token with the given PIN. // Exists returns whether or not the given user exists.
func (d *DiscordDaemon) DeleteVerifiedUser(pin string) { func (d *DiscordDaemon) Exists(user ContactMethodUser) bool {
delete(d.verifiedTokens, pin) return d.UserExists(user.MethodID().(string))
}
// DeleteVerifiedToken removes the token with the given PIN.
func (d *DiscordDaemon) DeleteVerifiedToken(PIN string) {
delete(d.verifiedTokens, PIN)
}
func (d *DiscordDaemon) PIN(req newUserDTO) string { return req.DiscordPIN }
func (d *DiscordDaemon) Name() string { return lm.Discord }
func (d *DiscordDaemon) Required() bool {
return d.app.config.Section("discord").Key("required").MustBool(false)
}
func (d *DiscordDaemon) UniqueRequired() bool {
return d.app.config.Section("discord").Key("require_unique").MustBool(false)
}
func (d *DiscordDaemon) PostVerificationTasks(PIN string, u ContactMethodUser) error {
err := d.ApplyRole(u.MethodID().(string))
if err != nil {
return fmt.Errorf(lm.FailedSetDiscordMemberRole, err)
}
return err
}
func (d *DiscordUser) Name() string { return RenderDiscordUsername(*d) }
func (d *DiscordUser) SetMethodID(id any) { d.ID = id.(string) }
func (d *DiscordUser) MethodID() any { return d.ID }
func (d *DiscordUser) SetJellyfin(id string) { d.JellyfinID = id }
func (d *DiscordUser) Jellyfin() string { return d.JellyfinID }
func (d *DiscordUser) SetAllowContactFromDTO(req newUserDTO) { d.Contact = req.DiscordContact }
func (d *DiscordUser) SetAllowContact(contact bool) { d.Contact = contact }
func (d *DiscordUser) AllowContact() bool { return d.Contact }
func (d *DiscordUser) Store(st *Storage) {
st.SetDiscordKey(d.Jellyfin(), *d)
} }

View File

@ -104,7 +104,7 @@ func (app *appContext) clearActivities() {
} }
} }
if err == badger.ErrTxnTooBig { if err == badger.ErrTxnTooBig {
app.debug.Printf(lm.AcitivityLogTxnTooBig) app.debug.Printf(lm.ActivityLogTxnTooBig)
list := []Activity{} list := []Activity{}
if errorSource == 0 { if errorSource == 0 {
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge)) app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))

0
images/jfa-go-icon.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

0
images/jfa-go-icon.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

@ -11,7 +11,7 @@ import (
func (app *appContext) SynchronizeJellyseerrUser(jfID string) { func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID) user, imported, err := app.js.GetOrImportUser(jfID)
if err != nil { if err != nil {
app.debug.Printf(lm.FailedMustGetJellyseerrUser, jfID, err) app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err)
return return
} }
if imported { if imported {

View File

@ -115,7 +115,7 @@ const (
FailedGetJellyseerrNotificationPrefs = "Failed to get user \"%s\"'s notification prefs from " + Jellyseerr + ": %v" FailedGetJellyseerrNotificationPrefs = "Failed to get user \"%s\"'s notification prefs from " + Jellyseerr + ": %v"
FailedSyncContactMethods = "Failed to sync contact methods with %s: %v" FailedSyncContactMethods = "Failed to sync contact methods with %s: %v"
ImportJellyseerrUser = "Triggered import for " + Jellyseerr + " user \"%s\" (New ID: %d)" ImportJellyseerrUser = "Triggered import for " + Jellyseerr + " user \"%s\" (New ID: %d)"
FailedMustGetJellyseerrUser = "Failed to get or trigger import for " + Jellyseerr + " user \"%s\": %v" FailedImportUser = "Failed to get or trigger import for %s user \"%s\": %v"
// api-messages.go // api-messages.go
FailedGetCustomMessage = "Failed to get custom message \"%s\"" FailedGetCustomMessage = "Failed to get custom message \"%s\""

44
main.go
View File

@ -102,8 +102,9 @@ type appContext struct {
// Keeping jf name because I can't think of a better one // Keeping jf name because I can't think of a better one
jf *mediabrowser.MediaBrowser jf *mediabrowser.MediaBrowser
authJf *mediabrowser.MediaBrowser authJf *mediabrowser.MediaBrowser
ombi *ombi.Ombi ombi *OmbiWrapper
js *jellyseerr.Jellyseerr js *JellyseerrWrapper
thirdPartyServices []ThirdPartyService
datePattern string datePattern string
timePattern string timePattern string
storage Storage storage Storage
@ -112,6 +113,7 @@ type appContext struct {
telegram *TelegramDaemon telegram *TelegramDaemon
discord *DiscordDaemon discord *DiscordDaemon
matrix *MatrixDaemon matrix *MatrixDaemon
contactMethods []ContactMethodLinker
info, debug, err *logger.Logger info, debug, err *logger.Logger
host string host string
port int port int
@ -349,27 +351,32 @@ func start(asDaemon, firstCall bool) {
} }
address = fmt.Sprintf("%s:%d", app.host, app.port) address = fmt.Sprintf("%s:%d", app.host, app.port)
// NOTE: As of writing this, the order in app.thirdPartServices doesn't matter,
// but in future it might (like app.contactMethods does), so append to the end!
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
app.ombi = &OmbiWrapper{}
app.debug.Printf(lm.UsingOmbi) app.debug.Printf(lm.UsingOmbi)
ombiServer := app.config.Section("ombi").Key("server").String() ombiServer := app.config.Section("ombi").Key("server").String()
app.ombi = ombi.NewOmbi( app.ombi.Ombi = ombi.NewOmbi(
ombiServer, ombiServer,
app.config.Section("ombi").Key("api_key").String(), app.config.Section("ombi").Key("api_key").String(),
common.NewTimeoutHandler("Ombi", ombiServer, true), common.NewTimeoutHandler("Ombi", ombiServer, true),
) )
app.thirdPartyServices = append(app.thirdPartyServices, app.ombi)
} }
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) { if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
app.js = &JellyseerrWrapper{}
app.debug.Printf(lm.UsingJellyseerr) app.debug.Printf(lm.UsingJellyseerr)
jellyseerrServer := app.config.Section("jellyseerr").Key("server").String() jellyseerrServer := app.config.Section("jellyseerr").Key("server").String()
app.js = jellyseerr.NewJellyseerr( app.js.Jellyseerr = jellyseerr.NewJellyseerr(
jellyseerrServer, jellyseerrServer,
app.config.Section("jellyseerr").Key("api_key").String(), app.config.Section("jellyseerr").Key("api_key").String(),
common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true), common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true),
) )
app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false) app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false)
// app.js.LogRequestBodies = true // app.js.LogRequestBodies = true
app.thirdPartyServices = append(app.thirdPartyServices, app.js)
} }
@ -511,17 +518,8 @@ func start(asDaemon, firstCall bool) {
defer backupDaemon.Shutdown() defer backupDaemon.Shutdown()
} }
if telegramEnabled { // NOTE: The order in which these are placed in app.contactMethods matters.
app.telegram, err = newTelegramDaemon(app) // Add new ones to the end.
if err != nil {
app.err.Printf(lm.FailedInitTelegram, err)
telegramEnabled = false
} else {
app.debug.Println(lm.InitTelegram)
go app.telegram.run()
defer app.telegram.Shutdown()
}
}
if discordEnabled { if discordEnabled {
app.discord, err = newDiscordDaemon(app) app.discord, err = newDiscordDaemon(app)
if err != nil { if err != nil {
@ -531,6 +529,19 @@ func start(asDaemon, firstCall bool) {
app.debug.Println(lm.InitDiscord) app.debug.Println(lm.InitDiscord)
go app.discord.run() go app.discord.run()
defer app.discord.Shutdown() defer app.discord.Shutdown()
app.contactMethods = append(app.contactMethods, app.discord)
}
}
if telegramEnabled {
app.telegram, err = newTelegramDaemon(app)
if err != nil {
app.err.Printf(lm.FailedInitTelegram, err)
telegramEnabled = false
} else {
app.debug.Println(lm.InitTelegram)
go app.telegram.run()
defer app.telegram.Shutdown()
app.contactMethods = append(app.contactMethods, app.telegram)
} }
} }
if matrixEnabled { if matrixEnabled {
@ -542,6 +553,7 @@ func start(asDaemon, firstCall bool) {
app.debug.Println(lm.InitMatrix) app.debug.Println(lm.InitMatrix)
go app.matrix.run() go app.matrix.run()
defer app.matrix.Shutdown() defer app.matrix.Shutdown()
app.contactMethods = append(app.contactMethods, app.matrix)
} }
} }
} else { } else {

View File

@ -32,15 +32,6 @@ type UnverifiedUser struct {
User *MatrixUser User *MatrixUser
} }
type MatrixUser struct {
RoomID string
Encrypted bool
UserID string
Lang string
Contact bool
JellyfinID string `badgerhold:"key"`
}
var matrixFilter = mautrix.Filter{ var matrixFilter = mautrix.Filter{
Room: mautrix.RoomFilter{ Room: mautrix.RoomFilter{
Timeline: mautrix.FilterPart{ Timeline: mautrix.FilterPart{
@ -277,6 +268,57 @@ func (d *MatrixDaemon) UserExists(userID string) bool {
return err != nil || c > 0 return err != nil || c > 0
} }
// Exists returns whether or not the given user exists.
func (d *MatrixDaemon) Exists(user ContactMethodUser) bool {
return d.UserExists(user.Name())
}
// User enters ID on sign-up, a PIN is sent to them. They enter it on sign-up. // User enters ID on sign-up, a PIN is sent to them. They enter it on sign-up.
// Message the user first, to avoid E2EE by default // Message the user first, to avoid E2EE by default
func (d *MatrixDaemon) PIN(req newUserDTO) string { return req.MatrixPIN }
func (d *MatrixDaemon) Name() string { return lm.Matrix }
func (d *MatrixDaemon) Required() bool {
return d.app.config.Section("telegram").Key("required").MustBool(false)
}
func (d *MatrixDaemon) UniqueRequired() bool {
return d.app.config.Section("telegram").Key("require_unique").MustBool(false)
}
// TokenVerified returns whether or not a token with the given PIN has been verified, and the token itself.
func (d *MatrixDaemon) TokenVerified(pin string) (token UnverifiedUser, ok bool) {
token, ok = d.tokens[pin]
// delete(t.verifiedTokens, pin)
return
}
// DeleteVerifiedToken removes the token with the given PIN.
func (d *MatrixDaemon) DeleteVerifiedToken(PIN string) {
delete(d.tokens, PIN)
}
func (d *MatrixDaemon) UserVerified(PIN string) (ContactMethodUser, bool) {
token, ok := d.TokenVerified(PIN)
if !ok {
return &MatrixUser{}, false
}
return token.User, ok
}
func (d *MatrixDaemon) PostVerificationTasks(string, ContactMethodUser) error { return nil }
func (m *MatrixUser) Name() string { return m.UserID }
func (m *MatrixUser) SetMethodID(id any) { m.UserID = id.(string) }
func (m *MatrixUser) MethodID() any { return m.UserID }
func (m *MatrixUser) SetJellyfin(id string) { m.JellyfinID = id }
func (m *MatrixUser) Jellyfin() string { return m.JellyfinID }
func (m *MatrixUser) SetAllowContactFromDTO(req newUserDTO) { m.Contact = req.MatrixContact }
func (m *MatrixUser) SetAllowContact(contact bool) { m.Contact = contact }
func (m *MatrixUser) AllowContact() bool { return m.Contact }
func (m *MatrixUser) Store(st *Storage) {
st.SetMatrixKey(m.Jellyfin(), *m)
}

View File

@ -31,7 +31,7 @@ type newUserDTO struct {
type newUserResponse struct { type newUserResponse struct {
User bool `json:"user" binding:"required"` // Whether user was created successfully User bool `json:"user" binding:"required"` // Whether user was created successfully
Email bool `json:"email"` // Whether welcome email was successfully sent (always true if feature is disabled Email bool `json:"email"` // Whether welcome email was successfully sent (always true if feature is disabled)
Error string `json:"error"` // Optional error message. Error string `json:"error"` // Optional error message.
} }

View File

@ -135,7 +135,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.GET(p+"/lang/:page/:file", app.ServeLang) router.GET(p+"/lang/:page/:file", app.ServeLang)
router.GET(p+"/token/login", app.getTokenLogin) router.GET(p+"/token/login", app.getTokenLogin)
router.GET(p+"/token/refresh", app.getTokenRefresh) router.GET(p+"/token/refresh", app.getTokenRefresh)
router.POST(p+"/newUser", app.NewUser) router.POST(p+"/newUser", app.NewUserFromInvite)
router.Use(static.Serve(p+"/invite/", app.webFS)) router.Use(static.Serve(p+"/invite/", app.webFS))
router.GET(p+"/invite/:invCode", app.InviteProxy) router.GET(p+"/invite/:invCode", app.InviteProxy)
if app.config.Section("captcha").Key("enabled").MustBool(false) { if app.config.Section("captcha").Key("enabled").MustBool(false) {
@ -182,7 +182,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.POST(p+"/logout", app.Logout) router.POST(p+"/logout", app.Logout)
api.DELETE(p+"/users", app.DeleteUsers) api.DELETE(p+"/users", app.DeleteUsers)
api.GET(p+"/users", app.GetUsers) api.GET(p+"/users", app.GetUsers)
api.POST(p+"/users", app.NewUserAdmin) api.POST(p+"/users", app.NewUserFromAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry) api.POST(p+"/users/extend", app.ExtendExpiry)
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
api.POST(p+"/users/enable", app.EnableDisableUsers) api.POST(p+"/users/enable", app.EnableDisableUsers)

View File

@ -509,6 +509,15 @@ func (st *Storage) GetDefaultProfile() Profile {
return defaultProfile return defaultProfile
} }
// MustGetProfileKey returns the profile at key k, or if missing, the default profile.
func (st *Storage) MustGetProfileKey(k string) Profile {
p, ok := st.GetProfileKey(k)
if !ok {
p = st.GetDefaultProfile()
}
return p
}
// GetCustomContent returns a copy of the store. // GetCustomContent returns a copy of the store.
func (st *Storage) GetCustomContent() []CustomContent { func (st *Storage) GetCustomContent() []CustomContent {
result := []CustomContent{} result := []CustomContent{}
@ -584,12 +593,35 @@ func (st *Storage) DeleteActivityKey(k string) {
st.db.Delete(k, Activity{}) st.db.Delete(k, Activity{})
} }
type TelegramUser struct { type ThirdPartyService interface {
JellyfinID string `badgerhold:"key"` // ok implies user imported, err can be any issue that occurs during
ChatID int64 `badgerhold:"index"` ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool)
Username string `badgerhold:"index"` AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error)
Lang string Enabled(app *appContext, profile *Profile) bool
Contact bool // Whether to contact through telegram or not Name() string
}
type ContactMethodLinker interface {
PIN(req newUserDTO) string
Name() string
Required() bool
UniqueRequired() bool
UserVerified(PIN string) (ContactMethodUser, bool)
PostVerificationTasks(PIN string, u ContactMethodUser) error
DeleteVerifiedToken(PIN string)
Exists(ContactMethodUser) bool
}
type ContactMethodUser interface {
SetMethodID(id any)
MethodID() any
Name() string
SetJellyfin(id string)
Jellyfin() string
SetAllowContactFromDTO(req newUserDTO)
SetAllowContact(contact bool)
AllowContact() bool
Store(st *Storage)
} }
type DiscordUser struct { type DiscordUser struct {
@ -602,6 +634,24 @@ type DiscordUser struct {
JellyfinID string `json:"-" badgerhold:"key"` JellyfinID string `json:"-" badgerhold:"key"`
} }
type TelegramUser struct {
TelegramVerifiedToken
JellyfinID string `badgerhold:"key"`
ChatID int64 `badgerhold:"index"`
Username string `badgerhold:"index"`
Lang string
Contact bool // Whether to contact through telegram or not
}
type MatrixUser struct {
RoomID string
Encrypted bool
UserID string
Lang string
Contact bool
JellyfinID string `badgerhold:"key"`
}
type EmailAddress struct { type EmailAddress struct {
Addr string `badgerhold:"index"` Addr string `badgerhold:"index"`
Label string // User Label. Label string // User Label.
@ -686,6 +736,16 @@ type Invite struct {
UseReferralExpiry bool `json:"use_referral_expiry"` UseReferralExpiry bool `json:"use_referral_expiry"`
} }
func (invite Invite) Source() (ActivitySource, string) {
sourceType := ActivityAnon
source := ""
if invite.ReferrerJellyfinID != "" {
sourceType = ActivityUser
source = invite.ReferrerJellyfinID
}
return sourceType, source
}
type Captcha struct { type Captcha struct {
Answer string Answer string
Image []byte // image/png Image []byte // image/png

View File

@ -16,9 +16,27 @@ const (
) )
type TelegramVerifiedToken struct { type TelegramVerifiedToken struct {
ChatID int64 JellyfinID string `badgerhold:"key"` // optional, for ensuring a user-requested change is only accessed by them.
Username string ChatID int64 `badgerhold:"index"`
JellyfinID string // optional, for ensuring a user-requested change is only accessed by them. Username string `badgerhold:"index"`
}
func (tv TelegramVerifiedToken) ToUser() *TelegramUser {
return &TelegramUser{
TelegramVerifiedToken: tv,
}
}
func (t *TelegramVerifiedToken) Name() string { return t.Username }
func (t *TelegramVerifiedToken) SetMethodID(id any) { t.ChatID = id.(int64) }
func (t *TelegramVerifiedToken) MethodID() any { return t.ChatID }
func (t *TelegramVerifiedToken) SetJellyfin(id string) { t.JellyfinID = id }
func (t *TelegramVerifiedToken) Jellyfin() string { return t.JellyfinID }
func (t *TelegramUser) SetAllowContactFromDTO(req newUserDTO) { t.Contact = req.TelegramContact }
func (t *TelegramUser) SetAllowContact(contact bool) { t.Contact = contact }
func (t *TelegramUser) AllowContact() bool { return t.Contact }
func (t *TelegramUser) Store(st *Storage) {
st.SetTelegramKey(t.Jellyfin(), *t)
} }
// VerifToken stores details about a pending user verification token. // VerifToken stores details about a pending user verification token.
@ -274,7 +292,39 @@ func (t *TelegramDaemon) UserExists(username string) bool {
return err != nil || c > 0 return err != nil || c > 0
} }
// DeleteVerifiedToken removes the token with the given PIN. // Exists returns whether or not the given user exists.
func (t *TelegramDaemon) DeleteVerifiedToken(pin string) { func (t *TelegramDaemon) Exists(user ContactMethodUser) bool {
delete(t.verifiedTokens, pin) return t.UserExists(user.Name())
} }
// DeleteVerifiedToken removes the token with the given PIN.
func (t *TelegramDaemon) DeleteVerifiedToken(PIN string) {
delete(t.verifiedTokens, PIN)
}
func (t *TelegramDaemon) PIN(req newUserDTO) string { return req.TelegramPIN }
func (t *TelegramDaemon) Name() string { return lm.Telegram }
func (t *TelegramDaemon) Required() bool {
return t.app.config.Section("telegram").Key("required").MustBool(false)
}
func (t *TelegramDaemon) UniqueRequired() bool {
return t.app.config.Section("telegram").Key("require_unique").MustBool(false)
}
func (t *TelegramDaemon) UserVerified(PIN string) (ContactMethodUser, bool) {
token, ok := t.TokenVerified(PIN)
if !ok {
return &TelegramUser{}, false
}
tu := token.ToUser()
if lang, ok := t.languages[tu.ChatID]; ok {
tu.Lang = lang
}
return tu, ok
}
func (t *TelegramDaemon) PostVerificationTasks(string, ContactMethodUser) error { return nil }

View File

@ -4,7 +4,8 @@
"target": "es2017", "target": "es2017",
"lib": ["dom", "es2017"], "lib": ["dom", "es2017"],
"typeRoots": ["./typings", "../node_modules/@types"], "typeRoots": ["./typings", "../node_modules/@types"],
"moduleResolution": "nodenext", "module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true "esModuleInterop": true
} }
} }

180
users.go Normal file
View File

@ -0,0 +1,180 @@
package main
import (
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
)
// ReponseFunc responds to the user, generally by HTTP response
// The cases when more than this occurs are given below.
type ResponseFunc func(gc *gin.Context)
// LogFunc prints a log line once called.
type LogFunc func()
type ContactMethodConf struct {
Email, Discord, Telegram, Matrix bool
}
type ContactMethodUsers struct {
Email emailStore
Discord DiscordUser
Telegram TelegramVerifiedToken
Matrix MatrixUser
}
type ContactMethodValidation struct {
Verified ContactMethodConf
Users ContactMethodUsers
}
type NewUserParams struct {
Req newUserDTO
SourceType ActivitySource
Source string
ContextForIPLogging *gin.Context
Profile *Profile
}
type NewUserData struct {
Created bool
Success bool
User mediabrowser.User
Message string
Status int
Log func()
}
// FIXME: First load of steps are going in NewUserFromInvite, because they're only used there.
// Make an interface{} for Require/Verify/ExistingUser which all contact daemons respect, then loop through!
/*
-- STEPS --
- Validate Invite
- Validate CAPTCHA
- Validate Password
- a) Discord (Require, Verify, ExistingUser, ApplyRole)
b) Telegram (Require, Verify, ExistingUser)
c) Matrix (Require, Verify, ExistingUser)
d) Email (Require, Verify, ExistingUser)
* Check for existing user
* Generate JF user
- Delete Invite
* Store Activity
* Store Email
- Store Discord/Telegram/Matrix/Label
- Notify Admin (Doesn't really matter when this happens)
* Apply Profile
* Generate JS, Ombi Users, apply profiles
* Send Welcome Email
*/
func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData) {
// Some helper functions which will behave as our app.info/error/debug
deferLogInfo := func(s string, args ...any) {
out.Log = func() {
app.info.Printf(s, args)
}
}
/* deferLogDebug := func(s string, args ...any) {
out.Log = func() {
app.debug.Printf(s, args)
}
} */
deferLogError := func(s string, args ...any) {
out.Log = func() {
app.err.Printf(s, args)
}
}
existingUser, _, _ := app.jf.UserByName(p.Req.Username, false)
if existingUser.Name != "" {
out.Message = lm.UserExists
deferLogInfo(lm.FailedCreateUser, lm.Jellyfin, p.Req.Username, out.Message)
out.Status = 401
return
}
var status int
var err error
out.User, status, err = app.jf.NewUser(p.Req.Username, p.Req.Password)
if !(status == 200 || status == 204) || err != nil {
out.Message = err.Error()
deferLogError(lm.FailedCreateUser, lm.Jellyfin, p.Req.Username, out.Message)
out.Status = 401
return
}
out.Created = true
// Invalidate Cache to be safe
app.jf.CacheExpiry = time.Now()
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreation,
UserID: out.User.ID,
SourceType: p.SourceType,
Source: p.Source,
InviteCode: p.Req.Code, // Left blank when an admin does this
Value: out.User.Name,
Time: time.Now(),
}, p.ContextForIPLogging, (p.SourceType != ActivityAdmin))
if p.Profile != nil {
status, err = app.jf.SetPolicy(out.User.ID, p.Profile.Policy)
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf(lm.FailedApplyTemplate, "policy", lm.Jellyfin, out.User.ID, err)
}
status, err = app.jf.SetConfiguration(out.User.ID, p.Profile.Configuration)
if (status == 200 || status == 204) && err == nil {
status, err = app.jf.SetDisplayPreferences(out.User.ID, p.Profile.Displayprefs)
}
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf(lm.FailedApplyTemplate, "configuration", lm.Jellyfin, out.User.ID, err)
}
for _, tps := range app.thirdPartyServices {
if !tps.Enabled(app, p.Profile) {
continue
}
// When ok and err != nil, its a non-fatal failure that we lot without the "FailedImportUser".
err, ok := tps.ImportUser(out.User.ID, p.Req, *p.Profile)
if !ok {
app.err.Printf(lm.FailedImportUser, tps.Name(), p.Req.Username, err)
} else if err != nil {
app.info.Println(err)
}
}
}
// Welcome email is sent by each user of this method separately..
out.Status = 200
out.Success = true
return
}
func (app *appContext) WelcomeNewUser(user mediabrowser.User, expiry time.Time) (failed bool) {
if !app.config.Section("welcome_email").Key("enabled").MustBool(false) {
// we didn't "fail", we "politely declined"
// failed = true
return
}
failed = true
name := app.getAddressOrName(user.ID)
if name == "" {
return
}
msg, err := app.email.constructWelcome(user.Name, expiry, app, false)
if err != nil {
app.err.Printf(lm.FailedConstructWelcomeMessage, user.ID, err)
} else if err := app.sendByID(msg, user.ID); err != nil {
app.err.Printf(lm.FailedSendWelcomeMessage, user.ID, name, err)
} else {
app.info.Printf(lm.SentWelcomeMessage, user.ID, name)
failed = false
}
return
}

189
views.go
View File

@ -237,7 +237,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) {
"server_channel": app.discord.serverChannelName, "server_channel": app.discord.serverChannelName,
})) }))
data["discordServerName"] = app.discord.serverName data["discordServerName"] = app.discord.serverName
data["discordInviteLink"] = app.discord.inviteChannelName != "" data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
} }
if data["linkResetEnabled"].(bool) { if data["linkResetEnabled"].(bool) {
data["resetPasswordUsername"] = app.config.Section("user_page").Key("allow_pwr_username").MustBool(true) data["resetPasswordUsername"] = app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
@ -616,13 +616,108 @@ func (app *appContext) VerifyCaptcha(gc *gin.Context) {
return return
} }
func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lang string, gc *gin.Context) {
fail := func() {
gcHTML(gc, 404, "404.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
var req newUserDTO
if app.ConfirmationKeys == nil {
fail()
return
}
invKeys, ok := app.ConfirmationKeys[invite.Code]
if !ok {
fail()
return
}
req, ok = invKeys[key]
if !ok {
fail()
return
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
fail()
app.debug.Printf(lm.FailedParseJWT, err)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["invite"].(string) == invite.Code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
fail()
app.debug.Printf(lm.InvalidJWT)
return
}
sourceType, source := invite.Source()
var profile *Profile = nil
if invite.Profile != "" {
p, ok := app.storage.GetProfileKey(invite.Profile)
if !ok {
app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, invite.Profile)
p = app.storage.GetDefaultProfile()
}
profile = &p
}
nu := app.NewUserPostVerification(NewUserParams{
Req: req,
SourceType: sourceType,
Source: source,
ContextForIPLogging: gc,
Profile: profile,
})
if !nu.Success {
nu.Log()
}
if !nu.Created {
respond(nu.Status, nu.Message, gc)
fail()
return
}
app.checkInvite(req.Code, true, req.Username)
jfLink := app.config.Section("ui").Key("redirect_url").String()
if app.config.Section("ui").Key("auto_redirect").MustBool(false) {
gc.Redirect(301, jfLink)
} else {
gcHTML(gc, http.StatusOK, "create-success.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"strings": app.storage.lang.User[lang].Strings,
"successMessage": app.config.Section("ui").Key("success_message").String(),
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"jfLink": jfLink,
})
}
app.confirmationKeysLock.Lock()
// Re-fetch invKeys just incase an update occurred
invKeys, ok = app.ConfirmationKeys[invite.Code]
if !ok {
fail()
return
}
delete(invKeys, key)
app.ConfirmationKeys[invite.Code] = invKeys
app.confirmationKeysLock.Unlock()
return
}
func (app *appContext) InviteProxy(gc *gin.Context) { func (app *appContext) InviteProxy(gc *gin.Context) {
app.pushResources(gc, FormPage) app.pushResources(gc, FormPage)
code := gc.Param("invCode")
lang := app.getLang(gc, FormPage, app.storage.lang.chosenUserLang) lang := app.getLang(gc, FormPage, app.storage.lang.chosenUserLang)
/* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */ /* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */
// if app.checkInvite(code, false, "") { // if app.checkInvite(code, false, "") {
inv, ok := app.storage.GetInvitesKey(code) invite, ok := app.storage.GetInvitesKey(gc.Param("invCode"))
if !ok { if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{ gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc), "urlBase": app.getURLBase(gc),
@ -632,75 +727,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
}) })
return return
} }
if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
fail := func() {
gcHTML(gc, 404, "404.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
var req newUserDTO
if app.ConfirmationKeys == nil {
fail()
return
}
invKeys, ok := app.ConfirmationKeys[code] if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
if !ok { app.NewUserFromConfirmationKey(invite, key, lang, gc)
fail()
return
}
req, ok = invKeys[key]
if !ok {
fail()
return
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
fail()
app.debug.Printf(lm.FailedParseJWT, err)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["invite"].(string) == code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
fail()
app.debug.Printf(lm.InvalidJWT)
return
}
f, success := app.newUser(req, true, gc)
if !success {
// Not meant for us. Calling this is bad but at least gives us log output.
f(gc)
fail()
return
}
jfLink := app.config.Section("ui").Key("redirect_url").String()
if app.config.Section("ui").Key("auto_redirect").MustBool(false) {
gc.Redirect(301, jfLink)
} else {
gcHTML(gc, http.StatusOK, "create-success.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"strings": app.storage.lang.User[lang].Strings,
"successMessage": app.config.Section("ui").Key("success_message").String(),
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"jfLink": jfLink,
})
}
delete(invKeys, key)
app.confirmationKeysLock.Lock()
app.ConfirmationKeys[code] = invKeys
app.confirmationKeysLock.Unlock()
return return
} }
email := ""
if invite, ok := app.storage.GetInvitesKey(code); ok { email := invite.SendTo
email = invite.SendTo
}
if strings.Contains(email, "Failed") || !strings.Contains(email, "@") { if strings.Contains(email, "Failed") || !strings.Contains(email, "@") {
email = "" email = ""
} }
@ -715,8 +748,8 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
userPageAddress += "/my/account" userPageAddress += "/my/account"
fromUser := "" fromUser := ""
if inv.ReferrerJellyfinID != "" { if invite.ReferrerJellyfinID != "" {
sender, status, err := app.jf.UserByID(inv.ReferrerJellyfinID, false) sender, status, err := app.jf.UserByID(invite.ReferrerJellyfinID, false)
if status == 200 && err == nil { if status == 200 && err == nil {
fromUser = sender.Name fromUser = sender.Name
} }
@ -738,13 +771,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"strings": app.storage.lang.User[lang].Strings, "strings": app.storage.lang.User[lang].Strings,
"validationStrings": app.storage.lang.User[lang].validationStringsJSON, "validationStrings": app.storage.lang.User[lang].validationStringsJSON,
"notifications": app.storage.lang.User[lang].notificationsJSON, "notifications": app.storage.lang.User[lang].notificationsJSON,
"code": code, "code": invite.Code,
"confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false), "confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false),
"userExpiry": inv.UserExpiry, "userExpiry": invite.UserExpiry,
"userExpiryMonths": inv.UserMonths, "userExpiryMonths": invite.UserMonths,
"userExpiryDays": inv.UserDays, "userExpiryDays": invite.UserDays,
"userExpiryHours": inv.UserHours, "userExpiryHours": invite.UserHours,
"userExpiryMinutes": inv.UserMinutes, "userExpiryMinutes": invite.UserMinutes,
"userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"), "userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"),
"langName": lang, "langName": lang,
"passwordReset": false, "passwordReset": false,
@ -779,7 +812,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"server_channel": app.discord.serverChannelName, "server_channel": app.discord.serverChannelName,
})) }))
data["discordServerName"] = app.discord.serverName data["discordServerName"] = app.discord.serverName
data["discordInviteLink"] = app.discord.inviteChannelName != "" data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
} }
if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled { if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled {
data["customSuccessCard"] = true data["customSuccessCard"] = true