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 { 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
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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
/target
|
||||||
|
|
||||||
|
# 开发者入口 hash 列表(秘密,单独部署,勿提交)
|
||||||
|
/dev_gate.txt
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user