feat: 화면에서 유튜브 OAuth 연결 지원
This commit is contained in:
parent
bc416ab7bb
commit
d74bbaf657
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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<YoutubeConnection, Long> {
|
||||
Optional<YoutubeConnection> findByChannelId(String channelId);
|
||||
List<YoutubeConnection> findAllByOrderByIdAsc();
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<Void> 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<Void> 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<List<YoutubeOAuthService.ConnectionDto>> connections() {
|
||||
return ApiResponse.ok(service.connections());
|
||||
}
|
||||
|
||||
@GetMapping("/settings/status")
|
||||
@ResponseBody
|
||||
public ApiResponse<YoutubeOAuthLocalSettings.Status> settingsStatus() {
|
||||
return ApiResponse.ok(localSettings.status());
|
||||
}
|
||||
|
||||
@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 키 설정은 서버 컴퓨터에서만 변경할 수 있습니다.");
|
||||
}
|
||||
localSettings.save(body.clientId(), body.clientSecret(), body.redirectUri());
|
||||
return ApiResponse.ok(localSettings.status());
|
||||
}
|
||||
|
||||
@DeleteMapping("/connections/{id}")
|
||||
@ResponseBody
|
||||
public ApiResponse<Void> disconnect(@PathVariable Long id) {
|
||||
service.disconnect(id);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
private ResponseEntity<Void> redirect(String location) {
|
||||
return ResponseEntity.status(302).location(URI.create(location)).build();
|
||||
}
|
||||
|
||||
public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { }
|
||||
}
|
||||
@ -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) { }
|
||||
}
|
||||
@ -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<ConnectionDto> connections() {
|
||||
return repository.findAllByOrderByIdAsc().stream().map(ConnectionDto::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void disconnect(Long id) {
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
private JsonNode exchangeCode(String code) {
|
||||
MultiValueMap<String, String> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,3 +6,8 @@ DB_URL: jdbc:postgresql://<host>:5432/<db>
|
||||
DB_USERNAME: <username>
|
||||
DB_PASSWORD: <password>
|
||||
YOUTUBE_API_KEY: <youtube-data-api-key>
|
||||
YOUTUBE_OAUTH_CLIENT_ID: <google-oauth-web-client-id>
|
||||
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>
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 @@
|
||||
<div layout:fragment="content" class="us-shell">
|
||||
<header class="us-head">
|
||||
<div><div class="us-kicker">YouTube operations desk</div><h1>업로드 스튜디오</h1><p>로컬 내보내기 영상과 채널 발행 기록을 맞춰 보고, 업로드와 예약을 한 흐름에서 관리합니다.</p></div>
|
||||
<button class="us-button us-primary" type="button" onclick="syncStudio()"><i data-lucide="refresh-cw"></i> YouTube 동기화</button>
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap"><button class="us-button" type="button" onclick="connectYoutube()"><i data-lucide="link"></i> 채널 연결</button><button class="us-button us-primary" type="button" onclick="syncStudio()"><i data-lucide="refresh-cw"></i> YouTube 동기화</button></div>
|
||||
</header>
|
||||
|
||||
<section class="us-top" aria-label="채널과 폴더 현황">
|
||||
<div class="us-channel">
|
||||
<div class="us-channel-line"><div class="us-avatar">H</div><div><div class="us-channel-name">h-lab Shorts</div><div class="us-muted">@hlab-shorts · 마지막 동기화 방금 전</div></div><select class="us-select" aria-label="YouTube 채널"><option>h-lab Shorts</option><option>h-lab Politics</option></select></div>
|
||||
<div class="us-channel-line"><div class="us-avatar" id="channelAvatar">Y</div><div><div class="us-channel-name" id="channelName">YouTube 채널을 연결하세요</div><div class="us-muted" id="channelMeta">Google OAuth 연결이 필요합니다.</div></div><select class="us-select" id="channelSelect" aria-label="YouTube 채널" onchange="selectChannel(this.value)"><option value="">연결된 채널 없음</option></select></div>
|
||||
<div class="us-rhythm" aria-label="최근 7일 업로드 8개">
|
||||
<div class="us-day" style="--h:22%"><span>월</span></div><div class="us-day" style="--h:60%"><span>화</span></div><div class="us-day" style="--h:36%"><span>수</span></div><div class="us-day" style="--h:82%"><span>목</span></div><div class="us-day" style="--h:50%"><span>금</span></div><div class="us-day" style="--h:18%"><span>토</span></div><div class="us-day" style="--h:70%"><span>오늘</span></div>
|
||||
</div>
|
||||
@ -117,6 +126,19 @@
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="us-modal" id="oauthModal" role="dialog" aria-modal="true" aria-labelledby="oauthTitle" onclick="if(event.target===this)closeOAuthSettings()">
|
||||
<div class="us-dialog">
|
||||
<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="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-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>
|
||||
</div>
|
||||
<div class="us-toast" id="studioToast" role="status"></div>
|
||||
</div>
|
||||
|
||||
@ -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=>`<option value="${c.id}">${esc(c.title)}</option>`).join(''):'<option value="">연결된 채널 없음</option>';
|
||||
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();
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user