Compare commits
13
Commits
2b748fc48e
..
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c67bf269a | ||
|
|
ed40a040e3 | ||
|
|
4200826120 | ||
|
|
6ce6438854 | ||
|
|
0def382511 | ||
|
|
636d13407f | ||
|
|
f9abb877e7 | ||
|
|
c947e62364 | ||
|
|
ded6fdb753 | ||
|
|
9c34374b95 | ||
|
|
8a3e213e40 | ||
|
|
2de07710da | ||
|
|
e16130bf8f |
@@ -168,6 +168,11 @@ function migrateSchema() {
|
|||||||
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {}
|
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {}
|
||||||
try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {}
|
try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {}
|
||||||
} },
|
} },
|
||||||
|
// v3: RainID 单点登录——影子账号绑定键(sub)。部分唯一索引:空串(本地账号)不参与唯一约束
|
||||||
|
{ version: 3, up: () => {
|
||||||
|
try { db.exec("ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''"); } catch {}
|
||||||
|
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid ON users(rainid_user_id) WHERE rainid_user_id <> ''"); } catch {}
|
||||||
|
} },
|
||||||
];
|
];
|
||||||
for (const m of migrations) {
|
for (const m of migrations) {
|
||||||
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
||||||
@@ -209,6 +214,10 @@ function seedDefaults() {
|
|||||||
captcha_register: '0',
|
captcha_register: '0',
|
||||||
captcha_forum: '0',
|
captcha_forum: '0',
|
||||||
captcha_type: 'builtin',
|
captcha_type: 'builtin',
|
||||||
|
rainid_enabled: '0',
|
||||||
|
rainid_client_id: '',
|
||||||
|
rainid_discovery_url: 'https://rainid.rainnya.asia/oauth',
|
||||||
|
rainid_register_redirect: '0',
|
||||||
site_favicon: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>',
|
site_favicon: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>',
|
||||||
music_embed_enabled: '0',
|
music_embed_enabled: '0',
|
||||||
music_embed_code: '',
|
music_embed_code: '',
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import FormHelperText from '@mui/material/FormHelperText';
|
|||||||
import Radio from '@mui/material/Radio';
|
import Radio from '@mui/material/Radio';
|
||||||
import RadioGroup from '@mui/material/RadioGroup';
|
import RadioGroup from '@mui/material/RadioGroup';
|
||||||
import Switch from '@mui/material/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||||
import { getSettings, saveSettings } from '../../api/settings.js';
|
import { getSettings, saveSettings } from '../../api/settings.js';
|
||||||
import { showSnack } from '../snack.jsx';
|
import { showSnack } from '../snack.jsx';
|
||||||
|
|
||||||
@@ -20,7 +24,7 @@ const FOOTER_STYLES = [
|
|||||||
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 基本设置 + 页脚设置(v2:页脚样式 / 版权 / Powered by 均支持自定义) */
|
/** 基本设置 + 页脚设置 + 面板代理 + RainID 单点登录(v2) */
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
site_name: '',
|
site_name: '',
|
||||||
@@ -33,8 +37,15 @@ export default function Settings() {
|
|||||||
footer_desc: '',
|
footer_desc: '',
|
||||||
comment_moderate: '0',
|
comment_moderate: '0',
|
||||||
comment_notify: '0',
|
comment_notify: '0',
|
||||||
|
proxy_allowed_hosts: '',
|
||||||
|
rainid_enabled: '0',
|
||||||
|
rainid_client_id: '',
|
||||||
|
rainid_client_secret: '',
|
||||||
|
rainid_discovery_url: 'https://rainid.rainnya.asia/oauth',
|
||||||
|
rainid_register_redirect: '0',
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showSecret, setShowSecret] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSettings()
|
getSettings()
|
||||||
@@ -49,6 +60,13 @@ export default function Settings() {
|
|||||||
footer_desc: s.footer_desc || '',
|
footer_desc: s.footer_desc || '',
|
||||||
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
|
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
|
||||||
comment_notify: s.comment_notify === '1' ? '1' : '0',
|
comment_notify: s.comment_notify === '1' ? '1' : '0',
|
||||||
|
proxy_allowed_hosts: s.proxy_allowed_hosts || '',
|
||||||
|
rainid_enabled: s.rainid_enabled === '1' ? '1' : '0',
|
||||||
|
rainid_client_id: s.rainid_client_id || '',
|
||||||
|
// secret 后端不回读(ALLOWED_SET 可写不可读),始终为空,留空提交=不修改
|
||||||
|
rainid_client_secret: '',
|
||||||
|
rainid_discovery_url: s.rainid_discovery_url || 'https://rainid.rainnya.asia/oauth',
|
||||||
|
rainid_register_redirect: s.rainid_register_redirect === '1' ? '1' : '0',
|
||||||
}))
|
}))
|
||||||
.catch((e) => showSnack(e.message, 'error'));
|
.catch((e) => showSnack(e.message, 'error'));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -59,7 +77,10 @@ export default function Settings() {
|
|||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveSettings(form);
|
const body = { ...form };
|
||||||
|
// secret 空值处理:留空 = 不修改(后端不回读、空串会覆盖已有值,故从提交中移除该 key)
|
||||||
|
if (!body.rainid_client_secret) delete body.rainid_client_secret;
|
||||||
|
await saveSettings(body);
|
||||||
showSnack('设置已保存');
|
showSnack('设置已保存');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showSnack(e.message, 'error');
|
showSnack(e.message, 'error');
|
||||||
@@ -142,6 +163,79 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3, mt: 2 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>面板代理</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="代理允许的内网地址(白名单)"
|
||||||
|
value={form.proxy_allowed_hosts}
|
||||||
|
onChange={set('proxy_allowed_hosts')}
|
||||||
|
margin="normal"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
placeholder={'每行或逗号分隔一个地址/网段,例如:\n192.168.0.0/16\n10.0.0.0/8\n192.168.3.1:8080'}
|
||||||
|
helperText="https 页面无法嵌入 http 内网面板,把可信内网地址/网段加入白名单后可经面板代理放行。支持单 IP、IPv4 CIDR(如 192.168.0.0/16)与主机名;默认拦截所有内网地址,请谨慎配置。"
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3, mt: 2 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>RainID 单点登录</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
|
||||||
|
通过 RainID 统一身份认证,支持 ROPC 密码登录与授权码 SSO
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={form.rainid_enabled === '1'} onChange={toggle('rainid_enabled')} />}
|
||||||
|
label={(
|
||||||
|
<Box sx={{ py: 0.5 }}>
|
||||||
|
<Box sx={{ fontSize: 14, fontWeight: 500 }}>启用 RainID 登录</Box>
|
||||||
|
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>登录页显示「使用 RainID 登录」,密码登录自动转发 ROPC 校验</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={form.rainid_register_redirect === '1'} onChange={toggle('rainid_register_redirect')} />}
|
||||||
|
label={(
|
||||||
|
<Box sx={{ py: 0.5 }}>
|
||||||
|
<Box sx={{ fontSize: 14, fontWeight: 500 }}>注册跳转 RainID</Box>
|
||||||
|
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后注册页直接跳转 RainID 注册,不再显示本地注册表单</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField fullWidth label="Client ID" value={form.rainid_client_id} onChange={set('rainid_client_id')} margin="normal" placeholder="RainID 应用 Client ID(非机密)" />
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Client Secret(机密)"
|
||||||
|
type={showSecret ? 'text' : 'password'}
|
||||||
|
value={form.rainid_client_secret}
|
||||||
|
onChange={set('rainid_client_secret')}
|
||||||
|
margin="normal"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
helperText="保存在服务端且不回读;留空提交则保持原值不变"
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { 'aria-label': 'RainID Client Secret' },
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
aria-label={showSecret ? '隐藏 Secret' : '显示 Secret'}
|
||||||
|
onClick={() => setShowSecret((v) => !v)}
|
||||||
|
>
|
||||||
|
{showSecret ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField fullWidth label="Discovery URL" type="url" value={form.rainid_discovery_url} onChange={set('rainid_discovery_url')} margin="normal" placeholder="https://rainid.rainnya.asia/oauth" helperText="RainID OIDC 配置地址,默认即可" />
|
||||||
|
</Paper>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
|
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ export function me() {
|
|||||||
return request('/auth/me');
|
return request('/auth/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── RainID 单点登录(OIDC) ──────────────────────────────
|
||||||
|
/** 发起授权码登录:整页跳转到后端 /api/auth/oidc/login(后端 302 至 RainID) */
|
||||||
|
export function oidcLoginRedirect() {
|
||||||
|
window.location.href = '/api/auth/oidc/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RainID 回跳:用一次性过渡码(30s)换取会话 token → {token, username, role} */
|
||||||
|
export function oidcExchange(ticket) {
|
||||||
|
return request('/auth/oidc/exchange', { method: 'POST', body: { ticket } });
|
||||||
|
}
|
||||||
|
|
||||||
/** 退出登录:后端无对应接口,仅清除本地 token 并通知登录态变更 */
|
/** 退出登录:后端无对应接口,仅清除本地 token 并通知登录态变更 */
|
||||||
export function logout() {
|
export function logout() {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
|||||||
@@ -31,10 +31,23 @@ export function showCaptcha(action) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 执行并清空排队中的渲染回调(供 onload 全局与竞态兜底共用)
|
||||||
|
function flushCallbacks(type) {
|
||||||
|
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||||
|
const q = window[key] || [];
|
||||||
|
window[key] = [];
|
||||||
|
q.forEach((cb) => { try { cb(); } catch { /* 单条失败不影响其余 */ } });
|
||||||
|
}
|
||||||
|
|
||||||
// 加载第三方验证码脚本(recaptcha / turnstile)
|
// 加载第三方验证码脚本(recaptcha / turnstile)
|
||||||
|
// 注意:脚本通过 ?onload=onTurnstileLoad / onRecaptchaLoad 回调通知就绪,
|
||||||
|
// 必须在注入脚本之前定义好全局 onload 函数(事件丢失 → 排队回调永不触发 → 永久卡「正在加载」)。
|
||||||
function ensureThirdPartyScript(type) {
|
function ensureThirdPartyScript(type) {
|
||||||
if (type === 'recaptcha') {
|
if (type === 'recaptcha') {
|
||||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||||||
|
if (!window.onRecaptchaLoad) {
|
||||||
|
window.onRecaptchaLoad = () => flushCallbacks('recaptcha');
|
||||||
|
}
|
||||||
if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) {
|
if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
|
s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
|
||||||
@@ -43,6 +56,9 @@ function ensureThirdPartyScript(type) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||||||
|
if (!window.onTurnstileLoad) {
|
||||||
|
window.onTurnstileLoad = () => flushCallbacks('turnstile');
|
||||||
|
}
|
||||||
if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) {
|
if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit';
|
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit';
|
||||||
@@ -52,6 +68,20 @@ function ensureThirdPartyScript(type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底
|
||||||
|
// (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。
|
||||||
|
function queueRender(type, render) {
|
||||||
|
const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile';
|
||||||
|
if (typeof window[lib] !== 'undefined') { render(); return; }
|
||||||
|
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
|
||||||
|
window[key] = window[key] || [];
|
||||||
|
window[key].push(render);
|
||||||
|
ensureThirdPartyScript(type);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof window[lib] !== 'undefined') flushCallbacks(type);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
// SHA-256(Proof of Work 使用)
|
// SHA-256(Proof of Work 使用)
|
||||||
async function sha256(str) {
|
async function sha256(str) {
|
||||||
const buf = new TextEncoder().encode(str);
|
const buf = new TextEncoder().encode(str);
|
||||||
@@ -185,6 +215,7 @@ function ThirdPartyCaptcha({ type, onFinish }) {
|
|||||||
if (!siteKey) { onFinish(null); return; }
|
if (!siteKey) { onFinish(null); return; }
|
||||||
const container = widgetRef.current;
|
const container = widgetRef.current;
|
||||||
const render = () => {
|
const render = () => {
|
||||||
|
if (doneRef.current) return; // 弹窗已取消/卸载,不再向已移除的容器渲染
|
||||||
try {
|
try {
|
||||||
if (type === 'recaptcha') {
|
if (type === 'recaptcha') {
|
||||||
const wid = window.grecaptcha.render(container, {
|
const wid = window.grecaptcha.render(container, {
|
||||||
@@ -215,21 +246,8 @@ function ThirdPartyCaptcha({ type, onFinish }) {
|
|||||||
setTimeout(() => onFinish(null), 2000);
|
setTimeout(() => onFinish(null), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (type === 'recaptcha') {
|
// 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底)
|
||||||
if (typeof window.grecaptcha !== 'undefined') render();
|
queueRender(type, render);
|
||||||
else {
|
|
||||||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
|
||||||
window.recaptchaCallbacks.push(render);
|
|
||||||
ensureThirdPartyScript(type);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (typeof window.turnstile !== 'undefined') render();
|
|
||||||
else {
|
|
||||||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
|
||||||
window.turnstileCallbacks.push(render);
|
|
||||||
ensureThirdPartyScript(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 卸载时不触发重复回调
|
// 卸载时不触发重复回调
|
||||||
return () => { doneRef.current = true; };
|
return () => { doneRef.current = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export default function Layout() {
|
|||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
notifyAuthChange();
|
notifyAuthChange();
|
||||||
|
if (settings.rainid_enabled === '1') {
|
||||||
|
// RainID 单点登录:清本地后联动登出 RainID 端会话(整页跳转,不显示本地提示)
|
||||||
|
window.location.href = '/api/auth/oidc/logout';
|
||||||
|
return;
|
||||||
|
}
|
||||||
showSnackbar('已退出登录');
|
showSnackbar('已退出登录');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import * as authApi from '../api/auth.js';
|
import * as authApi from '../api/auth.js';
|
||||||
|
import * as settingsApi from '../api/settings.js';
|
||||||
import { required as captchaRequired } from '../api/captcha.js';
|
import { required as captchaRequired } from '../api/captcha.js';
|
||||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||||
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
|
||||||
|
|
||||||
|
/** RainID OIDC 回跳错误码 → 用户可读文案 */
|
||||||
|
const OIDC_ERROR_TEXT = {
|
||||||
|
access_denied: '已在 RainID 拒绝授权,可重新登录',
|
||||||
|
consent_required: '请重新完整走 RainID 登录',
|
||||||
|
login_required: '请重新完整走 RainID 登录',
|
||||||
|
invalid_grant: '登录状态已失效,请重新登录',
|
||||||
|
rate_limited: '请求过于频繁,请稍后再试',
|
||||||
|
invalid_scope: '配置错误,请联系管理员',
|
||||||
|
invalid_request: '配置错误,请联系管理员',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录页(迁移自 login.html):
|
* 登录页(迁移自 login.html):
|
||||||
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof;
|
||||||
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
|
||||||
|
* RainID 单点登录:rainid_enabled 时显示「使用 RainID 登录」按钮;回跳(?oidc_ticket / ?oidc_error)
|
||||||
|
* 在此页面收尾——一次性过渡码换 token / 错误码映射提示。ROPC 表单逻辑不变(后端已转发)。
|
||||||
*/
|
*/
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -21,19 +35,69 @@ export default function Login() {
|
|||||||
const [capRequired, setCapRequired] = useState(false);
|
const [capRequired, setCapRequired] = useState(false);
|
||||||
const [capDone, setCapDone] = useState(false);
|
const [capDone, setCapDone] = useState(false);
|
||||||
const [capResult, setCapResult] = useState(null);
|
const [capResult, setCapResult] = useState(null);
|
||||||
|
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||||
|
const [oidcBusy, setOidcBusy] = useState(false);
|
||||||
|
|
||||||
|
/** 清理地址栏的 oidc 过渡参数(替换为纯路径,避免残留/刷新重复兑换) */
|
||||||
|
const clearOidcParams = () => {
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** RainID 回跳收尾:一次性过渡码换 token → 复用本地登录成功逻辑 */
|
||||||
|
const handleOidcTicket = async (ticket) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const data = await authApi.oidcExchange(ticket);
|
||||||
|
setToken(data.token);
|
||||||
|
notifyAuthChange();
|
||||||
|
clearOidcParams();
|
||||||
|
const from = location.state && location.state.from;
|
||||||
|
navigate(from || '/');
|
||||||
|
} catch (e) {
|
||||||
|
clearOidcParams();
|
||||||
|
setError(e.message || 'RainID 登录失败,请重试');
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 1) RainID 回跳优先处理(一次性码 30s 有效,须先兑换)
|
||||||
|
// 注意:OIDC 走整页跳转,此 effect 仅在页面挂载时执行一次;
|
||||||
|
// 兑换后 navigate 触发 location 变化时不再重跑(避免覆盖 from 目标)。
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const ticket = params.get('oidc_ticket');
|
||||||
|
const oidcError = params.get('oidc_error');
|
||||||
|
if (ticket) { handleOidcTicket(ticket); return; }
|
||||||
|
if (oidcError) {
|
||||||
|
setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败,请重试');
|
||||||
|
clearOidcParams();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 2) 已登录直接回首页
|
||||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||||
|
// 3) 公开设置:rainid_enabled → 显示 RainID 按钮
|
||||||
|
settingsApi.getPublicSettings()
|
||||||
|
.then((s) => { if (s.rainid_enabled === '1') setRainidEnabled(true); })
|
||||||
|
.catch(() => {});
|
||||||
|
// 4) 人机验证需求
|
||||||
captchaRequired('login')
|
captchaRequired('login')
|
||||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [navigate]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const doCaptcha = async () => {
|
const doCaptcha = async () => {
|
||||||
const result = await showCaptcha('login');
|
const result = await showCaptcha('login');
|
||||||
if (result) { setCapResult(result); setCapDone(true); }
|
if (result) { setCapResult(result); setCapDone(true); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** RainID 整页跳转(loading 防重复点击) */
|
||||||
|
const handleRainId = () => {
|
||||||
|
if (oidcBusy) return;
|
||||||
|
setOidcBusy(true);
|
||||||
|
authApi.oidcLoginRedirect();
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
|
||||||
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
|
||||||
@@ -46,8 +110,13 @@ export default function Login() {
|
|||||||
const from = location.state && location.state.from;
|
const from = location.state && location.state.from;
|
||||||
navigate(from || '/');
|
navigate(from || '/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// 401 文案透传(含 RainID 已开启时的 2FA 提示:"该账号已开启二次验证,请使用 RainID 登录")
|
||||||
setError(e.message || '登录失败');
|
setError(e.message || '登录失败');
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
// 验证码 proof 为一次性消费:无论密码错还是 proof 已被使用,登录失败后均作废,
|
||||||
|
// 重置「已验证」标记允许用户重新完成验证,否则按钮保持 disabled 死锁。
|
||||||
|
setCapDone(false);
|
||||||
|
setCapResult(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +184,18 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rainidEnabled && (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline w-full"
|
||||||
|
onClick={handleRainId}
|
||||||
|
disabled={busy || oidcBusy}
|
||||||
|
style={{ display: 'inline-flex', marginBottom: 8, justifyContent: 'center', height: 44, gap: 8, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<span className="material-icons" style={{ fontSize: 20 }}>fingerprint</span>
|
||||||
|
<span>{oidcBusy ? '正在跳转 RainID…' : '使用 RainID 登录'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
|
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
|
||||||
{busy ? '登录中...' : '登录'}
|
{busy ? '登录中...' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import * as authApi from '../api/auth.js';
|
import * as authApi from '../api/auth.js';
|
||||||
import * as emailApi from '../api/email.js';
|
import * as emailApi from '../api/email.js';
|
||||||
|
import * as settingsApi from '../api/settings.js';
|
||||||
import { required as captchaRequired } from '../api/captcha.js';
|
import { required as captchaRequired } from '../api/captcha.js';
|
||||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||||
import { getToken } from '../api/client.js';
|
import { getToken } from '../api/client.js';
|
||||||
import { showSnackbar } from '../lib/utils.js';
|
import { showSnackbar } from '../lib/utils.js';
|
||||||
|
|
||||||
|
/** RainID 注册页地址(后端 rainid_register_redirect=1 时整页跳转) */
|
||||||
|
const RAINID_REGISTER_URL = 'https://rainid.rainnya.asia/register';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册页(迁移自 register.html):
|
* 注册页(迁移自 register.html):
|
||||||
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
|
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
|
||||||
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
|
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
|
||||||
|
* RainID:rainid_register_redirect=1 → 整页跳转 RainID 注册(不显示本地表单);
|
||||||
|
* 否则保留本地注册,表单下方提供「使用 RainID 登录」入口。
|
||||||
*/
|
*/
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -25,12 +31,27 @@ export default function Register() {
|
|||||||
const [capRequired, setCapRequired] = useState(false);
|
const [capRequired, setCapRequired] = useState(false);
|
||||||
const [capDone, setCapDone] = useState(false);
|
const [capDone, setCapDone] = useState(false);
|
||||||
const [capResult, setCapResult] = useState(null);
|
const [capResult, setCapResult] = useState(null);
|
||||||
|
const [rainidEnabled, setRainidEnabled] = useState(false);
|
||||||
|
const [redirecting, setRedirecting] = useState(false); // 正在跳转 RainID 注册
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (getToken()) { navigate('/', { replace: true }); return; }
|
if (getToken()) { navigate('/', { replace: true }); return; }
|
||||||
captchaRequired('register')
|
settingsApi.getPublicSettings()
|
||||||
.then((r) => { if (r && r.required) setCapRequired(true); })
|
.then((s) => {
|
||||||
.catch(() => {});
|
if (s.rainid_register_redirect === '1') {
|
||||||
|
// 注册托管给 RainID:短暂提示后整页替换(避免白屏感)
|
||||||
|
setRedirecting(true);
|
||||||
|
setTimeout(() => { window.location.replace(RAINID_REGISTER_URL); }, 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s.rainid_enabled === '1') setRainidEnabled(true);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
captchaRequired('register')
|
||||||
|
.then((r) => { if (r && r.required) setCapRequired(true); })
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const doCaptcha = async () => {
|
const doCaptcha = async () => {
|
||||||
@@ -38,6 +59,11 @@ export default function Register() {
|
|||||||
if (result) { setCapResult(result); setCapDone(true); }
|
if (result) { setCapResult(result); setCapDone(true); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** RainID 登录入口(与登录页同逻辑) */
|
||||||
|
const handleRainId = () => {
|
||||||
|
authApi.oidcLoginRedirect();
|
||||||
|
};
|
||||||
|
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
if (!username.trim() || !email.trim() || !password) { setError('请填写所有必填项'); return; }
|
if (!username.trim() || !email.trim() || !password) { setError('请填写所有必填项'); return; }
|
||||||
if (password.length < 6) { setError('密码至少6位'); return; }
|
if (password.length < 6) { setError('密码至少6位'); return; }
|
||||||
@@ -60,6 +86,9 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || '注册失败');
|
setError(e.message || '注册失败');
|
||||||
|
// 与登录一致:验证码 proof 一次性消费,注册失败后重置验证状态允许重新验证
|
||||||
|
setCapDone(false);
|
||||||
|
setCapResult(null);
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
@@ -85,6 +114,21 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// RainID 注册托管:整页跳转前短暂显示提示
|
||||||
|
if (redirecting) {
|
||||||
|
return (
|
||||||
|
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||||
|
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px', textAlign: 'center' }}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 36, color: 'var(--md-ref-primary)' }}>fingerprint</span>
|
||||||
|
<h1 style={{ fontSize: 22, fontWeight: 500, marginTop: 12 }}>正在跳转 RainID 注册…</h1>
|
||||||
|
<p className="text-muted" style={{ fontSize: 14, marginTop: 8 }}>
|
||||||
|
本平台注册已由 RainID 单点登录托管,即将跳转
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="register-page" style={{ minHeight: '70vh' }}>
|
<div className="register-page" style={{ minHeight: '70vh' }}>
|
||||||
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px' }}>
|
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px' }}>
|
||||||
@@ -167,6 +211,19 @@ export default function Register() {
|
|||||||
<span className="text-muted">已有账户?</span>
|
<span className="text-muted">已有账户?</span>
|
||||||
<Link to="/login.html" className="btn-text btn" style={{ fontSize: 14 }}>登录</Link>
|
<Link to="/login.html" className="btn-text btn" style={{ fontSize: 14 }}>登录</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{rainidEnabled && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-text btn-sm"
|
||||||
|
onClick={handleRainId}
|
||||||
|
style={{ fontSize: 14, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||||
|
>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>fingerprint</span>
|
||||||
|
已有 RainID 账号?使用 RainID 登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div style={{ textAlign: 'center', marginTop: verifyMode ? 16 : 8 }}>
|
<div style={{ textAlign: 'center', marginTop: verifyMode ? 16 : 8 }}>
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ import NotesView from './components/NoteEditor.jsx';
|
|||||||
import VaultView from './components/VaultDrawer.jsx';
|
import VaultView from './components/VaultDrawer.jsx';
|
||||||
import SidebarPane from './components/SidebarPane.jsx';
|
import SidebarPane from './components/SidebarPane.jsx';
|
||||||
import ActivityBar from './components/ActivityBar.jsx';
|
import ActivityBar from './components/ActivityBar.jsx';
|
||||||
|
import CollapseArrow from './components/CollapseArrow.jsx';
|
||||||
import ToolWindow from './components/ToolWindow.jsx';
|
import ToolWindow from './components/ToolWindow.jsx';
|
||||||
import TabStrip from './components/TabStrip.jsx';
|
import TabStrip from './components/TabStrip.jsx';
|
||||||
import ContentView from './components/ContentView.jsx';
|
import ContentView from './components/ContentView.jsx';
|
||||||
import TerminalPanel from './components/TerminalPanel.jsx';
|
|
||||||
import StatusBar from './components/StatusBar.jsx';
|
import StatusBar from './components/StatusBar.jsx';
|
||||||
import PanelFrame from './components/PanelFrame.jsx';
|
import PanelFrame from './components/PanelFrame.jsx';
|
||||||
import AddPanelDialog from './components/AddPanelDialog.jsx';
|
import AddPanelDialog from './components/AddPanelDialog.jsx';
|
||||||
@@ -61,6 +61,21 @@ import SnackHost, { showSnack } from '../../admin/snack.jsx';
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
const LAYOUT_KEY = 'workbench.layout';
|
const LAYOUT_KEY = 'workbench.layout';
|
||||||
|
const MAIN_ACTIVE_KEY = 'workbench.mainActive'; // 主区激活标签(刷新后恢复上次激活项)
|
||||||
|
|
||||||
|
/** 侧边栏默认宽度(px):箭头/活动栏图标展开时的目标宽度 */
|
||||||
|
const SIDEBAR_DEFAULT_WIDTH = { left: 240, right: 300 };
|
||||||
|
/** 视为「已折叠」的宽度阈值(px)。RRP v4 isCollapsed 用零容差比较(尺寸必须精确等于 0),
|
||||||
|
* 布局归一化把面板挤到亚像素宽度时会误判为「未折叠」,导致 expand() 失效、侧栏卡死。
|
||||||
|
* 右侧栏是布局末尾的柔性面板最易被挤压,因此用阈值 + resize() 兜底。 */
|
||||||
|
const SIDEBAR_COLLAPSED_PX = 24;
|
||||||
|
|
||||||
|
function persistMainActive(key) {
|
||||||
|
try {
|
||||||
|
if (key) localStorage.setItem(MAIN_ACTIVE_KEY, key);
|
||||||
|
else localStorage.removeItem(MAIN_ACTIVE_KEY);
|
||||||
|
} catch { /* 隐私模式忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
/** 布局持久化:{left, main, right} 三栏百分比(0-100),活动栏固定 48px 不入库 */
|
/** 布局持久化:{left, main, right} 三栏百分比(0-100),活动栏固定 48px 不入库 */
|
||||||
function readLayout() {
|
function readLayout() {
|
||||||
@@ -196,23 +211,38 @@ function WorkbenchContent() {
|
|||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
const [mainRefresh, setMainRefresh] = useState(0);
|
const [mainRefresh, setMainRefresh] = useState(0);
|
||||||
const [modalRefresh, setModalRefresh] = useState(0);
|
const [modalRefresh, setModalRefresh] = useState(0);
|
||||||
const [activeMainKey, setActiveMainKey] = useState(null);
|
// 主区激活标签:同步从 localStorage 恢复(workbench.mainActive),刷新后回到上次激活项
|
||||||
|
const mainActiveInit = useRef(null);
|
||||||
|
const [activeMainKey, setActiveMainKey] = useState(() => {
|
||||||
|
let v = null;
|
||||||
|
try { v = localStorage.getItem(MAIN_ACTIVE_KEY); } catch { /* ignore */ }
|
||||||
|
mainActiveInit.current = v;
|
||||||
|
return v;
|
||||||
|
});
|
||||||
const [activeDrag, setActiveDrag] = useState(null); // dnd overlay {id,label}
|
const [activeDrag, setActiveDrag] = useState(null); // dnd overlay {id,label}
|
||||||
const searchRef = useRef(null);
|
const searchRef = useRef(null);
|
||||||
|
|
||||||
// ---- react-resizable-panels 引用 ----
|
// ---- react-resizable-panels 引用 ----
|
||||||
const groupRef = useRef(null);
|
const groupRef = useRef(null);
|
||||||
|
const mainRef = useRef(null); // 主面板(始终 ≥minSize 可见),用作 setLayout 像素↔百分比换算探针
|
||||||
const leftRef = useRef(null);
|
const leftRef = useRef(null);
|
||||||
const rightRef = useRef(null);
|
const rightRef = useRef(null);
|
||||||
const termRef = useRef(null);
|
|
||||||
|
|
||||||
const savedLayout = useRef(readLayout());
|
const savedLayout = useRef(readLayout());
|
||||||
|
// 侧边栏折叠状态(供折叠箭头方向/语义;拖拽折叠也会经 onResize 同步)
|
||||||
|
const [leftCollapsed, setLeftCollapsed] = useState(
|
||||||
|
() => !!savedLayout.current && savedLayout.current.left === 0
|
||||||
|
);
|
||||||
|
const [rightCollapsed, setRightCollapsed] = useState(
|
||||||
|
() => !!savedLayout.current && savedLayout.current.right === 0
|
||||||
|
);
|
||||||
// v4 布局为 { PanelId: 百分比 };活动栏有 min/max 48px 硬约束,传多少都会被钳回 48
|
// v4 布局为 { PanelId: 百分比 };活动栏有 min/max 48px 硬约束,传多少都会被钳回 48
|
||||||
const defaultLayout = savedLayout.current
|
const defaultLayout = savedLayout.current
|
||||||
? {
|
? {
|
||||||
'activity-l': 3,
|
'activity-l': 3,
|
||||||
'sidebar-l': Math.max(0, Math.min(40, savedLayout.current.left)),
|
'sidebar-l': Math.max(0, Math.min(40, savedLayout.current.left)),
|
||||||
main: Math.max(5, Math.min(90, savedLayout.current.main)),
|
// main 上限 75%:防止恢复时主区过大挤压两侧栏(右侧栏在布局末尾最易被挤到亚像素卡死)
|
||||||
|
main: Math.max(20, Math.min(75, savedLayout.current.main)),
|
||||||
'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)),
|
'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)),
|
||||||
'activity-r': 3,
|
'activity-r': 3,
|
||||||
}
|
}
|
||||||
@@ -238,13 +268,44 @@ function WorkbenchContent() {
|
|||||||
|
|
||||||
useEffect(() => () => clearTimeout(layoutTimer.current), []);
|
useEffect(() => () => clearTimeout(layoutTimer.current), []);
|
||||||
|
|
||||||
|
/** 程序化调整侧边栏宽度:走 groupRef.setLayout() 整体替换布局。
|
||||||
|
* 不能再依赖 ref.collapse()/resize()/expand()——RRP 命令式 API 按 pivot 相邻面板分配
|
||||||
|
* delta,右侧栏的右邻 activity-r 是刚性面板(minSize=maxSize=48px),delta 被其 clamp
|
||||||
|
* 全部吸收 → 布局不变 → 静默 no-op;拖拽走 Separator(邻面板 main 柔性)所以只有拖拽有效。
|
||||||
|
* setLayout() 不走 pivot:K() 直接归一化 + 逐面板 clamp,目标尺寸落在目标面板上。 */
|
||||||
|
const applySidebarSize = useCallback((side, targetPx) => {
|
||||||
|
const group = groupRef.current;
|
||||||
|
if (!group) return;
|
||||||
|
const cur = group.getLayout();
|
||||||
|
// defaultLayoutDeferred 期间 getLayout() 返回 {},setLayout 也不应用——直接跳过
|
||||||
|
if (!cur || Object.keys(cur).length === 0) return;
|
||||||
|
// 用任一可见面板换算组宽度:groupPx = inPixels / (asPercentage/100)
|
||||||
|
let groupPx = 0;
|
||||||
|
for (const probe of [mainRef.current, rightRef.current, leftRef.current]) {
|
||||||
|
try {
|
||||||
|
const g = probe?.getSize();
|
||||||
|
if (g && g.inPixels > 0 && g.asPercentage > 0) {
|
||||||
|
groupPx = g.inPixels / (g.asPercentage / 100);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch { /* 面板尚未就绪 */ }
|
||||||
|
}
|
||||||
|
const id = side === 'left' ? 'sidebar-l' : 'sidebar-r';
|
||||||
|
if (groupPx > 0) {
|
||||||
|
group.setLayout({ ...cur, [id]: (targetPx / groupPx) * 100 });
|
||||||
|
} else if (targetPx === 0) {
|
||||||
|
group.setLayout({ ...cur, [id]: 0 }); // 折叠:无法换算也直接置 0
|
||||||
|
} else {
|
||||||
|
group.setLayout({ ...cur, [id]: side === 'left' ? 20 : 25 }); // 退化固定百分比,K 的 clamp 兜底
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleSidebar = useCallback((side) => {
|
const toggleSidebar = useCallback((side) => {
|
||||||
const ref = side === 'left' ? leftRef.current : rightRef.current;
|
// 用状态(阈值判定)而非 ref.isCollapsed():RRP 零容差比较在亚像素宽度下会误判
|
||||||
if (!ref) return;
|
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
|
||||||
if (ref.isCollapsed()) ref.expand();
|
applySidebarSize(side, collapsed ? SIDEBAR_DEFAULT_WIDTH[side] : 0);
|
||||||
else ref.collapse();
|
setTimeout(saveLayoutNow, 0); // setLayout 的 onLayoutChanged 不带 isUserInteraction,须手动兜底写盘
|
||||||
setTimeout(saveLayoutNow, 0);
|
}, [leftCollapsed, rightCollapsed, applySidebarSize, saveLayoutNow]);
|
||||||
}, [saveLayoutNow]);
|
|
||||||
|
|
||||||
// ---- 视图(活动栏 / 侧栏 / 标签页) ----
|
// ---- 视图(活动栏 / 侧栏 / 标签页) ----
|
||||||
const leftActive = layout.activeViewOf('left');
|
const leftActive = layout.activeViewOf('left');
|
||||||
@@ -318,20 +379,27 @@ function WorkbenchContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mainTabs = buildMainTabs();
|
const mainTabs = buildMainTabs();
|
||||||
// 活动主标签:优先记住的用户选择,失效则回退到第一个
|
// 活动主标签:优先记住的用户选择(含持久化的上次激活项),失效则回退到第一个
|
||||||
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
|
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
|
||||||
? activeMainKey
|
? activeMainKey
|
||||||
: (mainTabs[0]?.key ?? null);
|
: (mainTabs[0]?.key ?? null);
|
||||||
|
|
||||||
// 打开面板 / 历史导航时自动切到对应 iframe 标签
|
// 打开面板 / 历史导航时自动切到对应 iframe 标签。
|
||||||
|
// 首次挂载若已有持久化激活项(可能指向 tab 模式视图)则尊重它,只消费一次;
|
||||||
|
// 之后每次 activeId 变化都同步并持久化。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeId != null) setActiveMainKey('panel:' + activeId);
|
if (activeId == null) return;
|
||||||
|
if (mainActiveInit.current) { mainActiveInit.current = null; return; }
|
||||||
|
const key = 'panel:' + activeId;
|
||||||
|
setActiveMainKey(key);
|
||||||
|
persistMainActive(key);
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
|
||||||
const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id));
|
const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id));
|
||||||
|
|
||||||
const handleSelectTab = (key) => {
|
const handleSelectTab = (key) => {
|
||||||
setActiveMainKey(key);
|
setActiveMainKey(key);
|
||||||
|
persistMainActive(key);
|
||||||
if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length));
|
if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length));
|
||||||
};
|
};
|
||||||
const handleCloseTab = (key) => {
|
const handleCloseTab = (key) => {
|
||||||
@@ -339,6 +407,14 @@ function WorkbenchContent() {
|
|||||||
else if (key.startsWith('view:')) setViewModeSafely(key.slice('view:'.length), VIEW_MODE_SIDEBAR);
|
else if (key.startsWith('view:')) setViewModeSafely(key.slice('view:'.length), VIEW_MODE_SIDEBAR);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 侧边栏 Panel onResize:阈值判定折叠状态(拖拽折叠/亚像素挤压都覆盖) */
|
||||||
|
const onSidebarResize = useCallback((side) => (size) => {
|
||||||
|
const px = size && typeof size === 'object' ? size.inPixels : size;
|
||||||
|
const collapsed = px < SIDEBAR_COLLAPSED_PX;
|
||||||
|
if (side === 'left') setLeftCollapsed(collapsed);
|
||||||
|
else setRightCollapsed(collapsed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
|
const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
|
||||||
|
|
||||||
const handleOpenExternal = () => {
|
const handleOpenExternal = () => {
|
||||||
@@ -371,10 +447,11 @@ function WorkbenchContent() {
|
|||||||
mode={layout.mode[viewId]}
|
mode={layout.mode[viewId]}
|
||||||
onSetMode={(m) => setViewModeSafely(viewId, m)}
|
onSetMode={(m) => setViewModeSafely(viewId, m)}
|
||||||
onFloat={() => showSnack('浮动模式即将上线', 'info')}
|
onFloat={() => showSnack('浮动模式即将上线', 'info')}
|
||||||
|
closeTitle="关闭侧边栏"
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
// X = 关闭整个侧边栏(折叠到 0 宽度),活动栏图标仍可重新展开
|
||||||
const side = layout.sideOf[viewId];
|
const side = layout.sideOf[viewId];
|
||||||
const ref = side === 'left' ? leftRef.current : rightRef.current;
|
toggleSidebar(side);
|
||||||
ref?.collapse();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
@@ -401,17 +478,16 @@ function WorkbenchContent() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起 */
|
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起(VSCode 语义) */
|
||||||
const handleActivateView = (viewId, side) => {
|
const handleActivateView = (viewId, side) => {
|
||||||
const ref = side === 'left' ? leftRef.current : rightRef.current;
|
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
|
||||||
const alreadyActive = layout.activeViewOf(side) === viewId;
|
const alreadyActive = layout.activeViewOf(side) === viewId;
|
||||||
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
|
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
|
||||||
layout.activateView(viewId);
|
layout.activateView(viewId);
|
||||||
if (ref) {
|
// setLayout 整体替换:展开到默认宽度 / 折叠到 0,绕开 pivot 刚性邻居吸收问题
|
||||||
if (alreadyActive && !ref.isCollapsed()) ref.collapse();
|
if (alreadyActive && !collapsed) applySidebarSize(side, 0);
|
||||||
else ref.expand();
|
else applySidebarSize(side, SIDEBAR_DEFAULT_WIDTH[side]);
|
||||||
setTimeout(saveLayoutNow, 0);
|
setTimeout(saveLayoutNow, 0);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- 菜单栏配置 ----
|
// ---- 菜单栏配置 ----
|
||||||
@@ -444,8 +520,8 @@ function WorkbenchContent() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '终端', items: [
|
label: '终端', items: [
|
||||||
{ label: '切换终端面板', Icon: TerminalIcon, action: () => layout.toggleTerminal() },
|
// 终端功能临时下线(TerminalPanel 已归档到 archive/),保留占位提示,后续恢复
|
||||||
{ label: '新建终端会话', Icon: AddIcon, action: () => { layout.openTerminal(); termRef.current?.newSession(); } },
|
{ label: '终端功能即将回归', Icon: TerminalIcon },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -579,7 +655,7 @@ function WorkbenchContent() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
<ResizeSeparator disabled />
|
<ResizeSeparator disabled />
|
||||||
{/* 左侧侧边栏 */}
|
{/* 左侧侧边栏 */}
|
||||||
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef}>
|
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef} onResize={onSidebarResize('left')}>
|
||||||
<SidebarPane
|
<SidebarPane
|
||||||
side="left"
|
side="left"
|
||||||
views={leftPaneViews}
|
views={leftPaneViews}
|
||||||
@@ -590,8 +666,10 @@ function WorkbenchContent() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
<ResizeSeparator />
|
<ResizeSeparator />
|
||||||
{/* 主区:标签条 + 内容 + 底部终端 */}
|
{/* 主区:标签条 + 内容 + 底部终端 */}
|
||||||
<Panel id="main" minSize={320}>
|
<Panel id="main" minSize={320} panelRef={mainRef}>
|
||||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
{/* 注意:RRP Panel 内容容器是 block 布局(maxHeight:100% + flexGrow:1),
|
||||||
|
这里必须用 height:'100%' 而非 flex:1,否则高度塌陷导致内容挤顶部 */}
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden', position: 'relative' }}>
|
||||||
{mainTabs.length > 0 && (
|
{mainTabs.length > 0 && (
|
||||||
<TabStrip
|
<TabStrip
|
||||||
tabs={mainTabs}
|
tabs={mainTabs}
|
||||||
@@ -604,23 +682,15 @@ function WorkbenchContent() {
|
|||||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
|
||||||
{mainBody}
|
{mainBody}
|
||||||
</Box>
|
</Box>
|
||||||
{/* 底部终端:默认隐藏,keep-alive 不销毁会话 */}
|
{/* 侧边栏折叠箭头:贴主区整高的边缘(即侧边栏与主区 Separator 位置)垂直居中,
|
||||||
<Box
|
折叠为 0 后主区仍在 → 箭头始终可点,支持再点击展开 */}
|
||||||
sx={{
|
<CollapseArrow side="left" collapsed={leftCollapsed} onToggle={() => toggleSidebar('left')} />
|
||||||
height: layout.terminalOpen ? 240 : 0,
|
<CollapseArrow side="right" collapsed={rightCollapsed} onToggle={() => toggleSidebar('right')} />
|
||||||
flexShrink: 0, overflow: 'hidden',
|
|
||||||
transition: 'height .18s ease',
|
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
borderTop: layout.terminalOpen ? 1 : 0, borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TerminalPanel ref={termRef} open={layout.terminalOpen} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Panel>
|
</Panel>
|
||||||
<ResizeSeparator />
|
<ResizeSeparator />
|
||||||
{/* 右侧侧边栏 */}
|
{/* 右侧侧边栏 */}
|
||||||
<Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef}>
|
<Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef} onResize={onSidebarResize('right')}>
|
||||||
<SidebarPane
|
<SidebarPane
|
||||||
side="right"
|
side="right"
|
||||||
views={rightPaneViews}
|
views={rightPaneViews}
|
||||||
@@ -637,8 +707,6 @@ function WorkbenchContent() {
|
|||||||
views={rightBarViews}
|
views={rightBarViews}
|
||||||
activeId={rightActive}
|
activeId={rightActive}
|
||||||
onActivate={(id) => handleActivateView(id, 'right')}
|
onActivate={(id) => handleActivateView(id, 'right')}
|
||||||
terminalOpen={layout.terminalOpen}
|
|
||||||
onToggleTerminal={layout.toggleTerminal}
|
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
+52
-17
@@ -415,23 +415,37 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
|||||||
if (!inst || !el) return;
|
if (!inst || !el) return;
|
||||||
if (inst.term) {
|
if (inst.term) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { inst.fit?.fit(); manager.current.sendResize(id); } catch {}
|
try {
|
||||||
|
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit?.fit(); manager.current.sendResize(id); }
|
||||||
|
} catch {}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const term = new Terminal({
|
let term;
|
||||||
fontSize: 13,
|
let fit = null;
|
||||||
fontFamily: FONT,
|
try {
|
||||||
lineHeight: 1.35,
|
term = new Terminal({
|
||||||
cursorBlink: true,
|
fontSize: 13,
|
||||||
scrollback: 3000,
|
fontFamily: FONT,
|
||||||
theme: themeObj,
|
lineHeight: 1.35,
|
||||||
});
|
cursorBlink: true,
|
||||||
const fit = new FitAddon();
|
scrollback: 3000,
|
||||||
term.loadAddon(fit);
|
theme: themeObj,
|
||||||
term.loadAddon(new Unicode11Addon());
|
});
|
||||||
term.unicode.activeVersion = '11';
|
fit = new FitAddon();
|
||||||
term.open(el);
|
term.loadAddon(fit);
|
||||||
|
term.loadAddon(new Unicode11Addon());
|
||||||
|
term.unicode.activeVersion = '11';
|
||||||
|
term.open(el);
|
||||||
|
inst.term = term;
|
||||||
|
inst.fit = fit;
|
||||||
|
} catch (e) {
|
||||||
|
// xterm 初始化异常:显式报错而非白屏/整树崩溃
|
||||||
|
console.error('[TerminalPanel] xterm 初始化失败:', e);
|
||||||
|
patchStatus(id, 'error');
|
||||||
|
setBanner({ type: 'error', text: `终端初始化失败:${e?.message || '未知错误'}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
term.onData((d) => {
|
term.onData((d) => {
|
||||||
const ws = inst.ws;
|
const ws = inst.ws;
|
||||||
if (!ws || ws.readyState !== 1) return;
|
if (!ws || ws.readyState !== 1) return;
|
||||||
@@ -449,11 +463,12 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
inst.term = term;
|
|
||||||
inst.fit = fit;
|
|
||||||
inst.ro = ro;
|
inst.ro = ro;
|
||||||
|
// rAF fit 同样做尺寸守卫:容器零尺寸时跳过,避免 0 行列渲染成空白
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { fit.fit(); manager.current.sendResize(id); } catch {}
|
try {
|
||||||
|
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit.fit(); manager.current.sendResize(id); }
|
||||||
|
} catch {}
|
||||||
});
|
});
|
||||||
manager.current.connectSession(id);
|
manager.current.connectSession(id);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -672,6 +687,26 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} />
|
<Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} />
|
||||||
|
{/* 未连接状态条:防止"白屏"——始终可见的连接进度/错误提示(banner 可被关掉) */}
|
||||||
|
{s.status !== 'connected' && s.status !== 'ended' && (
|
||||||
|
<Box
|
||||||
|
role="status"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 3,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5,
|
||||||
|
bgcolor: s.status === 'error' ? 'error.main' : 'warning.main',
|
||||||
|
color: s.status === 'error' ? 'error.contrastText' : 'warning.contrastText',
|
||||||
|
borderTop: 1, borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.status !== 'error' && <CircularProgress size={12} color="inherit" />}
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||||
|
{s.status === 'connecting' ? '正在连接终端服务…'
|
||||||
|
: s.status === 'reconnecting' ? '连接中断,正在重试…'
|
||||||
|
: '终端服务不可用'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{/* 会话终止覆盖层:exit/空闲超时后不自动重生,显式重连 */}
|
{/* 会话终止覆盖层:exit/空闲超时后不自动重生,显式重连 */}
|
||||||
{s.status === 'ended' && (
|
{s.status === 'ended' && (
|
||||||
<Box
|
<Box
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-
|
|||||||
* - 无障碍:role=toolbar,仅活动项 tabIndex=0(roving),
|
* - 无障碍:role=toolbar,仅活动项 tabIndex=0(roving),
|
||||||
* ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器;
|
* ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器;
|
||||||
* 侧栏内由 aria-live 播报当前视图(见 SidebarPane)
|
* 侧栏内由 aria-live 播报当前视图(见 SidebarPane)
|
||||||
* - 右侧底部固定「终端」呼出按钮(不参与拖拽)
|
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
|
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
|
||||||
@@ -73,33 +71,8 @@ function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TerminalButton({ open, onToggle }) {
|
|
||||||
return (
|
|
||||||
<Tooltip title="终端" placement="right">
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
aria-label="终端"
|
|
||||||
aria-pressed={open}
|
|
||||||
sx={{
|
|
||||||
width: 48, height: 48, flexShrink: 0, border: 'none', background: 'transparent',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
|
||||||
color: open ? 'primary.main' : 'text.secondary',
|
|
||||||
bgcolor: open ? 'action.selected' : 'transparent',
|
|
||||||
'&:hover': { bgcolor: open ? 'action.selected' : 'action.hover' },
|
|
||||||
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -3 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TerminalIcon sx={{ fontSize: 22 }} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ActivityBar({
|
export default function ActivityBar({
|
||||||
side, views, activeId, onActivate,
|
side, views, activeId, onActivate,
|
||||||
terminalOpen = false, onToggleTerminal,
|
|
||||||
}) {
|
}) {
|
||||||
const right = side === 'right';
|
const right = side === 'right';
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
|
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
|
||||||
@@ -146,7 +119,6 @@ export default function ActivityBar({
|
|||||||
</Box>
|
</Box>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
<Box sx={{ flex: 1 }} />
|
<Box sx={{ flex: 1 }} />
|
||||||
{right && <TerminalButton open={terminalOpen} onToggle={onToggleTerminal} />}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* CollapseArrow:侧边栏折叠箭头(VSCode 风格)
|
||||||
|
*
|
||||||
|
* - 由父级绝对定位到主区边缘(即侧边栏与主区的 Separator 位置)垂直居中;
|
||||||
|
* 侧边栏折叠为 0 后主区仍在,箭头始终可见可点,支持"再点击展开"。
|
||||||
|
* - 箭头始终指向栏的方向:展开时指栏(点击收起),折叠后反向(点击展开)。
|
||||||
|
* - 可见性:**常驻可见**——不透明背景(background.paper)+ 实心边框 + 阴影,
|
||||||
|
* opacity 展开 0.9 / 折叠 1,hover 全亮加深阴影;不再半透明叠加 iframe 混色。
|
||||||
|
* - 28x28 触控目标,zIndex 12 盖在 iframe 内容之上。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export default function CollapseArrow({ side, collapsed = false, onToggle }) {
|
||||||
|
const right = side === 'right';
|
||||||
|
const label = collapsed ? `展开${right ? '右' : '左'}侧边栏` : `折叠${right ? '右' : '左'}侧边栏`;
|
||||||
|
// 展开 → 指向栏(收起动作);折叠 → 反向(展开动作)
|
||||||
|
const Icon = collapsed
|
||||||
|
? (right ? ChevronLeftIcon : ChevronRightIcon)
|
||||||
|
: (right ? ChevronRightIcon : ChevronLeftIcon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={label} placement={right ? 'left' : 'right'}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onToggle}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', top: '50%', [right ? 'right' : 'left']: 2,
|
||||||
|
transform: 'translateY(-50%)', zIndex: 12,
|
||||||
|
width: 28, height: 28, minWidth: 28, p: 0,
|
||||||
|
opacity: collapsed ? 1 : 0.9,
|
||||||
|
bgcolor: 'background.paper', border: 1, borderColor: 'divider',
|
||||||
|
boxShadow: (t) => t.shadows[2],
|
||||||
|
transition: 'opacity .12s ease, background-color .12s ease, box-shadow .12s ease',
|
||||||
|
'&:hover': { opacity: 1, bgcolor: 'action.hover', boxShadow: (t) => t.shadows[4] },
|
||||||
|
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,28 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 面板图标:Google favicon 服务 + 失败回退(首字母块 / 地球图标)
|
* 面板图标:Google favicon 服务 + 失败回退(站点首字母 + 确定性 MD3 容器色)
|
||||||
|
*
|
||||||
|
* - 成功:显示 favicon(不变)
|
||||||
|
* - 失败/无 host:显示首字母色块(类似 Google Avatar)——
|
||||||
|
* 背景色从 MD3 容器色(primary/secondary/tertiary.container)按
|
||||||
|
* 域名/标题哈希确定性选取,比默认地球图标更可辨识。
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 确定性回退色板:MD3 token(不硬编码 hex),按哈希循环选取 */
|
||||||
|
const FALLBACK_PALETTE = [
|
||||||
|
{ bg: 'primary.container', fg: 'primary.onContainer' },
|
||||||
|
{ bg: 'secondary.container', fg: 'secondary.onContainer' },
|
||||||
|
{ bg: 'tertiary.container', fg: 'tertiary.onContainer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function hashString(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
function hostOf(url) {
|
function hostOf(url) {
|
||||||
try {
|
try {
|
||||||
const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url;
|
const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url;
|
||||||
@@ -17,19 +34,24 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const host = hostOf(url);
|
const host = hostOf(url);
|
||||||
|
|
||||||
if (!host) {
|
// 首字母 + 确定性颜色
|
||||||
|
const letter = ((title && title[0]) || (host && host[0]) || '?').toUpperCase();
|
||||||
|
const { bg, fg } = FALLBACK_PALETTE[hashString(host || title || '?') % FALLBACK_PALETTE.length];
|
||||||
|
|
||||||
|
// 无 host(内部地址/空)或 favicon 加载失败(404/API 不可达)→ 首字母色块
|
||||||
|
if (!host || error) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
aria-hidden
|
aria-hidden
|
||||||
sx={{
|
sx={{
|
||||||
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
width: size, height: size, borderRadius: size / 4, flexShrink: 0,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
bgcolor: 'primary.container', color: 'primary.onContainer',
|
bgcolor: bg, color: fg,
|
||||||
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
|
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(title[0] || '?').toUpperCase()}
|
{letter}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,19 +65,15 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
|
|||||||
bgcolor: 'background.paper', ...sx,
|
bgcolor: 'background.paper', ...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error ? (
|
<img
|
||||||
<LanguageIcon sx={{ fontSize: size * 0.7, color: 'text.disabled' }} />
|
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
|
||||||
) : (
|
alt=""
|
||||||
<img
|
width={size}
|
||||||
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
|
height={size}
|
||||||
alt=""
|
loading="lazy"
|
||||||
width={size}
|
onError={() => setError(true)}
|
||||||
height={size}
|
style={{ display: 'block' }}
|
||||||
loading="lazy"
|
/>
|
||||||
onError={() => setError(true)}
|
|
||||||
style={{ display: 'block' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ export default function PanelsView({
|
|||||||
return m;
|
return m;
|
||||||
}, [links]);
|
}, [links]);
|
||||||
|
|
||||||
const pinnedPanels = links.filter((p) => pinned.includes(p.id));
|
const pinnedPanels = links.filter((p) => pinned.includes(String(p.id)));
|
||||||
|
|
||||||
const renderItem = (p) => {
|
const renderItem = (p) => {
|
||||||
const selected = String(p.id) === String(activeId);
|
const selected = String(p.id) === String(activeId);
|
||||||
const isPinned = pinned.includes(p.id);
|
const isPinned = pinned.includes(String(p.id));
|
||||||
const mode = modes[p.id] || OPEN_MODE_EMBED;
|
const mode = modes[p.id] || OPEN_MODE_EMBED;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { VIEW_MODE_SIDEBAR, VIEW_MODE_TAB } from '../hooks/useWorkbenchLayout.js
|
|||||||
* - 纯展示组件,无内部状态;模式/关闭行为由父级注入
|
* - 纯展示组件,无内部状态;模式/关闭行为由父级注入
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, actions = null, children }) {
|
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, closeTitle = '关闭', actions = null, children }) {
|
||||||
const modeBtn = (m, label, MIcon) => (
|
const modeBtn = (m, label, MIcon) => (
|
||||||
<Tooltip key={m} title={label}>
|
<Tooltip key={m} title={label}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -65,11 +65,11 @@ export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onCl
|
|||||||
)}
|
)}
|
||||||
{actions}
|
{actions}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<Tooltip title="关闭">
|
<Tooltip title={closeTitle}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="关闭"
|
aria-label={closeTitle}
|
||||||
sx={{ width: 26, height: 26, borderRadius: 1 }}
|
sx={{ width: 26, height: 26, borderRadius: 1 }}
|
||||||
>
|
>
|
||||||
<CloseIcon sx={{ fontSize: 15 }} />
|
<CloseIcon sx={{ fontSize: 15 }} />
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import PanelIcon from './PanelIcon.jsx';
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
* Toolbar:VSCode 风格顶栏(高度 56px)
|
* Toolbar:VSCode 风格顶栏(高度 56px)
|
||||||
*
|
*
|
||||||
* Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 缩小地址栏(只读) | 搜索 | 返回
|
* Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 地址栏(自适应填满) | 搜索 | 返回
|
||||||
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签
|
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签;
|
||||||
|
* flex:1 填满顶栏剩余空间,左侧内容变宽时自然缩减
|
||||||
* - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入)
|
* - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ export default function Toolbar({
|
|||||||
component="span"
|
component="span"
|
||||||
aria-label="Rain Work"
|
aria-label="Rain Work"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 15, fontWeight: 700, letterSpacing: 0.3, mr: 0.75, flexShrink: 0,
|
fontSize: 19.5, fontWeight: 700, letterSpacing: 0.4, mr: 1, flexShrink: 0,
|
||||||
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
|
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
|
||||||
WebkitTextFillColor: 'transparent', color: 'transparent',
|
WebkitTextFillColor: 'transparent', color: 'transparent',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
@@ -75,12 +76,13 @@ export default function Toolbar({
|
|||||||
<span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span>
|
<span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* 缩小地址栏(只读) */}
|
{/* 地址栏(只读,flex:1 自适应填满顶栏剩余空间;无 maxWidth 上限,
|
||||||
|
左侧菜单/导航变宽时自然缩减,顶栏始终占满) */}
|
||||||
<Box
|
<Box
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-label="当前面板地址"
|
aria-label="当前面板地址"
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1, minWidth: 120, maxWidth: 360, display: 'flex', alignItems: 'center', gap: 0.75,
|
flex: 1, minWidth: 120, maxWidth: 'none', display: 'flex', alignItems: 'center', gap: 0.75,
|
||||||
mx: 0.75, height: 34, px: 1.25,
|
mx: 0.75, height: 34, px: 1.25,
|
||||||
bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25,
|
bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25,
|
||||||
'&:focus-within': { borderColor: 'primary.main' },
|
'&:focus-within': { borderColor: 'primary.main' },
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ export default function useNotes() {
|
|||||||
|
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
// ---- 保存(PUT) ----
|
||||||
|
// 注意:必须声明在引用它的 useEffect(会话恢复 / save-request 监听)之前,
|
||||||
|
// 否则渲染期求值依赖数组会触发 TDZ(Cannot access before initialization)。
|
||||||
|
const saveContent = useCallback((name, text) => {
|
||||||
|
setSaving(true);
|
||||||
|
return notesApi.updateNote(name, text)
|
||||||
|
.then((res) => {
|
||||||
|
setSavedContent(text);
|
||||||
|
sessionCache.text = text;
|
||||||
|
setNotes((prev) => (prev
|
||||||
|
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
|
||||||
|
: prev));
|
||||||
|
})
|
||||||
|
.catch((e) => showSnack(e.message, 'error'))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ---- 会话恢复(挂载一次):缓存有同名草稿→直接恢复并推给服务端(幂等);
|
// ---- 会话恢复(挂载一次):缓存有同名草稿→直接恢复并推给服务端(幂等);
|
||||||
// 无缓存但记住了文件名→从服务端读取 ----
|
// 无缓存但记住了文件名→从服务端读取 ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,21 +111,6 @@ export default function useNotes() {
|
|||||||
}
|
}
|
||||||
}, [activeName]);
|
}, [activeName]);
|
||||||
|
|
||||||
// ---- 保存(PUT) ----
|
|
||||||
const saveContent = useCallback((name, text) => {
|
|
||||||
setSaving(true);
|
|
||||||
return notesApi.updateNote(name, text)
|
|
||||||
.then((res) => {
|
|
||||||
setSavedContent(text);
|
|
||||||
sessionCache.text = text;
|
|
||||||
setNotes((prev) => (prev
|
|
||||||
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
|
|
||||||
: prev));
|
|
||||||
})
|
|
||||||
.catch((e) => showSnack(e.message, 'error'))
|
|
||||||
.finally(() => setSaving(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ---- 打开文件 ----
|
// ---- 打开文件 ----
|
||||||
const open = useCallback((name) => {
|
const open = useCallback((name) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|||||||
@@ -36,14 +36,31 @@ function write(key, value) {
|
|||||||
export default function usePanels() {
|
export default function usePanels() {
|
||||||
const [links, setLinks] = useState(null); // null = 加载中
|
const [links, setLinks] = useState(null); // null = 加载中
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [openIds, setOpenIds] = useState(() => read(STORE.open, []));
|
// 初始化即清洗(治愈已污染的 localStorage 存量数据):
|
||||||
const [activeId, setActiveId] = useState(() => read(STORE.active, null));
|
// 面板 id 统一为字符串 + 去重。此前 openPanel 被传入数字(p.id)与字符串
|
||||||
|
// (handleSelectTab 的 key.slice) 两种类型,prev.includes 严格比较漏判 →
|
||||||
|
// 同一面板以两份加入 openIds → 双 iframe 并排"分屏"、closePanel 关不掉。
|
||||||
|
const [openIds, setOpenIds] = useState(() => {
|
||||||
|
const a = read(STORE.open, []);
|
||||||
|
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
|
||||||
|
});
|
||||||
|
const [activeId, setActiveId] = useState(() => {
|
||||||
|
const v = read(STORE.active, null);
|
||||||
|
return v == null ? null : String(v);
|
||||||
|
});
|
||||||
const [history, setHistory] = useState(() => {
|
const [history, setHistory] = useState(() => {
|
||||||
const h = read(STORE.history, null);
|
const h = read(STORE.history, null);
|
||||||
return (h && Array.isArray(h.ids)) ? h : { ids: [], index: -1 };
|
if (h && Array.isArray(h.ids)) {
|
||||||
|
const ids = [...new Set(h.ids.map(String))];
|
||||||
|
return { ids, index: Math.min(Number(h.index) || -1, ids.length - 1) };
|
||||||
|
}
|
||||||
|
return { ids: [], index: -1 };
|
||||||
});
|
});
|
||||||
const [groups, setGroups] = useState(() => read(STORE.groups, {})); // name -> true 表示折叠
|
const [groups, setGroups] = useState(() => read(STORE.groups, {})); // name -> true 表示折叠
|
||||||
const [pinned, setPinned] = useState(() => read(STORE.pinned, []));
|
const [pinned, setPinned] = useState(() => {
|
||||||
|
const a = read(STORE.pinned, []);
|
||||||
|
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
|
||||||
|
});
|
||||||
const [modes, setModes] = useState(() => read(STORE.modes, {}));
|
const [modes, setModes] = useState(() => read(STORE.modes, {}));
|
||||||
const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
|
const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -68,11 +85,12 @@ export default function usePanels() {
|
|||||||
const arr = Array.isArray(ls) ? ls : [];
|
const arr = Array.isArray(ls) ? ls : [];
|
||||||
setLinks(arr);
|
setLinks(arr);
|
||||||
const valid = new Set(arr.map((p) => String(p.id)));
|
const valid = new Set(arr.map((p) => String(p.id)));
|
||||||
setOpenIds((prev) => prev.filter((id) => valid.has(id)));
|
// String 归一化后再过滤,防止存量数字/字符串混杂 id 绕过校验
|
||||||
setPinned((prev) => prev.filter((id) => valid.has(id)));
|
setOpenIds((prev) => prev.map(String).filter((id) => valid.has(id)));
|
||||||
|
setPinned((prev) => prev.map(String).filter((id) => valid.has(id)));
|
||||||
setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id))));
|
setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id))));
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const ids = prev.ids.filter((id) => valid.has(id));
|
const ids = prev.ids.map(String).filter((id) => valid.has(id));
|
||||||
return { ids, index: Math.min(prev.index, ids.length - 1) };
|
return { ids, index: Math.min(prev.index, ids.length - 1) };
|
||||||
});
|
});
|
||||||
return arr;
|
return arr;
|
||||||
@@ -99,7 +117,8 @@ export default function usePanels() {
|
|||||||
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
|
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
|
||||||
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
|
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
|
||||||
const openPanel = useCallback((id, panelOverride) => {
|
const openPanel = useCallback((id, panelOverride) => {
|
||||||
const panel = panelOverride || (links && links.find((p) => String(p.id) === String(id)));
|
id = String(id); // 统一字符串,避免数字/字符串双份加入 openIds
|
||||||
|
const panel = panelOverride || (links && links.find((p) => String(p.id) === id));
|
||||||
if (!panel) return false;
|
if (!panel) return false;
|
||||||
touch(id);
|
touch(id);
|
||||||
const mode = modes[id] || OPEN_MODE_EMBED;
|
const mode = modes[id] || OPEN_MODE_EMBED;
|
||||||
@@ -108,7 +127,7 @@ export default function usePanels() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
|
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
|
||||||
if (String(activeId) === String(id)) return false;
|
if (activeId === id) return false;
|
||||||
setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const ids = prev.ids.slice(0, prev.index + 1);
|
const ids = prev.ids.slice(0, prev.index + 1);
|
||||||
@@ -143,6 +162,7 @@ export default function usePanels() {
|
|||||||
|
|
||||||
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
|
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
|
||||||
const closePanel = useCallback((id) => {
|
const closePanel = useCallback((id) => {
|
||||||
|
id = String(id); // 统一字符串:此前数字/字符串混杂导致 filter 漏删、标签关不掉
|
||||||
const nextOpen = openIds.filter((x) => x !== id);
|
const nextOpen = openIds.filter((x) => x !== id);
|
||||||
setOpenIds(nextOpen);
|
setOpenIds(nextOpen);
|
||||||
delete recency.current[id];
|
delete recency.current[id];
|
||||||
@@ -157,6 +177,7 @@ export default function usePanels() {
|
|||||||
}, [history, openIds]);
|
}, [history, openIds]);
|
||||||
|
|
||||||
const togglePin = useCallback((id) => {
|
const togglePin = useCallback((id) => {
|
||||||
|
id = String(id); // 防止固定集合再次被数字污染
|
||||||
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -49,17 +49,48 @@ function write(key, value) {
|
|||||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧 */
|
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧,且保证每侧至少一个视图 */
|
||||||
function sanitizeOrder(saved) {
|
function sanitizeOrder(saved) {
|
||||||
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
|
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const clean = (arr) => arr.filter((id) => VIEW_IDS.includes(id) && !seen.has((seen.add(id), id)));
|
const clean = (arr) => {
|
||||||
|
const out = [];
|
||||||
|
arr.forEach((id) => {
|
||||||
|
if (!VIEW_IDS.includes(id) || seen.has(id)) return; // 先查重,后登记
|
||||||
|
seen.add(id);
|
||||||
|
out.push(id);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
const left = clean(saved.left);
|
const left = clean(saved.left);
|
||||||
const right = clean(saved.right);
|
const right = clean(saved.right);
|
||||||
VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); });
|
VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); });
|
||||||
|
// 兜底:持久化可能把视图全挪到一侧(或空数组),刷新后另一侧空白打不开。
|
||||||
|
// 保证每侧至少一个视图(panels 优先左侧,notes 次之)。
|
||||||
|
if (left.length === 0 && right.length > 0) {
|
||||||
|
const preferred = ['panels', 'notes'].find((id) => right.includes(id));
|
||||||
|
const move = preferred || right[0];
|
||||||
|
left.push(move);
|
||||||
|
right.splice(right.indexOf(move), 1);
|
||||||
|
}
|
||||||
|
if (right.length === 0 && left.length > 0) {
|
||||||
|
const preferred = ['notes', 'vault'].find((id) => left.includes(id));
|
||||||
|
const move = preferred || left[0];
|
||||||
|
right.push(move);
|
||||||
|
left.splice(left.indexOf(move), 1);
|
||||||
|
}
|
||||||
return { left, right };
|
return { left, right };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验 active:指向的视图必须仍在本侧,否则回退到该侧第一个 */
|
||||||
|
function sanitizeActive(saved, order) {
|
||||||
|
if (!saved || typeof saved !== 'object') return { ...DEFAULT_ACTIVE };
|
||||||
|
const pick = (side, fallback) => (
|
||||||
|
order[side].includes(saved[side]) ? saved[side] : (order[side][0] || fallback)
|
||||||
|
);
|
||||||
|
return { left: pick('left', 'panels'), right: pick('right', 'notes') };
|
||||||
|
}
|
||||||
|
|
||||||
/** 校验 mode:只接受 sidebar/tab(float 为保留值,不持久化激活) */
|
/** 校验 mode:只接受 sidebar/tab(float 为保留值,不持久化激活) */
|
||||||
function sanitizeMode(saved) {
|
function sanitizeMode(saved) {
|
||||||
const out = { ...DEFAULT_MODE };
|
const out = { ...DEFAULT_MODE };
|
||||||
@@ -73,10 +104,7 @@ function sanitizeMode(saved) {
|
|||||||
|
|
||||||
export default function useWorkbenchLayout() {
|
export default function useWorkbenchLayout() {
|
||||||
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
|
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
|
||||||
const [active, setActive] = useState(() => {
|
const [active, setActive] = useState(() => sanitizeActive(read(STORE.active, null), order));
|
||||||
const saved = read(STORE.active, null);
|
|
||||||
return saved && saved.left && saved.right ? saved : DEFAULT_ACTIVE;
|
|
||||||
});
|
|
||||||
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
|
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
|
||||||
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
|
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export { default as SidebarPane } from './components/SidebarPane.jsx';
|
|||||||
export { default as ToolWindow } from './components/ToolWindow.jsx';
|
export { default as ToolWindow } from './components/ToolWindow.jsx';
|
||||||
export { default as TabStrip } from './components/TabStrip.jsx';
|
export { default as TabStrip } from './components/TabStrip.jsx';
|
||||||
export { default as ContentView } from './components/ContentView.jsx';
|
export { default as ContentView } from './components/ContentView.jsx';
|
||||||
export { default as TerminalPanel } from './components/TerminalPanel.jsx';
|
// 终端面板临时下线:组件归档到 archive/(前端入口已移除,恢复时改回 components/ 路径)
|
||||||
|
export { default as TerminalPanel } from './archive/TerminalPanel.jsx';
|
||||||
export { default as StatusBar } from './components/StatusBar.jsx';
|
export { default as StatusBar } from './components/StatusBar.jsx';
|
||||||
export { default as MenuBar } from './components/MenuBar.jsx';
|
export { default as MenuBar } from './components/MenuBar.jsx';
|
||||||
export { default as useNotes } from './hooks/useNotes.js';
|
export { default as useNotes } from './hooks/useNotes.js';
|
||||||
|
|||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
// RainID OIDC 接入共享逻辑(routes/auth.js ROPC 密码登录 / routes/oidc.js 授权码流共用)
|
||||||
|
// 安全要点:
|
||||||
|
// - client_secret 双通道:优先 .env.json(gitignored),回退后台站点设置
|
||||||
|
// site_settings.rainid_client_secret(routes/settings.js ALLOWED_SET 可写、GET 不返回,不泄露)
|
||||||
|
// - rainid_enabled='1' 但 client_id/secret 任一通道均缺失 → enabled=false(fail-closed,本地登录不受影响)
|
||||||
|
// - 影子账号按 sub 绑定(唯一索引兜底);email_verified 是自动绑定闸门(防撞绑)
|
||||||
|
// - 影子账号随机不可登录密码,禁止本地密码登入
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const openidClient = require('openid-client'); // v6 函数式 API(Node >=23 支持 require(esm))
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth';
|
||||||
|
|
||||||
|
// 机密 client_secret 双通道读取(优先级从高到低):
|
||||||
|
// ① 环境变量 RAINID_CLIENT_SECRET(server.js 启动时从 .env.json 的 cfg.rainid_client_secret 读入)
|
||||||
|
// ② .env.json 直接读取
|
||||||
|
// ③ 后台站点设置 site_settings.rainid_client_secret(可写不可读,GET /api/settings 不返回)
|
||||||
|
function getClientSecret() {
|
||||||
|
if (process.env.RAINID_CLIENT_SECRET) return process.env.RAINID_CLIENT_SECRET;
|
||||||
|
try {
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '.env.json'), 'utf8'));
|
||||||
|
if (cfg.rainid_client_secret) return cfg.rainid_client_secret;
|
||||||
|
} catch { /* .env.json 缺失/损坏 → 走 settings 回退 */ }
|
||||||
|
return db.getSetting('rainid_client_secret') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOidcSettings() {
|
||||||
|
const clientId = db.getSetting('rainid_client_id');
|
||||||
|
const clientSecret = getClientSecret();
|
||||||
|
return {
|
||||||
|
// fail-closed:enabled 声明为 true 仅当三项齐全(rainid_enabled + client_id + secret)
|
||||||
|
enabled: db.getSetting('rainid_enabled') === '1' && !!clientId && !!clientSecret,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
discoveryUrl: db.getSetting('rainid_discovery_url') || DISCOVERY_DEFAULT,
|
||||||
|
registerRedirect: db.getSetting('rainid_register_redirect') === '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// discovery 结果缓存(RainID JWKS 多 key 轮换由 SDK 自动处理;设置变更需重启进程生效)
|
||||||
|
let configCache = null;
|
||||||
|
async function getOidcConfig() {
|
||||||
|
if (configCache) return configCache;
|
||||||
|
const s = getOidcSettings();
|
||||||
|
if (!s.clientId || !s.clientSecret) {
|
||||||
|
const err = new Error('RainID 客户端未配置(rainid_client_id / rainid_client_secret)');
|
||||||
|
err.code = 'RAINID_NOT_CONFIGURED';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
configCache = await openidClient.discovery(s.discoveryUrl, s.clientId, s.clientSecret);
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 站点基址:redirect_uri / 回跳前端页面用。优先 site_url 设置,回退请求头(反代注意 X-Forwarded-Proto)
|
||||||
|
function siteBase(req) {
|
||||||
|
const u = db.getSetting('site_url');
|
||||||
|
if (u) return String(u).replace(/\/+$/, '');
|
||||||
|
return (req.protocol || 'http') + '://' + (req.get('host') || 'localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 影子账号 查/建(sub 为 OIDC 稳定绑定键):
|
||||||
|
// 1. rainid_user_id = sub 命中 → 直接返回
|
||||||
|
// 2. email_verified=true 且本地存在同 email 且未绑定 → 自动绑定(防撞绑:仅 RainID 已验证邮箱可绑)
|
||||||
|
// 3. 否则创建影子账号(username=preferred_username 或 rainid_<sub前8>,冲突加后缀;
|
||||||
|
// password=随机不可登录;email_verified=1;全站无 admin 时首个用户为 admin)
|
||||||
|
// 4. 并发兜底:唯一索引冲突 → 重查按 sub 返回
|
||||||
|
function findOrCreateRainidUser(sub, profile) {
|
||||||
|
if (!sub) {
|
||||||
|
const e = new Error('RainID userinfo 缺少 sub');
|
||||||
|
e.code = 'RAINID_NO_SUB';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const email = String(profile.email || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
// 1) 已绑定
|
||||||
|
let user = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
// 2) 同 email 自动绑定(条件 UPDATE:仅绑定未绑定的本地号,防并发/防覆盖其他 sub)
|
||||||
|
if (email && profile.email_verified) {
|
||||||
|
const local = db.get('SELECT * FROM users WHERE email = ?', [email]);
|
||||||
|
if (local && !local.rainid_user_id) {
|
||||||
|
db.run('UPDATE users SET rainid_user_id = ? WHERE id = ? AND rainid_user_id = ?', [sub, local.id, '']);
|
||||||
|
const updated = db.get('SELECT * FROM users WHERE id = ?', [local.id]);
|
||||||
|
if (updated && updated.rainid_user_id === sub) return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 创建影子账号
|
||||||
|
let username = profile.preferred_username || ('rainid_' + String(sub).slice(0, 8));
|
||||||
|
if (db.get('SELECT id FROM users WHERE username = ?', [username])) {
|
||||||
|
username = username + '_' + String(sub).slice(0, 4);
|
||||||
|
}
|
||||||
|
const randomHash = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10);
|
||||||
|
const adminCount = db.get("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'");
|
||||||
|
const role = adminCount && adminCount.c > 0 ? 'user' : 'admin';
|
||||||
|
try {
|
||||||
|
const id = db.run(
|
||||||
|
'INSERT INTO users (username, password, email, email_verified, role, avatar, rainid_user_id) VALUES (?, ?, ?, 1, ?, ?, ?)',
|
||||||
|
[username, randomHash, email, role, profile.picture || '', sub]
|
||||||
|
);
|
||||||
|
return db.get('SELECT * FROM users WHERE id = ?', [id]);
|
||||||
|
} catch (e) {
|
||||||
|
// 并发兜底:唯一索引冲突 → 重查按 sub 登录
|
||||||
|
const dup = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
|
||||||
|
if (dup) return dup;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ROPC 密码登录:RainID token 端点 grant_type=password(openid-client genericGrantRequest 自动带 client 认证)
|
||||||
|
// 成功 → 影子账号 → 返回 { ok:true, user };失败 → { ok:false, status, error }
|
||||||
|
async function rainidRopcLogin(username, password) {
|
||||||
|
const s = getOidcSettings();
|
||||||
|
if (!s.enabled) return { ok: false, status: 400, error: 'RainID 登录未启用' };
|
||||||
|
let config;
|
||||||
|
try { config = await getOidcConfig(); }
|
||||||
|
catch { return { ok: false, status: 500, error: 'RainID 客户端未配置' }; }
|
||||||
|
try {
|
||||||
|
const tokens = await openidClient.genericGrantRequest(config, 'password', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
scope: 'openid profile email',
|
||||||
|
});
|
||||||
|
// id_token 声明(签名/iss/aud 校验由 SDK 承担);拿不到 sub 则 userinfo 跳过 subject 校验
|
||||||
|
let expectedSub = openidClient.skipSubjectCheck;
|
||||||
|
try {
|
||||||
|
const claims = tokens.claims();
|
||||||
|
if (claims && claims.sub) expectedSub = claims.sub;
|
||||||
|
} catch { /* id_token 解析失败不阻断(userinfo 为准) */ }
|
||||||
|
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, expectedSub);
|
||||||
|
const user = findOrCreateRainidUser(userinfo.sub, userinfo);
|
||||||
|
return { ok: true, user };
|
||||||
|
} catch (err) {
|
||||||
|
return mapOidcError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RainID OAuth 错误 → HTTP 状态 + 文案(RainID 对接文档 §5.3 / §12 错误码总表)
|
||||||
|
function mapOidcError(err) {
|
||||||
|
const code = err && err.error;
|
||||||
|
const desc = (err && err.error_description) || '';
|
||||||
|
switch (code) {
|
||||||
|
case 'invalid_grant':
|
||||||
|
// 2FA 用户 ROPC 被拒 → 固定文案;其余统一"用户名/邮箱或密码错误"(防枚举)
|
||||||
|
return { ok: false, status: 401, error: desc.includes('二次验证')
|
||||||
|
? '该账号已开启二次验证,请使用 RainID 登录'
|
||||||
|
: '用户名/邮箱或密码错误' };
|
||||||
|
case 'rate_limited':
|
||||||
|
return { ok: false, status: 429, error: '尝试过于频繁,请稍后再试' };
|
||||||
|
case 'invalid_client':
|
||||||
|
case 'invalid_scope':
|
||||||
|
case 'invalid_request':
|
||||||
|
return { ok: false, status: 500, error: 'RainID 配置错误,请联系管理员' };
|
||||||
|
default:
|
||||||
|
return { ok: false, status: 502, error: 'RainID 服务暂不可用,请稍后再试' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOidcSettings,
|
||||||
|
getOidcConfig,
|
||||||
|
getClientSecret,
|
||||||
|
siteBase,
|
||||||
|
findOrCreateRainidUser,
|
||||||
|
rainidRopcLogin,
|
||||||
|
mapOidcError,
|
||||||
|
};
|
||||||
Generated
+41
-9
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.1.0",
|
"version": "2.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.1.0",
|
"version": "2.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-resizable-panels": "^4.12.2",
|
"react-resizable-panels": "^4.12.2",
|
||||||
@@ -567,7 +568,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emotion/utils": {
|
"node_modules/@emotion/utils": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -1402,7 +1403,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
"engines": [
|
"engines": [
|
||||||
@@ -1562,7 +1563,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1639,7 +1640,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2096,6 +2097,15 @@
|
|||||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
|
||||||
|
"integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -2639,13 +2649,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2725,6 +2735,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
||||||
|
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -2758,6 +2777,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "6.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
|
||||||
|
"integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^6.2.2",
|
||||||
|
"oauth4webapi": "^3.8.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -3687,7 +3719,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xmlchars": {
|
"node_modules/xmlchars": {
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.1.0",
|
"version": "2.3.0",
|
||||||
"description": "链接聚合管理平台",
|
"description": "链接聚合管理平台",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-resizable-panels": "^4.12.2",
|
"react-resizable-panels": "^4.12.2",
|
||||||
|
|||||||
+17
-1
@@ -6,6 +6,7 @@ const rateLimit = require('express-rate-limit');
|
|||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { SECRET, authMiddleware, adminOnly } = require('../middleware/auth');
|
const { SECRET, authMiddleware, adminOnly } = require('../middleware/auth');
|
||||||
const { consumeProof } = require('./captcha');
|
const { consumeProof } = require('./captcha');
|
||||||
|
const { rainidRopcLogin } = require('../lib/rainid');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -82,8 +83,19 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|||||||
return res.status(400).json({ error: '请先完成验证码验证' });
|
return res.status(400).json({ error: '请先完成验证码验证' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RainID 启用时:用户名/密码转发 RainID(ROPC),按 sub 登录/绑定影子账号
|
||||||
|
if (db.getSetting('rainid_enabled') === '1') {
|
||||||
|
const r = await rainidRopcLogin(username, password);
|
||||||
|
if (!r.ok) return res.status(r.status).json({ error: r.error });
|
||||||
|
const user = r.user;
|
||||||
|
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, SECRET, { expiresIn: '7d' });
|
||||||
|
return res.json({ token, username: user.username, role: user.role, email: user.email, email_verified: user.email_verified });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地 bcrypt 登录(RainID 未启用 / 未配置时回退,本地功能不受 RainID 影响)
|
||||||
const user = db.get('SELECT * FROM users WHERE username = ?', [username]);
|
const user = db.get('SELECT * FROM users WHERE username = ?', [username]);
|
||||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
// 影子账号(rainid_user_id 非空)无本地密码 → 禁止本地密码登入
|
||||||
|
if (!user || (user.rainid_user_id && !user.password) || !bcrypt.compareSync(password, user.password)) {
|
||||||
return res.status(401).json({ error: '用户名或密码错误' });
|
return res.status(401).json({ error: '用户名或密码错误' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +104,10 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/register', async (req, res) => {
|
router.post('/register', async (req, res) => {
|
||||||
|
// 注册跳转 RainID 开启:前端直接跳 RainID 注册页,本地注册接口拒绝
|
||||||
|
if (db.getSetting('rainid_register_redirect') === '1') {
|
||||||
|
return res.status(400).json({ error: '注册已跳转 RainID' });
|
||||||
|
}
|
||||||
const { username, password, email } = req.body;
|
const { username, password, email } = req.body;
|
||||||
if (!username || !password || !email)
|
if (!username || !password || !email)
|
||||||
return res.status(400).json({ error: '请填写所有必填项' });
|
return res.status(400).json({ error: '请填写所有必填项' });
|
||||||
|
|||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
// RainID OIDC 授权码 + PKCE 接入(挂 /api/auth/oidc)
|
||||||
|
// 流程:GET /login 发起 → RainID 登录/同意 → GET /callback 换 token 建影子账号
|
||||||
|
// → 一次性 ticket → 前端 POST /exchange 换本地 JWT(避免长 token 进 URL)
|
||||||
|
// → GET /logout 登出联动(end_session)
|
||||||
|
// 安全:state 一次性 + PKCE S256(SDK 自动验 id_token iss/aud/exp/签名);ticket 30s 一次性。
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const openidClient = require('openid-client'); // v6 函数式 API
|
||||||
|
const { SECRET } = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
getOidcSettings,
|
||||||
|
getOidcConfig,
|
||||||
|
siteBase,
|
||||||
|
findOrCreateRainidUser,
|
||||||
|
} = require('../lib/rainid');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ---- OIDC 流程状态:内存 Map + 短 TTL(模式同 routes/captcha.js 的 captchaStore)----
|
||||||
|
const OIDC_STATE_TTL = 10 * 60 * 1000; // state:发起登录 → 回调,10 分钟
|
||||||
|
const OIDC_TICKET_TTL = 30 * 1000; // ticket:回调 → 前端换 JWT,30 秒
|
||||||
|
const oidcStates = new Map(); // state -> { verifier, createdAt }
|
||||||
|
const oidcTickets = new Map(); // ticket -> { jwt, username, role, email, email_verified, createdAt }
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of oidcStates) if (now - v.createdAt > OIDC_STATE_TTL) oidcStates.delete(k);
|
||||||
|
for (const [k, v] of oidcTickets) if (now - v.createdAt > OIDC_TICKET_TTL) oidcTickets.delete(k);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
const REDIRECT_PATH = '/api/auth/oidc/callback';
|
||||||
|
const LOGOUT_REDIRECT_PATH = '/login.html'; // RainID 登出回跳白名单需登记该地址
|
||||||
|
|
||||||
|
function frontLoginUrl(req, qs) {
|
||||||
|
return siteBase(req) + '/login.html' + (qs || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 发起登录:PKCE + state → 302 RainID authorize
|
||||||
|
router.get('/login', async (req, res) => {
|
||||||
|
const s = getOidcSettings();
|
||||||
|
if (!s.enabled) return res.status(400).json({ error: '未启用 RainID 登录' });
|
||||||
|
try {
|
||||||
|
const config = await getOidcConfig();
|
||||||
|
const codeVerifier = openidClient.randomPKCECodeVerifier();
|
||||||
|
const codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
const state = openidClient.randomState();
|
||||||
|
oidcStates.set(state, { verifier: codeVerifier, createdAt: Date.now() });
|
||||||
|
const url = openidClient.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: siteBase(req) + REDIRECT_PATH,
|
||||||
|
scope: 'openid profile email',
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
res.redirect(url.href);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RainID] login redirect error:', err.message);
|
||||||
|
res.status(502).json({ error: 'RainID 服务暂不可用' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ② 回调:state 校验 → 换 token → userinfo → 影子账号 → 发 JWT → 一次性 ticket → 302 前端
|
||||||
|
router.get('/callback', async (req, res) => {
|
||||||
|
const state = req.query.state;
|
||||||
|
const stored = state && oidcStates.get(state);
|
||||||
|
if (!stored) {
|
||||||
|
return res.status(400).send('登录状态已失效,请重新使用 RainID 登录');
|
||||||
|
}
|
||||||
|
oidcStates.delete(state); // state 一次性
|
||||||
|
let config;
|
||||||
|
try { config = await getOidcConfig(); }
|
||||||
|
catch { return res.status(500).send('RainID 客户端未配置'); }
|
||||||
|
|
||||||
|
const base = siteBase(req);
|
||||||
|
const currentUrl = new URL(req.originalUrl, base);
|
||||||
|
try {
|
||||||
|
// openid-client 自动验 id_token:签名(RS256/JWKS) + iss(discovery issuer) + aud(client_id) + exp
|
||||||
|
const tokens = await openidClient.authorizationCodeGrant(config, currentUrl, {
|
||||||
|
pkceCodeVerifier: stored.verifier,
|
||||||
|
expectedState: state,
|
||||||
|
idTokenExpected: true,
|
||||||
|
});
|
||||||
|
const claims = tokens.claims();
|
||||||
|
const sub = claims && claims.sub;
|
||||||
|
if (!sub) throw new Error('id_token 缺少 sub');
|
||||||
|
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub);
|
||||||
|
const user = findOrCreateRainidUser(sub, userinfo);
|
||||||
|
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, SECRET, { expiresIn: '7d' });
|
||||||
|
const ticket = crypto.randomBytes(16).toString('hex');
|
||||||
|
oidcTickets.set(ticket, {
|
||||||
|
jwt: token,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
email: user.email,
|
||||||
|
email_verified: user.email_verified,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
res.redirect(frontLoginUrl(req, '?oidc_ticket=' + encodeURIComponent(ticket)));
|
||||||
|
} catch (err) {
|
||||||
|
// error 参数(access_denied / consent_required 等)或换 token 失败 → 回前端登录页带错误码
|
||||||
|
const code = err && err.error;
|
||||||
|
console.error('[RainID] callback error:', code || err.message);
|
||||||
|
res.redirect(frontLoginUrl(req, '?oidc_error=' + encodeURIComponent(code || 'server_error')));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ③ 一次性 ticket 换本地 JWT(响应结构同 /api/auth/login,auth.js:91)
|
||||||
|
router.post('/exchange', (req, res) => {
|
||||||
|
const ticket = req.body && req.body.ticket;
|
||||||
|
if (!ticket) return res.status(400).json({ error: '缺少 ticket' });
|
||||||
|
const rec = oidcTickets.get(ticket);
|
||||||
|
if (!rec) return res.status(401).json({ error: '登录已过期,请重新使用 RainID 登录' });
|
||||||
|
oidcTickets.delete(ticket); // 一次性
|
||||||
|
res.json({
|
||||||
|
token: rec.jwt,
|
||||||
|
username: rec.username,
|
||||||
|
role: rec.role,
|
||||||
|
email: rec.email,
|
||||||
|
email_verified: rec.email_verified,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ④ 登出联动:302 RainID end_session(post_logout_redirect_uri 需先在 RainID Admin 登记)
|
||||||
|
router.get('/logout', async (req, res) => {
|
||||||
|
const s = getOidcSettings();
|
||||||
|
if (!s.enabled) return res.redirect('/');
|
||||||
|
try {
|
||||||
|
const config = await getOidcConfig();
|
||||||
|
const url = openidClient.buildEndSessionUrl(config, {
|
||||||
|
post_logout_redirect_uri: siteBase(req) + LOGOUT_REDIRECT_PATH,
|
||||||
|
});
|
||||||
|
res.redirect(url.href);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RainID] logout error:', err.message);
|
||||||
|
res.redirect('/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+56
-2
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('../db');
|
||||||
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
|
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -24,12 +25,65 @@ function isBlockedHost(hostname) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 内网代理白名单(settings: proxy_allowed_hosts,逗号/空格/换行分隔)----
|
||||||
|
// 用途:https 页面无法嵌入 http 内网面板,管理员可在后台把可信内网地址/网段加入白名单放行。
|
||||||
|
// 仅放行被 isBlockedHost 拦截的地址;公网地址不受影响。SSRF 信任模型不变(仍 adminOnly)。
|
||||||
|
|
||||||
|
// IPv4 字符串 → 32 位整数(解析失败返回 null)
|
||||||
|
function ipv4ToInt(ip) {
|
||||||
|
const parts = String(ip).split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
let n = 0;
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!/^\d{1,3}$/.test(p)) return null;
|
||||||
|
const v = parseInt(p, 10);
|
||||||
|
if (v > 255) return null;
|
||||||
|
n = (n << 8) + v;
|
||||||
|
}
|
||||||
|
return n >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4 CIDR 匹配(手写实现,项目无 ipaddr 依赖)。
|
||||||
|
// 返回 true/false 表示确定命中/不命中;返回 null 表示条目无法解析(调用方跳过该行)。
|
||||||
|
function ipv4CidrContains(cidr, host) {
|
||||||
|
const m = String(cidr).match(/^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const net = ipv4ToInt(m[1]);
|
||||||
|
const hostInt = ipv4ToInt(host);
|
||||||
|
const prefix = parseInt(m[2], 10);
|
||||||
|
if (net === null || hostInt === null || prefix < 0 || prefix > 32) return null;
|
||||||
|
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||||
|
return (net & mask) === (hostInt & mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断 hostname 是否命中白名单:支持单 IP / IPv4 CIDR 网段 / 主机名精确匹配,IPv4 映射 IPv6 自动归一。
|
||||||
|
function proxyAllowed(hostname) {
|
||||||
|
if (!hostname) return false;
|
||||||
|
let host = String(hostname).toLowerCase();
|
||||||
|
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
||||||
|
const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (mapped) host = mapped[1];
|
||||||
|
const raw = (db.getSetting('proxy_allowed_hosts') || '').trim();
|
||||||
|
if (!raw) return false;
|
||||||
|
for (const entry of raw.split(/[,;\s]+/)) {
|
||||||
|
let e = entry.trim().toLowerCase();
|
||||||
|
if (!e) continue;
|
||||||
|
// 兼容 ip:port 写法(仅 IPv4 形态,避免误伤 IPv6 冒号)
|
||||||
|
const lastColon = e.lastIndexOf(':');
|
||||||
|
if (lastColon > 0 && e.indexOf('.') !== -1 && !e.includes('/')) e = e.slice(0, lastColon);
|
||||||
|
if (e === host) return true;
|
||||||
|
if (e.includes('/') && ipv4CidrContains(e, host) === true) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function proxyRequest(target, res, maxRedirects = 5) {
|
function proxyRequest(target, res, maxRedirects = 5) {
|
||||||
if (maxRedirects <= 0) return res.status(502).json({ error: '重定向次数过多' });
|
if (maxRedirects <= 0) return res.status(502).json({ error: '重定向次数过多' });
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(target);
|
const parsed = new URL(target);
|
||||||
// 发起请求前校验目标 hostname,拒绝内网地址(含重定向跳转的目标)
|
// 发起请求前校验目标 hostname:内网地址一律拦截,除非命中白名单(proxy_allowed_hosts)。
|
||||||
if (isBlockedHost(parsed.hostname)) {
|
// 注:403 JSON 经 Cloudflare 显示为 502 属正常(上游非 2xx),白名单放行后不再触发。
|
||||||
|
if (isBlockedHost(parsed.hostname) && !proxyAllowed(parsed.hostname)) {
|
||||||
return res.status(403).json({ error: '禁止访问内网地址' });
|
return res.status(403).json({ error: '禁止访问内网地址' });
|
||||||
}
|
}
|
||||||
const client = parsed.protocol === 'https:' ? https : http;
|
const client = parsed.protocol === 'https:' ? https : http;
|
||||||
|
|||||||
+5
-2
@@ -9,6 +9,7 @@ const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
|
|||||||
'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style',
|
'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style',
|
||||||
'glass_blur','glass_opacity','theme_force_dark',
|
'glass_blur','glass_opacity','theme_force_dark',
|
||||||
'captcha_type','captcha_login','captcha_register','captcha_forum',
|
'captcha_type','captcha_login','captcha_register','captcha_forum',
|
||||||
|
'rainid_enabled',
|
||||||
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
||||||
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
|
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
|
||||||
'footer_style','footer_copyright','footer_powered','footer_desc'];
|
'footer_style','footer_copyright','footer_powered','footer_desc'];
|
||||||
@@ -17,12 +18,14 @@ const ALL_KEYS = ['site_name','site_description','site_url','primary_color','rec
|
|||||||
'smtp_host','smtp_port','smtp_user','smtp_from_email','smtp_from_name',
|
'smtp_host','smtp_port','smtp_user','smtp_from_email','smtp_from_name',
|
||||||
'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style','glass_blur','glass_opacity','theme_force_dark',
|
'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style','glass_blur','glass_opacity','theme_force_dark',
|
||||||
'captcha_type','captcha_login','captcha_register','captcha_forum',
|
'captcha_type','captcha_login','captcha_register','captcha_forum',
|
||||||
|
'rainid_enabled','rainid_client_id','rainid_discovery_url','rainid_register_redirect',
|
||||||
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
||||||
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
|
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
|
||||||
'footer_style','footer_copyright','footer_powered','footer_desc',
|
'footer_style','footer_copyright','footer_powered','footer_desc',
|
||||||
'comment_moderate','comment_notify'];
|
'comment_moderate','comment_notify',
|
||||||
|
'proxy_allowed_hosts'];
|
||||||
|
|
||||||
const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass', 'turnstile_secret_key'];
|
const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass', 'turnstile_secret_key', 'rainid_client_secret'];
|
||||||
|
|
||||||
router.get('/public', (req, res) => {
|
router.get('/public', (req, res) => {
|
||||||
const settings = {};
|
const settings = {};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ try {
|
|||||||
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
||||||
}
|
}
|
||||||
process.env.JWT_SECRET = cfg.jwt_secret;
|
process.env.JWT_SECRET = cfg.jwt_secret;
|
||||||
|
// RainID OIDC 机密 client_secret(仅存 .env.json,gitignored;lib/rainid.js 读取)
|
||||||
|
if (cfg.rainid_client_secret) process.env.RAINID_CLIENT_SECRET = cfg.rainid_client_secret;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -35,6 +37,7 @@ const importRoutes = require('./routes/import');
|
|||||||
const noteRoutes = require('./routes/notes');
|
const noteRoutes = require('./routes/notes');
|
||||||
const feedRoutes = require('./routes/feed');
|
const feedRoutes = require('./routes/feed');
|
||||||
const terminalRoutes = require('./routes/terminal');
|
const terminalRoutes = require('./routes/terminal');
|
||||||
|
const oidcRoutes = require('./routes/oidc');
|
||||||
const { blogSSR, forumSSR, sitemapXml } = require('./ssr');
|
const { blogSSR, forumSSR, sitemapXml } = require('./ssr');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -100,6 +103,7 @@ app.use('/uploads', express.static(path.join(__dirname, 'uploads'), {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/auth/oidc', oidcRoutes);
|
||||||
app.use('/api/admin-links', adminLinkRoutes);
|
app.use('/api/admin-links', adminLinkRoutes);
|
||||||
app.use('/api/announcements', announcementRoutes);
|
app.use('/api/announcements', announcementRoutes);
|
||||||
app.use('/api/forum', forumRoutes);
|
app.use('/api/forum', forumRoutes);
|
||||||
@@ -190,6 +194,17 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await getDb();
|
await getDb();
|
||||||
|
// RainID 单点登录启动校验:开启但 client_id/secret 缺失 → 警告并按未启用处理(fail-closed)
|
||||||
|
try {
|
||||||
|
const { getClientSecret } = require('./lib/rainid');
|
||||||
|
const { getSetting } = require('./db');
|
||||||
|
if (getSetting('rainid_enabled') === '1') {
|
||||||
|
const clientId = getSetting('rainid_client_id');
|
||||||
|
if (!clientId || !getClientSecret()) {
|
||||||
|
console.warn('[RainID] rainid_enabled=1 但 client_id / rainid_client_secret 未配置,RainID 登录将不可用(fail-closed,本地登录不受影响)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`RainWeb running on port ${PORT}`);
|
console.log(`RainWeb running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ module.exports = defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:3101',
|
'/api': 'http://localhost:3101',
|
||||||
'/uploads': 'http://localhost:3101',
|
'/uploads': 'http://localhost:3101',
|
||||||
|
// Web 终端 WS:/ws/terminal 必须走 WebSocket 代理,否则 dev 下解锁后
|
||||||
|
// 连接永远失败 → 终端空转白屏(生产由 server.js 的 upgrade 事件处理)
|
||||||
|
'/ws': { target: 'ws://localhost:3101', ws: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user