package main import ( "encoding/json" "fmt" "io/fs" "log" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/hrfee/jfa-go/common" "github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/logger" lm "github.com/hrfee/jfa-go/logmessages" "github.com/hrfee/mediabrowser" "github.com/timshannon/badgerhold/v4" "gopkg.in/ini.v1" ) type discordStore map[string]DiscordUser type telegramStore map[string]TelegramUser type matrixStore map[string]MatrixUser type emailStore map[string]EmailAddress type ActivityType int const ( ActivityCreation ActivityType = iota ActivityDeletion ActivityDisabled ActivityEnabled ActivityContactLinked ActivityContactUnlinked ActivityChangePassword ActivityResetPassword ActivityCreateInvite ActivityDeleteInvite ActivityUnknown ) type ActivitySource int const ( ActivityUser ActivitySource = iota // Source = UserID. For ActivityCreation, this would mean the referrer. ActivityAdmin // Source = Admin's UserID, or blank if jellyfin login isn't on. ActivityAnon // Source = Blank, or potentially browser info. For ActivityCreation, this would be via an invite ActivityDaemon // Source = Blank, was deleted/disabled due to expiry by daemon ) type Activity struct { ID string `badgerhold:"key"` Type ActivityType `badgerhold:"index"` UserID string // ID of target user. For account creation, this will be the newly created account SourceType ActivitySource Source string InviteCode string // Set for ActivityCreation, create/deleteInvite Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username. Time time.Time IP string } type UserExpiry struct { JellyfinID string `badgerhold:"key"` Expiry time.Time DeleteAfterPeriod bool // Whether or not to further disable the user later on } type DebugLogAction int const ( NoLog DebugLogAction = iota LogAll LogDeletion // Logs deletion, and wiping of main field in new data, e.g. setting email.addr to "". ) type Storage struct { debug *logger.Logger logActions map[string]DebugLogAction timePattern string db_path string db *badgerhold.Store invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path, announcements_path, matrix_sql_path, userPage_path string deprecatedUserExpiries map[string]time.Time // Map of Jellyfin User IDs to their expiry times. deprecatedInvites Invites deprecatedProfiles map[string]Profile deprecatedDisplayprefs, deprecatedOmbiTemplate map[string]interface{} deprecatedEmails emailStore // Map of Jellyfin User IDs to Email addresses. deprecatedTelegram telegramStore // Map of Jellyfin User IDs to telegram users. deprecatedDiscord discordStore // Map of Jellyfin user IDs to discord users. deprecatedMatrix matrixStore // Map of Jellyfin user IDs to Matrix users. deprecatedPolicy mediabrowser.Policy deprecatedConfiguration mediabrowser.Configuration deprecatedAnnouncements map[string]announcementTemplate deprecatedCustomEmails customEmails deprecatedUserPageContent userPageContent lang Lang } type StoreType int // Used for debug logging of storage. const ( StoredEmails StoreType = iota StoredDiscord StoredTelegram StoredMatrix StoredInvites StoredAnnouncements StoredExpiries StoredProfiles StoredCustomContent ) // DebugWatch logs database writes according on the advanced debugging settings in the Advanced section func (st *Storage) DebugWatch(storeType StoreType, key, mainData string) { if st.debug == nil { return } actionKey := "" switch storeType { case StoredEmails: actionKey = "emails" case StoredDiscord: actionKey = "discord" case StoredTelegram: actionKey = "telegram" case StoredMatrix: actionKey = "matrix" case StoredInvites: actionKey = "invites" case StoredAnnouncements: actionKey = "announcements" case StoredExpiries: actionKey = "expiries" case StoredProfiles: actionKey = "profiles" case StoredCustomContent: actionKey = "custom_content" } logAction := st.logActions[actionKey] if logAction == NoLog { return } actionString := "WRITE" if mainData == "" { actionString = "DELETE" } if logAction == LogAll || mainData == "" { st.debug.Printf("%s @ %s %s[%s] = \"%s\"\n", actionString, logger.Lshortfile(3), actionKey, key, mainData) } } func generateLogActions(c *ini.File) map[string]DebugLogAction { m := map[string]DebugLogAction{} for _, v := range []string{"emails", "discord", "telegram", "matrix", "invites", "announcements", "expirires", "profiles", "custom_content"} { switch c.Section("advanced").Key("debug_log_" + v).MustString("none") { case "none": m[v] = NoLog case "all": m[v] = LogAll case "deletion": m[v] = LogDeletion } } return m } func (app *appContext) ConnectDB() { opts := badgerhold.DefaultOptions opts.Dir = app.storage.db_path opts.ValueDir = app.storage.db_path db, err := badgerhold.Open(opts) if err != nil { app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err) } app.storage.db = db app.info.Printf(lm.ConnectDB, app.storage.db_path) } // GetEmails returns a copy of the store. func (st *Storage) GetEmails() []EmailAddress { result := []EmailAddress{} err := st.db.Find(&result, &badgerhold.Query{}) if err != nil { // fmt.Printf("Failed to find emails: %v\n", err) } return result } // GetEmailsKey returns the value stored in the store's key. func (st *Storage) GetEmailsKey(k string) (EmailAddress, bool) { result := EmailAddress{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find email: %v\n", err) ok = false } return result, ok } // SetEmailsKey stores value v in key k. func (st *Storage) SetEmailsKey(k string, v EmailAddress) { st.DebugWatch(StoredEmails, k, v.Addr) v.JellyfinID = k err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set email: %v\n", err) } } // DeleteEmailKey deletes value at key k. func (st *Storage) DeleteEmailsKey(k string) { st.DebugWatch(StoredEmails, k, "") st.db.Delete(k, EmailAddress{}) } // GetDiscord returns a copy of the store. func (st *Storage) GetDiscord() []DiscordUser { result := []DiscordUser{} err := st.db.Find(&result, &badgerhold.Query{}) if err != nil { // fmt.Printf("Failed to find users: %v\n", err) } return result } // GetDiscordKey returns the value stored in the store's key. func (st *Storage) GetDiscordKey(k string) (DiscordUser, bool) { result := DiscordUser{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find user: %v\n", err) ok = false } return result, ok } // SetDiscordKey stores value v in key k. func (st *Storage) SetDiscordKey(k string, v DiscordUser) { st.DebugWatch(StoredDiscord, k, v.Username) v.JellyfinID = k err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set user: %v\n", err) } } // DeleteDiscordKey deletes value at key k. func (st *Storage) DeleteDiscordKey(k string) { st.DebugWatch(StoredDiscord, k, "") st.db.Delete(k, DiscordUser{}) } // GetTelegram returns a copy of the store. func (st *Storage) GetTelegram() []TelegramUser { result := []TelegramUser{} err := st.db.Find(&result, &badgerhold.Query{}) if err != nil { // fmt.Printf("Failed to find users: %v\n", err) } return result } // GetTelegramKey returns the value stored in the store's key. func (st *Storage) GetTelegramKey(k string) (TelegramUser, bool) { result := TelegramUser{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find user: %v\n", err) ok = false } return result, ok } // SetTelegramKey stores value v in key k. func (st *Storage) SetTelegramKey(k string, v TelegramUser) { st.DebugWatch(StoredTelegram, k, v.Username) v.JellyfinID = k err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set user: %v\n", err) } } // DeleteTelegramKey deletes value at key k. func (st *Storage) DeleteTelegramKey(k string) { st.DebugWatch(StoredTelegram, k, "") st.db.Delete(k, TelegramUser{}) } // GetMatrix returns a copy of the store. func (st *Storage) GetMatrix() []MatrixUser { result := []MatrixUser{} err := st.db.Find(&result, &badgerhold.Query{}) if err != nil { // fmt.Printf("Failed to find users: %v\n", err) } return result } // GetMatrixKey returns the value stored in the store's key. func (st *Storage) GetMatrixKey(k string) (MatrixUser, bool) { result := MatrixUser{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find user: %v\n", err) ok = false } return result, ok } // SetMatrixKey stores value v in key k. func (st *Storage) SetMatrixKey(k string, v MatrixUser) { st.DebugWatch(StoredMatrix, k, v.UserID) v.JellyfinID = k err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set user: %v\n", err) } } // DeleteMatrixKey deletes value at key k. func (st *Storage) DeleteMatrixKey(k string) { st.DebugWatch(StoredMatrix, k, "") st.db.Delete(k, MatrixUser{}) } // GetInvites returns a copy of the store. func (st *Storage) GetInvites() []Invite { result := []Invite{} err := st.db.Find(&result, &badgerhold.Query{}) if err != nil { // fmt.Printf("Failed to find invites: %v\n", err) } return result } // GetInvitesKey returns the value stored in the store's key. func (st *Storage) GetInvitesKey(k string) (Invite, bool) { result := Invite{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find invite: %v\n", err) ok = false } return result, ok } // SetInvitesKey stores value v in key k. func (st *Storage) SetInvitesKey(k string, v Invite) { st.DebugWatch(StoredInvites, k, "changed") // Not sure what the main data from this would be v.Code = k err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set invite: %v\n", err) } } // DeleteInvitesKey deletes value at key k. func (st *Storage) DeleteInvitesKey(k string) { st.DebugWatch(StoredInvites, k, "") st.db.Delete(k, Invite{}) } // GetAnnouncements returns a copy of the store. func (st *Storage) GetAnnouncements() []announcementTemplate { result := []announcementTemplate{} err := st.db.Find(&result, &badgerhold.Query{}) if err != nil { // fmt.Printf("Failed to find announcements: %v\n", err) } return result } // GetAnnouncementsKey returns the value stored in the store's key. func (st *Storage) GetAnnouncementsKey(k string) (announcementTemplate, bool) { result := announcementTemplate{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find announcement: %v\n", err) ok = false } return result, ok } // SetAnnouncementsKey stores value v in key k. func (st *Storage) SetAnnouncementsKey(k string, v announcementTemplate) { st.DebugWatch(StoredAnnouncements, k, v.Subject) err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set announcement: %v\n", err) } } // DeleteAnnouncementsKey deletes value at key k. func (st *Storage) DeleteAnnouncementsKey(k string) { st.DebugWatch(StoredAnnouncements, k, "") st.db.Delete(k, announcementTemplate{}) } // GetUserExpiries returns a copy of the store. func (st *Storage) GetUserExpiries() []UserExpiry { result := []UserExpiry{} err := st.db.Find(&result, &badgerhold.Query{}) if err != nil { // fmt.Printf("Failed to find expiries: %v\n", err) } return result } // GetUserExpiryKey returns the value stored in the store's key. func (st *Storage) GetUserExpiryKey(k string) (UserExpiry, bool) { result := UserExpiry{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find expiry: %v\n", err) ok = false } return result, ok } // SetUserExpiryKey stores value v in key k. func (st *Storage) SetUserExpiryKey(k string, v UserExpiry) { st.DebugWatch(StoredExpiries, k, v.Expiry.String()) v.JellyfinID = k err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set expiry: %v\n", err) } } // DeleteUserExpiryKey deletes value at key k. func (st *Storage) DeleteUserExpiryKey(k string) { st.DebugWatch(StoredExpiries, k, "") st.db.Delete(k, UserExpiry{}) } // GetProfiles returns a copy of the store. func (st *Storage) GetProfiles() []Profile { result := []Profile{} err := st.db.Find(&result, &badgerhold.Query{}) if err != nil { // fmt.Printf("Failed to find profiles: %v\n", err) } return result } // GetProfileKey returns the value stored in the store's key. func (st *Storage) GetProfileKey(k string) (Profile, bool) { result := Profile{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find profile: %v\n", err) ok = false } if result.Policy.BlockedTags == nil { result.Policy.BlockedTags = []interface{}{} } return result, ok } // SetProfileKey stores value v in key k. func (st *Storage) SetProfileKey(k string, v Profile) { st.DebugWatch(StoredProfiles, k, "changed") v.Name = k v.Admin = v.Policy.IsAdministrator if v.Policy.EnabledFolders != nil { if len(v.Policy.EnabledFolders) == 0 { v.LibraryAccess = "All" } else { v.LibraryAccess = strconv.Itoa(len(v.Policy.EnabledFolders)) } } if v.FromUser == "" { v.FromUser = "Unknown" } err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set profile: %v\n", err) } } // DeleteProfileKey deletes value at key k. func (st *Storage) DeleteProfileKey(k string) { st.DebugWatch(StoredProfiles, k, "") st.db.Delete(k, Profile{}) } // GetDefaultProfile returns the first profile set as default, or anything available if there isn't one. func (st *Storage) GetDefaultProfile() Profile { defaultProfile := Profile{} err := st.db.FindOne(&defaultProfile, badgerhold.Where("Default").Eq(true)) if err != nil { st.db.FindOne(&defaultProfile, &badgerhold.Query{}) } 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. func (st *Storage) GetCustomContent() []CustomContent { result := []CustomContent{} err := st.db.Find(&result, &badgerhold.Query{}) if err != nil { // fmt.Printf("Failed to find custom content: %v\n", err) } return result } // GetCustomContentKey returns the value stored in the store's key. func (st *Storage) GetCustomContentKey(k string) (CustomContent, bool) { result := CustomContent{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find custom content: %v\n", err) ok = false } return result, ok } // MustGetCustomContentKey returns the value stored in the store's key, or an empty value. func (st *Storage) MustGetCustomContentKey(k string) CustomContent { result := CustomContent{} st.db.Get(k, &result) return result } // SetCustomContentKey stores value v in key k. func (st *Storage) SetCustomContentKey(k string, v CustomContent) { st.DebugWatch(StoredCustomContent, k, "changed") v.Name = k err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set custom content: %v\n", err) } } // DeleteCustomContentKey deletes value at key k. func (st *Storage) DeleteCustomContentKey(k string) { st.DebugWatch(StoredCustomContent, k, "") st.db.Delete(k, CustomContent{}) } // GetActivityKey returns the value stored in the store's key. func (st *Storage) GetActivityKey(k string) (Activity, bool) { result := Activity{} err := st.db.Get(k, &result) ok := true if err != nil { // fmt.Printf("Failed to find custom content: %v\n", err) ok = false } return result, ok } // SetActivityKey stores value v in key k. // If the IP should be logged, pass "gc", and whether or not the action is of a user func (st *Storage) SetActivityKey(k string, v Activity, gc *gin.Context, user bool) { v.ID = k if gc != nil && ((LOGIPU && user) || (LOGIP && !user)) { v.IP = gc.ClientIP() } err := st.db.Upsert(k, v) if err != nil { // fmt.Printf("Failed to set custom content: %v\n", err) } } // DeleteActivityKey deletes value at key k. func (st *Storage) DeleteActivityKey(k string) { st.db.Delete(k, Activity{}) } type ThirdPartyService interface { common.ConfigurableTransport // ok implies user imported, err can be any issue that occurs during ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) Enabled(app *appContext, profile *Profile) bool Name() string } type ContactMethodLinker interface { common.ConfigurableTransport 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 { ChannelID string ID string `badgerhold:"index"` Username string `badgerhold:"index"` Discriminator string Lang string Contact bool 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 UserID string Lang string Contact bool JellyfinID string `badgerhold:"key"` } type EmailAddress struct { Addr string `badgerhold:"index"` Label string // User Label. Contact bool Admin bool // Whether or not user is jfa-go admin. JellyfinID string `badgerhold:"key"` ReferralTemplateKey string } type customEmails struct { UserCreated CustomContent `json:"userCreated"` InviteExpiry CustomContent `json:"inviteExpiry"` PasswordReset CustomContent `json:"passwordReset"` UserDeleted CustomContent `json:"userDeleted"` UserDisabled CustomContent `json:"userDisabled"` UserEnabled CustomContent `json:"userEnabled"` UserExpiryAdjusted CustomContent `json:"userExpiryAdjusted"` InviteEmail CustomContent `json:"inviteEmail"` WelcomeEmail CustomContent `json:"welcomeEmail"` EmailConfirmation CustomContent `json:"emailConfirmation"` UserExpired CustomContent `json:"userExpired"` } // CustomContent stores customized versions of jfa-go content, including emails and user messages. type CustomContent struct { Name string `json:"name" badgerhold:"key"` Enabled bool `json:"enabled,omitempty"` Content string `json:"content"` Variables []string `json:"variables,omitempty"` Conditionals []string `json:"conditionals,omitempty"` } type userPageContent struct { Login CustomContent `json:"login"` Page CustomContent `json:"page"` } // timePattern: %Y-%m-%dT%H:%M:%S.%f type Profile struct { Name string `badgerhold:"key"` Admin bool `json:"admin,omitempty" badgerhold:"index"` LibraryAccess string `json:"libraries,omitempty"` FromUser string `json:"fromUser,omitempty"` Homescreen bool `json:"homescreen"` Policy mediabrowser.Policy `json:"policy,omitempty"` Configuration mediabrowser.Configuration `json:"configuration,omitempty"` Displayprefs map[string]interface{} `json:"displayprefs,omitempty"` Default bool `json:"default,omitempty"` Ombi map[string]interface{} `json:"ombi,omitempty"` Jellyseerr JellyseerrTemplate `json:"jellyseerr,omitempty"` ReferralTemplateKey string } type JellyseerrTemplate struct { Enabled bool `json:"enabled,omitempty"` User jellyseerr.UserTemplate `json:"user,omitempty"` Notifications jellyseerr.NotificationsTemplate `json:"notifications,omitempty"` } type Invite struct { Code string `badgerhold:"key"` Created time.Time `json:"created"` NoLimit bool `json:"no-limit"` RemainingUses int `json:"remaining-uses"` ValidTill time.Time `json:"valid_till"` UserExpiry bool `json:"user-duration"` UserMonths int `json:"user-months,omitempty"` UserDays int `json:"user-days,omitempty"` UserHours int `json:"user-hours,omitempty"` UserMinutes int `json:"user-minutes,omitempty"` SendTo string `json:"email"` // Used to be stored as formatted time, now as Unix. UsedBy [][]string `json:"used-by"` Notify map[string]map[string]bool `json:"notify"` Profile string `json:"profile"` Label string `json:"label,omitempty"` UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite. Captchas map[string]Captcha // Map of Captcha IDs to images & answers IsReferral bool `json:"is_referral" badgerhold:"index"` ReferrerJellyfinID string `json:"referrer_id"` 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 { Answer string Image []byte // image/png Generated time.Time } type Lang struct { AdminPath string chosenAdminLang string Admin adminLangs AdminJSON map[string]string UserPath string chosenUserLang string User userLangs PasswordResetPath string chosenPWRLang string PasswordReset pwrLangs EmailPath string chosenEmailLang string Email emailLangs CommonPath string Common commonLangs SetupPath string Setup setupLangs // Telegram translations are also used for Discord bots (and likely future ones). chosenTelegramLang string TelegramPath string Telegram telegramLangs } func (st *Storage) loadLang(filesystems ...fs.FS) (err error) { err = st.loadLangCommon(filesystems...) if err != nil { err = fmt.Errorf("common: %v", err) return } err = st.loadLangAdmin(filesystems...) if err != nil { err = fmt.Errorf("admin: %v", err) return } err = st.loadLangEmail(filesystems...) if err != nil { err = fmt.Errorf("email: %v", err) return } err = st.loadLangUser(filesystems...) if err != nil { err = fmt.Errorf("user: %v", err) return } err = st.loadLangPWR(filesystems...) if err != nil { err = fmt.Errorf("pwr: %v", err) return } err = st.loadLangTelegram(filesystems...) return } // The following patch* functions fill in a language with missing values // from a list of other sources in a preferred order. // languages to patch from should be in decreasing priority, // E.g: If to = fr-be, from = [fr-fr, en-us]. func (common *commonLangs) patchCommonStrings(to *langSection, from ...string) { if *to == nil { *to = langSection{} } for n, ev := range (*common)[from[len(from)-1]].Strings { if v, ok := (*to)[n]; !ok || v == "" { i := 0 for i < len(from)-1 { ev, ok = (*common)[from[i]].Strings[n] if ok && ev != "" { break } i++ } (*to)[n] = ev } } } func (common *commonLangs) patchCommonNotifications(to *langSection, from ...string) { if *to == nil { *to = langSection{} } for n, ev := range (*common)[from[len(from)-1]].Notifications { if v, ok := (*to)[n]; !ok || v == "" { i := 0 for i < len(from)-1 { ev, ok = (*common)[from[i]].Notifications[n] if ok && ev != "" { break } i++ } (*to)[n] = ev } } } func (common *commonLangs) patchCommonQuantityStrings(to *map[string]quantityString, from ...string) { if *to == nil { *to = map[string]quantityString{} } for n, ev := range (*common)[from[len(from)-1]].QuantityStrings { if v, ok := (*to)[n]; !ok || (v.Singular == "" && v.Plural == "") { i := 0 for i < len(from)-1 { ev, ok = (*common)[from[i]].QuantityStrings[n] if ok && ev.Singular != "" && ev.Plural != "" { break } i++ } (*to)[n] = ev } } } func patchLang(to *langSection, from ...*langSection) { if *to == nil { *to = langSection{} } for n, ev := range *from[len(from)-1] { if v, ok := (*to)[n]; !ok || v == "" { i := 0 for i < len(from)-1 { ev, ok = (*from[i])[n] if ok && ev != "" { break } i++ } (*to)[n] = ev } } } func patchQuantityStrings(to *map[string]quantityString, from ...*map[string]quantityString) { if *to == nil { *to = map[string]quantityString{} } for n, ev := range *from[len(from)-1] { qs, ok := (*to)[n] if !ok || qs.Singular == "" || qs.Plural == "" { i := 0 subOk := false for i < len(from)-1 { ev, subOk = (*from[i])[n] if subOk && ev.Singular != "" && ev.Plural != "" { break } i++ } if !ok { (*to)[n] = ev continue } else if qs.Singular == "" { qs.Singular = ev.Singular } else if qs.Plural == "" { qs.Plural = ev.Plural } (*to)[n] = qs } } } type loadLangFunc func(fsIndex int, name string) error func (st *Storage) loadLangCommon(filesystems ...fs.FS) error { st.lang.Common = map[string]commonLang{} var english commonLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := commonLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.CommonPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.Common[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.Common[lang.Meta.Fallback] } if err == nil { loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true patchLang(&lang.Strings, &fallback.Strings, &english.Strings) patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications) patchQuantityStrings(&lang.QuantityStrings, &fallback.QuantityStrings, &english.QuantityStrings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.Strings, &english.Strings) patchLang(&lang.Notifications, &english.Notifications) patchQuantityStrings(&lang.QuantityStrings, &english.QuantityStrings) } } st.lang.Common[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.Common["en-us"] commonLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.CommonPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { commonLoaded = true loadedLangs[i][f.Name()] = true } } } } if !commonLoaded { return err } return nil } func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error { st.lang.Admin = map[string]adminLang{} var english adminLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := adminLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.AdminPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } st.lang.Common.patchCommonStrings(&lang.Strings, index) st.lang.Common.patchCommonNotifications(&lang.Notifications, index) if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.Admin[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.Admin[lang.Meta.Fallback] } if err == nil { loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true patchLang(&lang.Strings, &fallback.Strings, &english.Strings) patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications) patchQuantityStrings(&lang.QuantityStrings, &fallback.QuantityStrings, &english.QuantityStrings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.Strings, &english.Strings) patchLang(&lang.Notifications, &english.Notifications) patchQuantityStrings(&lang.QuantityStrings, &english.QuantityStrings) } } stringAdmin, err := json.Marshal(lang) if err != nil { return err } lang.JSON = string(stringAdmin) st.lang.Admin[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.Admin["en-us"] adminLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.AdminPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { adminLoaded = true loadedLangs[i][f.Name()] = true } } } } if !adminLoaded { return err } return nil } func (st *Storage) loadLangUser(filesystems ...fs.FS) error { st.lang.User = map[string]userLang{} var english userLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := userLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.UserPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } st.lang.Common.patchCommonStrings(&lang.Strings, index) st.lang.Common.patchCommonNotifications(&lang.Notifications, index) st.lang.Common.patchCommonQuantityStrings(&lang.QuantityStrings, index) // turns out, a lot of email strings are useful on the user page. emailLang := []langSection{st.lang.Email[index].WelcomeEmail, st.lang.Email[index].UserDisabled, st.lang.Email[index].UserExpired} for _, v := range emailLang { patchLang(&lang.Strings, &v) } if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.User[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.User[lang.Meta.Fallback] } if err == nil { loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true patchLang(&lang.Strings, &fallback.Strings, &english.Strings) patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications) patchQuantityStrings(&lang.ValidationStrings, &fallback.ValidationStrings, &english.ValidationStrings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.Strings, &english.Strings) patchLang(&lang.Notifications, &english.Notifications) patchQuantityStrings(&lang.ValidationStrings, &english.ValidationStrings) } } notifications, err := json.Marshal(lang.Notifications) if err != nil { return err } validationStrings, err := json.Marshal(lang.ValidationStrings) if err != nil { return err } userJSON, err := json.Marshal(lang) if err != nil { return err } lang.notificationsJSON = string(notifications) lang.validationStringsJSON = string(validationStrings) lang.JSON = string(userJSON) st.lang.User[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.User["en-us"] userLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.UserPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { userLoaded = true loadedLangs[i][f.Name()] = true } } } } if !userLoaded { return err } return nil } func (st *Storage) loadLangPWR(filesystems ...fs.FS) error { st.lang.PasswordReset = map[string]pwrLang{} var english pwrLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := pwrLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.PasswordResetPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } st.lang.Common.patchCommonStrings(&lang.Strings, index) if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.PasswordReset[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.PasswordReset[lang.Meta.Fallback] } if err == nil { patchLang(&lang.Strings, &fallback.Strings, &english.Strings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.Strings, &english.Strings) } } st.lang.PasswordReset[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.PasswordReset["en-us"] userLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.PasswordResetPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { userLoaded = true loadedLangs[i][f.Name()] = true } } } } if !userLoaded { return err } return nil } func (st *Storage) loadLangEmail(filesystems ...fs.FS) error { st.lang.Email = map[string]emailLang{} var english emailLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := emailLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.EmailPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } st.lang.Common.patchCommonStrings(&lang.Strings, index) if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.Email[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.Email[lang.Meta.Fallback] } if err == nil { loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true patchLang(&lang.UserCreated, &fallback.UserCreated, &english.UserCreated) patchLang(&lang.InviteExpiry, &fallback.InviteExpiry, &english.InviteExpiry) patchLang(&lang.PasswordReset, &fallback.PasswordReset, &english.PasswordReset) patchLang(&lang.UserDeleted, &fallback.UserDeleted, &english.UserDeleted) patchLang(&lang.UserDisabled, &fallback.UserDisabled, &english.UserDisabled) patchLang(&lang.UserEnabled, &fallback.UserEnabled, &english.UserEnabled) patchLang(&lang.UserExpiryAdjusted, &fallback.UserExpiryAdjusted, &english.UserExpiryAdjusted) patchLang(&lang.InviteEmail, &fallback.InviteEmail, &english.InviteEmail) patchLang(&lang.WelcomeEmail, &fallback.WelcomeEmail, &english.WelcomeEmail) patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.UserExpired, &fallback.UserExpired, &english.UserExpired) patchLang(&lang.Strings, &fallback.Strings, &english.Strings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.UserCreated, &english.UserCreated) patchLang(&lang.InviteExpiry, &english.InviteExpiry) patchLang(&lang.PasswordReset, &english.PasswordReset) patchLang(&lang.UserDeleted, &english.UserDeleted) patchLang(&lang.UserDisabled, &english.UserDisabled) patchLang(&lang.UserEnabled, &english.UserEnabled) patchLang(&lang.UserExpiryAdjusted, &english.UserExpiryAdjusted) patchLang(&lang.InviteEmail, &english.InviteEmail) patchLang(&lang.WelcomeEmail, &english.WelcomeEmail) patchLang(&lang.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.UserExpired, &english.UserExpired) patchLang(&lang.Strings, &english.Strings) } } st.lang.Email[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.Email["en-us"] emailLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.EmailPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { emailLoaded = true loadedLangs[i][f.Name()] = true } } } } if !emailLoaded { return err } return nil } func (st *Storage) loadLangTelegram(filesystems ...fs.FS) error { st.lang.Telegram = map[string]telegramLang{} var english telegramLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := telegramLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.TelegramPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } st.lang.Common.patchCommonStrings(&lang.Strings, index) if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.Telegram[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.Telegram[lang.Meta.Fallback] } if err == nil { loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true patchLang(&lang.Strings, &fallback.Strings, &english.Strings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.Strings, &english.Strings) } } st.lang.Telegram[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.Telegram["en-us"] telegramLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.TelegramPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { telegramLoaded = true loadedLangs[i][f.Name()] = true } } } } if !telegramLoaded { return err } return nil } type Invites map[string]Invite func (st *Storage) loadInvites() error { return loadJSON(st.invite_path, &st.deprecatedInvites) } func (st *Storage) storeInvites() error { return storeJSON(st.invite_path, st.deprecatedInvites) } func (st *Storage) loadUserExpiries() error { if st.deprecatedUserExpiries == nil { st.deprecatedUserExpiries = map[string]time.Time{} } temp := map[string]time.Time{} err := loadJSON(st.users_path, &temp) if err != nil { return err } for id, t1 := range temp { if _, ok := st.deprecatedUserExpiries[id]; !ok { st.deprecatedUserExpiries[id] = t1 } } return nil } func (st *Storage) storeUserExpiries() error { return storeJSON(st.users_path, st.deprecatedUserExpiries) } func (st *Storage) loadEmails() error { return loadJSON(st.emails_path, &st.deprecatedEmails) } func (st *Storage) storeEmails() error { return storeJSON(st.emails_path, st.deprecatedEmails) } func (st *Storage) loadTelegramUsers() error { return loadJSON(st.telegram_path, &st.deprecatedTelegram) } func (st *Storage) storeTelegramUsers() error { return storeJSON(st.telegram_path, st.deprecatedTelegram) } func (st *Storage) loadDiscordUsers() error { return loadJSON(st.discord_path, &st.deprecatedDiscord) } func (st *Storage) storeDiscordUsers() error { return storeJSON(st.discord_path, st.deprecatedDiscord) } func (st *Storage) loadMatrixUsers() error { return loadJSON(st.matrix_path, &st.deprecatedMatrix) } func (st *Storage) storeMatrixUsers() error { return storeJSON(st.matrix_path, st.deprecatedMatrix) } func (st *Storage) loadCustomEmails() error { return loadJSON(st.customEmails_path, &st.deprecatedCustomEmails) } func (st *Storage) storeCustomEmails() error { return storeJSON(st.customEmails_path, st.deprecatedCustomEmails) } func (st *Storage) loadUserPageContent() error { return loadJSON(st.userPage_path, &st.deprecatedUserPageContent) } func (st *Storage) storeUserPageContent() error { return storeJSON(st.userPage_path, st.deprecatedUserPageContent) } func (st *Storage) loadPolicy() error { return loadJSON(st.policy_path, &st.deprecatedPolicy) } func (st *Storage) storePolicy() error { return storeJSON(st.policy_path, st.deprecatedPolicy) } func (st *Storage) loadConfiguration() error { return loadJSON(st.configuration_path, &st.deprecatedConfiguration) } func (st *Storage) storeConfiguration() error { return storeJSON(st.configuration_path, st.deprecatedConfiguration) } func (st *Storage) loadDisplayprefs() error { return loadJSON(st.displayprefs_path, &st.deprecatedDisplayprefs) } func (st *Storage) storeDisplayprefs() error { return storeJSON(st.displayprefs_path, st.deprecatedDisplayprefs) } func (st *Storage) loadOmbiTemplate() error { return loadJSON(st.ombi_path, &st.deprecatedOmbiTemplate) } func (st *Storage) storeOmbiTemplate() error { return storeJSON(st.ombi_path, st.deprecatedOmbiTemplate) } func (st *Storage) loadAnnouncements() error { return loadJSON(st.announcements_path, &st.deprecatedAnnouncements) } func (st *Storage) storeAnnouncements() error { return storeJSON(st.announcements_path, st.deprecatedAnnouncements) } func (st *Storage) loadProfiles() error { err := loadJSON(st.profiles_path, &st.deprecatedProfiles) for name, profile := range st.deprecatedProfiles { // if profile.Default { // st.defaultProfile = name // } change := false if profile.Policy.IsAdministrator != profile.Admin { change = true } profile.Admin = profile.Policy.IsAdministrator if profile.Policy.EnabledFolders != nil { length := len(profile.Policy.EnabledFolders) if length == 0 { profile.LibraryAccess = "All" } else { profile.LibraryAccess = strconv.Itoa(length) } change = true } if profile.FromUser == "" { profile.FromUser = "Unknown" change = true } if change { st.deprecatedProfiles[name] = profile } } // if st.defaultProfile == "" { // for n := range st.deprecatedProfiles { // st.defaultProfile = n // } // } return err } func (st *Storage) storeProfiles() error { return storeJSON(st.profiles_path, st.deprecatedProfiles) } func (st *Storage) migrateToProfile() error { st.loadPolicy() st.loadConfiguration() st.loadDisplayprefs() st.loadProfiles() st.deprecatedProfiles["Default"] = Profile{ Policy: st.deprecatedPolicy, Configuration: st.deprecatedConfiguration, Displayprefs: st.deprecatedDisplayprefs, } return st.storeProfiles() } func loadJSON(path string, obj interface{}) error { var file []byte var err error file, err = os.ReadFile(path) if err != nil { file = []byte("{}") } err = json.Unmarshal(file, &obj) if err != nil { log.Printf("ERROR: Failed to read \"%s\": %s", path, err) } return err } func storeJSON(path string, obj interface{}) error { data, err := json.Marshal(obj) if err != nil { return err } err = os.WriteFile(path, data, 0644) if err != nil { log.Printf("ERROR: Failed to write to \"%s\": %s", path, err) } return err }