feat: enforce auth gate across linux lab

This commit is contained in:
Codex
2026-03-24 17:07:40 +08:00
parent d2b667f569
commit d61730bf17
2 changed files with 223 additions and 74 deletions

View File

@@ -434,6 +434,74 @@
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
} }
.auth-gate {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 100;
}
.auth-gate.hidden {
opacity: 0;
pointer-events: none;
}
.auth-gate-card {
width: min(520px, 100%);
border-radius: 24px;
border: 1px solid var(--line);
background: var(--panel);
padding: 30px;
text-align: center;
box-shadow: var(--shadow);
}
.auth-gate-card h2 {
margin: 0 0 10px;
font-size: 28px;
}
.auth-gate-card p {
margin: 0 0 18px;
color: var(--muted);
line-height: 1.7;
}
.auth-gate-card form {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.auth-gate-card input {
border-radius: 12px;
border: 1px solid var(--line);
padding: 12px 14px;
background: transparent;
color: var(--text);
font-size: 14px;
}
.auth-gate-card button {
border: 0;
border-radius: 14px;
padding: 12px 18px;
font-weight: 700;
background: linear-gradient(135deg, var(--brand), #37a1ff);
color: #fff;
cursor: pointer;
font-size: 14px;
}
.overview-nav {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.overview-nav .nav-card {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.18);
}
.variant { .variant {
padding: 10px 12px; padding: 10px 12px;
border-radius: 14px; border-radius: 14px;
@@ -486,6 +554,7 @@
<button class="btn-ghost" type="button" id="languageBtn">EN</button> <button class="btn-ghost" type="button" id="languageBtn">EN</button>
<button class="btn-soft" type="button" id="resetBtn" onclick="resetSandbox()">Reset sandbox</button> <button class="btn-soft" type="button" id="resetBtn" onclick="resetSandbox()">Reset sandbox</button>
<a class="btn-ghost" id="privacyLink" href="/privacy" target="_blank" rel="noreferrer">Privacy</a> <a class="btn-ghost" id="privacyLink" href="/privacy" target="_blank" rel="noreferrer">Privacy</a>
<button class="btn-ghost" type="button" id="logoutBtn" style="display:none;">退出登录</button>
</div> </div>
</header> </header>
@@ -535,29 +604,29 @@
<div class="diagnostic" id="diagnosticNote" style="margin-top: 12px;">Checking course and sandbox alignment...</div> <div class="diagnostic" id="diagnosticNote" style="margin-top: 12px;">Checking course and sandbox alignment...</div>
</section> </section>
<section class="section">
<div class="eyebrow">登录培训入口</div>
<h2>登录并解锁专属实验</h2>
<div class="auth-card">
<div class="auth-header">
<div>
<strong id="authModeLabel">未登录</strong>
<p style="margin:4px 0 0; font-size:12px; color:var(--muted);" id="authHint">登录后可访问加速练习、保存进度、查看历史。</p>
</div>
<span class="auth-status" id="authStatusBadge">公开访问</span>
</div>
<form id="loginForm">
<input id="authUser" type="text" placeholder="用户名" required />
<input id="authPassword" type="password" placeholder="密码" required />
<button type="submit" class="btn">登录并解锁</button>
</form>
<button type="button" class="btn-soft" id="logoutBtn" style="display:none;">退出登录</button>
<div id="authMessage" style="font-size:13px; color:var(--muted);"></div>
</div>
</section>
</aside> </aside>
<main class="main"> <main class="main">
<section class="card overview-nav" id="overviewNav">
<article class="nav-card">
<div class="eyebrow" id="navEyebrow">总览菜单</div>
<h3 id="navTitle">课程总览</h3>
<p class="muted" id="navText">按模块、阶段或练习快速跳转,不再只能上一课下一课。</p>
<div class="mini-stats">
<div class="mini-stat"><span id="navStageCountLabel">已设置阶段</span><strong id="navStageCount">0</strong></div>
<div class="mini-stat"><span id="navExerciseCountLabel">练习总数</span><strong id="navExerciseCount">0</strong></div>
</div>
<div class="results" id="navLessonResults"><div class="empty" id="navLessonEmpty">登录后即可选择模块导航。</div></div>
</article>
<article class="nav-card">
<div class="eyebrow" id="navStageEyebrow">阶段地图</div>
<div id="navStageMap"></div>
</article>
<article class="nav-card">
<div class="eyebrow" id="navCategoryEyebrow">命令分类</div>
<div id="navCategoryGrid"></div>
</article>
</section>
<section class="hero card"> <section class="hero card">
<div class="hero-head"> <div class="hero-head">
<div> <div>
@@ -590,17 +659,17 @@
</section> </section>
<section class="detail card"> <section class="detail card">
<div class="eyebrow">Stage map</div> <div class="eyebrow" id="stageMapEyebrow">Stage map</div>
<h2>阶段路线清单</h2> <h2 id="stageMapTitle">阶段路线清单</h2>
<p class="muted">按阶段划分的学习路线,帮你把命令练习串联成一条完整的运维路径。</p> <p class="muted" id="stageMapText">按阶段划分的学习路线,帮你把命令练习串联成一条完整的运维路径。</p>
<div class="stage-map" id="stageMap"></div> <div class="stage-map" id="stageMap"></div>
<div class="progress-track" id="progressTrack"></div> <div class="progress-track" id="progressTrack"></div>
</section> </section>
<section class="detail card"> <section class="detail card">
<div class="eyebrow">Command categories</div> <div class="eyebrow" id="categoryEyebrow">Command categories</div>
<h2>命令家族可视化</h2> <h2 id="categoryTitle">命令家族可视化</h2>
<p class="muted">了解各类命令在当前课程中的分布,以及建议的学习顺序。</p> <p class="muted" id="categoryText">了解各类命令在当前课程中的分布,以及建议的学习顺序。</p>
<div class="category-grid" id="categoryGrid"></div> <div class="category-grid" id="categoryGrid"></div>
</section> </section>
@@ -1343,6 +1412,7 @@
document.getElementById('practiceText').textContent = text.practiceText; document.getElementById('practiceText').textContent = text.practiceText;
document.getElementById('prevBtn').textContent = text.prevBtn; document.getElementById('prevBtn').textContent = text.prevBtn;
document.getElementById('nextBtn').textContent = text.nextBtn; document.getElementById('nextBtn').textContent = text.nextBtn;
renderAuthControls();
if (!state.overview) { if (!state.overview) {
document.getElementById('courseTitle').textContent = text.courseTitle; document.getElementById('courseTitle').textContent = text.courseTitle;
@@ -1389,6 +1459,7 @@
renderStaticText(); renderStaticText();
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light')); document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light'));
document.getElementById('languageBtn').addEventListener('click', () => setLanguage(state.language === 'zh' ? 'en' : 'zh')); document.getElementById('languageBtn').addEventListener('click', () => setLanguage(state.language === 'zh' ? 'en' : 'zh'));
document.getElementById('logoutBtn').addEventListener('click', logoutLab);
document.getElementById('searchInput').addEventListener('keydown', (event) => { document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') runSearch(); if (event.key === 'Enter') runSearch();
}); });
@@ -1399,8 +1470,12 @@
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id); if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id);
}); });
document.getElementById('masteryBtn').addEventListener('click', toggleMastery); document.getElementById('masteryBtn').addEventListener('click', toggleMastery);
await loadOverview(); if (state.auth.loggedIn) {
renderAuthStatus(); hideAuthGate();
await loadOverview();
} else {
showAuthGate();
}
}); });
function saveProgress() { function saveProgress() {
@@ -1434,24 +1509,55 @@
localStorage.setItem('linux_lab_auth', JSON.stringify(state.auth)); localStorage.setItem('linux_lab_auth', JSON.stringify(state.auth));
} }
function renderAuthStatus() { function renderAuthControls() {
const badge = document.getElementById('authStatusBadge'); const zh = state.language === 'zh';
const modeLabel = document.getElementById('authModeLabel');
const hint = document.getElementById('authHint');
const form = document.getElementById('loginForm');
const logoutBtn = document.getElementById('logoutBtn'); const logoutBtn = document.getElementById('logoutBtn');
badge.textContent = state.auth.loggedIn ? '已登录' : '公开访问';
modeLabel.textContent = state.auth.loggedIn ? `当前登录:${state.auth.user}` : '未登录';
hint.textContent = state.auth.loggedIn
? '您已解锁全部实验和进度保存功能。'
: '登录后可解锁任务追踪、保存进度和补全排障建议提示。';
form.style.display = state.auth.loggedIn ? 'none' : 'flex';
logoutBtn.style.display = state.auth.loggedIn ? 'inline-flex' : 'none'; logoutBtn.style.display = state.auth.loggedIn ? 'inline-flex' : 'none';
badge.classList.toggle('auth-status', !!state.auth.loggedIn); logoutBtn.textContent = zh ? '退出登录' : 'Log out';
document.getElementById('navEyebrow').textContent = zh ? '总览菜单' : 'Overview menu';
document.getElementById('navTitle').textContent = zh ? '课程总览' : 'Course overview';
document.getElementById('navText').textContent = zh ? '按模块、阶段或练习快速跳转,不再只能上一课下一课。' : 'Jump by module, stage, or exercise instead of relying only on previous and next.';
document.getElementById('navStageCountLabel').textContent = zh ? '已设置阶段' : 'Mapped stages';
document.getElementById('navExerciseCountLabel').textContent = zh ? '练习总数' : 'Exercises';
document.getElementById('navLessonEmpty').textContent = zh ? '登录后即可选择模块导航。' : 'Sign in to unlock module navigation.';
document.getElementById('navStageEyebrow').textContent = zh ? '阶段地图' : 'Stage map';
document.getElementById('navCategoryEyebrow').textContent = zh ? '命令分类' : 'Command categories';
document.getElementById('stageMapEyebrow').textContent = zh ? '阶段地图' : 'Stage map';
document.getElementById('stageMapTitle').textContent = zh ? '阶段路线清单' : 'Stage route checklist';
document.getElementById('stageMapText').textContent = zh ? '按阶段划分的学习路线,帮你把命令练习串联成一条完整的运维路径。' : 'Study by stages so commands connect into a complete operations path.';
document.getElementById('categoryEyebrow').textContent = zh ? '命令分类' : 'Command categories';
document.getElementById('categoryTitle').textContent = zh ? '命令家族可视化' : 'Command family map';
document.getElementById('categoryText').textContent = zh ? '了解各类命令在当前课程中的分布,以及建议的学习顺序。' : 'See how command families are distributed and which order makes sense.';
document.getElementById('gateTitle').textContent = zh ? '请先登录' : 'Sign in first';
document.getElementById('gateDescription').textContent = zh ? '登录后解锁课程总览、阶段地图、命令分类和练习仪表盘。' : 'Sign in to unlock the overview, stage map, command categories, and practice dashboard.';
document.getElementById('gateUser').setAttribute('placeholder', zh ? '用户名' : 'Username');
document.getElementById('gatePassword').setAttribute('placeholder', zh ? '密码' : 'Password');
document.getElementById('gateSubmit').textContent = zh ? '登录并进入总览' : 'Login and enter overview';
if (!state.auth.loggedIn) {
document.getElementById('gateMessage').textContent = zh ? '将自动保存进度与阶段状态,方便继续学习。' : 'Progress and stage state will be saved automatically so you can resume later.';
}
}
async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.auth.token) {
headers.set('Authorization', `Bearer ${state.auth.token}`);
headers.set('X-Token', state.auth.token);
}
return fetch(path, Object.assign({}, options, { headers }));
}
function showAuthGate() {
document.getElementById('authGate').classList.remove('hidden');
}
function hideAuthGate() {
document.getElementById('authGate').classList.add('hidden');
} }
async function loadOverview() { async function loadOverview() {
const response = await fetch('/api/overview'); const response = await apiFetch('/api/overview');
state.overview = await response.json(); state.overview = await response.json();
renderOverview(); renderOverview();
const firstLesson = state.overview.modules?.[0]?.lessons?.[0]; const firstLesson = state.overview.modules?.[0]?.lessons?.[0];
@@ -1482,6 +1588,7 @@
renderModules(); renderModules();
renderStageGrid(); renderStageGrid();
renderModuleSummaryGrid(); renderModuleSummaryGrid();
renderVisualModules();
} }
function renderRuntime(runtime) { function renderRuntime(runtime) {
@@ -1570,7 +1677,7 @@
} }
async function openLesson(lessonId, focusExerciseId = '') { async function openLesson(lessonId, focusExerciseId = '') {
const response = await fetch('/api/lesson?id=' + encodeURIComponent(lessonId)); const response = await apiFetch('/api/lesson?id=' + encodeURIComponent(lessonId));
const payload = await response.json(); const payload = await response.json();
state.lesson = payload.lesson; state.lesson = payload.lesson;
renderModules(); renderModules();
@@ -1750,11 +1857,11 @@
feedback.className = 'feedback'; feedback.className = 'feedback';
feedback.textContent = ''; feedback.textContent = '';
try { try {
const runResponse = await fetch('/api/run?cmd=' + encodeURIComponent(cmd)); const runResponse = await apiFetch('/api/run?cmd=' + encodeURIComponent(cmd));
const runData = await runResponse.json(); const runData = await runResponse.json();
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || text.noOutput}`; output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || text.noOutput}`;
renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' }); renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' });
const checkResponse = await fetch('/api/check?exercise_id=' + encodeURIComponent(exerciseId) + '&last_cmd=' + encodeURIComponent(cmd) + '&output=' + encodeURIComponent(runData.output || '')); const checkResponse = await apiFetch('/api/check?exercise_id=' + encodeURIComponent(exerciseId) + '&last_cmd=' + encodeURIComponent(cmd) + '&output=' + encodeURIComponent(runData.output || ''));
const checkData = await checkResponse.json(); const checkData = await checkResponse.json();
feedback.className = 'feedback show ' + (checkData.success ? 'success' : 'warn'); feedback.className = 'feedback show ' + (checkData.success ? 'success' : 'warn');
feedback.textContent = checkData.message + (checkData.next_suggestion ? '\n' + checkData.next_suggestion : ''); feedback.textContent = checkData.message + (checkData.next_suggestion ? '\n' + checkData.next_suggestion : '');
@@ -1767,7 +1874,7 @@
async function resetSandbox() { async function resetSandbox() {
const text = uiText(); const text = uiText();
await fetch('/api/reset', { method: 'POST' }); await apiFetch('/api/reset', { method: 'POST' });
renderRuntime({ cwd: '/', user: 'sandbox_user' }); renderRuntime({ cwd: '/', user: 'sandbox_user' });
alert(text.sandboxResetComplete); alert(text.sandboxResetComplete);
} }
@@ -1782,7 +1889,7 @@
} }
container.innerHTML = `<div class="empty">${escapeHtml(text.searchLoading)}</div>`; container.innerHTML = `<div class="empty">${escapeHtml(text.searchLoading)}</div>`;
try { try {
const response = await fetch('/api/course/search?q=' + encodeURIComponent(query)); const response = await apiFetch('/api/course/search?q=' + encodeURIComponent(query));
const payload = await response.json(); const payload = await response.json();
const results = payload.results || []; const results = payload.results || [];
if (!results.length) { if (!results.length) {
@@ -1846,6 +1953,19 @@
const percent = totalLessons ? Math.round((masteredLessons / totalLessons) * 100) : 0; const percent = totalLessons ? Math.round((masteredLessons / totalLessons) * 100) : 0;
return `<span class="progress-pill">${escapeHtml(localizedModuleTitle(module))}: ${percent}%</span>`; return `<span class="progress-pill">${escapeHtml(localizedModuleTitle(module))}: ${percent}%</span>`;
}).join(""); }).join("");
const navStageMap = document.getElementById("navStageMap");
const navStageCount = document.getElementById("navStageCount");
if (navStageMap) {
navStageMap.innerHTML = modules.map((module, index) => `
<div class="stage-step">
<strong>${index + 1}. ${escapeHtml(localizedModuleTitle(module))}</strong>
<span class="muted">${(module.lesson_count || 0)} lessons</span>
</div>
`).join("");
}
if (navStageCount) {
navStageCount.textContent = modules.length;
}
} }
function renderCategoryGrid() { function renderCategoryGrid() {
@@ -1873,13 +1993,38 @@
`).join(""); `).join("");
} }
function renderNavLessons() {
const container = document.getElementById("navLessonResults");
const modules = state.overview?.modules || [];
const exerciseCount = modules.reduce((sum, module) => sum + (module.exercise_count || 0), 0);
const lessonCountEl = document.getElementById("navLessonCount");
const exerciseCountEl = document.getElementById("navExerciseCount");
if (lessonCountEl) {
lessonCountEl.textContent = modules.length;
}
if (exerciseCountEl) {
exerciseCountEl.textContent = exerciseCount;
}
if (!modules.length) {
container.innerHTML = `<div class="empty">${escapeHtml(uiText().noModulesFound)}</div>`;
return;
}
container.innerHTML = modules.map((module) => `
<button class="result" type="button" onclick="openLesson('${(module.lessons || [])[0]?.id || ''}')">
<strong>${escapeHtml(localizedModuleTitle(module))}</strong>
<span class="muted">${(module.lesson_count || 0)} lessons · ${(module.exercise_count || 0)} exercises</span>
</button>
`).join("");
}
async function loginToLab(event) { async function loginToLab(event) {
event.preventDefault(); event.preventDefault();
const username = document.getElementById("authUser").value.trim(); const zh = state.language === 'zh';
const password = document.getElementById("authPassword").value.trim(); const username = document.getElementById("gateUser").value.trim();
const messageNode = document.getElementById("authMessage"); const password = document.getElementById("gatePassword").value.trim();
const messageNode = document.getElementById("gateMessage");
if (!username || !password) { if (!username || !password) {
messageNode.textContent = "请输入用户名和密码"; messageNode.textContent = zh ? "请输入用户名和密码" : "Please enter a username and password.";
return; return;
} }
try { try {
@@ -1891,41 +2036,52 @@
const payload = await response.json(); const payload = await response.json();
if (payload.success) { if (payload.success) {
state.auth = { loggedIn: true, user: username, token: payload.token }; state.auth = { loggedIn: true, user: username, token: payload.token };
messageNode.textContent = payload.message || "登录成功"; persistAuth();
document.getElementById("authStatusBadge").textContent = "已登录"; hideAuthGate();
document.getElementById("authModeLabel").textContent = `当前登录:${username}`; messageNode.textContent = payload.message || (zh ? "登录成功" : "Login succeeded.");
document.getElementById("logoutBtn").style.display = "inline-flex"; renderAuthControls();
document.getElementById("loginForm").style.display = "none"; await loadOverview();
return; return;
} }
messageNode.textContent = payload.error || "登录失败"; messageNode.textContent = payload.error || (zh ? "登录失败" : "Login failed.");
} catch (error) { } catch (error) {
messageNode.textContent = "网络异常,请稍后再试"; messageNode.textContent = zh ? "网络异常,请稍后再试" : "Network error. Please try again later.";
} }
} }
function logoutLab() { function logoutLab() {
const zh = state.language === 'zh';
state.auth = { loggedIn: false, user: "", token: "" }; state.auth = { loggedIn: false, user: "", token: "" };
document.getElementById("authMessage").textContent = "已退出"; persistAuth();
document.getElementById("authStatusBadge").textContent = "仅公开"; showAuthGate();
document.getElementById("authModeLabel").textContent = "未登录"; renderAuthControls();
document.getElementById("logoutBtn").style.display = "none"; document.getElementById("gateMessage").textContent = zh ? "已退出,重新登录即可继续。" : "Logged out. Sign in again to continue.";
document.getElementById("loginForm").style.display = "flex";
} }
function renderVisualModules() { function renderVisualModules() {
renderStageTimeline(); renderStageTimeline();
renderCategoryGrid(); renderCategoryGrid();
renderNavLessons();
} }
document.getElementById("loginForm").addEventListener("submit", loginToLab); document.getElementById("gateLoginForm").addEventListener("submit", loginToLab);
document.getElementById("logoutBtn").addEventListener("click", logoutLab);
window.openLesson = openLesson; window.openLesson = openLesson;
window.runExercise = runExercise; window.runExercise = runExercise;
window.runSearch = runSearch; window.runSearch = runSearch;
window.resetSandbox = resetSandbox; window.resetSandbox = resetSandbox;
renderVisualModules();
</script> </script>
<div class="auth-gate" id="authGate">
<div class="auth-gate-card">
<h2 id="gateTitle">请先登录</h2>
<p id="gateDescription">登录后解锁课程总览、阶段地图、命令分类和练习仪表盘。</p>
<form id="gateLoginForm">
<input id="gateUser" type="text" placeholder="用户名" required />
<input id="gatePassword" type="password" placeholder="密码" required />
<button type="submit" id="gateSubmit">登录并进入总览</button>
</form>
<p id="gateMessage" class="muted">将自动保存进度与阶段状态,方便继续学习。</p>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -26,15 +26,11 @@ PUBLIC_GET_PATHS = {
"/", "/",
"/privacy", "/privacy",
"/privacy.html", "/privacy.html",
"/api/course",
"/api/course/search",
"/api/diagnostics",
"/api/health", "/api/health",
"/api/lesson",
"/api/overview",
} }
PUBLIC_POST_PATHS = { PUBLIC_POST_PATHS = {
"/api/login", "/api/login",
"/api/logout",
} }
SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in" SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in"
@@ -1009,8 +1005,6 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
return False return False
def check_auth(self, auth_header: str, token: str) -> bool: def check_auth(self, auth_header: str, token: str) -> bool:
if self.client_address[0] == "127.0.0.1":
return True
if token == "safe_linux_2026": if token == "safe_linux_2026":
return True return True
if auth_header.startswith("Bearer ") and auth_header[7:] == "safe_linux_2026": if auth_header.startswith("Bearer ") and auth_header[7:] == "safe_linux_2026":
@@ -1020,10 +1014,9 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
def require_auth_if_needed(self, path: str, method: str) -> bool: def require_auth_if_needed(self, path: str, method: str) -> bool:
if self.is_public_path(path, method): if self.is_public_path(path, method):
return True return True
host = self.headers.get("Host", "")
auth_header = self.headers.get("Authorization", "") auth_header = self.headers.get("Authorization", "")
token = self.headers.get("X-Token", "") token = self.headers.get("X-Token", "")
if SAFE_REMOTE_HOST in host and not self.check_auth(auth_header, token): if not self.check_auth(auth_header, token):
self.send_json({"error": "Authentication required"}, 401) self.send_json({"error": "Authentication required"}, 401)
return False return False
return True return True