完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 00:17:00 +08:00
parent acde7ee288
commit 6f054a903c
98 changed files with 8239 additions and 2073 deletions

View File

@@ -86,6 +86,9 @@ var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
// Register 用户注册
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
return nil, err
}
// 检查用户名是否已存在
var count int64
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
@@ -559,6 +562,9 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
// - email 可为空;非空时需符合邮箱格式且不与他人重复
// - signature 不超过 255 字符
func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) {
if err := NewOperations(s.db, nil).Filter("username", nickname, userID); err != nil {
return nil, err
}
nickname = strings.TrimSpace(nickname)
if nickname == "" {
return nil, errors.New("昵称不能为空")

View File

@@ -28,6 +28,10 @@ const (
BrandMarkImage = "image"
BrandMarkText = "text"
FooterLinkAlignLeft = "left"
FooterLinkAlignCenter = "center"
FooterLinkAlignRight = "right"
BrandLogoSizeSQ = "sq"
BrandLogoSize2x1 = "2x1"
BrandLogoSize3x1 = "3x1"
@@ -53,6 +57,7 @@ type FooterLink struct {
Label string `json:"label"`
URL string `json:"url"`
NewTab bool `json:"new_tab"`
Align string `json:"align"`
}
// 仅接受本站品牌目录;Logo 不含 ico,Favicon 含 ico
@@ -219,6 +224,19 @@ type footerLinkIn struct {
Label string `json:"label"`
URL string `json:"url"`
NewTab *bool `json:"new_tab"`
Align string `json:"align"`
}
// NormalizeFooterLinkAlign 单条页脚链接所属栏位;空串视为居右。
func NormalizeFooterLinkAlign(raw string) (string, bool) {
switch strings.TrimSpace(raw) {
case "", FooterLinkAlignRight:
return FooterLinkAlignRight, true
case FooterLinkAlignLeft, FooterLinkAlignCenter:
return strings.TrimSpace(raw), true
default:
return "", false
}
}
// NormalizeFooterLinks 校验并截断页脚链接;外链未显式指定时默认新标签
@@ -236,7 +254,11 @@ func NormalizeFooterLinks(raw []FooterLink) ([]FooterLink, error) {
if !ok {
return nil, ErrInvalidSiteSetting
}
out = append(out, FooterLink{Label: label, URL: u, NewTab: item.NewTab})
align, ok := NormalizeFooterLinkAlign(item.Align)
if !ok {
return nil, ErrInvalidSiteSetting
}
out = append(out, FooterLink{Label: label, URL: u, NewTab: item.NewTab, Align: align})
}
return out, nil
}
@@ -266,7 +288,11 @@ func parseFooterLinksJSON(raw string) ([]FooterLink, error) {
} else if isExternalFooterURL(u) {
newTab = true
}
out = append(out, FooterLink{Label: label, URL: u, NewTab: newTab})
align, ok := NormalizeFooterLinkAlign(item.Align)
if !ok {
align = FooterLinkAlignRight
}
out = append(out, FooterLink{Label: label, URL: u, NewTab: newTab, Align: align})
}
return out, nil
}
@@ -467,6 +493,8 @@ func (s *SettingService) SetFooterLinks(raw []FooterLink) error {
if err != nil {
return err
}
// 去掉曾用的站级对齐键(现改为逐条 align)
_ = s.deleteKey("footer_links_align")
if len(list) == 0 {
return s.deleteKey(SettingKeyFooterLinks)
}

View File

@@ -2,6 +2,7 @@ package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
@@ -10,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
)
@@ -134,6 +136,16 @@ func (s *UploadService) CopyBrandFromMedia(userID, attachmentID uint, slot strin
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
return "", errors.New("图片不存在或不属于你")
}
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
if e != nil {
return "", e
}
defer r.Close()
return s.SaveBrand(slot, r)
}
abs, ok := s.safeUploadPath(att.URL)
if !ok {
return "", errors.New("无效的图片地址")

View File

@@ -60,10 +60,10 @@ func TestNormalizeKeywords(t *testing.T) {
func TestNormalizeFooterLinks(t *testing.T) {
got, err := NormalizeFooterLinks([]FooterLink{
{Label: "备案", URL: "https://beian.miit.gov.cn/", NewTab: true},
{Label: "关于", URL: "/about", NewTab: false},
{Label: "备案", URL: "https://beian.miit.gov.cn/", NewTab: true, Align: FooterLinkAlignRight},
{Label: "关于", URL: "/about", NewTab: false, Align: FooterLinkAlignLeft},
})
if err != nil || len(got) != 2 || got[1].URL != "/about" {
if err != nil || len(got) != 2 || got[1].URL != "/about" || got[1].Align != FooterLinkAlignLeft {
t.Fatalf("got %+v err=%v", got, err)
}
bad := []FooterLink{
@@ -71,6 +71,7 @@ func TestNormalizeFooterLinks(t *testing.T) {
{Label: "x", URL: "//evil.test"},
{Label: "", URL: "/a"},
{Label: "x", URL: "ftp://files.test/a"},
{Label: "x", URL: "/ok", Align: "middle"},
}
for _, item := range bad {
if _, err := NormalizeFooterLinks([]FooterLink{item}); err == nil {
@@ -79,8 +80,28 @@ func TestNormalizeFooterLinks(t *testing.T) {
}
raw := `[{"label":"ICP","url":"https://beian.miit.gov.cn/"}]`
parsed, err := parseFooterLinksJSON(raw)
if err != nil || len(parsed) != 1 || !parsed[0].NewTab {
t.Fatalf("external default new tab, got %+v err=%v", parsed, err)
if err != nil || len(parsed) != 1 || !parsed[0].NewTab || parsed[0].Align != FooterLinkAlignRight {
t.Fatalf("external default new tab + right align, got %+v err=%v", parsed, err)
}
}
func TestNormalizeFooterLinkAlign(t *testing.T) {
cases := []struct {
in string
want string
ok bool
}{
{"", FooterLinkAlignRight, true},
{" right ", FooterLinkAlignRight, true},
{"left", FooterLinkAlignLeft, true},
{"center", FooterLinkAlignCenter, true},
{"middle", "", false},
}
for _, tc := range cases {
got, ok := NormalizeFooterLinkAlign(tc.in)
if ok != tc.ok || got != tc.want {
t.Fatalf("in=%q got=%q ok=%v want=%q/%v", tc.in, got, ok, tc.want, tc.ok)
}
}
}

View File

@@ -298,6 +298,9 @@ func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Acto
// pending 评论不计入 comment_count,审核通过时才 +1。
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string) (*model.Comment, *model.Comment, error) {
if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil {
return nil, nil, err
}
content = strings.TrimSpace(content)
if content == "" {
return nil, nil, errors.New("评论内容不能为空")
@@ -600,6 +603,9 @@ func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
// Update 编辑评论(作者本人或版主;已软删不可改);先写入旧正文快照再更新。
func (s *CommentService) Update(actor *Actor, commentID, userID uint, content string) (*CommentNode, error) {
if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil {
return nil, err
}
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("评论内容不能为空")

View File

@@ -0,0 +1,579 @@
package service
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/freefire/jiang13-bbs/config"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"io"
"net/mail"
"net/url"
"os"
"reflect"
"regexp"
"strings"
"time"
)
type SecurityConfig struct {
AllowRegister bool `json:"allow_register"`
RegisterNotice string `json:"register_notice"`
VerifyEmail bool `json:"verify_email"`
PasswordReset bool `json:"password_reset"`
LoginWindow int `json:"login_window"`
LoginFailures int `json:"login_failures"`
PostInterval int `json:"post_interval"`
CommentInterval int `json:"comment_interval"`
ResendInterval int `json:"resend_interval"`
EmailHourly int `json:"email_hourly"`
SearchMinute int `json:"search_minute"`
}
type MailConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
TLS string `json:"tls"`
Username string `json:"username"`
Password string `json:"password"`
FromName string `json:"from_name"`
From string `json:"from"`
ReplyTo string `json:"reply_to"`
Timeout int `json:"timeout"`
Retention int `json:"retention"`
SubjectTemplate string `json:"subject_template"`
BodyTemplate string `json:"body_template"`
}
type StorageConfig struct {
Backend string `json:"backend"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Prefix string `json:"prefix"`
CDN string `json:"cdn"`
PathStyle bool `json:"path_style"`
ImageMaxMB int `json:"image_max_mb"`
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
AttachmentExtLimit bool `json:"attachment_ext_limit"`
AttachmentExts []string `json:"attachment_exts"`
}
type FilterRule struct {
ID string `json:"id"`
Word string `json:"word"`
Scopes []string `json:"scopes"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
Exceptions []string `json:"exceptions"`
Note string `json:"note"`
}
type FilterConfig struct {
Enabled bool `json:"enabled"`
Rules []FilterRule `json:"rules"`
}
type MaintenanceConfig struct {
Mode string `json:"mode"`
Title string `json:"title"`
Message string `json:"message"`
Until string `json:"until"`
RetryAfter int `json:"retry_after"`
Contact string `json:"contact"`
TempDays int `json:"temp_days"`
}
type Operations struct {
smtpRoots *x509.CertPool
db *gorm.DB
cfg *config.Config
Started time.Time
}
var ErrConfigConflict = errors.New("配置已被其他管理员更新,请重新加载后合并修改")
func NewOperations(db *gorm.DB, cfg *config.Config) *Operations {
return &Operations{db: db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}), cfg: cfg, Started: time.Now()}
}
func defaultModule(name string) any {
switch name {
case "security":
return &SecurityConfig{AllowRegister: true, LoginWindow: 600, LoginFailures: 5, PostInterval: 6, CommentInterval: 2, ResendInterval: 60, EmailHourly: 5, SearchMinute: 30}
case "mail":
return &MailConfig{Port: 465, TLS: "tls", Timeout: 10, Retention: 30, SubjectTemplate: defaultMailSubject, BodyTemplate: defaultMailBody}
case "storage":
return &StorageConfig{Backend: "local", Region: "us-east-1", Prefix: "jiang13/uploads/", PathStyle: true, ImageMaxMB: 5, AttachmentMaxMB: 20, AttachmentMaxCount: 10, AttachmentExtLimit: true, AttachmentExts: DefaultAttachmentExts}
case "filter":
return &FilterConfig{Rules: []FilterRule{}}
case "maintenance":
return &MaintenanceConfig{Mode: "normal", Title: "站点维护中", RetryAfter: 300, TempDays: 7}
}
return nil
}
func (o *Operations) read(db *gorm.DB, name string) (any, int64, error) {
v := defaultModule(name)
if v == nil {
return nil, 0, errors.New("未知配置模块")
}
var row model.ModuleConfig
err := db.First(&row, "name = ?", name).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 0, err
}
if err == nil {
if err = json.Unmarshal([]byte(row.Data), v); err != nil {
return nil, 0, err
}
}
st := NewSettingService(db)
if s, ok := v.(*SecurityConfig); ok {
s.AllowRegister, err = st.AllowRegister()
}
if s, ok := v.(*StorageConfig); ok {
s.ImageMaxMB, err = st.ImageMaxMB()
if err != nil {
return nil, 0, err
}
s.AttachmentMaxMB, err = st.AttachmentMaxMB()
if err != nil {
return nil, 0, err
}
s.AttachmentMaxCount, err = st.AttachmentMaxCount()
if err != nil {
return nil, 0, err
}
s.AttachmentExtLimit, err = st.AttachmentExtLimit()
if err != nil {
return nil, 0, err
}
s.AttachmentExts, err = st.AttachmentExts()
}
if m, ok := v.(*MailConfig); ok {
m.applyTemplateDefaults()
}
if errors.Is(err, gorm.ErrRecordNotFound) {
err = nil
}
return v, row.Version, err
}
func (o *Operations) Security() (SecurityConfig, error) {
v, _, e := o.read(o.db, "security")
if e != nil {
return SecurityConfig{}, e
}
return *v.(*SecurityConfig), nil
}
func (o *Operations) Maintenance() (MaintenanceConfig, error) {
v, _, e := o.read(o.db, "maintenance")
if e != nil {
return MaintenanceConfig{}, e
}
m := *v.(*MaintenanceConfig)
if os.Getenv("MAINTENANCE_RECOVERY") == "1" {
m.Mode = "normal"
}
return m, nil
}
func (o *Operations) Read(name string) (map[string]any, error) {
v, version, e := o.read(o.db, name)
if e != nil {
return nil, e
}
b, _ := json.Marshal(v)
var data map[string]any
_ = json.Unmarshal(b, &data)
for _, k := range secretFields(name) {
data[k+"_configured"] = data[k] != ""
data[k] = ""
}
state := "已启用"
if name == "mail" && !v.(*MailConfig).Enabled {
state = "未配置"
if v.(*MailConfig).Host != "" {
state = "未启用"
}
}
if name == "filter" && !v.(*FilterConfig).Enabled {
state = "未启用"
}
if name == "storage" {
state = v.(*StorageConfig).Backend
}
if name == "maintenance" {
state = v.(*MaintenanceConfig).Mode
}
out := map[string]any{"data": data, "version": version, "state": state, "effective": "保存后立即生效", "site_url": o.cfg.SiteURL, "local_directory": o.cfg.DataDir, "encryption_ready": o.keyReady(), "recovery_override": os.Getenv("MAINTENANCE_RECOVERY") == "1"}
if name == "mail" {
out["template_preview"] = o.MailPreview("示例验证码").Body
out["reset_preview"] = o.MailPreview("示例验证码", "reset").Body
}
return out, nil
}
func secretFields(name string) []string {
switch name {
case "mail":
return []string{"password"}
case "storage":
return []string{"access_key", "secret_key"}
}
return nil
}
func (o *Operations) masterKey() string {
if o.cfg != nil {
if v := strings.TrimSpace(o.cfg.SettingsMasterKey); v != "" {
return v
}
}
return strings.TrimSpace(os.Getenv("SETTINGS_MASTER_KEY"))
}
func (o *Operations) aead() (cipher.AEAD, error) {
k, e := base64.StdEncoding.DecodeString(o.masterKey())
if e != nil || len(k) != 32 {
return nil, errors.New("请在 app.ini 的 [security] 或环境变量 SETTINGS_MASTER_KEY 配置 32 字节标准 Base64 主密钥后再保存凭据")
}
b, e := aes.NewCipher(k)
if e != nil {
return nil, e
}
return cipher.NewGCM(b)
}
func (o *Operations) keyReady() bool { _, e := o.aead(); return e == nil }
func (o *Operations) seal(s, domain string) (string, error) {
a, e := o.aead()
if e != nil {
return "", e
}
n := make([]byte, a.NonceSize())
if _, e = rand.Read(n); e != nil {
return "", e
}
return base64.StdEncoding.EncodeToString(a.Seal(n, n, []byte(s), []byte(domain))), nil
}
func (o *Operations) open(s, domain string) (string, error) {
if s == "" {
return "", nil
}
a, e := o.aead()
if e != nil {
return "", e
}
b, e := base64.StdEncoding.DecodeString(s)
if e != nil || len(b) < a.NonceSize() {
return "", errors.New("凭据密文无效")
}
p, e := a.Open(nil, b[:a.NonceSize()], b[a.NonceSize():], []byte(domain))
if e != nil {
return "", errors.New("凭据解密失败,请核对部署主密钥")
}
return string(p), nil
}
func (o *Operations) draft(db *gorm.DB, name string, raw json.RawMessage, clear []string) (any, int64, []string, error) {
old, version, e := o.read(db, name)
if e != nil {
return nil, 0, nil, e
}
b, _ := json.Marshal(old)
var previous map[string]any
_ = json.Unmarshal(b, &previous)
var input map[string]any
if e = json.Unmarshal(raw, &input); e != nil {
return nil, 0, nil, errors.New("表单格式无效")
}
for k := range input {
if strings.HasSuffix(k, "_configured") {
delete(input, k)
continue
}
if _, ok := previous[k]; !ok {
return nil, 0, nil, fmt.Errorf("未知字段:%s", k)
}
}
fields := []string{}
for k, val := range input {
if !reflect.DeepEqual(previous[k], val) {
fields = append(fields, k)
}
}
for _, key := range secretFields(name) {
value, _ := input[key].(string)
input[key] = previous[key]
if value != "" {
if strings.Contains(value, "***") || strings.Contains(value, "••") {
return nil, 0, nil, fmt.Errorf("%s:不能保存脱敏占位符", key)
}
enc, err := o.seal(value, name+":"+key)
if err != nil {
return nil, 0, nil, err
}
input[key] = enc
}
for _, c := range clear {
if c == key {
input[key] = ""
fields = append(fields, key)
}
}
}
for k, v := range input {
previous[k] = v
}
b, _ = json.Marshal(previous)
v := defaultModule(name)
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
if e = d.Decode(v); e != nil {
return nil, 0, nil, errors.New("字段类型无效")
}
if e = o.validate(db, name, v); e != nil {
return nil, 0, nil, e
}
return v, version, fields, nil
}
func validEmail(s string) bool {
a, e := mail.ParseAddress(s)
return e == nil && a.Address == s && !strings.ContainsAny(s, "\r\n")
}
func bounded(n, lo, hi int) bool { return n >= lo && n <= hi }
func (o *Operations) validate(db *gorm.DB, name string, v any) error {
bad := func(field, msg string) error { return fmt.Errorf("%s:%s", field, msg) }
switch c := v.(type) {
case *SecurityConfig:
if len([]rune(c.RegisterNotice)) > 200 {
return bad("register_notice", "最多 200 字")
}
for _, n := range []struct {
k string
n, lo, hi int
}{{"login_window", c.LoginWindow, 60, 3600}, {"login_failures", c.LoginFailures, 3, 50}, {"post_interval", c.PostInterval, 0, 3600}, {"comment_interval", c.CommentInterval, 0, 3600}, {"resend_interval", c.ResendInterval, 30, 3600}, {"email_hourly", c.EmailHourly, 1, 20}, {"search_minute", c.SearchMinute, 1, 120}} {
if !bounded(n.n, n.lo, n.hi) {
return bad(n.k, fmt.Sprintf("范围 %d–%d", n.lo, n.hi))
}
}
if c.VerifyEmail || c.PasswordReset {
m, _, e := o.read(db, "mail")
if e != nil {
return e
}
if !m.(*MailConfig).Enabled {
return bad("verify_email", "须先启用邮件服务")
}
if e = o.validate(db, "mail", m); e != nil {
return e
}
origin, originErr := url.Parse(o.cfg.SiteURL)
if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && !(o.cfg.DevMode && origin.Scheme == "http")) {
return bad("verify_email", "部署 SITE_URL 后才能开启账号邮件")
}
}
case *MailConfig:
c.applyTemplateDefaults()
if strings.ContainsAny(c.SubjectTemplate, "\r\n") || len([]rune(c.SubjectTemplate)) > 200 {
return bad("subject_template", "主题最多 200 字,且不能换行")
}
if len(c.BodyTemplate) > 200000 {
return bad("body_template", "正文最多 200000 字节")
}
if !strings.Contains(c.BodyTemplate, "{{code}}") {
return bad("body_template", "正文必须包含 {{code}},否则用户收不到验证码")
}
if !bounded(c.Port, 1, 65535) || !bounded(c.Timeout, 2, 30) || !bounded(c.Retention, 1, 365) {
return bad("port", "端口 1–65535;超时 2–30 秒;记录保留 1–365 天")
}
if c.TLS != "tls" && c.TLS != "starttls" {
return bad("tls", "仅支持 TLS 或必需 STARTTLS")
}
if strings.ContainsAny(c.Host, "/:\\ \r\n") || len(c.Host) > 253 {
return bad("host", "请输入主机名")
}
if strings.ContainsAny(c.FromName+c.Username, "\r\n") || len(c.FromName) > 120 || len(c.Username) > 256 {
return bad("from_name", "格式无效")
}
if c.Enabled {
if c.Host == "" {
return bad("host", "启用前填写 SMTP 主机")
}
if c.Username == "" {
return bad("username", "启用前填写登录用户名")
}
if c.Password == "" {
return bad("password", "启用前填写密码或授权码。要继续使用已保存的授权码,请不要移除它")
}
if !validEmail(c.From) {
return bad("from", "启用前填写有效的发件邮箱")
}
if c.ReplyTo != "" && !validEmail(c.ReplyTo) {
return bad("reply_to", "邮箱格式无效")
}
if _, e := o.open(c.Password, "mail:password"); e != nil {
return e
}
} else {
s, _, e := o.read(db, "security")
if e != nil {
return e
}
x := s.(*SecurityConfig)
if x.VerifyEmail || x.PasswordReset {
return bad("enabled", "注册验证或密码找回正在使用邮件,请先关闭依赖")
}
}
case *StorageConfig:
if c.Backend != "local" && c.Backend != "s3" {
return bad("backend", "存储类型无效")
}
if c.Backend == "s3" {
u, e := url.Parse(c.Endpoint)
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && !(o.cfg.DevMode && u.Scheme == "http")) {
return bad("endpoint", "须为 HTTPS origin(开发可 HTTP)")
}
if !regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`).MatchString(c.Bucket) || strings.Contains(c.Bucket, "..") {
return bad("bucket", "桶名称无效")
}
if !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`).MatchString(c.Region) {
return bad("region", "区域无效")
}
if c.AccessKey == "" {
return bad("access_key", "启用 S3 前填写 Access Key。要继续使用已保存的值,请不要移除它")
}
if c.SecretKey == "" {
return bad("secret_key", "启用 S3 前填写 Secret Key。要继续使用已保存的值,请不要移除它")
}
}
if !regexp.MustCompile(`^[a-zA-Z0-9_/-]{1,100}/$`).MatchString(c.Prefix) || strings.HasPrefix(c.Prefix, "/") || strings.Contains(c.Prefix, "//") {
return bad("prefix", "必须为应用专用的相对目录并以 / 结束")
}
if c.CDN != "" {
u, e := url.Parse(c.CDN)
if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return bad("cdn", "须为 HTTPS 地址")
}
}
if !bounded(c.ImageMaxMB, 1, 50) || !bounded(c.AttachmentMaxMB, 1, 100) || !bounded(c.AttachmentMaxCount, 1, 20) {
return bad("image_max_mb", "图片 1–50 MiB,附件 1–100 MiB,数量 1–20")
}
if len(c.AttachmentExts) == 0 || len(c.AttachmentExts) > 80 {
return bad("attachment_exts", "需要 1–80 个扩展名")
}
for _, x := range c.AttachmentExts {
if !attachmentExtRe.MatchString(x) {
return bad("attachment_exts", "扩展名只含小写字母与数字")
}
}
case *FilterConfig:
if len(c.Rules) > 2000 {
return bad("rules", "最多 2000 条规则")
}
ids := map[string]bool{}
for i := range c.Rules {
r := &c.Rules[i]
r.Word = strings.TrimSpace(r.Word)
if r.ID == "" || len(r.ID) > 64 || ids[r.ID] {
return bad("rules", "规则 ID 无效或重复")
}
ids[r.ID] = true
if len([]rune(r.Word)) < 1 || len([]rune(r.Word)) > 80 || len(r.Note) > 500 || len(r.Exceptions) > 20 {
return bad("rules", "词语 1–80 字,例外最多 20 个")
}
if r.Action != "block" && r.Action != "log" {
return bad("rules", "动作无效")
}
if len(r.Scopes) == 0 {
return bad("rules", "请选择适用范围")
}
for _, s := range r.Scopes {
if s != "username" && s != "title" && s != "body" && s != "comment" {
return bad("rules", "范围无效")
}
}
for _, s := range r.Exceptions {
if len(s) > 240 || strings.TrimSpace(s) == "" {
return bad("rules", "例外词无效")
}
}
}
if c.Enabled && len(c.Rules) == 0 {
return bad("enabled", "添加规则后才能启用")
}
case *MaintenanceConfig:
if c.Mode != "normal" && c.Mode != "readonly" && c.Mode != "paused" {
return bad("mode", "模式无效")
}
if !bounded(c.RetryAfter, 30, 86400) || !bounded(c.TempDays, 1, 365) {
return bad("retry_after", "重试 30–86400 秒,临时保留 1–365 天")
}
if len(c.Title) > 240 || len(c.Message) > 4000 || len(c.Contact) > 500 {
return bad("message", "文案过长")
}
if c.Until != "" {
if _, e := time.Parse(time.RFC3339, c.Until); e != nil {
return bad("until", "使用含时区的 ISO 时间")
}
}
}
return nil
}
func (o *Operations) Save(name string, version int64, raw json.RawMessage, clear []string, actor uint) error {
return o.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Exec("SELECT pg_advisory_xact_lock(130013)").Error; e != nil {
return e
}
v, current, fields, e := o.draft(tx, name, raw, clear)
if e != nil {
return e
}
if version != current {
return ErrConfigConflict
}
st := NewSettingService(tx)
if c, ok := v.(*SecurityConfig); ok {
if e = st.SetAllowRegister(c.AllowRegister); e != nil {
return e
}
}
if c, ok := v.(*StorageConfig); ok {
for _, f := range []func() error{func() error { return st.SetImageMaxMB(c.ImageMaxMB) }, func() error { return st.SetAttachmentMaxMB(c.AttachmentMaxMB) }, func() error { return st.SetAttachmentMaxCount(c.AttachmentMaxCount) }, func() error { return st.SetAttachmentExtLimit(c.AttachmentExtLimit) }, func() error { return st.SetAttachmentExts(c.AttachmentExts) }} {
if e = f(); e != nil {
return e
}
}
}
b, _ := json.Marshal(v)
if e = tx.Save(&model.ModuleConfig{Name: name, Version: current + 1, Data: string(b)}).Error; e != nil {
return e
}
if name == "storage" {
if e = tx.Create(&model.ModuleConfig{Name: fmt.Sprintf("storage-%d", current+1), Version: current + 1, Data: string(b)}).Error; e != nil {
return e
}
}
f, _ := json.Marshal(fields)
return tx.Create(&model.SettingsAudit{ActorID: actor, Module: name, Action: "save", Fields: string(f), Result: "已生效"}).Error
})
}
func (o *Operations) Audit(actor uint, module, action, result string) {
o.db.Create(&model.SettingsAudit{ActorID: actor, Module: module, Action: action, Fields: "[]", Result: result})
}
func (o *Operations) AuditRows(module string) ([]model.SettingsAudit, error) {
var a []model.SettingsAudit
switch module {
case "security", "filter", "maintenance":
e := o.db.Where("module = ?", module).Order("id desc").Limit(100).Find(&a).Error
return a, e
default:
return nil, nil
}
}
func randomID() string {
b := make([]byte, 24)
if _, e := io.ReadFull(rand.Reader, b); e != nil {
panic(e)
}
return base64.RawURLEncoding.EncodeToString(b)
}

View File

@@ -0,0 +1,159 @@
package service
import (
"encoding/json"
"errors"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
"golang.org/x/text/unicode/norm"
"html"
"strings"
"unicode/utf8"
)
type FilterHit struct {
ID string `json:"id"`
Word string `json:"word"`
Scope string `json:"scope"`
Start int `json:"start"`
End int `json:"end"`
Action string `json:"action"`
Excepted bool `json:"excepted"`
}
type FilterResult struct {
Result string `json:"result"`
Text string `json:"text"`
Hits []FilterHit `json:"hits"`
Skipped []string `json:"skipped"`
}
func normalizeFilter(s string) string {
return strings.ToLower(norm.NFKC.String(html.UnescapeString(s)))
}
func readableMarkdown(s string) string {
src := []byte(s)
doc := goldmark.DefaultParser().Parse(text.NewReader(src))
var b strings.Builder
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
if n.Type() == ast.TypeBlock {
b.WriteByte('\n')
}
return ast.WalkContinue, nil
}
switch n.Kind() {
case ast.KindCodeBlock, ast.KindFencedCodeBlock, ast.KindCodeSpan, ast.KindHTMLBlock, ast.KindRawHTML, ast.KindAutoLink:
return ast.WalkSkipChildren, nil
}
if t, ok := n.(*ast.Text); ok {
b.Write(t.Segment.Value(src))
if t.SoftLineBreak() || t.HardLineBreak() {
b.WriteByte('\n')
}
}
if t, ok := n.(*ast.String); ok {
b.Write(t.Value)
}
return ast.WalkContinue, nil
})
return b.String()
}
func MatchFilter(c FilterConfig, scope, input string) FilterResult {
result := FilterResult{Result: "pass", Text: input, Hits: []FilterHit{}, Skipped: []string{}}
if scope == "body" || scope == "comment" {
result.Text = readableMarkdown(input)
result.Skipped = []string{"代码块", "行内代码", "链接目标及自动链接", "HTML 标签/HTML 块"}
}
result.Text = normalizeFilter(result.Text)
if !c.Enabled {
return result
}
for _, r := range c.Rules {
if !r.Enabled {
continue
}
applies := false
for _, s := range r.Scopes {
if s == scope {
applies = true
}
}
if !applies {
continue
}
word := normalizeFilter(r.Word)
if word == "" {
continue
}
for pos := 0; pos < len(result.Text); {
i := strings.Index(result.Text[pos:], word)
if i < 0 {
break
}
start := pos + i
end := start + len(word)
excepted := false
for _, ex := range r.Exceptions {
ex = normalizeFilter(ex)
if ex == "" {
continue
}
for off := 0; off < len(result.Text); {
k := strings.Index(result.Text[off:], ex)
if k < 0 {
break
}
a := off + k
if a <= start && a+len(ex) >= end {
excepted = true
}
off = a + len(ex)
}
}
result.Hits = append(result.Hits, FilterHit{ID: r.ID, Word: r.Word, Scope: scope, Start: utf8.RuneCountInString(result.Text[:start]), End: utf8.RuneCountInString(result.Text[:end]), Action: r.Action, Excepted: excepted})
if !excepted {
if r.Action == "block" {
result.Result = "block"
} else if result.Result == "pass" {
result.Result = "log"
}
}
pos = end
}
}
return result
}
func (o *Operations) Filter(scope, input string, actor uint) error {
v, _, e := o.read(o.db, "filter")
if e != nil {
return errors.New("内容过滤暂不可用,请稍后重试")
}
r := MatchFilter(*v.(*FilterConfig), scope, input)
if len(r.Hits) > 0 {
ids := []string{}
for _, h := range r.Hits {
if !h.Excepted {
ids = append(ids, h.ID)
}
}
b, _ := json.Marshal(ids)
o.Audit(actor, "filter", scope+":"+r.Result, string(b))
}
if r.Result == "block" {
return errors.New("内容未通过站点规则,请修改后重试")
}
return nil
}
func (o *Operations) TestFilter(raw json.RawMessage, scope, input string) (FilterResult, error) {
if len(input) > 100000 {
return FilterResult{}, errors.New("测试文本最多 100000 字节")
}
v, _, _, e := o.draft(o.db, "filter", raw, nil)
if e != nil {
return FilterResult{}, e
}
c := *v.(*FilterConfig)
c.Enabled = true
return MatchFilter(c, scope, input), nil
}

View File

@@ -0,0 +1,450 @@
package service
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
stdhtml "html"
"io"
"mime"
"net"
"net/mail"
"net/smtp"
"net/url"
"strconv"
"strings"
"time"
)
type mailPayload struct{ To, Subject, Body string }
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
password, e := o.open(c.Password, "mail:password")
if e != nil {
return e
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(c.Timeout)*time.Second)
defer cancel()
conn, e := safeDial(ctx, "tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)))
if e != nil {
return e
}
defer conn.Close()
deadline, _ := ctx.Deadline()
_ = conn.SetDeadline(deadline)
tlsCfg := &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12, RootCAs: o.smtpRoots}
if c.TLS == "tls" {
t := tls.Client(conn, tlsCfg)
if e = t.HandshakeContext(ctx); e != nil {
return errors.New("TLS 协商或证书校验失败")
}
conn = t
}
client, e := smtp.NewClient(conn, c.Host)
if e != nil {
return errors.New("SMTP 握手失败")
}
defer client.Close()
if c.TLS == "starttls" {
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("服务器未提供必需 STARTTLS")
}
if e = client.StartTLS(tlsCfg); e != nil {
return errors.New("TLS 协商或证书校验失败")
}
}
if e = client.Auth(smtp.PlainAuth("", c.Username, password, c.Host)); e != nil {
return errors.New("SMTP 认证失败")
}
if p == nil {
return nil
}
if !validEmail(p.To) {
return errors.New("测试收件邮箱无效")
}
if e = client.Mail(c.From); e != nil {
return errors.New("服务器拒绝发件人")
}
if e = client.Rcpt(p.To); e != nil {
return errors.New("服务器拒绝收件人")
}
w, e := client.Data()
if e != nil {
return errors.New("提交邮件失败")
}
if c.FromName == "" {
st, e := NewSettingService(o.db).Public()
if e == nil {
c.FromName = st.SiteName
}
}
from := (&mail.Address{Name: c.FromName, Address: c.From}).String()
reply := c.ReplyTo
if reply == "" {
reply = c.From
}
// QQ / 网关常按 7bit 路径转发:HTML 必须用 quoted-printable,避免长行与中文破坏 style。
var msg bytes.Buffer
msg.WriteString("From: " + from + "\r\n")
msg.WriteString("To: " + p.To + "\r\n")
msg.WriteString("Reply-To: " + reply + "\r\n")
msg.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", p.Subject) + "\r\n")
msg.WriteString("Message-ID: <" + id + "@jiang13.local>\r\n")
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
msg.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
if e = writeQuotedPrintableHTML(&msg, p.Body); e != nil {
return errors.New("提交邮件失败")
}
if _, e = io.Copy(w, &msg); e != nil {
return errors.New("提交邮件失败或超时")
}
if e = w.Close(); e != nil {
return errors.New("服务器未确认接受邮件")
}
return nil
}
const defaultMailSubject = "{{site_name}} · {{purpose}}"
// 旧版内置正文:读取时若仍是此值,升级为新默认模板。
const legacyMailBody = "{{logo}}<h1>{{site_name}} · {{purpose}}</h1><p>您的验证码:</p><p><strong>{{code}}</strong></p><p>15 分钟内有效。如果不是您本人操作,请忽略本邮件。</p><p><a href=\"{{link}}\">前往站点完成{{purpose}}</a>(请粘贴以上验证码)</p>"
const defaultMailBody = `<div class="email-wrap" style="margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;">
<style>
.email-wrap{margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;}
.email-card{max-width:520px;margin:0 auto;background:#fff;border:1px solid #eef2f6;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,.06);overflow:hidden;padding:40px 36px 32px;}
.email-logo,.email-wrap img{display:block;margin:0 auto 24px;max-width:180px;height:auto;}
.email-title{margin:0 0 8px;font-size:22px;font-weight:650;color:#0f172a;text-align:center;letter-spacing:-.02em;}
.email-lead{margin:0 0 24px;font-size:15px;color:#475569;text-align:center;}
.email-code-box{margin:0 0 24px;padding:22px 16px;text-align:center;background:#f8fafc;border:1px solid #e2e8f0;border-radius:16px;}
.email-code-label{margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#475569;}
.email-code{margin:0;font-size:40px;font-weight:700;letter-spacing:.18em;line-height:1.2;color:#0f172a;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
.email-hint{margin:0 0 20px;font-size:14px;color:#334155;text-align:center;}
.email-btn-wrap{text-align:center;margin:0 0 18px;}
.email-btn{display:inline-block;padding:14px 32px;border-radius:999px;background:#1e293b;color:#fff !important;text-decoration:none;font-size:15px;font-weight:600;}
.email-link{margin:0 0 24px;font-size:12px;color:#475569;text-align:center;word-break:break-all;}
.email-link a{color:#1d4ed8;}
.email-hr{height:1px;margin:0 0 20px;background:#e2e8f0;border:0;}
.email-foot{margin:0;font-size:13px;line-height:1.7;color:#475569;text-align:center;}
.email-foot strong{color:#334155;font-weight:600;}
@media (max-width:480px){.email-card{padding:28px 20px;border-radius:20px;}.email-code{font-size:32px;letter-spacing:.12em;}.email-btn{display:block;}}
</style>
<div class="email-card" style="max-width:520px;margin:0 auto;background:#ffffff;border:1px solid #eef2f6;border-radius:24px;padding:40px 36px 32px;">
{{logo}}
<h1 class="email-title" style="margin:0 0 8px;font-size:22px;font-weight:650;color:#0f172a;text-align:center;">{{purpose}}</h1>
<p class="email-lead" style="margin:0 0 24px;font-size:15px;color:#475569;text-align:center;">您好,感谢使用 {{site_name}}。请使用下面的验证码完成{{purpose}}。</p>
<div class="email-code-box" style="margin:0 0 24px;padding:22px 16px;text-align:center;background:#f8fafc;border:1px solid #e2e8f0;border-radius:16px;">
<div class="email-code-label" style="margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:.08em;color:#475569;">您的验证码</div>
<div class="email-code" style="margin:0;font-size:40px;font-weight:700;letter-spacing:.18em;color:#0f172a;font-family:ui-monospace,Menlo,Consolas,monospace;">{{code}}</div>
</div>
<p class="email-hint" style="margin:0 0 20px;font-size:14px;color:#334155;text-align:center;">验证码 15 分钟内有效。您也可以点击下方按钮前往站点继续操作。</p>
<div class="email-btn-wrap" style="text-align:center;margin:0 0 18px;">
<a class="email-btn" href="{{link}}" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:14px 32px;border-radius:999px;background:#1e293b;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;">前往完成{{purpose}}</a>
</div>
<p class="email-link" style="margin:0 0 24px;font-size:12px;color:#475569;text-align:center;word-break:break-all;">若按钮无法点击,请复制链接:<br><a href="{{link}}" target="_blank" rel="noopener noreferrer" style="color:#1d4ed8;">{{link}}</a></p>
<div class="email-hr" style="height:1px;margin:0 0 20px;background:#e2e8f0;"></div>
<p class="email-foot" style="margin:0;font-size:13px;line-height:1.7;color:#475569;text-align:center;">本邮件由 <strong style="color:#334155;">{{site_name}}</strong> 系统发出,请勿直接回复。<br>如非本人操作,请忽略本邮件。<br><span style="color:#334155;">{{site_url}}</span></p>
</div>
</div>`
func (c *MailConfig) applyTemplateDefaults() {
if strings.TrimSpace(c.SubjectTemplate) == "" {
c.SubjectTemplate = defaultMailSubject
}
body := strings.TrimSpace(c.BodyTemplate)
if body == "" || body == legacyMailBody {
c.BodyTemplate = defaultMailBody
}
}
func (o *Operations) MailPreview(code string, kinds ...string) mailPayload {
kind := ""
if len(kinds) > 0 {
kind = kinds[0]
}
c := MailConfig{}
if o.db != nil {
if v, _, e := o.read(o.db, "mail"); e == nil {
c = *v.(*MailConfig)
}
}
return o.renderMail(c, code, kind)
}
func (o *Operations) renderMail(c MailConfig, code, kind string) mailPayload {
c.applyTemplateDefaults()
siteName, logoURL := "", ""
if o.db != nil {
if st, e := NewSettingService(o.db).Public(); e == nil {
siteName, logoURL = st.SiteName, st.LogoLightURL
}
}
purpose, path := "账号验证", "/register"
if kind == "reset" {
purpose, path = "密码找回", "/reset-password"
}
base, dev := "", false
if o.cfg != nil {
base = strings.TrimRight(o.cfg.SiteURL, "/")
dev = o.cfg.DevMode
}
link := ""
if base != "" {
link = base + path
}
absLogo := mailLogoURL(dev, base, logoURL)
body := strings.NewReplacer(
"{{site_name}}", stdhtml.EscapeString(siteName),
"{{purpose}}", stdhtml.EscapeString(purpose),
"{{code}}", stdhtml.EscapeString(code),
"{{link}}", stdhtml.EscapeString(link),
"{{site_url}}", stdhtml.EscapeString(base),
"{{logo_url}}", stdhtml.EscapeString(absLogo),
"{{logo}}", mailLogoImg(absLogo),
).Replace(c.BodyTemplate)
subject := strings.NewReplacer(
"{{site_name}}", mailOneLine(siteName),
"{{purpose}}", purpose,
"{{code}}", mailOneLine(code),
"{{link}}", mailOneLine(link),
"{{site_url}}", mailOneLine(base),
"{{logo_url}}", "",
"{{logo}}", "",
).Replace(c.SubjectTemplate)
return mailPayload{Subject: mailOneLine(subject), Body: prepareMailHTML(body)}
}
func mailOneLine(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
}
func mailLogoURL(dev bool, base, logoURL string) string {
if logoURL == "" || base == "" {
return ""
}
origin, e := url.Parse(base)
ref, re := url.Parse(logoURL)
if e != nil || re != nil {
return ""
}
u := origin.ResolveReference(ref)
if u.Scheme != "https" && !(dev && u.Scheme == "http") {
return ""
}
return u.String()
}
func mailLogoImg(abs string) string {
if abs == "" {
return ""
}
return "<img class=\"email-logo\" width=\"180\" alt=\"站点 Logo\" src=\"" + stdhtml.EscapeString(abs) + "\" style=\"display:block;margin:0 auto 24px;max-width:180px;height:auto;\">"
}
func (o *Operations) TestMail(ctx context.Context, raw json.RawMessage, clear []string, send bool, to string, actor uint) error {
v, _, _, e := o.draft(o.db, "mail", raw, clear)
if e != nil {
return e
}
c := *v.(*MailConfig)
c.Enabled = true
if e = o.validate(o.db, "mail", &c); e != nil {
return e
}
var p *mailPayload
if send {
preview := o.renderMail(c, "测试邮件,无有效验证码", "")
preview.To = to
p = &preview
}
e = o.smtp(ctx, c, p, randomID())
result := "连接、TLS 与认证通过"
if send {
result = "服务器已接受(不代表送达)"
}
if e != nil {
result = e.Error()
}
o.Audit(actor, "mail", "test", result)
return e
}
func (o *Operations) EnqueueMail(tx *gorm.DB, to, kind, code, dedupe string) error {
p := o.MailPreview(code, kind)
p.To = to
b, _ := json.Marshal(p)
encrypted, e := o.seal(string(b), "mail-task")
if e != nil {
return e
}
masked := "***"
parts := strings.Split(to, "@")
if len(parts) == 2 {
masked = "***@" + parts[1]
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.MailTask{Dedupe: dedupe, Kind: kind, Recipient: masked, Payload: encrypted, Status: "queued", NextAt: time.Now()}).Error
}
// A lease is committed before I/O; expired leases are recovered after a restart.
func (o *Operations) ProcessMail(ctx context.Context) error {
v, _, e := o.read(o.db, "mail")
if e != nil {
return e
}
c := *v.(*MailConfig)
if !c.Enabled {
return nil
}
o.db.Model(&model.MailTask{}).Where("status = ? AND attempts >= 3 AND next_at <= ?", "sending", time.Now()).Updates(map[string]any{"status": "failed", "summary": "重试次数已用完,最终接受状态未知", "payload": ""})
var task model.MailTask
e = o.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("status IN ? AND next_at <= ?", []string{"queued", "retry", "sending"}, time.Now()).Order("id").First(&task).Error; e != nil {
return e
}
task.Attempts++
task.Status = "sending"
task.NextAt = time.Now().Add(2 * time.Minute)
return tx.Save(&task).Error
})
if errors.Is(e, gorm.ErrRecordNotFound) {
return nil
}
if e != nil {
return e
}
plaintext, e := o.open(task.Payload, "mail-task")
var p mailPayload
if e == nil {
e = json.Unmarshal([]byte(plaintext), &p)
}
if e == nil {
e = o.smtp(ctx, c, &p, task.Dedupe)
}
status, summary := "accepted", "服务器已接受(不代表送达)"
if e != nil {
status = "retry"
summary = "发送失败,请检查邮件服务"
if task.Attempts >= 3 {
status = "failed"
}
}
updates := map[string]any{"status": status, "summary": summary, "next_at": time.Now().Add(time.Duration(task.Attempts) * time.Minute)}
if status == "accepted" || status == "failed" {
updates["payload"] = ""
}
return o.db.Model(&model.MailTask{}).Where("id = ? AND attempts = ? AND status = ?", task.ID, task.Attempts, "sending").Updates(updates).Error
}
func (o *Operations) Run(ctx context.Context) {
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
n := 0
for {
select {
case <-ctx.Done():
return
case <-tick.C:
_ = o.ProcessMail(ctx)
n++
if n%300 == 0 {
o.db.Where("expires_at < ?", time.Now()).Delete(&model.ActionCounter{})
o.db.Where("expires_at < ?", time.Now().Add(-24*time.Hour)).Delete(&model.EmailChallenge{})
}
}
}
}
func (o *Operations) MailRows() ([]model.MailTask, error) {
var a []model.MailTask
e := o.db.Order("id desc").Limit(100).Find(&a).Error
return a, e
}
func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
email = strings.ToLower(strings.TrimSpace(email))
if !validEmail(email) {
return 0, errors.New("邮箱格式无效")
}
cfg, e := o.Security()
if e != nil {
return 0, e
}
if purpose != "register" && purpose != "reset" {
return 0, errors.New("用途无效")
}
if (purpose == "register" && (!cfg.VerifyEmail || !cfg.AllowRegister)) || (purpose == "reset" && !cfg.PasswordReset) {
return 0, errors.New("该账号邮件功能未开启")
}
for _, q := range []struct {
k string
n, s int
}{{"email:ip:" + ip, 20, 3600}, {"email:resend:" + purpose + ":" + email, 1, cfg.ResendInterval}, {"email:hour:" + email, cfg.EmailHourly, 3600}} {
wait, e := o.Quota(q.k, q.n, q.s)
if e != nil || wait > 0 {
return wait, e
}
}
if purpose == "reset" {
var n int64
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
return 0, e
}
if n != 1 {
return 0, nil
}
}
code := randomID()
hash := counterKey(email + ":" + purpose + ":" + code)
e = o.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&model.EmailChallenge{Hash: hash, Email: email, Purpose: purpose, ExpiresAt: time.Now().Add(15 * time.Minute)}).Error; e != nil {
return e
}
return o.EnqueueMail(tx, email, purpose, code, hash)
})
return 0, e
}
func (o *Operations) ConsumeCode(email, purpose, code string) error {
r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(strings.ToLower(strings.TrimSpace(email))+":"+purpose+":"+code), time.Now()).Update("used", true)
if r.Error != nil {
return r.Error
}
if r.RowsAffected != 1 {
return fmt.Errorf("验证码无效或已过期")
}
return nil
}
// Probe merged drafts so partial API updates cannot bypass activation validation.
func (o *Operations) ProbeBeforeSave(ctx context.Context, name string, raw json.RawMessage, clear []string) error {
v, _, _, e := o.draft(o.db, name, raw, clear)
if e != nil {
return e
}
if e = o.validate(o.db, name, v); e != nil {
return e
}
switch c := v.(type) {
case *StorageConfig:
return o.TestStorage(ctx, raw, clear)
case *MailConfig:
if c.Enabled {
return o.smtp(ctx, *c, nil, randomID())
}
case *SecurityConfig:
old, e := o.Security()
if e != nil {
return e
}
if (c.VerifyEmail && !old.VerifyEmail) || (c.PasswordReset && !old.PasswordReset) {
mail, _, e := o.read(o.db, "mail")
if e != nil {
return e
}
return o.smtp(ctx, *mail.(*MailConfig), nil, randomID())
}
}
return nil
}

View File

@@ -0,0 +1,265 @@
package service
import (
"bytes"
"mime/quotedprintable"
"regexp"
"strings"
"golang.org/x/net/html"
)
// 手机 QQ / 163 等会丢弃 <style>;发送与预览前把简单 class 规则内联,并做成完整 HTML 文档。
var (
mailCSSCommentRe = regexp.MustCompile(`(?s)/\*.*?\*/`)
mailCSSRuleRe = regexp.MustCompile(`([^{}@]+)\{([^{}]+)\}`)
)
func prepareMailHTML(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
doc, err := html.Parse(strings.NewReader(raw))
if err != nil {
return wrapMailDocument(raw)
}
css := map[string]string{}
collectAndRemoveMailStyles(doc, css)
if len(css) > 0 {
applyMailClassStyles(doc, css)
}
ensureMailDocumentHead(doc)
var buf bytes.Buffer
if err := html.Render(&buf, doc); err != nil {
return wrapMailDocument(raw)
}
return buf.String()
}
func wrapMailDocument(body string) string {
lower := strings.ToLower(body)
if strings.Contains(lower, "<html") {
return body
}
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;padding:0;">` + body + `</body></html>`
}
func collectAndRemoveMailStyles(n *html.Node, css map[string]string) {
if n.Type == html.ElementNode && n.Data == "style" {
var b strings.Builder
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
b.WriteString(c.Data)
}
}
parseMailCSSRules(b.String(), css)
// 删除 style 节点
parent := n.Parent
if parent != nil {
next := n.NextSibling
parent.RemoveChild(n)
if next != nil {
collectAndRemoveMailStyles(next, css)
}
return
}
}
for c := n.FirstChild; c != nil; {
next := c.NextSibling
collectAndRemoveMailStyles(c, css)
c = next
}
}
func parseMailCSSRules(src string, css map[string]string) {
src = mailCSSCommentRe.ReplaceAllString(src, "")
src = stripMailAtRules(src)
for _, m := range mailCSSRuleRe.FindAllStringSubmatch(src, -1) {
decls := normalizeMailDecls(m[2])
if decls == "" {
continue
}
for _, sel := range strings.Split(m[1], ",") {
sel = strings.TrimSpace(sel)
// 仅支持单一 class:.email-card
if !strings.HasPrefix(sel, ".") || strings.ContainsAny(sel, " \t>+~[:#") {
continue
}
name := strings.TrimPrefix(sel, ".")
if name == "" || strings.Contains(name, ".") {
continue
}
css[name] = mergeMailDecls(css[name], decls)
}
}
}
// 去掉 @media / @supports 等块,避免大括号干扰简单解析。
func stripMailAtRules(src string) string {
var b strings.Builder
for i := 0; i < len(src); {
if src[i] == '@' {
j := i
for j < len(src) && src[j] != '{' {
j++
}
if j >= len(src) {
break
}
depth := 0
for j < len(src) {
if src[j] == '{' {
depth++
} else if src[j] == '}' {
depth--
if depth == 0 {
j++
break
}
}
j++
}
i = j
continue
}
b.WriteByte(src[i])
i++
}
return b.String()
}
func normalizeMailDecls(s string) string {
parts := strings.Split(s, ";")
out := make([]string, 0, len(parts))
seen := map[string]int{}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
key, _, ok := strings.Cut(p, ":")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
if key == "" {
continue
}
// 后写覆盖先写
if idx, ok := seen[key]; ok {
out[idx] = p
continue
}
seen[key] = len(out)
out = append(out, p)
}
return strings.Join(out, ";")
}
func mergeMailDecls(base, extra string) string {
if base == "" {
return normalizeMailDecls(extra)
}
if extra == "" {
return normalizeMailDecls(base)
}
return normalizeMailDecls(base + ";" + extra)
}
func applyMailClassStyles(n *html.Node, css map[string]string) {
if n.Type == html.ElementNode {
class := ""
styleIdx := -1
styleVal := ""
for i, a := range n.Attr {
switch strings.ToLower(a.Key) {
case "class":
class = a.Val
case "style":
styleIdx = i
styleVal = a.Val
}
}
if class != "" {
var fromClass string
for _, c := range strings.Fields(class) {
if d, ok := css[c]; ok {
fromClass = mergeMailDecls(fromClass, d)
}
}
if fromClass != "" {
// 元素已有 style 优先(等价于内联覆盖 class)
merged := mergeMailDecls(fromClass, styleVal)
if styleIdx >= 0 {
n.Attr[styleIdx].Val = merged
} else {
n.Attr = append(n.Attr, html.Attribute{Key: "style", Val: merged})
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
applyMailClassStyles(c, css)
}
}
func ensureMailDocumentHead(doc *html.Node) {
var htmlNode *html.Node
for c := doc.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == "html" {
htmlNode = c
break
}
}
if htmlNode == nil {
return
}
hasLang := false
for _, a := range htmlNode.Attr {
if strings.EqualFold(a.Key, "lang") {
hasLang = true
break
}
}
if !hasLang {
htmlNode.Attr = append(htmlNode.Attr, html.Attribute{Key: "lang", Val: "zh-CN"})
}
var head *html.Node
for c := htmlNode.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == "head" {
head = c
break
}
}
if head == nil {
head = &html.Node{Type: html.ElementNode, Data: "head"}
htmlNode.InsertBefore(head, htmlNode.FirstChild)
}
hasCharset := false
for c := head.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == "meta" {
for _, a := range c.Attr {
if strings.EqualFold(a.Key, "charset") {
hasCharset = true
}
}
}
}
if !hasCharset {
meta := &html.Node{
Type: html.ElementNode,
Data: "meta",
Attr: []html.Attribute{{Key: "charset", Val: "UTF-8"}},
}
head.InsertBefore(meta, head.FirstChild)
}
}
func writeQuotedPrintableHTML(w interface{ Write([]byte) (int, error) }, body string) error {
qp := quotedprintable.NewWriter(w)
if _, err := qp.Write([]byte(body)); err != nil {
return err
}
return qp.Close()
}

View File

@@ -0,0 +1,131 @@
package service
import (
"context"
"errors"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/version"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"os"
"path/filepath"
"strings"
"time"
)
func (o *Operations) ResetPassword(email, code, password string) error {
cfg, e := o.Security()
if e != nil || !cfg.PasswordReset {
return errors.New("找回密码未开启")
}
if len(password) < 6 || len(password) > 64 {
return errors.New("密码长度须为 6–64 字节")
}
hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if e != nil {
return e
}
return o.db.Transaction(func(tx *gorm.DB) error {
scoped := *o
scoped.db = tx
if e := scoped.ConsumeCode(email, "reset", code); e != nil {
return e
}
var users []model.User
if e := tx.Where("LOWER(email) = ?", strings.ToLower(strings.TrimSpace(email))).Limit(2).Find(&users).Error; e != nil {
return e
}
if len(users) != 1 {
return errors.New("账号不可用")
}
return tx.Model(&model.User{}).Where("id = ?", users[0].ID).Updates(map[string]any{"password": string(hash), "token_version": gorm.Expr("token_version + 1")}).Error
})
}
func (o *Operations) Diagnostics(ctx context.Context) map[string]any {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
dbState := "正常"
sql, e := o.db.DB()
if e != nil {
dbState = "异常"
} else if sql.PingContext(ctx) != nil {
dbState = "异常"
}
local := "正常"
if o.LocalWritable() != nil {
local = "异常"
}
v, _, e := o.read(o.db, "storage")
storage := "未知"
if e == nil {
storage = "本地:" + local
if v.(*StorageConfig).Backend == "s3" {
storage = "S3:未知(使用存储页测试)"
}
}
var last model.SettingsAudit
result := map[string]any{"state": "未知"}
if o.db.Where("module = ? AND action = ?", "mail", "test").Order("id desc").First(&last).Error == nil {
result = map[string]any{"result": last.Result, "tested_at": last.CreatedAt, "note": "历史测试,不代表持续健康"}
}
var queued int64
queueErr := o.db.WithContext(ctx).Model(&model.MailTask{}).Where("status IN ?", []string{"queued", "retry", "sending"}).Count(&queued).Error
queueState := "正常 · PostgreSQL 持久邮件队列"
if queueErr != nil {
queueState = "异常 · 暂时无法读取邮件队列"
}
return map[string]any{"version": version.Version, "uptime_seconds": int(time.Since(o.Started).Seconds()), "database": dbState, "cache": "未配置独立应用缓存", "tasks": queueState, "queued_mail": queued, "storage": storage, "mail_test": result}
}
// Draft references live in browsers, so unattached database rows are NEVER deleted.
func (o *Operations) ScanTemporary() (map[string]any, error) {
m, e := o.Maintenance()
if e != nil {
return nil, e
}
count, size := 0, int64(0)
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
for _, dir := range []string{filepath.Join(o.cfg.DataDir, "uploads"), filepath.Join(o.cfg.DataDir, "private")} {
e = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&os.ModeSymlink != 0 {
return nil
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".partial") {
return nil
}
info, e := d.Info()
if e != nil {
return e
}
if info.ModTime().Before(cutoff) {
count++
size += info.Size()
}
return nil
})
if e != nil {
return nil, e
}
}
candidates, e := o.temporaryCandidates()
if e != nil {
return nil, e
}
var drafts int64
if e = o.db.Model(&model.PostAttachment{}).Where("post_id = 0").Count(&drafts).Error; e != nil {
return nil, e
}
return map[string]any{"candidates": candidates, "expired_partial_count": count, "expired_partial_bytes": size, "protected_draft_attachments": drafts, "retention_days": m.TempDays, "message": "保留所有已入库图片和草稿附件。只清理受本版本上传锁保护且已过期的候选文件;未知来源的旧 partial 不在线删除。"}, nil
}
func (o *Operations) ClearMailLogs() (int64, error) {
v, _, e := o.read(o.db, "mail")
if e != nil {
return 0, e
}
c := v.(*MailConfig)
r := o.db.Where("status IN ? AND updated_at < ?", []string{"accepted", "failed"}, time.Now().Add(-time.Duration(c.Retention)*24*time.Hour)).Delete(&model.MailTask{})
return r.RowsAffected, r.Error
}

View File

@@ -0,0 +1,120 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"net"
"net/netip"
"os"
"strings"
"time"
)
func counterKey(key string) string { x := sha256.Sum256([]byte(key)); return hex.EncodeToString(x[:]) }
// PostgreSQL row locks provide shared atomic quotas across application instances.
func (o *Operations) Quota(key string, limit, seconds int) (int, error) {
if seconds <= 0 {
return 0, nil
}
wait := 0
e := o.db.Transaction(func(tx *gorm.DB) error {
key = counterKey(key)
if e := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.ActionCounter{Key: key, ExpiresAt: time.Now().Add(time.Duration(seconds) * time.Second)}).Error; e != nil {
return e
}
var c model.ActionCounter
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&c, "key = ?", key).Error; e != nil {
return e
}
now := time.Now()
if !now.Before(c.ExpiresAt) {
c.Count = 0
c.ExpiresAt = now.Add(time.Duration(seconds) * time.Second)
}
if c.Count >= limit {
wait = int(time.Until(c.ExpiresAt).Seconds()) + 1
return nil
}
c.Count++
return tx.Save(&c).Error
})
return wait, e
}
func (o *Operations) FailureWait(account, ip string, c SecurityConfig) (int, error) {
var rows []model.ActionCounter
if e := o.db.Where("key IN ? AND expires_at > ?", []string{counterKey("failure:account:" + strings.ToLower(strings.TrimSpace(account))), counterKey("failure:ip:" + ip)}, time.Now()).Find(&rows).Error; e != nil {
return 0, e
}
for _, r := range rows {
limit := c.LoginFailures
if r.Key == counterKey("failure:ip:"+ip) {
limit *= 4
}
if r.Count >= limit {
return int(time.Until(r.ExpiresAt).Seconds()) + 1, nil
}
}
return 0, nil
}
func (o *Operations) RecordFailure(account, ip string, c SecurityConfig) {
_, _ = o.Quota("failure:account:"+strings.ToLower(strings.TrimSpace(account)), c.LoginFailures, c.LoginWindow)
_, _ = o.Quota("failure:ip:"+ip, c.LoginFailures*4, c.LoginWindow)
}
func (o *Operations) ClearFailure(account string) {
o.db.Delete(&model.ActionCounter{}, "key = ?", counterKey("failure:account:"+strings.ToLower(strings.TrimSpace(account))))
}
// Resolve once, validate every address, and dial the validated IP to prevent DNS rebinding.
// Private targets require an exact deployment allowlist entry, not a settings checkbox.
func safeDial(ctx context.Context, network, address string) (net.Conn, error) {
host, port, e := net.SplitHostPort(address)
if e != nil {
return nil, errors.New("连接地址无效")
}
allow := false
for _, v := range strings.Split(os.Getenv("SERVICE_PRIVATE_HOSTS"), ",") {
if strings.EqualFold(strings.TrimSpace(v), host) {
allow = true
}
}
ips, e := net.DefaultResolver.LookupIPAddr(ctx, host)
if e != nil || len(ips) == 0 {
return nil, errors.New("地址解析失败")
}
for _, a := range ips {
if !allow && (restrictedServiceIP(a.IP) || !a.IP.IsGlobalUnicast() || a.IP.IsPrivate() || a.IP.IsLoopback() || a.IP.IsLinkLocalUnicast() || a.IP.IsUnspecified()) {
return nil, errors.New("目标地址受限;私网服务需部署 SERVICE_PRIVATE_HOSTS")
}
}
var last error
for _, a := range ips {
c, e := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
if e == nil {
return c, nil
}
last = e
}
_ = last
return nil, fmt.Errorf("连接失败或超时")
}
func restrictedServiceIP(ip net.IP) bool {
a, ok := netip.AddrFromSlice(ip)
if !ok {
return true
}
a = a.Unmap()
for _, p := range []string{"100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "2001:db8::/32"} {
if netip.MustParsePrefix(p).Contains(a) {
return true
}
}
return false
}

View File

@@ -0,0 +1,243 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/freefire/jiang13-bbs/model"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
u, e := url.Parse(c.Endpoint)
if e != nil {
return nil, errors.New("存储地址无效")
}
access, e := o.open(c.AccessKey, "storage:access_key")
if e != nil {
return nil, e
}
secret, e := o.open(c.SecretKey, "storage:secret_key")
if e != nil {
return nil, e
}
lookup := minio.BucketLookupDNS
if c.PathStyle {
lookup = minio.BucketLookupPath
}
tr := &http.Transport{DialContext: safeDial, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 15 * time.Second, DisableKeepAlives: true}
return minio.New(u.Host, &minio.Options{Creds: credentials.NewStaticV4(access, secret, ""), Secure: u.Scheme == "https", Region: c.Region, BucketLookup: lookup, Transport: boundedS3Transport{base: tr, host: func() string {
if c.PathStyle {
return u.Host
}
return c.Bucket + "." + u.Host
}(), scheme: u.Scheme}})
}
func (o *Operations) TestStorage(ctx context.Context, raw json.RawMessage, clear []string) error {
v, _, _, e := o.draft(o.db, "storage", raw, clear)
if e != nil {
return e
}
c := *v.(*StorageConfig)
if c.Backend == "local" {
return o.LocalWritable()
}
client, e := o.s3(c)
if e != nil {
return e
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
key := c.Prefix + "tests/" + randomID()
payload := []byte("jiang13 storage probe")
if _, e = client.PutObject(ctx, c.Bucket, key, bytes.NewReader(payload), int64(len(payload)), minio.PutObjectOptions{ContentType: "text/plain", DisableMultipart: true}); e != nil {
return errors.New("存储写入失败(连接、凭据或权限)")
}
cleanup := func() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
}
object, e := client.GetObject(ctx, c.Bucket, key, minio.GetObjectOptions{})
if e != nil {
_ = cleanup()
return errors.New("存储读取失败")
}
b, e := io.ReadAll(io.LimitReader(object, 128))
_ = object.Close()
cleanErr := cleanup()
if e != nil || !bytes.Equal(b, payload) {
return errors.New("存储读取校验失败")
}
if cleanErr != nil {
return errors.New("存储清理失败;测试对象保留于应用 tests 前缀")
}
return nil
}
func (o *Operations) LocalWritable() error {
dir := filepath.Join(o.cfg.DataDir, "uploads")
f, e := os.CreateTemp(dir, ".probe-")
if e != nil {
return errors.New("本地上传目录不可写")
}
name := f.Name()
_, e = f.Write([]byte("probe"))
_ = f.Close()
removeErr := os.Remove(name)
if e != nil || removeErr != nil {
return errors.New("本地存储读写或清理失败")
}
return nil
}
// Uploads are validated locally first, then moved to the selected target. No fallback.
func (o *Operations) StoreFile(path, mimeType string, public bool) (string, error) {
v, version, e := o.read(o.db, "storage")
if e != nil {
return "", errors.New("读取存储配置失败")
}
c := *v.(*StorageConfig)
if c.Backend == "local" {
return "", nil
}
client, e := o.s3(c)
if e != nil {
return "", e
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
f, e := os.Open(path)
if e != nil {
return "", e
}
defer f.Close()
stat, e := f.Stat()
if e != nil {
return "", e
}
id := randomID()
key := c.Prefix + "objects/" + id
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
}
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
if e = o.db.Create(&obj).Error; e != nil {
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
return "", errors.New("保存文件记录失败")
}
return id, nil
}
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
var obj model.StoredObject
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
return nil, "", errors.New("文件不存在")
}
if requirePublic && !obj.Public {
return nil, "", errors.New("文件不存在")
}
var row model.ModuleConfig
if e := o.db.First(&row, "name = ?", obj.ConfigName).Error; e != nil {
return nil, "", errors.New("历史存储配置不可用")
}
var c StorageConfig
if e := json.Unmarshal([]byte(row.Data), &c); e != nil {
return nil, "", errors.New("历史存储配置无效")
}
client, e := o.s3(c)
if e != nil {
return nil, "", e
}
object, e := client.GetObject(ctx, c.Bucket, obj.Key, minio.GetObjectOptions{})
if e != nil {
return nil, "", errors.New("读取存储失败")
}
if _, e = object.Stat(); e != nil {
_ = object.Close()
return nil, "", errors.New("文件暂不可用")
}
return object, obj.MIME, nil
}
func (o *Operations) StorageReferences() ([]map[string]any, error) {
type row struct {
ConfigName string
Count int64
}
var rows []row
e := o.db.Model(&model.StoredObject{}).Select("config_name, count(*) as count").Group("config_name").Scan(&rows).Error
out := []map[string]any{}
for _, r := range rows {
out = append(out, map[string]any{"revision": r.ConfigName, "references": r.Count})
}
return out, e
}
func RemoteObjectID(url string) string { return strings.TrimPrefix(url, "/api/media/") }
type boundedS3Transport struct {
base http.RoundTripper
host, scheme string
}
func (t boundedS3Transport) RoundTrip(r *http.Request) (*http.Response, error) {
if !strings.EqualFold(r.URL.Host, t.host) || r.URL.Scheme != t.scheme {
return nil, errors.New("拒绝存储重定向到未配置目标")
}
return t.base.RoundTrip(r)
}
func (o *Operations) PublicObjectLocation(id string) (string, error) {
var object model.StoredObject
if e := o.db.First(&object, "id = ? AND public = true", id).Error; e != nil {
return "", errors.New("文件不存在")
}
var row model.ModuleConfig
if e := o.db.First(&row, "name = ?", object.ConfigName).Error; e != nil {
return "", e
}
var c StorageConfig
if e := json.Unmarshal([]byte(row.Data), &c); e != nil {
return "", e
}
if c.CDN == "" {
return "", nil
}
return strings.TrimRight(c.CDN, "/") + "/" + object.Key, nil
}
// RemoveObject revokes public access before best-effort remote cleanup.
// Failed cleanup retains the historic configuration reference for manual retry.
func (o *Operations) RemoveObject(id string) error {
var obj model.StoredObject
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
return e
}
if e := o.db.Model(&obj).Update("public", false).Error; e != nil {
return e
}
var row model.ModuleConfig
if e := o.db.First(&row, "name = ?", obj.ConfigName).Error; e != nil {
return e
}
var c StorageConfig
if e := json.Unmarshal([]byte(row.Data), &c); e != nil {
return e
}
client, e := o.s3(c)
if e != nil {
return e
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if e = client.RemoveObject(ctx, c.Bucket, obj.Key, minio.RemoveObjectOptions{}); e != nil {
return errors.New("远程文件清理失败,已撤销公开访问")
}
return o.db.Delete(&obj).Error
}

View File

@@ -0,0 +1,213 @@
package service
import (
"context"
"crypto/sha256"
"database/sql"
"database/sql/driver"
"encoding/binary"
"errors"
"github.com/freefire/jiang13-bbs/model"
"os"
"path/filepath"
"strings"
"time"
)
func temporaryLockKey(path string) int64 {
key := filepath.ToSlash(filepath.Clean(path))
for _, root := range []string{"/uploads/", "/private/"} {
if i := strings.LastIndex(key, root); i >= 0 {
key = key[i+1:]
break
}
}
s := sha256.Sum256([]byte("temporary:" + key))
return int64(binary.BigEndian.Uint64(s[:8]))
}
func (o *Operations) lockTemporary(path string, try bool) (*sql.Conn, bool, error) {
db, e := o.db.DB()
if e != nil {
return nil, false, e
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
c, e := db.Conn(ctx)
if e != nil {
return nil, false, e
}
if try {
var ok bool
e = c.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", temporaryLockKey(path)).Scan(&ok)
if e != nil || !ok {
c.Close()
return nil, false, e
}
} else {
if _, e = c.ExecContext(ctx, "SELECT pg_advisory_lock($1)", temporaryLockKey(path)); e != nil {
c.Close()
return nil, false, e
}
}
return c, true, nil
}
func unlockTemporary(c *sql.Conn, path string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, e := c.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", temporaryLockKey(path))
if e != nil {
_ = c.Raw(func(any) error { return driver.ErrBadConn })
}
_ = c.Close()
}
// The database session lock lasts for the complete upload, including a stalled writer.
// A crashed process releases it automatically; cleaners can never unlink an active upload.
func (o *Operations) BeginTemporary(path string) (func(), error) {
rel, e := filepath.Rel(o.cfg.DataDir, path)
if e != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return nil, errors.New("临时文件目录无效")
}
c, _, e := o.lockTemporary(path, false)
if e != nil {
return nil, e
}
row := model.TemporaryUpload{ID: counterKey(filepath.ToSlash(rel)), RelativePath: filepath.ToSlash(rel)}
if e = o.db.Create(&row).Error; e != nil {
unlockTemporary(c, path)
return nil, e
}
return func() {
if _, e := os.Lstat(path); os.IsNotExist(e) {
o.db.Delete(&model.TemporaryUpload{}, "id = ?", row.ID)
}
unlockTemporary(c, path)
}, nil
}
func (o *Operations) temporaryPath(row model.TemporaryUpload) (string, error) {
rel := filepath.FromSlash(row.RelativePath)
if filepath.IsAbs(rel) || strings.HasPrefix(filepath.Clean(rel), "..") || !strings.HasSuffix(rel, ".partial") {
return "", errors.New("临时路径不在允许范围")
}
path := filepath.Join(o.cfg.DataDir, rel)
parent := filepath.ToSlash(filepath.Dir(rel))
if parent != "uploads/images" && parent != "uploads/backgrounds" && parent != "private/files" {
return "", errors.New("目录不在允许范围")
}
root, e := filepath.EvalSymlinks(o.cfg.DataDir)
if e != nil {
return "", e
}
actual, e := filepath.EvalSymlinks(path)
if e != nil {
return "", e
}
within, e := filepath.Rel(root, actual)
if e != nil || strings.HasPrefix(within, "..") || filepath.IsAbs(within) {
return "", errors.New("拒绝目录外文件")
}
info, e := os.Lstat(path)
if e != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return "", errors.New("拒绝非普通文件")
}
return path, nil
}
type TemporaryCandidate struct {
ID string `json:"id"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
func (o *Operations) temporaryCandidates() ([]TemporaryCandidate, error) {
m, e := o.Maintenance()
if e != nil {
return nil, e
}
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
var rows []model.TemporaryUpload
if e = o.db.Where("created_at < ?", cutoff).Order("created_at").Limit(100).Find(&rows).Error; e != nil {
return nil, e
}
out := []TemporaryCandidate{}
for _, row := range rows {
path, e := o.temporaryPath(row)
if e != nil {
continue
}
c, ok, e := o.lockTemporary(path, true)
if e != nil {
return nil, e
}
if !ok {
continue
}
info, e := os.Stat(path)
if e == nil && info.ModTime().Before(cutoff) {
out = append(out, TemporaryCandidate{row.ID, info.Size(), info.ModTime()})
}
unlockTemporary(c, path)
}
return out, nil
}
func (o *Operations) CleanTemporary(ids []string) (map[string]any, error) {
if len(ids) == 0 || len(ids) > 100 {
return nil, errors.New("每次请选择扫描出的 1–100 个临时文件")
}
m, e := o.Maintenance()
if e != nil {
return nil, e
}
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
removed, skipped, failed := 0, 0, 0
for _, id := range ids {
var row model.TemporaryUpload
if o.db.First(&row, "id = ? AND created_at < ?", id, cutoff).Error != nil {
skipped++
continue
}
path, e := o.temporaryPath(row)
if e != nil {
skipped++
continue
}
c, ok, e := o.lockTemporary(path, true)
if e != nil {
failed++
continue
}
if !ok {
skipped++
continue
}
info, e := os.Stat(path)
if e != nil || !info.ModTime().Before(cutoff) {
unlockTemporary(c, path)
skipped++
continue
}
// Raw Markdown references are checked again while holding the upload lock.
referenced := false
for _, table := range []string{"posts", "comments"} {
var n int64
e = o.db.Table(table).Where("content LIKE ?", "%"+filepath.Base(path)+"%").Count(&n).Error
if e != nil || n > 0 {
referenced = true
break
}
}
if referenced {
unlockTemporary(c, path)
skipped++
continue
}
if e = os.Remove(path); e != nil {
failed++
} else {
o.db.Delete(&model.TemporaryUpload{}, "id = ?", id)
removed++
}
unlockTemporary(c, path)
}
return map[string]any{"message": "临时文件清理完成", "removed": removed, "skipped": skipped, "failed": failed}, nil
}

View File

@@ -0,0 +1,625 @@
package service
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"github.com/freefire/jiang13-bbs/config"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func testKey(t *testing.T) {
t.Helper()
t.Setenv("SETTINGS_MASTER_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
}
func testOps(t *testing.T) *Operations {
t.Helper()
dsn := os.Getenv("OPS_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set OPS_TEST_DATABASE_URL to an isolated PostgreSQL database")
}
u, e := url.Parse(dsn)
if e != nil || !strings.HasPrefix(strings.TrimPrefix(u.Path, "/"), "ops_test") {
t.Fatal("test database name must begin ops_test")
}
db, e := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if e != nil {
t.Fatal(e)
}
schema := "ops_test_" + fmt.Sprint(time.Now().UnixNano())
if e = db.Exec("CREATE SCHEMA " + schema).Error; e != nil {
t.Fatal(e)
}
q := u.Query()
q.Set("search_path", schema)
u.RawQuery = q.Encode()
scoped, e := gorm.Open(postgres.Open(u.String()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() {
sql, _ := scoped.DB()
_ = sql.Close()
db.Exec("DROP SCHEMA " + schema + " CASCADE")
sql, _ = db.DB()
_ = sql.Close()
})
if e = scoped.AutoMigrate(&model.TemporaryUpload{}, &model.ModuleConfig{}, &model.SiteSetting{}, &model.SettingsAudit{}, &model.ActionCounter{}, &model.MailTask{}, &model.EmailChallenge{}, &model.StoredObject{}, &model.User{}, &model.PostAttachment{}); e != nil {
t.Fatal(e)
}
dir := t.TempDir()
_ = os.MkdirAll(filepath.Join(dir, "uploads"), 0700)
_ = os.MkdirAll(filepath.Join(dir, "private"), 0700)
return NewOperations(scoped, &config.Config{DataDir: dir, DevMode: true, SiteURL: "https://forum.example.com"})
}
func raw(v any) json.RawMessage { b, _ := json.Marshal(v); return b }
func TestOperationalSecrets(t *testing.T) {
testKey(t)
o := &Operations{}
enc, e := o.seal("do-not-leak", "mail:password")
if e != nil {
t.Fatal(e)
}
enc2, _ := o.seal("do-not-leak", "mail:password")
if enc == enc2 || strings.Contains(enc, "do-not-leak") {
t.Fatal("nonce or encryption failure")
}
p, e := o.open(enc, "mail:password")
if e != nil || p != "do-not-leak" {
t.Fatal("roundtrip")
}
if _, e = o.open(enc, "storage:secret_key"); e == nil {
t.Fatal("domain substitution accepted")
}
t.Setenv("SETTINGS_MASTER_KEY", "")
if _, e = o.open(enc, "mail:password"); e == nil {
t.Fatal("missing key accepted")
}
}
func TestFilterNormalizationExceptionsAndMarkdown(t *testing.T) {
c := FilterConfig{Enabled: true, Rules: []FilterRule{{ID: "a", Word: "bad", Scopes: []string{"body"}, Action: "block", Enabled: true, Exceptions: []string{"badminton"}}, {ID: "b", Word: "hello", Scopes: []string{"body"}, Action: "log", Enabled: true}}}
r := MatchFilter(c, "body", "HELLO badminton and BAD **bad** [label](https://bad.example)\n\n\u0060bad\u0060\n\n\u0060\u0060\u0060go\nbad\n\u0060\u0060\u0060")
if r.Result != "block" {
t.Fatal(r)
}
blocks, excepted := 0, 0
for _, h := range r.Hits {
if h.Action == "block" {
if h.Excepted {
excepted++
} else {
blocks++
}
}
}
if blocks != 2 || excepted != 1 {
t.Fatalf("blocks=%d excepted=%d text=%q", blocks, excepted, r.Text)
}
if MatchFilter(c, "title", "bad").Result != "pass" {
t.Fatal("scope leak")
}
if MatchFilter(c, "body", "badminton").Result != "pass" {
t.Fatal("exception not applied")
}
}
func TestRateLimiterUsesRequestCategory(t *testing.T) {
r := NewRateLimiter()
r.SetLimit(RateLogin, 1)
if !r.Allow("login:127.0.0.1") || r.Allow("login:127.0.0.1") {
t.Fatal("per-IP key bypassed limit")
}
if !r.Allow("login:127.0.0.2") {
t.Fatal("different IP denied")
}
}
func TestPrivateServiceTargetDenied(t *testing.T) {
t.Setenv("SERVICE_PRIVATE_HOSTS", "")
c, e := safeDial(context.Background(), "tcp", "127.0.0.1:25")
if c != nil {
c.Close()
}
if e == nil || !strings.Contains(e.Error(), "受限") {
t.Fatal(e)
}
}
func TestModuleConflictAndAtomicDependency(t *testing.T) {
testKey(t)
o := testOps(t)
cfg := defaultModule("security").(*SecurityConfig)
cfg.CommentInterval = 15
var wins atomic.Int32
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
e := o.Save("security", 0, raw(cfg), nil, 1)
if e == nil {
wins.Add(1)
} else if !errors.Is(e, ErrConfigConflict) {
t.Errorf("unexpected: %v", e)
}
}()
}
wg.Wait()
if wins.Load() != 1 {
t.Fatalf("concurrent winners: %d", wins.Load())
}
reopened := NewOperations(o.db, o.cfg)
s, e := reopened.Security()
if e != nil || s.CommentInterval != 15 {
t.Fatal("restart persistence", e)
}
cfg.VerifyEmail = true
cfg.AllowRegister = false
if e = o.Save("security", 1, raw(cfg), nil, 1); e == nil {
t.Fatal("dependency accepted")
}
s, _ = o.Security()
if !s.AllowRegister || s.VerifyEmail {
t.Fatal("failed save changed active config")
}
m := defaultModule("mail").(*MailConfig)
m.Enabled = true
m.Host = "smtp.example.com"
m.Username = "user"
m.Password = "secret-value"
m.From = "sender@example.com"
if e = o.Save("mail", 0, raw(m), nil, 1); e != nil {
t.Fatal(e)
}
got, e := o.Read("mail")
if e != nil {
t.Fatal(e)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), "secret-value") || strings.Contains(string(b), "mail:password") {
t.Fatal("secret exposed")
}
cfg.AllowRegister = true
if e = o.Save("security", 1, raw(cfg), nil, 1); e != nil {
t.Fatal(e)
}
if e = o.Save("mail", 1, raw(map[string]any{"enabled": false}), nil, 1); e == nil {
t.Fatal("mail dependency bypass")
}
if e = o.Save("mail", 1, raw(map[string]any{}), []string{"password"}, 1); e == nil {
t.Fatal("dependent credential cleared")
}
if e = o.Save("mail", 1, raw(map[string]any{"password": ""}), nil, 1); e != nil {
t.Fatal("blank failed to retain secret", e)
}
}
func TestSharedQuotaAndFiniteLoginRecovery(t *testing.T) {
o := testOps(t)
var wins atomic.Int32
var wg sync.WaitGroup
for range 20 {
wg.Add(1)
go func() {
defer wg.Done()
wait, e := o.Quota("same", 5, 60)
if e != nil {
t.Error(e)
} else if wait == 0 {
wins.Add(1)
}
}()
}
wg.Wait()
if wins.Load() != 5 {
t.Fatal(wins.Load())
}
cfg := *defaultModule("security").(*SecurityConfig)
for range cfg.LoginFailures {
o.RecordFailure("Victim", "ip", cfg)
}
wait, e := o.FailureWait("victim", "other", cfg)
if e != nil || wait <= 0 || wait > cfg.LoginWindow+1 {
t.Fatal(wait, e)
}
o.db.Model(&model.ActionCounter{}).Where("key = ?", counterKey("failure:account:victim")).Update("expires_at", time.Now().Add(-time.Second))
if wait, e = o.FailureWait("victim", "other", cfg); e != nil || wait != 0 {
t.Fatal("account did not recover")
}
}
func smtpFixture(t *testing.T, authFail, stall bool) (string, int, *x509.CertPool, *atomic.Int32) {
t.Helper()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
der, e := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
if e != nil {
t.Fatal(e)
}
keyDER, _ := x509.MarshalECPrivateKey(key)
cert, _ := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
ln, e := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { ln.Close() })
var accepted atomic.Int32
go func() {
for {
conn, e := ln.Accept()
if e != nil {
return
}
go func() {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(4 * time.Second))
if stall {
time.Sleep(3 * time.Second)
return
}
io.WriteString(conn, "220 test SMTP\r\n")
r := bufio.NewReader(conn)
for {
line, e := r.ReadString('\n')
if e != nil {
return
}
switch {
case strings.HasPrefix(line, "EHLO"):
io.WriteString(conn, "250-test\r\n250 AUTH PLAIN\r\n")
case strings.HasPrefix(line, "AUTH"):
if authFail {
io.WriteString(conn, "535 rejected\r\n")
} else {
io.WriteString(conn, "235 accepted\r\n")
}
case strings.HasPrefix(line, "DATA"):
io.WriteString(conn, "354 go\r\n")
for {
l, e := r.ReadString('\n')
if e != nil {
return
}
if l == ".\r\n" {
break
}
}
accepted.Add(1)
io.WriteString(conn, "250 queued\r\n")
case strings.HasPrefix(line, "QUIT"):
io.WriteString(conn, "221 bye\r\n")
return
default:
io.WriteString(conn, "250 ok\r\n")
}
}
}()
}
}()
host, port, _ := net.SplitHostPort(ln.Addr().String())
n := 0
fmt.Sscan(port, &n)
return host, n, pool, &accepted
}
func TestSMTPConnectionAcceptAuthAndTimeout(t *testing.T) {
testKey(t)
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
for _, mode := range []string{"success", "auth", "timeout", "certificate"} {
t.Run(mode, func(t *testing.T) {
host, port, roots, accepted := smtpFixture(t, mode == "auth", mode == "timeout")
o := &Operations{smtpRoots: roots}
if mode == "certificate" {
o.smtpRoots = nil
}
secret, _ := o.seal("smtp-test-secret", "mail:password")
c := MailConfig{Host: host, Port: port, TLS: "tls", Username: "test", Password: secret, From: "test@example.com", FromName: "Test", Timeout: 2}
e := o.smtp(context.Background(), c, &mailPayload{To: "admin@example.com", Subject: "Test", Body: "<p>test</p>"}, "test")
if mode == "success" {
if e != nil || accepted.Load() != 1 {
t.Fatal(e, accepted.Load())
}
} else if e == nil {
t.Fatal("failure expected")
}
})
}
}
func TestStorageProbeAndHistoricPrivateObject(t *testing.T) {
testKey(t)
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
o := testOps(t)
var mu sync.Mutex
objects := map[string][]byte{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if r.Header.Get("Authorization") == "" {
w.WriteHeader(403)
return
}
switch r.Method {
case "PUT":
var body io.Reader = r.Body
if strings.Contains(r.Header.Get("Content-Encoding"), "aws-chunked") || strings.HasPrefix(r.Header.Get("X-Amz-Content-Sha256"), "STREAMING-") {
body = httputil.NewChunkedReader(r.Body)
}
b, _ := io.ReadAll(body)
objects[r.URL.Path] = b
w.Header().Set("ETag", `"test"`)
w.WriteHeader(200)
case "GET", "HEAD":
b, ok := objects[r.URL.Path]
if !ok {
w.WriteHeader(404)
return
}
w.Header().Set("Content-Length", fmt.Sprint(len(b)))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("ETag", `"test"`)
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
if r.Method == "GET" {
w.Write(b)
}
case "DELETE":
delete(objects, r.URL.Path)
w.WriteHeader(204)
default:
w.WriteHeader(400)
}
}))
defer srv.Close()
c := defaultModule("storage").(*StorageConfig)
c.Backend = "s3"
c.Endpoint = srv.URL
c.Bucket = "test-bucket"
c.AccessKey = "test-access"
c.SecretKey = "test-secret"
if e := o.TestStorage(context.Background(), raw(c), nil); e != nil {
t.Fatal(e)
}
if len(objects) != 0 {
t.Fatal("probe leaked object")
}
if e := o.Save("storage", 0, raw(c), nil, 1); e != nil {
t.Fatal(e)
}
path := filepath.Join(t.TempDir(), "file.txt")
os.WriteFile(path, []byte("historic"), 0600)
id, e := o.StoreFile(path, "text/plain", false)
if e != nil {
t.Fatal(e)
}
c.Backend = "local"
c.AccessKey = ""
c.SecretKey = ""
if e = o.Save("storage", 1, raw(c), nil, 1); e != nil {
t.Fatal(e)
}
if _, _, e = o.OpenObject(context.Background(), id, true); e == nil {
t.Fatal("private object publicly accessible")
}
r, _, e := o.OpenObject(context.Background(), id, false)
if e != nil {
t.Fatal(e)
}
defer r.Close()
b, _ := io.ReadAll(r)
if string(b) != "historic" {
t.Fatalf("%q", b)
}
_ = r.Close()
if e = o.RemoveObject(id); e != nil {
t.Fatal(e)
}
if _, _, e = o.OpenObject(context.Background(), id, false); e == nil {
t.Fatal("deleted object accessible")
}
}
func TestTemporaryCleanupProtectsActiveReferencedAndDraftFiles(t *testing.T) {
o := testOps(t)
if e := o.db.Exec("CREATE TABLE posts (content text); CREATE TABLE comments (content text)").Error; e != nil {
t.Fatal(e)
}
dir := filepath.Join(o.cfg.DataDir, "uploads", "images")
_ = os.MkdirAll(dir, 0700)
path := filepath.Join(dir, "managed.png.partial")
release, e := o.BeginTemporary(path)
if e != nil {
t.Fatal(e)
}
_ = os.WriteFile(path, []byte("temporary"), 0600)
old := time.Now().Add(-8 * 24 * time.Hour)
_ = os.Chtimes(path, old, old)
o.db.Model(&model.TemporaryUpload{}).Where("id = ?", counterKey("uploads/images/managed.png.partial")).Update("created_at", old)
candidates, e := o.temporaryCandidates()
if e != nil || len(candidates) != 0 {
t.Fatal("active upload selected", e)
}
release()
candidates, e = o.temporaryCandidates()
if e != nil || len(candidates) != 1 {
t.Fatal("expired inactive upload not selected", e)
}
_ = o.db.Exec("INSERT INTO posts(content) VALUES (?)", "referenced "+filepath.Base(path)).Error
r, e := o.CleanTemporary([]string{candidates[0].ID})
if e != nil || r["removed"] != 0 {
t.Fatal("reference deleted", r, e)
}
o.db.Exec("DELETE FROM posts")
draft := filepath.Join(dir, "draft.png")
_ = os.WriteFile(draft, []byte("draft"), 0600)
_ = os.Chtimes(draft, old, old)
unknown := filepath.Join(dir, "legacy.png.partial")
_ = os.WriteFile(unknown, []byte("unknown"), 0600)
_ = os.Chtimes(unknown, old, old)
r, e = o.CleanTemporary([]string{candidates[0].ID})
if e != nil || r["removed"] != 1 {
t.Fatal(r, e)
}
for _, p := range []string{draft, unknown} {
if _, e = os.Stat(p); e != nil {
t.Fatal("protected file removed")
}
}
r, e = o.CleanTemporary([]string{candidates[0].ID})
if e != nil || r["removed"] != 0 {
t.Fatal("duplicate cleanup", r, e)
}
}
func TestDurableMailRetriesAndCodeSingleUse(t *testing.T) {
testKey(t)
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
o := testOps(t)
host, port, roots, _ := smtpFixture(t, true, false)
o.smtpRoots = roots
c := defaultModule("mail").(*MailConfig)
c.Enabled = true
c.Host = host
c.Port = port
c.From = "test@example.com"
c.FromName = "Test"
c.Username = "test"
c.Password = "test"
if e := o.Save("mail", 0, raw(c), nil, 1); e != nil {
t.Fatal(e)
}
if e := o.EnqueueMail(o.db, "user@example.com", "register", "single-code", "same-task"); e != nil {
t.Fatal(e)
}
if e := o.EnqueueMail(o.db, "user@example.com", "register", "single-code", "same-task"); e != nil {
t.Fatal(e)
}
for range 3 {
if e := o.ProcessMail(context.Background()); e != nil {
t.Fatal(e)
}
o.db.Model(&model.MailTask{}).Where("dedupe = ?", "same-task").Update("next_at", time.Now().Add(-time.Second))
}
rows, e := o.MailRows()
if e != nil || len(rows) != 1 || rows[0].Status != "failed" || rows[0].Attempts != 3 || rows[0].Payload != "" {
t.Fatal(rows, e)
}
challenge := model.EmailChallenge{Hash: counterKey("user@example.com:register:single-code"), Email: "user@example.com", Purpose: "register", ExpiresAt: time.Now().Add(time.Minute)}
if e = o.db.Create(&challenge).Error; e != nil {
t.Fatal(e)
}
if e = o.ConsumeCode("user@example.com", "register", "single-code"); e != nil {
t.Fatal(e)
}
if e = o.ConsumeCode("user@example.com", "register", "single-code"); e == nil {
t.Fatal("code replay")
}
}
func TestMailQueueRecoveryAndDestinationQuota(t *testing.T) {
testKey(t)
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
o := testOps(t)
host, port, roots, accepted := smtpFixture(t, false, false)
c := defaultModule("mail").(*MailConfig)
c.Enabled = true
c.Host = host
c.Port = port
c.Username = "test"
c.Password = "secret"
c.From = "test@example.com"
c.FromName = "Test"
if e := o.Save("mail", 0, raw(c), nil, 1); e != nil {
t.Fatal(e)
}
security := defaultModule("security").(*SecurityConfig)
security.VerifyEmail = true
if e := o.Save("security", 0, raw(security), nil, 1); e != nil {
t.Fatal(e)
}
if wait, e := o.SendCode("USER@example.com", "register", "192.0.2.1"); e != nil || wait != 0 {
t.Fatal(wait, e)
}
if wait, e := o.SendCode("user@example.com", "register", "192.0.2.2"); e != nil || wait < 1 {
t.Fatal("destination quota bypassed", wait, e)
}
var task model.MailTask
if e := o.db.First(&task).Error; e != nil {
t.Fatal(e)
}
if strings.Contains(task.Payload, "user@example.com") {
t.Fatal("unencrypted queue")
}
o.db.Model(&task).Updates(map[string]any{"status": "sending", "attempts": 1, "next_at": time.Now().Add(-time.Minute)})
restarted := NewOperations(o.db, o.cfg)
restarted.smtpRoots = roots
if e := restarted.ProcessMail(context.Background()); e != nil {
t.Fatal(e)
}
var finished model.MailTask
o.db.First(&finished, task.ID)
if finished.Status != "accepted" || finished.Attempts != 2 || finished.Payload != "" || accepted.Load() != 1 {
t.Fatal("queue did not recover", finished.Status, finished.Attempts)
}
}
func TestMailTemplateGuards(t *testing.T) {
var cfg MailConfig
cfg.applyTemplateDefaults()
if cfg.BodyTemplate != defaultMailBody || cfg.SubjectTemplate != defaultMailSubject {
t.Fatal("defaults not applied")
}
cfg.BodyTemplate = legacyMailBody
cfg.applyTemplateDefaults()
if cfg.BodyTemplate != defaultMailBody {
t.Fatal("legacy default not upgraded")
}
o := &Operations{cfg: &config.Config{SiteURL: "https://forum.example.com", DevMode: true}}
if e := o.validate(nil, "mail", &MailConfig{Port: 465, TLS: "tls", Timeout: 10, Retention: 30, BodyTemplate: "<script></script><p>no code</p>"}); e == nil || !strings.Contains(e.Error(), "{{code}}") {
t.Fatal(e)
}
custom := `<!DOCTYPE html><html><body><style>.x{color:red}</style><script>void 0</script><p class="x">{{code}}</p><a href="{{link}}">go</a></body></html>`
p := o.renderMail(MailConfig{BodyTemplate: custom}, "<b>x</b>", "reset")
if strings.Contains(p.Body, "<b>x</b>") || !strings.Contains(p.Body, "&lt;b&gt;x&lt;/b&gt;") || !strings.Contains(p.Body, "<script>") {
t.Fatal(p.Body)
}
if strings.Contains(strings.ToLower(p.Body), "<style") {
t.Fatal("style blocks should be inlined for mail clients", p.Body)
}
if !strings.Contains(p.Body, "color:red") && !strings.Contains(p.Body, "color: red") {
t.Fatal("class styles should be inlined", p.Body)
}
if !strings.Contains(p.Subject, "密码找回") || strings.Contains(p.Subject, "\n") {
t.Fatal(p.Subject)
}
if !strings.Contains(p.Body, "https://forum.example.com/reset-password") {
t.Fatal(p.Body)
}
def := o.renderMail(MailConfig{}, "123456", "")
if strings.Contains(strings.ToLower(def.Body), "<style") {
t.Fatal("default template style should be inlined")
}
if !strings.Contains(def.Body, "background:#f5f7fb") && !strings.Contains(def.Body, "background: #f5f7fb") {
t.Fatal("default wrap style missing after prepare", def.Body)
}
}

View File

@@ -13,10 +13,10 @@ import (
// 帖子操作错误
var (
ErrPostNotFound = errors.New("帖子不存在")
ErrPostForbidden = errors.New("无权限操作此帖子")
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
ErrPostInvalidType = errors.New("无效的删除类型")
ErrPostNotFound = errors.New("帖子不存在")
ErrPostForbidden = errors.New("无权限操作此帖子")
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
ErrPostInvalidType = errors.New("无效的删除类型")
)
// PostService 帖子服务
@@ -65,7 +65,7 @@ func toPostListItems(posts []model.Post) []PostListItem {
TypeStatus: ComputeTypeStatus(pt, p.TypeMeta),
ContentAccess: model.NormalizeContentAccess(p.ContentAccess),
AccessPoints: p.AccessPoints,
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
CommentCount: p.CommentCount, Status: p.Status, CreatedAt: p.CreatedAt,
Board: p.Board, User: p.User,
})
@@ -505,11 +505,11 @@ func buildPostDetail(post *model.Post) *PostDetail {
return &PostDetail{
ID: post.ID, BoardID: post.BoardID, UserID: post.UserID,
Title: post.Title, Content: post.Content, Tags: post.Tags,
PostType: model.NormalizePostType(post.PostType),
PostType: model.NormalizePostType(post.PostType),
ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status,
Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status,
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
Board: post.Board, User: post.User,
@@ -694,6 +694,11 @@ func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
// Create 创建帖子。status 由 handler 按角色计算
func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
if err := NewOperations(s.db, nil).Filter(scope, text, in.UserID); err != nil {
return nil, err
}
}
title := strings.TrimSpace(in.Title)
content := strings.TrimSpace(in.Content)
if title == "" {
@@ -856,6 +861,11 @@ func validateTypeMeta(postType, meta string) error {
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInput) (*PostDetail, error) {
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
if err := NewOperations(s.db, nil).Filter(scope, text, userID); err != nil {
return nil, err
}
}
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return nil, ErrPostNotFound

View File

@@ -31,6 +31,7 @@ var (
// PostFileService 帖子文件附件(私有目录 + API 下载)
type PostFileService struct {
ops *Operations
db *gorm.DB
dir string // data/private/files
setting *SettingService
@@ -132,6 +133,13 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
stored := hex.EncodeToString(raw) + ext
full := absPath(s.dir, stored)
tmp := full + ".partial"
if s.ops != nil {
release, e := s.ops.BeginTemporary(tmp)
if e != nil {
return nil, e
}
defer release()
}
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
@@ -168,6 +176,17 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
Size: int(written),
PricePoints: pricePoints,
}
if s.ops != nil {
id, e := s.ops.StoreFile(full, att.MIME, false)
if e != nil {
_ = os.Remove(full)
return nil, e
}
att.ObjectID = id
if id != "" {
_ = os.Remove(full)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(full)
return nil, err
@@ -268,6 +287,9 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
if err := s.db.Delete(&att).Error; err != nil {
return err
}
if s.ops != nil && att.ObjectID != "" {
return s.ops.RemoveObject(att.ObjectID)
}
_ = os.Remove(path)
return nil
}
@@ -332,3 +354,5 @@ func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostA
}).Error
})
}
func (s *PostFileService) WithOperations(o *Operations) { s.ops = o }

View File

@@ -1,6 +1,7 @@
package service
import (
"strings"
"sync"
"time"
)
@@ -33,12 +34,22 @@ func (r *RateLimiter) Allow(key string) bool {
r.mu.Lock()
defer r.mu.Unlock()
limit, ok := r.limits[key]
limit, ok := r.limits[strings.SplitN(key, ":", 2)[0]]
if !ok {
return true
}
now := time.Now()
if len(r.records) > 10000 {
for k, v := range r.records {
if len(v) == 0 || now.Sub(v[len(v)-1]) > time.Minute {
delete(r.records, k)
}
}
if len(r.records) > 20000 {
return false
}
}
windowStart := now.Add(-1 * time.Minute)
// 清理过期记录
@@ -76,14 +87,14 @@ const (
// DefaultRateLimiter 创建默认速率限制器
func DefaultRateLimiter() *RateLimiter {
rl := NewRateLimiter()
rl.SetLimit(RateLogin, 20) // 登录 20/分钟
rl.SetLimit(RateRegister, 10) // 注册 10/分钟
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
rl.SetLimit(RateComment, 30) // 评论 30/分钟
rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP)
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
rl.SetLimit(RateLogin, 20) // 登录 20/分钟
rl.SetLimit(RateRegister, 10) // 注册 10/分钟
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
rl.SetLimit(RateComment, 30) // 评论 30/分钟
rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP)
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
return rl
}

View File

@@ -22,6 +22,8 @@ const (
SettingKeyAllowRegister = "allow_register"
// SettingKeyAllowComments 全站开放评论;缺行视为开启(默认开)
SettingKeyAllowComments = "allow_comments"
// SettingKeyCommentsRequireLogin 评论仅登录可见;缺行视为关闭(游客可读)
SettingKeyCommentsRequireLogin = "comments_require_login"
// SettingKeyAllowMessages 全站开放私聊/群聊;缺行视为开启(默认开)
SettingKeyAllowMessages = "allow_messages"
SettingKeyPostCooldownHours = "post_cooldown_hours"
@@ -100,8 +102,9 @@ type PublicSiteSettings struct {
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
AllowRegister bool `json:"allow_register"`
AllowComments bool `json:"allow_comments"`
AllowMessages bool `json:"allow_messages"`
AllowComments bool `json:"allow_comments"`
CommentsRequireLogin bool `json:"comments_require_login"`
AllowMessages bool `json:"allow_messages"`
PostCooldownHours int `json:"post_cooldown_hours"`
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
CodeBlockFoldLines int `json:"code_block_fold_lines"`
@@ -181,6 +184,26 @@ func (s *SettingService) setBoolDefaultTrue(key string, on bool) error {
return s.putValue(key, "false")
}
// 缺行视为关;仅显式 true/1/on/yes 为开。
func parseBoolDefaultFalse(v string, found bool) bool {
if !found {
return false
}
switch strings.TrimSpace(strings.ToLower(v)) {
case "true", "1", "on", "yes":
return true
default:
return false
}
}
func (s *SettingService) setBoolDefaultFalse(key string, on bool) error {
if !on {
return s.deleteKey(key)
}
return s.putValue(key, "true")
}
// Public 返回解析后的公开站点设置(缺省已填默认值)
func (s *SettingService) Public() (PublicSiteSettings, error) {
out := PublicSiteSettings{
@@ -189,6 +212,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
TrustReviewedPublish: true,
AllowRegister: true,
AllowComments: true,
CommentsRequireLogin: false,
AllowMessages: true,
PostCooldownHours: DefaultCooldownHours,
CodeBlockAutoFold: true,
@@ -254,6 +278,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
}
out.AllowComments = comments
requireLogin, err := s.CommentsRequireLogin()
if err != nil {
return out, err
}
out.CommentsRequireLogin = requireLogin
messages, err := s.AllowMessages()
if err != nil {
return out, err
@@ -447,6 +477,20 @@ func (s *SettingService) SetAllowComments(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAllowComments, on)
}
// CommentsRequireLogin 评论是否仅登录可见。缺行视为关闭。
func (s *SettingService) CommentsRequireLogin() (bool, error) {
v, found, err := s.getValue(SettingKeyCommentsRequireLogin)
if err != nil {
return false, err
}
return parseBoolDefaultFalse(v, found), nil
}
// SetCommentsRequireLogin 持久化「登录可见评论」;关闭时删键保持「缺行=默认关」。
func (s *SettingService) SetCommentsRequireLogin(on bool) error {
return s.setBoolDefaultFalse(SettingKeyCommentsRequireLogin, on)
}
// AllowMessages 是否允许全站私聊/群聊。缺行视为开启。
func (s *SettingService) AllowMessages() (bool, error) {
v, found, err := s.getValue(SettingKeyAllowMessages)

View File

@@ -9,16 +9,12 @@ import (
"unicode/utf8"
)
// SettingKeyTimelineGitImport 提交导入适配器 JSON(不进公开 settings)
const SettingKeyTimelineGitImport = "timeline_git_import"
const (
maxAdapterJSONBytes = 64 * 1024
maxAdapterRegexLen = 512
maxAdapterSources = 20
maxAdapterRegexLen = 512
maxAdapterSources = 20
)
// TimelineGitAdapter 声明式提交导入配置
// TimelineGitAdapter 声明式提交导入配置(内置,不可在站点设置中编辑)
type TimelineGitAdapter struct {
MaxPages int `json:"max_pages"`
MaxCommits int `json:"max_commits"`
@@ -27,15 +23,15 @@ type TimelineGitAdapter struct {
// TimelineGitSourceCfg 单个 Git 源
type TimelineGitSourceCfg struct {
ID string `json:"id"`
Host string `json:"host"` // 精确主机或 "*"
ListPath string `json:"list_path"`
CommitPath string `json:"commit_path,omitempty"` // 单条 commit 页
APIURL string `json:"api_url"`
CommitAPI string `json:"commit_api,omitempty"`
Query map[string]string `json:"query"`
Headers map[string]string `json:"headers"`
Pagination string `json:"pagination"` // link_header | query_page
ID string `json:"id"`
Host string `json:"host"` // 精确主机或 "*"
ListPath string `json:"list_path"`
CommitPath string `json:"commit_path,omitempty"` // 单条 commit 页
APIURL string `json:"api_url"`
CommitAPI string `json:"commit_api,omitempty"`
Query map[string]string `json:"query"`
Headers map[string]string `json:"headers"`
Pagination string `json:"pagination"` // link_header | query_page
Item TimelineGitItemMap `json:"item"`
}
@@ -97,29 +93,6 @@ func DefaultTimelineGitAdapterJSON() string {
}`
}
// ValidateTimelineGitAdapterJSON 校验并规范化适配器 JSON
func ValidateTimelineGitAdapterJSON(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("适配器不能为空")
}
if len(raw) > maxAdapterJSONBytes {
return "", errors.New("适配器过大")
}
var cfg TimelineGitAdapter
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return "", fmt.Errorf("JSON 无效: %w", err)
}
if err := validateTimelineGitAdapter(&cfg); err != nil {
return "", err
}
out, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
return string(out), nil
}
func validateTimelineGitAdapter(cfg *TimelineGitAdapter) error {
if cfg.MaxPages < 1 || cfg.MaxPages > 50 {
return errors.New("max_pages 须在 1–50")
@@ -177,54 +150,14 @@ func validateTimelineGitAdapter(cfg *TimelineGitAdapter) error {
return nil
}
// TimelineGitAdapterJSON 读取站点适配器;缺行返回默认
func (s *SettingService) TimelineGitAdapterJSON() (string, error) {
v, found, err := s.getValue(SettingKeyTimelineGitImport)
if err != nil {
return "", err
}
if !found || strings.TrimSpace(v) == "" {
return DefaultTimelineGitAdapterJSON(), nil
}
return v, nil
}
// SetTimelineGitAdapterJSON 保存适配器(已校验)
func (s *SettingService) SetTimelineGitAdapterJSON(raw string) (string, error) {
normalized, err := ValidateTimelineGitAdapterJSON(raw)
if err != nil {
return "", err
}
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
return "", err
}
return normalized, nil
}
// ResetTimelineGitAdapter 恢复默认
func (s *SettingService) ResetTimelineGitAdapter() (string, error) {
def := DefaultTimelineGitAdapterJSON()
normalized, err := ValidateTimelineGitAdapterJSON(def)
if err != nil {
return "", err
}
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
return "", err
}
return normalized, nil
}
func (s *SettingService) loadTimelineGitAdapter() (*TimelineGitAdapter, error) {
raw, err := s.TimelineGitAdapterJSON()
if err != nil {
return nil, err
}
raw := DefaultTimelineGitAdapterJSON()
var cfg TimelineGitAdapter
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
return nil, errors.New("内置提交导入规则无效")
}
if err := validateTimelineGitAdapter(&cfg); err != nil {
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
return nil, errors.New("内置提交导入规则无效")
}
return &cfg, nil
}

View File

@@ -2,6 +2,7 @@ package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
@@ -14,6 +15,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"golang.org/x/image/webp"
@@ -37,6 +39,7 @@ const (
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
type UploadService struct {
ops *Operations
db *gorm.DB
dir string // 上传根目录(如 data/uploads)
setting *SettingService
@@ -122,6 +125,17 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
Height: cfg.Height,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
if e != nil {
_ = os.Remove(fullPath)
return nil, e
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(fullPath)
}
}
// 附件记录与用户头像更新在同一事务内完成
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(att).Error; err != nil {
@@ -204,6 +218,13 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
filename := hex.EncodeToString(nameBytes) + format.ext
fullPath := filepath.Join(s.dir, "images", filename)
tmp := fullPath + ".partial"
if s.ops != nil {
release, e := s.ops.BeginTemporary(tmp)
if e != nil {
return nil, e
}
defer release()
}
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
@@ -270,6 +291,17 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
Width: w,
Height: h,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
if e != nil {
_ = os.Remove(fullPath)
return nil, e
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(fullPath)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(fullPath)
return nil, err
@@ -310,6 +342,13 @@ func (s *UploadService) SaveBackground(src io.Reader) (string, error) {
filename := hex.EncodeToString(nameBytes) + format.ext
fullPath := filepath.Join(s.dir, "backgrounds", filename)
tmp := fullPath + ".partial"
if s.ops != nil {
release, e := s.ops.BeginTemporary(tmp)
if e != nil {
return "", e
}
defer release()
}
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
@@ -370,6 +409,16 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
return "", errors.New("图片不存在或不属于你")
}
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
if e != nil {
return "", e
}
defer r.Close()
return s.SaveBackground(r)
}
abs, ok := s.safeUploadPath(att.URL)
if !ok {
return "", errors.New("无效的图片地址")
@@ -411,7 +460,7 @@ func (s *UploadService) RemoveBackgroundIfUnused(oldURL, siteURL, adminURL strin
// UseAvatar 选用一张【本人历史上传】的头像
func (s *UploadService) UseAvatar(userID uint, url string) error {
url = strings.TrimSpace(url)
if !strings.HasPrefix(url, "/uploads/avatars/") || len(url) > 512 {
if (!strings.HasPrefix(url, "/uploads/avatars/") && !strings.HasPrefix(url, "/api/media/")) || len(url) > 512 {
return errors.New("无效的头像地址")
}
var count int64
@@ -469,6 +518,12 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
return err
}
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
}
return nil
}
// DB 已清理后再删物理文件;文件删除失败只记日志(不影响用户侧结果)
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(att.URL, "/uploads/")))
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
@@ -476,3 +531,5 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
}
return nil
}
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }