chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
@@ -0,0 +1,102 @@
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
<br>Powered by NekoBox | {{ ICP }}<br>{{t .LangCode "site.footer.runtime"}}:<span id="box_runtime"></span>
<br>
<!--<a href="/pixel">画板</a> | -->
<a href="https://box.tama.guru/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
| <a href="https://blog.tama.guru" target="_blank">{{t .LangCode "site.footer.notice"}}</a>
| <a href="https://chat.tama.guru" target="_blank">{{t .LangCode "site.footer.chat"}}</a>
| <a href="https://share.tama.guru" target="_blank">{{t .LangCode "site.footer.drive"}}</a>
| <a href="https://status.tama.guru" target="_blank">{{t .LangCode "site.footer.status"}}</a>
| <a href="https://tama.guru" target="_blank">{{t .LangCode "site.footer.nav"}}</a>
</p>
<script>
// 页脚「提问箱已成功存活」倒计时:原 R2 cdn/time.box.js(1.6KB)内联于此,
// 消除全站每页一个 R2 慢 defer(实测 0.7-1.7s 波动)。相对原脚本两处修复:
// 1) 先判空再写入(原版先赋值后判空,box_runtime 缺失时会抛 ReferenceError 中断脚本);
// 2) 首帧立即填充(原版 DOMContentLoaded + 首个 1s 间隔后才显示,首秒为空)。
(function () {
var create = Math.round(Date.UTC(2023, 6, 1, 18, 15, 43) / 1000); // 提问箱创建时刻(UTC)
function tick() {
var el = document.getElementById("box_runtime");
if (!el) return;
var s = Math.round((Date.now() + 8 * 3600 * 1000) / 1000) - create; // UTC+8 秒级存活
var t = [0, 0, 0, 0, 0];
if (s >= 31536000) { t[0] = s / 31536000 | 0; s %= 31536000; }
if (s >= 86400) { t[1] = s / 86400 | 0; s %= 86400; }
if (s >= 3600) { t[2] = s / 3600 | 0; s %= 3600; }
if (s >= 60) { t[3] = s / 60 | 0; s %= 60; }
if (s > 0) { t[4] = s; }
el.innerHTML = t[0] + '年 ' + t[1] + '天 ' + t[2] + '时 ' + t[3] + '分 ' + t[4] + '秒 ';
}
tick();
setInterval(tick, 1000);
})();
</script>
<br>
</div>
<script>
// 提交反馈(P0):点击任意表单的提交按钮后,立即把触发按钮变成「转圈 + 本地化文字」,
// 并禁用表单内全部按钮,物理阻止在上传/网络慢时的重复点击与重复提交。
// 浏览器前进/后退(bfcache)回到本页时自动复位,避免停留在禁用或转圈状态。
// 纯内联反馈,不使用全屏遮罩。
var SUBMIT_I18N = {
submitting: '{{t .LangCode "common.submitting"}}',
uploading: '{{t .LangCode "common.uploading"}}'
};
function tbResetSubmitButtons() {
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
b.disabled = false;
b.removeAttribute('data-tb-submitting');
var orig = b.getAttribute('data-tb-restore');
if (orig !== null) {
b.innerHTML = orig;
b.removeAttribute('data-tb-restore');
}
});
}
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
// 页面级提交反馈必须尊重前置校验:投票编辑页等 onsubmit 返回 false 时,
// submit 事件仍可能继续到达此监听器;此时绝不能切换到“提交中”状态。
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
form.removeAttribute('data-tb-skip-submit-feedback');
return;
}
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
if (form.getAttribute('target') === '_blank') return;
// 触发提交的按钮(programmatic submit 时 e.submitter 为 null,回退到首个 submit 按钮)
var trigger = e.submitter || form.querySelector('button[type="submit"]');
// 表单含已选中的文件 → 提示“上传中”,否则“提交中”
var hasFile = false;
form.querySelectorAll('input[type="file"]').forEach(function(f) {
if (f.files && f.files.length > 0) hasFile = true;
});
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
if (trigger && trigger.tagName === 'BUTTON') {
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
}
// 禁用表单内所有按钮,防止重复提交
form.querySelectorAll('button').forEach(function(b) {
b.disabled = true;
b.setAttribute('data-tb-submitting', '1');
});
});
// bfcache 还原时复位按钮
window.addEventListener('pageshow', tbResetSubmitButtons);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
{{template "base/header" .}}
<div class="uk-card uk-card-default uk-card-body">
<h1 class="uk-heading-small">{{t .LangCode "site.title"}}</h1>
<p><br>⭐一个无需注册即可提问的匿名提问箱。你不需要下载App,不需要绑定社交账号,甚至连邮箱都不需要是真的。<br>⭐本站在原版的基础上新增了问题页面的二维码、私聊、投票、追加提问等功能以及更多自定义内容。各位用户可自行修改二维码背景图、颜色等个性化配置。<br>⭐可以到站长唯一提问提问箱链接<a href="//box.tama.guru/_/tamakyi">小狼的提问箱</a>尝试相关功能或给我提意见。<br>⭐可以加群:<a href="http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=rRy9g5XY34LIzOITGGmQHjX6BIICFpam&authKey=EjyNjp60KyG8BtKZGN8BXJaf1jl8okkCcUhVF2zAnGPRjhvuSdopnVqOSfSymnGg&noverify=0&group_code=777163254">小狼的群</a>一起水群。<br></p>
<hr><p style="color: red;">近期将国内段访问交由EdgeOne接管,境外访问照旧由CloudFlare提供,经测试国内访问速度有所提升。但因EdgeOne Free套餐不提供CIDR列表,IP放行采取自动抓取手动维护模式,如果出现521错误请刷新一下页面,过几天应该就好了。</p>
<hr><p style="color: red; font-weight: bold;">近期更新:<br>⭐2026.9 增加了提问、投票、私聊的总开关,可以设置定时收集、定量收集或关闭功能,顺便调整了前后端显示,看起来更易用了一些。<br>⭐2026.8 新增了<a href="https://box.tama.guru/vote/sANyLkiBNUGN">投票功能</a>、追加提问、多语言、<a href="https://box.tama.guru/_/tamakyi/2894">Markdown显示</a>、置顶显示等多个功能,详情见<a href="https://blog.tama.guru/record/tamabox_changelog_20260813">这里</a>。<br>⭐2026.8 放弃使用recaptcha等对国内用户非常不友好的验证方式,更新了密码加密方式,修复了图片上传等一系列问题。<br>⭐2026.5 增加了私聊功能,可进行连续性对话,详见<a href="https://blog.tama.guru/record/tamabox-update-20260513">介绍</a>;另有增加提问箱的卡片分享,更直观和美观。<br>⭐2026.2 新增了用户展示和标签功能,如果你希望陌生人给你提问,可以在设置页面给自己贴上5个标签并选择是否展示在探索页面:<a href="https://box.tama.guru/explore">点我访问</a>,详情见<a href="https://blog.tama.guru/record/tamabox-tags">狼的公告板</a><br></p>
<hr><p>一点提醒:<br>⭐本站唯一指定认证站长:<a href="//box.tama.guru/_/tamakyi">小狼</a>,注意不要被骗。
<br>⭐本站不允许垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁、无意义、灌水提问的出现,如有涉证或不良言论将直接删除。</p>
</div>
<p>{{t .LangCode "site.stats"}}:<pre style="overflow: hidden;" id="site-stats-text">{{if .SiteStats}}{{.SiteStats.Text}}{{end}}</pre></p>
<script type="text/javascript">
// 首页统计已改为程序内直接计算(服务端 30 分钟缓存),这里每 30 分钟定时拉取最新值刷新,
// 与原来 crontab 生成 question.count.txt 的节奏一致,且不再依赖外部静态文件。
document.addEventListener('DOMContentLoaded', function () {
setInterval(function () {
fetch("/api/v1/site-stats")
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data;
if (!d || !d.text) return;
var el = document.getElementById("site-stats-text");
if (el) el.textContent = d.text;
})
.catch(function () {});
}, 30 * 60 * 1000);
});
</script>
<!--<script src="https://r2.337845818.best/oss-to-r2/nekobox/cdn/waline/client/v2/waline.js"></script>
<link href='https://r2.337845818.best/oss-to-r2/nekobox/cdn/waline/client/v2/waline-no-commits.css' rel='stylesheet' />-->
<div id="waline"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const waline = Waline.init({
el: '#waline',
//后端服务器地址
serverURL: 'https://waline.337845818.best/',
path: window.location.href,
// 中文默认
locale: {
nick: '昵称',
nickError: '昵称不能少于3个字符',
mail: '邮箱',
mailError: '请填写正确的邮件地址',
link: '网址',
optional: '可选',
placeholder: '欢迎评论(可以无需登录,但需要手工审核,不会立即展现)',
sofa: '来发评论吧~',
submit: '提交',
like: '喜欢',
cancelLike: '取消喜欢',
reply: '回复',
cancelReply: '取消回复',
comment: '评论',
refresh: '刷新',
more: '加载更多...',
preview: '预览',
emoji: '表情',
uploadImage: '上传图片',
seconds: '秒前',
minutes: '分钟前',
hours: '小时前',
days: '天前',
now: '刚刚',
uploading: '正在上传',
login: '登录',
logout: '退出',
admin: '博主',
sticky: '置顶',
word: '字',
wordHint: '评论字数应在 $0 到 $1 字之间!\n当前字数:$2',
anonymous: '无名小狼',
level0: '潜水',
level1: '冒泡',
level2: '吐槽',
level3: '活跃',
level4: '话痨',
level5: '传说',
gif: '表情包',
gifSearchPlaceholder: '搜索表情包',
profile: '个人资料',
approved: '通过',
waiting: '待审核',
spam: '垃圾',
unsticky: '取消置顶',
oldest: '按倒序',
latest: '按正序',
hottest: '按热度',
reactionTitle: '',
reaction0: '哇哦',
reaction1: 'prpr',
reaction2: '流汗',
reaction3: '生气',
reaction4: '流鼻血',
reaction5: 'あべ',
},
reaction: [
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGQ3OTRhYzRjMTBkZmZhZDVmZTA1EIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwOGEyOTlhYzRjMTBmOWFkZjAwYRCIzNcv.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGRhOTVhYzRjMTBkNmViYTVlNTA2EIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGU1OTJhYzRjMTBmMzk3ZmVmNTAyEIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGNkODdhYzRjMTBjYmEwOTFjNjAxEIjM1y8.gif',
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwODliOGVhYzRjMTBmOGM0ZWY1ZRCIzNcv.gif',
],
meta: ['nick','mail'],
requiredMeta: ['mail'],
pageview: true,
search: false,
login: 'enable',
wordLimit: 50,
pageSize: 5,
avatar: 'identicon',
emoji: [
'https://gcore.jsdelivr.net/gh/tamakyi/emoji-collection@main/acfun-color/60x60',
],
//禁止图片上传按钮
imageUploader: false,
//不显示版权标志
copyright: false,
});
});
</script>
{{template "base/footer" .}}
@@ -0,0 +1,121 @@
# 狼的提问箱 / Wolf's Question Box — 完整说明
狼的提问箱(Wolf's Question Box)是一个免费、无需注册的匿名提问箱服务,是 NekoBox 的社区分支(community fork)。访客无需下载 App、绑定社交账号或提供邮箱,即可向箱主匿名提问;箱主可公开或私密回答,并与提问者进行追问式的多轮对话。
> 隐私声明:单个提问箱内的问答内容属于用户隐私,不对外公开索引,也不写入 sitemap。本文件仅使用站点的公开功能信息,不包含任何具体提问箱或提问内容。
## 1. 服务定位
- 面向个人创作者、UP 主、博主等,提供一个让粉丝/陌生人匿名提问的入口。
- 匿名是核心:提问者不需要任何账号、邮箱或社交登录。
- 多语言界面:简体中文、繁体中文、英文、日文。
- 内置浅色 / 深色主题切换。
## 2. 核心功能详解
### 2.1 匿名提问
访客打开任意箱主的提问箱页面即可直接提问,全程无需登录。提问内容以纯文本或 Markdown 提交。
### 2.2 回答与追问(多轮对话)
- 箱主可对问题进行公开回答。
- 提问者可就同一问题「继续追问」,箱主可再次回复,形成来回多轮对话,适合连续交流、澄清或访谈场景。
- 已回答的追问,箱主可随时「更新回答」。
### 2.3 Markdown 渲染
问题与回答支持 Markdown 显示(标题、列表、引用、代码块、表格、图片等),保留排版格式。
### 2.4 置顶
箱主可将重要或精彩的问题「置顶」,固定展示在提问箱顶部,便于新访客一眼看到。
### 2.5 访问密码 / 提问密码
- **查看密码**:访问提问箱页面时需要输入密码,适合仅对特定人群开放。
- **提问密码**:向某提问箱提问时需要输入密码,防止陌生人随意刷屏。
- 密码采用加密方式存储,不保存明文。
### 2.6 二维码分享
每个问题页均可生成分享二维码,箱主可自定义二维码的背景图与颜色,方便线下或社媒直接扫码访问。
### 2.7 标签与探索页
- 箱主最多可设置 5 个标签,并选择是否展示在「探索」页面。
- 「探索」页(`/explore`)按用户与标签发现其他公开提问箱,是站内唯一的公开内容发现入口。
### 2.8 私聊
箱主与访客之间可进行一对一的连续私密对话(私聊),区别于公开问答,内容不对外展示。
### 2.9 多语言
界面支持简体中文、繁体中文、英文、日文四种语言,跟随用户选择或浏览器/站点偏好。
### 2.10 深色模式
全站内置浅色 / 深色主题切换,偏好记忆在本地 cookie。
### 2.11 验证码与图片上传
- 为兼顾国内外用户,已弃用对国内不友好的 reCAPTCHA,改用更友好的验证方式(如算数验证码 / Turnstile)。
- 提问与回答支持图片上传,上传前会做像素总量校验以防解压炸弹。
## 3. 使用流程
### 创建提问箱(箱主)
1. 访问 `/register` 注册一个免费账号,获得专属提问箱与二级域名(如 `/_/你的用户名`)。
2. 在 `/user/profile` 设置用户名、头像、简介与标签。
3. 按需开启「查看密码」或「提问密码」,并设置是否参与探索页。
### 匿名提问(访客)
1. 打开某箱主的提问箱页面(如 `/_/用户名`)。
2. 若箱主设置了提问密码,先输入密码。
3. 填写问题并提交(可勾选邮件通知,但邮箱非必填)。
4. 等待箱主公开回答;如需补充,可使用「追问」。
### 分享
- 复制提问箱链接,或使用问题页二维码分享。
- 推荐国内用户使用主域名 `https://box.tama.guru`。
## 4. 常见问题(FAQ)
### 4.1 给提问者(访客)
- **提问需要注册吗?** 不需要。任何人都能匿名提问;只有创建与管理提问箱(注册账号)才需要登录。
- **我的匿名性能保证吗?** 提问者无需提供账号或邮箱即可提问;是否填写通知邮箱完全可选,不填也能正常使用。
- **我可以追问吗?** 可以。对箱主已公开回答的问题,你可「继续追问」,箱主再次回复后形成来回多轮对话,适合澄清、访谈或连续交流。
- **我能收到回答通知吗?** 可以。提问时若留下邮箱(可选),箱主首次公开回答后你会收到邮件通知;邮件内含一条私密链接,可凭其查看该问题、接收回复并继续追问。
- **提问有数量限制吗?** 有基础频率限制(约 30 条/分钟/IP)以防刷屏滥用,正常使用基本无感。
- **能上传图片吗?** 能。提问与回答均支持图片上传,上传前会做像素总量等安全校验以防解压炸弹。
- **需要验证码吗?** 需要。提问前需通过验证码(算数验证码 / Turnstile),已弃用对国内不友好的 reCAPTCHA,兼顾国内外用户。
- **提问内容会被搜索引擎收录吗?** 不会。单个提问箱及其问答内容属于用户隐私,已排除在索引与 sitemap 之外;仅首页、注册、登录、探索页等公开页可被收录。
### 4.2 给箱主(创作者)
- **如何创建提问箱?** 注册一个免费账号,即可获得专属路径(如 `/_/你的用户名`),作为你的匿名提问箱。
- **如何设置个人资料?** 在 `/user/profile` 设置用户名、头像、简介与标签。
- **如何限制谁能访问或提问?** 可开启「查看密码」(访问页面需密码)与「提问密码」(提问需密码),均以加密形式存储,服务端不保存明文。
- **我能删除某条提问吗?** 可以。箱主可删除提问箱内的任意提问。
- **我能隐藏某条提问/回答吗?** 可以。单条问题可设为「私密」不对外公开展示,也可随时改回公开。
- **如何把精彩问题置顶?** 使用「置顶」功能,将重要或精彩的问题固定在提问箱顶部,便于新访客一眼看到。
- **如何出现在探索页?** 设置最多 5 个标签,并选择是否展示在「探索」页(`/explore`),这是站内唯一的公开内容发现入口。
- **我能和访客私聊吗?** 可以。支持一对一连续私密对话(区别于公开问答);可拉黑对方、或删除整个对话,被拉黑方将无法继续发送。
- **有新提问/新消息会通知我吗?** 会。若你已设置邮箱,有新提问、新私聊消息时会收到邮件通知。
- **分享卡片 / 二维码能自定义吗?** 可以。问题页与提问箱页面均可生成分享卡片与二维码,并自定义二维码背景图与颜色,方便线下或社媒直接扫码访问。
- **内容审核如何运作?** 采用自动与人工结合的审核,对垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁及无意义灌水内容进行处置与删除。
### 4.3 通用
- **支持哪些语言?** 简体中文、繁体中文、英文、日文;可在页面切换,偏好记忆于本地 cookie。
- **支持深色模式吗?** 支持。全站内置浅色 / 深色主题切换,偏好同样记忆于本地 cookie。
- **这是 NekoBox 吗?** 不是。TamaBox(狼的提问箱)是 NekoBox 的社区分支(community fork),在功能与多语言方面做了扩展。
- **收费吗?** 免费。
- **数据安全吗?** 密码加密存储,图片上传前后均做安全校验,单个提问箱内容不对外索引。
- **如何分享我的提问箱?** 复制提问箱链接,或使用页面二维码、分享卡片;国内用户推荐使用主域名 `https://box.tama.guru`。
- **忘记密码怎么办?** 登录页提供找回密码流程(通过注册邮箱重置),具体以登录页提示为准。
## 5. 隐私与使用政策
- **隐私**:单个提问箱内容属于用户隐私,不对外索引、不写入 sitemap。
- **内容规范**:禁止垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁、无意义或灌水内容;违规内容将被删除。
- **数据安全**:图片上传前后均做安全校验,密码加密存储。
## 6. 资源
- 更新日志(功能更新记录):https://blog.tama.guru/record/tamabox_changelog_20260813
- 站点首页:https://box.tama.guru/
- 探索页:https://box.tama.guru/explore
- 注册:https://box.tama.guru/register
- 登录:https://box.tama.guru/login
@@ -0,0 +1,29 @@
# 狼的提问箱 / Wolf's Question Box
> 狼的提问箱(Wolf's Question Box)是一个免费、无需注册的匿名提问箱服务。访客无需下载 App、绑定社交账号或提供邮箱,即可向箱主匿名提问;箱主可公开或私密回答,并与提问者进行追问式的多轮对话。本站为 NekoBox 的社区分支(community fork)。单个提问箱内的问答内容属于用户隐私,不对外公开索引。
## 概述
- [首页](https://box.tama.guru/): 站点介绍与多语言落地页。
- [探索](https://box.tama.guru/explore): 按用户与标签发现公开的提问箱。
- [注册](https://box.tama.guru/register): 免费创建自己的提问箱。
- [登录](https://box.tama.guru/login): 登录以管理你的提问箱。
## 核心功能
- 匿名提问:无需账号、邮箱或社交登录即可提问。
- 回答与追问:箱主可公开回答;提问者可继续追问,箱主可回复,形成多轮来回对话。
- Markdown 渲染:问题与回答支持 Markdown 显示。
- 访问控制:可分别设置「查看提问箱密码」与「提问密码」,密码加密存储。
- 置顶:将重要问题置顶展示。
- 二维码分享:每个问题页生成可分享二维码,背景图与颜色可自定义。
- 标签与发现:最多添加 5 个标签,展示于探索页。
- 私聊:与箱主一对一的连续私密对话。
- 多语言界面:简体中文、繁体中文、英文、日文。
- 深色模式:内置浅色 / 深色主题切换。
## 资源
- [完整说明 (llms-full.txt)](https://box.tama.guru/llms-full.txt): 站点完整功能说明、使用流程、FAQ 与隐私政策。
- [更新日志](https://blog.tama.guru/record/tamabox_changelog_20260813): 近期功能更新(追问、多语言、Markdown、置顶等)。
## 使用政策
- 隐私:单个提问箱内容属于隐私,已排除在搜索引擎索引与 sitemap 之外。
- 内容规范:禁止垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁、无意义或灌水内容,违规内容将被删除。
@@ -0,0 +1,92 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
)
func Register(ctx context.Context) {
ctx.Success("auth/register")
}
func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.RecaptchaV3) {
if ctx.HasError() {
ctx.Success("auth/register")
return
}
// Check captcha code (unified: recaptcha, turnstile, or math).
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/register")
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/register")
return
}
if err := db.Users.Create(ctx.Request().Context(), db.CreateUserOptions{
Name: f.Name,
Password: f.Password,
Email: f.Email,
Avatar: conf.Upload.DefaultAvatarURL,
Domain: f.Domain,
Background: conf.Upload.DefaultBackground,
Intro: "问你想问的",
Qrcodebackcolor: "#FDF8F0",
Qrcodecolor: "#C45500",
Usernamecolor: "#000000",
Introcolor: "#000000",
Dotscale: "0.35",
BackgroundImage: conf.Upload.DefaultBackgroundImageURL,
Backgroundimagealpha: "0.8",
Qrcodepdpcolor: "#C45500",
Acfunlink: "none",
Bililink: "none",
Weibolink: "none",
Bloglink: "box.tama.guru",
Xiaohongshulink: "none",
Language: i18n.Resolve(f.Language), // 注册时选择的界面语言;留空回退站点默认
}); err != nil {
switch {
case errors.Is(err, db.ErrDuplicateEmail):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateEmail")))
case errors.Is(err, db.ErrDuplicateDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateDomain")))
case errors.Is(err, db.ErrBadCredential):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errBadCredential")))
default:
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new user")
ctx.SetInternalError()
}
ctx.Success("auth/register")
return
}
// 成功提示跟随用户注册时选择的语言
lang := "zh-CN"
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
ctx.SetSuccessFlash(i18n.T(lang, "auth.register.success"))
ctx.Redirect("/login")
}
@@ -0,0 +1,7 @@
User-agent: *
Disallow: /_/
Disallow: /user/
Allow: /explore
Allow: /llms.txt
Allow: /llms-full.txt
Sitemap: https://box.tama.guru/sitemap.xml
@@ -0,0 +1,414 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"encoding/gob"
"io"
"net/http"
"strings"
"time"
"github.com/flamego/cache"
cacheRedis "github.com/flamego/cache/redis"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/recaptcha"
"github.com/flamego/session"
"github.com/flamego/session/mysql"
sessionRedis "github.com/flamego/session/redis"
"github.com/flamego/template"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/shared"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
"github.com/tamakyi/TamaBox/route"
"github.com/tamakyi/TamaBox/route/auth"
"github.com/tamakyi/TamaBox/route/pixel"
"github.com/tamakyi/TamaBox/route/question"
"github.com/tamakyi/TamaBox/route/service"
"github.com/tamakyi/TamaBox/route/user"
"github.com/tamakyi/TamaBox/route/vote"
"github.com/tamakyi/TamaBox/static"
"github.com/tamakyi/TamaBox/templates"
)
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致
// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用
// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token
// 对全部已登录用户均有效,CSRF 退化为全站公开常量。
//
// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在
// CI 失败,避免修复悄悄倒退。
const CSRFUserSessionKey = "uid"
// securityHeaders adds security-related HTTP headers to every response.
func securityHeaders() flamego.Handler {
return func(ctx flamego.Context) {
w := ctx.ResponseWriter()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
if strings.HasPrefix(conf.App.ExternalURL, "https") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
}
}
func New() *flamego.Flame {
f := flamego.Classic()
if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd)
}
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
// 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。
// 404 是否参与统计由 ban_track_notfound 控制(默认 false:正常业务一律放行,
// 仅敏感扫描路径计数封禁,见 conf.Security.BanTrackNotFound)。
ab := autoban.New(autoban.Options{
Enabled: conf.Security.EnableAutoBan,
TrackNotFound: conf.Security.BanTrackNotFound,
ThresholdSensitive: conf.Security.BanThresholdSensitive,
Threshold404: conf.Security.BanThreshold404,
Window: time.Duration(conf.Security.BanWindowSeconds) * time.Second,
Duration: time.Duration(conf.Security.BanDurationMinutes) * time.Minute,
AlertEmail: conf.Security.SecurityAlertEmail,
SendAlert: mail.SendSecurityAlertMail,
})
// 需要输出详细日志时配置如下
// if conf.App.Production {
// flamego.SetEnv(flamego.EnvTypeProd)
// }
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
if err != nil {
logrus.WithError(err).Fatal("Failed to embed templates file system")
}
gob.Register(time.Time{})
gob.Register(context.Flash{})
gob.Register(captcha.StoredAnswer{})
var sessionStorage interface{}
initer := session.MemoryIniter()
if conf.Database.DSN != "" {
initer = mysql.Initer()
sessionStorage = mysql.Config{
DSN: conf.Database.DSN,
Lifetime: 7 * 24 * time.Hour,
}
}
if conf.Redis.Addr != "" {
initer = sessionRedis.Initer()
sessionStorage = sessionRedis.Config{
Options: &sessionRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 1,
},
Lifetime: 7 * 24 * time.Hour,
}
}
sessioner := session.Sessioner(session.Options{
Initer: initer,
Config: sessionStorage,
// 显式声明 cookie 安全属性,不依赖库默认值:
// - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险
// - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一)
// - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。
// 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
},
})
f.Use(flamego.Static(flamego.StaticOptions{
FileSystem: http.FS(static.FS),
Prefix: "/static",
// 静态资源随二进制发布,内容变更需重新部署;1 天缓存是安全折中
CacheControl: func() string {
return "public, max-age=86400"
},
}))
f.Use(securityHeaders())
f.Use(ipBlacklistMiddleware())
f.Use(autobanMiddleware(ab))
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
f.Use(requestBodyLimitMiddleware())
reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
f.Group("", func() {
f.Get("/", route.Home)
f.Get("/pixel", reqUserSignIn, pixel.Index)
f.Get("/sponsor", route.Sponsor)
f.Get("/change-logs", route.ChangeLogs)
f.Get("/about-me", route.AboutMe)
f.Get("/robots.txt", func(c context.Context) {
fs, err := static.FS.Open("robots.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/favicon.ico", func(c context.Context) {
fs, _ := static.FS.Open("favicon.ico")
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "image/x-icon")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/sitemap.xml", func(c context.Context) {
fs, err := static.FS.Open("sitemap.xml")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "application/xml")
c.ResponseWriter().Header().Set("Content-Type", "text/xml; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/llms.txt", func(c context.Context) {
fs, err := static.FS.Open("llms.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/llms-full.txt", func(c context.Context) {
fs, err := static.FS.Open("llms-full.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Group("", func() {
f.Combo("/register").Get(auth.Register).Post(form.Bind(form.Register{}), rateLimitMiddleware(10, 1*time.Minute), auth.RegisterAction)
f.Combo("/login").Get(auth.Login).Post(form.Bind(form.Login{}), rateLimitMiddleware(20, 1*time.Minute), auth.LoginAction)
f.Combo("/forgot-password").Get(auth.ForgotPassword).Post(form.Bind(form.ForgotPassword{}), rateLimitMiddleware(5, 5*time.Minute), auth.ForgotPasswordAction)
f.Combo("/recover-password").Get(auth.RecoverPassword).Post(form.Bind(form.RecoverPassword{}), auth.RecoverPasswordAction)
}, reqUserSignOut)
// 页面密码验证路由(独立于密码保护中间件);限流防爆破
f.Combo("/_/{domain}/password").Get(question.PasswordPage).Post(rateLimitMiddleware(10, 1*time.Minute), question.VerifyPagePassword)
// 独立的分享图路由(不经过 Pager 中间件,直接返回图片)
// 注意:仍挂载 question.Pager,页面设置了密码时必须先通过密码验证,
// 否则分享图会绕过页面密码保护(与单题分享图行为一致)。
f.Group("/_/{domain}", func() {
f.Get("/share-card", question.ShareCard)
}, question.Pager)
f.Group("/_/{domain}", func() {
f.Combo("").Get(question.List).Post(form.Bind(form.NewQuestion{}), rateLimitMiddleware(30, 1*time.Minute), question.New)
f.Group("/{questionID}", func() {
f.Get("", question.Item)
f.Get("/share-card", question.QuestionShareCard)
f.Post("/delete", question.Delete)
f.Post("/set-private", question.SetPrivate)
f.Post("/set-public", question.SetPublic)
f.Post("/pin", question.TogglePin)
f.Post("/read", question.SetRead)
f.Post("/unread", question.SetUnread)
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
f.Post("/follow-up", form.Bind(form.PublishFollowUpQuestion{}), question.PublishFollowUp)
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
}, question.Questioner)
// 新增:私聊路由(需要登录,使用 Token 标识对话)
f.Group("/chat/{token}", func() {
f.Get("", chat.ConversationPage)
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
f.Post("/delete", chat.DeleteConversation)
f.Post("/block", chat.ToggleBlockUser)
}, reqUserSignIn)
}, question.Pager) // Pager 中间件已内置页面密码检查
f.Get("/explore", user.Explore)
// 匿名投票参与端(公开):token 链接入口
f.Group("/vote/{token}", func() {
f.Get("", vote.Page)
// 提交限流优先按登录用户/签名 vote_fp 计数,IP 仅作高阈值兜底,避免共享出口误伤。
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
})
// 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化)
// 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效,
// 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。
f.Get("/i18n/{lang}", func(c context.Context) {
lang := i18n.Resolve(c.Param("lang"))
// 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。
c.Session.Set("lang", lang)
to := c.Query("to")
// 仅允许站内相对路径,防开放重定向:
// 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。
if to == "" || !strings.HasPrefix(to, "/") ||
strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") {
to = "/"
}
c.Redirect(to)
})
// 算数验证码 API(仅在 captcha_type = "math" 时生效,但路由本身不依赖中间件)
// 加限流:防止匿名脚本无限生成/轮询爆破(答案本身对客户端可见,限流是主要节流手段)。
f.Get("/api/captcha/generate", rateLimitMiddleware(20, 1*time.Minute), captcha.GenerateHandler)
f.Post("/api/captcha/verify", rateLimitMiddleware(20, 1*time.Minute), captcha.VerifyHandler)
f.Group("/user", func() {
f.Get("/questions", user.QuestionList)
// 匿名投票管理(箱主侧)
f.Group("/votes", func() {
f.Get("", user.VoteList)
f.Get("/new", user.VoteNew)
f.Post("", rateLimitMiddleware(10, 1*time.Minute), user.VoteCreate)
f.Get("/{voteID}/edit", user.VoteEdit)
f.Post("/{voteID}", rateLimitMiddleware(10, 1*time.Minute), user.VoteUpdate)
f.Post("/{voteID}/publish", user.VotePublish)
f.Post("/{voteID}/close", user.VoteClose)
f.Post("/{voteID}/delete", user.VoteDelete)
f.Get("/{voteID}/results", user.VoteResults)
f.Get("/{voteID}/preview", user.VotePreview)
// 导出为只读操作(幂等、无副作用),用 GET 链接下载,避免表单提交
f.Get("/{voteID}/export", user.VoteExportCSV)
})
f.Group("/profile", func() {
f.Get("", user.Profile)
f.Post("/update", form.Bind(form.UpdateProfile{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateProfile)
f.Post("/export", user.ExportProfile)
f.Post("/export-json", user.ExportProfileJSON)
f.Combo("/deactivate").Get(user.DeactivateProfile).Post(user.DeactivateProfileAction)
f.Post("/update-page-password", form.Bind(form.UpdatePagePassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdatePagePassword)
f.Post("/update-ask-password", form.Bind(form.UpdateAskPassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateAskPassword)
f.Post("/update-chat-settings", user.UpdateChatSettings)
// 功能管理(提问 / 私聊 / 投票 的开关、定时开关与定量收集):
// 不单独开页面,作为账号设置页的三个面板,保存后回跳到对应面板。
f.Group("/features", func() {
f.Post("/update", rateLimitMiddleware(20, 1*time.Minute), user.FeaturesUpdate)
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
})
})
// 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update)
// 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经
// ?tab= 参数回跳到来源面板。
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
f.Post("/logout", auth.Logout)
}, reqUserSignIn)
f.Group("/api/v1", func() {
f.Group("/user", func() {
f.Get("", reqUserSignIn, user.ProfileAPI)
f.Group("/{domain}", func() {
f.Group("/questions", func() {
f.Get("", rateLimitMiddleware(60, 1*time.Minute), question.ListAPI)
})
})
})
// 像素追踪代理:仅暴露只读 GET(f.Any 会放行写方法,曾有 CSRF 豁免 + 写方法放行的风险),
// 并加限流防止被用作高频打点通道。
f.Get("/pixel/{**}", reqUserSignIn, rateLimitMiddleware(30, 1*time.Minute), pixel.Proxy)
// 服务反代仅暴露只读 GET:本网关是把已登录用户的请求转发到配置好的内部服务,
// 不应允许任意写方法,避免被用作对内网后端的越权操作入口。
f.Get("/service/{**}", reqUserSignIn, service.Proxy)
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
}, context.APIEndpoint)
},
cache.Cacher(cache.Options{
Initer: cacheRedis.Initer(),
Config: cacheRedis.Config{
Options: &cacheRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 0,
},
},
}),
recaptcha.V3(
recaptcha.Options{
Secret: conf.Recaptcha.ServerKey,
VerifyURL: func() recaptcha.VerifyURL {
if conf.Recaptcha.TurnstileStyle {
return "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
return recaptcha.VerifyURLGlobal
}(),
},
),
sessioner,
csrf.Csrfer(csrf.Options{
Secret: conf.Server.XSRFKey,
Header: "X-CSRF-Token",
// ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。
// flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID")
// 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算
// (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的
// /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。
// 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。
SessionKey: CSRFUserSessionKey,
}),
template.Templater(template.Options{
FileSystem: templateFS,
FuncMaps: templatepkg.FuncMap(),
}),
context.Contexter(),
)
f.NotFound(func(ctx flamego.Context) {
// 真正的 404 探测在此记录(仅此处,不把业务 302→/ 误判为扫描)。
// 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/",
// 因此这里在 Redirect 之前记录,确保状态为 404。
// 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合),
// 否则 404 计数会记在另一个键上,导致封禁永不生效。
// ⚠️ 该 404 计数受 ban_track_notfound 配置控制,默认关闭(正常业务一律放行,
// 仅敏感扫描路径计数封禁);Record 内部对未开启的 KindNotFound 直接忽略。
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
}
ctx.Redirect("/")
})
return f
}
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
>
<url>
<loc>https://box.tama.guru/explore</loc>
<priority>0.3</priority>
<lastmod>2026-02-27</lastmod>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://box.tama.guru/</loc>
<priority>0.3</priority>
<lastmod>2026-02-27</lastmod>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://box.tama.guru/register</loc>
<priority>0.3</priority>
<lastmod>2026-02-27</lastmod>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://box.tama.guru/login</loc>
<priority>0.3</priority>
<lastmod>2026-02-27</lastmod>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://box.tama.guru/_/tamakyi</loc>
<priority>0.3</priority>
<lastmod>2026-02-27</lastmod>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://box.tama.guru</loc>
<priority>0.3</priority>
<lastmod>2026-02-27</lastmod>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://box.tama.guru/change-logs</loc>
<priority>0.3</priority>
<lastmod>2026-02-27</lastmod>
<changefreq>weekly</changefreq>
</url>
</urlset>
@@ -0,0 +1,118 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package censor
import (
"context"
"encoding/json"
"fmt"
"strings"
)
type ForbiddenType string
const (
ForbiddenTypeSpam ForbiddenType = "spam"
ForbiddenTypeAd ForbiddenType = "ad"
ForbiddenTypePolitics ForbiddenType = "politics"
ForbiddenTypeTerrorism ForbiddenType = "terrorism"
ForbiddenTypeAbuse ForbiddenType = "abuse"
ForbiddenTypePorn ForbiddenType = "porn"
ForbiddenTypeFlood ForbiddenType = "flood"
ForbiddenTypeContraband ForbiddenType = "contraband"
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
ForbiddenTypeHarmful ForbiddenType = "harmful"
)
func (f ForbiddenType) String() string {
return map[ForbiddenType]string{
ForbiddenTypeSpam: "含垃圾信息",
ForbiddenTypeAd: "广告",
ForbiddenTypePolitics: "涉政",
ForbiddenTypeTerrorism: "暴恐",
ForbiddenTypeAbuse: "辱骂",
ForbiddenTypePorn: "色情",
ForbiddenTypeFlood: "灌水",
ForbiddenTypeContraband: "违禁",
ForbiddenTypeMeaningless: "无意义",
ForbiddenTypeHarmful: "不良场景",
}[f]
}
type TextCensorResponse struct {
SourceName string `json:"source_name"`
Pass bool `json:"pass"`
ForbiddenType ForbiddenType `json:"forbidden_type"`
Hint string `json:"hint"`
Confidence float64 `json:"confidence"`
RawResponse json.RawMessage `json:"raw_response"`
}
func (r *TextCensorResponse) ToJSON() []byte {
jsonBytes, _ := json.Marshal(r)
return jsonBytes
}
func (r *TextCensorResponse) ErrorMessage() string {
errorMessage := "内容安全检查不通过"
if r.ForbiddenType != "" {
errorMessage += fmt.Sprintf(" [%s]", r.ForbiddenType.String())
}
if r.Hint != "" {
errorMessage += fmt.Sprintf(",相关标签:%q", r.Hint)
}
return errorMessage
}
type TextCensor interface {
Censor(ctx context.Context, text string) (*TextCensorResponse, error)
String() string
}
var TrustedHosts = []string{
"xhslink.com", // 小红书
"weibo.com", // 微博
"b23.tv", // Bilibili
"acfun.cn",
"aixifan.com",
"github.com",
"tama.guru",
"337845818.best",
"x.com",
"bilibili.com",
}
// RemoveTrustedURL removes the trusted URL from the text,
// in order to bypass the URL censoring.
// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于字符串结尾时才视为可信,
// 避免 github.com.evil.tld 被朴素替换误判为 github.com 之子域而漏审。
func RemoveTrustedURL(str string) string {
for _, host := range TrustedHosts {
for _, protocol := range []string{"http://", "https://"} {
needle := protocol + host
var b strings.Builder
rest := str
for {
idx := strings.Index(rest, needle)
if idx < 0 {
b.WriteString(rest)
break
}
b.WriteString(rest[:idx])
after := rest[idx+len(needle):]
if len(after) == 0 || after[0] == '/' || after[0] == '?' || after[0] == '#' {
// 可信域名边界:跳过该前缀,从域名之后继续(域名本身被移除,不进入审核)。
rest = after
} else {
// 非可信域名边界(如 github.com.evil):保留前缀,从其后继续扫描。
b.WriteString(needle)
rest = after
}
}
str = b.String()
}
}
return str
}