111 lines
4.1 KiB
PHP
111 lines
4.1 KiB
PHP
<?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;
|