Files
laoyang 8eb19267bc 阶段 0 完整版:名册 + 账号 + 安装向导 + 设置(宣纸主题)
这一版把「家」的地基铺完:

功能
- 安装向导 install.php:建四张表 → 建家庭 → 建第一人(兼管理员),装完自删;
  「是否已装」以数据库四张表为准,不以文件是否存在为准
- 家庭名册:成员增删改查、头像上传(居中裁方 512×512 转 JPEG、finfo 校验、
  随机文件名)、生日自动算年龄与倒数天数
- 敏感信息加密存储:手机号双写(SHA-256 哈希用于登录 + AES-256-CBC 密文用于显示)、
  身份证只存密文;页面上永远只显示脱敏后的 138****8000 / 前 6 后 4
- 账号体系:人与账号分离(孩子可入名册但无需密码)、开通/停用/角色、
  防爆破(5 次失败锁 60 秒)、修改密码、最后一个管理员不可移除
- 设置页:家庭名称与座右铭、加密密钥状态
- 宣纸主题 UI:砖红取自图标,扁平、留白充足;移动端顶栏收紧不换行

安全
- 所有 POST 走 CSRF;输出全量 e() 转义;uploads/ 禁执行脚本
- app/ 与 templates/ 加 .htaccess 整体拒绝直连(直接请求模板会 Fatal 并
  把服务器绝对路径打在页面上 —— 这是线上真实抓到过的问题)
- 已安装的 install.php 返回 404;display_errors 线上关闭

工程
- 无框架无 Composer,query-string 路由(index.php?r=模块/动作),不依赖 rewrite
- 静态资源带 mtime+size 版本号,改版后浏览器不会吃旧缓存
- 配套部署与冒烟(_tooling/,不入库):部署脚本容忍 tar 对非属主目录的
  元数据回填失败;冒烟断言「状态码 + 正文无 PHP 错误 + </html> 收尾 +
  无绝对路径泄露 + 断言总数下限」—— 只看状态码会把「200 但正文中间
  Fatal」当成通过,这一版在线上真实抓到过一次
2026-09-30 00:49:53 +08:00

101 lines
3.2 KiB
PHP
Raw Permalink 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
/**
* 敏感字段加密。
*
* 身份证号、手机号这类个人信息,一律加密后再进库:
* - 加密用 AES-256-CBC,密钥 = sha256(app_key)(app_key 是 32 位十六进制,
* raw bytes 只有 16 字节,不够 256 位,所以必须再过一次 sha256)
* - 手机号另存一份 sha256 明文哈希,专门用于「登录时按手机号查找」
*
* ⚠️ app_key 换掉,已有数据就再解开不了 —— 见 README 的备份说明。
*/
declare(strict_types=1);
namespace App;
final class Crypto
{
private const CIPHER = 'aes-256-cbc';
private static function key(): string
{
$raw = $GLOBALS['config']['app_key'] ?? '';
if ($raw === '' || strpos($raw, '请填写') === 0) {
throw new \RuntimeException('config.php 里的 app_key 还没填写');
}
return hash('sha256', $raw, true);
}
public static function encrypt(string $plain): string
{
if ($plain === '') {
return '';
}
$iv = random_bytes(openssl_cipher_iv_length(self::CIPHER));
$raw = openssl_encrypt($plain, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv);
if ($raw === false) {
throw new \RuntimeException('加密失败');
}
return base64_encode($iv . $raw);
}
/** 解密失败返回空串,不抛异常 —— 展示层宁可空白,也不让整页崩掉 */
public static function decrypt(string $blob): string
{
if ($blob === '') {
return '';
}
$data = base64_decode($blob, true);
if ($data === false) {
return '';
}
$len = openssl_cipher_iv_length(self::CIPHER);
if (strlen($data) <= $len) {
return '';
}
$iv = substr($data, 0, $len);
$raw = substr($data, $len);
$out = openssl_decrypt($raw, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv);
return $out === false ? '' : $out;
}
/** 手机号 / 登录名查找用的哈希(确定性,不需要可逆) */
public static function hash(string $plain): string
{
return hash('sha256', self::normalizePhone($plain));
}
/** 去掉号码里的空格、横线、加号前缀,避免"138 0000 0000"和"13800000000"存成两个人 */
public static function normalizePhone(string $phone): string
{
$p = preg_replace('/[\s\-()]/', '', trim($phone));
return preg_replace('/^\+?86/', '', $p) === '' ? '' : preg_replace('/^\+?86/', '', $p);
}
/** 掩码显示:手机保留前 3 后 4,身份证保留前 6 后 4 */
public static function maskPhone(string $phone): string
{
$p = self::decrypt($phone);
if ($p === '') {
return '—';
}
$n = strlen($p);
return $n <= 7 ? substr($p, 0, 1) . str_repeat('*', max($n - 1, 0))
: substr($p, 0, 3) . '****' . substr($p, -4);
}
public static function maskIdCard(string $blob): string
{
$c = self::decrypt($blob);
if ($c === '') {
return '—';
}
$n = strlen($c);
if ($n <= 10) {
return str_repeat('*', $n);
}
return substr($c, 0, 6) . str_repeat('*', $n - 10) . substr($c, -4);
}
}