diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java index 328d622..30318c2 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java @@ -2,10 +2,8 @@ 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; @@ -17,8 +15,6 @@ 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; @@ -30,9 +26,6 @@ public class YoutubeOAuthController { 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(); @@ -68,30 +61,19 @@ public class YoutubeOAuthController { @ResponseBody public ApiResponse settingsStatus() { YoutubeOAuthLocalSettings.Status status = localSettings.status(); - return ApiResponse.ok(new SettingsStatus(status.configured(), status.redirectUri(), - setupToken != null && !setupToken.isBlank())); + return ApiResponse.ok(new SettingsStatus(status.configured(), status.redirectUri())); } @PostMapping("/settings") @ResponseBody - public ApiResponse saveSettings(@RequestBody SettingsRequest body, - HttpServletRequest request) { - requireSetupToken(request.getHeader("X-YouTube-Setup-Token")); + public ApiResponse saveSettings(@RequestBody SettingsRequest body) { + if (localSettings.status().configured()) { + throw new IllegalArgumentException("YouTube OAuth 설정이 이미 완료되었습니다."); + } 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) { @@ -104,5 +86,5 @@ public class YoutubeOAuthController { } public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { } - public record SettingsStatus(boolean configured, String redirectUri, boolean setupTokenConfigured) { } + public record SettingsStatus(boolean configured, String redirectUri) { } } diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthLocalSettings.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthLocalSettings.java index ea28313..cf4768e 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthLocalSettings.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthLocalSettings.java @@ -42,6 +42,9 @@ public class YoutubeOAuthLocalSettings { } public synchronized void save(String clientId, String clientSecret, String redirectUri) { + if (Files.exists(path)) { + throw new IllegalArgumentException("YouTube OAuth 설정이 이미 완료되었습니다."); + } if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) { throw new IllegalArgumentException("Client ID와 Client Secret을 모두 입력해 주세요."); } diff --git a/src/main/resources/application-local.yml.example b/src/main/resources/application-local.yml.example index be61062..46f80b9 100644 --- a/src/main/resources/application-local.yml.example +++ b/src/main/resources/application-local.yml.example @@ -11,7 +11,5 @@ YOUTUBE_OAUTH_CLIENT_SECRET: YOUTUBE_OAUTH_REDIRECT_URI: http://localhost:8088/api/youtube/oauth/callback # Generate once: openssl rand -base64 32 YOUTUBE_OAUTH_ENCRYPTION_KEY: -# Required when saving OAuth credentials from the web UI on a remote server. -YOUTUBE_SETUP_TOKEN: # Point this at a persistent mounted volume in production. YOUTUBE_OAUTH_SETTINGS_FILE: config/youtube-oauth.properties diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 09a27e4..fdf81cb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,7 +55,6 @@ youtube: client-secret: ${YOUTUBE_OAUTH_CLIENT_SECRET:} redirect-uri: ${YOUTUBE_OAUTH_REDIRECT_URI:http://localhost:8088/api/youtube/oauth/callback} encryption-key: ${YOUTUBE_OAUTH_ENCRYPTION_KEY:} - setup-token: ${YOUTUBE_SETUP_TOKEN:} settings-file: ${YOUTUBE_OAUTH_SETTINGS_FILE:config/youtube-oauth.properties} # Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드. diff --git a/src/main/resources/templates/upload-studio.html b/src/main/resources/templates/upload-studio.html index fc9e98d..4b3d730 100644 --- a/src/main/resources/templates/upload-studio.html +++ b/src/main/resources/templates/upload-studio.html @@ -131,11 +131,10 @@
One-time setup

YouTube API 키 연결

Google Cloud의 웹 OAuth 클라이언트 정보를 한 번만 입력하세요.
Google Cloud Console에서 YouTube Data API v3를 사용 설정하고 OAuth 클라이언트 유형을 웹 애플리케이션으로 만드세요. 승인된 리디렉션 URI에는 아래 콜백 주소를 그대로 등록해야 합니다.
-
-
키는 브라우저나 Git에 보관하지 않습니다. 서버의 YOUTUBE_OAUTH_SETTINGS_FILE 경로에 저장되므로 운영 환경에서는 영속 볼륨 경로를 지정하세요.
+
키는 브라우저나 Git에 보관하지 않습니다. 최초 설정 후에는 화면/API에서 값을 읽거나 덮어쓸 수 없으며, 저장 직후 Google 로그인으로 이동합니다.
@@ -170,21 +169,21 @@ document.getElementById('folderFallback').click(); } document.getElementById('folderFallback').addEventListener('change',e=>{const files=[...e.target.files].filter(f=>f.type.startsWith('video/'));videos=files.map((f,i)=>({id:Date.now()+i,name:f.name,meta:(f.size/1048576).toFixed(1)+' MB · 로컬 영상',status:'wait',label:'업로드 대기'}));document.getElementById('folderTitle').textContent=e.target.files[0]?.webkitRelativePath.split('/')[0]||'선택한 폴더';document.getElementById('folderPath').textContent='권한 연결됨 · '+videos.length+'개 영상 발견';renderVideos();}); - let connections=[], setupTokenConfigured=false; + let connections=[]; let oauthConfigured=false; async function loadOAuthStatus(){ - try{const response=await fetch('/api/youtube/settings/status');const body=await response.json();oauthConfigured=!!body.data.configured;setupTokenConfigured=!!body.data.setupTokenConfigured;if(body.data.redirectUri)document.getElementById('oauthRedirectUri').value=body.data.redirectUri;}catch(e){oauthConfigured=false;setupTokenConfigured=false;} + try{const response=await fetch('/api/youtube/settings/status');const body=await response.json();oauthConfigured=!!body.data.configured;if(body.data.redirectUri)document.getElementById('oauthRedirectUri').value=body.data.redirectUri;}catch(e){oauthConfigured=false;} } async function connectYoutube(){await loadOAuthStatus();if(!oauthConfigured){openOAuthSettings();return;}location.href='/api/youtube/oauth/start';} - function openOAuthSettings(){document.getElementById('oauthModal').classList.add('open');if(!setupTokenConfigured)showToast('서버에 YOUTUBE_SETUP_TOKEN 환경변수를 먼저 설정하세요.');setTimeout(()=>document.getElementById('oauthSetupToken').focus(),0);} + function openOAuthSettings(){document.getElementById('oauthModal').classList.add('open');setTimeout(()=>document.getElementById('oauthClientId').focus(),0);} function closeOAuthSettings(){document.getElementById('oauthModal').classList.remove('open');} async function saveOAuthSettings(event){ event.preventDefault(); const button=document.getElementById('oauthSaveButton');button.disabled=true;button.textContent='안전하게 저장 중…'; try{ - const response=await fetch('/api/youtube/settings',{method:'POST',headers:{'Content-Type':'application/json','X-YouTube-Setup-Token':document.getElementById('oauthSetupToken').value},body:JSON.stringify({clientId:document.getElementById('oauthClientId').value.trim(),clientSecret:document.getElementById('oauthClientSecret').value.trim(),redirectUri:document.getElementById('oauthRedirectUri').value.trim()})}); + const response=await fetch('/api/youtube/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientId:document.getElementById('oauthClientId').value.trim(),clientSecret:document.getElementById('oauthClientSecret').value.trim(),redirectUri:document.getElementById('oauthRedirectUri').value.trim()})}); const body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'설정 저장 실패'); - document.getElementById('oauthClientSecret').value='';document.getElementById('oauthSetupToken').value='';oauthConfigured=true;location.href='/api/youtube/oauth/start'; + document.getElementById('oauthClientSecret').value='';oauthConfigured=true;location.href='/api/youtube/oauth/start'; }catch(e){showToast(e.message||'키 설정을 저장하지 못했습니다.');button.disabled=false;button.textContent='저장하고 채널 연결';} } async function loadConnections(){