diff --git a/backend/routes/settings.js b/backend/routes/settings.js index 27ec6d2..d518461 100644 --- a/backend/routes/settings.js +++ b/backend/routes/settings.js @@ -43,4 +43,52 @@ router.put('/settings', authenticate, requireRole('owner','admin'), async (req, res.json({ message: '设置已保存' }); }); +// ---- 皮肤站 UUID 查询(Yggdrasil API: POST {site}/api/yggdrasil/api/profiles/minecraft) ---- +function formatUuid(id) { + if (!id) return ''; + const s = String(id).replace(/-/g, '').toLowerCase(); + if (s.length !== 32) return String(id); + return `${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20)}`; +} + +// 公开: 读取站点配置的皮肤站地址(玩家绑定身份时自动带出) +router.get('/skin-site', async (req, res) => { + try { + const row = await getRow("SELECT v FROM settings WHERE k = 'skin_site'"); + res.json({ site: row?.v || '' }); + } catch { res.json({ site: '' }); } +}); + +router.get('/uuid-lookup', authenticate, async (req, res) => { + const { site, name } = req.query; + if (!site || !name) return res.status(400).json({ error: '缺少参数 site 或 name' }); + if (!/^[A-Za-z0-9_]{1,32}$/.test(name)) return res.status(400).json({ error: '用户名仅允许字母数字下划线' }); + let u; + try { u = new URL(site); } catch { return res.status(400).json({ error: '皮肤站地址无效' }); } + if (u.protocol !== 'https:' && u.protocol !== 'http:') return res.status(400).json({ error: '协议不支持' }); + const host = u.hostname; + if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || + host.startsWith('192.168.') || host.startsWith('10.') || host.startsWith('172.16.')) { + return res.status(400).json({ error: '不允许内网地址(SSRF 防护)' }); + } + try { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 10000); + const resp = await fetch(`${u.origin}/api/yggdrasil/api/profiles/minecraft`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'User-Agent': 'MCReport/1.0' }, + body: JSON.stringify([name]), + signal: ctl.signal, + }); + clearTimeout(t); + if (resp.status === 204) return res.json({ found: false, uuid: '', message: '皮肤站未找到该用户名' }); + const data = await resp.json(); + const hit = Array.isArray(data) ? data.find(x => x && x.name && String(x.name).toLowerCase() === name.toLowerCase()) : null; + if (!hit?.id) return res.json({ found: false, uuid: '', message: '皮肤站未找到该用户名' }); + res.json({ found: true, uuid: formatUuid(hit.id), raw_id: String(hit.id).replace(/-/g, '') }); + } catch (e) { + res.status(502).json({ error: '查询皮肤站失败: ' + (e.message || '网络错误') }); + } +}); + module.exports = router; diff --git a/docs/API.md b/docs/API.md index fe5c804..18ae826 100644 --- a/docs/API.md +++ b/docs/API.md @@ -85,6 +85,8 @@ Base URL: `http://:3100/api` | 同来源重复 | 拒绝(先删除再重新绑定) | | 至少保留一个 | 解绑时若只剩一个身份则拒绝 | +**皮肤站 UUID 自动查询**:皮肤站来源时,可通过 `GET /settings/uuid-lookup?site=<皮肤站地址>&name=<用户名>` 自动获取 UUID(调 Yggdrasil API `POST {site}/api/yggdrasil/api/profiles/minecraft`,带 SSRF 防护与 10s 超时)。站点皮肤站地址可在系统设置配置,前端绑定弹窗自动带出。 + ### DELETE /auth/identities/:id *(认证)* 解绑身份。响应: `{ "message": "已解绑" }` @@ -225,7 +227,7 @@ Base URL: `http://:3100/api` ```json { "source": "skin", "game_name": "Alex", "game_uid": "SKIN_UUID" } ``` -规则同 `POST /auth/identities`(同来源唯一、游戏名全局唯一、网易端必填 UID)。 +规则同 `POST /auth/identities`(同来源唯一、游戏名全局唯一、网易端必填 UID)。皮肤站来源同样支持「获取 UUID」辅助查询。 ### DELETE /users/:id/identities/:identityId *(admin/owner)* 管理员解绑用户身份(至少保留一个)。 diff --git a/public/js/pages/dashboard.js b/public/js/pages/dashboard.js index 07a53f2..7e6006f 100644 --- a/public/js/pages/dashboard.js +++ b/public/js/pages/dashboard.js @@ -93,14 +93,47 @@ const Dashboard = {
+ `); - document.getElementById('ident-source').onchange = () => { + const toggleSource = () => { const isNetease = document.getElementById('ident-source').value === 'netease'; document.getElementById('ident-uid-group').style.display = isNetease ? '' : 'none'; document.getElementById('ident-uid').required = isNetease; + document.getElementById('ident-skin-group').classList.toggle('hidden', isNetease); + }; + document.getElementById('ident-source').onchange = toggleSource; + toggleSource(); + // 自动带出站点配置的皮肤站地址 + API.get('/settings/skin-site').then(r => { + if (r.site && !document.getElementById('ident-site').value) document.getElementById('ident-site').value = r.site; + }).catch(()=>{}); + document.getElementById('ident-fetch-btn').onclick = async () => { + const site = document.getElementById('ident-site').value.trim(); + const name = document.getElementById('ident-name').value.trim(); + if (!site) return alert('请填写皮肤站地址'); + if (!name) return alert('请先填写游戏名(皮肤站用户名)'); + const btn = document.getElementById('ident-fetch-btn'); + btn.disabled = true; btn.innerHTML = ' 查询中'; + try { + const r = await API.get(`/settings/uuid-lookup?site=${encodeURIComponent(site)}&name=${encodeURIComponent(name)}`); + if (r.found) { + document.getElementById('ident-uid').value = r.uuid; + alert('已获取 UUID: ' + r.uuid); + } else { + alert(r.message || '未找到该用户名'); + } + } catch (ex) { alert(ex.message); } + btn.disabled = false; btn.innerHTML = ' 获取UUID'; }; document.getElementById('ident-form').onsubmit = async e => { e.preventDefault(); diff --git a/public/js/pages/settings-page.js b/public/js/pages/settings-page.js index e1beaea..21da838 100644 --- a/public/js/pages/settings-page.js +++ b/public/js/pages/settings-page.js @@ -14,6 +14,7 @@ const SettingsPage = {
+
@@ -39,6 +40,7 @@ const SettingsPage = { site_url: {value: document.getElementById('s-url').value, label:'站点地址'}, copyright: {value: document.getElementById('s-copyright').value, label:'页尾版权'}, icp: {value: document.getElementById('s-icp').value, label:'ICP备案号'}, + skin_site: {value: document.getElementById('s-skin-site').value.trim(), label:'皮肤站地址'}, }); alert('已保存'); } catch (ex) { alert(ex.message); } diff --git a/public/js/pages/users.js b/public/js/pages/users.js index 71328ac..b925ec3 100644 --- a/public/js/pages/users.js +++ b/public/js/pages/users.js @@ -114,8 +114,13 @@ const UsersPage = { + + @@ -133,6 +138,34 @@ const UsersPage = { const pw = document.getElementById('eu-p').value; if (pw) data.password = pw; try { await API.put('/users/'+id, data); App.closeModal(); this.load(); } catch (ex) { alert(ex.message); } }; + + // 来源切换: 皮肤站时显示 UUID 查询辅助 + const euToggleSource = () => { + const isSkin = document.getElementById('eu-ident-source').value === 'skin'; + document.getElementById('eu-ident-site-group').classList.toggle('hidden', !isSkin); + }; + document.getElementById('eu-ident-source').onchange = euToggleSource; + euToggleSource(); + // 自动带出站点配置的皮肤站地址 + API.get('/settings/skin-site').then(r => { + if (r.site && !document.getElementById('eu-ident-site').value) document.getElementById('eu-ident-site').value = r.site; + }).catch(()=>{}); + + // 皮肤站用户名 → UUID 查询 + document.getElementById('eu-ident-fetch').onclick = async () => { + const site = document.getElementById('eu-ident-site').value.trim(); + const name = document.getElementById('eu-ident-name').value.trim(); + if (!site) return alert('请填写皮肤站地址(来源为皮肤站时显示)'); + if (!name) return alert('请先填写游戏名(皮肤站用户名)'); + const btn = document.getElementById('eu-ident-fetch'); + btn.disabled = true; btn.innerHTML = ''; + try { + const r = await API.get(`/settings/uuid-lookup?site=${encodeURIComponent(site)}&name=${encodeURIComponent(name)}`); + if (r.found) { document.getElementById('eu-ident-uid').value = r.uuid; alert('已获取 UUID: ' + r.uuid); } + else alert(r.message || '未找到该用户名'); + } catch (ex) { alert(ex.message); } + btn.disabled = false; btn.innerHTML = ''; + }; }, async addIdentity(userId) {