feat: skin site UUID lookup - auto-fetch UUID from player name

- GET /settings/uuid-lookup?site=&name= : proxy Yggdrasil API
  (POST {site}/api/yggdrasil/api/profiles/minecraft), SSRF guard,
  10s timeout, name regex, UUID formatting (with dashes)
- GET /settings/skin-site : public read of configured skin site
- settings page: 皮肤站地址 config
- bind identity modals (player dashboard + admin users): 获取UUID button,
  auto-fill site from settings, auto-fill UID from lookup
- verified live against littleskin.cn (Steve -> df273bda...)
- API docs updated
This commit is contained in:
2026-08-17 02:05:21 +08:00
parent a6a548b1ce
commit 1d669fd1d0
5 changed files with 120 additions and 2 deletions

View File

@@ -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;