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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user