Files
Gnuboard7/app/Http/Controllers/Api/Admin/SettingsController.php
T
HeuJung de18159986 fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다
- config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단
- custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장
- 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제)
- 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비
- sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트
- 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
2026-09-06 15:22:52 +09:00

448 lines
17 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Settings\RegenerateAppKeyRequest;
use App\Http\Requests\Settings\RestoreSettingsRequest;
use App\Http\Requests\Settings\SaveSettingsRequest;
use App\Http\Requests\Settings\TestDriverConnectionRequest;
use App\Http\Requests\Settings\TestMailRequest;
use App\Http\Requests\Settings\TestOutboundProxyRequest;
use App\Http\Requests\Settings\UpdateSettingRequest;
use App\Http\Resources\SettingsResource;
use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
use App\Services\ExtensionStaticCacheService;
use App\Services\OutboundProxyTester;
use App\Services\SettingsService;
use App\Support\EnvPriority;
use App\Support\TrustedProxyDiagnostic;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 시스템 설정 컨트롤러
*
* 관리자가 시스템 설정을 관리할 수 있는 기능을 제공합니다.
*/
class SettingsController extends AdminBaseController
{
public function __construct(
private SettingsService $settingsService,
private DriverConnectionTester $driverConnectionTester,
private DriverRegistryService $driverRegistryService,
private OutboundProxyTester $outboundProxyTester,
private ExtensionStaticCacheService $staticCacheService
) {
parent::__construct();
}
/**
* 설정 응답에 동봉할 `_meta` 를 만듭니다.
*
* 조회와 저장 두 응답이 같은 모양이어야 화면이 저장 직후에도 같은 판정을 이어갑니다 —
* 한쪽만 필드가 늘면 저장 후 잠금 표시가 조용히 사라집니다.
*
* - `limits`: 입력 한계값 (core.settings_limits)
* - `env_priority_enabled`: `.env` 우선 모드 활성 여부 (안내 배너 표시 판정)
* - `env_locked`: `.env` 로 잠긴 필드 목록 (프론트엔드 키 기준)
*
* @return array<string, mixed> 응답 메타 배열
*/
private function buildSettingsMeta(): array
{
return [
'limits' => config('core.settings_limits', []),
'env_priority_enabled' => EnvPriority::enabled(),
'env_locked' => $this->settingsService->envLockedMeta(),
];
}
/**
* 모든 시스템 설정을 조회합니다.
*
* @return JsonResponse 시스템 설정 목록을 포함한 JSON 응답
*/
public function index(): JsonResponse
{
try {
$settings = $this->settingsService->getAllSettings();
$settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$settings['_meta'] = $this->buildSettingsMeta();
return $this->success('settings.fetch_success',
(new SettingsResource($settings))->toArray(request())
);
} catch (\Exception $e) {
return $this->error('settings.fetch_failed', 500, $e->getMessage());
}
}
/**
* 여러 시스템 설정을 일괄 저장합니다.
*
* 저장 성공 시 전체 settings를 응답에 포함하여
* 프론트엔드에서 새로고침 없이 전역 상태를 업데이트할 수 있도록 합니다.
*
* @param SaveSettingsRequest $request 설정 저장 요청 데이터
* @return JsonResponse 저장 결과 JSON 응답
*/
public function store(SaveSettingsRequest $request): JsonResponse
{
try {
$result = $this->settingsService->saveSettings($request->validated());
if ($result) {
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$allSettings = $this->settingsService->getAllSettings();
$allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$allSettings['_meta'] = $this->buildSettingsMeta();
return $this->success('settings.save_success', [
'settings' => $allSettings,
]);
} else {
return $this->error('settings.save_failed');
}
} catch (ValidationException $e) {
return $this->error('settings.save_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('settings.save_error', 500, $e->getMessage());
}
}
/**
* 특정 키의 시스템 설정을 조회합니다.
*
* @param string $key 조회할 설정 키
* @return JsonResponse 설정 값을 포함한 JSON 응답
*/
public function show(string $key): JsonResponse
{
try {
$value = $this->settingsService->getSetting($key);
return $this->success('settings.fetch_success', [
'key' => $key,
'value' => $value,
]);
} catch (\Exception $e) {
return $this->error('settings.fetch_failed', 500, $e->getMessage());
}
}
/**
* 특정 키의 시스템 설정을 업데이트합니다.
*
* @param string $key 업데이트할 설정 키
* @param UpdateSettingRequest $request 설정 업데이트 요청 데이터
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function update(string $key, UpdateSettingRequest $request): JsonResponse
{
try {
$result = $this->settingsService->setSetting($key, $request->validated()['value']);
if ($result) {
return $this->success('settings.update_success');
} else {
return $this->error('settings.update_failed');
}
} catch (ValidationException $e) {
return $this->error('settings.update_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('settings.update_error', 500, $e->getMessage());
}
}
/**
* 시스템 환경 정보를 조회합니다.
*
* @return JsonResponse 시스템 정보를 포함한 JSON 응답
*/
public function systemInfo(): JsonResponse
{
try {
$systemInfo = $this->settingsService->getSystemInfo();
return $this->success('common.success', $systemInfo);
} catch (\Throwable $e) {
Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]);
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
/**
* 신뢰 프록시(리버스 프록시) 설정 진단 결과를 조회합니다 (#124).
*
* 읽기 전용이다 — 값 편집 엔드포인트는 두지 않는다. 이 값은 "앱이 프록시 없이 도달
* 가능한가" 라는 배포 구조 지식이 있어야 정할 수 있고, 웹에서 편집 가능해지면 관리자
* 계정 탈취가 곧 X-Forwarded-For 위조 경로가 된다. 편집은 `.env` 전용이다.
*
* 판정 대상은 관리자 브라우저의 **실제 요청**이므로 현재 요청을 그대로 쓴다.
* 입력을 받지 않는 읽기 전용 조회라 FormRequest 를 두지 않는다.
*
* @return JsonResponse 진단 결과 JSON 응답
*/
public function trustedProxy(): JsonResponse
{
return $this->success('common.success', TrustedProxyDiagnostic::forRequest(request()));
}
/**
* 초기 화면 정적 파일(부트스트랩 리소스 정적 게시) 상태를 조회합니다 (#651).
*
* CLI `ext-static:status` 와 **같은 판정**(`ExtensionStaticCacheService::statusReport`)을 돌려준다.
* 읽기 전용이며 입력이 없어 FormRequest 를 두지 않는다 (`trustedProxy` 와 동형).
*
* @return JsonResponse 상태 보고서 JSON 응답
*/
public function staticCacheStatus(): JsonResponse
{
return $this->success('settings.static_cache_status_loaded', $this->staticCacheService->statusReport());
}
/**
* 초기 화면 정적 파일을 지금 다시 만듭니다 (관리자 수동 복구, #651).
*
* 캐시 버전을 올리고 현재 버전을 강제 재게시한다. 게시 실패·게시가 쓰이지 않는 환경은
* 요청 처리 실패가 아니라 **진단 결과**라 HTTP 200 으로 돌려주고 성공 여부는 페이로드
* (`republished`)가 말한다 — 연결 테스트·드라이버 테스트와 같은 규약. 사이트는 어느 경우에도
* API 폴백으로 정상이다.
*
* @return JsonResponse 재게시 결과 + 상태 보고서 JSON 응답
*/
public function republishStaticCache(): JsonResponse
{
$result = $this->staticCacheService->republish();
return $this->success(
($result['republished'] ?? false) ? 'settings.static_cache_republished' : 'settings.static_cache_republish_failed',
$result
);
}
/**
* 시스템 캐시를 정리합니다.
*
* @return JsonResponse 캐시 정리 결과 JSON 응답
*/
public function clearCache(): JsonResponse
{
try {
$result = $this->settingsService->clearCache();
if ($result) {
return $this->success('settings.cache_clear_success');
} else {
return $this->error('settings.cache_clear_failed');
}
} catch (\Exception $e) {
return $this->error('settings.cache_clear_error', 500, $e->getMessage());
}
}
/**
* 시스템을 최적화합니다 (캐시 생성).
*
* @return JsonResponse 최적화 결과 JSON 응답
*/
public function optimizeSystem(): JsonResponse
{
try {
$result = $this->settingsService->optimizeSystem();
if ($result) {
return $this->success('settings.optimize_success');
} else {
return $this->error('settings.optimize_failed');
}
} catch (\Exception $e) {
return $this->error('settings.optimize_error', 500, $e->getMessage());
}
}
/**
* 데이터베이스 백업 — 아직 제공하지 않는 기능임을 알립니다.
*
* 코어에는 DB 덤프 수단이 없어 이 엔드포인트는 구현된 적이 없다.
* 종전에는 존재하지 않는 `SettingsService::backupDatabase()` 를 호출했고,
* PHP 가 던지는 `Error` 는 `catch (\Exception)` 에 걸리지 않아 그대로 500 이 되면서
* 내부 메서드 이름까지 응답에 실려 나갔다 (공개 #115 부록 B3).
*
* 기능 부재는 서버 고장이 아니므로 501(Not Implemented)로 답한다.
* 설정 파일 백업은 `POST /api/admin/settings/backup` 이 담당한다.
*
* @return JsonResponse 미제공 안내 JSON 응답 (501)
*/
public function backupDatabase(): JsonResponse
{
return $this->error('settings.database_backup_unavailable', 501);
}
/**
* 현재 앱 키를 조회합니다 (마스킹된 형태).
*
* @return JsonResponse 마스킹된 앱 키 JSON 응답
*/
public function getAppKey(): JsonResponse
{
try {
$maskedKey = $this->settingsService->maskAppKey();
return $this->success('common.success', [
'app_key' => $maskedKey,
]);
} catch (\Exception $e) {
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
/**
* 앱 키를 재생성합니다.
*
* @param RegenerateAppKeyRequest $request 앱 키 재생성 요청 데이터
* @return JsonResponse 재생성된 앱 키 JSON 응답
*/
public function regenerateAppKey(RegenerateAppKeyRequest $request): JsonResponse
{
try {
$result = $this->settingsService->regenerateAppKey($request->password);
if (! $result['success']) {
return $this->error($result['error'], 401);
}
return $this->success('settings.app_key_regenerated', [
'app_key' => $result['app_key'],
]);
} catch (\Exception $e) {
return $this->error('settings.app_key_regenerate_failed', 500, $e->getMessage());
}
}
/**
* 설정을 백업합니다.
*
* @return JsonResponse 백업 결과 JSON 응답
*/
public function backup(): JsonResponse
{
try {
$backupPath = $this->settingsService->backupSettings();
return $this->success('settings.backup_success', [
'backup_path' => $backupPath,
]);
} catch (\Exception $e) {
return $this->error('settings.backup_failed', 500, $e->getMessage());
}
}
/**
* 백업에서 설정을 복원합니다.
*
* @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로)
* @return JsonResponse 복원 결과 JSON 응답
*/
public function restore(RestoreSettingsRequest $request): JsonResponse
{
try {
$backupPath = $request->validated('backup_path');
$result = $this->settingsService->restoreSettings($backupPath);
if ($result) {
return $this->success('settings.restore_success');
}
return $this->error('settings.restore_failed');
} catch (\Exception $e) {
return $this->error('settings.restore_error', 500, $e->getMessage());
}
}
/**
* 테스트 메일을 발송합니다.
*
* @param TestMailRequest $request 테스트 메일 발송 요청 데이터
* @return JsonResponse 발송 결과 JSON 응답
*/
public function testMail(TestMailRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$toEmail = $validated['to_email'];
$mailSettings = collect($validated)->except('to_email')->filter(fn ($v) => $v !== null)->toArray();
$result = $this->settingsService->sendTestMail($toEmail, $mailSettings);
if ($result['success']) {
return $this->success($result['message'], [
'subject' => $result['subject'],
'body' => $result['body'],
]);
}
return $this->error($result['message'], 500, $result['error'] ?? null);
} catch (\Exception $e) {
return $this->error('settings.test_mail_error', 500, $e->getMessage());
}
}
/**
* 드라이버 연결을 테스트합니다.
*
* S3, Redis, Memcached, Websocket 등 외부 서비스 드라이버의
* 연결 상태를 테스트합니다.
*
* @param TestDriverConnectionRequest $request 드라이버 테스트 요청 데이터
* @return JsonResponse 테스트 결과 JSON 응답
*/
public function testDriverConnection(TestDriverConnectionRequest $request): JsonResponse
{
try {
$settings = $request->validated();
$result = $this->driverConnectionTester->testAll($settings);
if ($result['all_passed']) {
return $this->success('settings.driver_test_success', $result);
}
// 일부 테스트 실패 시에도 결과 반환 (성공 상태지만 all_passed가 false)
return $this->success('settings.driver_test_partial', $result);
} catch (\Exception $e) {
return $this->error('settings.driver_test_error', 500, $e->getMessage());
}
}
/**
* 아웃바운드 프록시 연결을 테스트합니다.
*
* 저장하기 전에 프록시가 실제로 동작하는지, 그리고 그 프록시를 거쳐 나갔을 때 상대편에
* 어떤 IP 로 보이는지 확인합니다. 출발지 IP 는 운영자가 결제사·외부 서비스에 등록해야
* 하는 값이라 결과의 핵심입니다.
*
* 검사 대상은 저장된 설정이 아니라 이번 요청이 제출한 값입니다.
*
* @param TestOutboundProxyRequest $request 검증된 요청
* @return JsonResponse 검사 결과
*/
public function testOutboundProxy(TestOutboundProxyRequest $request): JsonResponse
{
$validated = $request->validated();
$result = $this->outboundProxyTester->test(
(string) $validated['outbound_proxy'],
(array) ($validated['outbound_proxy_bypass'] ?? [])
);
// 연결 실패는 요청 처리 실패가 아니라 진단 결과다 — 200 으로 결과를 돌려주고
// 성공 여부는 페이로드가 말한다 (드라이버 연결 테스트와 같은 규약).
return $this->success($result['message_key'], $result);
}
}