package com.hlab.yanalyst.domain.upload; import com.hlab.yanalyst.global.common.ApiResponse; import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; import java.net.URI; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.List; import java.util.UUID; @Controller @RequestMapping("/api/youtube") @RequiredArgsConstructor public class YoutubeOAuthController { private static final String STATE_SESSION_KEY = "youtube.oauth.state"; private final YoutubeOAuthService service; private final YoutubeOAuthLocalSettings localSettings; @Value("${youtube.oauth.setup-token:}") private String setupToken; @GetMapping("/oauth/start") public ResponseEntity start(HttpSession session) { String state = UUID.randomUUID().toString(); session.setAttribute(STATE_SESSION_KEY, state); return ResponseEntity.status(302).location(service.authorizationUri(state)).build(); } @GetMapping("/oauth/callback") public ResponseEntity callback(@RequestParam(required = false) String code, @RequestParam(required = false) String state, @RequestParam(required = false) String error, HttpSession session) { String expected = (String) session.getAttribute(STATE_SESSION_KEY); session.removeAttribute(STATE_SESSION_KEY); if (error != null) return redirect("/upload-studio?youtube=denied"); if (expected == null || !expected.equals(state)) return redirect("/upload-studio?youtube=invalid-state"); if (code == null || code.isBlank()) return redirect("/upload-studio?youtube=missing-code"); try { service.connect(code); return redirect("/upload-studio?youtube=connected"); } catch (RuntimeException e) { return redirect("/upload-studio?youtube=failed"); } } @GetMapping("/connections") @ResponseBody public ApiResponse> connections() { return ApiResponse.ok(service.connections()); } @GetMapping("/settings/status") @ResponseBody public ApiResponse settingsStatus() { YoutubeOAuthLocalSettings.Status status = localSettings.status(); return ApiResponse.ok(new SettingsStatus(status.configured(), status.redirectUri(), setupToken != null && !setupToken.isBlank())); } @PostMapping("/settings") @ResponseBody public ApiResponse saveSettings(@RequestBody SettingsRequest body, HttpServletRequest request) { requireSetupToken(request.getHeader("X-YouTube-Setup-Token")); localSettings.save(body.clientId(), body.clientSecret(), body.redirectUri()); return ApiResponse.ok(localSettings.status()); } private void requireSetupToken(String provided) { if (setupToken == null || setupToken.isBlank()) { throw new IllegalStateException("서버에 YOUTUBE_SETUP_TOKEN을 먼저 설정해 주세요."); } byte[] expected = setupToken.getBytes(StandardCharsets.UTF_8); byte[] actual = (provided == null ? "" : provided).getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(expected, actual)) { throw new IllegalArgumentException("관리자 설정 토큰이 올바르지 않습니다."); } } @DeleteMapping("/connections/{id}") @ResponseBody public ApiResponse disconnect(@PathVariable Long id) { service.disconnect(id); return ApiResponse.ok(null); } private ResponseEntity redirect(String location) { return ResponseEntity.status(302).location(URI.create(location)).build(); } public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { } public record SettingsStatus(boolean configured, String redirectUri, boolean setupTokenConfigured) { } }