feat: 원격 서버 OAuth 키 설정 지원
This commit is contained in:
parent
d74bbaf657
commit
a6c87ffbcb
@ -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<Void> start(HttpSession session) {
|
||||
String state = UUID.randomUUID().toString();
|
||||
@ -60,22 +66,32 @@ public class YoutubeOAuthController {
|
||||
|
||||
@GetMapping("/settings/status")
|
||||
@ResponseBody
|
||||
public ApiResponse<YoutubeOAuthLocalSettings.Status> settingsStatus() {
|
||||
return ApiResponse.ok(localSettings.status());
|
||||
public ApiResponse<SettingsStatus> settingsStatus() {
|
||||
YoutubeOAuthLocalSettings.Status status = localSettings.status();
|
||||
return ApiResponse.ok(new SettingsStatus(status.configured(), status.redirectUri(),
|
||||
setupToken != null && !setupToken.isBlank()));
|
||||
}
|
||||
|
||||
@PostMapping("/settings")
|
||||
@ResponseBody
|
||||
public ApiResponse<YoutubeOAuthLocalSettings.Status> 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<Void> 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) { }
|
||||
}
|
||||
|
||||
@ -11,3 +11,7 @@ YOUTUBE_OAUTH_CLIENT_SECRET: <google-oauth-web-client-secret>
|
||||
YOUTUBE_OAUTH_REDIRECT_URI: http://localhost:8088/api/youtube/oauth/callback
|
||||
# Generate once: openssl rand -base64 32
|
||||
YOUTUBE_OAUTH_ENCRYPTION_KEY: <base64-encoded-32-byte-key>
|
||||
# Required when saving OAuth credentials from the web UI on a remote server.
|
||||
YOUTUBE_SETUP_TOKEN: <long-random-admin-setup-token>
|
||||
# Point this at a persistent mounted volume in production.
|
||||
YOUTUBE_OAUTH_SETTINGS_FILE: config/youtube-oauth.properties
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -131,10 +131,11 @@
|
||||
<div class="us-dialog-head"><div><div class="us-kicker">One-time setup</div><h2 id="oauthTitle">YouTube API 키 연결</h2><div class="us-muted">Google Cloud의 웹 OAuth 클라이언트 정보를 한 번만 입력하세요.</div></div><button class="us-button" type="button" aria-label="닫기" onclick="closeOAuthSettings()">×</button></div>
|
||||
<form class="us-dialog-body" onsubmit="saveOAuthSettings(event)">
|
||||
<div class="us-help">Google Cloud Console에서 <b>YouTube Data API v3</b>를 사용 설정하고 OAuth 클라이언트 유형을 <b>웹 애플리케이션</b>으로 만드세요. 승인된 리디렉션 URI에는 아래 콜백 주소를 그대로 등록해야 합니다.</div>
|
||||
<div class="us-field"><label for="oauthSetupToken">관리자 설정 토큰</label><input id="oauthSetupToken" type="password" autocomplete="off" placeholder="서버의 YOUTUBE_SETUP_TOKEN" required></div>
|
||||
<div class="us-field"><label for="oauthClientId">Client ID</label><input id="oauthClientId" autocomplete="off" placeholder="000000000000-….apps.googleusercontent.com" required></div>
|
||||
<div class="us-field"><label for="oauthClientSecret">Client Secret</label><input id="oauthClientSecret" type="password" autocomplete="new-password" placeholder="GOCSPX-…" required></div>
|
||||
<div class="us-field"><label for="oauthRedirectUri">승인된 리디렉션 URI</label><input id="oauthRedirectUri" type="url" value="http://localhost:8088/api/youtube/oauth/callback" required></div>
|
||||
<div class="us-help">키는 브라우저에 보관하지 않으며 Git에서 제외된 서버 로컬 설정에 저장됩니다. 보안을 위해 이 화면은 서버 컴퓨터에서 접속했을 때만 저장할 수 있습니다.</div>
|
||||
<div class="us-help">키는 브라우저나 Git에 보관하지 않습니다. 서버의 <code>YOUTUBE_OAUTH_SETTINGS_FILE</code> 경로에 저장되므로 운영 환경에서는 영속 볼륨 경로를 지정하세요.</div>
|
||||
<div class="us-dialog-actions"><button class="us-button" type="button" onclick="closeOAuthSettings()">취소</button><button class="us-button us-primary" id="oauthSaveButton" type="submit">저장하고 채널 연결</button></div>
|
||||
</form>
|
||||
</div>
|
||||
@ -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(){
|
||||
|
||||
Loading…
Reference in New Issue
Block a user