Files
StarlightRadio/public/index.html
T
Xiao-no-love f0fe50e484 feat: StarLight 第三方登录 + 安全加固 + 循环池 + 热配置
功能:
- StarLight Yggdrasil 第三方登录(后端中转,accessToken 不落地)
- 管理员可视化权限管理(用户列表 + 一键授予/移出)
- 设置页热配置(11 项可编辑,立即生效)
- 循环池(每流独立手动歌单,队列空时随机不重复抽取)
- 登录页统一主站浅色风格
- 全站改名「Starlight专属电台」
- 删除关于页

安全:
- 全局 CSRF 防护(双提交 Cookie)
- 登录限流防 XFF 伪造、错误信息脱敏
- 权限中间件 requireAdminForWrites(读放行/写 admin)
- 密码哈希存储(scrypt/sha256)
- SSRF 防护(封面下载域名白名单)
- 空 catch 加日志、SSE 连接上限

修复:
- 多角色账号 selectedProfile 缺失
- 普通用户越权删歌(V4)
- streams 写操作越权(V9)
- 设置页布局溢出

测试:38 项全绿(api16 / e2e17 / write5)
2026-10-06 16:37:03 +08:00

458 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1"
/>
<meta name="theme-color" content="#f4f5f9" />
<title>Starlight专属电台</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
<link rel="stylesheet" href="vendor/bootstrap-icons.css" />
<script src="vendor/Sortable.min.js"></script>
<script src="vendor/hls.min.js"></script>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<div class="page-bg"></div>
<div class="bg-shapes">
<div class="s"></div>
<div class="s"></div>
<div class="s"></div>
</div>
<div class="toast-container" id="toastContainer"></div>
<!-- Header -->
<header class="top-header">
<div class="header-inner">
<div class="brand">
<div class="brand-icon">
<img src="favicon.png" alt="logo" />
</div>
<span>Starlight专属电台</span>
<span class="live-pill"
><span class="live-dot"></span>LIVE</span
>
</div>
<div class="header-actions">
<span class="cache-pill" id="cacheInfo"><i class="bi bi-database"></i> --</span>
<button class="channel-btn" id="btnStreamMgr" title="频道与切换"><i class="bi bi-broadcast"></i><span id="currentChannelName">主电台</span></button>
<button class="btn-link-sm" id="liveLink" title="复制直播流链接"
><i class="bi bi-link-45deg"></i></button
>
<span class="user-name" id="userName" title="" style="display:none"></span>
<button class="btn-link-sm" id="btnLogout" title="退出登录"
><i class="bi bi-box-arrow-right"></i></button>
</div>
</div>
</header>
<!-- Pages -->
<div class="page-container">
<!-- 0. Live 收听 -->
<div class="page active" id="pageLive">
<div class="live-card">
<div class="live-now-playing">
<div class="live-cover-wrap">
<img class="live-cover" id="liveCover" src="" alt="" />
<div class="live-cover-ph" id="liveCoverPh">
<i class="bi bi-broadcast"></i>
</div>
<span class="live-badge"><span class="live-dot"></span>LIVE</span>
</div>
<div class="live-meta">
<div class="live-station" id="liveStation">主电台</div>
<div class="live-now" id="liveNow">正在加载直播流...</div>
<div class="live-sub" id="liveSub">--</div>
</div>
</div>
<div class="live-controls">
<button class="live-play-btn" id="livePlayBtn"><i class="bi bi-play-fill"></i></button>
<div class="live-progress-wrap">
<div class="live-progress-bar"><div class="live-progress-fill" id="liveProgressFill"></div></div>
<div class="live-status" id="liveStatus">未连接</div>
</div>
</div>
<div class="live-hint">
<i class="bi bi-info-circle"></i>
点击播放按钮收听 <b id="liveHintStation">主电台</b> 的实时直播流
</div>
</div>
</div>
<!-- 1. Search -->
<div class="page" id="pageSearch">
<div class="search-card">
<div class="search-header">
<h2>导入音源</h2>
<p>输入网易云歌曲 ID、链接或歌名查询并下载</p>
</div>
<div class="search-input-wrap">
<div class="search-field">
<input
type="text"
id="musicId"
placeholder="歌曲 ID、链接或歌名,如 33894312 / 知我"
inputmode="text"
autocomplete="off"
spellcheck="false"
/>
<button class="btn-search" id="btnSearch">
查询
</button>
</div>
</div>
<div class="parse-hint" id="parseHint">
<i class="bi bi-magic"></i>
已自动解析为 ID:<code id="parsedId"></code>
</div>
<!-- Preview: info only -->
<div id="musicPreview" style="display: none">
<div class="preview-card">
<div class="preview-cover-section">
<div class="preview-cover-wrap">
<img
id="musicCover"
class="preview-cover"
src=""
alt=""
/>
</div>
<div class="preview-info">
<div
class="preview-title"
id="musicName"
></div>
<div
class="preview-artist"
id="musicArtist"
></div>
</div>
</div>
<div
class="preview-details"
id="musicDetails"
></div>
<div class="preview-actions">
<button class="btn-download" id="btnDownload">
<i class="bi bi-download"></i> 下载到资源库
</button>
</div>
</div>
</div>
<div
id="searchResults"
class="searchResults"
style="display: none"
></div>
<div class="search-tips" id="searchTips">
<div class="tips-icon">
<i class="bi bi-music-note-beamed"></i>
</div>
<div class="tips-title">搜索并下载音乐</div>
<div class="tips-desc">
在网易云音乐中复制歌曲 ID 或完整链接<br />粘贴到上方输入框即可自动识别
</div>
</div>
<!-- Import local audio -->
<div class="import-card" id="importCard">
<div class="import-header">
<h3><i class="bi bi-upload"></i> 本地导入</h3>
<p>上传音频文件,自动识别作者与封面(封面存本地)</p>
</div>
<input type="file" id="importFile" accept="audio/*,.mp3,.m4a,.flac,.wav,.aac,.ogg" hidden />
<input type="text" id="importTitle" class="import-input" placeholder="歌曲名(选填,留空则用文件名)" />
<input type="text" id="importArtist" class="import-input" placeholder="作者(选填,留空自动匹配)" />
<button class="btn-download" id="btnImport"><i class="bi bi-upload"></i> 选择文件并导入</button>
<div class="import-progress" id="importProgress"></div>
</div>
</div>
</div>
<!-- 2. Resources -->
<div class="page" id="pageResources">
<div class="section-card">
<div class="section-header">
<div class="section-title">
<i
class="bi bi-folder2-open"
style="color: var(--accent)"
></i>
已有音乐
<span class="section-count" id="resourceCount"
>0</span
>
</div>
<div class="resource-tools">
<select class="resource-sort" id="resourceSort" title="排序方式">
<option value="created_desc">加入时间 ↓</option>
<option value="created_asc">加入时间 ↑</option>
<option value="heat_desc">热度 ↓</option>
<option value="heat_asc">热度 ↑</option>
<option value="name_asc">名称 ↑</option>
<option value="name_desc">名称 ↓</option>
</select>
<input
type="text"
class="resource-search"
id="resourceSearch"
placeholder="搜索已有音乐..."
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div class="section-body" id="resourceList">
<div class="empty-state">
<div class="empty-icon">
<i class="bi bi-arrow-repeat"></i>
</div>
<div class="empty-title">加载中...</div>
</div>
</div>
</div>
</div>
<!-- 3. Playlist -->
<div class="page" id="pagePlaylist">
<div class="section-card">
<div class="section-header">
<div class="section-title">
<i
class="bi bi-music-note-list"
style="color: var(--accent)"
></i>
播放列表
<span class="section-count" id="playlistCount"
>0</span
>
</div>
<button class="btn-clear" id="btnClear">
<i class="bi bi-trash3"></i> 清空
</button>
</div>
<div class="section-body" id="playlistList">
<div class="empty-state">
<div class="empty-icon">
<i class="bi bi-arrow-repeat"></i>
</div>
<div class="empty-title">加载中...</div>
</div>
</div>
</div>
</div>
<!-- 5. Settings -->
<div class="page" id="pageSettings">
<div class="section-card">
<div class="section-header">
<div class="section-title">
<i class="bi bi-database" style="color: var(--warning)"></i>
缓存管理
</div>
<button class="btn-clear" id="btnCleanup"><i class="bi bi-trash3"></i> 立即清理</button>
</div>
<div class="section-body">
<div class="cache-stat">
<div class="cache-stat-row">
<span>已用 / 上限</span>
<b id="setCacheText">-- / --</b>
</div>
<div class="cache-bar"><div class="cache-bar-fill" id="setCacheBar"></div></div>
<div class="cache-stat-note" id="setCacheNote">超限时自动按热度 LRU 淘汰</div>
</div>
</div>
</div>
<div class="section-card">
<div class="section-header">
<div class="section-title">
<i class="bi bi-image" style="color: var(--accent)"></i>
封面维护
</div>
<button class="btn-clear" id="btnRefreshCovers"><i class="bi bi-arrow-repeat"></i> 刷新缺失封面</button>
</div>
<div class="section-body">
<div class="cache-stat-note" id="coverRefreshNote">为所有没有封面的音乐重新匹配网易云封面(远程优先,本地兜底)</div>
<div class="import-progress" id="coverRefreshProgress"></div>
</div>
</div>
<div class="section-card">
<div class="section-header">
<div class="section-title">
<i class="bi bi-sliders" style="color: var(--accent)"></i>
热配置
</div>
<button class="btn-clear" id="btnSaveSettings"><i class="bi bi-check-lg"></i> 保存并生效</button>
</div>
<div class="section-body">
<div class="settings-form" id="settingsForm">
<div class="setting-row">
<label>切片时长(秒)</label>
<input type="number" id="cfgHlsTime" min="2" max="30" placeholder="5" value="5">
</div>
<div class="setting-row">
<label>滑动窗口(段)</label>
<input type="number" id="cfgSlideWindow" min="5" max="60" placeholder="15" value="15">
</div>
<div class="setting-row">
<label>缓存上限(MB)</label>
<input type="number" id="cfgMaxCacheMB" min="100" max="100000" placeholder="1000" value="1000">
</div>
<div class="setting-row">
<label>默认频道</label>
<select id="cfgDefaultStream"></select>
</div>
<div class="setting-row">
<label>日志级别</label>
<select id="cfgLogLevel">
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</div>
<div class="setting-row">
<label>会话有效期(小时)</label>
<input type="number" id="cfgSessionTtlHours" min="1" max="720" placeholder="72" value="72">
</div>
<div class="setting-row">
<label>登录失败上限(次)</label>
<input type="number" id="cfgLoginMaxFails" min="3" max="20" placeholder="5" value="5">
</div>
<div class="setting-row">
<label>登录锁定时长(分)</label>
<input type="number" id="cfgLoginLockMin" min="1" max="1440" placeholder="10" value="10">
</div>
<div class="setting-row">
<label>搜索缓存时长(秒)</label>
<input type="number" id="cfgSearchCacheTtlSec" min="10" max="3600" placeholder="300" value="300">
</div>
<div class="setting-row">
<label>搜索限流上限(次/窗口)</label>
<input type="number" id="cfgSearchRateMax" min="5" max="500" placeholder="20" value="20">
</div>
<div class="setting-row">
<label>搜索限流窗口(秒)</label>
<input type="number" id="cfgSearchRateWindowSec" min="10" max="600" placeholder="60" value="60">
</div>
</div>
<div class="cache-stat-note" style="margin-top:12px">
ⓘ 修改后点「保存并生效」,立即应用,无需重启。切片时长等影响新生成的流。
</div>
</div>
</div>
<div class="section-card">
<div class="section-header">
<div class="section-title">
<i class="bi bi-shield-lock" style="color: var(--accent)"></i>
权限管理
</div>
<button class="btn-clear" id="btnRefreshUsers"><i class="bi bi-arrow-repeat"></i> 刷新</button>
</div>
<div class="section-body">
<div class="cache-stat-note" style="margin-bottom:12px">
ⓘ 登录过的用户列表。点「设为管理员」授予权限(下次登录生效)。
</div>
<div class="user-list" id="userList">加载中...</div>
</div>
</div>
<div class="section-card">
<div class="section-header">
<div class="section-title">
<i class="bi bi-file-text" style="color: var(--success)"></i>
运行日志
</div>
<select class="log-tail-select" id="logTail">
<option value="50">50 行</option>
<option value="100" selected>100 行</option>
<option value="200">200 行</option>
<option value="500">500 行</option>
</select>
</div>
<div class="section-body">
<pre class="log-view" id="logView">加载中...</pre>
</div>
</div>
</div>
</div>
<!-- 频道管理模态框 -->
<div class="modal-mask" id="streamModal">
<div class="modal-box">
<div class="modal-head">
<h3><i class="bi bi-sliders"></i> 频道管理</h3>
<button class="modal-close" id="streamModalClose" title="关闭"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body">
<div class="stream-list" id="streamMgrList"></div>
<div class="stream-create">
<input type="text" id="newStreamId" placeholder="频道 ID(字母数字_-,如 chill)" autocomplete="off" spellcheck="false" />
<input type="text" id="newStreamName" placeholder="显示名称(选填)" autocomplete="off" />
<button id="btnCreateStream"><i class="bi bi-plus-lg"></i> 新建频道</button>
</div>
</div>
</div>
</div>
<!-- Mini Player — full background progress + drag seek -->
<div class="mini-player" id="miniPlayer">
<div class="mp-progress" id="mpProgress"></div>
<div class="mp-body" id="mpBody">
<img
class="mp-cover"
id="mpCover"
src=""
alt=""
onerror="this.style.display = 'none'"
/>
<div class="mp-info">
<div class="mp-name" id="mpName">未在播放</div>
<div class="mp-artist" id="mpArtist">--</div>
</div>
<div class="mp-controls">
<span class="mp-time" id="mpTime">0:00</span>
<button class="mp-play" id="mpPlay">
<i class="bi bi-play-fill"></i>
</button>
<button class="mp-expand" id="mpExpand" title="查看详情">
<i class="bi bi-chevron-up"></i>
</button>
</div>
</div>
</div>
<!-- Bottom Nav — 4 tabs, full-width aligned with content -->
<nav class="bottom-nav">
<div class="bottom-nav-inner">
<button class="nav-tab active" data-page="pageLive">
<i class="bi bi-broadcast"></i><span>收听</span>
</button>
<button class="nav-tab" data-page="pageSearch">
<i class="bi bi-search"></i><span>搜索</span>
</button>
<button class="nav-tab" data-page="pageResources">
<i class="bi bi-folder"></i><span>资源</span>
</button>
<button class="nav-tab" data-page="pagePlaylist">
<i class="bi bi-list-ul"></i><span>列表</span>
</button>
<button class="nav-tab" data-page="pageSettings">
<i class="bi bi-gear"></i><span>设置</span>
</button>
</div>
</nav>
<!-- Global audio element -->
<audio id="musicPlayer" preload="none" style="display: none"></audio>
<script src="js/app.js"></script>
</body>
</html>