Files
qwqcode 1d5811a59c feat(config): enhance config file and data dir retrieval (#992)
* 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
2024-10-03 15:04:42 +08:00

289 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}