diff --git a/.gitignore b/.gitignore index d8fbdbc..c42c19c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ out/ # Secrets (DO NOT COMMIT) src/main/resources/credentials.json src/main/resources/application-local.yml +config/youtube-oauth.properties tokens/ *.log diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnection.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnection.java new file mode 100644 index 0000000..76105ee --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnection.java @@ -0,0 +1,72 @@ +package com.hlab.yanalyst.domain.upload; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import jakarta.persistence.EntityListeners; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "youtube_upload_connections") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class YoutubeConnection { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String channelId; + + @Column(nullable = false) + private String channelTitle; + + private String channelHandle; + + @Column(length = 2083) + private String thumbnailUrl; + + @Column(nullable = false, columnDefinition = "TEXT") + private String encryptedRefreshToken; + + @Column(nullable = false, length = 20) + private String tokenStatus = "ACTIVE"; + + private LocalDateTime lastSyncedAt; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + public YoutubeConnection(String channelId, String channelTitle, String channelHandle, + String thumbnailUrl, String encryptedRefreshToken) { + this.channelId = channelId; + update(channelTitle, channelHandle, thumbnailUrl, encryptedRefreshToken); + } + + public void update(String channelTitle, String channelHandle, String thumbnailUrl, + String encryptedRefreshToken) { + this.channelTitle = channelTitle; + this.channelHandle = channelHandle; + this.thumbnailUrl = thumbnailUrl; + if (encryptedRefreshToken != null && !encryptedRefreshToken.isBlank()) { + this.encryptedRefreshToken = encryptedRefreshToken; + } + this.tokenStatus = "ACTIVE"; + this.lastSyncedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnectionRepository.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnectionRepository.java new file mode 100644 index 0000000..b99f272 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnectionRepository.java @@ -0,0 +1,11 @@ +package com.hlab.yanalyst.domain.upload; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface YoutubeConnectionRepository extends JpaRepository { + Optional findByChannelId(String channelId); + List findAllByOrderByIdAsc(); +} diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeCredentialCipher.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeCredentialCipher.java new file mode 100644 index 0000000..01b0c57 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeCredentialCipher.java @@ -0,0 +1,56 @@ +package com.hlab.yanalyst.domain.upload; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; + +@Component +public class YoutubeCredentialCipher { + private static final int IV_LENGTH = 12; + private static final int TAG_LENGTH = 128; + private volatile String encodedKey; + private final SecureRandom secureRandom = new SecureRandom(); + + public YoutubeCredentialCipher(@Value("${youtube.oauth.encryption-key:}") String encodedKey) { + this.encodedKey = encodedKey; + } + + public void configure(String encodedKey) { + this.encodedKey = encodedKey; + } + + public String encrypt(String plaintext) { + try { + byte[] key = key(); + byte[] iv = new byte[IV_LENGTH]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_LENGTH, iv)); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] payload = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, payload, 0, iv.length); + System.arraycopy(encrypted, 0, payload, iv.length, encrypted.length); + return Base64.getEncoder().encodeToString(payload); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new IllegalStateException("YouTube OAuth 토큰을 암호화하지 못했습니다.", e); + } + } + + private byte[] key() { + if (encodedKey == null || encodedKey.isBlank()) { + throw new IllegalStateException("YOUTUBE_OAUTH_ENCRYPTION_KEY가 설정되지 않았습니다."); + } + byte[] decoded = Base64.getDecoder().decode(encodedKey); + if (decoded.length != 32) { + throw new IllegalStateException("YOUTUBE_OAUTH_ENCRYPTION_KEY는 Base64로 인코딩한 32바이트 키여야 합니다."); + } + return decoded; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java new file mode 100644 index 0000000..505cee1 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java @@ -0,0 +1,91 @@ +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.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.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; + + @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() { + return ApiResponse.ok(localSettings.status()); + } + + @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 키 설정은 서버 컴퓨터에서만 변경할 수 있습니다."); + } + localSettings.save(body.clientId(), body.clientSecret(), body.redirectUri()); + return ApiResponse.ok(localSettings.status()); + } + + @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) { } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthLocalSettings.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthLocalSettings.java new file mode 100644 index 0000000..ea28313 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthLocalSettings.java @@ -0,0 +1,93 @@ +package com.hlab.yanalyst.domain.upload; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Properties; + +@Component +public class YoutubeOAuthLocalSettings { + private final Path path; + private final YoutubeOAuthService oauthService; + private final YoutubeCredentialCipher cipher; + + public YoutubeOAuthLocalSettings( + @Value("${youtube.oauth.settings-file:config/youtube-oauth.properties}") String settingsFile, + YoutubeOAuthService oauthService, + YoutubeCredentialCipher cipher) { + this.path = Path.of(settingsFile).toAbsolutePath().normalize(); + this.oauthService = oauthService; + this.cipher = cipher; + } + + @PostConstruct + void load() { + if (!Files.isRegularFile(path)) return; + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(path)) { + properties.load(input); + apply(properties); + } catch (IOException e) { + throw new IllegalStateException("YouTube OAuth 로컬 설정을 읽지 못했습니다.", e); + } + } + + public synchronized void save(String clientId, String clientSecret, String redirectUri) { + if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) { + throw new IllegalArgumentException("Client ID와 Client Secret을 모두 입력해 주세요."); + } + if (redirectUri == null || !redirectUri.matches("https?://[^\\s]+/api/youtube/oauth/callback")) { + throw new IllegalArgumentException("올바른 OAuth 콜백 주소를 입력해 주세요."); + } + Properties properties = new Properties(); + properties.setProperty("clientId", clientId.trim()); + properties.setProperty("clientSecret", clientSecret.trim()); + properties.setProperty("redirectUri", redirectUri.trim()); + properties.setProperty("encryptionKey", existingOrNewKey()); + try { + if (path.getParent() != null) Files.createDirectories(path.getParent()); + try (OutputStream output = Files.newOutputStream(path, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + properties.store(output, "Local YouTube OAuth settings - never commit this file"); + } + apply(properties); + } catch (IOException e) { + throw new IllegalStateException("YouTube OAuth 설정을 저장하지 못했습니다.", e); + } + } + + public Status status() { + return new Status(oauthService.isConfigured(), oauthService.redirectUri()); + } + + private void apply(Properties properties) { + oauthService.configure(properties.getProperty("clientId", ""), + properties.getProperty("clientSecret", ""), properties.getProperty("redirectUri", "")); + cipher.configure(properties.getProperty("encryptionKey", "")); + } + + private String existingOrNewKey() { + if (Files.isRegularFile(path)) { + Properties existing = new Properties(); + try (InputStream input = Files.newInputStream(path)) { + existing.load(input); + String key = existing.getProperty("encryptionKey", ""); + if (!key.isBlank()) return key; + } catch (IOException ignored) { } + } + byte[] key = new byte[32]; + new SecureRandom().nextBytes(key); + return Base64.getEncoder().encodeToString(key); + } + + public record Status(boolean configured, String redirectUri) { } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java new file mode 100644 index 0000000..78f1567 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java @@ -0,0 +1,156 @@ +package com.hlab.yanalyst.domain.upload; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.List; + +@Service +public class YoutubeOAuthService { + private static final String AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + private static final String TOKEN_URL = "https://oauth2.googleapis.com/token"; + private static final String CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels"; + private static final String SCOPE = "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly"; + + @Qualifier("restTemplate") + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final YoutubeConnectionRepository repository; + private final YoutubeCredentialCipher cipher; + + @Value("${youtube.oauth.client-id:}") private volatile String clientId; + @Value("${youtube.oauth.client-secret:}") private volatile String clientSecret; + @Value("${youtube.oauth.redirect-uri:http://localhost:8088/api/youtube/oauth/callback}") private volatile String redirectUri; + + public YoutubeOAuthService(@Qualifier("restTemplate") RestTemplate restTemplate, + ObjectMapper objectMapper, + YoutubeConnectionRepository repository, + YoutubeCredentialCipher cipher) { + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + this.repository = repository; + this.cipher = cipher; + } + + public URI authorizationUri(String state) { + requireConfiguration(); + return UriComponentsBuilder.fromUriString(AUTH_URL) + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("response_type", "code") + .queryParam("scope", SCOPE) + .queryParam("access_type", "offline") + .queryParam("include_granted_scopes", "true") + .queryParam("prompt", "consent") + .queryParam("state", state) + .build().encode().toUri(); + } + + public void configure(String clientId, String clientSecret, String redirectUri) { + this.clientId = clientId; + this.clientSecret = clientSecret; + if (redirectUri != null && !redirectUri.isBlank()) this.redirectUri = redirectUri; + } + + public boolean isConfigured() { + return clientId != null && !clientId.isBlank() && clientSecret != null && !clientSecret.isBlank(); + } + + public String redirectUri() { + return redirectUri; + } + + @Transactional + public YoutubeConnection connect(String code) { + requireConfiguration(); + JsonNode token = exchangeCode(code); + String accessToken = requiredText(token, "access_token", "Google access token이 없습니다."); + String refreshToken = requiredText(token, "refresh_token", "Google refresh token이 없습니다. 권한 동의를 다시 진행해 주세요."); + JsonNode channel = loadChannel(accessToken); + String channelId = requiredText(channel, "id", "연결된 YouTube 채널 ID가 없습니다."); + String title = channel.path("snippet").path("title").asText("YouTube 채널"); + String handle = channel.path("snippet").path("customUrl").asText(""); + String thumbnail = channel.path("snippet").path("thumbnails").path("medium").path("url").asText(""); + String encryptedToken = cipher.encrypt(refreshToken); + + YoutubeConnection connection = repository.findByChannelId(channelId) + .orElseGet(() -> new YoutubeConnection(channelId, title, handle, thumbnail, encryptedToken)); + connection.update(title, handle, thumbnail, encryptedToken); + return repository.save(connection); + } + + @Transactional(readOnly = true) + public List connections() { + return repository.findAllByOrderByIdAsc().stream().map(ConnectionDto::from).toList(); + } + + @Transactional + public void disconnect(Long id) { + repository.deleteById(id); + } + + private JsonNode exchangeCode(String code) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("code", code); + form.add("client_id", clientId); + form.add("client_secret", clientSecret); + form.add("redirect_uri", redirectUri); + form.add("grant_type", "authorization_code"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String body = restTemplate.postForObject(TOKEN_URL, new HttpEntity<>(form, headers), String.class); + return readJson(body); + } + + private JsonNode loadChannel(String accessToken) { + URI uri = UriComponentsBuilder.fromUriString(CHANNELS_URL) + .queryParam("part", "snippet,contentDetails,statistics") + .queryParam("mine", "true").build().encode().toUri(); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + String body = restTemplate.exchange(uri, org.springframework.http.HttpMethod.GET, + new HttpEntity<>(headers), String.class).getBody(); + JsonNode items = readJson(body).path("items"); + if (!items.isArray() || items.isEmpty()) { + throw new IllegalStateException("이 Google 계정에서 YouTube 채널을 찾지 못했습니다."); + } + return items.get(0); + } + + private JsonNode readJson(String body) { + try { return objectMapper.readTree(body); } + catch (Exception e) { throw new IllegalStateException("Google 응답을 해석하지 못했습니다.", e); } + } + + private String requiredText(JsonNode node, String field, String message) { + String value = node.path(field).asText(""); + if (value.isBlank()) throw new IllegalStateException(message); + return value; + } + + private void requireConfiguration() { + if (!isConfigured()) { + throw new IllegalStateException("YOUTUBE_OAUTH_CLIENT_ID와 YOUTUBE_OAUTH_CLIENT_SECRET을 설정해 주세요."); + } + } + + public record ConnectionDto(Long id, String channelId, String title, String handle, + String thumbnailUrl, String tokenStatus, java.time.LocalDateTime lastSyncedAt) { + static ConnectionDto from(YoutubeConnection c) { + return new ConnectionDto(c.getId(), c.getChannelId(), c.getChannelTitle(), c.getChannelHandle(), + c.getThumbnailUrl(), c.getTokenStatus(), c.getLastSyncedAt()); + } + } +} diff --git a/src/main/resources/application-local.yml.example b/src/main/resources/application-local.yml.example index a24109b..5efb617 100644 --- a/src/main/resources/application-local.yml.example +++ b/src/main/resources/application-local.yml.example @@ -6,3 +6,8 @@ DB_URL: jdbc:postgresql://:5432/ DB_USERNAME: DB_PASSWORD: YOUTUBE_API_KEY: +YOUTUBE_OAUTH_CLIENT_ID: +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: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f4d9c6a..d22c541 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,6 +50,11 @@ server: youtube: api: key: ${YOUTUBE_API_KEY:} + oauth: + client-id: ${YOUTUBE_OAUTH_CLIENT_ID:} + 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:} # 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 cb3ab0b..bf27a57 100644 --- a/src/main/resources/templates/upload-studio.html +++ b/src/main/resources/templates/upload-studio.html @@ -72,6 +72,15 @@ .us-submit { width:100%; min-height:46px; border:0; border-radius:var(--r-sm); font-weight:800; cursor:pointer; } .us-empty { padding:3rem 1rem; text-align:center; color:var(--text-2); } .us-toast { position:fixed; right:1.25rem; bottom:1.25rem; background:#201d35; color:white; padding:.85rem 1rem; border-radius:6px; box-shadow:var(--shadow-lg); z-index:20; display:none; } + .us-modal { position:fixed; inset:0; z-index:30; background:rgba(15,14,22,.58); display:none; place-items:center; padding:1rem; } + .us-modal.open { display:grid; } + .us-dialog { width:min(520px,100%); max-height:calc(100vh - 2rem); overflow:auto; background:var(--surface); border:1px solid var(--border-strong); box-shadow:var(--shadow-lg); } + .us-dialog-head { padding:1.1rem 1.2rem; border-bottom:1px solid var(--border); display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; } + .us-dialog-head h2 { margin:0 0 .3rem; font-size:1.3rem; } + .us-dialog-body { padding:1.2rem; display:grid; gap:1rem; } + .us-help { background:var(--studio-soft); border-left:3px solid var(--studio); padding:.75rem .85rem; color:var(--text-2); font-size:.78rem; line-height:1.55; } + .us-help code { color:var(--text); overflow-wrap:anywhere; } + .us-dialog-actions { display:flex; justify-content:flex-end; gap:.5rem; } @media(max-width:1100px){ .us-work{grid-template-columns:1fr}.us-panel{position:static}.us-card{grid-template-columns:28px 100px minmax(150px,1fr) 110px}.us-time{display:none} } @media(max-width:760px){ .us-head{align-items:flex-start;flex-direction:column}.us-top{grid-template-columns:1fr}.us-folder{border-left:0;border-top:1px solid var(--border)}.us-summary{grid-template-columns:1fr 1fr}.us-stat:nth-child(2){border-right:0}.us-stat:nth-child(-n+2){border-bottom:1px solid var(--border)}.us-card{grid-template-columns:24px 84px minmax(120px,1fr)}.us-badge{grid-column:3}.us-head .us-button{width:100%} } @media(prefers-reduced-motion:reduce){ *{scroll-behavior:auto!important;transition:none!important} } @@ -81,12 +90,12 @@
YouTube operations desk

업로드 스튜디오

로컬 내보내기 영상과 채널 발행 기록을 맞춰 보고, 업로드와 예약을 한 흐름에서 관리합니다.

- +
-
H
h-lab Shorts
@hlab-shorts · 마지막 동기화 방금 전
+
Y
YouTube 채널을 연결하세요
Google OAuth 연결이 필요합니다.
월
화
수
목
금
토
오늘
@@ -117,6 +126,19 @@
+
@@ -147,10 +169,48 @@ 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();}); - function syncStudio(){showToast('YouTube 채널 기록을 동기화했습니다.');} + 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;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');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'},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'; + }catch(e){showToast(e.message||'키 설정을 저장하지 못했습니다.');button.disabled=false;button.textContent='저장하고 채널 연결';} + } + async function loadConnections(){ + try{ + const response=await fetch('/api/youtube/connections'); + const body=await response.json(); + if(!body.success) throw new Error(body.message); + connections=body.data||[]; + const select=document.getElementById('channelSelect'); + select.innerHTML=connections.length?connections.map(c=>``).join(''):''; + selectChannel(select.value); + }catch(e){showToast('채널 정보를 불러오지 못했습니다.');} + } + function selectChannel(id){ + const c=connections.find(x=>String(x.id)===String(id)); + document.getElementById('channelName').textContent=c?c.title:'YouTube 채널을 연결하세요'; + document.getElementById('channelMeta').textContent=c?(c.handle||c.channelId)+' · 토큰 '+(c.tokenStatus==='ACTIVE'?'정상':'확인 필요'):'Google OAuth 연결이 필요합니다.'; + document.getElementById('channelAvatar').textContent=c?c.title.slice(0,1).toUpperCase():'Y'; + } + function syncStudio(){if(!connections.length){connectYoutube();return;}loadConnections().then(()=>showToast('YouTube 채널 정보를 동기화했습니다.'));} function submitUpload(e){e.preventDefault();if(!selectedId){showToast('먼저 영상을 선택해 주세요.');return;}if(uploadMode==='schedule'&&!document.getElementById('scheduleAt').value){showToast('공개 예정 시간을 선택해 주세요.');return;}const v=videos.find(x=>x.id===selectedId);v.status=uploadMode==='schedule'?'done':'done';v.label=uploadMode==='schedule'?'예약됨':'업로드 중';renderVideos();showToast(uploadMode==='schedule'?'예약 업로드를 등록했습니다.':'업로드를 시작했습니다.');} function showToast(message){const t=document.getElementById('studioToast');t.textContent=message;t.style.display='block';clearTimeout(t.timer);t.timer=setTimeout(()=>t.style.display='none',2400);} + const oauthResult=new URLSearchParams(location.search).get('youtube'); + if(oauthResult){const messages={connected:'YouTube 채널을 연결했습니다.',denied:'YouTube 권한 요청이 취소되었습니다.','invalid-state':'안전하지 않은 OAuth 응답을 차단했습니다.','missing-code':'Google 인증 코드가 없습니다.',failed:'YouTube 채널 연결에 실패했습니다.'};setTimeout(()=>showToast(messages[oauthResult]||'YouTube 연결을 확인해 주세요.'),100);history.replaceState(null,'',location.pathname);} renderVideos(); + loadOAuthStatus(); + loadConnections();