Compare commits
4 Commits
f4b417bcaf
...
7d2a1f0ea5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d2a1f0ea5 | |||
| e5b27a6281 | |||
| fdc2f8b94b | |||
| 6335654904 |
@ -20,6 +20,12 @@ function App() {
|
||||
)
|
||||
// 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名
|
||||
const [wantsFolder, setWantsFolder] = useState<string | null>(null)
|
||||
// 路由是否还在异步解析中。非 #photo 的 hash 要等 checkFolder/checkGate 返回才知道
|
||||
// 去向;解析完成前先不渲染任何目标页,避免已登录时一闪 BlogPage 兜底再跳回。
|
||||
const [resolving, setResolving] = useState(() => {
|
||||
const h = window.location.hash.replace(/^#/, '')
|
||||
return !!h && h !== PHOTO_HASH
|
||||
})
|
||||
const signOut = auth.signOut
|
||||
|
||||
// 监听地址栏 hash:
|
||||
@ -34,22 +40,24 @@ function App() {
|
||||
setWantsPhoto(photo)
|
||||
setWantsFolder(null)
|
||||
setShowLogin(false)
|
||||
// #photo 与无 hash 都能同步定向,不需要异步解析
|
||||
setResolving(!!hash && !photo)
|
||||
}
|
||||
if (photo) return
|
||||
if (photo || !hash) return
|
||||
|
||||
if (hash) {
|
||||
// 先查是否是已注册的特殊文件夹
|
||||
const isFolder = await checkFolder(hash)
|
||||
if (!alive || hash !== window.location.hash.replace(/^#/, '')) return
|
||||
if (isFolder) {
|
||||
setWantsFolder(hash)
|
||||
return
|
||||
}
|
||||
// 不是文件夹,查 gate
|
||||
const ok = await checkGate(hash)
|
||||
if (alive && hash === window.location.hash.replace(/^#/, '')) {
|
||||
setShowLogin(ok)
|
||||
}
|
||||
// 先查是否是已注册的特殊文件夹
|
||||
const isFolder = await checkFolder(hash)
|
||||
if (!alive || hash !== window.location.hash.replace(/^#/, '')) return
|
||||
if (isFolder) {
|
||||
setWantsFolder(hash)
|
||||
setResolving(false)
|
||||
return
|
||||
}
|
||||
// 不是文件夹,查 gate
|
||||
const ok = await checkGate(hash)
|
||||
if (alive && hash === window.location.hash.replace(/^#/, '')) {
|
||||
setShowLogin(ok)
|
||||
setResolving(false)
|
||||
}
|
||||
}
|
||||
check()
|
||||
@ -79,7 +87,10 @@ function App() {
|
||||
}
|
||||
|
||||
let view
|
||||
if (auth.username) {
|
||||
if (resolving) {
|
||||
// 路由解析中:先留空,等 checkFolder/checkGate 定向后再渲染目标页
|
||||
view = null
|
||||
} else if (auth.username) {
|
||||
if (wantsPhoto) {
|
||||
view = <PhotoWallPage onExit={goStart} token={auth.token ?? ''} />
|
||||
} else if (wantsFolder) {
|
||||
|
||||
@ -1,13 +1,39 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, useMotionValue } from 'framer-motion'
|
||||
|
||||
// 跟随鼠标的小圆环(仿 Few & Far)。
|
||||
// 跟随鼠标的实心几何图案(仿 Few & Far)。
|
||||
// 每次刷新随机抽一个形状 + 随机颜色;悬停可交互元素时放大。
|
||||
// 仅在精确指针设备启用;触摸设备与 reduced-motion 下不挂载。
|
||||
|
||||
// 形状库:viewBox 0 0 24 24,统一实心填充。
|
||||
const SHAPES: Array<(c: string) => React.ReactNode> = [
|
||||
// 圆
|
||||
(c) => <circle cx={12} cy={12} r={11} fill={c} />,
|
||||
// 圆角方块
|
||||
(c) => <rect x={1} y={1} width={22} height={22} rx={4} fill={c} />,
|
||||
// 菱形
|
||||
(c) => <path d="M12 0 L24 12 L12 24 L0 12 Z" fill={c} />,
|
||||
// 三角
|
||||
(c) => <path d="M12 1 L23 22 L1 22 Z" fill={c} />,
|
||||
// 五角星
|
||||
(c) => <path d="M12 1 l2.9 6.6 7.2.6 -5.45 4.75 1.65 7.05 -6.3-3.8 -6.3 3.8 1.65-7.05 -5.45-4.75 7.2-.6 Z" fill={c} />,
|
||||
// 心形
|
||||
(c) => <path d="M12 21.5 C12 21.5 1.5 14.3 1.5 7.4 C1.5 4.2 4 1.8 7.1 1.8 C9.2 1.8 11 3 12 4.9 C13 3 14.8 1.8 16.9 1.8 C20 1.8 22.5 4.2 22.5 7.4 C22.5 14.3 12 21.5 12 21.5 Z" fill={c} />,
|
||||
// 六边形
|
||||
(c) => <path d="M6.5 2.5 L17.5 2.5 L23 12 L17.5 21.5 L6.5 21.5 L1 12 Z" fill={c} />,
|
||||
]
|
||||
|
||||
export function CursorFollower() {
|
||||
// 位置 1:1 跟手,不做弹簧平滑(平滑在掉帧时会显得发涩/拖影)。
|
||||
const x = useMotionValue(-100)
|
||||
const y = useMotionValue(-100)
|
||||
|
||||
// 每次挂载(= 每次刷新)固定一个随机形状 + 随机颜色。
|
||||
const [{ shape, color }] = useState(() => ({
|
||||
shape: SHAPES[Math.floor(Math.random() * SHAPES.length)],
|
||||
color: `hsl(${Math.floor(Math.random() * 360)}, 70%, 55%)`,
|
||||
}))
|
||||
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [hovering, setHovering] = useState(false)
|
||||
const [visible, setVisible] = useState(false)
|
||||
@ -45,20 +71,23 @@ export function CursorFollower() {
|
||||
return (
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed left-0 top-0 z-[100]"
|
||||
className="cursor-follower pointer-events-none fixed left-0 top-0 z-[100]"
|
||||
style={{ x, y }}
|
||||
>
|
||||
<motion.span
|
||||
className="block rounded-full border border-accent"
|
||||
<motion.div
|
||||
className="block"
|
||||
style={{ translateX: '-50%', translateY: '-50%' }}
|
||||
animate={{
|
||||
width: hovering ? 54 : 24,
|
||||
height: hovering ? 54 : 24,
|
||||
opacity: visible ? 1 : 0,
|
||||
backgroundColor: hovering ? 'rgba(201, 154, 91, 0.22)' : 'rgba(201, 154, 91, 0)',
|
||||
width: hovering ? 40 : 20,
|
||||
height: hovering ? 40 : 20,
|
||||
opacity: visible ? 0.9 : 0,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
||||
/>
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="100%" height="100%">
|
||||
{shape(color)}
|
||||
</svg>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -551,8 +551,8 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
showToast(`已达上限(${maxItems} 条)`)
|
||||
return
|
||||
}
|
||||
// 客户端压缩(大图缩小 + 转 JPEG q=0.78)
|
||||
const src = await compressImage(rawSrc)
|
||||
// 客户端压缩:仅当原文件 > 1MB 时才压(大图缩小 + 转 JPEG q=0.78),小图原样上传
|
||||
const src = file.size > 1024 * 1024 ? await compressImage(rawSrc) : rawSrc
|
||||
const uploadFn = isPersonal
|
||||
? (t: string, d: string) => uploadPhoto(t, d)
|
||||
: (t: string, d: string) => uploadFolderPhoto(t, folderName!, d)
|
||||
@ -644,6 +644,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
const dx = ev.clientX - startX
|
||||
const dy = ev.clientY - startY
|
||||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
|
||||
if (!moved) document.body.classList.add('pw-hide-cursor')
|
||||
moved = true
|
||||
clearTimeout(longPress)
|
||||
}
|
||||
@ -654,6 +655,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
||||
const cleanup = () => {
|
||||
clearTimeout(longPress)
|
||||
node.classList.remove('pw-dragging')
|
||||
document.body.classList.remove('pw-hide-cursor')
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
@ -46,6 +46,11 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* 照片墙拖动照片时隐藏自定义鼠标(拖拽中由 PhotoWallPage 切换此类) */
|
||||
body.pw-hide-cursor .cursor-follower {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* 全站文字默认不可选;输入框/文本域/可编辑区例外,加 data-selectable 可单独放开 */
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
|
||||
@ -4,15 +4,14 @@
|
||||
//! 机器内存紧张,宁可每次打一次很轻的索引查询,也不常驻一份副本:
|
||||
//! - 登录:表 `ccuser(username, password_sha256)`;
|
||||
//! - 入口门:表 `dev_gate(gate_hash)`。
|
||||
//! 连接串由环境变量 `DATABASE_URL` 指定,`UserStore` 与 `DevGate` **共用同一个
|
||||
//! 连接池**(见 `db_pool_from_env`),避免开两份连接。未设置 `DATABASE_URL` 时各自
|
||||
//! 回退到内置/文件兜底,方便本地无 DB 时开发。
|
||||
//! 连接串由环境变量 `DATABASE_URL` 指定,`UserStore`/`WallUserStore`/`DevGate` **共用
|
||||
//! 同一个连接池**(见 `db_pool_from_env`),避免开多份连接。**强制要求 `DATABASE_URL`**:
|
||||
//! 未设置则启动即 panic——不再有任何写死/文件兜底,登录与入口门一律走数据库。
|
||||
//!
|
||||
//! 口令永不明文存储:库里存的是 SHA256(hex),登录时把传入明文同样
|
||||
//! 哈希后做**等长比较**。(注意:SHA256 仅用于“不落明文”,并非抗暴力破解的
|
||||
//! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。)
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
@ -21,18 +20,19 @@ use sha2::{Digest, Sha256};
|
||||
use sqlx::mysql::{MySqlPool, MySqlPoolOptions};
|
||||
|
||||
/// 按 `DATABASE_URL` 建一个**懒连接**池(启动不阻塞,首次查询才真正建连),
|
||||
/// 供 `UserStore` 与 `DevGate` 共用。未设置该环境变量则返回 `None`,调用方各自兜底。
|
||||
pub fn db_pool_from_env() -> Option<MySqlPool> {
|
||||
match std::env::var("DATABASE_URL") {
|
||||
Ok(url) if !url.trim().is_empty() => {
|
||||
let pool = MySqlPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_lazy(&url)
|
||||
.expect("DATABASE_URL 格式非法");
|
||||
Some(pool)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
/// 供 `UserStore`/`WallUserStore`/`DevGate` 共用。
|
||||
///
|
||||
/// **强制要求 `DATABASE_URL`**:未设置则直接 panic 拒绝启动——不再有任何写死/文件兜底,
|
||||
/// 所有登录与入口门一律走数据库。
|
||||
pub fn db_pool_from_env() -> MySqlPool {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.ok()
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.expect("未设置 DATABASE_URL:登录与入口门已无兜底,必须配置数据库连接串后再启动");
|
||||
MySqlPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_lazy(&url)
|
||||
.expect("DATABASE_URL 格式非法")
|
||||
}
|
||||
|
||||
/// 开发者「入口门」:一组合法的入口 hash。
|
||||
@ -43,119 +43,46 @@ pub fn db_pool_from_env() -> Option<MySqlPool> {
|
||||
///
|
||||
/// 注意:入口 hash 只是“藏门”的共享口令,**不是身份凭证**。真正证明身份仍靠
|
||||
/// 登录(用户名 + 密码,见 `UserStore`)。
|
||||
pub enum DevGate {
|
||||
pub struct DevGate {
|
||||
/// 每次校验查 MySQL `dev_gate` 表(不缓存整表)。
|
||||
Db(MySqlPool),
|
||||
/// 内置/文件载入的 hash 集合,仅作无 DB 时的兜底。
|
||||
Memory(HashSet<String>),
|
||||
pool: MySqlPool,
|
||||
}
|
||||
|
||||
impl DevGate {
|
||||
/// 有连接池就走 MySQL;否则读 `GATE_FILE`(默认 `dev_gate.txt`),文件也没有再 `seed()`。
|
||||
pub fn new(pool: Option<MySqlPool>) -> Self {
|
||||
match pool {
|
||||
Some(p) => {
|
||||
eprintln!("[gate] 入口校验走 MySQL");
|
||||
DevGate::Db(p)
|
||||
}
|
||||
None => Self::load_file(),
|
||||
}
|
||||
/// 入口门一律走 MySQL(`dev_gate` 表),无任何文件/写死兜底。
|
||||
pub fn new(pool: MySqlPool) -> Self {
|
||||
eprintln!("[gate] 入口校验走 MySQL");
|
||||
DevGate { pool }
|
||||
}
|
||||
|
||||
/// 从 `GATE_FILE`(默认 `dev_gate.txt`)读入口 hash;读不到回退内置 `seed()`。
|
||||
/// 文件格式:一行一个 hash,`#` 之后为注释,空行忽略。
|
||||
fn load_file() -> Self {
|
||||
let path = std::env::var("GATE_FILE").unwrap_or_else(|_| "dev_gate.txt".to_string());
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => {
|
||||
let hashes = Self::parse(&text);
|
||||
eprintln!("[gate] 从 {path} 载入 {} 个入口 hash", hashes.len());
|
||||
DevGate::Memory(hashes)
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[gate] 未找到 {path},回退到内置写死列表");
|
||||
Self::seed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把文件文本解析为 hash 集合:丢掉 `#` 注释与空行,每行取剩下的非空串。
|
||||
fn parse(text: &str) -> HashSet<String> {
|
||||
text.lines()
|
||||
.map(|line| line.split('#').next().unwrap_or("").trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 内置写死的入口 hash 表,作为无 DB / 无文件时的兜底。
|
||||
pub fn seed() -> Self {
|
||||
let mut hashes = HashSet::new();
|
||||
|
||||
// cc:SHA256("192118Lht") 的前 12 位。对应入口 域名/#0086e83c9b10
|
||||
hashes.insert("0086e83c9b10".to_string());
|
||||
|
||||
DevGate::Memory(hashes)
|
||||
}
|
||||
|
||||
/// 该 hash 是否属于某个合法开发者。DB 模式下每次查库(命中即放行);
|
||||
/// 该 hash 是否属于某个合法开发者。每次查库(命中即放行);
|
||||
/// 查询出错按“不放行”处理,不泄露后端内部状态。
|
||||
pub async fn allows(&self, hash: &str) -> bool {
|
||||
match self {
|
||||
DevGate::Memory(hashes) => hashes.contains(hash),
|
||||
DevGate::Db(pool) => {
|
||||
let row: Result<Option<(String,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT gate_hash FROM dev_gate WHERE gate_hash = ?")
|
||||
.bind(hash)
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
match row {
|
||||
Ok(found) => found.is_some(),
|
||||
Err(e) => {
|
||||
eprintln!("[gate] 查询 dev_gate 失败: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
let row: Result<Option<(String,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT gate_hash FROM dev_gate WHERE gate_hash = ?")
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await;
|
||||
match row {
|
||||
Ok(found) => found.is_some(),
|
||||
Err(e) => {
|
||||
eprintln!("[gate] 查询 dev_gate 失败: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户校验后端:优先连 MySQL,无 `DATABASE_URL` 时退回内置写死表。
|
||||
pub enum UserStore {
|
||||
/// 从 MySQL 的 `ccuser` 表查 `password_sha256`。
|
||||
Db(MySqlPool),
|
||||
/// 内置写死表(用户名 → 密码SHA256 hex),仅作无 DB 时的兜底。
|
||||
Memory(HashMap<String, String>),
|
||||
/// 用户校验后端:一律走 MySQL `ccuser` 表,无写死兜底。
|
||||
pub struct UserStore {
|
||||
pool: MySqlPool,
|
||||
}
|
||||
|
||||
impl UserStore {
|
||||
/// 有连接池就走 MySQL(每次校验查 `ccuser`);否则回退到内置 `seed()`。
|
||||
pub fn new(pool: Option<MySqlPool>) -> Self {
|
||||
match pool {
|
||||
Some(p) => {
|
||||
eprintln!("[auth] 用户校验走 MySQL");
|
||||
UserStore::Db(p)
|
||||
}
|
||||
None => {
|
||||
eprintln!("[auth] 未设置 DATABASE_URL,回退到内置写死用户表");
|
||||
Self::seed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内置写死的单用户表,作为 `load()` 无 DB 配置时的兜底。
|
||||
pub fn seed() -> Self {
|
||||
let mut users = HashMap::new();
|
||||
|
||||
// cc / 192118Lht
|
||||
// 下面这串是 SHA256("192118Lht") 的 hex,明文不进源码。
|
||||
users.insert(
|
||||
"cc".to_string(),
|
||||
"0086e83c9b108b227eed55425e9641286f42bd0c31a1b95afbb9edd6d3aa6234".to_string(),
|
||||
);
|
||||
|
||||
UserStore::Memory(users)
|
||||
/// 每次校验查 `ccuser`,无任何内置兜底。
|
||||
pub fn new(pool: MySqlPool) -> Self {
|
||||
eprintln!("[auth] 用户校验走 MySQL");
|
||||
UserStore { pool }
|
||||
}
|
||||
|
||||
/// 校验用户名 + 明文密码是否匹配。
|
||||
@ -164,21 +91,16 @@ impl UserStore {
|
||||
/// 不向外区分(避免泄露“某用户名是否存在”或后端内部状态)。
|
||||
pub async fn verify(&self, username: &str, password: &str) -> bool {
|
||||
let actual_hex = sha256_hex(password);
|
||||
let expected_hex = match self {
|
||||
UserStore::Memory(users) => users.get(username).cloned(),
|
||||
UserStore::Db(pool) => {
|
||||
let row: Result<Option<(String,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT password_sha256 FROM ccuser WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
match row {
|
||||
Ok(found) => found.map(|(hex,)| hex),
|
||||
Err(e) => {
|
||||
eprintln!("[auth] 查询 ccuser 失败: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
let row: Result<Option<(String,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT password_sha256 FROM ccuser WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await;
|
||||
let expected_hex = match row {
|
||||
Ok(found) => found.map(|(hex,)| hex),
|
||||
Err(e) => {
|
||||
eprintln!("[auth] 查询 ccuser 失败: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
match expected_hex {
|
||||
@ -188,60 +110,29 @@ impl UserStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 照片墙用户校验后端:查 `wall_users` 表(与 `ccuser` 物理隔离)。
|
||||
/// 未设 DATABASE_URL 时退回内置兜底。
|
||||
pub enum WallUserStore {
|
||||
Db(MySqlPool),
|
||||
Memory(HashMap<String, String>),
|
||||
/// 照片墙用户校验后端:一律走 MySQL `wall_users` 表(与 `ccuser` 物理隔离),无写死兜底。
|
||||
pub struct WallUserStore {
|
||||
pool: MySqlPool,
|
||||
}
|
||||
|
||||
impl WallUserStore {
|
||||
pub fn new(pool: Option<MySqlPool>) -> Self {
|
||||
match pool {
|
||||
Some(p) => {
|
||||
eprintln!("[auth] 照片墙用户校验走 MySQL (wall_users)");
|
||||
WallUserStore::Db(p)
|
||||
}
|
||||
None => {
|
||||
eprintln!("[auth] 未设置 DATABASE_URL,照片墙用户回退到内置表");
|
||||
Self::seed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内置兜底:cc + UserTest
|
||||
pub fn seed() -> Self {
|
||||
let mut users = HashMap::new();
|
||||
// cc / 192118Lht
|
||||
users.insert(
|
||||
"cc".to_string(),
|
||||
"0086e83c9b108b227eed55425e9641286f42bd0c31a1b95afbb9edd6d3aa6234".to_string(),
|
||||
);
|
||||
// UserTest / 2015.08.607
|
||||
users.insert(
|
||||
"UserTest".to_string(),
|
||||
"76f03f2eaa05e66c82d25e168fec27e7d0d202f15da73aa5852562f863633b8e".to_string(),
|
||||
);
|
||||
WallUserStore::Memory(users)
|
||||
pub fn new(pool: MySqlPool) -> Self {
|
||||
eprintln!("[auth] 照片墙用户校验走 MySQL (wall_users)");
|
||||
WallUserStore { pool }
|
||||
}
|
||||
|
||||
pub async fn verify(&self, username: &str, password: &str) -> bool {
|
||||
let actual_hex = sha256_hex(password);
|
||||
let expected_hex = match self {
|
||||
WallUserStore::Memory(users) => users.get(username).cloned(),
|
||||
WallUserStore::Db(pool) => {
|
||||
let row: Result<Option<(String,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT password_sha256 FROM wall_users WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
match row {
|
||||
Ok(found) => found.map(|(hex,)| hex),
|
||||
Err(e) => {
|
||||
eprintln!("[auth] 查询 wall_users 失败: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
let row: Result<Option<(String,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT password_sha256 FROM wall_users WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await;
|
||||
let expected_hex = match row {
|
||||
Ok(found) => found.map(|(hex,)| hex),
|
||||
Err(e) => {
|
||||
eprintln!("[auth] 查询 wall_users 失败: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
match expected_hex {
|
||||
|
||||
@ -17,7 +17,7 @@ use crate::weather::WeatherService;
|
||||
pub struct AppState {
|
||||
/// 最新一次采样快照。
|
||||
pub snapshot: Arc<RwLock<Stats>>,
|
||||
/// 用户校验后端(MySQL 或内置兜底):内部已是连接池/只读 map,`Arc` 共享即可。
|
||||
/// 用户校验后端(MySQL `ccuser`):内部持连接池,`Arc` 共享即可。
|
||||
pub users: Arc<UserStore>,
|
||||
/// 开发者入口门:合法入口 hash 集合,只读共享。
|
||||
pub gate: Arc<DevGate>,
|
||||
@ -36,6 +36,7 @@ pub struct AppState {
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
// 一个连接池给登录校验与入口门共用(克隆是廉价的、共享底层连接)。
|
||||
// db_pool_from_env 强制要求 DATABASE_URL,缺失则在此 panic 拒绝启动。
|
||||
let pool = auth::db_pool_from_env();
|
||||
Self {
|
||||
snapshot: Arc::new(RwLock::new(Stats::default())),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 登录 + 开发者入口门(MySQL 后端)
|
||||
|
||||
两道关卡,都由后端 Rust(`Server/src/auth.rs`)实现,**默认实时查 MySQL**:
|
||||
两道关卡,都由后端 Rust(`Server/src/auth.rs`)实现,**实时查 MySQL(无兜底)**:
|
||||
|
||||
1. **入口门(藏门)**:起始页没有任何登录 UI 入口(设置齿轮已移除)。只有访问
|
||||
`http://<host>/#<hash>` 且 `<hash>` 命中后端 `dev_gate` 表,前端才放行到登录页。
|
||||
@ -24,16 +24,17 @@
|
||||
回起始页时抹掉地址栏 hash)、登录 `Client/src/api/auth.ts`。
|
||||
- 后端:`Server/src/auth.rs`、路由 `Server/src/routes/web.rs` 的 `/api/web/gate`、`/api/web/login`。
|
||||
|
||||
## 存储:MySQL(不缓存,按需查库)
|
||||
## 存储:MySQL(不缓存,按需查库,**无兜底**)
|
||||
|
||||
机器内存紧张,所以**不把整表读进内存**:`UserStore` 与 `DevGate` 都是
|
||||
`Db(pool) | Memory(兜底)` 两态,DB 模式下每次校验跑一条索引查询。
|
||||
二者**共用同一个 sqlx 懒连接池**(`auth::db_pool_from_env()`,max 5 连接),避免开两份连接。
|
||||
机器内存紧张,所以**不把整表读进内存**:`UserStore`(`ccuser`)、`WallUserStore`
|
||||
(`wall_users`,照片墙登录)、`DevGate`(`dev_gate`)三者各持一个连接池,每次校验跑一条
|
||||
索引查询。**共用同一个 sqlx 懒连接池**(`auth::db_pool_from_env()`,max 5 连接)。
|
||||
|
||||
- 连接串来自环境变量 `DATABASE_URL`(线上写在 `/opt/internet-project/weather.env`):
|
||||
`mysql://KarCharon:<password>@127.0.0.1:3306/InternetProject`
|
||||
- **未设 `DATABASE_URL`** → 各自回退:用户用内置写死表(仅 `cc`)、入口门读 `GATE_FILE`
|
||||
(默认 `dev_gate.txt`)再退内置。方便本地无 DB 时开发。
|
||||
- **强制要求 `DATABASE_URL`**:未设置则 `db_pool_from_env()` 直接 `panic`,服务拒绝启动。
|
||||
**不再有任何写死账号 / 文件兜底**(旧的内置写死表、`GATE_FILE`/`dev_gate.txt` 文件模式
|
||||
已全部删除)——登录与入口门一律走数据库。本地开发也必须先把库搭好(见下)。
|
||||
- 依赖:`sqlx 0.8`,features `runtime-tokio, tls-rustls, mysql, macros`。
|
||||
|
||||
### 表结构(库 `InternetProject`,utf8mb4)
|
||||
@ -51,22 +52,34 @@ CREATE TABLE dev_gate (
|
||||
label VARCHAR(64) DEFAULT NULL, -- 备注(开发者名)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE wall_users ( -- 照片墙登录,与 ccuser 物理隔离
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password_sha256 CHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
- 加用户:`INSERT INTO ccuser (username, password_sha256) VALUES ('名', SHA2('明文',256));`
|
||||
- 加开发者用户:`INSERT INTO ccuser (username, password_sha256) VALUES ('名', SHA2('明文',256));`
|
||||
- 加照片墙用户:`INSERT INTO wall_users (username, password_sha256) VALUES ('名', SHA2('明文',256));`
|
||||
- 加开发者入口:`INSERT INTO dev_gate (gate_hash, label) VALUES ('<hash>', '名');`
|
||||
(DB 模式即时生效,无需重启。)
|
||||
(即时生效,无需重启。)
|
||||
- 当前 `cc`:登录密码 `192118Lht`;入口 hash `0086e83c9b10`(= 该密码 SHA256 的前 12 位)。
|
||||
照片墙用户另有 `UserTest`(文件夹 607)、`UserTest6340`(文件夹 6340)。
|
||||
|
||||
## 一次性搭库(服务器本机,MySQL 仅监听 127.0.0.1)
|
||||
## 一次性搭库(服务器本机 / 本地开发,MySQL/MariaDB 仅监听 127.0.0.1)
|
||||
|
||||
```sql
|
||||
CREATE DATABASE InternetProject CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'KarCharon'@'localhost' IDENTIFIED WITH mysql_native_password BY '<password>';
|
||||
CREATE USER 'KarCharon'@'localhost' IDENTIFIED BY '<password>';
|
||||
GRANT ALL PRIVILEGES ON InternetProject.* TO 'KarCharon'@'localhost';
|
||||
-- 然后建上面两张表、插入初始数据。
|
||||
-- 然后建上面三张表(ccuser / dev_gate / wall_users)、插入初始数据。
|
||||
```
|
||||
|
||||
> 因为已**去兜底**,本地开发同样要先搭库(无 DB 启动即 panic)。MariaDB 即可,与 MySQL
|
||||
> 兼容(`SHA2()`、`mysql_native_password` 均支持)。启动后端时带上连接串:
|
||||
> `DATABASE_URL='mysql://KarCharon:<password>@127.0.0.1:3306/InternetProject' cargo run`
|
||||
|
||||
## 安全注记(待办)
|
||||
|
||||
- 密码是**裸 SHA256(无盐)**,仅保证「不落明文」,不抗暴力破解。对外网开放前应换
|
||||
|
||||
Loading…
Reference in New Issue
Block a user