First Push

This commit is contained in:
喆OvO
2026-08-06 11:11:11 +08:00
commit 0fef309972
551 changed files with 15793 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
/* Passkey 中间件:统一所有 WebAuthn 调用(登录/注册/二次验证/管理员代配)。
*
* 根据 Agent 判定执行路径:
* - 浏览器 / 小窗(UA 不含 "CalDisplayApp" 且未注入标志)→ 直接调用
* navigator.credentials(标准 WebAuthn,Safari 小窗环境原生支持 Passkey)
* - iOS 套壳 App(注入 window.__CAL_DISPLAY_APP__ 或 UA 含 "CalDisplayApp")
* → 把 {op, options} 存入服务端一次性 stash,跳转 /passkey 小窗页面;
* App 拦截导航后用 ASWebAuthenticationSession 打开该页面,页面内完成
* WebAuthn 仪式,经 dashboard://passkey 回调由 App 回传结果。
*
* 所有返回的 credential 均为纯 base64url JSON(与浏览器 WebAuthn 结构一致),
* 调用方无需再做 ArrayBuffer 转换。
*/
(function (global) {
'use strict';
// iOS 套壳 App 判定:App 注入的全局标志优先,UA 标记兜底
var IS_IOS_APP = false;
try { IS_IOS_APP = window.__CAL_DISPLAY_APP__ === true; } catch (e) {}
if (!IS_IOS_APP) { IS_IOS_APP = /CalDisplayApp/i.test(navigator.userAgent || ''); }
var _pending = null; // {resolve, reject}
// ── base64url <-> ArrayBuffer ──
function b64ToBuf(b64) {
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufToB64(buf) {
var bytes = new Uint8Array(buf);
var binary = '';
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// ── options 准备(浏览器模式:base64url → ArrayBuffer)──
function prepareCreation(options) {
var o = JSON.parse(JSON.stringify(options));
o.challenge = b64ToBuf(o.challenge);
if (o.user && o.user.id) o.user.id = b64ToBuf(o.user.id);
if (o.excludeCredentials) {
o.excludeCredentials = o.excludeCredentials.map(function (c) {
return { id: b64ToBuf(c.id), type: c.type };
});
}
return o;
}
function prepareAssertion(options) {
var o = JSON.parse(JSON.stringify(options));
o.challenge = b64ToBuf(o.challenge);
if (o.allowCredentials) {
o.allowCredentials = o.allowCredentials.map(function (c) {
return { id: b64ToBuf(c.id), type: c.type };
});
}
return o;
}
// ── 浏览器直接 WebAuthn ──
function webAuthnCreation(options) {
return navigator.credentials.create({ publicKey: prepareCreation(options) }).then(function (cred) {
return {
id: cred.id,
type: cred.type,
rawId: bufToB64(cred.rawId),
response: {
clientDataJSON: bufToB64(cred.response.clientDataJSON),
attestationObject: bufToB64(cred.response.attestationObject),
transports: cred.response.getTransports ? cred.response.getTransports() : []
}
};
});
}
function webAuthnAssertion(options) {
return navigator.credentials.get({ publicKey: prepareAssertion(options) }).then(function (cred) {
return {
id: cred.id,
type: cred.type,
rawId: bufToB64(cred.rawId),
response: {
authenticatorData: bufToB64(cred.response.authenticatorData),
clientDataJSON: bufToB64(cred.response.clientDataJSON),
signature: bufToB64(cred.response.signature),
userHandle: cred.response.userHandle ? bufToB64(cred.response.userHandle) : null
}
};
});
}
// ── iOS App:先把参数存入服务端一次性 stash,再跳转小窗页面 ──
// 小窗(ASWebAuthenticationSession)是独立的 ephemeral 会话,读不到主容器
// 的 sessionStorage,因此参数通过服务端短时暂存传递。
function iosDispatch(op, options) {
return fetch('/api/passkey/stash', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op: op, options: options })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.sid) throw new Error(d.error || 'Passkey 参数暂存失败');
var base = location.origin || ('https://' + location.host);
window.location.href = base + '/passkey?op=' + encodeURIComponent(op) + '&sid=' + encodeURIComponent(d.sid);
return new Promise(function (resolve, reject) {
_pending = { resolve: resolve, reject: reject };
// 兜底:小窗未完成/被取消时避免 promise 悬挂
setTimeout(function () {
if (_pending) {
_pending = null;
reject(new Error('Passkey 认证超时或已取消'));
}
}, 120000);
});
});
}
// ── 供原生层(evaluateJavaScript)调用的桥接方法 ──
function __iosComplete(credentialJson) {
var p = _pending;
_pending = null;
if (p) p.resolve(credentialJson);
}
function __iosCancel(message) {
var p = _pending;
_pending = null;
if (p) p.reject(new Error(message || 'Passkey 认证已取消'));
}
// ── 对外统一入口 ──
global.PasskeyBridge = {
creation: function (options) {
return IS_IOS_APP ? iosDispatch('creation', options) : webAuthnCreation(options);
},
assertion: function (options) {
return IS_IOS_APP ? iosDispatch('assertion', options) : webAuthnAssertion(options);
},
isIOSApp: function () { return IS_IOS_APP; },
__iosComplete: __iosComplete,
__iosCancel: __iosCancel
};
})(window);
+64
View File
@@ -0,0 +1,64 @@
/* 主题系统:账户独立主题(dark/light/auto)。
*
* - 后端 GET /api/theme 返回 {mode, effective};auto 已解析为 dark/light
* (按用户经纬度+时区计算日出日落)。
* - 应用方式:html 元素加 .theme-light class(CSS 变量覆盖浅色)。
* - 切换动画:切换瞬间给 html 加 .theme-anim,触发全局 CSS transition
* 平滑渐变,平时无全局 transition(不影响交互反馈)。
* - auto 模式每 5 分钟轮询一次(日落日出边界切换);显示端还会在收到
* SSE theme_changed 时立即刷新。
*/
(function (global) {
'use strict';
var current = 'dark';
var pollTimer = null;
function apply(effective) {
if (!effective) return;
if (effective === current) return;
current = effective;
var root = document.documentElement;
root.classList.add('theme-anim');
root.classList.toggle('theme-light', effective === 'light');
// 过渡结束后移除动画 class,恢复常规交互反馈
setTimeout(function () { root.classList.remove('theme-anim'); }, 600);
}
function fetchTheme(cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/theme');
xhr.onload = function () {
if (xhr.status === 200) {
var d = {};
try { d = JSON.parse(xhr.responseText || '{}'); } catch (e) {}
if (d.effective) apply(d.effective);
if (cb) cb(d);
}
};
xhr.onerror = function () { /* 网络失败保持当前主题 */ };
xhr.send();
}
function initTheme() {
fetchTheme();
if (!pollTimer) {
// auto 模式日落/日出切换:每 5 分钟刷新一次
pollTimer = setInterval(fetchTheme, 5 * 60 * 1000);
}
}
global.ThemeBridge = {
apply: apply,
fetch: fetchTheme,
init: initTheme,
getCurrent: function () { return current; }
};
// 页面加载即应用主题
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTheme);
} else {
initTheme();
}
})(window);