Files
FamilyBook/nav_avatar.php
T

111 lines
4.1 KiB
PHP
Raw Normal View History

<?php
/**
* 家书 FamilyBook · 导航展示页的**头像**公开出口(v24 第 25 批 用户第 9 条新增)
*
* 用户原话:「导航页的展示页面上面的那个头像就变成用户自己的头像就可以了。」
*
* 但这里有两个约束撞在一起:
* ① 头像一律经 `media.php` 出去,而 `media.php` **要求登录态**(未登录 401)——
* 展示页是免登录的,拿不到;
* ② `uploads/` 整个目录 `Require all denied`(线上实测直接取 403)。
* ⇒ 于是展示页的头像永远取不到(正是用户报的「头像没显示」)。
*
* 本端点的暴露面刻意压到**最小**:
* · 只认 `?p=<短语>`,短语必须有效(`Nav::ownerByPhrase`);
* · **只返回这个主人自己的头像** —— 不接任何 id、不接任何别的键,
* 所以拿别人的短语只能看到那个人自己的头像(那本来就公开在他的展示页上);
* · 头像是**公开内容**(它天天挂在那个公开页上),不含隐私;
* · 没有头像时 404(调用方 `onerror` 换成默认剪影,不在这里回退 ——
* 那样"有没有设过头像"在服务端就分不清了)。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Nav;
use App\Person;
use App\Storage\Storage;
function nav_av_fail(int $code, string $message): void
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$phrase = trim((string)($_GET['p'] ?? ''));
if ($phrase === '') {
nav_av_fail(404, '没有这个头像');
}
$personId = Nav::ownerByPhrase($phrase);
if ($personId <= 0) {
nav_av_fail(404, '没有这个头像');
}
$p = Person::find($personId);
if (!$p) {
nav_av_fail(404, '没有这个头像');
}
$key = trim((string)($p['avatar'] ?? ''));
if ($key === '') {
nav_av_fail(404, '这个人没有头像'); // 前端 onerror 会换成默认剪影
}
// 外部绝对地址(历史数据)→ 直接跳过去,不必代理
if (preg_match('#^https?://#i', $key)) {
header('Location: ' . $key, true, 302);
exit;
}
// 键净化:与 media.php 同一条判据(拒绝 `..`、绝对路径、点文件)
$key = ltrim(str_replace('\\', '/', $key), '/');
if ($key === '' || strpos($key, "\0") !== false || strpos($key, '..') !== false
|| !preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) {
nav_av_fail(404, '没有这个头像');
}
foreach (explode('/', $key) as $seg) {
if ($seg === '' || $seg === '.' || $seg[0] === '.') {
nav_av_fail(404, '没有这个头像');
}
}
$familyId = (int)($p['family_id'] ?? 0);
$res = Storage::read($familyId, $key);
$status = (int)($res['status'] ?? 0);
$body = (string)($res['body'] ?? '');
if (empty($res['ok']) || $status < 200 || $status >= 300) {
nav_av_fail($status === 404 || $status === 0 ? 404 : 502,
$status === 404 || $status === 0 ? '没有这个头像' : '头像暂时取不到');
}
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: '';
if ($mime === '' || $mime === 'application/octet-stream'
|| strpos($mime, 'text/html') !== false || strpos($mime, 'javascript') !== false) {
$mime = 'image/jpeg'; // 头像只可能是位图;嗅探失败按图片给
}
if (strpos($mime, 'svg') !== false) {
$mime = 'image/svg+xml';
}
$etag = '"' . md5($key . '|' . strlen($body) . '|' . $mime) . '"';
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
http_response_code(304);
header('ETag: ' . $etag);
header('Cache-Control: public, max-age=86400');
exit;
}
http_response_code(200);
header('Content-Type: ' . $mime);
header('Content-Length: ' . strlen($body));
// ⚠️ 只缓存 1 天(不是 immutable):头像可以被本人随时换掉,
// 而这里的 URL 不带版本号(键就是那个短语,换头像时短语不变)。
header('Cache-Control: public, max-age=86400');
header('ETag: ' . $etag);
header('X-Content-Type-Options: nosniff');
if ($mime === 'image/svg+xml') {
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox");
}
echo $body;
exit;