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

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

View File

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