fix(security): 修复A+B组11项高危问题 + 友校大使馆插件源码
13 个文件变更
+1001
-42
fangqihang1717@163.com
| •internal/database/database.go | +12 -1 |
| •internal/handlers/admin.go | +19 -6 |
| •internal/handlers/auth.go | +96 -2 |
| •internal/handlers/front.go | +8 -4 |
| •internal/middleware/middleware.go | +3 -3 |
| •internal/plugin/api_db.go | +22 -9 |
| •internal/plugin/api_net.go | +55 -1 |
| •internal/plugin/embassy_app_test.go | +220 -0 |
| •internal/plugin/plugin.go | +21 -6 |
| •internal/util/util.go | +49 -10 |
| •plugins/embassy/admin.html | +154 -0 |
| •plugins/embassy/main.js | +318 -0 |
| •plugins/embassy/plugin.json | +24 -0 |
变更内容
diff --git a/internal/database/database.go b/internal/database/database.go
index 81c74b0..cc891cb 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -97,7 +97,7 @@ CREATE TABLE IF NOT EXISTS admins (
);
CREATE TABLE IF NOT EXISTS settings (k VARCHAR(64) PRIMARY KEY, v {LT});
CREATE TABLE IF NOT EXISTS notices (id {PK}, title VARCHAR(128) DEFAULT '', content {LT}, created_at VARCHAR(40));
-CREATE TABLE IF NOT EXISTS apikeys (id {PK}, name VARCHAR(64) DEFAULT '', key VARCHAR(64) NOT NULL UNIQUE, status {I} DEFAULT 1, last_used VARCHAR(40) DEFAULT '', created_at VARCHAR(40));
+CREATE TABLE IF NOT EXISTS apikeys (id {PK}, name VARCHAR(64) DEFAULT '', api_key VARCHAR(64) NOT NULL UNIQUE, status {I} DEFAULT 1, last_used VARCHAR(40) DEFAULT '', created_at VARCHAR(40));
CREATE TABLE IF NOT EXISTS ai_logs (id {PK}, post_id {I} DEFAULT 0, action VARCHAR(16) DEFAULT '', reason {LT}, created_at VARCHAR(40));
CREATE TABLE IF NOT EXISTS bans (id {PK}, btype VARCHAR(16) DEFAULT '', bvalue VARCHAR(128) DEFAULT '', created_at VARCHAR(40));
CREATE TABLE IF NOT EXISTS verifications (id {PK}, email VARCHAR(128) NOT NULL, code VARCHAR(10) NOT NULL, expires VARCHAR(40));
@@ -134,6 +134,16 @@ func Migrate() error {
}
_, _ = DB.Exec(alter)
_, _ = DB.Exec("ALTER TABLE posts ADD COLUMN badges VARCHAR(255) DEFAULT ''")
+ // 兼容旧库:apikeys.key 是 MySQL 保留字,统一改名为 api_key(列不存在/已改名时报错,忽略即可)
+ rename := `ALTER TABLE apikeys RENAME COLUMN "key" TO "api_key"`
+ if Driver == "mysql" {
+ rename = "ALTER TABLE apikeys RENAME COLUMN `key` TO `api_key`"
+ }
+ _, _ = DB.Exec(rename)
+
+ // 兼容旧库:likes 去重后再建唯一索引,防止并发点赞产生重复行
+ _, _ = DB.Exec(`DELETE FROM likes WHERE id NOT IN (
+ SELECT min_id FROM (SELECT MIN(id) AS min_id FROM likes GROUP BY post_id, fingerprint) t)`)
// 常用索引(忽略已存在错误)
indexes := []string{
@@ -142,6 +152,7 @@ func Migrate() error {
"CREATE INDEX idx_media_post ON medias (post_id)",
"CREATE INDEX idx_comments_post ON comments (post_id)",
"CREATE INDEX idx_likes_post ON likes (post_id)",
+ "CREATE UNIQUE INDEX idx_likes_uniq ON likes (post_id, fingerprint)",
}
for _, ix := range indexes {
_, _ = DB.Exec(ix)
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index 02f5e40..01eba17 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -40,15 +40,23 @@ func AdminLoginPage(w http.ResponseWriter, r *http.Request) {
// AdminLoginSubmit 管理员登录(12 小时会话)
func AdminLoginSubmit(w http.ResponseWriter, r *http.Request) {
+ ip := util.ClientIP(r)
+ // 与发帖守卫共用失败锁定:同一 IP 连续失败 5 次锁 10 分钟,防止后台账号被在线爆破
+ if adminTryBlocked("login:" + ip) {
+ Render(w, r, "admin_layout.html", "pg_admin_login", map[string]any{"err": "失败次数过多,请 10 分钟后再试"})
+ return
+ }
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
var id int64
var hash string
err := database.DB.QueryRow("SELECT id,password FROM admins WHERE username=?", username).Scan(&id, &hash)
if err != nil || !util.CheckPassword(hash, password) {
+ adminTryFail("login:" + ip)
Render(w, r, "admin_layout.html", "pg_admin_login", map[string]any{"err": "用户名或密码错误"})
return
}
+ adminTryReset("login:" + ip)
http.SetCookie(w, &http.Cookie{
Name: "clv_admin", Value: util.SignSession(id, 12*time.Hour), Path: "/",
MaxAge: 12 * 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
@@ -352,10 +360,15 @@ func AdminThemeDelete(w http.ResponseWriter, r *http.Request) {
return
}
name := filepath.Base(r.FormValue("name"))
- if name != "default" {
- _ = os.RemoveAll(filepath.Join(config.Cfg.DataDir, "themes", name))
- if models.GetSetting("theme") == name {
- _ = models.SetSetting("theme", "default")
+ // 防路径穿越:拒绝 . / .. 等非法目录名,并确保解析后仍在 themes 目录内
+ if name != "default" && name != "." && name != ".." && !strings.HasPrefix(name, ".") {
+ themesDir := filepath.Clean(filepath.Join(config.Cfg.DataDir, "themes"))
+ target := filepath.Clean(filepath.Join(themesDir, name))
+ if target != themesDir && strings.HasPrefix(target, themesDir+string(os.PathSeparator)) {
+ _ = os.RemoveAll(target)
+ if models.GetSetting("theme") == name {
+ _ = models.SetSetting("theme", "default")
+ }
}
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
@@ -1007,7 +1020,7 @@ func AdminAPIKeys(w http.ResponseWriter, r *http.Request) {
if a := requireAdmin(w, r, models.PermAPI); a == nil {
return
}
- rows, err := database.DB.Query("SELECT id,name,key,status,IFNULL(last_used,''),created_at FROM apikeys ORDER BY id DESC")
+ rows, err := database.DB.Query("SELECT id,name,api_key,status,IFNULL(last_used,''),created_at FROM apikeys ORDER BY id DESC")
if err != nil {
http.Error(w, "查询失败", http.StatusInternalServerError)
return
@@ -1039,7 +1052,7 @@ func AdminAPIKeyCreate(w http.ResponseWriter, r *http.Request) {
if name == "" {
name = "未命名"
}
- _, _ = database.DB.Exec("INSERT INTO apikeys(name,key,created_at) VALUES(?,?,?)",
+ _, _ = database.DB.Exec("INSERT INTO apikeys(name,api_key,created_at) VALUES(?,?,?)",
name, util.RandomHex(24), models.Now())
http.Redirect(w, r, "/admin/apikeys", http.StatusSeeOther)
}
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index 52a340d..ffee5a3 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -8,6 +8,7 @@ import (
"net/mail"
"regexp"
"strings"
+ "sync"
"time"
"clearlove/internal/database"
@@ -20,6 +21,70 @@ import (
// Go 正则不支持 \u 转义,使用 \x{...} 表示中文区间
var usernameRe = regexp.MustCompile(`^[\w\x{4e00}-\x{9fa5}]{3,20}$`)
+// ---------- 通用失败计数(防爆破/轰炸,进程内内存态) ----------
+
+type tryEntry struct {
+ Count int
+ Until time.Time
+}
+
+var (
+ codeTryMu sync.Mutex
+ codeTries = map[string]tryEntry{}
+ codeTriesSeen = time.Now()
+)
+
+// codeTryBlocked 是否被锁定(连续失败 5 次锁 10 分钟)
+func codeTryBlocked(key string) bool {
+ codeTryMu.Lock()
+ defer codeTryMu.Unlock()
+ t, ok := codeTries[key]
+ if !ok {
+ return false
+ }
+ if time.Now().After(t.Until) {
+ delete(codeTries, key)
+ return false
+ }
+ return t.Count >= 5
+}
+
+func codeTryFail(key string) {
+ codeTryMu.Lock()
+ defer codeTryMu.Unlock()
+ // 定期整体回收过期条目,防止伪造 key 导致 map 无界增长
+ if time.Since(codeTriesSeen) > time.Hour {
+ codeTriesSeen = time.Now()
+ now := time.Now()
+ for k, v := range codeTries {
+ if now.After(v.Until) {
+ delete(codeTries, k)
+ }
+ }
+ }
+ t := codeTries[key]
+ t.Count++
+ t.Until = time.Now().Add(10 * time.Minute)
+ codeTries[key] = t
+}
+
+func codeTryReset(key string) {
+ codeTryMu.Lock()
+ defer codeTryMu.Unlock()
+ delete(codeTries, key)
+}
+
+// codeTryCount 当前失败次数(未锁定也返回计数,用于发码限流判断)
+func codeTryCount(key string) int {
+ codeTryMu.Lock()
+ defer codeTryMu.Unlock()
+ t, ok := codeTries[key]
+ if !ok || time.Now().After(t.Until) {
+ return 0
+ }
+ return t.Count
+}
+
// LoginPage 登录页
func LoginPage(w http.ResponseWriter, r *http.Request) {
Render(w, r, "layout.html", "pg_login", map[string]any{
@@ -114,14 +179,29 @@ func RegisterSubmit(w http.ResponseWriter, r *http.Request) {
}
// SMTP 已配置则强制校验验证码
if models.GetSetting("smtp_host") != "" {
+ key := "vcode:" + email
+ var vid int64
var dbCode string
var exp string
err := database.DB.QueryRow(
- "SELECT code,expires FROM verifications WHERE email=? ORDER BY id DESC LIMIT 1", email).Scan(&dbCode, &exp)
- if err != nil || dbCode != code || !codeValid(exp) {
+ "SELECT id,code,expires FROM verifications WHERE email=? ORDER BY id DESC LIMIT 1", email).Scan(&vid, &dbCode, &exp)
+ if err != nil || !codeValid(exp) {
q("邮箱验证码错误或已过期")
return
}
+ if dbCode != code {
+ // 防爆破:同一邮箱连续错误 5 次即作废当前验证码,必须重新获取
+ if codeTryBlocked(key) {
+ _, _ = database.DB.Exec("DELETE FROM verifications WHERE id=?", vid)
+ codeTryReset(key)
+ q("验证码错误次数过多,请重新获取验证码")
+ return
+ }
+ codeTryFail(key)
+ q("邮箱验证码错误或已过期")
+ return
+ }
+ codeTryReset(key)
}
res, err := database.DB.Exec(
"INSERT INTO users(username,email,password,status,created_at) VALUES(?,?,?,1,?)",
@@ -158,11 +238,23 @@ func RegisterCode(w http.ResponseWriter, r *http.Request) {
fail(w, 400, "邮箱格式不正确")
return
}
+ // 发码限流:同一邮箱 10 分钟内最多 3 次,同一 IP 10 分钟内最多 10 次,防 SMTP 轰炸
+ ip := util.ClientIP(r)
+ if codeTryCount("send:"+email) >= 3 {
+ fail(w, 429, "发送过于频繁,请稍后再试")
+ return
+ }
+ if codeTryCount("sendip:"+ip) >= 10 {
+ fail(w, 429, "发送过于频繁,请稍后再试")
+ return
+ }
if models.QueryInt("SELECT COUNT(1) FROM users WHERE email=?", email) > 0 {
fail(w, 400, "该邮箱已注册")
return
}
code := fmt.Sprintf("%06d", randomInt(1000000))
+ // 同邮箱旧验证码作废,避免多条并存增加爆破面
+ _, _ = database.DB.Exec("DELETE FROM verifications WHERE email=?", email)
_, err := database.DB.Exec(
"INSERT INTO verifications(email,code,expires) VALUES(?,?,?)",
email, code, time.Now().UTC().Add(10*time.Minute).Format(time.RFC3339))
@@ -170,6 +262,8 @@ func RegisterCode(w http.ResponseWriter, r *http.Request) {
fail(w, 500, "验证码生成失败")
return
}
+ codeTryFail("send:" + email)
+ codeTryFail("sendip:" + ip)
err = mailer.SendCode(host, models.GetSetting("smtp_port"),
models.GetSetting("smtp_user"), models.GetSetting("smtp_pass"),
models.GetSetting("smtp_from"), email, code)
diff --git a/internal/handlers/front.go b/internal/handlers/front.go
index 3182bdf..03cb169 100644
--- a/internal/handlers/front.go
+++ b/internal/handlers/front.go
@@ -570,16 +570,20 @@ func toggleLike(postID int64, fp string) (int64, bool) {
return 0, false
}
exists := models.QueryInt("SELECT COUNT(1) FROM likes WHERE post_id=? AND fingerprint=?", postID, fp)
+ liked := exists == 0
if exists > 0 {
_, _ = database.DB.Exec("DELETE FROM likes WHERE post_id=? AND fingerprint=?", postID, fp)
} else {
+ // 唯一索引 idx_likes_uniq 拦截并发重复点赞,冲突报错直接忽略(等价于已被并发请求点亮)
_, _ = database.DB.Exec(
"INSERT INTO likes(post_id,fingerprint,created_at) VALUES(?,?,?)", postID, fp, models.Now())
}
- n := models.QueryInt("SELECT COUNT(1) FROM likes WHERE post_id=?", postID)
- _, _ = database.DB.Exec("UPDATE posts SET like_count=? WHERE id=?", n, postID)
- plugin.Emit("like_toggled", map[string]any{"post_id": postID, "liked": exists == 0, "count": n})
- return n, exists == 0
+ // 单语句内重算计数,避免"读-改-写"交错导致 like_count 与实际行数永久漂移
+ _, _ = database.DB.Exec(
+ "UPDATE posts SET like_count=(SELECT COUNT(1) FROM likes WHERE post_id=?) WHERE id=?", postID, postID)
+ n := models.QueryInt("SELECT like_count FROM posts WHERE id=?", postID)
+ plugin.Emit("like_toggled", map[string]any{"post_id": postID, "liked": liked, "count": n})
+ return n, liked
}
// addComment 发表评论
diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go
index b57d069..23914f2 100644
--- a/internal/middleware/middleware.go
+++ b/internal/middleware/middleware.go
@@ -175,7 +175,7 @@ func exemptCSRF(r *http.Request) bool {
return false
}
var n int64
- err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE key=? AND status=1", k).Scan(&n)
+ err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE api_key=? AND status=1", k).Scan(&n)
return err == nil && n > 0
}
@@ -214,9 +214,9 @@ func HasAPIKey(r *http.Request) bool {
return false
}
var n int64
- if err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE key=? AND status=1", k).Scan(&n); err != nil || n == 0 {
+ if err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE api_key=? AND status=1", k).Scan(&n); err != nil || n == 0 {
return false
}
- _, _ = database.DB.Exec("UPDATE apikeys SET last_used=? WHERE key=?", models.Now(), k)
+ _, _ = database.DB.Exec("UPDATE apikeys SET last_used=? WHERE api_key=?", models.Now(), k)
return true
}
diff --git a/internal/plugin/api_db.go b/internal/plugin/api_db.go
index ea50822..260c11c 100644
--- a/internal/plugin/api_db.go
+++ b/internal/plugin/api_db.go
@@ -59,29 +59,42 @@ func (rt *jsRuntime) installDB(clv *goja.Object) error {
// ---------- SQL 安全 ----------
var (
- reWriteKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|replace|alter|drop|create|truncate|rename)\b`)
reTableRef = regexp.MustCompile("(?i)\\b(?:into|update|from|table|join)\\s+[`\"']?([a-zA-Z0-9_]+)")
+ reSQLComment = regexp.MustCompile(`(?s)/\*.*?\*/|--[^\n]*`)
+ reDangerousSQL = regexp.MustCompile(`(?i)\b(pragma|attach|detach|vacuum)\b`)
)
-// sqlAllowed 写操作必须落在本插件前缀的表上
+// sqlAllowed 插件 SQL 管控:
+// - 声明 db.admin 权限的插件不受限
+// - 拒绝多语句(分号拆分逐段校验)与 PRAGMA/ATTACH/DETACH/VACUUM 等危险语句
+// - 读写一致:所有被引用的表必须落在本插件前缀(pl_<slug>_)内;
+// 读取内核表请走 clv.site(site.read 权限),防止仅声明 db 的插件拖库
func (rt *jsRuntime) sqlAllowed(sqlText string) bool {
if rt.app != nil && rt.app.Plugin.HasPermission("db.admin") {
return true
}
- if !reWriteKeyword.MatchString(sqlText) {
- return true // 纯读取
- }
- prefix := strings.ToLower(rt.plugin.TablePrefix())
- for _, m := range reTableRef.FindAllStringSubmatch(sqlText, -1) {
- if !strings.HasPrefix(strings.ToLower(m[1]), prefix) {
+ // 去除注释再校验,防止 UPDATE/**/settings 这类拆词绕过
+ cleaned := reSQLComment.ReplaceAllString(sqlText, " ")
+ for _, seg := range strings.Split(cleaned, ";") {
+ seg = strings.TrimSpace(seg)
+ if seg == "" {
+ continue
+ }
+ if reDangerousSQL.MatchString(seg) {
return false
}
+ prefix := strings.ToLower(rt.plugin.TablePrefix())
+ for _, m := range reTableRef.FindAllStringSubmatch(seg, -1) {
+ if !strings.HasPrefix(strings.ToLower(m[1]), prefix) {
+ return false
+ }
+ }
}
return true
}
func (rt *jsRuntime) sqlDeniedErr(sqlText string) error {
- return fmt.Errorf("插件 %s 只能写自己的数据表(%s*);如需访问其它表请声明 db.admin 权限。SQL: %.100s",
+ return fmt.Errorf("插件 %s 只能访问自己的数据表(%s*);读取内核数据请用 clv.site,如需访问其它表请声明 db.admin 权限。SQL: %.100s",
rt.plugin.SlugOf(), rt.plugin.TablePrefix(), strings.TrimSpace(sqlText))
}
diff --git a/internal/plugin/api_net.go b/internal/plugin/api_net.go
index 60b54af..08ff90b 100644
--- a/internal/plugin/api_net.go
+++ b/internal/plugin/api_net.go
@@ -2,6 +2,7 @@
package plugin
import (
+ "context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
@@ -163,7 +164,19 @@ func (rt *jsRuntime) httpRequest(call goja.FunctionCall) (any, error) {
req.Header.Set("User-Agent", "ClearLove-Plugin/1.0")
}
- client := &http.Client{Timeout: time.Duration(timeoutMS) * time.Millisecond}
+ // 拨号期校验实际解析到的 IP(防 DNS rebinding:校验与拨号共用同一次解析结果,
+ // 并直接连接已校验的 IP),重定向目标同样复查(防外站 302 跳内网绕过)。
+ transport := &http.Transport{DialContext: ssrfGuardedDial(allowLocal)}
+ client := &http.Client{
+ Timeout: time.Duration(timeoutMS) * time.Millisecond,
+ Transport: transport,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ return errors.New("重定向次数过多")
+ }
+ return guardSSRF(req.URL.String(), allowLocal)
+ },
+ }
resp, err := client.Do(req)
if err != nil {
return nil, err
@@ -224,6 +237,47 @@ func isPrivateIP(ip net.IP) bool {
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
+// ssrfGuardedDial 返回带内网校验的拨号函数:域名解析后逐 IP 校验,
+// 仅连接通过校验的 IP,消除"校验时公网、拨号时内网"的 DNS rebinding 窗口。
+func ssrfGuardedDial(allowLocal bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
+ dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
+ return func(ctx context.Context, network, addr string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ return nil, err
+ }
+ if allowLocal {
+ return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ if isPrivateIP(ip) {
+ return nil, fmt.Errorf("禁止连接内网地址 %s", ip)
+ }
+ return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
+ }
+ ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
+ if err != nil {
+ return nil, err
+ }
+ var lastErr error
+ for _, ipa := range ips {
+ if isPrivateIP(ipa.IP) {
+ lastErr = fmt.Errorf("禁止连接内网地址(%s 解析到 %s)", host, ipa.IP)
+ continue
+ }
+ conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipa.IP.String(), port))
+ if err == nil {
+ return conn, nil
+ }
+ lastErr = err
+ }
+ if lastErr == nil {
+ lastErr = fmt.Errorf("无法解析主机 %s", host)
+ }
+ return nil, lastErr
+ }
+}
+
func randomUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
diff --git a/internal/plugin/embassy_app_test.go b/internal/plugin/embassy_app_test.go
new file mode 100644
index 0000000..37769c8
--- /dev/null
+++ b/internal/plugin/embassy_app_test.go
@@ -0,0 +1,220 @@
+package plugin
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "clearlove/internal/database"
+ "clearlove/internal/models"
+)
+
+// TestEmbassyPlugin 加载仓库 plugins/embassy 真实插件,验证「友校大使馆」完整业务流:
+// 后台登记大使 → 昵称识别 → 验证账密 → post_created 落认证 → 卡片徽章。
+func TestEmbassyPlugin(t *testing.T) {
+ dataDir := setupAppEnv(t)
+
+ // 拷贝真实插件文件到临时插件目录
+ src, err := filepath.Abs(filepath.Join("..", "..", "plugins", "embassy"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ dst := filepath.Join(dataDir, "plugins", "embassy")
+ if err := os.MkdirAll(dst, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range []string{"plugin.json", "main.js"} {
+ b, err := os.ReadFile(filepath.Join(src, f))
+ if err != nil {
+ t.Fatalf("读取插件文件失败: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dst, f), b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ SetEnabled("友校大使馆", true)
+ if err := LoadApp("embassy"); err != nil {
+ t.Fatalf("加载 embassy 插件失败: %v", err)
+ }
+ defer func() {
+ UnloadApp("embassy")
+ SetEnabled("友校大使馆", false)
+ }()
+
+ app := GetApp("embassy")
+ if app == nil {
+ t.Fatal("embassy 未登记到注册表")
+ }
+ if len(app.Routes) != 5 {
+ t.Fatalf("路由数 = %d,期望 5", len(app.Routes))
+ }
+ if len(app.Pages) != 1 || len(AdminMenus()) != 1 {
+ t.Fatalf("后台页/菜单注册异常")
+ }
+ if len(EventHooks("post_created")) != 1 {
+ t.Fatal("post_created 事件未注册")
+ }
+ if len(FilterHooks("filter.card")) != 1 {
+ t.Fatal("filter.card 未注册")
+ }
+
+ // 表已创建
+ if _, err := database.DB.Exec("SELECT COUNT(1) FROM pl_embassy_ambassador"); err != nil {
+ t.Fatalf("插件表未创建: %v", err)
+ }
+
+ // 简易 ctx 构造器(对齐 buildRequestCtx 的关键字段)
+ mkCtx := func(query, form map[string]string) map[string]any {
+ return map[string]any{
+ "req": map[string]any{"query": query, "form": form, "json": nil, "ip": "1.2.3.4", "fingerprint": "fp-test"},
+ "params": map[string]string{},
+ "userId": int64(0), "adminId": int64(1), "isAdmin": true, "csrf": "test-csrf",
+ }
+ }
+ call := func(fn string, arg any) (any, error) {
+ return app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
+ return rt.callFn(fn, arg)
+ })
+ }
+ // 路由函数返回 {status?, json: {...}},直接调用时需解包
+ jbody := func(v any) map[string]any {
+ m, _ := v.(map[string]any)
+ if m == nil {
+ return nil
+ }
+ j, _ := m["json"].(map[string]any)
+ return j
+ }
+
+ // 1) 未登记昵称 → 不受保护
+ v, err := call("apiLookup", mkCtx(map[string]string{"nickname": "小美"}, nil))
+ if err != nil {
+ t.Fatalf("apiLookup 失败: %v", err)
+ }
+ if m := jbody(v); m == nil || m["protected"] == true {
+ t.Fatalf("未登记昵称不应受保护: %v", v)
+ }
+
+ // 2) 后台登记大使(密码 bcrypt 落库)
+ v, err = call("adminAdd", mkCtx(nil, map[string]string{
+ "school": "邻校一中", "account": "amb01", "password": "1234", "nickname": "邻校大使小美"}))
+ if err != nil {
+ t.Fatalf("adminAdd 失败: %v", err)
+ }
+ if m, _ := v.(map[string]any); m == nil || m["redirect"] == "" {
+ t.Fatalf("adminAdd 应重定向回管理页: %v", v)
+ }
+ var ambID int64
+ var hash, school string
+ if err := database.DB.QueryRow(
+ "SELECT id, school, password FROM pl_embassy_ambassador WHERE nickname='邻校大使小美'").
+ Scan(&ambID, &school, &hash); err != nil {
+ t.Fatalf("大使未入库: %v", err)
+ }
+ if school != "邻校一中" {
+ t.Fatalf("school 异常: %q", school)
+ }
+ if strings.EqualFold(hash, "1234") || len(hash) < 20 {
+ t.Fatalf("密码应为 bcrypt 哈希,实际: %q", hash)
+ }
+
+ // 3) 已登记昵称 → 受保护
+ v, err = call("apiLookup", mkCtx(map[string]string{"nickname": "邻校大使小美"}, nil))
+ if err != nil {
+ t.Fatalf("apiLookup 失败: %v", err)
+ }
+ if m := jbody(v); m == nil || m["protected"] != true || m["school"] != "邻校一中" {
+ t.Fatalf("已登记昵称应受保护: %v", v)
+ }
+
+ // 4) 错误密码 → 验证失败
+ v, err = call("apiVerify", mkCtx(nil, map[string]string{
+ "nickname": "邻校大使小美", "account": "amb01", "password": "wrong"}))
+ if err != nil {
+ t.Fatalf("apiVerify 失败: %v", err)
+ }
+ if m := jbody(v); m == nil {
+ t.Fatalf("apiVerify 返回异常: %v", v)
+ } else if ok, _ := m["ok"].(bool); ok {
+ t.Fatalf("错误密码不应通过验证: %v", v)
+ }
+
+ // 5) 正确密码 → 验证通过,验证记录落库
+ v, err = call("apiVerify", mkCtx(nil, map[string]string{
+ "nickname": "邻校大使小美", "account": "amb01", "password": "1234"}))
+ if err != nil {
+ t.Fatalf("apiVerify 失败: %v", err)
+ }
+ if m := jbody(v); m == nil {
+ t.Fatalf("apiVerify 返回异常: %v", v)
+ } else if ok, _ := m["ok"].(bool); !ok {
+ t.Fatalf("正确密码应通过验证: %v", v)
+ }
+ if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_verify WHERE fingerprint='fp-test'") != 1 {
+ t.Fatal("验证记录未落库")
+ }
+
+ // 6) status:已验证指纹 → verified
+ v, err = call("apiStatus", mkCtx(map[string]string{"nickname": "邻校大使小美"}, nil))
+ if err != nil {
+ t.Fatalf("apiStatus 失败: %v", err)
+ }
+ if m := jbody(v); m == nil {
+ t.Fatalf("apiStatus 返回异常: %v", v)
+ } else if verified, _ := m["verified"].(bool); !verified {
+ t.Fatalf("status 应为已验证: %v", v)
+ }
+
+ // 7) post_created 处理(同步调用 handler)→ 认证落库
+ if _, err := call("onPostCreated", map[string]any{
+ "post_id": 101, "nickname": "邻校大使小美", "content": "你好"}); err != nil {
+ t.Fatalf("onPostCreated 失败: %v", err)
+ }
+ if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_cert WHERE post_id=101") != 1 {
+ t.Fatal("认证记录未落库")
+ }
+
+ // 8) filter.card → 徽章追加
+ card := map[string]any{"id": int64(101), "badges": []any{}}
+ out := ApplyFilterValue("filter.card", card, nil)
+ om, _ := out.(map[string]any)
+ if om == nil {
+ t.Fatalf("filter.card 无返回: %v", out)
+ }
+ badges, _ := om["badges"].([]any)
+ found := false
+ for _, b := range badges {
+ if s, _ := b.(string); s == "邻校一中大使官方认证" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("徽章未追加: %v", om["badges"])
+ }
+
+ // 9) 无验证时同昵称发帖 → 不落认证
+ if _, err := database.DB.Exec("DELETE FROM pl_embassy_verify"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := call("onPostCreated", map[string]any{
+ "post_id": 102, "nickname": "邻校大使小美", "content": "仿冒"}); err != nil {
+ t.Fatalf("onPostCreated 失败: %v", err)
+ }
+ if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_cert WHERE post_id=102") != 0 {
+ t.Fatal("无有效验证不应落认证")
+ }
+
+ // 10) 删除大使 → 连带清理验证记录
+ if _, err := call("adminDelete", mkCtx(nil, map[string]string{"id": "1"})); err != nil {
+ t.Fatalf("adminDelete 失败: %v", err)
+ }
+ if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_ambassador") != 0 {
+ t.Fatal("大使未删除")
+ }
+ if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_verify") != 0 {
+ t.Fatal("验证记录未连带清理")
+ }
+}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index c843b58..0823e3d 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -18,6 +18,7 @@ import (
"bytes"
"encoding/json"
"fmt"
+ "html"
"io"
"net/http"
"os"
@@ -307,13 +308,27 @@ func SaveConfig(p *Plugin, values map[string]string) error {
// reConfigVar 配置占位符 {config:key}
var reConfigVar = regexp.MustCompile(`\{config:[A-Za-z0-9_\-]+\}`)
-// expand 展开 {config:key} 占位符
+// expand 展开 {config:key} 占位符(用于昵称名单、标签、提示语等非 HTML 场景,原样替换)
func expand(s string, cfg map[string]string) string {
+ return expandIn(s, cfg, false)
+}
+
+// expandHTML 展开 {config:key} 占位符,配置值做 HTML 转义,
+// 防止管理员配置值(如 {config:url})携带引号/尖括号闭合标签属性造成存储型 XSS
+func expandHTML(s string, cfg map[string]string) string {
+ return expandIn(s, cfg, true)
+}
+
+func expandIn(s string, cfg map[string]string, esc bool) string {
if s == "" || !strings.Contains(s, "{config:") {
return s
}
return reConfigVar.ReplaceAllStringFunc(s, func(m string) string {
- return cfg[m[len("{config:"):len(m)-1]]
+ v := cfg[m[len("{config:"):len(m)-1]]
+ if esc {
+ return html.EscapeString(v)
+ }
+ return v
})
}
@@ -573,7 +588,7 @@ func CallHTML(hook string) string {
cfg := GetConfig(p)
for _, h := range p.Hooks {
if h.Hook == hook && h.Type == "html" {
- b.WriteString(expand(h.HTML, cfg))
+ b.WriteString(expandHTML(h.HTML, cfg))
}
}
}
@@ -596,9 +611,9 @@ func Notify(event string, payload map[string]any) {
}
func postWebhook(url, event string, payload map[string]any) {
- payload["event"] = event
- payload["time"] = models.Now()
- body, _ := json.Marshal(payload)
+ // 拷贝后再补充元数据字段:payload 会被多个 webhook goroutine 与内核共享,
+ // 直接写入原 map 会触发并发写 panic
+ body, _ := json.Marshal(withMeta(event, payload))
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
diff --git a/internal/util/util.go b/internal/util/util.go
index 5ebf984..0fd8ecc 100644
--- a/internal/util/util.go
+++ b/internal/util/util.go
@@ -19,6 +19,7 @@ import (
"log"
"math"
"mime/multipart"
+ "net"
"net/http"
"os"
"path/filepath"
@@ -109,21 +110,38 @@ type captchaEntry struct {
var captchas sync.Map
+func init() {
+ // 定期回收过期验证码,防止匿名刷 /api/captcha 导致内存无限增长
+ go func() {
+ for {
+ time.Sleep(5 * time.Minute)
+ now := time.Now().Unix()
+ captchas.Range(func(k, v any) bool {
+ if e, ok := v.(captchaEntry); ok && e.Exp <= now {
+ captchas.Delete(k)
+ }
+ return true
+ })
+ }
+ }()
+}
+
// NewCaptcha 生成验证码,返回 ID 与内联 SVG
func NewCaptcha() (string, string) {
- code := fmt.Sprintf("%04d", time.Now().UnixNano()%10000)
+ b := make([]byte, 2)
+ _, _ = rand.Read(b)
+ code := fmt.Sprintf("%04d", (int(b[0])<<8|int(b[1]))%10000)
id := RandomHex(8)
captchas.Store(id, captchaEntry{Code: code, Exp: time.Now().Add(5 * time.Minute).Unix()})
return id, captchaSVG(code)
}
-// VerifyCaptcha 校验并消费验证码(一次性)
+// VerifyCaptcha 校验并消费验证码(一次性;原子取出+删除,防并发重放)
func VerifyCaptcha(id, code string) bool {
- v, ok := captchas.Load(id)
+ v, ok := captchas.LoadAndDelete(id)
if !ok {
return false
}
- captchas.Delete(id) // 一次性使用
e := v.(captchaEntry)
return e.Exp > time.Now().Unix() && e.Code == strings.TrimSpace(code)
}
@@ -333,21 +351,42 @@ func io_ReadAll(r interface{ Read([]byte) (int, error) }) ([]byte, error) {
// ============ 请求辅助 ============
-// ClientIP 提取客户端真实 IP(支持反代 X-Forwarded-For / X-Real-IP)
+// ClientIP 提取客户端真实 IP(支持反代 X-Forwarded-For / X-Real-IP)。
+// 仅当直连来源为回环/内网地址(即确实经过本机反向代理转发)时才采信代理头,
+// 防止服务直接暴露时攻击者伪造 X-Forwarded-For 绕过 IP 封禁与失败锁定。
func ClientIP(r *http.Request) string {
- if v := r.Header.Get("X-Forwarded-For"); v != "" {
- return strings.TrimSpace(strings.Split(v, ",")[0])
- }
- if v := r.Header.Get("X-Real-IP"); v != "" {
- return v
+ remote := remoteIP(r)
+ if isTrustedProxy(remote) {
+ if v := r.Header.Get("X-Forwarded-For"); v != "" {
+ return strings.TrimSpace(strings.Split(v, ",")[0])
+ }
+ if v := r.Header.Get("X-Real-IP"); v != "" {
+ return strings.TrimSpace(v)
+ }
}
+ return remote
+}
+
+// remoteIP 取 TCP 连接的直连对端地址(去掉端口)
+func remoteIP(r *http.Request) string {
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
+ // 去掉 IPv6 zone/方括号
+ host = strings.Trim(host, "[]")
return host
}
+// isTrustedProxy 判断直连对端是否为可信代理(回环或 RFC 私有/链路本地地址)
+func isTrustedProxy(ip string) bool {
+ parsed := net.ParseIP(ip)
+ if parsed == nil {
+ return false
+ }
+ return parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast()
+}
+
// FingerprintOf 获取请求携带的浏览器指纹(前端 JS 计算后随请求提交)
func FingerprintOf(r *http.Request) string {
fp := r.Header.Get("X-Fingerprint")
diff --git a/plugins/embassy/admin.html b/plugins/embassy/admin.html
new file mode 100644
index 0000000..52e446d
--- /dev/null
+++ b/plugins/embassy/admin.html
@@ -0,0 +1,154 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>友校大使馆 · 后台管理</title>
+<style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+ background: #f5f6fa; color: #23262f; font-size: 14px; line-height: 1.6;
+ padding: 28px 20px 60px;
+ }
+ .wrap { max-width: 980px; margin: 0 auto; }
+ .head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
+ .head .logo {
+ width: 40px; height: 40px; border-radius: 12px; flex: none;
+ background: linear-gradient(135deg, #ff7a9c, #ff5c8a);
+ display: flex; align-items: center; justify-content: center; color: #fff;
+ }
+ h1 { font-size: 20px; font-weight: 700; }
+ .sub { color: #8a8f9c; font-size: 13px; margin-bottom: 18px; }
+ .card {
+ background: #fff; border-radius: 14px; padding: 18px 20px; margin-bottom: 16px;
+ box-shadow: 0 1px 3px rgba(20, 24, 40, .06), 0 8px 24px -18px rgba(20, 24, 40, .12);
+ }
+ .card h3 { font-size: 15px; font-weight: 700; margin-bottom: 12px; }
+ .tip {
+ background: #fff7f9; border: 1px solid #ffe1e9; color: #8a5a68;
+ border-radius: 12px; padding: 12px 16px; font-size: 13px; line-height: 1.8; margin-bottom: 16px;
+ }
+ .tip b { color: #ff5c8a; }
+ form.add { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
+ form.add label { flex: 1 1 160px; min-width: 150px; font-size: 12.5px; color: #5a5f6d; font-weight: 600; }
+ form.add input {
+ width: 100%; margin-top: 5px; padding: 10px 12px; font-size: 14px; font-family: inherit;
+ border: 1px solid #e3e6ee; border-radius: 10px; background: #fbfbfd; color: inherit; outline: none;
+ transition: border-color .15s, box-shadow .15s;
+ }
+ form.add input:focus { border-color: #ff7a9c; box-shadow: 0 0 0 3px rgba(255, 122, 156, .15); background: #fff; }
+ .btn {
+ border: 0; cursor: pointer; font-family: inherit; font-weight: 600; font-size: 14px;
+ padding: 10px 22px; border-radius: 10px; color: #fff;
+ background: linear-gradient(135deg, #ff7a9c, #ff5c8a);
+ box-shadow: 0 4px 14px -4px rgba(255, 92, 138, .5);
+ transition: transform .1s, box-shadow .15s;
+ }
+ .btn:hover { transform: translateY(-1px); box-shadow: 0 6px 18px -4px rgba(255, 92, 138, .55); }
+ .btn:active { transform: translateY(0); }
+ .btn-del {
+ border: 0; background: none; cursor: pointer; font-family: inherit; font-size: 13px;
+ color: #e5484d; padding: 2px 6px; border-radius: 6px;
+ }
+ .btn-del:hover { background: #fdecec; }
+ .table-scroll { overflow-x: auto; }
+ table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
+ th {
+ text-align: left; font-size: 12px; color: #8a8f9c; font-weight: 600;
+ padding: 8px 10px; border-bottom: 1px solid #eef0f4; white-space: nowrap;
+ }
+ td { padding: 10px; border-bottom: 1px solid #f3f4f8; }
+ tr:last-child td { border-bottom: 0; }
+ tbody tr:hover { background: #fafafc; }
+ .nick { font-weight: 700; }
+ .muted { color: #9aa0ae; }
+ .empty { text-align: center; color: #9aa0ae; padding: 22px 10px !important; }
+ .badge {
+ display: inline-block; font-size: 12px; font-weight: 600; color: #ff5c8a;
+ background: #fff0f4; border: 1px solid #ffdbe5; border-radius: 999px; padding: 1px 10px;
+ }
+ a { color: #ff5c8a; text-decoration: none; }
+ a:hover { text-decoration: underline; }
+ .count { font-weight: 400; color: #9aa0ae; font-size: 13px; }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <div class="head">
+ <div class="logo">
+ <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 21v-6h6v6"/><path d="M12 3v4"/></svg>
+ </div>
+ <h1>友校大使馆</h1>
+ </div>
+ <p class="sub">登记各友校大使账号,发帖人使用大使专属昵称时将弹窗验证身份,通过后帖子自动携带官方认证标识。</p>
+
+ <div class="tip">
+ 在此登记各友校大使的账号信息。发帖人使用「<b>大使专属昵称</b>」发布时,前台会弹窗要求验证身份;
+ 验证通过后在有效期内(后台「插件设置」可调)以该昵称发帖,帖子将自动携带
+ 「<b>xx学校大使官方认证</b>」标识。未通过验证的仿冒昵称不会获得任何标识。
+ </div>
+
+ <div class="card">
+ <h3>登记友校大使</h3>
+ <form class="add" method="post" action="/x/embassy/admin/add">
+ <input type="hidden" name="_csrf" value="{{.csrf}}">
+ <label>学校<input name="school" required placeholder="如:邻校一中"></label>
+ <label>账号<input name="account" required autocomplete="off" placeholder="大使登录账号"></label>
+ <label>密码<input name="password" required minlength="4" autocomplete="new-password" placeholder="至少 4 位"></label>
+ <label>大使专属昵称<input name="nickname" required placeholder="发帖时使用的昵称"></label>
+ <button class="btn" type="submit">添加</button>
+ </form>
+ </div>
+
+ <div class="card">
+ <h3>已登记大使 <span class="count">({{.ambs | len}})</span></h3>
+ <div class="table-scroll">
+ <table>
+ <thead><tr><th>学校</th><th>账号</th><th>大使专属昵称</th><th>登记时间</th><th>操作</th></tr></thead>
+ <tbody>
+ {{range .ambs}}
+ <tr>
+ <td>{{.school}}</td>
+ <td>{{.account}}</td>
+ <td class="nick">{{.nickname}}</td>
+ <td class="muted">{{.created_at}}</td>
+ <td>
+ <form method="post" action="/x/embassy/admin/delete" style="display:inline" onsubmit="return confirm('确定删除该大使?删除后该昵称不再需要验证。')">
+ <input type="hidden" name="_csrf" value="{{$.csrf}}">
+ <input type="hidden" name="id" value="{{.id}}">
+ <button class="btn-del" type="submit">删除</button>
+ </form>
+ </td>
+ </tr>
+ {{else}}
+ <tr><td colspan="5" class="empty">暂无登记,使用上方表单添加第一位友校大使</td></tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ <div class="card">
+ <h3>最近认证的帖子 <span class="count">({{.certs | len}})</span></h3>
+ <div class="table-scroll">
+ <table>
+ <thead><tr><th>帖子</th><th>学校</th><th>大使昵称</th><th>认证时间</th></tr></thead>
+ <tbody>
+ {{range .certs}}
+ <tr>
+ <td><a href="/post/{{.post_id}}" target="_blank">#{{.post_id}}</a></td>
+ <td><span class="badge">{{.school}} · 大使官方认证</span></td>
+ <td>{{.nickname}}</td>
+ <td class="muted">{{.created_at}}</td>
+ </tr>
+ {{else}}
+ <tr><td colspan="4" class="empty">暂无认证记录</td></tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+ </div>
+</div>
+</body>
+</html>
diff --git a/plugins/embassy/main.js b/plugins/embassy/main.js
new file mode 100644
index 0000000..d3670fa
--- /dev/null
+++ b/plugins/embassy/main.js
@@ -0,0 +1,318 @@
+// 友校大使馆 —— 应用型插件(kind=app)
+//
+// 功能:
+// 1. 管理员在后台登记各友校大使(学校 / 账号 / 密码 / 大使专属昵称)
+// 2. 发帖人使用大使专属昵称时,前台弹窗要求验证身份(账号 + 密码)
+// 3. 验证通过后在有效期内以该昵称发帖,帖子自动带上「xx学校大使官方认证」标识
+//
+// 实现要点:
+// - clv.table 三张表:ambassador(大使)、verify(验证记录)、cert(帖子认证)
+// - clv.route 前台 lookup/status/verify API + 后台增删
+// - clv.on 订阅 post_created 事件,验证有效则落认证记录
+// - clv.filter filter.card 给卡片追加认证徽章(列表 / 详情 / API 通用)
+// - clv.slot body.end 注入前台拦截与弹窗脚本
+//
+// 安全说明:未通过验证的仿冒昵称可以发帖,但不会有任何认证标识;
+// 认证标识仅在验证有效期内由 post_created 事件落库,伪造不了。
+
+var T_AMB = clv.db.table("ambassador"); // pl_embassy_ambassador
+var T_VERIFY = clv.db.table("verify"); // pl_embassy_verify
+var T_CERT = clv.db.table("cert"); // pl_embassy_cert
+
+function setup() {
+ // 1) 建表(幂等)
+ clv.table("ambassador", {
+ school: "string",
+ account: "string",
+ password: "string", // bcrypt 哈希
+ nickname: "string",
+ created_at: "time"
+ }, { unique: [["nickname"]], indexes: [["account"]] });
+
+ clv.table("verify", {
+ ambassador_id: "int",
+ fingerprint: "string",
+ ip: "string",
+ created_at: "time"
+ }, { indexes: [["fingerprint"], ["ambassador_id"]] });
+
+ clv.table("cert", {
+ post_id: "int",
+ ambassador_id: "int",
+ school: "string",
+ nickname: "string",
+ created_at: "time"
+ }, { unique: [["post_id"]], indexes: [["post_id"]] });
+
+ // 2) 前台 API(/x/embassy/api/...)
+ clv.route("GET", "/api/lookup", "apiLookup", { auth: "none", json: true });
+ clv.route("GET", "/api/status", "apiStatus", { auth: "none", json: true });
+ clv.route("POST", "/api/verify", "apiVerify", { auth: "none", json: true });
+
+ // 3) 后台管理(/admin/plugins/embassy/)
+ clv.adminPage("/", "adminMain", { perm: "plugins" });
+ clv.adminMenu({ label: "友校大使馆", path: "/", perm: "plugins", order: 55 });
+ clv.route("POST", "/admin/add", "adminAdd", { auth: "admin", json: true });
+ clv.route("POST", "/admin/delete", "adminDelete", { auth: "admin", json: true });
+
+ // 4) 前台:发帖拦截 + 身份验证弹窗
+ clv.slot("body.end", "clientScript");
+
+ // 5) 认证落库 + 徽章展示
+ clv.on("post_created", "onPostCreated");
+ clv.filter("filter.card", 50, "cardBadge");
+}
+
+/* ---------------- 工具 ---------------- */
+
+function validMS() {
+ var m = Number(clv.plugin.config("valid_minutes"));
+ if (!m || m <= 0) m = 10;
+ return m * 60000;
+}
+
+function nowISO() { return new Date().toISOString(); }
+
+function byNickname(nick) {
+ return clv.db.get("SELECT id, school, account, password, nickname FROM " + T_AMB + " WHERE nickname=?", nick);
+}
+
+// 某指纹对某大使是否存在有效期内的验证记录
+function hasValidVerify(ambID, fingerprint) {
+ if (!ambID) return false;
+ if (fingerprint) {
+ var r = clv.db.get(
+ "SELECT id FROM " + T_VERIFY + " WHERE ambassador_id=? AND fingerprint=? AND created_at>=?",
+ ambID, fingerprint, new Date(Date.now() - validMS()).toISOString());
+ if (r) return true;
+ }
+ return false;
+}
+
+/* ---------------- 前台 API ---------------- */
+
+// GET /api/lookup?nickname=xxx → 该昵称是否为大使专属昵称
+function apiLookup(ctx) {
+ var nick = String(ctx.req.query.nickname || "").trim();
+ if (!nick) return { json: { ok: true, protected: false } };
+ var amb = byNickname(nick);
+ return { json: { ok: true, protected: !!amb, school: amb ? amb.school : "" } };
+}
+
+// GET /api/status?nickname=xxx → 当前浏览器指纹是否已验证且仍在有效期
+function apiStatus(ctx) {
+ var nick = String(ctx.req.query.nickname || "").trim();
+ var amb = nick ? byNickname(nick) : null;
+ if (!amb) return { json: { ok: true, verified: false } };
+ return { json: { ok: true, verified: hasValidVerify(amb.id, ctx.req.fingerprint) } };
+}
+
+// POST /api/verify {nickname, account, password} → 校验大使账密,成功则记录验证
+function apiVerify(ctx) {
+ var body = ctx.req.json || {};
+ var nick = String(body.nickname || ctx.req.form.nickname || "").trim();
+ var account = String(body.account || ctx.req.form.account || "").trim();
+ var password = String(body.password || ctx.req.form.password || "");
+ if (!nick || !account || !password) {
+ return { status: 400, json: { ok: false, msg: "请填写账号和密码" } };
+ }
+ var amb = byNickname(nick);
+ if (!amb) return { status: 404, json: { ok: false, msg: "该昵称未登记大使身份" } };
+ if (account !== amb.account || !clv.crypto.verify(amb.password, password)) {
+ clv.log.info("大使验证失败:nickname=" + nick + " account=" + account + " ip=" + ctx.req.ip);
+ return { status: 403, json: { ok: false, msg: "账号或密码错误,验证失败" } };
+ }
+ var fp = ctx.req.fingerprint;
+ // 同一指纹重复验证时清理旧记录,避免堆积
+ clv.db.exec("DELETE FROM " + T_VERIFY + " WHERE ambassador_id=? AND fingerprint=?", amb.id, fp);
+ clv.db.exec("INSERT INTO " + T_VERIFY + "(ambassador_id,fingerprint,ip,created_at) VALUES(?,?,?,?)",
+ amb.id, fp, ctx.req.ip, nowISO());
+ clv.log.info("大使验证通过:" + amb.school + " / " + nick + " ip=" + ctx.req.ip);
+ return { json: { ok: true, school: amb.school } };
+}
+
+/* ---------------- 事件与过滤器 ---------------- */
+
+// post_created:昵称为大使昵称且验证仍有效 → 落认证记录(帖子获得徽章)
+function onPostCreated(payload) {
+ if (!payload) return;
+ var nick = String(payload.nickname || "").trim();
+ var postID = Number(payload.post_id || 0);
+ if (!nick || !postID) return;
+ var amb = byNickname(nick);
+ if (!amb) return;
+ // 验证与发帖之间没有指纹可比(事件不含指纹),以时间窗口为准
+ var v = clv.db.get("SELECT id FROM " + T_VERIFY + " WHERE ambassador_id=? AND created_at>=?",
+ amb.id, new Date(Date.now() - validMS()).toISOString());
+ if (!v) return;
+ if (clv.db.get("SELECT id FROM " + T_CERT + " WHERE post_id=?", postID)) return;
+ clv.db.exec("INSERT INTO " + T_CERT + "(post_id,ambassador_id,school,nickname,created_at) VALUES(?,?,?,?,?)",
+ postID, amb.id, amb.school, amb.nickname, nowISO());
+ clv.log.info("帖子 #" + postID + " 获得「" + amb.school + "大使官方认证」");
+}
+
+// filter.card:列表 / 详情 / API 卡片统一追加认证徽章
+function cardBadge(card) {
+ if (!card || !card.id) return card;
+ var cert = clv.db.get("SELECT school FROM " + T_CERT + " WHERE post_id=?", card.id);
+ if (!cert) return card;
+ var badges = card.badges;
+ if (!badges || typeof badges.length !== "number") badges = [];
+ var label = cert.school + "大使官方认证";
+ for (var i = 0; i < badges.length; i++) {
+ if (badges[i] === label) return card;
+ }
+ badges.push(label);
+ card.badges = badges;
+ return card;
+}
+
+/* ---------------- 后台管理 ---------------- */
+
+function adminMain(ctx) {
+ var ambs = clv.db.query(
+ "SELECT id, school, account, nickname, created_at FROM " + T_AMB + " ORDER BY id DESC LIMIT 200");
+ var certs = clv.db.query(
+ "SELECT post_id, school, nickname, created_at FROM " + T_CERT + " ORDER BY id DESC LIMIT 20");
+ return { template: "admin.html", data: { ambs: ambs, certs: certs, csrf: ctx.csrf } };
+}
+
+function adminAdd(ctx) {
+ var f = ctx.req.form;
+ var school = String(f.school || "").trim();
+ var account = String(f.account || "").trim();
+ var password = String(f.password || "");
+ var nickname = String(f.nickname || "").trim();
+ if (!school || !account || !password || !nickname) {
+ return { status: 400, body: "学校、账号、密码、昵称均不能为空" };
+ }
+ if (password.length < 4) return { status: 400, body: "密码至少 4 位" };
+ if (byNickname(nickname)) return { status: 400, body: "昵称「" + nickname + "」已被登记" };
+ var dup = clv.db.get("SELECT id FROM " + T_AMB + " WHERE account=?", account);
+ if (dup) return { status: 400, body: "账号「" + account + "」已存在" };
+ clv.db.exec("INSERT INTO " + T_AMB + "(school,account,password,nickname,created_at) VALUES(?,?,?,?,?)",
+ school, account, clv.crypto.bcrypt(password), nickname, nowISO());
+ clv.log.info("登记友校大使:" + school + " / " + nickname);
+ return { redirect: "/admin/plugins/embassy/" };
+}
+
+function adminDelete(ctx) {
+ var id = Number(ctx.req.form.id || 0);
+ if (id > 0) {
+ clv.db.exec("DELETE FROM " + T_AMB + " WHERE id=?", id);
+ clv.db.exec("DELETE FROM " + T_VERIFY + " WHERE ambassador_id=?", id);
+ }
+ return { redirect: "/admin/plugins/embassy/" };
+}
+
+/* ---------------- 前台脚本注入 ---------------- */
+
+function clientScript() {
+ return '<style>' +
+ '#embassy-mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(15,17,24,.55);z-index:99998;display:none;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}' +
+ '#embassy-box{width:100%;max-width:400px;background:var(--clv-card,#fff);color:var(--clv-text,#23262f);border-radius:var(--clv-radius,16px);box-shadow:0 18px 50px rgba(0,0,0,.28);padding:22px 20px;font-family:var(--clv-font,sans-serif);box-sizing:border-box}' +
+ '#embassy-box h3{margin:0 0 6px;font-size:17px;display:flex;align-items:center;gap:8px}' +
+ '#embassy-box .emb-sub{margin:0 0 14px;font-size:13px;line-height:1.6;color:var(--clv-muted,#8a8f9c)}' +
+ '#embassy-box .emb-school{color:var(--clv-accent,#ff7a9c);font-weight:600}' +
+ '#embassy-box input{width:100%;box-sizing:border-box;padding:11px 12px;margin-bottom:10px;border:1px solid var(--clv-border,#eceef4);border-radius:10px;font-size:14px;font-family:inherit;background:var(--clv-bg,#fff);color:inherit}' +
+ '#embassy-box input:focus{outline:none;border-color:var(--clv-accent,#ff7a9c)}' +
+ '#embassy-err{min-height:18px;font-size:12.5px;color:#e5484d;margin:0 0 8px}' +
+ '#embassy-box .emb-acts{display:flex;gap:10px}' +
+ '#embassy-box .emb-acts button{flex:1;padding:11px 14px;border-radius:10px;font-size:15px;font-family:inherit;border:1px solid var(--clv-border,#eceef4);background:transparent;color:inherit;cursor:pointer}' +
+ '#embassy-box #embassy-ok{flex:1.4;border:0;background:var(--clv-accent,#ff7a9c);color:#fff;font-weight:600}' +
+ '#embassy-box #embassy-ok:disabled{opacity:.5;cursor:not-allowed}' +
+ '</style>' +
+ '<div id="embassy-mask" role="dialog" aria-modal="true"><div id="embassy-box">' +
+ '<h3><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M9 12l2 2 4-4"/></svg>大使身份验证</h3>' +
+ '<p class="emb-sub">「<span id="embassy-nick"></span>」是 <span class="emb-school" id="embassy-school"></span> 大使专属昵称,请输入大使账号与密码完成验证。</p>' +
+ '<input id="embassy-account" type="text" autocomplete="off" placeholder="大使账号">' +
+ '<input id="embassy-password" type="password" placeholder="大使密码">' +
+ '<p id="embassy-err"></p>' +
+ '<div class="emb-acts"><button type="button" id="embassy-no">取消</button><button type="button" id="embassy-ok">验证并发布</button></div>' +
+ '</div></div>' +
+ '<scr' + 'ipt>(function () {' +
+ 'var mask, nick, school, acc, pwd, err, okBtn, curForm = null;' +
+ 'function q(id) { return document.getElementById(id); }' +
+ 'function csrf() {' +
+ // clv_csrf Cookie 为 HttpOnly,JS 不可读;改从服务端渲染的表单隐藏字段取令牌
+ ' var f = document.getElementById("compose-form");' +
+ ' var i = f ? f.querySelector("[name=_csrf]") : null;' +
+ ' if (!i || !i.value) { i = document.querySelector("form input[name=_csrf]"); }' +
+ ' return i ? i.value : "";' +
+ '}' +
+ 'function fp() {' +
+ // 服务端指纹取自 X-Fingerprint 头;优先复用 app.js 的 CLV.fingerprint(),不可用则本地缓存一个稳定值
+ ' try { if (window.CLV && typeof CLV.fingerprint === "function") { return String(CLV.fingerprint() || ""); } } catch (e) {}' +
+ ' try {' +
+ ' var v = localStorage.getItem("clv_embassy_fp");' +
+ ' if (!v) { v = "emb-" + Math.random().toString(36).slice(2) + Date.now().toString(36); localStorage.setItem("clv_embassy_fp", v); }' +
+ ' return v;' +
+ ' } catch (e2) { return "emb-fallback"; }' +
+ '}' +
+ 'function ajax(method, url, data, cb) {' +
+ ' var x = new XMLHttpRequest();' +
+ ' x.open(method, url, true);' +
+ ' x.setRequestHeader("X-Fingerprint", fp());' +
+ ' x.setRequestHeader("X-Requested-With", "XMLHttpRequest");' +
+ ' if (data) {' +
+ ' x.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");' +
+ ' x.setRequestHeader("X-CSRF-Token", csrf());' +
+ ' }' +
+ ' x.onload = function () { var d = null; try { d = JSON.parse(x.responseText); } catch (e) {} cb(d, x.status); };' +
+ ' x.onerror = function () { cb(null, 0); };' +
+ ' x.send(data || null);' +
+ '}' +
+ 'function submitNow(f) { f.__embassyOk = 1; if (typeof f.requestSubmit === "function") { f.requestSubmit(); } else { f.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); } }' +
+ 'function close() { if (mask) { mask.style.display = "none"; } document.body.style.overflow = ""; curForm = null; }' +
+ 'function show(n, sch, f) {' +
+ ' curForm = f; q("embassy-nick").textContent = n; q("embassy-school").textContent = sch;' +
+ ' q("embassy-account").value = ""; q("embassy-password").value = "";' +
+ ' q("embassy-err").textContent = ""; q("embassy-ok").disabled = false;' +
+ ' mask.style.display = "flex"; document.body.style.overflow = "hidden";' +
+ ' setTimeout(function () { q("embassy-account").focus(); }, 60);' +
+ '}' +
+ 'function verify() {' +
+ ' var a = q("embassy-account").value.trim(), p = q("embassy-password").value;' +
+ ' if (!a || !p) { q("embassy-err").textContent = "请填写账号和密码"; return; }' +
+ ' okBtn.disabled = true; q("embassy-err").textContent = "验证中…";' +
+ ' var body = "nickname=" + encodeURIComponent(q("embassy-nick").textContent) +' +
+ ' "&account=" + encodeURIComponent(a) + "&password=" + encodeURIComponent(p) +' +
+ ' "&_csrf=" + encodeURIComponent(csrf());' +
+ ' ajax("POST", "/x/embassy/api/verify", body, function (d, status) {' +
+ ' if (d && d.ok) { close(); if (curForm) { submitNow(curForm); } return; }' +
+ ' okBtn.disabled = false;' +
+ ' q("embassy-err").textContent = (d && d.msg) ? d.msg : "验证失败,请稍后再试";' +
+ ' });' +
+ '}' +
+ 'function check(n, f) {' +
+ ' ajax("GET", "/x/embassy/api/lookup?nickname=" + encodeURIComponent(n), null, function (d) {' +
+ ' if (!d || !d.ok || !d.protected) { submitNow(f); return; }' +
+ ' ajax("GET", "/x/embassy/api/status?nickname=" + encodeURIComponent(n), null, function (s) {' +
+ ' if (s && s.ok && s.verified) { submitNow(f); return; }' +
+ ' show(n, d.school || "友校", f);' +
+ ' });' +
+ ' });' +
+ '}' +
+ 'document.addEventListener("submit", function (ev) {' +
+ ' var f = ev.target;' +
+ ' if (!f || f.id !== "compose-form") { return; }' +
+ ' if (f.__embassyOk) { f.__embassyOk = 0; return; }' +
+ ' var input = f.querySelector("[name=nickname]");' +
+ ' var n = input ? String(input.value || "").trim() : "";' +
+ ' if (!n) { return; }' +
+ ' ev.preventDefault(); ev.stopImmediatePropagation();' +
+ ' check(n, f);' +
+ '}, true);' +
+ 'document.addEventListener("DOMContentLoaded", function () {' +
+ ' mask = q("embassy-mask");' +
+ ' okBtn = q("embassy-ok");' +
+ ' q("embassy-no").addEventListener("click", close);' +
+ ' okBtn.addEventListener("click", verify);' +
+ ' q("embassy-password").addEventListener("keydown", function (e) { if (e.key === "Enter") { verify(); } });' +
+ ' mask.addEventListener("click", function (e) { if (e.target === mask) { close(); } });' +
+ ' document.addEventListener("keydown", function (e) {' +
+ ' if (e.key === "Escape" && mask && mask.style.display !== "none") { close(); }' +
+ ' });' +
+ '});' +
+ '})();</scr' + 'ipt>';
+}
diff --git a/plugins/embassy/plugin.json b/plugins/embassy/plugin.json
new file mode 100644
index 0000000..39dba4a
--- /dev/null
+++ b/plugins/embassy/plugin.json
@@ -0,0 +1,24 @@
+{
+ "kind": "app",
+ "name": "友校大使馆",
+ "slug": "embassy",
+ "version": "1.0.0",
+ "author": "ClearLove",
+ "description": "管理员在后台登记各友校大使账号;发帖人使用大使专属昵称时弹窗验证身份,验证通过后帖子自动带上「xx学校大使官方认证」标识",
+ "requires": "2.1.0",
+ "runtime": {
+ "engine": "js",
+ "entry": "main.js",
+ "timeout_ms": 3000
+ },
+ "permissions": ["db"],
+ "config": [
+ {
+ "key": "valid_minutes",
+ "label": "验证有效期(分钟)",
+ "type": "number",
+ "default": "10",
+ "help": "大使验证通过后,在该时长内以大使昵称发帖会自动携带官方认证标识"
+ }
+ ]
+}