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