Files
FamilyBook/app/Upload.php
T
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

106 lines
3.7 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
/**
* 头像上传:裁成正方形、缩到 512×512、转成 JPEG 落盘。
*
* 安全要点:
* - 用 finfo 判断真实 MIME,不信浏览器给的扩展名
* - 文件名随机,绝不使用用户提供的名字(防路径穿越与扩展名绕过)
* - 只写进 uploads/avatars/,该目录有 .htaccess 禁止执行脚本
*/
declare(strict_types=1);
namespace App;
final class Upload
{
private const MAX_BYTES = 5 * 1024 * 1024;
private const SIZE = 512;
/** @return array{ok:bool,file?:string,error?:string} */
public static function avatar(array $file): array
{
if (!isset($file['error']) || is_array($file['error'])) {
return ['ok' => false, 'error' => '上传参数不对'];
}
switch ($file['error']) {
case UPLOAD_ERR_OK:
break;
case UPLOAD_ERR_NO_FILE:
return ['ok' => false, 'error' => ''];
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return ['ok' => false, 'error' => '图片太大了(上限 5MB)'];
default:
return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')'];
}
if ($file['size'] > self::MAX_BYTES) {
return ['ok' => false, 'error' => '图片太大了(上限 5MB)'];
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allow = ['image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif', 'image/webp' => 'imagecreatefromwebp'];
if (!isset($allow[$mime])) {
return ['ok' => false, 'error' => '只支持 JPG / PNG / GIF / WebP 图片'];
}
$src = $allow[$mime]($file['tmp_name']);
if (!$src) {
return ['ok' => false, 'error' => '图片读不出来'];
}
$w = imagesx($src);
$h = imagesy($src);
// 居中裁成正方形
$side = min($w, $h);
$sx = (int)(($w - $side) / 2);
$sy = (int)(($h - $side) / 2);
$canvas = imagecreatetruecolor(self::SIZE, self::SIZE);
// PNG/GIF 的透明区转成纸白,不然头像会是黑底
$bg = imagecolorallocate($canvas, 253, 251, 245);
imagefilledrectangle($canvas, 0, 0, self::SIZE, self::SIZE, $bg);
imagecopyresampled($canvas, $src, 0, 0, $sx, $sy, self::SIZE, self::SIZE, $side, $side);
$dir = FB_UPLOADS . '/avatars';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$name = bin2hex(random_bytes(12)) . '.jpg';
if (!imagejpeg($canvas, $dir . '/' . $name, 88)) {
imagedestroy($canvas);
imagedestroy($src);
return ['ok' => false, 'error' => '保存失败'];
}
imagedestroy($canvas);
imagedestroy($src);
return ['ok' => true, 'file' => 'avatars/' . $name];
}
/** 头像访问地址(无头像时回退到内置剪影,保证永远不出现破图) */
public static function avatarUrl(?string $rel): string
{
if ($rel && is_file(FB_UPLOADS . '/' . $rel)) {
$p = FB_UPLOADS . '/' . $rel;
// 注意:filemtime() 返回 int,本文件开了 strict_types,直接喂给 md5() 会 TypeError
return 'uploads/' . $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8);
}
return asset('/assets/avatar-default.svg');
}
public static function remove(?string $rel): void
{
if (!$rel) {
return;
}
$p = FB_UPLOADS . '/' . $rel;
if (is_file($p)) {
@unlink($p);
}
}
}