revert: remove Google Drive storage backend

Remove all GDrive/Google Drive storage extension code:
- Delete 7 GDrive-specific files (handler, service, tests, router, frontend)
- Remove GDrive fields from setting service and DTOs
- Remove GDrive routes, wire dependencies, i18n entries
- Revert DataManagementView to S3-only storage management
This commit is contained in:
erio
2026-04-03 18:38:16 +08:00
parent fc5e96aa9f
commit 950ac48f9f
20 changed files with 101 additions and 1842 deletions
+7 -11
View File
@@ -185,15 +185,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink)
soraS3Storage := service.NewSoraS3Storage(settingService)
soraGDriveStorage := service.NewSoraGDriveStorage(settingService)
soraStorageRouter := service.NewSoraStorageRouter(settingService, soraS3Storage, soraGDriveStorage)
settingService.SetOnS3UpdateCallback(soraStorageRouter.RefreshAll)
settingService.SetOnS3UpdateCallback(soraS3Storage.RefreshClient)
soraGenerationRepository := repository.NewSoraGenerationRepository(db)
soraQuotaService := service.NewSoraQuotaService(userRepository, groupRepository, settingService)
soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraStorageRouter, soraQuotaService)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage, soraGDriveStorage, soraGenerationService)
soraGDriveOAuthService := service.NewSoraGDriveOAuthService(settingService)
gdriveOAuthHandler := admin.NewGDriveOAuthHandler(settingService, soraGDriveOAuthService, soraGDriveStorage)
soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraS3Storage, soraQuotaService)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage)
opsHandler := admin.NewOpsHandler(opsService)
updateCache := repository.NewUpdateCache(redisClient)
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
@@ -221,7 +217,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
channelHandler := admin.NewChannelHandler(channelService, billingService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, gdriveOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
@@ -230,12 +226,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
soraSDKClient := service.ProvideSoraSDKClient(configConfig, httpUpstream, openAITokenProvider, accountRepository, soraAccountRepository)
soraMediaStorage := service.ProvideSoraMediaStorage(configConfig)
soraGatewayService := service.NewSoraGatewayService(soraSDKClient, rateLimitService, httpUpstream, configConfig)
soraClientHandler := handler.NewSoraClientHandler(soraGenerationService, soraQuotaService, soraStorageRouter, soraGatewayService, gatewayService, soraMediaStorage, apiKeyService)
soraClientHandler := handler.NewSoraClientHandler(soraGenerationService, soraQuotaService, soraS3Storage, soraGatewayService, gatewayService, soraMediaStorage, apiKeyService)
soraTaskRepository := repository.NewSoraTaskRepository(db)
soraTaskService := service.NewSoraTaskService(soraTaskRepository, accountRepository, soraSDKClient, httpUpstream)
soraTaskWorker := service.NewSoraTaskWorker(soraTaskService, accountRepository, soraStorageRouter, soraMediaStorage, 60*time.Second)
soraTaskWorker := service.NewSoraTaskWorker(soraTaskService, accountRepository, soraS3Storage, soraMediaStorage, 60*time.Second)
soraTaskWorker.Start()
soraVideosHandler := handler.NewSoraVideosHandler(soraTaskService, gatewayService, soraStorageRouter, soraMediaStorage, soraGatewayService)
soraVideosHandler := handler.NewSoraVideosHandler(soraTaskService, gatewayService, soraS3Storage, soraMediaStorage, soraGatewayService)
soraGatewayHandler := handler.NewSoraGatewayHandler(gatewayService, soraGatewayService, concurrencyService, billingCacheService, usageRecordWorkerPool, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
totpHandler := handler.NewTotpHandler(totpService)
@@ -1,148 +0,0 @@
package admin
import (
"log/slog"
"net/http"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// GDriveOAuthHandler 处理 Google Drive OAuth 授权流程。
type GDriveOAuthHandler struct {
settingService *service.SettingService
gdriveOAuth *service.SoraGDriveOAuthService
gdriveStorage *service.SoraGDriveStorage
}
// NewGDriveOAuthHandler 创建 GDrive OAuth Handler。
func NewGDriveOAuthHandler(settingService *service.SettingService, gdriveOAuth *service.SoraGDriveOAuthService, gdriveStorage *service.SoraGDriveStorage) *GDriveOAuthHandler {
return &GDriveOAuthHandler{
settingService: settingService,
gdriveOAuth: gdriveOAuth,
gdriveStorage: gdriveStorage,
}
}
// StartOAuthRequest 启动 OAuth 授权请求。
type StartOAuthRequest struct {
ClientID string `json:"client_id" binding:"required"`
ClientSecret string `json:"client_secret" binding:"required"`
RedirectURI string `json:"redirect_uri" binding:"required"`
}
// StartOAuth 生成 Google OAuth 授权 URL。
// POST /api/v1/admin/settings/sora-storage/gdrive-oauth/start
func (h *GDriveOAuthHandler) StartOAuth(c *gin.Context) {
if h.gdriveOAuth == nil {
response.Error(c, http.StatusInternalServerError, "GDrive OAuth service not initialized")
return
}
var req StartOAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
authURL, state, err := h.gdriveOAuth.GenerateAuthURL(req.ClientID, req.ClientSecret, req.RedirectURI)
if err != nil {
response.Error(c, http.StatusInternalServerError, "生成授权 URL 失败: "+err.Error())
return
}
response.Success(c, gin.H{
"auth_url": authURL,
"state": state,
})
}
// OAuthCallbackRequest OAuth 回调请求。
type OAuthCallbackRequest struct {
ClientID string `json:"client_id" binding:"required"`
ClientSecret string `json:"client_secret" binding:"required"`
RedirectURI string `json:"redirect_uri" binding:"required"`
Code string `json:"code" binding:"required"`
ProfileID string `json:"profile_id"` // 要保存到的 profile ID(可选)
}
// OAuthCallback 用授权码换取 refresh_token 并保存到 profile。
// POST /api/v1/admin/settings/sora-storage/gdrive-oauth/callback
func (h *GDriveOAuthHandler) OAuthCallback(c *gin.Context) {
if h.gdriveOAuth == nil {
response.Error(c, http.StatusInternalServerError, "GDrive OAuth service not initialized")
return
}
var req OAuthCallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
refreshToken, err := h.gdriveOAuth.ExchangeCode(c.Request.Context(), req.ClientID, req.ClientSecret, req.RedirectURI, req.Code)
if err != nil {
slog.Error("[GDriveOAuth] exchange failed",
"client_id_len", len(req.ClientID),
"client_secret_len", len(req.ClientSecret),
"redirect_uri", req.RedirectURI,
"code_len", len(req.Code),
"error", err,
)
response.Error(c, http.StatusBadRequest, "换取 refresh_token 失败: "+err.Error())
return
}
// 如果指定了 profile_id,自动保存 refresh_token 到 profile
if req.ProfileID != "" {
profiles, err := h.settingService.ListSoraS3Profiles(c.Request.Context())
if err == nil {
for _, p := range profiles.Items {
if p.ProfileID == req.ProfileID {
_, _ = h.settingService.UpdateSoraS3Profile(c.Request.Context(), req.ProfileID, &service.SoraS3Profile{
Name: p.Name,
Provider: p.Provider,
AccessMode: p.AccessMode,
Enabled: p.Enabled,
Endpoint: p.Endpoint,
Region: p.Region,
Bucket: p.Bucket,
AccessKeyID: p.AccessKeyID,
Prefix: p.Prefix,
ForcePathStyle: p.ForcePathStyle,
CDNURL: p.CDNURL,
DefaultStorageQuotaBytes: p.DefaultStorageQuotaBytes,
AuthType: p.AuthType,
ClientID: p.ClientID,
FolderID: p.FolderID,
RefreshToken: refreshToken,
})
break
}
}
}
}
response.Success(c, gin.H{
"refresh_token": refreshToken,
"message": "OAuth 授权成功",
})
}
// TestGDriveStorage 测试 GDrive 存储的完整上传→下载→删除流程。
// POST /api/v1/admin/settings/sora-storage/gdrive-test
func (h *GDriveOAuthHandler) TestGDriveStorage(c *gin.Context) {
if h.gdriveStorage == nil {
response.Error(c, http.StatusInternalServerError, "GDrive storage not initialized")
return
}
result, err := h.gdriveStorage.TestFullCycle(c.Request.Context())
if err != nil {
response.Error(c, http.StatusBadRequest, "GDrive 测试失败: "+err.Error())
return
}
response.Success(c, result)
}
+19 -124
View File
@@ -37,25 +37,21 @@ func generateMenuItemID() (string, error) {
// SettingHandler 系统设置处理器
type SettingHandler struct {
settingService *service.SettingService
emailService *service.EmailService
turnstileService *service.TurnstileService
opsService *service.OpsService
soraS3Storage *service.SoraS3Storage
soraGDriveStorage *service.SoraGDriveStorage
soraGenerationService *service.SoraGenerationService
settingService *service.SettingService
emailService *service.EmailService
turnstileService *service.TurnstileService
opsService *service.OpsService
soraS3Storage *service.SoraS3Storage
}
// NewSettingHandler 创建系统设置处理器
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage, soraGDriveStorage *service.SoraGDriveStorage, soraGenerationService *service.SoraGenerationService) *SettingHandler {
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage) *SettingHandler {
return &SettingHandler{
settingService: settingService,
emailService: emailService,
turnstileService: turnstileService,
opsService: opsService,
soraS3Storage: soraS3Storage,
soraGDriveStorage: soraGDriveStorage,
soraGenerationService: soraGenerationService,
settingService: settingService,
emailService: emailService,
turnstileService: turnstileService,
opsService: opsService,
soraS3Storage: soraS3Storage,
}
}
@@ -1234,8 +1230,6 @@ func toSoraS3ProfileDTO(profile service.SoraS3Profile) dto.SoraS3Profile {
ProfileID: profile.ProfileID,
Name: profile.Name,
IsActive: profile.IsActive,
Provider: profile.GetProvider(),
AccessMode: profile.AccessMode,
Enabled: profile.Enabled,
Endpoint: profile.Endpoint,
Region: profile.Region,
@@ -1247,13 +1241,6 @@ func toSoraS3ProfileDTO(profile service.SoraS3Profile) dto.SoraS3Profile {
CDNURL: profile.CDNURL,
DefaultStorageQuotaBytes: profile.DefaultStorageQuotaBytes,
UpdatedAt: profile.UpdatedAt,
// Google Drive 专属
AuthType: profile.AuthType,
ClientID: profile.ClientID,
ClientSecretConfigured: profile.ClientSecretConfigured,
RefreshTokenConfigured: profile.RefreshTokenConfigured,
ServiceAccountConfigured: profile.ServiceAccountConfigured,
FolderID: profile.FolderID,
}
}
@@ -1333,8 +1320,6 @@ type CreateSoraS3ProfileRequest struct {
ProfileID string `json:"profile_id"`
Name string `json:"name"`
SetActive bool `json:"set_active"`
Provider string `json:"provider"` // "s3" / "gdrive"
AccessMode string `json:"access_mode"` // "direct" / "proxy"
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
@@ -1345,19 +1330,10 @@ type CreateSoraS3ProfileRequest struct {
ForcePathStyle bool `json:"force_path_style"`
CDNURL string `json:"cdn_url"`
DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"`
// Google Drive 专属
AuthType string `json:"auth_type,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ServiceAccountJSON string `json:"service_account_json,omitempty"`
FolderID string `json:"folder_id,omitempty"`
}
type UpdateSoraS3ProfileRequest struct {
Name string `json:"name"`
Provider string `json:"provider"`
AccessMode string `json:"access_mode"`
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
@@ -1368,13 +1344,6 @@ type UpdateSoraS3ProfileRequest struct {
ForcePathStyle bool `json:"force_path_style"`
CDNURL string `json:"cdn_url"`
DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"`
// Google Drive 专属
AuthType string `json:"auth_type,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ServiceAccountJSON string `json:"service_account_json,omitempty"`
FolderID string `json:"folder_id,omitempty"`
}
// CreateSoraS3Profile 创建 Sora S3 配置
@@ -1397,23 +1366,15 @@ func (h *SettingHandler) CreateSoraS3Profile(c *gin.Context) {
response.BadRequest(c, "Profile ID is required")
return
}
// S3 专属字段验证:仅当 provider 为 s3(或未指定)时校验
provider := req.Provider
if provider == "" {
provider = "s3"
}
if provider == "s3" {
if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, false); err != nil {
response.BadRequest(c, err.Error())
return
}
// S3 字段验证
if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, false); err != nil {
response.BadRequest(c, err.Error())
return
}
created, err := h.settingService.CreateSoraS3Profile(c.Request.Context(), &service.SoraS3Profile{
ProfileID: req.ProfileID,
Name: req.Name,
Provider: req.Provider,
AccessMode: req.AccessMode,
Enabled: req.Enabled,
Endpoint: req.Endpoint,
Region: req.Region,
@@ -1424,13 +1385,6 @@ func (h *SettingHandler) CreateSoraS3Profile(c *gin.Context) {
ForcePathStyle: req.ForcePathStyle,
CDNURL: req.CDNURL,
DefaultStorageQuotaBytes: req.DefaultStorageQuotaBytes,
// Google Drive 专属
AuthType: req.AuthType,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
RefreshToken: req.RefreshToken,
ServiceAccountJSON: req.ServiceAccountJSON,
FolderID: req.FolderID,
}, req.SetActive)
if err != nil {
response.ErrorFrom(c, err)
@@ -1473,25 +1427,14 @@ func (h *SettingHandler) UpdateSoraS3Profile(c *gin.Context) {
response.ErrorFrom(c, service.ErrSoraS3ProfileNotFound)
return
}
// S3 专属字段验证
provider := req.Provider
if provider == "" && existing != nil {
provider = existing.GetProvider()
}
if provider == "" {
provider = "s3"
}
if provider == "s3" {
if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, existing.SecretAccessKeyConfigured); err != nil {
response.BadRequest(c, err.Error())
return
}
// S3 字段验证
if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, existing.SecretAccessKeyConfigured); err != nil {
response.BadRequest(c, err.Error())
return
}
updated, updateErr := h.settingService.UpdateSoraS3Profile(c.Request.Context(), profileID, &service.SoraS3Profile{
Name: req.Name,
Provider: req.Provider,
AccessMode: req.AccessMode,
Enabled: req.Enabled,
Endpoint: req.Endpoint,
Region: req.Region,
@@ -1502,13 +1445,6 @@ func (h *SettingHandler) UpdateSoraS3Profile(c *gin.Context) {
ForcePathStyle: req.ForcePathStyle,
CDNURL: req.CDNURL,
DefaultStorageQuotaBytes: req.DefaultStorageQuotaBytes,
// Google Drive 专属
AuthType: req.AuthType,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
RefreshToken: req.RefreshToken,
ServiceAccountJSON: req.ServiceAccountJSON,
FolderID: req.FolderID,
})
if updateErr != nil {
response.ErrorFrom(c, updateErr)
@@ -1845,44 +1781,3 @@ func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) {
ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes,
})
}
// GetGDriveQuota 获取 Google Drive 配额信息。
// GET /api/v1/admin/settings/sora-storage/gdrive-quota
func (h *SettingHandler) GetGDriveQuota(c *gin.Context) {
if h.soraGDriveStorage == nil {
response.Error(c, http.StatusServiceUnavailable, "GDrive storage not configured")
return
}
quota, err := h.soraGDriveStorage.GetQuotaInfo(c.Request.Context())
if err != nil {
response.Error(c, http.StatusInternalServerError, fmt.Sprintf("failed to get GDrive quota: %v", err))
return
}
response.Success(c, quota)
}
// GetStorageVideoStats 获取各存储类型的视频统计信息。
// GET /api/v1/admin/settings/sora-storage/video-stats
func (h *SettingHandler) GetStorageVideoStats(c *gin.Context) {
if h.soraGenerationService == nil {
response.Error(c, http.StatusServiceUnavailable, "generation service not configured")
return
}
storageTypes := []string{service.SoraStorageTypeS3, service.SoraStorageTypeGDrive}
result := make(map[string]*service.StorageVideoStats, len(storageTypes))
for _, st := range storageTypes {
completed, inProgress, err := h.soraGenerationService.CountByStorageType(c.Request.Context(), st)
if err != nil {
log.Printf("[SettingHandler] CountByStorageType(%s) error: %v", st, err)
continue
}
result[st] = &service.StorageVideoStats{
Completed: completed,
InProgress: inProgress,
}
}
response.Success(c, result)
}
-10
View File
@@ -152,8 +152,6 @@ type SoraS3Profile struct {
ProfileID string `json:"profile_id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
Provider string `json:"provider"` // "s3" / "gdrive"
AccessMode string `json:"access_mode"` // "direct" / "proxy"
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
@@ -165,14 +163,6 @@ type SoraS3Profile struct {
CDNURL string `json:"cdn_url"`
DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"`
UpdatedAt string `json:"updated_at"`
// --- Google Drive 专属 ---
AuthType string `json:"auth_type,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecretConfigured bool `json:"client_secret_configured"`
RefreshTokenConfigured bool `json:"refresh_token_configured"`
ServiceAccountConfigured bool `json:"service_account_configured"`
FolderID string `json:"folder_id,omitempty"`
}
// ListSoraS3ProfilesResponse Sora S3 配置列表响应
-1
View File
@@ -30,7 +30,6 @@ type AdminHandlers struct {
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
GDriveOAuth *admin.GDriveOAuthHandler
Channel *admin.ChannelHandler
}
-3
View File
@@ -20,7 +20,6 @@ func ProvideAdminHandlers(
openaiOAuthHandler *admin.OpenAIOAuthHandler,
geminiOAuthHandler *admin.GeminiOAuthHandler,
antigravityOAuthHandler *admin.AntigravityOAuthHandler,
gdriveOAuthHandler *admin.GDriveOAuthHandler,
proxyHandler *admin.ProxyHandler,
redeemHandler *admin.RedeemHandler,
promoHandler *admin.PromoHandler,
@@ -61,7 +60,6 @@ func ProvideAdminHandlers(
TLSFingerprintProfile: tlsFingerprintProfileHandler,
APIKey: apiKeyHandler,
ScheduledTest: scheduledTestHandler,
GDriveOAuth: gdriveOAuthHandler,
Channel: channelHandler,
}
}
@@ -156,7 +154,6 @@ var ProviderSet = wire.NewSet(
admin.NewTLSFingerprintProfileHandler,
admin.NewAdminAPIKeyHandler,
admin.NewScheduledTestHandler,
admin.NewGDriveOAuthHandler,
admin.NewChannelHandler,
// AdminHandlers and Handlers constructors
-7
View File
@@ -442,13 +442,6 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
adminSettings.PUT("/sora-storage/profiles/:profile_id", h.Admin.Setting.UpdateSoraS3Profile)
adminSettings.DELETE("/sora-storage/profiles/:profile_id", h.Admin.Setting.DeleteSoraS3Profile)
adminSettings.POST("/sora-storage/profiles/:profile_id/activate", h.Admin.Setting.SetActiveSoraS3Profile)
// Google Drive OAuth
adminSettings.POST("/sora-storage/gdrive-oauth/start", h.Admin.GDriveOAuth.StartOAuth)
adminSettings.POST("/sora-storage/gdrive-oauth/callback", h.Admin.GDriveOAuth.OAuthCallback)
adminSettings.POST("/sora-storage/gdrive-test", h.Admin.GDriveOAuth.TestGDriveStorage)
// Sora 存储统计
adminSettings.GET("/sora-storage/gdrive-quota", h.Admin.Setting.GetGDriveQuota)
adminSettings.GET("/sora-storage/video-stats", h.Admin.Setting.GetStorageVideoStats)
}
}
@@ -227,49 +227,6 @@ func (s *SettingService) SetOnS3UpdateCallback(callback func()) {
s.onS3Update = callback
}
// SetOnStorageUpdateCallback 设置存储配置变更时的回调函数(用于刷新所有存储客户端缓存)。
// 替代 SetOnS3UpdateCallback,支持 S3 + GDrive 统一刷新。
func (s *SettingService) SetOnStorageUpdateCallback(callback func()) {
s.onS3Update = callback
}
// --- 统一存储 Profile 方法别名 ---
// ListSoraStorageProfiles 获取 Sora 存储多配置列表(统一方法名)。
func (s *SettingService) ListSoraStorageProfiles(ctx context.Context) (*SoraS3ProfileList, error) {
return s.ListSoraS3Profiles(ctx)
}
// CreateSoraStorageProfile 创建 Sora 存储配置(统一方法名)。
func (s *SettingService) CreateSoraStorageProfile(ctx context.Context, profile *SoraS3Profile, setActive bool) (*SoraS3Profile, error) {
return s.CreateSoraS3Profile(ctx, profile, setActive)
}
// UpdateSoraStorageProfile 更新 Sora 存储配置(统一方法名)。
func (s *SettingService) UpdateSoraStorageProfile(ctx context.Context, profileID string, profile *SoraS3Profile) (*SoraS3Profile, error) {
return s.UpdateSoraS3Profile(ctx, profileID, profile)
}
// DeleteSoraStorageProfile 删除 Sora 存储配置(统一方法名)。
func (s *SettingService) DeleteSoraStorageProfile(ctx context.Context, profileID string) error {
return s.DeleteSoraS3Profile(ctx, profileID)
}
// SetActiveSoraStorageProfile 设置激活的 Sora 存储配置(统一方法名)。
func (s *SettingService) SetActiveSoraStorageProfile(ctx context.Context, profileID string) (*SoraS3Profile, error) {
return s.SetActiveSoraS3Profile(ctx, profileID)
}
// GetActiveStorageProfile 获取当前激活的存储配置 profile。
func (s *SettingService) GetActiveStorageProfile(ctx context.Context) (*SoraS3Profile, error) {
profiles, err := s.ListSoraS3Profiles(ctx)
if err != nil {
return nil, err
}
active := pickActiveSoraS3Profile(profiles.Items, profiles.ActiveProfileID)
return active, nil
}
// SetVersion sets the application version for injection into public settings
func (s *SettingService) SetVersion(version string) {
s.version = version
@@ -1635,8 +1592,6 @@ type soraS3ProfilesStore struct {
type soraS3ProfileStoreItem struct {
ProfileID string `json:"profile_id"`
Name string `json:"name"`
Provider string `json:"provider,omitempty"` // "s3" / "gdrive",空值视为 "s3"
AccessMode string `json:"access_mode,omitempty"` // "direct" / "proxy",空值视为 "direct"
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
@@ -1648,14 +1603,6 @@ type soraS3ProfileStoreItem struct {
CDNURL string `json:"cdn_url"`
DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"`
UpdatedAt string `json:"updated_at"`
// --- Google Drive 专属 ---
AuthType string `json:"auth_type,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ServiceAccountJSON string `json:"service_account_json,omitempty"`
FolderID string `json:"folder_id,omitempty"`
}
// GetSoraS3Settings 获取 Sora S3 存储配置(兼容旧单配置语义:返回当前激活配置)
@@ -1767,8 +1714,6 @@ func (s *SettingService) CreateSoraS3Profile(ctx context.Context, profile *SoraS
store.Items = append(store.Items, soraS3ProfileStoreItem{
ProfileID: profileID,
Name: name,
Provider: profile.Provider,
AccessMode: profile.AccessMode,
Enabled: profile.Enabled,
Endpoint: strings.TrimSpace(profile.Endpoint),
Region: strings.TrimSpace(profile.Region),
@@ -1780,13 +1725,6 @@ func (s *SettingService) CreateSoraS3Profile(ctx context.Context, profile *SoraS
CDNURL: strings.TrimSpace(profile.CDNURL),
DefaultStorageQuotaBytes: maxInt64(profile.DefaultStorageQuotaBytes, 0),
UpdatedAt: now,
// Google Drive 专属
AuthType: profile.AuthType,
ClientID: strings.TrimSpace(profile.ClientID),
ClientSecret: profile.ClientSecret,
RefreshToken: profile.RefreshToken,
ServiceAccountJSON: profile.ServiceAccountJSON,
FolderID: strings.TrimSpace(profile.FolderID),
})
if setActive || store.ActiveProfileID == "" {
@@ -1832,8 +1770,6 @@ func (s *SettingService) UpdateSoraS3Profile(ctx context.Context, profileID stri
return nil, infraerrors.BadRequest("SORA_S3_PROFILE_NAME_REQUIRED", "name is required")
}
target.Name = name
target.Provider = profile.Provider
target.AccessMode = profile.AccessMode
target.Enabled = profile.Enabled
target.Endpoint = strings.TrimSpace(profile.Endpoint)
target.Region = strings.TrimSpace(profile.Region)
@@ -1846,19 +1782,6 @@ func (s *SettingService) UpdateSoraS3Profile(ctx context.Context, profileID stri
if profile.SecretAccessKey != "" {
target.SecretAccessKey = profile.SecretAccessKey
}
// Google Drive 专属
target.AuthType = profile.AuthType
target.ClientID = strings.TrimSpace(profile.ClientID)
if profile.ClientSecret != "" {
target.ClientSecret = profile.ClientSecret
}
if profile.RefreshToken != "" {
target.RefreshToken = profile.RefreshToken
}
if profile.ServiceAccountJSON != "" {
target.ServiceAccountJSON = profile.ServiceAccountJSON
}
target.FolderID = strings.TrimSpace(profile.FolderID)
target.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
store.Items[targetIndex] = target
@@ -2161,8 +2084,6 @@ func convertSoraS3ProfilesStore(store *soraS3ProfilesStore) *SoraS3ProfileList {
ProfileID: item.ProfileID,
Name: item.Name,
IsActive: item.ProfileID == store.ActiveProfileID,
Provider: item.Provider,
AccessMode: item.AccessMode,
Enabled: item.Enabled,
Endpoint: item.Endpoint,
Region: item.Region,
@@ -2175,16 +2096,6 @@ func convertSoraS3ProfilesStore(store *soraS3ProfilesStore) *SoraS3ProfileList {
CDNURL: item.CDNURL,
DefaultStorageQuotaBytes: item.DefaultStorageQuotaBytes,
UpdatedAt: item.UpdatedAt,
// Google Drive 专属
AuthType: item.AuthType,
ClientID: item.ClientID,
ClientSecret: item.ClientSecret,
ClientSecretConfigured: item.ClientSecret != "",
RefreshToken: item.RefreshToken,
RefreshTokenConfigured: item.RefreshToken != "",
ServiceAccountJSON: item.ServiceAccountJSON,
ServiceAccountConfigured: item.ServiceAccountJSON != "",
FolderID: item.FolderID,
})
}
return &SoraS3ProfileList{
-21
View File
@@ -136,8 +136,6 @@ type SoraS3Profile struct {
ProfileID string `json:"profile_id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
Provider string `json:"provider"` // "s3" / "gdrive",空值视为 "s3"
AccessMode string `json:"access_mode"` // "direct" / "proxy",空值视为 "direct"
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
@@ -150,25 +148,6 @@ type SoraS3Profile struct {
CDNURL string `json:"cdn_url"`
DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"`
UpdatedAt string `json:"updated_at"`
// --- Google Drive 专属 ---
AuthType string `json:"auth_type,omitempty"` // "oauth2" / "service_account"
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"-"`
ClientSecretConfigured bool `json:"client_secret_configured"`
RefreshToken string `json:"-"`
RefreshTokenConfigured bool `json:"refresh_token_configured"`
ServiceAccountJSON string `json:"-"`
ServiceAccountConfigured bool `json:"service_account_configured"`
FolderID string `json:"folder_id,omitempty"`
}
// GetProvider 返回 Provider,空值视为 "s3"。
func (p *SoraS3Profile) GetProvider() string {
if p.Provider == "" {
return SoraStorageTypeS3
}
return p.Provider
}
// SoraS3ProfileList Sora S3 多配置列表
@@ -1,71 +0,0 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/drive/v3"
)
// SoraGDriveOAuthService 处理 Google Drive OAuth2 授权流程。
type SoraGDriveOAuthService struct{}
// NewSoraGDriveOAuthService 创建 GDrive OAuth 服务。
func NewSoraGDriveOAuthService(_ *SettingService) *SoraGDriveOAuthService {
return &SoraGDriveOAuthService{}
}
// GenerateAuthURL 生成 Google OAuth 授权 URL。
func (s *SoraGDriveOAuthService) GenerateAuthURL(clientID, clientSecret, redirectURI string) (authURL, state string, err error) {
if clientID == "" || clientSecret == "" || redirectURI == "" {
return "", "", fmt.Errorf("client_id, client_secret, redirect_uri are required")
}
config := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: google.Endpoint,
Scopes: []string{drive.DriveFileScope},
RedirectURL: redirectURI,
}
// 生成随机 state
stateBytes := make([]byte, 16)
if _, err := rand.Read(stateBytes); err != nil {
return "", "", fmt.Errorf("generate state: %w", err)
}
state = hex.EncodeToString(stateBytes)
authURL = config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
return authURL, state, nil
}
// ExchangeCode 用授权码换取 refresh_token。
func (s *SoraGDriveOAuthService) ExchangeCode(ctx context.Context, clientID, clientSecret, redirectURI, code string) (string, error) {
if code == "" {
return "", fmt.Errorf("authorization code is required")
}
config := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: google.Endpoint,
Scopes: []string{drive.DriveFileScope},
RedirectURL: redirectURI,
}
token, err := config.Exchange(ctx, code)
if err != nil {
return "", fmt.Errorf("exchange code: %w", err)
}
if token.RefreshToken == "" {
return "", fmt.Errorf("no refresh_token received, please revoke app access and try again")
}
return token.RefreshToken, nil
}
@@ -1,433 +0,0 @@
package service
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
// SoraGDriveStorage 负责 Sora 媒体文件的 Google Drive 存储操作。
type SoraGDriveStorage struct {
settingService *SettingService
mu sync.RWMutex
srv *drive.Service
cfg *SoraS3Profile // 缓存当前 GDrive 配置
healthCheckedAt time.Time
healthErr error
healthTTL time.Duration
}
const defaultGDriveHealthTTL = 30 * time.Second
// NewSoraGDriveStorage 创建 Google Drive 存储服务实例。
func NewSoraGDriveStorage(settingService *SettingService) *SoraGDriveStorage {
return &SoraGDriveStorage{
settingService: settingService,
healthTTL: defaultGDriveHealthTTL,
}
}
// StorageType 返回存储类型标识。
func (s *SoraGDriveStorage) StorageType() string {
return SoraStorageTypeGDrive
}
// Enabled 返回 Google Drive 存储是否已启用。
func (s *SoraGDriveStorage) Enabled(ctx context.Context) bool {
profile := s.getActiveGDriveProfile(ctx)
if profile == nil {
return false
}
return profile.Enabled && s.hasValidCredentials(profile)
}
// getActiveGDriveProfile 获取当前激活的 GDrive 配置。
func (s *SoraGDriveStorage) getActiveGDriveProfile(ctx context.Context) *SoraS3Profile {
if s.settingService == nil {
return nil
}
profile, err := s.settingService.GetActiveStorageProfile(ctx)
if err != nil || profile == nil {
return nil
}
if profile.GetProvider() != SoraStorageTypeGDrive {
return nil
}
return profile
}
// hasValidCredentials 检查 GDrive 配置是否有有效凭证。
func (s *SoraGDriveStorage) hasValidCredentials(profile *SoraS3Profile) bool {
switch profile.AuthType {
case "oauth2":
return profile.ClientID != "" && profile.ClientSecret != "" && profile.RefreshToken != ""
case "service_account":
return profile.ServiceAccountJSON != ""
default:
return false
}
}
// getService 获取或初始化 Drive 服务(带缓存)。
func (s *SoraGDriveStorage) getService(ctx context.Context) (*drive.Service, *SoraS3Profile, error) {
s.mu.RLock()
if s.srv != nil && s.cfg != nil {
srv, cfg := s.srv, s.cfg
s.mu.RUnlock()
return srv, cfg, nil
}
s.mu.RUnlock()
return s.initService(ctx)
}
func (s *SoraGDriveStorage) initService(ctx context.Context) (*drive.Service, *SoraS3Profile, error) {
s.mu.Lock()
defer s.mu.Unlock()
// 双重检查
if s.srv != nil && s.cfg != nil {
return s.srv, s.cfg, nil
}
profile := s.getActiveGDriveProfile(ctx)
if profile == nil {
return nil, nil, fmt.Errorf("no active gdrive profile found")
}
if !profile.Enabled {
return nil, nil, fmt.Errorf("gdrive storage is disabled")
}
srv, err := s.buildDriveService(ctx, profile)
if err != nil {
return nil, nil, fmt.Errorf("build gdrive service: %w", err)
}
s.srv = srv
s.cfg = profile
logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 客户端已初始化 auth_type=%s folder_id=%s", profile.AuthType, profile.FolderID)
return srv, profile, nil
}
// buildDriveService 根据认证类型创建 Google Drive 服务。
func (s *SoraGDriveStorage) buildDriveService(ctx context.Context, profile *SoraS3Profile) (*drive.Service, error) {
switch profile.AuthType {
case "oauth2":
return s.buildOAuth2Service(ctx, profile)
case "service_account":
return s.buildServiceAccountService(ctx, profile)
default:
return nil, fmt.Errorf("unsupported auth_type: %s", profile.AuthType)
}
}
func (s *SoraGDriveStorage) buildOAuth2Service(ctx context.Context, profile *SoraS3Profile) (*drive.Service, error) {
config := &oauth2.Config{
ClientID: profile.ClientID,
ClientSecret: profile.ClientSecret,
Endpoint: google.Endpoint,
Scopes: []string{drive.DriveFileScope},
}
token := &oauth2.Token{
RefreshToken: profile.RefreshToken,
}
tokenSource := config.TokenSource(ctx, token)
srv, err := drive.NewService(ctx, option.WithTokenSource(tokenSource))
if err != nil {
return nil, fmt.Errorf("create gdrive oauth2 service: %w", err)
}
return srv, nil
}
func (s *SoraGDriveStorage) buildServiceAccountService(ctx context.Context, profile *SoraS3Profile) (*drive.Service, error) {
srv, err := drive.NewService(ctx, option.WithCredentialsJSON([]byte(profile.ServiceAccountJSON))) //nolint:staticcheck // SA1019: admin-controlled service account JSON, safe to use
if err != nil {
return nil, fmt.Errorf("create gdrive service account service: %w", err)
}
return srv, nil
}
// RefreshClient 清除缓存的 Drive 客户端。
func (s *SoraGDriveStorage) RefreshClient() {
s.mu.Lock()
defer s.mu.Unlock()
s.srv = nil
s.cfg = nil
s.healthCheckedAt = time.Time{}
s.healthErr = nil
logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 客户端缓存已清除")
}
// GDriveQuotaInfo 包含 Google Drive 配额信息。
type GDriveQuotaInfo struct {
LimitBytes int64 `json:"limit_bytes"`
UsedBytes int64 `json:"used_bytes"`
}
// TestConnection 测试 Google Drive 连接。
func (s *SoraGDriveStorage) TestConnection(ctx context.Context) error {
srv, _, err := s.getService(ctx)
if err != nil {
return err
}
_, err = srv.About.Get().Fields("storageQuota").Context(ctx).Do()
if err != nil {
return fmt.Errorf("gdrive About.Get failed: %w", err)
}
return nil
}
// GetQuotaInfo 获取 Google Drive 配额信息(总量和已用量)。
func (s *SoraGDriveStorage) GetQuotaInfo(ctx context.Context) (*GDriveQuotaInfo, error) {
srv, _, err := s.getService(ctx)
if err != nil {
return nil, err
}
about, err := srv.About.Get().Fields("storageQuota").Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("gdrive About.Get failed: %w", err)
}
if about.StorageQuota == nil {
return nil, fmt.Errorf("storageQuota not available")
}
return &GDriveQuotaInfo{
LimitBytes: about.StorageQuota.Limit,
UsedBytes: about.StorageQuota.Usage,
}, nil
}
// TestFullCycle 执行完整的上传→获取链接→删除测试。
func (s *SoraGDriveStorage) TestFullCycle(ctx context.Context) (map[string]any, error) {
srv, cfg, err := s.getService(ctx)
if err != nil {
return nil, fmt.Errorf("init client: %w", err)
}
result := map[string]any{}
// 1. 测试 API 连接
about, err := srv.About.Get().Fields("storageQuota").Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("API connection failed: %w", err)
}
if about.StorageQuota != nil {
result["quota_limit_bytes"] = about.StorageQuota.Limit
result["quota_used_bytes"] = about.StorageQuota.Usage
}
// 2. 上传测试文件
testContent := "sub2api GDrive test file - " + time.Now().Format(time.RFC3339)
fileMeta := &drive.File{
Name: "sub2api_test_" + uuid.NewString()[:8] + ".txt",
MimeType: "text/plain",
}
if cfg.FolderID != "" {
fileMeta.Parents = []string{cfg.FolderID}
}
uploaded, err := srv.Files.Create(fileMeta).
Media(strings.NewReader(testContent)).
Fields("id,name,size,webViewLink").
Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("upload test file failed: %w", err)
}
result["uploaded_file_id"] = uploaded.Id
result["uploaded_file_name"] = uploaded.Name
result["uploaded_file_size"] = uploaded.Size
result["web_view_link"] = uploaded.WebViewLink
// 3. 获取访问链接
accessURL, err := s.GetAccessURL(ctx, uploaded.Id)
if err != nil {
// 即使获取链接失败,仍尝试清理
_ = srv.Files.Delete(uploaded.Id).Context(ctx).Do()
return nil, fmt.Errorf("get access URL failed: %w", err)
}
result["access_url"] = accessURL
// 4. 删除测试文件
if err := srv.Files.Delete(uploaded.Id).Context(ctx).Do(); err != nil {
result["delete_warning"] = fmt.Sprintf("delete failed (manual cleanup needed): %v", err)
} else {
result["deleted"] = true
}
result["status"] = "ok"
return result, nil
}
// IsHealthy 返回 Google Drive 健康状态(带短缓存)。
func (s *SoraGDriveStorage) IsHealthy(ctx context.Context) bool {
if s == nil {
return false
}
now := time.Now()
s.mu.RLock()
lastCheck := s.healthCheckedAt
lastErr := s.healthErr
ttl := s.healthTTL
s.mu.RUnlock()
if ttl <= 0 {
ttl = defaultGDriveHealthTTL
}
if !lastCheck.IsZero() && now.Sub(lastCheck) < ttl {
return lastErr == nil
}
err := s.TestConnection(ctx)
s.mu.Lock()
s.healthCheckedAt = time.Now()
s.healthErr = err
s.mu.Unlock()
return err == nil
}
// UploadFromURL 从上游 URL 下载并上传到 Google Drive。
// 返回 Google Drive 文件 ID 作为 objectKey、文件大小、存储类型。
func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) {
srv, cfg, err := s.getService(ctx)
if err != nil {
return "", 0, "", err
}
// 下载源文件
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return "", 0, "", fmt.Errorf("create download request: %w", err)
}
httpClient := &http.Client{Timeout: 5 * time.Minute}
resp, err := httpClient.Do(req)
if err != nil {
return "", 0, "", fmt.Errorf("download from upstream: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return "", 0, "", &UpstreamDownloadError{StatusCode: resp.StatusCode}
}
// 推断文件扩展名和 MIME
ext := fileExtFromURL(sourceURL)
if ext == "" {
ext = fileExtFromContentType(resp.Header.Get("Content-Type"))
}
if ext == "" {
ext = ".bin"
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
// 生成文件名
datePath := time.Now().Format("2006-01-02")
fileName := fmt.Sprintf("sora_%d_%s_%s%s", userID, datePath, uuid.NewString()[:8], ext)
// 创建文件元数据
fileMeta := &drive.File{
Name: fileName,
MimeType: contentType,
}
if cfg.FolderID != "" {
fileMeta.Parents = []string{cfg.FolderID}
}
// 使用 CountingReader 统计大小
cr := &countingReader{Reader: resp.Body}
// 上传到 Google Drive
created, err := srv.Files.Create(fileMeta).
Media(cr).
Fields("id, size").
Context(ctx).
Do()
if err != nil {
return "", 0, "", fmt.Errorf("gdrive upload: %w", err)
}
fileSize := cr.BytesRead
if created.Size > 0 {
fileSize = created.Size
}
// 根据 access_mode 设置权限
if cfg.AccessMode == "" || cfg.AccessMode == "direct" {
// 设为任何人可读
_, permErr := srv.Permissions.Create(created.Id, &drive.Permission{
Type: "anyone",
Role: "reader",
}).Context(ctx).Do()
if permErr != nil {
logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 设置公开权限失败 fileID=%s err=%v", created.Id, permErr)
}
}
logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 上传完成 fileID=%s size=%d", created.Id, fileSize)
return created.Id, fileSize, SoraStorageTypeGDrive, nil
}
// DeleteObjects 删除一组 Google Drive 文件。
func (s *SoraGDriveStorage) DeleteObjects(ctx context.Context, objectKeys []string) error {
if len(objectKeys) == 0 {
return nil
}
srv, _, err := s.getService(ctx)
if err != nil {
return err
}
var lastErr error
for _, fileID := range objectKeys {
if err := srv.Files.Delete(fileID).Context(ctx).Do(); err != nil {
logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 删除失败 fileID=%s err=%v", fileID, err)
lastErr = err
}
}
return lastErr
}
// GetAccessURL 获取 Google Drive 文件的访问 URL。
func (s *SoraGDriveStorage) GetAccessURL(ctx context.Context, objectKey string) (string, error) {
_, cfg, err := s.getService(ctx)
if err != nil {
return "", err
}
// CDN URL 优先
if cfg.CDNURL != "" {
cdnBase := strings.TrimRight(cfg.CDNURL, "/")
return cdnBase + "/" + objectKey, nil
}
// 默认使用 Google Drive 直链
return fmt.Sprintf("https://drive.google.com/uc?export=download&id=%s", objectKey), nil
}
// countingReader 包装 io.Reader 以统计读取的字节数。
type countingReader struct {
Reader io.Reader
BytesRead int64
}
func (r *countingReader) Read(p []byte) (int, error) {
n, err := r.Reader.Read(p)
r.BytesRead += int64(n)
return n, err
}
@@ -1,124 +0,0 @@
//go:build unit
package service
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSoraGDriveStorage_StorageType(t *testing.T) {
s := NewSoraGDriveStorage(nil)
assert.Equal(t, SoraStorageTypeGDrive, s.StorageType())
}
func TestSoraGDriveStorage_EnabledWithNilSettingService(t *testing.T) {
s := NewSoraGDriveStorage(nil)
assert.False(t, s.Enabled(context.Background()))
}
func TestSoraGDriveStorage_IsHealthyWithNilReceiver(t *testing.T) {
var s *SoraGDriveStorage
assert.False(t, s.IsHealthy(context.Background()))
}
func TestSoraGDriveStorage_GetServiceWithoutProfile(t *testing.T) {
s := NewSoraGDriveStorage(nil)
_, _, err := s.getService(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "no active gdrive profile")
}
func TestSoraGDriveStorage_DeleteObjectsEmpty(t *testing.T) {
s := NewSoraGDriveStorage(nil)
err := s.DeleteObjects(context.Background(), []string{})
assert.NoError(t, err)
}
func TestSoraGDriveStorage_RefreshClient(t *testing.T) {
s := NewSoraGDriveStorage(nil)
// 不应 panic
s.RefreshClient()
assert.Nil(t, s.srv)
assert.Nil(t, s.cfg)
}
func TestSoraGDriveStorage_HasValidCredentials(t *testing.T) {
s := NewSoraGDriveStorage(nil)
tests := []struct {
name string
profile *SoraS3Profile
want bool
}{
{
name: "oauth2 with all fields",
profile: &SoraS3Profile{
AuthType: "oauth2",
ClientID: "id",
ClientSecret: "secret",
RefreshToken: "token",
},
want: true,
},
{
name: "oauth2 missing refresh token",
profile: &SoraS3Profile{
AuthType: "oauth2",
ClientID: "id",
ClientSecret: "secret",
},
want: false,
},
{
name: "service_account with json",
profile: &SoraS3Profile{
AuthType: "service_account",
ServiceAccountJSON: `{"type":"service_account"}`,
},
want: true,
},
{
name: "service_account without json",
profile: &SoraS3Profile{
AuthType: "service_account",
},
want: false,
},
{
name: "unknown auth type",
profile: &SoraS3Profile{
AuthType: "unknown",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := s.hasValidCredentials(tt.profile)
assert.Equal(t, tt.want, got)
})
}
}
func TestSoraStorageRouter_DefaultsToS3(t *testing.T) {
s3 := NewSoraS3Storage(nil)
router := NewSoraStorageRouter(nil, s3, nil)
// settingService 为 nil,应返回 s3Storage
backend := router.activeBackend(context.Background())
assert.Equal(t, s3, backend)
}
func TestSoraStorageRouter_StorageType(t *testing.T) {
router := NewSoraStorageRouter(nil, nil, nil)
assert.Equal(t, SoraStorageTypeS3, router.StorageType())
}
func TestSoraStorageRouter_RefreshAllNoPanic(t *testing.T) {
router := NewSoraStorageRouter(nil, nil, nil)
// 不应 panic
router.RefreshAll()
}
@@ -3,7 +3,6 @@ package service
import "context"
// SoraObjectStorage 是 Sora 媒体文件的通用对象存储接口。
// S3 和 Google Drive 等存储后端均实现此接口。
type SoraObjectStorage interface {
// Enabled 返回存储是否已启用且配置有效。
Enabled(ctx context.Context) bool
@@ -15,7 +14,7 @@ type SoraObjectStorage interface {
TestConnection(ctx context.Context) error
// UploadFromURL 从上游 URL 下载并上传到存储。
// 返回 object key(S3 key 或 GDrive file ID)、文件大小、实际使用的存储类型。
// 返回 object key、文件大小、实际使用的存储类型。
UploadFromURL(ctx context.Context, userID int64, sourceURL string) (objectKey string, sizeBytes int64, storageType string, err error)
// DeleteObjects 删除一组存储对象。
@@ -27,11 +26,11 @@ type SoraObjectStorage interface {
// RefreshClient 清除缓存客户端,配置变更时调用。
RefreshClient()
// StorageType 返回存储类型标识("s3" / "gdrive")。
// StorageType 返回存储类型标识。
StorageType() string
}
// IsObjectStorageType 判断是否为对象存储类型(S3 或 Google Drive)。
// IsObjectStorageType 判断是否为对象存储类型。
func IsObjectStorageType(t string) bool {
return t == SoraStorageTypeS3 || t == SoraStorageTypeGDrive
return t == SoraStorageTypeS3
}
@@ -1,129 +0,0 @@
package service
import (
"context"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
// SoraStorageRouter 根据激活 profile 的 provider 字段路由到对应存储实现。
// 实现 SoraObjectStorage 接口。
type SoraStorageRouter struct {
settingService *SettingService
s3Storage *SoraS3Storage
gdriveStorage SoraObjectStorage // 可为 nil(GDrive 未实现时)
}
// NewSoraStorageRouter 创建存储路由。
func NewSoraStorageRouter(
settingService *SettingService,
s3Storage *SoraS3Storage,
gdriveStorage SoraObjectStorage,
) *SoraStorageRouter {
return &SoraStorageRouter{
settingService: settingService,
s3Storage: s3Storage,
gdriveStorage: gdriveStorage,
}
}
// activeBackend 返回当前激活 profile 对应的存储后端。
func (r *SoraStorageRouter) activeBackend(ctx context.Context) SoraObjectStorage {
if r.settingService == nil {
return r.s3Storage // 默认 S3
}
profile, err := r.settingService.GetActiveStorageProfile(ctx)
if err != nil || profile == nil {
return r.s3Storage // 默认 S3
}
switch profile.GetProvider() {
case SoraStorageTypeGDrive:
if r.gdriveStorage != nil {
return r.gdriveStorage
}
logger.LegacyPrintf("service.storage_router", "[StorageRouter] GDrive 后端未初始化,降级到 S3")
return r.s3Storage
default:
return r.s3Storage
}
}
func (r *SoraStorageRouter) Enabled(ctx context.Context) bool {
backend := r.activeBackend(ctx)
if backend == nil {
return false
}
return backend.Enabled(ctx)
}
func (r *SoraStorageRouter) IsHealthy(ctx context.Context) bool {
backend := r.activeBackend(ctx)
if backend == nil {
return false
}
return backend.IsHealthy(ctx)
}
func (r *SoraStorageRouter) TestConnection(ctx context.Context) error {
backend := r.activeBackend(ctx)
if backend == nil {
return fmt.Errorf("no storage backend available")
}
return backend.TestConnection(ctx)
}
func (r *SoraStorageRouter) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) {
backend := r.activeBackend(ctx)
if backend == nil {
return "", 0, "", fmt.Errorf("no storage backend available")
}
return backend.UploadFromURL(ctx, userID, sourceURL)
}
func (r *SoraStorageRouter) DeleteObjects(ctx context.Context, objectKeys []string) error {
backend := r.activeBackend(ctx)
if backend == nil {
return fmt.Errorf("no storage backend available")
}
return backend.DeleteObjects(ctx, objectKeys)
}
func (r *SoraStorageRouter) GetAccessURL(ctx context.Context, objectKey string) (string, error) {
backend := r.activeBackend(ctx)
if backend == nil {
return "", fmt.Errorf("no storage backend available")
}
return backend.GetAccessURL(ctx, objectKey)
}
func (r *SoraStorageRouter) RefreshClient() {
if r.s3Storage != nil {
r.s3Storage.RefreshClient()
}
if r.gdriveStorage != nil {
r.gdriveStorage.RefreshClient()
}
}
// RefreshAll 刷新所有后端客户端(用作配置变更回调)。
func (r *SoraStorageRouter) RefreshAll() {
r.RefreshClient()
}
func (r *SoraStorageRouter) StorageType() string {
// 不带 context 的方法,返回默认值
// 真实的 StorageType 在 activeBackend 中动态确定
return SoraStorageTypeS3
}
// StorageTypeWithContext 返回当前激活后端的存储类型。
func (r *SoraStorageRouter) StorageTypeWithContext(ctx context.Context) string {
backend := r.activeBackend(ctx)
if backend == nil {
return SoraStorageTypeS3
}
return backend.StorageType()
}
+1 -103
View File
@@ -413,9 +413,7 @@ export interface SoraS3Profile {
profile_id: string
name: string
is_active: boolean
provider: string // "s3" | "gdrive"
enabled: boolean
access_mode: string // "direct" | "proxy"
endpoint: string
region: string
bucket: string
@@ -426,13 +424,6 @@ export interface SoraS3Profile {
cdn_url: string
default_storage_quota_bytes: number
updated_at: string
// Google Drive fields
auth_type: string // "oauth2" | "service_account"
client_id: string
client_secret_configured: boolean
refresh_token_configured: boolean
service_account_configured: boolean
folder_id: string
}
export interface ListSoraS3ProfilesResponse {
@@ -458,9 +449,7 @@ export interface CreateSoraS3ProfileRequest {
profile_id: string
name: string
set_active?: boolean
provider?: string
enabled: boolean
access_mode?: string
endpoint?: string
region?: string
bucket?: string
@@ -470,19 +459,11 @@ export interface CreateSoraS3ProfileRequest {
force_path_style?: boolean
cdn_url?: string
default_storage_quota_bytes?: number
// Google Drive fields
auth_type?: string
client_id?: string
client_secret?: string
refresh_token?: string
service_account_json?: string
folder_id?: string
}
export interface UpdateSoraS3ProfileRequest {
name: string
enabled: boolean
access_mode?: string
endpoint?: string
region?: string
bucket?: string
@@ -492,13 +473,6 @@ export interface UpdateSoraS3ProfileRequest {
force_path_style?: boolean
cdn_url?: string
default_storage_quota_bytes?: number
// Google Drive fields
auth_type?: string
client_id?: string
client_secret?: string
refresh_token?: string
service_account_json?: string
folder_id?: string
}
export interface TestSoraS3ConnectionRequest {
@@ -515,30 +489,6 @@ export interface TestSoraS3ConnectionRequest {
default_storage_quota_bytes?: number
}
export interface StartGDriveOAuthRequest {
client_id: string
client_secret: string
redirect_uri: string
}
export interface StartGDriveOAuthResponse {
auth_url: string
state: string
}
export interface ExchangeGDriveOAuthCodeRequest {
client_id: string
client_secret: string
redirect_uri: string
code: string
profile_id?: string
}
export interface ExchangeGDriveOAuthCodeResponse {
refresh_token: string
message: string
}
export async function getSoraS3Settings(): Promise<SoraS3Settings> {
const { data } = await apiClient.get<SoraS3Settings>('/admin/settings/sora-storage')
return data
@@ -580,53 +530,6 @@ export async function setActiveSoraS3Profile(profileID: string): Promise<SoraS3P
return data
}
export async function startGDriveOAuth(request: StartGDriveOAuthRequest): Promise<StartGDriveOAuthResponse> {
const { data } = await apiClient.post<StartGDriveOAuthResponse>('/admin/settings/sora-storage/gdrive-oauth/start', request)
return data
}
export async function exchangeGDriveOAuthCode(request: ExchangeGDriveOAuthCodeRequest): Promise<ExchangeGDriveOAuthCodeResponse> {
const { data } = await apiClient.post<ExchangeGDriveOAuthCodeResponse>('/admin/settings/sora-storage/gdrive-oauth/callback', request)
return data
}
export interface TestGDriveStorageResponse {
status: string
quota_limit_bytes?: number
quota_used_bytes?: number
uploaded_file_id?: string
uploaded_file_name?: string
uploaded_file_size?: number
access_url?: string
web_view_link?: string
deleted?: boolean
delete_warning?: string
}
export async function testGDriveStorage(): Promise<TestGDriveStorageResponse> {
const { data } = await apiClient.post<TestGDriveStorageResponse>('/admin/settings/sora-storage/gdrive-test')
return data
}
export interface GDriveQuotaInfo {
limit_bytes: number
used_bytes: number
}
export interface StorageVideoStats {
[type: string]: { completed: number; in_progress: number }
}
export async function getGDriveQuota(): Promise<GDriveQuotaInfo> {
const { data } = await apiClient.get<GDriveQuotaInfo>('/admin/settings/sora-storage/gdrive-quota')
return data
}
export async function getStorageVideoStats(): Promise<StorageVideoStats> {
const { data } = await apiClient.get<StorageVideoStats>('/admin/settings/sora-storage/video-stats')
return data
}
export const settingsAPI = {
getSettings,
updateSettings,
@@ -650,12 +553,7 @@ export const settingsAPI = {
createSoraS3Profile,
updateSoraS3Profile,
deleteSoraS3Profile,
setActiveSoraS3Profile,
startGDriveOAuth,
exchangeGDriveOAuthCode,
testGDriveStorage,
getGDriveQuota,
getStorageVideoStats
setActiveSoraS3Profile
}
export default settingsAPI
+2 -25
View File
@@ -4483,7 +4483,7 @@ export default {
},
soraS3: {
title: 'Sora Storage',
description: 'Manage Sora media storage profiles with S3 and Google Drive support',
description: 'Manage Sora media storage profiles with S3 support',
newProfile: 'New Profile',
reloadProfiles: 'Reload Profiles',
empty: 'No storage profiles yet, create one first',
@@ -4491,7 +4491,6 @@ export default {
editTitle: 'Edit Storage Profile',
selectProvider: 'Select Storage Type',
providerS3Desc: 'S3-compatible object storage',
providerGDriveDesc: 'Google Drive cloud storage',
profileID: 'Profile ID',
profileName: 'Profile Name',
setActive: 'Set as active after creation',
@@ -4549,29 +4548,7 @@ export default {
testSuccess: 'Connection test successful',
testFailed: 'Connection test failed',
saved: 'Storage settings saved successfully',
saveFailed: 'Failed to save storage settings',
gdrive: {
authType: 'Authentication Method',
serviceAccount: 'Service Account',
clientId: 'Client ID',
clientSecret: 'Client Secret',
clientSecretConfigured: '(Configured, leave blank to keep)',
refreshToken: 'Refresh Token',
refreshTokenConfigured: '(Configured, leave blank to keep)',
serviceAccountJson: 'Service Account JSON',
serviceAccountConfigured: '(Configured, leave blank to keep)',
folderId: 'Folder ID (optional)',
authorize: 'Authorize Google Drive',
authorizeHint: 'Get Refresh Token via OAuth2',
oauthFieldsRequired: 'Please fill in Client ID and Client Secret first',
oauthSuccess: 'Google Drive authorization successful',
oauthFailed: 'Google Drive authorization failed',
closeWindow: 'This window will close automatically',
processing: 'Processing authorization...',
testStorage: 'Test Storage',
testSuccess: 'Google Drive storage test passed (upload, access, delete all OK)',
testFailed: 'Google Drive storage test failed'
}
saveFailed: 'Failed to save storage settings'
},
overloadCooldown: {
title: '529 Overload Cooldown',
+2 -25
View File
@@ -4647,7 +4647,7 @@ export default {
},
soraS3: {
title: 'Sora 存储配置',
description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive',
description: '以多配置列表管理 Sora 媒体存储,支持 S3',
newProfile: '新建配置',
reloadProfiles: '刷新列表',
empty: '暂无存储配置,请先创建',
@@ -4655,7 +4655,6 @@ export default {
editTitle: '编辑存储配置',
selectProvider: '选择存储类型',
providerS3Desc: 'S3 兼容对象存储',
providerGDriveDesc: 'Google Drive 云盘',
profileID: '配置 ID',
profileName: '配置名称',
setActive: '创建后设为生效',
@@ -4713,29 +4712,7 @@ export default {
testSuccess: '连接测试成功',
testFailed: '连接测试失败',
saved: '存储设置保存成功',
saveFailed: '保存存储设置失败',
gdrive: {
authType: '认证方式',
serviceAccount: '服务账号',
clientId: 'Client ID',
clientSecret: 'Client Secret',
clientSecretConfigured: '(已配置,留空保持不变)',
refreshToken: 'Refresh Token',
refreshTokenConfigured: '(已配置,留空保持不变)',
serviceAccountJson: '服务账号 JSON',
serviceAccountConfigured: '(已配置,留空保持不变)',
folderId: 'Folder ID(可选)',
authorize: '授权 Google Drive',
authorizeHint: '通过 OAuth2 获取 Refresh Token',
oauthFieldsRequired: '请先填写 Client ID 和 Client Secret',
oauthSuccess: 'Google Drive 授权成功',
oauthFailed: 'Google Drive 授权失败',
closeWindow: '此窗口将自动关闭',
processing: '正在处理授权...',
testStorage: '测试存储',
testSuccess: 'Google Drive 存储测试成功(上传、访问、删除均正常)',
testFailed: 'Google Drive 存储测试失败'
}
saveFailed: '保存存储设置失败'
},
overloadCooldown: {
title: '529 过载冷却',
-11
View File
@@ -225,17 +225,6 @@ const routes: RouteRecordRaw[] = [
}
},
// ==================== Admin OAuth Callback Routes ====================
{
path: '/admin/gdrive-oauth-callback',
name: 'GDriveOAuthCallback',
component: () => import('@/views/admin/GDriveOAuthCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'Google Drive OAuth'
}
},
// ==================== Admin Routes ====================
{
path: '/admin',
+66 -431
View File
@@ -21,16 +21,13 @@
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[1300px] text-sm">
<table class="w-full min-w-[1000px] text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400">
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.profileId') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.name') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.provider') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.active') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.storagePath') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.capacityUsage') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.videoCount') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.updatedAt') }}</th>
<th class="py-2">{{ t('admin.settings.soraS3.columns.actions') }}</th>
</tr>
@@ -41,14 +38,6 @@
<div class="font-mono text-xs">{{ profile.profile_id }}</div>
</td>
<td class="py-3 pr-4 text-xs">{{ profile.name }}</td>
<td class="py-3 pr-4">
<span
class="rounded px-2 py-0.5 text-xs"
:class="getProviderBadgeClass(profile.provider)"
>
{{ getProviderLabel(profile.provider) }}
</span>
</td>
<td class="py-3 pr-4">
<span
class="rounded px-2 py-0.5 text-xs"
@@ -58,43 +47,8 @@
</span>
</td>
<td class="py-3 pr-4 text-xs">
<template v-if="(profile.provider || 's3') === 's3'">
<div>{{ profile.endpoint || '-' }}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ [profile.bucket, profile.prefix].filter(Boolean).join('/') || '-' }}</div>
</template>
<template v-else>
<div>{{ profile.folder_id ? `folder: ${profile.folder_id}` : t('admin.settings.soraS3.columns.rootFolder') }}</div>
</template>
</td>
<td class="py-3 pr-4 text-xs">
<template v-if="(profile.provider || 's3') === 'gdrive'">
<template v-if="gdriveQuota">
<div>{{ formatBytes(gdriveQuota.used_bytes) }} / {{ formatBytes(gdriveQuota.limit_bytes) }}</div>
</template>
<template v-else-if="loadingGDriveQuota">
<div class="text-gray-400">{{ t('common.loading') }}</div>
</template>
<template v-else>
<div class="text-gray-400">-</div>
</template>
</template>
<template v-else>
<div class="text-gray-400">{{ t('admin.settings.soraS3.columns.capacityUnlimited') }}</div>
</template>
</td>
<td class="py-3 pr-4 text-xs">
<template v-if="videoStats && videoStats[profile.provider || 's3']">
<div>{{ videoStats[profile.provider || 's3'].completed }} {{ t('admin.settings.soraS3.columns.videoCompleted') }}</div>
<div v-if="videoStats[profile.provider || 's3'].in_progress > 0" class="mt-0.5 text-gray-500 dark:text-gray-400">
{{ videoStats[profile.provider || 's3'].in_progress }} {{ t('admin.settings.soraS3.columns.videoInProgress') }}
</div>
</template>
<template v-else-if="loadingVideoStats">
<div class="text-gray-400">{{ t('common.loading') }}</div>
</template>
<template v-else>
<div class="text-gray-400">-</div>
</template>
<div>{{ profile.endpoint || '-' }}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ [profile.bucket, profile.prefix].filter(Boolean).join('/') || '-' }}</div>
</td>
<td class="py-3 pr-4 text-xs">{{ formatDate(profile.updated_at) }}</td>
<td class="py-3 text-xs">
@@ -102,8 +56,7 @@
<button
type="button"
class="btn btn-secondary btn-xs"
:disabled="testingProfiles[profile.profile_id]
|| ((profile.provider || 's3') === 'gdrive' && !profile.is_active)"
:disabled="testingProfiles[profile.profile_id]"
@click="testProfileInTable(profile)"
>
{{ testingProfiles[profile.profile_id] ? t('admin.settings.soraS3.columns.testingInTable') : t('admin.settings.soraS3.columns.testInTable') }}
@@ -132,7 +85,7 @@
</td>
</tr>
<tr v-if="soraS3Profiles.length === 0">
<td colspan="9" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
<td colspan="6" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.soraS3.empty') }}
</td>
</tr>
@@ -160,9 +113,6 @@
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-dark-700">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ creatingSoraProfile ? t('admin.settings.soraS3.createTitle') : t('admin.settings.soraS3.editTitle') }}
<span v-if="soraProfileForm.provider" class="ml-2 rounded bg-gray-100 px-2 py-0.5 text-xs font-normal text-gray-600 dark:bg-dark-800 dark:text-gray-400">
{{ getProviderLabel(soraProfileForm.provider) }}
</span>
</h4>
<button
type="button"
@@ -174,33 +124,7 @@
</div>
<div class="flex-1 overflow-y-auto p-4">
<!-- Provider selection (only when creating and not yet chosen) -->
<div v-if="creatingSoraProfile && !soraProfileForm.provider" class="flex flex-col items-center justify-center gap-4 py-12">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.soraS3.selectProvider') }}
</h4>
<div class="grid w-full max-w-sm grid-cols-2 gap-3">
<button
type="button"
class="flex flex-col items-center gap-2 rounded-lg border-2 border-gray-200 p-4 transition hover:border-blue-400 hover:bg-blue-50 dark:border-dark-600 dark:hover:border-blue-500 dark:hover:bg-dark-800"
@click="selectProvider('s3')"
>
<span class="text-2xl">S3</span>
<span class="text-xs text-gray-600 dark:text-gray-400">{{ t('admin.settings.soraS3.providerS3Desc') }}</span>
</button>
<button
type="button"
class="flex flex-col items-center gap-2 rounded-lg border-2 border-gray-200 p-4 transition hover:border-blue-400 hover:bg-blue-50 dark:border-dark-600 dark:hover:border-blue-500 dark:hover:bg-dark-800"
@click="selectProvider('gdrive')"
>
<span class="text-2xl">GDrive</span>
<span class="text-xs text-gray-600 dark:text-gray-400">{{ t('admin.settings.soraS3.providerGDriveDesc') }}</span>
</button>
</div>
</div>
<!-- Profile form (shown after provider is selected) -->
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<!-- Common fields -->
<input
v-model="soraProfileForm.profile_id"
@@ -218,79 +142,22 @@
<span>{{ t('admin.settings.soraS3.enabled') }}</span>
</label>
<!-- S3-specific fields -->
<template v-if="soraProfileForm.provider === 's3'">
<input v-model="soraProfileForm.endpoint" class="input w-full" :placeholder="t('admin.settings.soraS3.endpoint')" />
<input v-model="soraProfileForm.region" class="input w-full" :placeholder="t('admin.settings.soraS3.region')" />
<input v-model="soraProfileForm.bucket" class="input w-full" :placeholder="t('admin.settings.soraS3.bucket')" />
<input v-model="soraProfileForm.prefix" class="input w-full" :placeholder="t('admin.settings.soraS3.prefix')" />
<input v-model="soraProfileForm.access_key_id" class="input w-full" :placeholder="t('admin.settings.soraS3.accessKeyId')" />
<input
v-model="soraProfileForm.secret_access_key"
type="password"
class="input w-full"
:placeholder="soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
/>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="soraProfileForm.force_path_style" type="checkbox" />
<span>{{ t('admin.settings.soraS3.forcePathStyle') }}</span>
</label>
</template>
<!-- GDrive-specific fields -->
<template v-if="soraProfileForm.provider === 'gdrive'">
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.soraS3.gdrive.authType') }}
</label>
<select v-model="soraProfileForm.auth_type" class="input w-full">
<option value="oauth2">OAuth2</option>
<option value="service_account">{{ t('admin.settings.soraS3.gdrive.serviceAccount') }}</option>
</select>
</div>
<template v-if="soraProfileForm.auth_type === 'oauth2'">
<input v-model="soraProfileForm.client_id" class="input w-full" :placeholder="t('admin.settings.soraS3.gdrive.clientId')" />
<input
v-model="soraProfileForm.client_secret"
type="password"
class="input w-full"
:placeholder="soraProfileForm.client_secret_configured ? t('admin.settings.soraS3.gdrive.clientSecretConfigured') : t('admin.settings.soraS3.gdrive.clientSecret')"
/>
<div class="md:col-span-2">
<input
v-model="soraProfileForm.refresh_token"
type="password"
class="input w-full"
:placeholder="soraProfileForm.refresh_token_configured ? t('admin.settings.soraS3.gdrive.refreshTokenConfigured') : t('admin.settings.soraS3.gdrive.refreshToken')"
/>
<div class="mt-2 flex items-center gap-2">
<button
type="button"
class="btn btn-secondary btn-xs"
:disabled="!soraProfileForm.client_id || !soraProfileForm.client_secret || startingOAuth"
@click="startGDriveOAuth"
>
{{ startingOAuth ? t('common.loading') : t('admin.settings.soraS3.gdrive.authorize') }}
</button>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.gdrive.authorizeHint') }}</p>
</div>
</div>
</template>
<template v-if="soraProfileForm.auth_type === 'service_account'">
<div class="md:col-span-2">
<textarea
v-model="soraProfileForm.service_account_json"
class="input w-full"
rows="6"
:placeholder="soraProfileForm.service_account_configured ? t('admin.settings.soraS3.gdrive.serviceAccountConfigured') : t('admin.settings.soraS3.gdrive.serviceAccountJson')"
></textarea>
</div>
</template>
<input v-model="soraProfileForm.folder_id" class="input w-full md:col-span-2" :placeholder="t('admin.settings.soraS3.gdrive.folderId')" />
</template>
<!-- S3 fields -->
<input v-model="soraProfileForm.endpoint" class="input w-full" :placeholder="t('admin.settings.soraS3.endpoint')" />
<input v-model="soraProfileForm.region" class="input w-full" :placeholder="t('admin.settings.soraS3.region')" />
<input v-model="soraProfileForm.bucket" class="input w-full" :placeholder="t('admin.settings.soraS3.bucket')" />
<input v-model="soraProfileForm.prefix" class="input w-full" :placeholder="t('admin.settings.soraS3.prefix')" />
<input v-model="soraProfileForm.access_key_id" class="input w-full" :placeholder="t('admin.settings.soraS3.accessKeyId')" />
<input
v-model="soraProfileForm.secret_access_key"
type="password"
class="input w-full"
:placeholder="soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
/>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="soraProfileForm.force_path_style" type="checkbox" />
<span>{{ t('admin.settings.soraS3.forcePathStyle') }}</span>
</label>
<!-- Common bottom fields -->
<input v-model="soraProfileForm.cdn_url" class="input w-full" :placeholder="t('admin.settings.soraS3.cdnUrl')" />
@@ -301,12 +168,11 @@
</div>
</div>
<div v-if="soraProfileForm.provider" class="flex flex-wrap justify-end gap-2 border-t border-gray-200 p-4 dark:border-dark-700">
<div class="flex flex-wrap justify-end gap-2 border-t border-gray-200 p-4 dark:border-dark-700">
<button type="button" class="btn btn-secondary btn-sm" @click="closeSoraProfileDrawer">
{{ t('common.cancel') }}
</button>
<button
v-if="soraProfileForm.provider === 's3'"
type="button"
class="btn btn-secondary btn-sm"
:disabled="testingSoraProfile || !soraProfileForm.enabled"
@@ -314,15 +180,6 @@
>
{{ testingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.testConnection') }}
</button>
<button
v-if="soraProfileForm.provider === 'gdrive' && !creatingSoraProfile"
type="button"
class="btn btn-secondary btn-sm"
:disabled="testingSoraProfile || !soraProfileForm.enabled"
@click="testGDriveStorageConnection"
>
{{ testingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.gdrive.testStorage') }}
</button>
<button type="button" class="btn btn-primary btn-sm" :disabled="savingSoraProfile" @click="saveSoraProfile">
{{ savingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.saveProfile') }}
</button>
@@ -335,7 +192,7 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraS3Profile, GDriveQuotaInfo, StorageVideoStats } from '@/api/admin/settings'
import type { SoraS3Profile } from '@/api/admin/settings'
import { adminAPI } from '@/api'
import { useAppStore } from '@/stores'
@@ -349,12 +206,7 @@ const activatingSoraProfile = ref(false)
const deletingSoraProfile = ref(false)
const creatingSoraProfile = ref(false)
const soraProfileDrawerOpen = ref(false)
const startingOAuth = ref(false)
const loadingGDriveQuota = ref(false)
const loadingVideoStats = ref(false)
const testingProfiles: Record<string, boolean> = reactive({})
const gdriveQuota = ref<GDriveQuotaInfo | null>(null)
const videoStats = ref<StorageVideoStats | null>(null)
const soraS3Profiles = ref<SoraS3Profile[]>([])
const selectedSoraProfileID = ref('')
@@ -363,9 +215,7 @@ type SoraStorageProfileForm = {
profile_id: string
name: string
set_active: boolean
provider: string
enabled: boolean
// S3 fields
endpoint: string
region: string
bucket: string
@@ -374,31 +224,10 @@ type SoraStorageProfileForm = {
secret_access_key_configured: boolean
prefix: string
force_path_style: boolean
// GDrive fields
auth_type: string
client_id: string
client_secret: string
client_secret_configured: boolean
refresh_token: string
refresh_token_configured: boolean
service_account_json: string
service_account_configured: boolean
folder_id: string
// Common
cdn_url: string
}
const soraProfileForm = ref<SoraStorageProfileForm>(newDefaultProfileForm('s3'))
function getProviderLabel(provider?: string): string {
return (provider || 's3') === 'gdrive' ? 'Google Drive' : 'S3'
}
function getProviderBadgeClass(provider?: string): string {
return (provider || 's3') === 'gdrive'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
}
const soraProfileForm = ref<SoraStorageProfileForm>(newDefaultProfileForm())
async function loadSoraS3Profiles() {
loadingSoraProfiles.value = true
@@ -414,9 +243,6 @@ async function loadSoraS3Profiles() {
}
syncSoraProfileFormWithSelection()
}
// Async load quota and video stats (non-blocking)
loadGDriveQuotaIfNeeded()
loadVideoStats()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
@@ -431,36 +257,24 @@ async function testProfileInTable(profile: SoraS3Profile) {
if (testingProfiles[pid]) return
testingProfiles[pid] = true
const provider = profile.provider || 's3'
try {
if (provider === 'gdrive') {
const result = await Promise.race([
adminAPI.settings.testGDriveStorage(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(t('admin.settings.soraS3.columns.testTimeout'))), testProfileTimeoutMs))
])
const msg = result.status === 'ok'
? t('admin.settings.soraS3.gdrive.testSuccess')
: t('admin.settings.soraS3.gdrive.testFailed')
appStore.showSuccess(msg)
} else {
await Promise.race([
adminAPI.settings.testSoraS3Connection({
profile_id: pid,
enabled: profile.enabled,
endpoint: profile.endpoint || '',
region: profile.region || '',
bucket: profile.bucket || '',
access_key_id: profile.access_key_id || '',
prefix: profile.prefix || '',
force_path_style: Boolean(profile.force_path_style),
cdn_url: profile.cdn_url || '',
default_storage_quota_bytes: profile.default_storage_quota_bytes || 0
}),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(t('admin.settings.soraS3.columns.testTimeout'))), testProfileTimeoutMs))
])
appStore.showSuccess(t('admin.settings.soraS3.testSuccess'))
}
await Promise.race([
adminAPI.settings.testSoraS3Connection({
profile_id: pid,
enabled: profile.enabled,
endpoint: profile.endpoint || '',
region: profile.region || '',
bucket: profile.bucket || '',
access_key_id: profile.access_key_id || '',
prefix: profile.prefix || '',
force_path_style: Boolean(profile.force_path_style),
cdn_url: profile.cdn_url || '',
default_storage_quota_bytes: profile.default_storage_quota_bytes || 0
}),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(t('admin.settings.soraS3.columns.testTimeout'))), testProfileTimeoutMs))
])
appStore.showSuccess(t('admin.settings.soraS3.testSuccess'))
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
@@ -468,46 +282,13 @@ async function testProfileInTable(profile: SoraS3Profile) {
}
}
async function loadGDriveQuotaIfNeeded() {
const hasGDrive = soraS3Profiles.value.some(
(p) => (p.provider || 's3') === 'gdrive' && p.is_active
)
if (!hasGDrive) {
gdriveQuota.value = null
return
}
loadingGDriveQuota.value = true
try {
gdriveQuota.value = await adminAPI.settings.getGDriveQuota()
} catch {
gdriveQuota.value = null
} finally {
loadingGDriveQuota.value = false
}
}
async function loadVideoStats() {
loadingVideoStats.value = true
try {
videoStats.value = await adminAPI.settings.getStorageVideoStats()
} catch {
videoStats.value = null
} finally {
loadingVideoStats.value = false
}
}
function startCreateSoraProfile() {
creatingSoraProfile.value = true
selectedSoraProfileID.value = ''
soraProfileForm.value = newDefaultProfileForm('')
soraProfileForm.value = newDefaultProfileForm()
soraProfileDrawerOpen.value = true
}
function selectProvider(provider: string) {
soraProfileForm.value = newDefaultProfileForm(provider)
}
function editSoraProfile(profileID: string) {
selectedSoraProfileID.value = profileID
creatingSoraProfile.value = false
@@ -538,8 +319,8 @@ async function saveSoraProfile() {
appStore.showError(t('admin.settings.soraS3.profileSelectRequired'))
return
}
// S3-specific validation
if (form.provider === 's3' && form.enabled) {
// S3 validation
if (form.enabled) {
if (!form.endpoint.trim()) {
appStore.showError(t('admin.settings.soraS3.endpointRequired'))
return
@@ -557,66 +338,37 @@ async function saveSoraProfile() {
savingSoraProfile.value = true
try {
if (creatingSoraProfile.value) {
const request: Record<string, unknown> = {
const created = await adminAPI.settings.createSoraS3Profile({
profile_id: form.profile_id.trim(),
name: form.name.trim(),
set_active: form.set_active,
provider: form.provider,
enabled: form.enabled,
endpoint: form.endpoint,
region: form.region,
bucket: form.bucket,
access_key_id: form.access_key_id,
secret_access_key: form.secret_access_key || undefined,
prefix: form.prefix,
force_path_style: form.force_path_style,
cdn_url: form.cdn_url
}
if (form.provider === 's3') {
Object.assign(request, {
endpoint: form.endpoint,
region: form.region,
bucket: form.bucket,
access_key_id: form.access_key_id,
secret_access_key: form.secret_access_key || undefined,
prefix: form.prefix,
force_path_style: form.force_path_style
})
} else {
Object.assign(request, {
auth_type: form.auth_type,
client_id: form.client_id,
client_secret: form.client_secret || undefined,
refresh_token: form.refresh_token || undefined,
service_account_json: form.service_account_json || undefined,
folder_id: form.folder_id
})
}
const created = await adminAPI.settings.createSoraS3Profile(request as never)
})
selectedSoraProfileID.value = created.profile_id
creatingSoraProfile.value = false
soraProfileDrawerOpen.value = false
appStore.showSuccess(t('admin.settings.soraS3.profileCreated'))
} else {
const request: Record<string, unknown> = {
await adminAPI.settings.updateSoraS3Profile(selectedSoraProfileID.value, {
name: form.name.trim(),
enabled: form.enabled,
endpoint: form.endpoint,
region: form.region,
bucket: form.bucket,
access_key_id: form.access_key_id,
secret_access_key: form.secret_access_key || undefined,
prefix: form.prefix,
force_path_style: form.force_path_style,
cdn_url: form.cdn_url
}
if (form.provider === 's3') {
Object.assign(request, {
endpoint: form.endpoint,
region: form.region,
bucket: form.bucket,
access_key_id: form.access_key_id,
secret_access_key: form.secret_access_key || undefined,
prefix: form.prefix,
force_path_style: form.force_path_style
})
} else {
Object.assign(request, {
auth_type: form.auth_type,
client_id: form.client_id,
client_secret: form.client_secret || undefined,
refresh_token: form.refresh_token || undefined,
service_account_json: form.service_account_json || undefined,
folder_id: form.folder_id
})
}
await adminAPI.settings.updateSoraS3Profile(selectedSoraProfileID.value, request as never)
})
soraProfileDrawerOpen.value = false
appStore.showSuccess(t('admin.settings.soraS3.profileSaved'))
}
@@ -651,82 +403,6 @@ async function testSoraProfileConnection() {
}
}
async function testGDriveStorageConnection() {
testingSoraProfile.value = true
try {
const result = await adminAPI.settings.testGDriveStorage()
const msg = result.status === 'ok'
? t('admin.settings.soraS3.gdrive.testSuccess')
: t('admin.settings.soraS3.gdrive.testFailed')
appStore.showSuccess(msg)
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('admin.settings.soraS3.gdrive.testFailed'))
} finally {
testingSoraProfile.value = false
}
}
async function startGDriveOAuth() {
const form = soraProfileForm.value
if (!form.client_id || !form.client_secret) {
appStore.showError(t('admin.settings.soraS3.gdrive.oauthFieldsRequired'))
return
}
startingOAuth.value = true
try {
const redirectUri = `${window.location.origin}/admin/gdrive-oauth-callback`
const result = await adminAPI.settings.startGDriveOAuth({
client_id: form.client_id,
client_secret: form.client_secret,
redirect_uri: redirectUri
})
// Store state for callback verification
sessionStorage.setItem('gdrive_oauth_state', result.state)
sessionStorage.setItem('gdrive_oauth_client_id', form.client_id)
sessionStorage.setItem('gdrive_oauth_client_secret', form.client_secret)
sessionStorage.setItem('gdrive_oauth_redirect_uri', redirectUri)
sessionStorage.setItem('gdrive_oauth_profile_id', creatingSoraProfile.value ? '' : selectedSoraProfileID.value)
// Open Google auth in new window
const authWindow = window.open(result.auth_url, 'gdrive_oauth', 'width=600,height=700')
// Listen for callback message
const handleMessage = async (event: MessageEvent) => {
if (event.data?.type !== 'gdrive_oauth_callback') return
window.removeEventListener('message', handleMessage)
if (authWindow) authWindow.close()
const code = event.data.code
if (!code) {
appStore.showError(t('admin.settings.soraS3.gdrive.oauthFailed'))
return
}
try {
const exchangeResult = await adminAPI.settings.exchangeGDriveOAuthCode({
client_id: form.client_id,
client_secret: form.client_secret,
redirect_uri: redirectUri,
code,
profile_id: creatingSoraProfile.value ? undefined : selectedSoraProfileID.value
})
form.refresh_token = exchangeResult.refresh_token
form.refresh_token_configured = true
appStore.showSuccess(exchangeResult.message || t('admin.settings.soraS3.gdrive.oauthSuccess'))
} catch (err) {
appStore.showError((err as { message?: string })?.message || t('admin.settings.soraS3.gdrive.oauthFailed'))
}
}
window.addEventListener('message', handleMessage)
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
startingOAuth.value = false
}
}
async function activateSoraProfile(profileID: string) {
activatingSoraProfile.value = true
try {
@@ -766,18 +442,6 @@ function formatDate(value?: string): string {
return date.toLocaleString()
}
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let idx = 0
let val = bytes
while (val >= 1024 && idx < units.length - 1) {
val /= 1024
idx++
}
return `${val.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`
}
function pickPreferredSoraProfileID(): string {
const active = soraS3Profiles.value.find((item) => item.is_active)
if (active) return active.profile_id
@@ -786,16 +450,15 @@ function pickPreferredSoraProfileID(): string {
function syncSoraProfileFormWithSelection() {
const profile = soraS3Profiles.value.find((item) => item.profile_id === selectedSoraProfileID.value)
soraProfileForm.value = newDefaultProfileForm(profile?.provider || 's3', profile)
soraProfileForm.value = newDefaultProfileForm(profile)
}
function newDefaultProfileForm(provider: string, profile?: SoraS3Profile): SoraStorageProfileForm {
function newDefaultProfileForm(profile?: SoraS3Profile): SoraStorageProfileForm {
if (!profile) {
return {
profile_id: '',
name: '',
set_active: false,
provider,
enabled: false,
endpoint: '',
region: '',
@@ -803,17 +466,8 @@ function newDefaultProfileForm(provider: string, profile?: SoraS3Profile): SoraS
access_key_id: '',
secret_access_key: '',
secret_access_key_configured: false,
prefix: provider === 's3' ? 'sora/' : '',
prefix: 'sora/',
force_path_style: false,
auth_type: 'oauth2',
client_id: '',
client_secret: '',
client_secret_configured: false,
refresh_token: '',
refresh_token_configured: false,
service_account_json: '',
service_account_configured: false,
folder_id: '',
cdn_url: ''
}
}
@@ -822,7 +476,6 @@ function newDefaultProfileForm(provider: string, profile?: SoraS3Profile): SoraS
profile_id: profile.profile_id,
name: profile.name,
set_active: false,
provider: profile.provider || 's3',
enabled: profile.enabled,
endpoint: profile.endpoint || '',
region: profile.region || '',
@@ -832,15 +485,6 @@ function newDefaultProfileForm(provider: string, profile?: SoraS3Profile): SoraS
secret_access_key_configured: Boolean(profile.secret_access_key_configured),
prefix: profile.prefix || '',
force_path_style: Boolean(profile.force_path_style),
auth_type: profile.auth_type || 'oauth2',
client_id: profile.client_id || '',
client_secret: '',
client_secret_configured: Boolean(profile.client_secret_configured),
refresh_token: '',
refresh_token_configured: Boolean(profile.refresh_token_configured),
service_account_json: '',
service_account_configured: Boolean(profile.service_account_configured),
folder_id: profile.folder_id || '',
cdn_url: profile.cdn_url || ''
}
}
@@ -870,16 +514,7 @@ onMounted(async () => {
.dm-drawer-panel-enter-from,
.dm-drawer-panel-leave-to {
opacity: 0.96;
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.dm-drawer-mask-enter-active,
.dm-drawer-mask-leave-active,
.dm-drawer-panel-enter-active,
.dm-drawer-panel-leave-active {
transition-duration: 0s;
}
opacity: 0;
}
</style>
@@ -1,71 +0,0 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-dark-900">
<div class="w-full max-w-md text-center">
<div v-if="error" class="card p-8">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.soraS3.gdrive.oauthFailed') }}</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ error }}</p>
<p class="mt-4 text-xs text-gray-400 dark:text-gray-500">{{ t('admin.settings.soraS3.gdrive.closeWindow') }}</p>
</div>
<div v-else-if="sent" class="card p-8">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<svg class="h-6 w-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.soraS3.gdrive.oauthSuccess') }}</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.gdrive.closeWindow') }}</p>
</div>
<div v-else class="card p-8">
<div class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-blue-500"></div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.gdrive.processing') }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const route = useRoute()
const { t } = useI18n()
const error = ref('')
const sent = ref(false)
onMounted(() => {
const code = route.query.code as string
const state = route.query.state as string
const errorParam = route.query.error as string
const errorDesc = route.query.error_description as string
if (errorParam) {
error.value = errorDesc || errorParam
return
}
if (!code) {
error.value = 'No authorization code received'
return
}
// Send code back to opener window via postMessage
if (window.opener) {
window.opener.postMessage(
{ type: 'gdrive_oauth_callback', code, state },
window.location.origin
)
sent.value = true
// Auto-close after a short delay
setTimeout(() => window.close(), 1500)
} else {
error.value = 'Cannot communicate with the parent window. Please close this window and try again.'
}
})
</script>