三处小修 + 两个格式清理,均来自全盘复查: 1. ask-status domain 枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素 数组,凭空/非空可枚举 domain 是否存在(含被 L1 隐藏或设了页面密码的半公开箱)。 改为箱主不存在时也回显 ok:false,四种失败响应完全同形。 2. 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):读配置失败会回退默认值, 与「写路径必须 FeatureStateOfStrict(失败即拒)」的约定不符。此前靠 chat 默认关闭 的巧合侥幸安全,现统一改 Strict。 3. 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前 到 preventDefault 同步栈,物理阻止压缩窗口内双击重复提交。 顺带:box_views.go / message_image.go / insights 两个文件补末尾换行与 struct 字段对齐 (gofmt 干净)。
318 lines
14 KiB
HTML
318 lines
14 KiB
HTML
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
|
||
<br>
|
||
<a href="/rules">{{t .LangCode "site.footer.rules"}}</a> |
|
||
<a href="/stats">{{t .LangCode "site.footer.stats"}}</a> |
|
||
<!--<a href="/pixel">画板</a> | -->
|
||
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
|
||
<br>
|
||
<a href="https://share.shiroko.one" target="_blank">{{t .LangCode "site.footer.drive"}}</a>
|
||
| <a href="https://blog.shiroko.one" target="_blank">{{t .LangCode "site.footer.notice"}}</a>
|
||
| <a href="https://chat.shiroko.one" target="_blank">{{t .LangCode "site.footer.chat"}}</a>
|
||
| <a href="https://status.shiroko.one" target="_blank">{{t .LangCode "site.footer.status"}}</a>
|
||
| <a href="https://shiroko.one" target="_blank">{{t .LangCode "site.footer.nav"}}</a>
|
||
<br>Powered by NekoBox | {{ ICP }}
|
||
<br>{{t .LangCode "site.footer.runtime"}}:<span id="box_runtime"></span>
|
||
</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');
|
||
}
|
||
});
|
||
}
|
||
|
||
// tbBeginSubmit 把表单切到「提交中」状态:触发按钮换成 spinner + 本地化文案,
|
||
// 并禁用表单内所有按钮。
|
||
//
|
||
// 抽成具名函数(而不是内联在下面的事件监听里)是因为 **程序化 submit() 不触发
|
||
// submit 事件**:走 reCAPTCHA / Turnstile 的流程是在回调里调 form.submit() 的,
|
||
// 原本完全绕过下面那个监听器 —— 而默认部署 captcha_type = recaptcha,
|
||
// 也就是主提问流一直没有防重复点击保护。那些回调现在先调本函数再 submit()。
|
||
function tbBeginSubmit(form, trigger) {
|
||
if (!form) return;
|
||
|
||
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
|
||
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
|
||
if (form.getAttribute('target') === '_blank') return;
|
||
|
||
// 触发提交的按钮(programmatic submit 时调用方传 null,回退到首个 submit 按钮)
|
||
var btn = trigger || 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 (btn && btn.tagName === 'BUTTON') {
|
||
// 已经切过一次就不再覆盖 data-tb-restore,否则会把 spinner 的 HTML 当成原始内容,
|
||
// 导致 bfcache 复位后按钮永远停在转圈上。
|
||
if (!btn.hasAttribute('data-tb-restore')) {
|
||
btn.setAttribute('data-tb-restore', btn.innerHTML);
|
||
}
|
||
btn.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
|
||
}
|
||
|
||
// 禁用表单内所有按钮,防止重复提交
|
||
form.querySelectorAll('button').forEach(function(b) {
|
||
b.disabled = true;
|
||
b.setAttribute('data-tb-submitting', '1');
|
||
});
|
||
}
|
||
// 挂到 window 上供 header 里的验证码回调调用(脚本顺序:header → footer,
|
||
// 但回调发生在用户点击之后,此时本函数已定义)
|
||
window.tbBeginSubmit = tbBeginSubmit;
|
||
|
||
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;
|
||
}
|
||
|
||
tbBeginSubmit(form, e.submitter);
|
||
});
|
||
|
||
// bfcache 还原时复位按钮
|
||
window.addEventListener('pageshow', tbResetSubmitButtons);
|
||
</script>
|
||
|
||
<script>
|
||
// 图片前端压缩:选图后先在浏览器里把大图缩到服务端的上限,再随表单上传。
|
||
//
|
||
// 为什么值得做:服务端已经把静态图转 WebP 并把长边限到 1600(storage.GeneralEncodeSpec),
|
||
// 但**上传这一跳是先发生的** —— 手机拍的 4~8MB 照片要先原样传完,才能被服务端缩小。
|
||
// 于是出现两个真实问题:
|
||
// 1. 超过 form.MaxRequestBodySize / 服务端 5MB 校验的图直接被拒,用户写好的正文一起丢;
|
||
// 2. 移动网络上行为 1~2 Mbps,传一张 6MB 图要 30 秒以上,期间页面只能干等。
|
||
// 前端先缩到 1600 长边、质量 0.8(与服务端同一档),一张手机照片通常降到 200~400KB,
|
||
// 上传时间从几十秒变成一两秒,且不再触碰体积上限。
|
||
//
|
||
// 三条安全边界(都不能省,否则会「压缩」出比原图更差的结果):
|
||
// - GIF 与 SVG 直接跳过:canvas 会把 GIF 画成静止首帧、把 SVG 栅格化;
|
||
// - 压完不比原图小就退回原图:已经优化过的小图重编码反而更大;
|
||
// - PNG 的透明通道保留:优先输出 WebP(支持 alpha),浏览器不支持 WebP 编码时
|
||
// 用 PNG 而不是 JPEG(JPEG 会把透明变成黑底)。
|
||
(function () {
|
||
var MAX_SIDE = 1600; // 与服务端 GeneralEncodeSpec.MaxSide 对齐
|
||
var QUALITY = 0.8; // 与服务端 Quality 对齐
|
||
var MIN_BYTES = 300 * 1024; // 小于这个体积重编码收益有限,直接放行
|
||
var MIN_SAVING = 0.85; // 压完不到原体积 85% 就不采用
|
||
|
||
// 每个 input 最近的压缩任务,供 submit 前等待。
|
||
var pending = new WeakMap();
|
||
|
||
var webpOK = null;
|
||
function supportsWebP() {
|
||
if (webpOK !== null) return webpOK;
|
||
try {
|
||
var c = document.createElement('canvas');
|
||
c.width = c.height = 1;
|
||
webpOK = c.toDataURL('image/webp').indexOf('data:image/webp') === 0;
|
||
} catch (e) {
|
||
webpOK = false;
|
||
}
|
||
return webpOK;
|
||
}
|
||
|
||
function isCompressible(file) {
|
||
if (!file || !file.type) return false;
|
||
// GIF(可能动图)与 SVG(矢量)不能用 canvas 重编码
|
||
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return false;
|
||
if (file.type.indexOf('image/') !== 0) return false;
|
||
return file.size > MIN_BYTES;
|
||
}
|
||
|
||
function loadBitmap(file) {
|
||
if (self.createImageBitmap) {
|
||
// imageOrientation: 'from-image' 让手机竖拍照片按 EXIF 摆正;
|
||
// 老浏览器不认这个选项会抛错,退回不带选项的调用。
|
||
return createImageBitmap(file, { imageOrientation: 'from-image' })
|
||
.catch(function () { return createImageBitmap(file); });
|
||
}
|
||
return new Promise(function (resolve, reject) {
|
||
var img = new Image();
|
||
var url = URL.createObjectURL(file);
|
||
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
||
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('decode failed')); };
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
function toBlob(canvas, type, quality) {
|
||
return new Promise(function (resolve) { canvas.toBlob(resolve, type, quality); });
|
||
}
|
||
|
||
function reencode(file) {
|
||
return loadBitmap(file).then(function (bmp) {
|
||
var w = bmp.width, h = bmp.height;
|
||
if (!w || !h) throw new Error('empty image');
|
||
var scale = Math.min(1, MAX_SIDE / Math.max(w, h));
|
||
var tw = Math.max(1, Math.round(w * scale));
|
||
var th = Math.max(1, Math.round(h * scale));
|
||
|
||
// PNG 走 WebP(两者都支持透明);不支持 WebP 编码时宁可继续用 PNG,
|
||
// 也不要转成 JPEG —— 透明区域会变成黑块。
|
||
var transparentCapable = file.type === 'image/png';
|
||
var target = 'image/jpeg';
|
||
if (supportsWebP()) {
|
||
target = 'image/webp';
|
||
} else if (transparentCapable) {
|
||
target = 'image/png';
|
||
}
|
||
|
||
var canvas = document.createElement('canvas');
|
||
canvas.width = tw;
|
||
canvas.height = th;
|
||
var ctx = canvas.getContext('2d');
|
||
if (!ctx) throw new Error('no 2d context');
|
||
if (target === 'image/jpeg') {
|
||
// JPEG 没有 alpha:先铺白底,否则透明部分变黑。
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillRect(0, 0, tw, th);
|
||
}
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
ctx.drawImage(bmp, 0, 0, tw, th);
|
||
if (bmp.close) bmp.close();
|
||
|
||
return toBlob(canvas, target, QUALITY);
|
||
}).then(function (blob) {
|
||
if (!blob || blob.size >= file.size * MIN_SAVING) return file;
|
||
// 文件名保持不变:FormData 里的文件名只用于展示与日志,
|
||
// 服务端按文件头嗅探真实格式(image.DecodeConfig),不看扩展名。
|
||
return new File([blob], file.name, { type: blob.type, lastModified: Date.now() });
|
||
}).catch(function () {
|
||
// 任何一步失败(不支持、内存不足、编码器缺失)都退回原图,绝不阻断提交。
|
||
return file;
|
||
});
|
||
}
|
||
|
||
function swap(input, file) {
|
||
try {
|
||
var dt = new DataTransfer();
|
||
dt.items.add(file);
|
||
input.files = dt.files;
|
||
return true;
|
||
} catch (e) {
|
||
// 老浏览器不支持对 input.files 赋值:放弃替换,按原图提交。
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function bind(input) {
|
||
if (input.dataset.tbImgBound === '1') return;
|
||
input.dataset.tbImgBound = '1';
|
||
|
||
input.addEventListener('change', function () {
|
||
var file = input.files && input.files[0];
|
||
if (!isCompressible(file)) {
|
||
pending.delete(input);
|
||
return;
|
||
}
|
||
var job = reencode(file).then(function (out) {
|
||
if (out !== file) swap(input, out);
|
||
});
|
||
pending.set(input, job);
|
||
// 无论成功失败都清掉,避免下一次提交白等一个已完成的 Promise。
|
||
job.then(function () {
|
||
if (pending.get(input) === job) pending.delete(input);
|
||
});
|
||
});
|
||
}
|
||
|
||
function scan(root) {
|
||
(root || document).querySelectorAll('input[type="file"]').forEach(bind);
|
||
}
|
||
|
||
scan(document);
|
||
// 后插入的表单(如「点击回答」后展开的编辑器)不会触发上面的扫描,
|
||
// 用 MutationObserver 兜住;只在新增节点里找 input,开销可以忽略。
|
||
if (window.MutationObserver) {
|
||
new MutationObserver(function (records) {
|
||
records.forEach(function (r) {
|
||
Array.prototype.forEach.call(r.addedNodes, function (node) {
|
||
if (node.nodeType !== 1) return;
|
||
if (node.matches && node.matches('input[type="file"]')) bind(node);
|
||
if (node.querySelectorAll) scan(node);
|
||
});
|
||
});
|
||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||
}
|
||
|
||
// 兜底:极少数情况下用户选完图立刻提交(压缩还没跑完)。
|
||
// capture 阶段先拦下,等压缩结束再真正提交 —— 用 form.submit() 而不是
|
||
// 再走一次 submit 事件,天然避开死循环,也不会二次触发上面的防重复逻辑。
|
||
//
|
||
// 注意 tbBeginSubmit(禁用按钮)必须在 preventDefault 的同一同步栈里立刻调用,
|
||
// 而不是推迟到 Promise 回调里:大图压缩要 0.5~2s,这期间按钮若仍可点,
|
||
// 用户双击会触发两次 submit,各等同一个压缩任务、各 form.submit() 一次。
|
||
document.addEventListener('submit', function (e) {
|
||
var form = e.target;
|
||
if (!form || form.tagName !== 'FORM') return;
|
||
|
||
var waits = [];
|
||
form.querySelectorAll('input[type="file"]').forEach(function (input) {
|
||
var job = pending.get(input);
|
||
if (job) waits.push(job);
|
||
});
|
||
if (!waits.length) return;
|
||
|
||
e.preventDefault();
|
||
// 同步进入「提交中」:物理阻止压缩窗口内的重复提交。
|
||
if (window.tbBeginSubmit) window.tbBeginSubmit(form, e.submitter || null);
|
||
Promise.all(waits).then(function () {
|
||
form.submit();
|
||
});
|
||
}, true);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|