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:
parent
58bed0ae44
commit
b2a0b563c9
@ -4,49 +4,51 @@ import { LoginPage } from './components/LoginPage'
|
||||
import { BlogPage } from './components/BlogPage'
|
||||
import { StartPage } from './components/StartPage'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import { checkGate } from './api/gate'
|
||||
|
||||
function App() {
|
||||
// 登录态从 localStorage/sessionStorage 恢复:已登录直接进博客页。
|
||||
const auth = useAuth()
|
||||
// 未登录默认进导航页;点齿轮切到登录页。
|
||||
// 是否显示登录页。入口 hash 是否合法由后端判定,故初值为 false,等校验通过再开。
|
||||
const [showLogin, setShowLogin] = useState(false)
|
||||
const signOut = auth.signOut
|
||||
|
||||
// 进登录页:往浏览器历史压一条记录,便于返回键退回。
|
||||
const openLogin = () => {
|
||||
window.history.pushState({ page: 'login' }, '')
|
||||
setShowLogin(true)
|
||||
}
|
||||
|
||||
// “返回等于登出”:退回导航页时清掉登录态。
|
||||
// 浏览器返回键 / 登录页返回 / 博客页登出 都汇到这里。
|
||||
// 监听地址栏 hash:把 `#` 后那串发给后端校验,命中合法开发者才放行到登录页。
|
||||
// 合法 hash 列表只存在后端,前端不写死,扒 JS 也看不到合法值。
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
signOut()
|
||||
setShowLogin(false)
|
||||
let alive = true
|
||||
const check = async () => {
|
||||
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)
|
||||
return () => window.removeEventListener('popstate', onPop)
|
||||
}, [signOut])
|
||||
}
|
||||
check()
|
||||
window.addEventListener('hashchange', check)
|
||||
return () => {
|
||||
alive = false
|
||||
window.removeEventListener('hashchange', check)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 博客页登出:若是从登录流程进来的(历史里有 login 记录),走 history.back()
|
||||
// 让浏览器历史保持干净;否则(如记住登录直接进博客)直接回首页。
|
||||
const signOutFromBlog = () => {
|
||||
if (window.history.state?.page === 'login') {
|
||||
window.history.back()
|
||||
} else {
|
||||
// 回到导航页:清掉登录态,并抹掉地址栏里的密钥(防止留在历史里被看到)。
|
||||
const goStart = () => {
|
||||
signOut()
|
||||
setShowLogin(false)
|
||||
if (window.location.hash) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||
}
|
||||
}
|
||||
|
||||
let view
|
||||
if (auth.username) {
|
||||
view = <BlogPage username={auth.username} onSignOut={signOutFromBlog} />
|
||||
view = <BlogPage username={auth.username} onSignOut={goStart} />
|
||||
} else if (showLogin) {
|
||||
view = <LoginPage onSignIn={auth.signIn} onBack={() => window.history.back()} />
|
||||
view = <LoginPage onSignIn={auth.signIn} onBack={goStart} />
|
||||
} else {
|
||||
view = <StartPage onOpenSettings={openLogin} />
|
||||
view = <StartPage />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
24
Client/src/api/gate.ts
Normal file
24
Client/src/api/gate.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,6 @@ import { useWeather } from '../hooks/useWeather'
|
||||
import { Greeting } from './start/Greeting'
|
||||
import { SearchBar } from './start/SearchBar'
|
||||
import { QuickLinks } from './start/QuickLinks'
|
||||
import { SettingsGear } from './start/SettingsGear'
|
||||
import { WeatherFx } from './start/WeatherFx'
|
||||
|
||||
// 背景图:放 src/assets/startpage/ 下。按昼夜选 light / dark;
|
||||
@ -53,12 +52,7 @@ function isThunder(icon?: string): boolean {
|
||||
return icon ? ['302', '303', '304'].includes(icon) : false
|
||||
}
|
||||
|
||||
interface StartPageProps {
|
||||
/** 点右下齿轮:切到登录页 */
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function StartPage({ onOpenSettings }: StartPageProps) {
|
||||
export function StartPage() {
|
||||
const wx = useWeather()
|
||||
|
||||
// 每分钟一拍,让昼夜随时间自动翻(即便天气没刷新)。
|
||||
@ -132,8 +126,6 @@ export function StartPage({ onOpenSettings }: StartPageProps) {
|
||||
<QuickLinks />
|
||||
</motion.div>
|
||||
</motion.main>
|
||||
|
||||
<SettingsGear onClick={onOpenSettings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
3
Server/.gitignore
vendored
@ -1 +1,4 @@
|
||||
/target
|
||||
|
||||
# 开发者入口 hash 列表(秘密,单独部署,勿提交)
|
||||
/dev_gate.txt
|
||||
|
||||
Loading…
Reference in New Issue
Block a user