clearlove2.1
1// 友校大使馆 —— 应用型插件(kind=app)
2//
3// 功能:
4// 1. 管理员在后台登记各友校大使(学校 / 账号 / 密码 / 大使专属昵称)
5// 2. 发帖人使用大使专属昵称时,前台弹窗要求验证身份(账号 + 密码)
6// 3. 验证通过后在有效期内以该昵称发帖,帖子自动带上「xx学校大使官方认证」标识
7//
8// 实现要点:
9// - clv.table 三张表:ambassador(大使)、verify(验证记录)、cert(帖子认证)
10// - clv.route 前台 lookup/status/verify API + 后台增删
11// - clv.on 订阅 post_created 事件,验证有效则落认证记录
12// - clv.filter filter.card 给卡片追加认证徽章(列表 / 详情 / API 通用)
13// - clv.slot body.end 注入前台拦截与弹窗脚本
14//
15// 安全说明:未通过验证的仿冒昵称可以发帖,但不会有任何认证标识;
16// 认证标识仅在验证有效期内由 post_created 事件落库,伪造不了。
17
18var T_AMB = clv.db.table("ambassador"); // pl_embassy_ambassador
19var T_VERIFY = clv.db.table("verify"); // pl_embassy_verify
20var T_CERT = clv.db.table("cert"); // pl_embassy_cert
21
22function setup() {
23 // 1) 建表(幂等)
24 clv.table("ambassador", {
25 school: "string",
26 account: "string",
27 password: "string", // bcrypt 哈希
28 nickname: "string",
29 created_at: "time"
30 }, { unique: [["nickname"]], indexes: [["account"]] });
31
32 clv.table("verify", {
33 ambassador_id: "int",
34 fingerprint: "string",
35 ip: "string",
36 created_at: "time"
37 }, { indexes: [["fingerprint"], ["ambassador_id"]] });
38
39 clv.table("cert", {
40 post_id: "int",
41 ambassador_id: "int",
42 school: "string",
43 nickname: "string",
44 created_at: "time"
45 }, { unique: [["post_id"]], indexes: [["post_id"]] });
46
47 // 2) 前台 API(/x/embassy/api/...)
48 clv.route("GET", "/api/lookup", "apiLookup", { auth: "none", json: true });
49 clv.route("GET", "/api/status", "apiStatus", { auth: "none", json: true });
50 clv.route("POST", "/api/verify", "apiVerify", { auth: "none", json: true });
51
52 // 3) 后台管理(/admin/plugins/embassy/)
53 clv.adminPage("/", "adminMain", { perm: "plugins" });
54 clv.adminMenu({ label: "友校大使馆", path: "/", perm: "plugins", order: 55 });
55 clv.route("POST", "/admin/add", "adminAdd", { auth: "admin", json: true });
56 clv.route("POST", "/admin/delete", "adminDelete", { auth: "admin", json: true });
57
58 // 4) 前台:发帖拦截 + 身份验证弹窗
59 clv.slot("body.end", "clientScript");
60
61 // 5) 认证落库 + 徽章展示
62 clv.on("post_created", "onPostCreated");
63 clv.filter("filter.card", 50, "cardBadge");
64}
65
66/* ---------------- 工具 ---------------- */
67
68function validMS() {
69 var m = Number(clv.plugin.config("valid_minutes"));
70 if (!m || m <= 0) m = 10;
71 return m * 60000;
72}
73
74function nowISO() { return new Date().toISOString(); }
75
76function byNickname(nick) {
77 return clv.db.get("SELECT id, school, account, password, nickname FROM " + T_AMB + " WHERE nickname=?", nick);
78}
79
80// 某指纹对某大使是否存在有效期内的验证记录
81function hasValidVerify(ambID, fingerprint) {
82 if (!ambID) return false;
83 if (fingerprint) {
84 var r = clv.db.get(
85 "SELECT id FROM " + T_VERIFY + " WHERE ambassador_id=? AND fingerprint=? AND created_at>=?",
86 ambID, fingerprint, new Date(Date.now() - validMS()).toISOString());
87 if (r) return true;
88 }
89 return false;
90}
91
92/* ---------------- 前台 API ---------------- */
93
94// GET /api/lookup?nickname=xxx → 该昵称是否为大使专属昵称
95function apiLookup(ctx) {
96 var nick = String(ctx.req.query.nickname || "").trim();
97 if (!nick) return { json: { ok: true, protected: false } };
98 var amb = byNickname(nick);
99 return { json: { ok: true, protected: !!amb, school: amb ? amb.school : "" } };
100}
101
102// GET /api/status?nickname=xxx → 当前浏览器指纹是否已验证且仍在有效期
103function apiStatus(ctx) {
104 var nick = String(ctx.req.query.nickname || "").trim();
105 var amb = nick ? byNickname(nick) : null;
106 if (!amb) return { json: { ok: true, verified: false } };
107 return { json: { ok: true, verified: hasValidVerify(amb.id, ctx.req.fingerprint) } };
108}
109
110// POST /api/verify {nickname, account, password} → 校验大使账密,成功则记录验证
111function apiVerify(ctx) {
112 var body = ctx.req.json || {};
113 var nick = String(body.nickname || ctx.req.form.nickname || "").trim();
114 var account = String(body.account || ctx.req.form.account || "").trim();
115 var password = String(body.password || ctx.req.form.password || "");
116 if (!nick || !account || !password) {
117 return { status: 400, json: { ok: false, msg: "请填写账号和密码" } };
118 }
119 var amb = byNickname(nick);
120 if (!amb) return { status: 404, json: { ok: false, msg: "该昵称未登记大使身份" } };
121 if (account !== amb.account || !clv.crypto.verify(amb.password, password)) {
122 clv.log.info("大使验证失败:nickname=" + nick + " account=" + account + " ip=" + ctx.req.ip);
123 return { status: 403, json: { ok: false, msg: "账号或密码错误,验证失败" } };
124 }
125 var fp = ctx.req.fingerprint;
126 // 同一指纹重复验证时清理旧记录,避免堆积
127 clv.db.exec("DELETE FROM " + T_VERIFY + " WHERE ambassador_id=? AND fingerprint=?", amb.id, fp);
128 clv.db.exec("INSERT INTO " + T_VERIFY + "(ambassador_id,fingerprint,ip,created_at) VALUES(?,?,?,?)",
129 amb.id, fp, ctx.req.ip, nowISO());
130 clv.log.info("大使验证通过:" + amb.school + " / " + nick + " ip=" + ctx.req.ip);
131 return { json: { ok: true, school: amb.school } };
132}
133
134/* ---------------- 事件与过滤器 ---------------- */
135
136// post_created:昵称为大使昵称且验证仍有效 → 落认证记录(帖子获得徽章)
137function onPostCreated(payload) {
138 if (!payload) return;
139 var nick = String(payload.nickname || "").trim();
140 var postID = Number(payload.post_id || 0);
141 if (!nick || !postID) return;
142 var amb = byNickname(nick);
143 if (!amb) return;
144 // 验证与发帖之间没有指纹可比(事件不含指纹),以时间窗口为准
145 var v = clv.db.get("SELECT id FROM " + T_VERIFY + " WHERE ambassador_id=? AND created_at>=?",
146 amb.id, new Date(Date.now() - validMS()).toISOString());
147 if (!v) return;
148 if (clv.db.get("SELECT id FROM " + T_CERT + " WHERE post_id=?", postID)) return;
149 clv.db.exec("INSERT INTO " + T_CERT + "(post_id,ambassador_id,school,nickname,created_at) VALUES(?,?,?,?,?)",
150 postID, amb.id, amb.school, amb.nickname, nowISO());
151 clv.log.info("帖子 #" + postID + " 获得「" + amb.school + "大使官方认证」");
152}
153
154// filter.card:列表 / 详情 / API 卡片统一追加认证徽章
155function cardBadge(card) {
156 if (!card || !card.id) return card;
157 var cert = clv.db.get("SELECT school FROM " + T_CERT + " WHERE post_id=?", card.id);
158 if (!cert) return card;
159 var badges = card.badges;
160 if (!badges || typeof badges.length !== "number") badges = [];
161 var label = cert.school + "大使官方认证";
162 for (var i = 0; i < badges.length; i++) {
163 if (badges[i] === label) return card;
164 }
165 badges.push(label);
166 card.badges = badges;
167 return card;
168}
169
170/* ---------------- 后台管理 ---------------- */
171
172function adminMain(ctx) {
173 var ambs = clv.db.query(
174 "SELECT id, school, account, nickname, created_at FROM " + T_AMB + " ORDER BY id DESC LIMIT 200");
175 var certs = clv.db.query(
176 "SELECT post_id, school, nickname, created_at FROM " + T_CERT + " ORDER BY id DESC LIMIT 20");
177 return { template: "admin.html", data: { ambs: ambs, certs: certs, csrf: ctx.csrf } };
178}
179
180function adminAdd(ctx) {
181 var f = ctx.req.form;
182 var school = String(f.school || "").trim();
183 var account = String(f.account || "").trim();
184 var password = String(f.password || "");
185 var nickname = String(f.nickname || "").trim();
186 if (!school || !account || !password || !nickname) {
187 return { status: 400, body: "学校、账号、密码、昵称均不能为空" };
188 }
189 if (password.length < 4) return { status: 400, body: "密码至少 4 位" };
190 if (byNickname(nickname)) return { status: 400, body: "昵称「" + nickname + "」已被登记" };
191 var dup = clv.db.get("SELECT id FROM " + T_AMB + " WHERE account=?", account);
192 if (dup) return { status: 400, body: "账号「" + account + "」已存在" };
193 clv.db.exec("INSERT INTO " + T_AMB + "(school,account,password,nickname,created_at) VALUES(?,?,?,?,?)",
194 school, account, clv.crypto.bcrypt(password), nickname, nowISO());
195 clv.log.info("登记友校大使:" + school + " / " + nickname);
196 return { redirect: "/admin/plugins/embassy/" };
197}
198
199function adminDelete(ctx) {
200 var id = Number(ctx.req.form.id || 0);
201 if (id > 0) {
202 clv.db.exec("DELETE FROM " + T_AMB + " WHERE id=?", id);
203 clv.db.exec("DELETE FROM " + T_VERIFY + " WHERE ambassador_id=?", id);
204 }
205 return { redirect: "/admin/plugins/embassy/" };
206}
207
208/* ---------------- 前台脚本注入 ---------------- */
209
210function clientScript() {
211 return '<style>' +
212 '#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}' +
213 '#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}' +
214 '#embassy-box h3{margin:0 0 6px;font-size:17px;display:flex;align-items:center;gap:8px}' +
215 '#embassy-box .emb-sub{margin:0 0 14px;font-size:13px;line-height:1.6;color:var(--clv-muted,#8a8f9c)}' +
216 '#embassy-box .emb-school{color:var(--clv-accent,#ff7a9c);font-weight:600}' +
217 '#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}' +
218 '#embassy-box input:focus{outline:none;border-color:var(--clv-accent,#ff7a9c)}' +
219 '#embassy-err{min-height:18px;font-size:12.5px;color:#e5484d;margin:0 0 8px}' +
220 '#embassy-box .emb-acts{display:flex;gap:10px}' +
221 '#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}' +
222 '#embassy-box #embassy-ok{flex:1.4;border:0;background:var(--clv-accent,#ff7a9c);color:#fff;font-weight:600}' +
223 '#embassy-box #embassy-ok:disabled{opacity:.5;cursor:not-allowed}' +
224 '</style>' +
225 '<div id="embassy-mask" role="dialog" aria-modal="true"><div id="embassy-box">' +
226 '<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>' +
227 '<p class="emb-sub">「<span id="embassy-nick"></span>」是 <span class="emb-school" id="embassy-school"></span> 大使专属昵称,请输入大使账号与密码完成验证。</p>' +
228 '<input id="embassy-account" type="text" autocomplete="off" placeholder="大使账号">' +
229 '<input id="embassy-password" type="password" placeholder="大使密码">' +
230 '<p id="embassy-err"></p>' +
231 '<div class="emb-acts"><button type="button" id="embassy-no">取消</button><button type="button" id="embassy-ok">验证并发布</button></div>' +
232 '</div></div>' +
233 '<scr' + 'ipt>(function () {' +
234 'var mask, nick, school, acc, pwd, err, okBtn, curForm = null;' +
235 'function q(id) { return document.getElementById(id); }' +
236 'function csrf() {' +
237 // clv_csrf Cookie 为 HttpOnly,JS 不可读;改从服务端渲染的表单隐藏字段取令牌
238 ' var f = document.getElementById("compose-form");' +
239 ' var i = f ? f.querySelector("[name=_csrf]") : null;' +
240 ' if (!i || !i.value) { i = document.querySelector("form input[name=_csrf]"); }' +
241 ' return i ? i.value : "";' +
242 '}' +
243 'function fp() {' +
244 // 服务端指纹取自 X-Fingerprint 头;优先复用 app.js 的 CLV.fingerprint(),不可用则本地缓存一个稳定值
245 ' try { if (window.CLV && typeof CLV.fingerprint === "function") { return String(CLV.fingerprint() || ""); } } catch (e) {}' +
246 ' try {' +
247 ' var v = localStorage.getItem("clv_embassy_fp");' +
248 ' if (!v) { v = "emb-" + Math.random().toString(36).slice(2) + Date.now().toString(36); localStorage.setItem("clv_embassy_fp", v); }' +
249 ' return v;' +
250 ' } catch (e2) { return "emb-fallback"; }' +
251 '}' +
252 'function ajax(method, url, data, cb) {' +
253 ' var x = new XMLHttpRequest();' +
254 ' x.open(method, url, true);' +
255 ' x.setRequestHeader("X-Fingerprint", fp());' +
256 ' x.setRequestHeader("X-Requested-With", "XMLHttpRequest");' +
257 ' if (data) {' +
258 ' x.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");' +
259 ' x.setRequestHeader("X-CSRF-Token", csrf());' +
260 ' }' +
261 ' x.onload = function () { var d = null; try { d = JSON.parse(x.responseText); } catch (e) {} cb(d, x.status); };' +
262 ' x.onerror = function () { cb(null, 0); };' +
263 ' x.send(data || null);' +
264 '}' +
265 'function submitNow(f) { f.__embassyOk = 1; if (typeof f.requestSubmit === "function") { f.requestSubmit(); } else { f.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); } }' +
266 'function close() { if (mask) { mask.style.display = "none"; } document.body.style.overflow = ""; curForm = null; }' +
267 'function show(n, sch, f) {' +
268 ' curForm = f; q("embassy-nick").textContent = n; q("embassy-school").textContent = sch;' +
269 ' q("embassy-account").value = ""; q("embassy-password").value = "";' +
270 ' q("embassy-err").textContent = ""; q("embassy-ok").disabled = false;' +
271 ' mask.style.display = "flex"; document.body.style.overflow = "hidden";' +
272 ' setTimeout(function () { q("embassy-account").focus(); }, 60);' +
273 '}' +
274 'function verify() {' +
275 ' var a = q("embassy-account").value.trim(), p = q("embassy-password").value;' +
276 ' if (!a || !p) { q("embassy-err").textContent = "请填写账号和密码"; return; }' +
277 ' okBtn.disabled = true; q("embassy-err").textContent = "验证中…";' +
278 ' var body = "nickname=" + encodeURIComponent(q("embassy-nick").textContent) +' +
279 ' "&account=" + encodeURIComponent(a) + "&password=" + encodeURIComponent(p) +' +
280 ' "&_csrf=" + encodeURIComponent(csrf());' +
281 ' ajax("POST", "/x/embassy/api/verify", body, function (d, status) {' +
282 ' if (d && d.ok) { close(); if (curForm) { submitNow(curForm); } return; }' +
283 ' okBtn.disabled = false;' +
284 ' q("embassy-err").textContent = (d && d.msg) ? d.msg : "验证失败,请稍后再试";' +
285 ' });' +
286 '}' +
287 'function check(n, f) {' +
288 ' ajax("GET", "/x/embassy/api/lookup?nickname=" + encodeURIComponent(n), null, function (d) {' +
289 ' if (!d || !d.ok || !d.protected) { submitNow(f); return; }' +
290 ' ajax("GET", "/x/embassy/api/status?nickname=" + encodeURIComponent(n), null, function (s) {' +
291 ' if (s && s.ok && s.verified) { submitNow(f); return; }' +
292 ' show(n, d.school || "友校", f);' +
293 ' });' +
294 ' });' +
295 '}' +
296 'document.addEventListener("submit", function (ev) {' +
297 ' var f = ev.target;' +
298 ' if (!f || f.id !== "compose-form") { return; }' +
299 ' if (f.__embassyOk) { f.__embassyOk = 0; return; }' +
300 ' var input = f.querySelector("[name=nickname]");' +
301 ' var n = input ? String(input.value || "").trim() : "";' +
302 ' if (!n) { return; }' +
303 ' ev.preventDefault(); ev.stopImmediatePropagation();' +
304 ' check(n, f);' +
305 '}, true);' +
306 'document.addEventListener("DOMContentLoaded", function () {' +
307 ' mask = q("embassy-mask");' +
308 ' okBtn = q("embassy-ok");' +
309 ' q("embassy-no").addEventListener("click", close);' +
310 ' okBtn.addEventListener("click", verify);' +
311 ' q("embassy-password").addEventListener("keydown", function (e) { if (e.key === "Enter") { verify(); } });' +
312 ' mask.addEventListener("click", function (e) { if (e.target === mask) { close(); } });' +
313 ' document.addEventListener("keydown", function (e) {' +
314 ' if (e.key === "Escape" && mask && mask.style.display !== "none") { close(); }' +
315 ' });' +
316 '});' +
317 '})();</scr' + 'ipt>';
318}