From a6c87ffbcb592b60e9fcb9bbcf14bf16c8f2f4e2 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Mon, 31 Aug 2026 12:25:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9B=90=EA=B2=A9=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20OAuth=20=ED=82=A4=20=EC=84=A4=EC=A0=95=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/upload/YoutubeOAuthController.java | 29 +++++++++++++++---- .../resources/application-local.yml.example | 4 +++ src/main/resources/application.yml | 2 ++ .../resources/templates/upload-studio.html | 13 +++++---- 4 files changed, 36 insertions(+), 12 deletions(-) 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 505cee1..328d622 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java @@ -5,6 +5,7 @@ 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; @@ -16,6 +17,8 @@ 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; @@ -27,6 +30,9 @@ 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(); @@ -60,22 +66,32 @@ public class YoutubeOAuthController { @GetMapping("/settings/status") @ResponseBody - public ApiResponse settingsStatus() { - return ApiResponse.ok(localSettings.status()); + 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) { - if (!request.getRemoteAddr().equals("127.0.0.1") && !request.getRemoteAddr().equals("0:0:0:0:0:0:0:1") - && !request.getRemoteAddr().equals("::1")) { - throw new IllegalStateException("YouTube 키 설정은 서버 컴퓨터에서만 변경할 수 있습니다."); - } + 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) { @@ -88,4 +104,5 @@ public class YoutubeOAuthController { } public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { } + public record SettingsStatus(boolean configured, String redirectUri, boolean setupTokenConfigured) { } } diff --git a/src/main/resources/application-local.yml.example b/src/main/resources/application-local.yml.example index 5efb617..be61062 100644 --- a/src/main/resources/application-local.yml.example +++ b/src/main/resources/application-local.yml.example @@ -11,3 +11,7 @@ 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 d22c541..09a27e4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,6 +55,8 @@ 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 로 오버라이드. python: diff --git a/src/main/resources/templates/upload-studio.html b/src/main/resources/templates/upload-studio.html index bf27a57..fc9e98d 100644 --- a/src/main/resources/templates/upload-studio.html +++ b/src/main/resources/templates/upload-studio.html @@ -131,10 +131,11 @@
One-time setup

YouTube API 키 연결

Google Cloud의 웹 OAuth 클라이언트 정보를 한 번만 입력하세요.
Google Cloud Console에서 YouTube Data API v3를 사용 설정하고 OAuth 클라이언트 유형을 웹 애플리케이션으로 만드세요. 승인된 리디렉션 URI에는 아래 콜백 주소를 그대로 등록해야 합니다.
+
-
키는 브라우저에 보관하지 않으며 Git에서 제외된 서버 로컬 설정에 저장됩니다. 보안을 위해 이 화면은 서버 컴퓨터에서 접속했을 때만 저장할 수 있습니다.
+
키는 브라우저나 Git에 보관하지 않습니다. 서버의 YOUTUBE_OAUTH_SETTINGS_FILE 경로에 저장되므로 운영 환경에서는 영속 볼륨 경로를 지정하세요.
@@ -169,21 +170,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=[]; + let connections=[], setupTokenConfigured=false; let oauthConfigured=false; async function loadOAuthStatus(){ - 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;} + 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;} } async function connectYoutube(){await loadOAuthStatus();if(!oauthConfigured){openOAuthSettings();return;}location.href='/api/youtube/oauth/start';} - function openOAuthSettings(){document.getElementById('oauthModal').classList.add('open');setTimeout(()=>document.getElementById('oauthClientId').focus(),0);} + function openOAuthSettings(){document.getElementById('oauthModal').classList.add('open');if(!setupTokenConfigured)showToast('서버에 YOUTUBE_SETUP_TOKEN 환경변수를 먼저 설정하세요.');setTimeout(()=>document.getElementById('oauthSetupToken').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'},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','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 body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'설정 저장 실패'); - document.getElementById('oauthClientSecret').value='';oauthConfigured=true;location.href='/api/youtube/oauth/start'; + document.getElementById('oauthClientSecret').value='';document.getElementById('oauthSetupToken').value='';oauthConfigured=true;location.href='/api/youtube/oauth/start'; }catch(e){showToast(e.message||'키 설정을 저장하지 못했습니다.');button.disabled=false;button.textContent='저장하고 채널 연결';} } async function loadConnections(){