* feat(config): enhance config file and data dir retrieval - Add xdg package for XDG base directory support - Update RetrieveConfigFile to search in XDG_CONFIG_HOME and /etc - Add RetrieveDataDir to find data directory in XDG_DATA_HOME and /var/lib - Improve comments for better understanding of search order * add unit tests
289 lines
8.2 KiB
Go
289 lines
8.2 KiB
Go
package config
|
||
|
||
//go:generate go run ./meta/gen --format go --locale en --pkg config -o ./cache.go
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path"
|
||
"strconv"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"github.com/adrg/xdg"
|
||
"github.com/artalkjs/artalk/v2/internal/config/env_provider"
|
||
"github.com/artalkjs/artalk/v2/internal/log"
|
||
"github.com/artalkjs/artalk/v2/internal/utils"
|
||
"github.com/knadh/koanf"
|
||
"github.com/knadh/koanf/parsers/yaml"
|
||
"github.com/knadh/koanf/providers/file"
|
||
)
|
||
|
||
func New() *Config {
|
||
conf := &Config{}
|
||
|
||
return conf
|
||
}
|
||
|
||
// 从文件中创建配置实例
|
||
func NewFromFile(cfgFile string) (*Config, error) {
|
||
kf := koanf.New(".")
|
||
|
||
// load yaml config
|
||
if err := kf.Load(file.Provider(cfgFile), yaml.Parser()); err != nil {
|
||
return nil, fmt.Errorf("config file read error: %w", err)
|
||
}
|
||
|
||
// load environment variables and merge into the loaded config
|
||
const envPrefix = "ATK_"
|
||
if err := kf.Load(env_provider.Provider(envPrefix, EnvPathMapCache), nil); err != nil {
|
||
return nil, fmt.Errorf("config environment variable parse error: %w", err)
|
||
}
|
||
|
||
// create new config instance
|
||
conf := &Config{
|
||
cfgFile: cfgFile,
|
||
}
|
||
|
||
// use koanf parser to decode config file to instance
|
||
if err := kf.Unmarshal("", conf); err != nil {
|
||
return nil, fmt.Errorf("config file parse error: %w", err)
|
||
}
|
||
|
||
// patch config
|
||
{
|
||
conf.historyPatch()
|
||
conf.normalPatch()
|
||
conf.i18nPatch()
|
||
conf.ipRegionPatch()
|
||
}
|
||
|
||
return conf, nil
|
||
}
|
||
|
||
func (conf *Config) GetCfgFileLoaded() string {
|
||
return conf.cfgFile
|
||
}
|
||
|
||
// 配置修补
|
||
func (conf *Config) normalPatch() {
|
||
// 检查 app_key 是否设置
|
||
if strings.TrimSpace(conf.AppKey) == "" {
|
||
conf.AppKey = utils.RandomString(16)
|
||
log.Warn("config `app_key` is not set, now it is random value")
|
||
}
|
||
|
||
// 检查时区
|
||
conf.TimeZone = strings.TrimSpace(conf.TimeZone)
|
||
if conf.TimeZone == "" {
|
||
conf.TimeZone = "Local"
|
||
log.Warn("config `timezone` is not set, now it is: " + strconv.Quote(conf.TimeZone))
|
||
}
|
||
|
||
// 默认站点配置
|
||
conf.SiteDefault = strings.TrimSpace(conf.SiteDefault)
|
||
if conf.SiteDefault == "" {
|
||
conf.SiteDefault = "Default Site"
|
||
log.Warn("config `site_default` is not set, now it is: " + strconv.Quote(conf.SiteDefault))
|
||
}
|
||
|
||
// 缓存配置
|
||
if conf.Cache.Type == "" {
|
||
// 默认使用内建缓存
|
||
conf.Cache.Type = CacheTypeBuiltin
|
||
}
|
||
|
||
// 配置文件 alias 处理
|
||
if conf.Captcha.ActionLimit == 0 {
|
||
conf.Captcha.Always = true
|
||
}
|
||
|
||
// 管理员邮件通知配置继承
|
||
if conf.AdminNotify.Email.MailSubject == "" {
|
||
if conf.AdminNotify.NotifySubject != "" {
|
||
conf.AdminNotify.Email.MailSubject = conf.AdminNotify.NotifySubject
|
||
} else if conf.Email.MailSubject != "" {
|
||
conf.AdminNotify.Email.MailSubject = conf.Email.MailSubject
|
||
}
|
||
}
|
||
|
||
// 默认待审模式下开启管理员通知嘈杂模式,保证管理员能看到待审核文章
|
||
if conf.Moderator.PendingDefault {
|
||
conf.AdminNotify.NoiseMode = true
|
||
}
|
||
|
||
// 默认将验证码类型设置为 image
|
||
if strings.TrimSpace(string(conf.Captcha.CaptchaType)) == "" {
|
||
conf.Captcha.CaptchaType = TypeImage
|
||
}
|
||
|
||
// 图片上传存放路径默认设置
|
||
if conf.ImgUpload.Path == "" {
|
||
conf.ImgUpload.Path = "./data/artalk-img/"
|
||
log.Warn("[Image Upload] img_upload.path is not configured, using the default value: " + strconv.Quote(conf.ImgUpload.Path))
|
||
}
|
||
|
||
// HTTP 配置默认值
|
||
if conf.HTTP.BodyLimit <= 0 {
|
||
conf.HTTP.BodyLimit = 100
|
||
}
|
||
if conf.HTTP.ProxyHeader == nil {
|
||
log.Warn("config `http.proxy_header` is not set, not it is: \"X-Forwarded-For\". If you are not using a reverse proxy or CDN, please set it to blank for preventing IP spoofing.")
|
||
// The reason it doesn't default to empty here is that the default value in the old version
|
||
// was "X-Forwarded-For", and there is no new field in the conf file for this (the parse result would be a nil pointer).
|
||
//
|
||
// Therefore, to avoid BREAKING CHANGES, it remains unchanged with a warning.
|
||
// New user with a new conf file generated by the latest version will have this field set to empty.
|
||
defaultProxyHeader := "X-Forwarded-For"
|
||
conf.HTTP.ProxyHeader = &defaultProxyHeader
|
||
} else {
|
||
*conf.HTTP.ProxyHeader = strings.TrimSpace(*conf.HTTP.ProxyHeader)
|
||
}
|
||
|
||
// 社交登录配置
|
||
if conf.Auth.Enabled && strings.TrimSpace(conf.Auth.Callback) == "" {
|
||
callbackURL := "http://localhost:23366/api/v2/auth/:provider/callback"
|
||
log.Warn("[SocialLogin] config `auth.callback` is not set, now it is: ", strconv.Quote(callbackURL))
|
||
conf.Auth.Callback = callbackURL
|
||
}
|
||
}
|
||
|
||
// 多语言配置修补
|
||
func (conf *Config) i18nPatch() {
|
||
if conf.Locale == "" {
|
||
conf.Locale = "en"
|
||
|
||
// zh-CN default patch (for 历史兼容)
|
||
// 判断配置文件中是否有中文,若有中文则将 locale 设置为 zh-CN
|
||
if confRaw, err := os.ReadFile(conf.GetCfgFileLoaded()); err == nil {
|
||
containsHan := false
|
||
for _, runeValue := range string(confRaw) {
|
||
if unicode.Is(unicode.Han, runeValue) {
|
||
containsHan = true
|
||
break
|
||
}
|
||
}
|
||
if containsHan {
|
||
conf.Locale = "zh-CN"
|
||
}
|
||
}
|
||
|
||
log.Warn("config `locale` is not set, now it is: " + strconv.Quote(conf.Locale))
|
||
} else if conf.Locale == "zh" {
|
||
conf.Locale = "zh-CN"
|
||
}
|
||
|
||
// Case Convert
|
||
// follow Unicode BCP 47
|
||
// @see https://www.techonthenet.com/js/language_tags.php
|
||
parts := strings.Split(conf.Locale, "-")
|
||
if len(parts) == 2 {
|
||
conf.Locale = strings.ToLower(parts[0]) + "-" + strings.ToUpper(parts[1])
|
||
} else if len(parts) == 1 {
|
||
conf.Locale = strings.ToLower(parts[0])
|
||
}
|
||
|
||
// Temporary convert `en-*` to `en`
|
||
if parts[0] == "en" {
|
||
conf.Locale = "en"
|
||
}
|
||
}
|
||
|
||
// IP属地功能配置修补
|
||
func (conf *Config) ipRegionPatch() {
|
||
if !conf.IPRegion.Enabled {
|
||
return
|
||
}
|
||
|
||
// IP 属地默认数据文件
|
||
if conf.IPRegion.DBPath == "" {
|
||
conf.IPRegion.DBPath = "./data/ip2region.xdb"
|
||
}
|
||
|
||
// 检测配置文件是否存在
|
||
if !utils.CheckFileExist(conf.IPRegion.DBPath) {
|
||
log.Warn("未找到 IP 数据库文件:" + strconv.Quote(conf.IPRegion.DBPath) + ",IP 属地功能已禁用," +
|
||
"参考链接:https://artalk.js.org/guide/frontend/ip-region.html")
|
||
conf.IPRegion.Enabled = false
|
||
}
|
||
|
||
// 默认精确到省
|
||
if conf.IPRegion.Precision == "" {
|
||
conf.IPRegion.Precision = string(IPRegionProvince)
|
||
}
|
||
}
|
||
|
||
// 配置修补 for 历史版本
|
||
func (conf *Config) historyPatch() {
|
||
if conf.Captcha.ActionTimeout != 0 {
|
||
log.Warn("The config option `captcha.action_timeout` is deprecated, please use `captcha.action_reset` instead")
|
||
if conf.Captcha.ActionReset == 0 {
|
||
conf.Captcha.ActionReset = conf.Captcha.ActionTimeout
|
||
}
|
||
}
|
||
if len(conf.AllowOrigins) != 0 {
|
||
log.Warn("The config option `allow_origins` is deprecated, please use `trusted_domains` instead")
|
||
if len(conf.TrustedDomains) == 0 {
|
||
conf.TrustedDomains = conf.AllowOrigins
|
||
}
|
||
}
|
||
|
||
// @version < 2.2.0
|
||
if conf.Notify != nil {
|
||
log.Warn("The config option `notify` is deprecated, please use `admin_notify` instead")
|
||
conf.AdminNotify = *conf.Notify
|
||
}
|
||
if conf.AdminNotify.Email == nil {
|
||
conf.AdminNotify.Email = &AdminEmailConf{
|
||
Enabled: true, // 默认开启管理员邮件通知
|
||
}
|
||
}
|
||
if conf.Email.MailSubjectToAdmin != "" {
|
||
log.Warn("The config option `email.mail_subject_to_admin` is deprecated, please use `admin_notify.email.mail_subject` instead")
|
||
conf.AdminNotify.Email.MailSubject = conf.Email.MailSubjectToAdmin
|
||
}
|
||
}
|
||
|
||
// Try to find the configuration file
|
||
//
|
||
// The order of the search is:
|
||
// 1. Current directory (./artalk.yml)
|
||
// 2. Current subdirectory (./data/artalk.yml)
|
||
// 3. XDG_CONFIG_HOME (~/.config/artalk.yml)
|
||
// 4. /etc/artalk.yml (for linux packing version)
|
||
func RetrieveConfigFile() string {
|
||
lookupDirs := []string{"", "data", xdg.ConfigHome, "/etc"}
|
||
paths := []string{}
|
||
for _, dir := range lookupDirs {
|
||
for _, name := range CONF_DEFAULT_FILENAMES {
|
||
if dir == "" || dir == "data" {
|
||
paths = append(paths, path.Join(dir, name))
|
||
} else {
|
||
paths = append(paths, path.Join(dir, "artalk", name))
|
||
}
|
||
}
|
||
}
|
||
for _, path := range paths {
|
||
if utils.CheckFileExist(path) {
|
||
return path
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Try to find the data directory (work directory)
|
||
//
|
||
// The order of the search is:
|
||
// 1. XDG_DATA_HOME (~/.local/share)
|
||
// 2. /var/lib (for linux packing version)
|
||
func RetrieveDataDir() string {
|
||
lookupDirs := []string{xdg.DataHome, "/var/lib"}
|
||
for _, dir := range lookupDirs {
|
||
dir = path.Join(dir, "artalk")
|
||
if utils.CheckDirExist(dir) {
|
||
return dir
|
||
}
|
||
}
|
||
return ""
|
||
}
|