完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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("昵称不能为空")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("无效的图片地址")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("评论内容不能为空")
|
||||
|
||||
579
backend/service/operations.go
Normal file
579
backend/service/operations.go
Normal 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)
|
||||
}
|
||||
159
backend/service/operations_filter.go
Normal file
159
backend/service/operations_filter.go
Normal 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
|
||||
}
|
||||
450
backend/service/operations_mail.go
Normal file
450
backend/service/operations_mail.go
Normal 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
|
||||
}
|
||||
265
backend/service/operations_mail_html.go
Normal file
265
backend/service/operations_mail_html.go
Normal 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()
|
||||
}
|
||||
131
backend/service/operations_maintenance.go
Normal file
131
backend/service/operations_maintenance.go
Normal 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
|
||||
}
|
||||
120
backend/service/operations_security.go
Normal file
120
backend/service/operations_security.go
Normal 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
|
||||
}
|
||||
243
backend/service/operations_storage.go
Normal file
243
backend/service/operations_storage.go
Normal 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
|
||||
}
|
||||
213
backend/service/operations_temporary.go
Normal file
213
backend/service/operations_temporary.go
Normal 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
|
||||
}
|
||||
625
backend/service/operations_test.go
Normal file
625
backend/service/operations_test.go
Normal 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, "<b>x</b>") || !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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user