Files
FamilyBook/app/Auth.php
T
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00

298 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 登录态与权限。
*
* 一个重要设计:**人(Person)≠ 账号(Account)**。
* 六岁的孩子应该出现在家庭名册、照片、活动参与人里,但不必有密码。
* 所以 person 可以先于 account 存在,将来再「开通账号」。
*/
declare(strict_types=1);
namespace App;
final class Auth
{
public static function check(): bool
{
return !empty($_SESSION['account_id']);
}
public static function id(): ?int
{
return isset($_SESSION['account_id']) ? (int)$_SESSION['account_id'] : null;
}
public static function personId(): ?int
{
return isset($_SESSION['person_id']) ? (int)$_SESSION['person_id'] : null;
}
public static function familyId(): ?int
{
return isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : null;
}
public static function name(): string
{
return $_SESSION['name'] ?? '';
}
// ---------------- CalDAV 专用口令(v24 第 7 条后半) ----------------
/**
* 这个人有没有配过 CalDAV 口令。
*
* ⚠️ 为什么不用登录口令:CalDAV 客户端(iOS 日历 / DAVx⁵ / Thunderbird)
* 会把这串凭据**长期存在设备上**并反复自动发送。让它是登录口令,
* 等于把主密码复制到每一台设备、且改密码就要每台重配。
* 所以另发一个只用于日历订阅的口令,随时可换、与登录互不影响。
*/
public static function hasCalDav(int $personId): bool
{
$st = Database::get()->prepare('SELECT COUNT(*) FROM caldav_credentials WHERE person_id = ?');
$st->execute([$personId]);
return (int)$st->fetchColumn() > 0;
}
/**
* 生成(或重置)CalDAV 口令,**返回明文**(只在这一刻能看到,库里只有哈希)。
*/
public static function caldavIssue(int $personId): string
{
// 去掉容易读错的字符(0/O、1/l/I),因为用户要手打进手机
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
$plain = '';
for ($i = 0; $i < 24; $i++) {
$plain .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
Database::get()->prepare(
'INSERT INTO caldav_credentials (person_id, token_hash) VALUES (?,?)
ON DUPLICATE KEY UPDATE token_hash = VALUES(token_hash), created_at = NOW()'
)->execute([$personId, hash('sha256', $plain)]);
return $plain;
}
public static function caldavRevoke(int $personId): void
{
Database::get()->prepare('DELETE FROM caldav_credentials WHERE person_id = ?')
->execute([$personId]);
}
/**
* 用 Basic 凭据换一个「登录者」。成功返回 [personId, familyId, name],失败 null。
*
* 用户名可以是手机号,也可以是姓名 —— 手机上打姓名比打 11 位数字省事得多。
* 两者都走同一套节流(失败计数保存在会话里,与登录共用)。
*/
public static function caldavAuth(?string $user, ?string $pass): ?array
{
$user = trim((string)$user);
$pass = (string)$pass;
if ($user === '' || $pass === '') {
return null;
}
$fail = $_SESSION['cd_fail'] ?? ['n' => 0, 't' => 0];
if ($fail['n'] >= 8 && (time() - $fail['t']) < 60) {
usleep(500000);
return null;
}
$db = Database::get();
// 先按手机号(哈希)找,再按姓名找 —— 都限定 status=active
$row = null;
$st = $db->prepare(
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
FROM accounts a
JOIN persons p ON p.id = a.person_id
LEFT JOIN caldav_credentials cd ON cd.person_id = p.id
WHERE a.status = "active" AND p.phone_hash = ? LIMIT 1'
);
$st->execute([Crypto::hash($user)]);
$row = $st->fetch();
if (!$row) {
$st = $db->prepare(
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
FROM accounts a
JOIN persons p ON p.id = a.person_id
LEFT JOIN caldav_credentials cd ON cd.person_id = p.id
WHERE a.status = "active" AND p.name = ?
ORDER BY p.id LIMIT 1'
);
$st->execute([$user]);
$row = $st->fetch();
}
$ok = $row && !empty($row['token_hash'])
&& hash_equals((string)$row['token_hash'], hash('sha256', $pass));
if (!$ok) {
$_SESSION['cd_fail'] = ['n' => $fail['n'] + 1, 't' => time()];
return null;
}
unset($_SESSION['cd_fail']);
$db->prepare('UPDATE caldav_credentials SET last_used_at = NOW() WHERE person_id = ?')
->execute([(int)$row['person_id']]);
return [
'person_id' => (int)$row['person_id'],
'family_id' => (int)$row['family_id'],
'name' => (string)$row['name'],
];
}
public static function isAdmin(): bool
{
return ($_SESSION['role'] ?? '') === 'admin';
}
/** 访客:只能看被授权的信息,不能创建/编辑任何东西 */
public static function isGuest(): bool
{
return ($_SESSION['role'] ?? '') === 'guest';
}
/** 能正常使用系统的人(管理员或家庭成员)—— 判断「能否写」用这个 */
public static function canWrite(): bool
{
$r = $_SESSION['role'] ?? '';
return $r === 'admin' || $r === 'member';
}
public static function role(): string
{
return (string)($_SESSION['role'] ?? '');
}
public static function roleWord(): string
{
return ['admin' => '管理员', 'member' => '家庭成员', 'guest' => '访客'][self::role()] ?? '成员';
}
/**
* 首页标题 —— 「以我为中心」最直接的一处落点。
* 登录后看到的不是「家书」,而是「<我的名字>的家书」。
*/
public static function homeTitle(): string
{
$name = self::name();
return $name === '' ? '家书' : $name . '的家书';
}
/** 未登录跳登录页 */
public static function requireLogin(): void
{
if (!self::check()) {
redirect('auth/login');
}
}
/** 需要「能写」的权限(访客会被挡下) */
public static function requireWrite(): void
{
self::requireLogin();
if (!self::canWrite()) {
http_response_code(403);
\App\View::render('error', [
'title' => '访客只读',
'message' => '你的身份是访客,只能查看,不能新增或修改内容。',
], 'home');
exit;
}
}
public static function requireAdmin(): void
{
self::requireLogin();
if (!self::isAdmin()) {
http_response_code(403);
\App\View::render('error', [
'title' => '没有权限',
'message' => '这一页只有家庭管理员可以操作。',
], 'home');
exit;
}
}
/**
* 手机号 + 密码登录。
* 返回 true 表示成功并已写入会话。
*/
public static function login(string $phone, string $password): bool
{
// 简单节流:5 次失败后 60 秒内不再尝试。
// 防爆破的关键不在判定多严,而在于「尝试次数有成本」。
$fail = $_SESSION['login_fail'] ?? ['n' => 0, 't' => 0];
if ($fail['n'] >= 5 && (time() - $fail['t']) < 60) {
usleep(500000);
return false;
}
$hash = Crypto::hash($phone);
$stmt = Database::get()->prepare(
'SELECT a.id AS account_id, a.password_hash, a.role, a.status,
p.id AS person_id, p.family_id, p.name, p.avatar
FROM accounts a
JOIN persons p ON p.id = a.person_id
WHERE p.phone_hash = ? LIMIT 1'
);
$stmt->execute([$hash]);
$row = $stmt->fetch();
if (!$row || $row['status'] !== 'active'
|| !password_verify($password, (string)$row['password_hash'])) {
$_SESSION['login_fail'] = ['n' => $fail['n'] + 1, 't' => time()];
usleep(300000);
return false;
}
unset($_SESSION['login_fail']);
session_regenerate_id(true);
$_SESSION['account_id'] = (int)$row['account_id'];
$_SESSION['person_id'] = (int)$row['person_id'];
$_SESSION['family_id'] = (int)$row['family_id'];
$_SESSION['name'] = $row['name'];
$_SESSION['role'] = $row['role'];
$_SESSION['avatar'] = $row['avatar'];
Database::get()->prepare('UPDATE accounts SET last_login_at = NOW() WHERE id = ?')
->execute([$row['account_id']]);
// 进门就把「这个人的默认日历本」补上(幂等)。
//
// ⚠️ 为什么必须在这里兜一道,而不是只靠 Schema::ensure():
// 全新安装时 install.php 是**先** Schema::ensure()、**后**建人 ——
// ensure() 里那段 seedDefaultsForFamily() 遍历的是"已开通账号的人",
// 那时一个人都还没有,于是**一本日历都不建**;紧接着 schema_version
// 被写成最新版,升级路径以后也不会再跑 → 这个人永远没有观影/旅行两本。
// 症状很隐蔽:日程页能用,只是"我的日历本"空着,观影也不上日历。
// 放在登录处最稳:不管人是装的、别人加的、还是老库升级来的,进来就有。
try {
\App\Calendar::seedDefaults((int)$row['family_id'], (int)$row['person_id']);
} catch (\Throwable $ex) {
// 日历本建不出来不该把人挡在门外;下次登录会再试一次
error_log('[familybook] 补建默认日历本失败(已跳过):' . $ex->getMessage()
. ' @ ' . $ex->getFile() . ':' . $ex->getLine());
}
return true;
}
public static function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
/** 头像改动后刷新会话里的缓存 */
public static function refreshAvatar(string $avatar): void
{
$_SESSION['avatar'] = $avatar;
}
}