交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段 - 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口 (多人同乘一趟时座位号本来就无意义) - trip_nodes 的历史列保留但不再写入也不再渲染,编辑一次即被显式清成 NULL - 节点表单按出行方式切换号码标签(航班号 / 车次 / 班次 / 线路 / 航次) 照片 - 一个节点可传多张照片,前端支持逐张裁剪(Cropper.js,只把裁过的走 base64) - MIME 由自建的 src/Mime.php 按内容识别:getimagesize → 魔数嗅探 → 扩展名映射, 刻意不依赖 fileinfo —— 线上同时装着 PHP 7.4(没装该扩展)与 8.1 - 单张体积上限、可传张数在设置页可调 双存储后端 - 本站 uploads/ 或阿里云 OSS,在「存储设置」页配 endpoint / 桶名 / 前缀 / 内网开关; OSS 配置不完整时静默回退本地,避免管理员填错就把上传整个弄挂 - OSS 鉴权支持 ECS 实例 RAM 角色换 STS 临时凭证(临时凭证缓存在 /tmp,不落 Web 目录), 也支持直接填 AccessKey - OSS V1 签名手写实现,不引 SDK(本项目无 composer / 无 autoload) 媒体代理 - 照片与活动封面一律经 media/show、media/cover 代理输出,先校验家庭归属再吐字节 - 页面里不出现 OSS 域名,也不出现本地真实路径;桶保持私有 - 响应带 nosniff,并关闭 display_errors,免得 PHP 报错混进图片正文把图弄坏 数据库 - 新增迁移机制(src/Migrate.php + tools/migrate.php,仅 CLI 可跑) - 建 app_settings 表;attachments 增加 storage / object_key / mime_type / size_bytes / width / height / is_image 与 idx_storage - MySQL 8 不支持「加列时带 IF NOT EXISTS」,迁移一律先查 information_schema 界面 - 顶栏只留品牌与「新建活动」,其余入口收进右上角下拉菜单(details/summary,零 JS 可开合) - 新增「存储设置」页:当前生效存储、数据库结构版本、是否 ECS 运行环境三张状态卡, 外加测试连接与角色探测 - 样式表整体重写 验证 - 模板渲染检查 234 项、SQL 列名静态检查、模板到 CSS 的类覆盖检查、内联 JS 语法检查全通过 - 已部署到 trip.yangyq.net 并完成线上验证:双 PHP(8.1 / 7.4)14 条路由渲染字节数完全一致、 26 项内容断言全部通过、照片代理取件与磁盘原件逐字节一致(69/69 字节)
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Database;
|
||||
use App\Migrate;
|
||||
use App\Oss;
|
||||
use App\Settings;
|
||||
use App\Storage;
|
||||
|
||||
/**
|
||||
* 存储设置(仅管理员)。
|
||||
*
|
||||
* 支持两种后端:本站服务器(默认)/ 阿里云 OSS;
|
||||
* OSS 又支持两种鉴权:ECS 实例 RAM 角色换 STS 临时凭证(推荐,无需落库长期密钥)、
|
||||
* 或直接填 AccessKey。
|
||||
*/
|
||||
class SettingsController
|
||||
{
|
||||
private const DRIVERS = ['local', 'oss'];
|
||||
private const AUTHS = ['ecs_role', 'access_key'];
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
|
||||
$s = Settings::all();
|
||||
|
||||
// 只探测一次:元数据服务不可达时每次调用都要等超时,别在同一个请求里问两遍
|
||||
$ecsRoles = Oss::listEcsRoles();
|
||||
|
||||
view('settings/index', [
|
||||
's' => $s,
|
||||
'tableExists' => Settings::tableExists(),
|
||||
'schemaNow' => Migrate::currentVersion(Database::get()),
|
||||
'schemaTarget' => Migrate::VERSION,
|
||||
'driver' => Storage::driver(),
|
||||
'misconfigured' => Storage::ossMisconfigured(),
|
||||
'ecsRoles' => $ecsRoles,
|
||||
'isEcs' => $ecsRoles !== [],
|
||||
'pageTitle' => '存储设置',
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$driver = (string)($_POST['storage_driver'] ?? 'local');
|
||||
if (!in_array($driver, self::DRIVERS, true)) {
|
||||
$driver = 'local';
|
||||
}
|
||||
$auth = (string)($_POST['oss_auth'] ?? 'ecs_role');
|
||||
if (!in_array($auth, self::AUTHS, true)) {
|
||||
$auth = 'ecs_role';
|
||||
}
|
||||
|
||||
$pairs = [
|
||||
'storage_driver' => $driver,
|
||||
'oss_endpoint' => $this->cleanEndpoint((string)($_POST['oss_endpoint'] ?? '')),
|
||||
'oss_bucket' => $this->cleanToken((string)($_POST['oss_bucket'] ?? ''), 63),
|
||||
'oss_prefix' => $this->cleanPrefix((string)($_POST['oss_prefix'] ?? '')),
|
||||
'oss_auth' => $auth,
|
||||
'oss_role_name' => $this->cleanToken((string)($_POST['oss_role_name'] ?? ''), 64),
|
||||
'oss_ak_id' => $this->cleanToken((string)($_POST['oss_ak_id'] ?? ''), 128),
|
||||
'oss_internal' => empty($_POST['oss_internal']) ? '0' : '1',
|
||||
'photo_max_mb' => (string)max(1, min(50, (int)($_POST['photo_max_mb'] ?? 12))),
|
||||
];
|
||||
|
||||
// 密钥留空 = 不修改(避免管理员每次保存都要重新粘贴一遍)
|
||||
$newSecret = trim((string)($_POST['oss_ak_secret'] ?? ''));
|
||||
if ($newSecret !== '') {
|
||||
$pairs['oss_ak_secret'] = $newSecret;
|
||||
} elseif (!Settings::tableExists() || Settings::get('oss_ak_secret') === '') {
|
||||
$pairs['oss_ak_secret'] = '';
|
||||
}
|
||||
|
||||
if ($driver === 'oss') {
|
||||
$err = $this->validateOss($pairs, $auth);
|
||||
if ($err !== null) {
|
||||
flash('error', $err);
|
||||
redirect('settings/index');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Settings::put($pairs);
|
||||
flash('error', '设置已保存。当前生效的存储方式:' . Storage::label(Storage::driver()));
|
||||
} catch (\Throwable $e) {
|
||||
flash('error', '保存失败:' . $e->getMessage()
|
||||
. '(若提示表不存在,请先在服务器上执行 php tools/migrate.php)');
|
||||
}
|
||||
redirect('settings/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接。**用表单里当前填的值测**,所以可以先测通再保存。
|
||||
* 返回 JSON,供前端按钮就地显示结果。
|
||||
*/
|
||||
public function test(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
|
||||
http_response_code(419);
|
||||
echo json_encode(['ok' => false, 'message' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$auth = (string)($_POST['oss_auth'] ?? 'ecs_role');
|
||||
$secret = trim((string)($_POST['oss_ak_secret'] ?? ''));
|
||||
if ($secret === '') {
|
||||
// 前端没填新密钥 → 用已保存的那把
|
||||
$secret = (string)Settings::get('oss_ak_secret');
|
||||
}
|
||||
|
||||
$cfg = [
|
||||
'endpoint' => $this->cleanEndpoint((string)($_POST['oss_endpoint'] ?? '')),
|
||||
'bucket' => $this->cleanToken((string)($_POST['oss_bucket'] ?? ''), 63),
|
||||
'prefix' => $this->cleanPrefix((string)($_POST['oss_prefix'] ?? '')),
|
||||
'auth' => in_array($auth, self::AUTHS, true) ? $auth : 'ecs_role',
|
||||
'role_name' => $this->cleanToken((string)($_POST['oss_role_name'] ?? ''), 64),
|
||||
'ak_id' => $this->cleanToken((string)($_POST['oss_ak_id'] ?? ''), 128),
|
||||
'ak_secret' => $secret,
|
||||
'internal' => !empty($_POST['oss_internal']),
|
||||
];
|
||||
|
||||
if ($cfg['endpoint'] === '' || $cfg['bucket'] === '') {
|
||||
echo json_encode(['ok' => false, 'message' => '请先填写 Endpoint 和 Bucket'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = (new Oss($cfg))->testRoundTrip();
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['ok' => false, 'message' => $e->getMessage(), 'steps' => []];
|
||||
}
|
||||
|
||||
$result['endpointResolved'] = (new Oss($cfg))->endpoint();
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** 列出该 ECS 实例可用的 RAM 角色,供前端一键填入 */
|
||||
public function roles(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['roles' => Oss::listEcsRoles()], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────── 清洗与校验
|
||||
|
||||
private function validateOss(array $pairs, string $auth): ?string
|
||||
{
|
||||
if ($pairs['oss_endpoint'] === '') {
|
||||
return '请填写 OSS Endpoint(例如 oss-cn-qingdao.aliyuncs.com)';
|
||||
}
|
||||
if ($pairs['oss_bucket'] === '') {
|
||||
return '请填写 Bucket 名称';
|
||||
}
|
||||
if (strpos($pairs['oss_endpoint'], 'aliyuncs.com') === false
|
||||
&& strpos($pairs['oss_endpoint'], '.') === false) {
|
||||
return 'Endpoint 看起来不像域名,请检查';
|
||||
}
|
||||
if ($auth === 'ecs_role' && $pairs['oss_role_name'] === '') {
|
||||
return '选择 ECS 角色鉴权时,必须填写角色名(可点「检测可用角色」自动获取)';
|
||||
}
|
||||
if ($auth === 'access_key') {
|
||||
$hasId = $pairs['oss_ak_id'] !== '';
|
||||
$hasSecret = ($pairs['oss_ak_secret'] ?? '') !== '' || (string)Settings::get('oss_ak_secret') !== '';
|
||||
if (!$hasId || !$hasSecret) {
|
||||
return '选择 AccessKey 鉴权时,必须填写 AccessKeyId 与 AccessKeySecret';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** endpoint 统一成纯域名(容忍用户粘贴带 https:// 的完整地址) */
|
||||
private function cleanEndpoint(string $v): string
|
||||
{
|
||||
$v = strtolower(trim($v));
|
||||
$v = preg_replace('#^https?://#', '', $v) ?? $v;
|
||||
$v = rtrim($v, '/');
|
||||
$v = preg_replace('/[^a-z0-9.\-]/', '', $v) ?? $v;
|
||||
return $v;
|
||||
}
|
||||
|
||||
private function cleanToken(string $v, int $max): string
|
||||
{
|
||||
$v = trim($v);
|
||||
$v = preg_replace('/[^A-Za-z0-9._\-]/', '', $v) ?? $v;
|
||||
return mb_substr($v, 0, $max);
|
||||
}
|
||||
|
||||
private function cleanPrefix(string $v): string
|
||||
{
|
||||
$v = trim(str_replace('\\', '/', $v));
|
||||
$v = ltrim($v, '/');
|
||||
$v = preg_replace('#[^A-Za-z0-9._\-/]#', '', $v) ?? $v;
|
||||
$v = str_replace('..', '', $v) ?? $v;
|
||||
if ($v !== '' && substr($v, -1) !== '/') {
|
||||
$v .= '/';
|
||||
}
|
||||
return mb_substr($v, 0, 200);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user