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 条线上探针全过。
This commit is contained in:
+103
@@ -38,6 +38,109 @@ final class Auth
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user