feat(client): 隐藏登录入口改为后端校验 URL hash

去掉起始页右下角设置齿轮(SettingsGear),登录页不再有任何 UI 入口;
改为监听地址栏 `#<hash>`,经 checkGate() 交后端 /api/web/gate 校验,
命中合法开发者才放行到登录页。合法 hash 只存在后端、前端不写死。

- 新增 api/gate.ts;App.tsx 用 hashchange 监听 + 防竞态校验
- 返回起始页时抹掉地址栏 hash,避免密钥留在历史里
- Server/.gitignore 忽略 dev_gate.txt(秘密,单独部署)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cc 2026-06-20 20:45:30 +08:00
parent 58bed0ae44
commit b2a0b563c9
5 changed files with 56 additions and 69 deletions

View File

@ -4,49 +4,51 @@ import { LoginPage } from './components/LoginPage'
import { BlogPage } from './components/BlogPage' import { BlogPage } from './components/BlogPage'
import { StartPage } from './components/StartPage' import { StartPage } from './components/StartPage'
import { useAuth } from './hooks/useAuth' import { useAuth } from './hooks/useAuth'
import { checkGate } from './api/gate'
function App() { function App() {
// 登录态从 localStorage/sessionStorage 恢复:已登录直接进博客页。 // 登录态从 localStorage/sessionStorage 恢复:已登录直接进博客页。
const auth = useAuth() const auth = useAuth()
// 未登录默认进导航页;点齿轮切到登录页 // 是否显示登录页。入口 hash 是否合法由后端判定,故初值为 false等校验通过再开
const [showLogin, setShowLogin] = useState(false) const [showLogin, setShowLogin] = useState(false)
const signOut = auth.signOut const signOut = auth.signOut
// 进登录页:往浏览器历史压一条记录,便于返回键退回。 // 监听地址栏 hash把 `#` 后那串发给后端校验,命中合法开发者才放行到登录页。
const openLogin = () => { // 合法 hash 列表只存在后端,前端不写死,扒 JS 也看不到合法值。
window.history.pushState({ page: 'login' }, '')
setShowLogin(true)
}
// “返回等于登出”:退回导航页时清掉登录态。
// 浏览器返回键 / 登录页返回 / 博客页登出 都汇到这里。
useEffect(() => { useEffect(() => {
const onPop = () => { let alive = true
signOut() const check = async () => {
setShowLogin(false) const hash = window.location.hash.replace(/^#/, '')
const ok = hash ? await checkGate(hash) : false
// 防竞态:异步返回时若组件已卸载或 hash 又变了,丢弃这次结果。
if (alive && hash === window.location.hash.replace(/^#/, '')) {
setShowLogin(ok)
}
} }
window.addEventListener('popstate', onPop) check()
return () => window.removeEventListener('popstate', onPop) window.addEventListener('hashchange', check)
}, [signOut]) return () => {
alive = false
window.removeEventListener('hashchange', check)
}
}, [])
// 博客页登出:若是从登录流程进来的(历史里有 login 记录),走 history.back() // 回到导航页:清掉登录态,并抹掉地址栏里的密钥(防止留在历史里被看到)。
// 让浏览器历史保持干净;否则(如记住登录直接进博客)直接回首页。 const goStart = () => {
const signOutFromBlog = () => { signOut()
if (window.history.state?.page === 'login') { setShowLogin(false)
window.history.back() if (window.location.hash) {
} else { history.replaceState(null, '', window.location.pathname + window.location.search)
signOut()
setShowLogin(false)
} }
} }
let view let view
if (auth.username) { if (auth.username) {
view = <BlogPage username={auth.username} onSignOut={signOutFromBlog} /> view = <BlogPage username={auth.username} onSignOut={goStart} />
} else if (showLogin) { } else if (showLogin) {
view = <LoginPage onSignIn={auth.signIn} onBack={() => window.history.back()} /> view = <LoginPage onSignIn={auth.signIn} onBack={goStart} />
} else { } else {
view = <StartPage onOpenSettings={openLogin} /> view = <StartPage />
} }
return ( return (

24
Client/src/api/gate.ts Normal file
View File

@ -0,0 +1,24 @@
// 入口门校验。把 URL `#` 后那串 hash 发给后端,问“是不是合法开发者的入口”。
// 后端基址默认本机 8080可用 VITE_API_BASE 覆盖(与 auth/useSystemStats 一致)。
const API_BASE =
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080'
/**
* hash true false
* / 200 false
*/
export async function checkGate(hash: string): Promise<boolean> {
if (!hash) return false
try {
const res = await fetch(`${API_BASE}/api/web/gate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash }),
})
if (!res.ok) return false
const data = (await res.json()) as { ok?: boolean }
return data.ok === true
} catch {
return false
}
}

View File

@ -5,7 +5,6 @@ import { useWeather } from '../hooks/useWeather'
import { Greeting } from './start/Greeting' import { Greeting } from './start/Greeting'
import { SearchBar } from './start/SearchBar' import { SearchBar } from './start/SearchBar'
import { QuickLinks } from './start/QuickLinks' import { QuickLinks } from './start/QuickLinks'
import { SettingsGear } from './start/SettingsGear'
import { WeatherFx } from './start/WeatherFx' import { WeatherFx } from './start/WeatherFx'
// 背景图:放 src/assets/startpage/ 下。按昼夜选 light / dark // 背景图:放 src/assets/startpage/ 下。按昼夜选 light / dark
@ -53,12 +52,7 @@ function isThunder(icon?: string): boolean {
return icon ? ['302', '303', '304'].includes(icon) : false return icon ? ['302', '303', '304'].includes(icon) : false
} }
interface StartPageProps { export function StartPage() {
/** 点右下齿轮:切到登录页 */
onOpenSettings: () => void
}
export function StartPage({ onOpenSettings }: StartPageProps) {
const wx = useWeather() const wx = useWeather()
// 每分钟一拍,让昼夜随时间自动翻(即便天气没刷新)。 // 每分钟一拍,让昼夜随时间自动翻(即便天气没刷新)。
@ -132,8 +126,6 @@ export function StartPage({ onOpenSettings }: StartPageProps) {
<QuickLinks /> <QuickLinks />
</motion.div> </motion.div>
</motion.main> </motion.main>
<SettingsGear onClick={onOpenSettings} />
</div> </div>
) )
} }

View File

@ -1,34 +0,0 @@
import { motion } from 'framer-motion'
interface SettingsGearProps {
onClick: () => void
}
// 右下角齿轮:悬停放大并旋转,点击跳转登录页。
export function SettingsGear({ onClick }: SettingsGearProps) {
return (
<motion.button
type="button"
onClick={onClick}
aria-label="Settings — go to sign in"
whileHover={{ scale: 1.15, rotate: 35 }}
whileTap={{ scale: 0.92 }}
transition={{ type: 'spring', stiffness: 300, damping: 18 }}
className="glass-panel fixed bottom-6 right-6 z-30 flex h-12 w-12 items-center justify-center rounded-full text-white/80 hover:text-white"
>
<svg
viewBox="0 0 24 24"
width="22"
height="22"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</motion.button>
)
}

3
Server/.gitignore vendored
View File

@ -1 +1,4 @@
/target /target
# 开发者入口 hash 列表(秘密,单独部署,勿提交)
/dev_gate.txt