Files
tamabox/internal/conf/conf.go
T
2026-10-19 15:23:11 +08:00

149 lines
5.1 KiB
Go

// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package conf
import (
"os"
"strings"
"github.com/pkg/errors"
"gopkg.in/ini.v1"
)
// File 是解析后的配置对象。
var File *ini.File
func Init() error {
configFile := os.Getenv("NEKOBOX_CONFIG_PATH")
if configFile == "" {
configFile = "conf/app.ini"
}
var err error
File, err = ini.LoadSources(ini.LoadOptions{
IgnoreInlineComment: true,
}, configFile)
if err != nil {
return errors.Wrapf(err, "parse %q", configFile)
}
if err := File.Section("app").MapTo(&App); err != nil {
return errors.Wrap(err, "map 'server'")
}
if App.ExternalURL == "" {
return errors.New("app.external_url is required")
}
App.ExternalURL = strings.TrimRight(App.ExternalURL, "/")
if err := File.Section("security").MapTo(&Security); err != nil {
return errors.Wrap(err, "map 'security'")
}
if err := validateTextCensorSource(Security.EnableTextCensor, Security.TextCensorSource,
App.QiniuAccessKey, App.QiniuAccessSecret, App.AliyunAccessKey, App.AliyunAccessKeySecret); err != nil {
return err
}
if err := File.Section("server").MapTo(&Server); err != nil {
return errors.Wrap(err, "map 'server'")
}
// XSRFKey 为空会导致 CSRF token 以空密钥生成、可被预测,
// 使删题/置顶/拉黑/改密等所有 POST 操作的 CSRF 防护形同虚设。启动时强制校验。
if Server.XSRFKey == "" {
return errors.New("server.xsrf_key is required (used to generate CSRF tokens, must not be empty)")
}
// Salt 为空会让密码哈希以空盐计算(HmacSha1(password, "")),
// 相同密码产生相同哈希且可被离线字典/彩虹表攻击。启动时强制校验。
if Server.Salt == "" {
return errors.New("server.salt is required (used to hash user passwords, must not be empty)")
}
if err := File.Section("database").MapTo(&Database); err != nil {
return errors.Wrap(err, "map 'database'")
}
if err := File.Section("redis").MapTo(&Redis); err != nil {
return errors.Wrap(err, "map 'redis'")
}
if err := File.Section("recaptcha").MapTo(&Recaptcha); err != nil {
return errors.Wrap(err, "map 'recaptcha'")
}
if err := File.Section("pixel").MapTo(&Pixel); err != nil {
return errors.Wrap(err, "map 'pixel'")
}
if err := File.Section("upload").MapTo(&Upload); err != nil {
return errors.Wrap(err, "map 'upload'")
}
if err := File.Section("limits").MapTo(&Limits); err != nil {
return errors.Wrap(err, "map 'limits'")
}
// ini 键留空会被 int 解析为 0;<1 的值一律回退内置默认,防止上限被静默置零。
sanitizeLimits(&Limits)
// 主题:默认 slug 与启用清单。段缺失时 MapTo 得到空值,运行期回退内置 default,
// 因此老配置文件不写 [theme] 也能正常启动。
if err := File.Section("theme").MapTo(&Theme); err != nil {
return errors.Wrap(err, "map 'theme'")
}
if err := File.Section("mail").MapTo(&Mail); err != nil {
return errors.Wrap(err, "map 'mail'")
}
serviceSections := File.Section("service").ChildSections()
for _, serviceSection := range serviceSections {
serviceSection := serviceSection
var backend struct {
Prefix string `ini:"prefix"`
ForwardURL string `ini:"forward_url"`
}
if err := serviceSection.MapTo(&backend); err != nil {
return errors.Wrapf(err, "map 'service.%s'", serviceSection.Name())
}
Service.Backends = append(Service.Backends, backend)
}
return nil
}
// validateTextCensorSource 校验文本审核通道([security] text_censor_source)与凭据的搭配。
// 抽成纯函数(只依赖入参、不读全局)以便单测覆盖,也让 Init 保持轻薄。
//
// - 审核整体关闭(enabled=false)→ 不校验,不需要任何凭据。
// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都无凭据时
// 维持既有运行期 fail-closed 行为,不阻断启动(否则 app.sample.ini 原样拷贝无法启动)。
// - 显式选 qiniu / aliyun → 必须配齐该通道凭据,否则启动即失败:缺凭据时运行期审核必失败,
// 而审核失败是 fail-closed(拒绝提交),会让发布/私信整站不可用且现象隐蔽。
// - 其它取值 → 直接报错,不做静默回退:拼写错误若被当成 auto,运维会误以为已切通道。
func validateTextCensorSource(enabled bool, source, qiniuKey, qiniuSecret, aliyunKey, aliyunSecret string) error {
if !enabled {
return nil
}
switch strings.ToLower(strings.TrimSpace(source)) {
case "", CensorSourceAuto:
return nil
case CensorSourceQiniu:
if qiniuKey == "" || qiniuSecret == "" {
return errors.New("security.text_censor_source = qiniu requires app.qiniu_access_key and app.qiniu_access_secret")
}
return nil
case CensorSourceAliyun:
if aliyunKey == "" || aliyunSecret == "" {
return errors.New("security.text_censor_source = aliyun requires app.aliyun_access_key and app.aliyun_access_key_secret")
}
return nil
default:
return errors.Errorf("security.text_censor_source must be one of qiniu / aliyun / auto, got %q", source)
}
}