Files
jfa-go/config.go
Harvey Tindall 598a389e3d jellyseerr: fix extremely long import, run only once
cache was being invalidated for every user, and on my 5000 user test
instance, this sweated jellyseerr and my computer (audibly). Also, since
this only needs to realistically run once, a flag is set in the database
to indicate it's been done, and unset once the feature is disabled.
It'll only run on boot if the flag is unset, or if triggered by the
/tasks route. Will likely add manual trigger buttons on the web as well.
2025-11-29 14:13:34 +00:00

527 lines
19 KiB
Go

package main
import (
"fmt"
"io/fs"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"gopkg.in/ini.v1"
)
type Config struct {
*ini.File
proxyTransport *http.Transport
proxyConfig *easyproxy.ProxyConfig
}
var emailEnabled = false
var messagesEnabled = false
var telegramEnabled = false
var discordEnabled = false
var matrixEnabled = false
// URL subpaths. Ignore the "Current" field, it's populated when in copies of the struct used for page templating.
// IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
var PAGES = PagePaths{}
func (config *Config) GetPath(sect, key string) (fs.FS, string) {
val := config.Section(sect).Key(key).MustString("")
if strings.HasPrefix(val, "jfa-go:") {
return localFS, strings.TrimPrefix(val, "jfa-go:")
}
dir, file := filepath.Split(val)
return os.DirFS(dir), file
}
func (config *Config) MustSetValue(section, key, val string) {
config.Section(section).Key(key).SetValue(config.Section(section).Key(key).MustString(val))
}
func (config *Config) MustSetURLPath(section, key, val string) {
if !strings.HasPrefix(val, "/") && val != "" {
val = "/" + val
}
config.MustSetValue(section, key, val)
}
func FixFullURL(v string) string {
// Keep relative paths relative
if strings.HasPrefix(v, "/") {
return v
}
if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
v = "http://" + v
}
return v
}
func FormatSubpath(path string, removeSingleSlash bool) string {
if path == "/" {
if removeSingleSlash {
return ""
}
return path
}
return strings.TrimSuffix(path, "/")
}
func (config *Config) MustCorrectURL(section, key, value string) {
v := config.Section(section).Key(key).String()
if v == "" {
v = value
}
v = FixFullURL(v)
config.Section(section).Key(key).SetValue(v)
}
// ExternalDomain returns the Host for the request, using the fixed externalDomain value unless UseProxyHost is true.
func ExternalDomain(gc *gin.Context) string {
if !UseProxyHost || gc.Request.Host == "" {
return externalDomain
}
return gc.Request.Host
}
// ExternalDomainNoPort attempts to return ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway.
func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
domain := ExternalDomain(gc)
host, _, err := net.SplitHostPort(domain)
if err != nil {
return domain
}
return host
}
// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed externalURI value unless UseProxyHost is true and gc is not nil.
// When nil is passed, externalURI is returned.
func ExternalURI(gc *gin.Context) string {
if gc == nil {
return externalURI
}
var proto string
if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
proto = "https://"
} else {
proto = "http://"
}
// app.debug.Printf("Request: %+v\n", gc.Request)
if UseProxyHost && gc.Request.Host != "" {
return proto + gc.Request.Host + PAGES.Base
}
return externalURI
}
func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string {
if !strings.HasPrefix(path, "/") {
return path
}
var proto string
if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
proto = "https://"
} else {
proto = "http://"
}
return proto + ExternalDomain(gc) + path
}
// NewConfig reads and patches a config file for use. Passed loggers are used only once. Some dependencies can be reloaded after this is called with ReloadDependents(app).
func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Config, error) {
var err error
config := &Config{}
config.File, err = ini.ShadowLoad(configPathOrContents)
if err != nil {
return config, err
}
// URLs
config.MustSetURLPath("ui", "url_base", "")
config.MustSetURLPath("url_paths", "admin", "")
config.MustSetURLPath("url_paths", "user_page", "/my/account")
config.MustSetURLPath("url_paths", "form", "/invite")
PAGES.Base = FormatSubpath(config.Section("ui").Key("url_base").String(), true)
PAGES.Admin = FormatSubpath(config.Section("url_paths").Key("admin").String(), true)
PAGES.MyAccount = FormatSubpath(config.Section("url_paths").Key("user_page").String(), true)
PAGES.Form = FormatSubpath(config.Section("url_paths").Key("form").String(), true)
if !(config.Section("user_page").Key("enabled").MustBool(true)) {
PAGES.MyAccount = "disabled"
}
if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
logs.err.Printf(lm.BadURLBase, PAGES.Base)
}
logs.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
config.MustCorrectURL("jellyfin", "server", "")
config.MustCorrectURL("jellyfin", "public_server", config.Section("jellyfin").Key("server").String())
config.MustCorrectURL("ui", "redirect_url", config.Section("jellyfin").Key("public_server").String())
for _, key := range config.Section("files").Keys() {
if name := key.Name(); name != "html_templates" && name != "lang_files" {
key.SetValue(key.MustString(filepath.Join(dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".json"))))
}
for _, key := range []string{"matrix_sql"} {
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".db"))))
}
// If true, ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to externalDomain if not set.
UseProxyHost = config.Section("ui").Key("use_proxy_host").MustBool(false)
externalURI = strings.TrimSuffix(strings.TrimSuffix(config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
if !strings.HasSuffix(externalURI, PAGES.Base) {
logs.err.Println(lm.NoURLSuffix)
}
if externalURI == "" {
if UseProxyHost {
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
} else {
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave)
}
}
u, err := url.Parse(externalURI)
if err == nil {
externalDomain = u.Hostname()
}
config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
// FIXME: Remove all these, eventually
// config.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
// config.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
// config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
// config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
// config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
// config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
// config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
// config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
// config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
// config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
// config.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
// config.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
// Deletion template is good enough for these as well.
// config.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
// config.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
// config.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
// config.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
// config.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
// config.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
// config.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
// config.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
config.MustSetValue("user_expiry", "behaviour", "disable_user")
// config.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
// config.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
// config.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
// config.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
// config.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html")
// config.MustSetValue("user_expiry", "reminder_email_text", "jfa-go:"+"expiry-reminder.txt")
fnameSettingSuffix := []string{"html", "text"}
fnameExtension := []string{"html", "txt"}
for _, cc := range customContent {
if cc.SourceFile.DefaultValue == "" {
continue
}
for i := range fnameSettingSuffix {
config.MustSetValue(cc.SourceFile.Section, cc.SourceFile.SettingPrefix+fnameSettingSuffix[i], "jfa-go:"+cc.SourceFile.DefaultValue+"."+fnameExtension[i])
}
}
config.MustSetValue("smtp", "hello_hostname", "localhost")
config.MustSetValue("smtp", "cert_validation", "true")
config.MustSetValue("smtp", "auth_type", "4")
config.MustSetValue("smtp", "port", "465")
config.MustSetValue("activity_log", "keep_n_records", "1000")
config.MustSetValue("activity_log", "delete_after_days", "90")
sc := config.Section("discord").Key("start_command").MustString("start")
config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
config.MustSetValue("email", "collect", "true")
collect := config.Section("email").Key("collect").MustBool(true)
required := config.Section("email").Key("required").MustBool(false) && collect
config.Section("email").Key("required").SetValue(strconv.FormatBool(required))
unique := config.Section("email").Key("require_unique").MustBool(false) && collect
config.Section("email").Key("require_unique").SetValue(strconv.FormatBool(unique))
config.MustSetValue("matrix", "topic", "Jellyfin notifications")
config.MustSetValue("matrix", "show_on_reg", "true")
config.MustSetValue("discord", "show_on_reg", "true")
config.MustSetValue("telegram", "show_on_reg", "true")
config.MustSetValue("backups", "every_n_minutes", "1440")
config.MustSetValue("backups", "path", filepath.Join(dataPath, "backups"))
config.MustSetValue("backups", "keep_n_backups", "20")
config.MustSetValue("backups", "keep_previous_version_backup", "true")
config.Section("jellyfin").Key("version").SetValue(version)
config.Section("jellyfin").Key("device").SetValue("jfa-go")
config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
config.MustSetValue("jellyfin", "cache_timeout", "30")
config.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
LOGIP = config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false)
config.MustSetValue("advanced", "auth_retry_count", "6")
config.MustSetValue("advanced", "auth_retry_gap", "10")
config.MustSetValue("ui", "port", "8056")
config.MustSetValue("advanced", "tls_port", "8057")
config.MustSetValue("advanced", "value_log_size", "512")
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true
for _, v := range pwrMethods {
if config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false
}
}
if allDisabled {
logs.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods {
config.Section("user_page").Key(v).SetValue("true")
}
}
messagesEnabled = config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = config.Section("discord").Key("enabled").MustBool(false)
matrixEnabled = config.Section("matrix").Key("enabled").MustBool(false)
if !messagesEnabled {
emailEnabled = false
telegramEnabled = false
discordEnabled = false
matrixEnabled = false
} else if config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false
} else {
emailEnabled = true
}
if !emailEnabled && !telegramEnabled && !discordEnabled && !matrixEnabled {
messagesEnabled = false
}
if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
config.proxyConfig = &easyproxy.ProxyConfig{}
config.proxyConfig.Protocol = easyproxy.HTTP
if strings.Contains(config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
config.proxyConfig.Protocol = easyproxy.SOCKS5
}
config.proxyConfig.Addr = config.Section("advanced").Key("proxy_address").MustString("")
config.proxyConfig.User = config.Section("advanced").Key("proxy_user").MustString("")
config.proxyConfig.Password = config.Section("advanced").Key("proxy_password").MustString("")
config.proxyTransport, err = easyproxy.NewTransport(*(config.proxyConfig))
if err != nil {
logs.err.Printf(lm.FailedInitProxy, config.proxyConfig.Addr, err)
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
// Since we don't crash on this failing.
time.Sleep(15 * time.Second)
config.proxyConfig = nil
config.proxyTransport = nil
} else {
logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
}
}
config.MustSetValue("updates", "enabled", "true")
substituteStrings = config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
if substituteStrings != "" {
v := config.Section("ui").Key("success_message")
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
}
datePattern = config.Section("messages").Key("date_format").String()
timePattern = `%H:%M`
if !(config.Section("messages").Key("use_24h").MustBool(true)) {
timePattern = `%I:%M %p`
}
return config, nil
}
// ReloadDependents re-initialises or applies changes to components of the app which can be reconfigured without restarting.
func (config *Config) ReloadDependents(app *appContext) {
oldFormLang := config.Section("ui").Key("language").MustString("")
if oldFormLang != "" {
app.storage.lang.chosenUserLang = oldFormLang
}
newFormLang := config.Section("ui").Key("language-form").MustString("")
if newFormLang != "" {
app.storage.lang.chosenUserLang = newFormLang
}
app.storage.lang.chosenAdminLang = config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = config.Section("email").Key("language").MustString("en-us")
app.storage.lang.chosenPWRLang = config.Section("password_resets").Key("language").MustString("en-us")
app.storage.lang.chosenTelegramLang = config.Section("telegram").Key("language").MustString("en-us")
releaseChannel := config.Section("updates").Key("channel").String()
if config.Section("updates").Key("enabled").MustBool(false) {
v := version
if releaseChannel == "stable" {
if version == "git" {
v = "0.0.0"
}
} else if releaseChannel == "unstable" {
v = "git"
}
app.updater = NewUpdater(baseURL, namespace, repo, v, commit, updater)
if config.proxyTransport != nil {
app.updater.SetTransport(config.proxyTransport)
}
}
if releaseChannel == "" {
if version == "git" {
releaseChannel = "unstable"
} else {
releaseChannel = "stable"
}
config.MustSetValue("updates", "channel", releaseChannel)
}
app.email = NewEmailer(config, app.storage, app.LoggerSet)
}
func (app *appContext) ReloadConfig() {
var err error = nil
app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
if err != nil {
app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
}
app.config.ReloadDependents(app)
app.info.Printf(lm.LoadConfig, app.configPath)
}
func (app *appContext) PatchConfigBase() {
conf := app.configBase
// Load language options
formOptions := app.storage.lang.User.getOptions()
pwrOptions := app.storage.lang.PasswordReset.getOptions()
adminOptions := app.storage.lang.Admin.getOptions()
emailOptions := app.storage.lang.Email.getOptions()
telegramOptions := app.storage.lang.Email.getOptions()
for i, section := range app.configBase.Sections {
if section.Section == "updates" && updater == "" {
section.Meta.Disabled = true
}
for j, setting := range section.Settings {
if section.Section == "ui" {
if setting.Setting == "language-form" {
setting.Options = formOptions
setting.Value = "en-us"
} else if setting.Setting == "language-admin" {
setting.Options = adminOptions
setting.Value = "en-us"
}
} else if section.Section == "password_resets" {
if setting.Setting == "language" {
setting.Options = pwrOptions
setting.Value = "en-us"
}
} else if section.Section == "email" {
if setting.Setting == "language" {
setting.Options = emailOptions
setting.Value = "en-us"
}
} else if section.Section == "telegram" {
if setting.Setting == "language" {
setting.Options = telegramOptions
setting.Value = "en-us"
}
} else if section.Section == "smtp" {
if setting.Setting == "ssl_cert" && PLATFORM == "windows" {
// Not accurate but the effect is hiding the option, which we want.
setting.Deprecated = true
}
} else if section.Section == "matrix" {
if setting.Setting == "encryption" && !MatrixE2EE() {
// Not accurate but the effect is hiding the option, which we want.
setting.Deprecated = true
}
}
val := app.config.Section(section.Section).Key(setting.Setting)
switch setting.Type {
case "list":
setting.Value = val.StringsWithShadows("|")
case "text", "email", "select", "password", "note":
setting.Value = val.MustString("")
case "number":
setting.Value = val.MustInt(0)
case "bool":
setting.Value = val.MustBool(false)
}
section.Settings[j] = setting
}
conf.Sections[i] = section
}
app.patchedConfig = conf
}
func (app *appContext) PatchConfigDiscordRoles() {
if !discordEnabled {
return
}
r, err := app.discord.ListRoles()
if err != nil {
return
}
roles := make([]common.Option, len(r)+1)
roles[0] = common.Option{"", "None"}
for i, role := range r {
roles[i+1] = role
}
for i, section := range app.patchedConfig.Sections {
if section.Section != "discord" {
continue
}
for j, setting := range section.Settings {
if setting.Setting != "apply_role" {
continue
}
setting.Options = roles
section.Settings[j] = setting
}
app.patchedConfig.Sections[i] = section
}
}