Files
TripLog/src/Controllers/SettingsController.php
T
laoyang f9a624ef4b 交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- 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 字节)
2026-09-22 23:09:37 +08:00

214 lines
8.0 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
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);
}
}