From b3ad2efb2b9a4b66e45015d4080b935c911270ee Mon Sep 17 00:00:00 2001 From: Xheldon Date: Wed, 30 Jul 2025 14:40:04 +0800 Subject: [PATCH] add cos support(wip-2) --- cos-service.ts | 234 +++++++++++++++++++++++++++++++++++++++++++---- i18n-simple.ts | 16 +++- main.ts | 241 ++++++++++++++++++++++++++++++------------------- styles.css | 11 +++ types.ts | 63 ++++++++++--- 5 files changed, 438 insertions(+), 127 deletions(-) diff --git a/cos-service.ts b/cos-service.ts index b4e00fe..6738084 100644 --- a/cos-service.ts +++ b/cos-service.ts @@ -147,6 +147,14 @@ export class CosService { // Generate signature for Tencent COS const signature = await this.generateTencentSignature('PUT', remotePath, date, file.type); + console.log('Tencent COS upload debug:', { + url, + remotePath, + signature: signature.substring(0, 50) + '...', + fileSize: file.size, + fileType: file.type + }); + // Convert File to ArrayBuffer for requestUrl const fileBuffer = await file.arrayBuffer(); @@ -154,7 +162,6 @@ export class CosService { url: url, method: 'PUT', headers: { - 'Date': date, 'Content-Type': file.type, 'Authorization': signature }, @@ -304,7 +311,6 @@ export class CosService { url: url, method: 'DELETE', headers: { - 'Date': date, 'Authorization': signature } }); @@ -346,6 +352,16 @@ export class CosService { private async generateAliyunSignature(method: string, path: string, date: string, contentType?: string): Promise { const stringToSign = `${method}\n\n${contentType || ''}\n${date}\n/${this.config.bucket}/${path}`; + console.log('Aliyun OSS signature debug:', { + method, + path, + date, + contentType, + stringToSign: stringToSign.replace(/\n/g, '\\n'), + bucket: this.config.bucket, + region: this.config.region + }); + const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', @@ -356,29 +372,151 @@ export class CosService { ); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(stringToSign)); - return btoa(String.fromCharCode(...new Uint8Array(signature))); + const signatureBase64 = btoa(String.fromCharCode(...new Uint8Array(signature))); + + console.log('Generated Aliyun signature:', signatureBase64.substring(0, 20) + '...'); + + return signatureBase64; } /** * Generate signature for Tencent COS */ private async generateTencentSignature(method: string, path: string, date: string, contentType?: string): Promise { - // Simplified Tencent COS signature - in production, you might want to use their SDK - const stringToSign = `${method}\n/${this.config.bucket}/${path}\n\ndate=${date}&host=${this.config.bucket}.cos.${this.config.region}.myqcloud.com`; + // Tencent COS signature v5 implementation according to official documentation + const now = Math.floor(Date.now() / 1000); + const expireTime = now + 3600; // 1 hour expiry + const keyTime = `${now};${expireTime}`; + console.log('Tencent signature debug:', { + method, + path, + keyTime, + secretId: this.config.secretId.substring(0, 8) + '...', + bucket: this.config.bucket, + region: this.config.region + }); + + // Step 1: Generate SignKey using HMAC-SHA1(SecretKey, KeyTime) + const signKey = await this.hmacSha1(this.config.secretKey, keyTime); + + // Step 2: Generate HttpString + const httpMethod = method.toLowerCase(); + const uriPathname = `/${path}`; + const httpParameters = ''; // No query parameters for PUT + const httpHeaders = `host=${this.config.bucket}.cos.${this.config.region}.myqcloud.com`; + const httpString = `${httpMethod}\n${uriPathname}\n${httpParameters}\n${httpHeaders}\n`; + + // Step 3: Generate StringToSign + const httpStringSha1 = await this.sha1Hash(httpString); + const stringToSign = `sha1\n${keyTime}\n${httpStringSha1}\n`; + + // Step 4: Generate Signature using HMAC-SHA1(SignKey, StringToSign) + const signature = await this.hmacSha1(signKey, stringToSign); + + // Step 5: Generate Authorization header + const authorization = `q-sign-algorithm=sha1&q-ak=${this.config.secretId}&q-sign-time=${keyTime}&q-key-time=${keyTime}&q-header-list=host&q-url-param-list=&q-signature=${signature}`; + + console.log('Generated authorization:', authorization.substring(0, 100) + '...'); + + return authorization; + } + + /** + * HMAC-SHA1 implementation + */ + private async hmacSha1(key: string, data: string): Promise { const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( + const keyBuffer = encoder.encode(key); + const dataBuffer = encoder.encode(data); + + const cryptoKey = await crypto.subtle.importKey( 'raw', - encoder.encode(this.config.secretKey), + keyBuffer, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'] ); - const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(stringToSign)); - const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature))); + const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer); + return Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } + + /** + * SHA1 hash implementation + */ + private async sha1Hash(data: string): Promise { + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(data); + const hashBuffer = await crypto.subtle.digest('SHA-1', dataBuffer); + return Array.from(new Uint8Array(hashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } + + /** + * SHA256 hash implementation + */ + private async sha256Hash(data: string): Promise { + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(data); + const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer); + return Array.from(new Uint8Array(hashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } + + /** + * HMAC-SHA256 implementation (returns hex string) + */ + private async hmacSha256Hex(key: ArrayBuffer, data: string): Promise { + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(data); - return `q-sign-algorithm=sha1&q-ak=${this.config.secretId}&q-sign-time=0;32503651200&q-key-time=0;32503651200&q-header-list=date;host&q-url-param-list=&q-signature=${signatureB64}`; + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer); + return Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } + + /** + * HMAC-SHA256 implementation (returns ArrayBuffer) + */ + private async hmacSha256(key: ArrayBuffer, data: string): Promise { + const encoder = new TextEncoder(); + const dataBuffer = encoder.encode(data); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + return await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer); + } + + /** + * Generate AWS signature key + */ + private async getAWSSignatureKey(secretKey: string, dateStamp: string, region: string, service: string): Promise { + const encoder = new TextEncoder(); + const kDate = await this.hmacSha256(encoder.encode(`AWS4${secretKey}`), dateStamp); + const kRegion = await this.hmacSha256(kDate, region); + const kService = await this.hmacSha256(kRegion, service); + const kSigning = await this.hmacSha256(kService, 'aws4_request'); + return kSigning; } /** @@ -387,13 +525,42 @@ export class CosService { private async generateAWSSignature(method: string, path: string, date: Date, contentType?: string): Promise<{ headers: Record }> { const isoDate = date.toISOString().replace(/[:\-]|\.\d{3}/g, ''); const dateStamp = isoDate.substring(0, 8); + const service = 's3'; + const algorithm = 'AWS4-HMAC-SHA256'; + + // Step 1: Create canonical request + const host = `${this.config.bucket}.s3.${this.config.region}.amazonaws.com`; + const canonicalUri = `/${path}`; + const canonicalQueryString = ''; + const canonicalHeaders = `host:${host}\nx-amz-content-sha256:UNSIGNED-PAYLOAD\nx-amz-date:${isoDate}\n`; + const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'; + const payloadHash = 'UNSIGNED-PAYLOAD'; + + const canonicalRequest = `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`; + + // Step 2: Create string to sign + const credentialScope = `${dateStamp}/${this.config.region}/${service}/aws4_request`; + const canonicalRequestHash = await this.sha256Hash(canonicalRequest); + const stringToSign = `${algorithm}\n${isoDate}\n${credentialScope}\n${canonicalRequestHash}`; + + // Step 3: Calculate signature + const signingKey = await this.getAWSSignatureKey(this.config.secretKey, dateStamp, this.config.region, service); + const signature = await this.hmacSha256Hex(signingKey, stringToSign); + + console.log('AWS S3 signature debug:', { + method, + path, + bucket: this.config.bucket, + region: this.config.region, + canonicalRequest: canonicalRequest.substring(0, 100) + '...', + stringToSign: stringToSign.substring(0, 100) + '...', + signature: signature.substring(0, 16) + '...' + }); - // This is a simplified implementation - // In production, you should use AWS SDK or a complete V4 signature implementation const headers = { 'X-Amz-Date': isoDate, - 'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD', - 'Authorization': `AWS4-HMAC-SHA256 Credential=${this.config.secretId}/${dateStamp}/${this.config.region}/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=placeholder` + 'X-Amz-Content-Sha256': payloadHash, + 'Authorization': `${algorithm} Credential=${this.config.secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` }; return { headers }; @@ -403,13 +570,46 @@ export class CosService { * Generate signature for Cloudflare R2 (S3 compatible) */ private async generateCloudflareSignature(method: string, path: string, date: Date, contentType?: string): Promise<{ headers: Record }> { - // Similar to AWS S3 V4 signature + // Cloudflare R2 uses AWS S3 V4 signature with 'auto' region const isoDate = date.toISOString().replace(/[:\-]|\.\d{3}/g, ''); + const dateStamp = isoDate.substring(0, 8); + const service = 's3'; + const algorithm = 'AWS4-HMAC-SHA256'; + const region = 'auto'; // Cloudflare R2 uses 'auto' as region + + // Step 1: Create canonical request + const host = new URL(this.config.endpoint).host; + const canonicalUri = `/${path}`; + const canonicalQueryString = ''; + const canonicalHeaders = `host:${host}\nx-amz-content-sha256:UNSIGNED-PAYLOAD\nx-amz-date:${isoDate}\n`; + const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'; + const payloadHash = 'UNSIGNED-PAYLOAD'; + + const canonicalRequest = `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`; + + // Step 2: Create string to sign + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; + const canonicalRequestHash = await this.sha256Hash(canonicalRequest); + const stringToSign = `${algorithm}\n${isoDate}\n${credentialScope}\n${canonicalRequestHash}`; + + // Step 3: Calculate signature + const signingKey = await this.getAWSSignatureKey(this.config.secretKey, dateStamp, region, service); + const signature = await this.hmacSha256Hex(signingKey, stringToSign); + + console.log('Cloudflare R2 signature debug:', { + method, + path, + endpoint: this.config.endpoint, + host, + canonicalRequest: canonicalRequest.substring(0, 100) + '...', + stringToSign: stringToSign.substring(0, 100) + '...', + signature: signature.substring(0, 16) + '...' + }); const headers = { 'X-Amz-Date': isoDate, - 'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD', - 'Authorization': `AWS4-HMAC-SHA256 Credential=${this.config.secretId}/${isoDate.substring(0, 8)}/auto/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=placeholder` + 'X-Amz-Content-Sha256': payloadHash, + 'Authorization': `${algorithm} Credential=${this.config.secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` }; return { headers }; diff --git a/i18n-simple.ts b/i18n-simple.ts index e8920b4..825af85 100644 --- a/i18n-simple.ts +++ b/i18n-simple.ts @@ -49,7 +49,9 @@ const translations = { 'settings.clear.cache.button': '清空缓存', // COS settings - 'settings.cos.section.title': '图片上传设置', + 'settings.image.section.title': '图片处理', + 'settings.cos.provider.section.title': '云服务商设置', + 'settings.image.upload.section.title': '图片上传设置', 'settings.cos.provider.name': '云存储服务商', 'settings.cos.provider.desc': '选择要使用的云对象存储服务商', 'settings.cos.provider.aliyun': '阿里云 OSS', @@ -82,6 +84,10 @@ const translations = { 'settings.cos.upload.path.name': '上传路径模板', 'settings.cos.upload.path.desc': '支持占位符:{PATH}(当前文件路径)、{FILENAME}(文件名)、{FOLDER}(文件夹名)、{YYYY}(年)、{MM}(月)、{DD}(日)', 'settings.cos.upload.path.placeholder': '例如:images/{YYYY}/{MM}/{DD}', + 'settings.cos.clear.name': '清空当前配置', + 'settings.cos.clear.desc': '清空当前选中提供商的所有配置信息', + 'settings.cos.clear.button': '清空配置', + 'settings.cos.clear.success': '配置已清空', 'settings.cos.test.name': '测试COS配置', 'settings.cos.test.desc': '测试当前COS配置是否可用', 'settings.cos.test.button': '测试连接', @@ -243,7 +249,9 @@ const translations = { 'settings.clear.cache.button': 'Clear Cache', // COS settings - 'settings.cos.section.title': 'Image Upload Settings', + 'settings.image.section.title': 'Image Processing', + 'settings.cos.provider.section.title': 'Cloud Provider Settings', + 'settings.image.upload.section.title': 'Image Upload Settings', 'settings.cos.provider.name': 'Cloud Storage Provider', 'settings.cos.provider.desc': 'Select the cloud object storage service to use', 'settings.cos.provider.aliyun': 'Aliyun OSS', @@ -276,6 +284,10 @@ const translations = { 'settings.cos.upload.path.name': 'Upload Path Template', 'settings.cos.upload.path.desc': 'Supports placeholders: {PATH} (current file path), {FILENAME} (file name), {FOLDER} (folder name), {YYYY} (year), {MM} (month), {DD} (day)', 'settings.cos.upload.path.placeholder': 'e.g.: images/{YYYY}/{MM}/{DD}', + 'settings.cos.clear.name': 'Clear Current Configuration', + 'settings.cos.clear.desc': 'Clear all configuration for the currently selected provider', + 'settings.cos.clear.button': 'Clear Config', + 'settings.cos.clear.success': 'Configuration cleared', 'settings.cos.test.name': 'Test COS Configuration', 'settings.cos.test.desc': 'Test if current COS configuration is available', 'settings.cos.test.button': 'Test Connection', diff --git a/main.ts b/main.ts index 9fd221f..22fe091 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,5 @@ import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile, Menu, TFolder } from 'obsidian'; -import { GitSyncSettings, DEFAULT_SETTINGS, SyncResult, CosConfig, ImagePasteContext } from './types'; +import { GitSyncSettings, DEFAULT_SETTINGS, SyncResult, CosConfig, ImagePasteContext, CosProviderConfig } from './types'; import { GitHubService } from './github-service'; import { setLanguage, t, getSupportedLanguages, getActualLanguage } from './i18n-simple'; import { FileCacheService } from './file-cache'; @@ -697,20 +697,51 @@ export default class GitSyncPlugin extends Plugin { } } + /** + * Get current COS configuration + */ + getCurrentCosConfig(): CosProviderConfig { + return this.settings.cosConfigs[this.settings.cosProvider]; + } + + /** + * Update current COS configuration + */ + updateCurrentCosConfig(updates: Partial): void { + const currentConfig = this.getCurrentCosConfig(); + Object.assign(currentConfig, updates); + } + + /** + * Clear current COS configuration + */ + clearCurrentCosConfig(): void { + const emptyConfig: CosProviderConfig = { + secretId: '', + secretKey: '', + bucket: '', + endpoint: '', + cdnUrl: '', + region: '' + }; + this.settings.cosConfigs[this.settings.cosProvider] = emptyConfig; + } + /** * Check if COS is properly configured */ private isCosConfigured(): boolean { + const currentConfig = this.getCurrentCosConfig(); const requiredFields = [ - this.settings.cosSecretId, - this.settings.cosSecretKey, - this.settings.cosBucket, - this.settings.cosRegion // Region is required for all providers + currentConfig.secretId, + currentConfig.secretKey, + currentConfig.bucket, + currentConfig.region // Region is required for all providers ]; // Add provider-specific required fields if (this.settings.cosProvider === 'cloudflare') { - requiredFields.push(this.settings.cosEndpoint); // Endpoint only required for Cloudflare R2 + requiredFields.push(currentConfig.endpoint); // Endpoint only required for Cloudflare R2 } return requiredFields.every(field => field && field.trim().length > 0); @@ -743,14 +774,15 @@ export default class GitSyncPlugin extends Plugin { try { // Create COS configuration + const currentConfig = this.getCurrentCosConfig(); const cosConfig: CosConfig = { provider: this.settings.cosProvider, - secretId: this.settings.cosSecretId, - secretKey: this.settings.cosSecretKey, - bucket: this.settings.cosBucket, - endpoint: this.settings.cosEndpoint, - cdnUrl: this.settings.cosCdnUrl, - region: this.settings.cosRegion + secretId: currentConfig.secretId, + secretKey: currentConfig.secretKey, + bucket: currentConfig.bucket, + endpoint: currentConfig.endpoint, + cdnUrl: currentConfig.cdnUrl, + region: currentConfig.region }; // Upload to COS @@ -921,12 +953,18 @@ class GitSyncSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - // COS settings section + // Image processing main section containerEl.createEl('h2', { - text: t('settings.cos.section.title'), + text: t('settings.image.section.title'), cls: 'git-sync-section-title' }); + // Cloud provider settings subsection + containerEl.createEl('h3', { + text: t('settings.cos.provider.section.title'), + cls: 'git-sync-subsection-title' + }); + // Cloud storage provider new Setting(containerEl) .setName(t('settings.cos.provider.name')) @@ -941,20 +979,23 @@ class GitSyncSettingTab extends PluginSettingTab { dropdown.onChange(async (value) => { this.plugin.settings.cosProvider = value as 'aliyun' | 'tencent' | 'aws' | 'cloudflare'; await this.plugin.saveSettings(); - // Re-render settings to show/hide region field + // Re-render settings to show/hide region field and load saved config this.display(); }); }); + // Get current config for the selected provider + const currentConfig = this.plugin.getCurrentCosConfig(); + // Secret ID new Setting(containerEl) .setName(t('settings.cos.secret.id.name') + ' *') .setDesc(t('settings.cos.secret.id.desc')) .addText(text => text .setPlaceholder(t('settings.cos.secret.id.placeholder')) - .setValue(this.plugin.settings.cosSecretId) + .setValue(currentConfig.secretId) .onChange(async (value) => { - this.plugin.settings.cosSecretId = value; + this.plugin.updateCurrentCosConfig({ secretId: value }); await this.plugin.saveSettings(); })); @@ -964,9 +1005,9 @@ class GitSyncSettingTab extends PluginSettingTab { .setDesc(t('settings.cos.secret.key.desc')) .addText(text => text .setPlaceholder(t('settings.cos.secret.key.placeholder')) - .setValue(this.plugin.settings.cosSecretKey) + .setValue(currentConfig.secretKey) .onChange(async (value) => { - this.plugin.settings.cosSecretKey = value; + this.plugin.updateCurrentCosConfig({ secretKey: value }); await this.plugin.saveSettings(); })); @@ -976,9 +1017,9 @@ class GitSyncSettingTab extends PluginSettingTab { .setDesc(t('settings.cos.bucket.desc')) .addText(text => text .setPlaceholder(t('settings.cos.bucket.placeholder')) - .setValue(this.plugin.settings.cosBucket) + .setValue(currentConfig.bucket) .onChange(async (value) => { - this.plugin.settings.cosBucket = value; + this.plugin.updateCurrentCosConfig({ bucket: value }); await this.plugin.saveSettings(); })); @@ -988,9 +1029,9 @@ class GitSyncSettingTab extends PluginSettingTab { .setDesc(t('settings.cos.region.desc')) .addText(text => text .setPlaceholder(t('settings.cos.region.placeholder')) - .setValue(this.plugin.settings.cosRegion) + .setValue(currentConfig.region) .onChange(async (value) => { - this.plugin.settings.cosRegion = value; + this.plugin.updateCurrentCosConfig({ region: value }); await this.plugin.saveSettings(); })); @@ -1001,9 +1042,9 @@ class GitSyncSettingTab extends PluginSettingTab { .setDesc(t('settings.cos.endpoint.desc')) .addText(text => text .setPlaceholder(t('settings.cos.endpoint.placeholder')) - .setValue(this.plugin.settings.cosEndpoint) + .setValue(currentConfig.endpoint) .onChange(async (value) => { - this.plugin.settings.cosEndpoint = value; + this.plugin.updateCurrentCosConfig({ endpoint: value }); await this.plugin.saveSettings(); })); } @@ -1014,12 +1055,90 @@ class GitSyncSettingTab extends PluginSettingTab { .setDesc(t('settings.cos.cdn.desc')) .addText(text => text .setPlaceholder(t('settings.cos.cdn.placeholder')) - .setValue(this.plugin.settings.cosCdnUrl) + .setValue(currentConfig.cdnUrl) .onChange(async (value) => { - this.plugin.settings.cosCdnUrl = value; + this.plugin.updateCurrentCosConfig({ cdnUrl: value }); await this.plugin.saveSettings(); })); + // Clear current configuration button + new Setting(containerEl) + .setName(t('settings.cos.clear.name')) + .setDesc(t('settings.cos.clear.desc')) + .addButton(button => button + .setButtonText(t('settings.cos.clear.button')) + .setWarning() + .onClick(async () => { + this.plugin.clearCurrentCosConfig(); + await this.plugin.saveSettings(); + new Notice(t('settings.cos.clear.success')); + // Re-render to show cleared fields + this.display(); + })); + + // Test COS configuration + new Setting(containerEl) + .setName(t('settings.cos.test.name')) + .setDesc(t('settings.cos.test.desc')) + .addButton(button => button + .setButtonText(t('settings.cos.test.button')) + .onClick(async () => { + // Validate required fields based on provider + const requiredFields = [ + currentConfig.secretId, + currentConfig.secretKey, + currentConfig.bucket, + currentConfig.region // Region is required for all providers + ]; + + // Add provider-specific required fields + if (this.plugin.settings.cosProvider === 'cloudflare') { + requiredFields.push(currentConfig.endpoint); // Endpoint only required for Cloudflare R2 + } + + if (requiredFields.some(field => !field.trim())) { + new Notice(t('cos.error.config.invalid')); + return; + } + + // Set loading state + button.setButtonText(t('settings.cos.test.loading')); + button.setDisabled(true); + + try { + const cosConfig = { + provider: this.plugin.settings.cosProvider, + secretId: currentConfig.secretId, + secretKey: currentConfig.secretKey, + bucket: currentConfig.bucket, + endpoint: currentConfig.endpoint, + cdnUrl: currentConfig.cdnUrl, + region: currentConfig.region + }; + + const cosService = new (await import('./cos-service')).CosService(cosConfig); + const result = await cosService.testConnection(); + + if (result.success) { + new Notice(t('cos.test.success')); + } else { + new Notice(t('cos.test.failed', { error: result.message })); + } + } catch (error) { + console.error('COS test error:', error); + new Notice(t('cos.test.failed', { error: error.message })); + } finally { + button.setButtonText(t('settings.cos.test.button')); + button.setDisabled(false); + } + })); + + // Image upload settings subsection + containerEl.createEl('h3', { + text: t('settings.image.upload.section.title'), + cls: 'git-sync-subsection-title' + }); + // Keep images locally new Setting(containerEl) .setName(t('settings.cos.keep.local.name')) @@ -1059,75 +1178,7 @@ class GitSyncSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - // Test COS configuration - new Setting(containerEl) - .setName(t('settings.cos.test.name')) - .setDesc(t('settings.cos.test.desc')) - .addButton(button => button - .setButtonText(t('settings.cos.test.button')) - .onClick(async () => { - // Validate required fields based on provider - const requiredFields = [ - this.plugin.settings.cosSecretId, - this.plugin.settings.cosSecretKey, - this.plugin.settings.cosBucket, - this.plugin.settings.cosRegion // Region is required for all providers - ]; - // Add provider-specific required fields - if (this.plugin.settings.cosProvider === 'cloudflare') { - requiredFields.push(this.plugin.settings.cosEndpoint); // Endpoint only required for Cloudflare R2 - } - - if (requiredFields.some(field => !field.trim())) { - new Notice(t('cos.error.config.invalid')); - return; - } - - // Set loading state - button.setButtonText(t('settings.cos.test.loading')); - button.setDisabled(true); - - try { - const cosConfig = { - provider: this.plugin.settings.cosProvider, - secretId: this.plugin.settings.cosSecretId, - secretKey: this.plugin.settings.cosSecretKey, - bucket: this.plugin.settings.cosBucket, - endpoint: this.plugin.settings.cosEndpoint, - cdnUrl: this.plugin.settings.cosCdnUrl, - region: this.plugin.settings.cosRegion - }; - - const cosService = new (await import('./cos-service')).CosService(cosConfig); - const result = await cosService.testConnection(); - - if (result.success) { - new Notice(t('cos.test.success')); - } else { - new Notice(t('cos.test.failed', { error: result.message })); - } - } catch (error) { - console.error('COS test error:', error); - new Notice(t('cos.test.failed', { error: error.message })); - } finally { - button.setButtonText(t('settings.cos.test.button')); - button.setDisabled(false); - } - })); - - // Save settings button - put below basic settings area - const saveButtonContainer = containerEl.createEl('div', { - cls: 'git-sync-save-button-container' - }); - const saveButton = saveButtonContainer.createEl('button', { - cls: 'mod-cta', - text: t('settings.save.button') - }); - saveButton.addEventListener('click', async () => { - await this.plugin.saveSettings(); - new Notice(t('notice.settings.saved')); - }); // Danger zone title containerEl.createEl('h2', { diff --git a/styles.css b/styles.css index 81b196f..600c5e7 100644 --- a/styles.css +++ b/styles.css @@ -323,3 +323,14 @@ .sponsor { color: var(--color-orange); } + +/* Subsection titles styling */ +.git-sync-subsection-title { + color: var(--text-normal); + font-size: var(--font-ui-medium); + font-weight: var(--font-weight-medium); + margin-top: 24px; + margin-bottom: 12px; + padding-left: 8px; + border-left: 3px solid var(--text-accent); +} diff --git a/types.ts b/types.ts index b299575..fd44b0f 100644 --- a/types.ts +++ b/types.ts @@ -1,3 +1,12 @@ +export interface CosProviderConfig { + secretId: string; + secretKey: string; + bucket: string; + endpoint: string; + cdnUrl: string; + region: string; +} + export interface GitSyncSettings { githubToken: string; repositoryUrl: string; // Format: @https://github.com/user/repo/path/to/folder @@ -6,13 +15,13 @@ export interface GitSyncSettings { language: 'zh' | 'en' | 'auto'; // Language setting, auto means follow system // COS (Cloud Object Storage) settings - cosProvider: 'aliyun' | 'tencent' | 'aws' | 'cloudflare'; // Cloud service provider - cosSecretId: string; // Access Key ID / Secret ID - cosSecretKey: string; // Access Key Secret / Secret Key - cosBucket: string; // Bucket name - cosEndpoint: string; // Service endpoint - cosCdnUrl: string; // CDN domain for accessing uploaded files - cosRegion: string; // Region (for AWS/Cloudflare) + cosProvider: 'aliyun' | 'tencent' | 'aws' | 'cloudflare'; // Current selected provider + cosConfigs: { + aliyun: CosProviderConfig; + tencent: CosProviderConfig; + aws: CosProviderConfig; + cloudflare: CosProviderConfig; + }; // Local image storage settings keepLocalImages: boolean; // Whether to keep images locally after upload @@ -31,12 +40,40 @@ export const DEFAULT_SETTINGS: GitSyncSettings = { // COS default settings cosProvider: 'aliyun', - cosSecretId: '', - cosSecretKey: '', - cosBucket: '', - cosEndpoint: '', - cosCdnUrl: '', - cosRegion: '', + cosConfigs: { + aliyun: { + secretId: '', + secretKey: '', + bucket: '', + endpoint: '', + cdnUrl: '', + region: '' + }, + tencent: { + secretId: '', + secretKey: '', + bucket: '', + endpoint: '', + cdnUrl: '', + region: '' + }, + aws: { + secretId: '', + secretKey: '', + bucket: '', + endpoint: '', + cdnUrl: '', + region: '' + }, + cloudflare: { + secretId: '', + secretKey: '', + bucket: '', + endpoint: '', + cdnUrl: '', + region: '' + } + }, // Local image storage defaults keepLocalImages: false,