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