You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
jfa-go/storage.go

1643 lines
48 KiB

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
}