Files
TripLog/src/Controllers/SettingsController.php
T

214 lines
8.0 KiB
PHP
Raw Normal View History

<?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);
}
}