feat: add gdrive backup support and update backup logic
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/appDb'
|
||||
|
||||
// GET /api/backups/gdrive/callback?code=xxx&state=db_id
|
||||
// Google bu endpoint'e yönlendirir, code'u refresh_token ile değiştirip DB'ye kaydeder
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = req.nextUrl
|
||||
const code = searchParams.get('code')
|
||||
const db_id = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? req.nextUrl.protocol.replace(':', '')
|
||||
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? req.nextUrl.host
|
||||
const redirectUri = `${proto}://${host}/api/backups/gdrive/callback`
|
||||
const dashboardUrl = `${proto}://${host}/dashboard/databases`
|
||||
|
||||
if (error) {
|
||||
return NextResponse.redirect(`${dashboardUrl}?gdrive_error=${encodeURIComponent(error)}`)
|
||||
}
|
||||
|
||||
if (!code || !db_id) {
|
||||
return NextResponse.redirect(`${dashboardUrl}?gdrive_error=missing_params`)
|
||||
}
|
||||
|
||||
// DB'den kayıtlı client_id + client_secret'i al
|
||||
const res = await pool.query(`SELECT credentials FROM backup_configs WHERE db_id = $1 LIMIT 1`, [db_id])
|
||||
if (!res.rows[0]) {
|
||||
return NextResponse.redirect(`${dashboardUrl}?gdrive_error=config_not_found`)
|
||||
}
|
||||
|
||||
let client_id: string, client_secret: string
|
||||
try {
|
||||
const creds = JSON.parse(res.rows[0].credentials)
|
||||
client_id = creds.client_id
|
||||
client_secret = creds.client_secret
|
||||
} catch {
|
||||
return NextResponse.redirect(`${dashboardUrl}?gdrive_error=invalid_credentials`)
|
||||
}
|
||||
|
||||
// Code → refresh_token
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const err = await tokenRes.text()
|
||||
return NextResponse.redirect(`${dashboardUrl}?gdrive_error=${encodeURIComponent(err.slice(0, 100))}`)
|
||||
}
|
||||
|
||||
const tokens = await tokenRes.json()
|
||||
if (!tokens.refresh_token) {
|
||||
return NextResponse.redirect(`${dashboardUrl}?gdrive_error=no_refresh_token`)
|
||||
}
|
||||
|
||||
// refresh_token'ı credentials'a ekle
|
||||
const newCredentials = JSON.stringify({ client_id, client_secret, refresh_token: tokens.refresh_token })
|
||||
await pool.query(`UPDATE backup_configs SET credentials = $1 WHERE db_id = $2`, [newCredentials, db_id])
|
||||
|
||||
return NextResponse.redirect(`${dashboardUrl}?gdrive_connected=${db_id}`)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth'
|
||||
import pool from '@/lib/appDb'
|
||||
|
||||
// GET /api/backups/gdrive/connect?db_id=xxx
|
||||
// Reads saved client_id from backup_configs, redirects to Google OAuth consent screen
|
||||
export async function GET(req: NextRequest) {
|
||||
const authErr = await requireAuth(req)
|
||||
if (authErr) return authErr
|
||||
|
||||
const db_id = req.nextUrl.searchParams.get('db_id')
|
||||
if (!db_id) return NextResponse.json({ error: 'db_id gerekli' }, { status: 400 })
|
||||
|
||||
const res = await pool.query(`SELECT credentials FROM backup_configs WHERE db_id = $1 LIMIT 1`, [db_id])
|
||||
if (!res.rows[0]) return NextResponse.json({ error: 'Önce config kaydedin.' }, { status: 404 })
|
||||
|
||||
let client_id: string
|
||||
try {
|
||||
client_id = JSON.parse(res.rows[0].credentials).client_id
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Credentials JSON geçersiz.' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!client_id) return NextResponse.json({ error: 'client_id bulunamadı.' }, { status: 400 })
|
||||
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? req.nextUrl.protocol.replace(':', '')
|
||||
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? req.nextUrl.host
|
||||
const redirectUri = `${proto}://${host}/api/backups/gdrive/callback`
|
||||
|
||||
const oauthUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({
|
||||
client_id,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'https://www.googleapis.com/auth/drive.file',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state: db_id,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(oauthUrl)
|
||||
}
|
||||
@@ -1,23 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth'
|
||||
import { readConfig } from '@/lib/config'
|
||||
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDrive, backupFilename, testGoogleDriveCredentials } from '@/lib/backup'
|
||||
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from '@/lib/backup'
|
||||
import pool from '@/lib/appDb'
|
||||
|
||||
// POST /api/backups/test
|
||||
// { db_id, cloud_type, credentials, gdrive_folder_id? }
|
||||
// → Gerçek yedek alır, buluta yükler, loglar
|
||||
export async function POST(req: NextRequest) {
|
||||
const authErr = await requireAuth(req)
|
||||
if (authErr) return authErr
|
||||
|
||||
const { db_id, cloud_type, credentials, gdrive_folder_id } = await req.json()
|
||||
|
||||
if (!credentials?.trim()) {
|
||||
return NextResponse.json({ ok: false, error: 'Credentials boş olamaz.' })
|
||||
}
|
||||
if (!credentials?.trim()) return NextResponse.json({ ok: false, error: 'Credentials boş olamaz.' })
|
||||
|
||||
// DB bilgisini al
|
||||
const config = await readConfig()
|
||||
const db = config.databases.find(d => d.id === db_id)
|
||||
if (!db) return NextResponse.json({ ok: false, error: 'Veritabanı bulunamadı.' })
|
||||
@@ -25,22 +19,21 @@ export async function POST(req: NextRequest) {
|
||||
const filename = backupFilename(db.name)
|
||||
|
||||
try {
|
||||
// 1. Dump al
|
||||
const buffer = await createDbDumpBuffer(db)
|
||||
|
||||
// 2. Buluta yükle
|
||||
if (cloud_type === 'dropbox') {
|
||||
await uploadToDropbox(credentials.trim(), db.name, filename, buffer)
|
||||
} else if (cloud_type === 'gcs') {
|
||||
await uploadToGCS(credentials, gdrive_folder_id, db.name, filename, buffer)
|
||||
} else if (cloud_type === 'gdrive') {
|
||||
await uploadToGoogleDrive(credentials, db.name, filename, buffer, gdrive_folder_id || undefined)
|
||||
await uploadToGoogleDriveOAuth(credentials, filename, buffer, gdrive_folder_id || undefined)
|
||||
} else {
|
||||
return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' })
|
||||
}
|
||||
|
||||
// 3. Logla
|
||||
await pool.query(
|
||||
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
|
||||
[db_id, 'success', `Manuel test yedek — ${cloud_type}`, buffer.length]
|
||||
[db_id, 'success', `Manuel yedek — ${cloud_type}`, buffer.length]
|
||||
)
|
||||
|
||||
const sizeStr = buffer.length > 1024 * 1024
|
||||
@@ -49,14 +42,13 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
detail: `✓ Yedek alındı ve yüklendi — ${db.name}/${filename} (${sizeStr})`,
|
||||
detail: `✓ Yedek yüklendi — ${db.name}/${filename} (${sizeStr})`,
|
||||
})
|
||||
} catch (e: any) {
|
||||
await pool.query(
|
||||
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
|
||||
[db_id, 'error', e.message, 0]
|
||||
).catch(() => {})
|
||||
|
||||
return NextResponse.json({ ok: false, error: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,10 +432,6 @@ const CRON_PRESETS = [
|
||||
{ label: 'Her Pazartesi', value: '0 0 * * 1' },
|
||||
]
|
||||
|
||||
function extractGdriveEmail(json: string): string | null {
|
||||
try { return JSON.parse(json)?.client_email ?? null } catch { return null }
|
||||
}
|
||||
|
||||
function BackupTab({ db }: { db: Db }) {
|
||||
const [config, setConfig] = useState<any>(null)
|
||||
const [logs, setLogs] = useState<any[]>([])
|
||||
@@ -443,8 +439,9 @@ function BackupTab({ db }: { db: Db }) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; detail?: string; error?: string } | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'gdrive', credentials: '', gdrive_folder_id: '' })
|
||||
const [copiedUri, setCopiedUri] = useState(false)
|
||||
const [redirectUri, setRedirectUri] = useState('')
|
||||
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'gcs', credentials: '', gdrive_folder_id: '' })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -453,37 +450,56 @@ function BackupTab({ db }: { db: Db }) {
|
||||
const data = await r.json()
|
||||
setConfig(data.config)
|
||||
setLogs(data.logs)
|
||||
if (data.config) {
|
||||
setF({ schedule: data.config.schedule, cloud_type: data.config.cloud_type, credentials: data.config.credentials, gdrive_folder_id: data.config.gdrive_folder_id || '' })
|
||||
}
|
||||
if (data.config) setF({ schedule: data.config.schedule, cloud_type: data.config.cloud_type, credentials: data.config.credentials, gdrive_folder_id: data.config.gdrive_folder_id || '' })
|
||||
}
|
||||
setLoading(false)
|
||||
}, [db.id])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => {
|
||||
load()
|
||||
setRedirectUri(window.location.origin + '/api/backups/gdrive/callback')
|
||||
// URL param kontrol — Google OAuth callback sonrası
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('gdrive_connected') === db.id) {
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
load()
|
||||
} else if (params.get('gdrive_error')) {
|
||||
setTestResult({ ok: false, error: `Google bağlantı hatası: ${params.get('gdrive_error')}` })
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
}, [load, db.id])
|
||||
|
||||
// gdrive credentials yardımcıları
|
||||
const gdriveFields = (() => {
|
||||
try { const c = JSON.parse(f.credentials); return { client_id: c.client_id || '', client_secret: c.client_secret || '', connected: !!c.refresh_token } }
|
||||
catch { return { client_id: '', client_secret: '', connected: false } }
|
||||
})()
|
||||
|
||||
const updateGdriveField = (key: string, val: string) => {
|
||||
try { const c = JSON.parse(f.credentials); setF(p => ({ ...p, credentials: JSON.stringify({ ...c, [key]: val }) })) }
|
||||
catch { setF(p => ({ ...p, credentials: JSON.stringify({ [key]: val }) })) }
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true)
|
||||
await fetch('/api/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ db_id: db.id, ...f }),
|
||||
})
|
||||
await fetch('/api/backups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id, ...f }) })
|
||||
await load()
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const testCredentials = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
const r = await fetch('/api/backups/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ db_id: db.id, cloud_type: f.cloud_type, credentials: f.credentials, gdrive_folder_id: f.gdrive_folder_id }),
|
||||
})
|
||||
const connectGDrive = async () => {
|
||||
setSaving(true)
|
||||
await fetch('/api/backups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id, ...f }) })
|
||||
setSaving(false)
|
||||
window.location.href = `/api/backups/gdrive/connect?db_id=${db.id}`
|
||||
}
|
||||
|
||||
const testNow = async () => {
|
||||
setTesting(true); setTestResult(null)
|
||||
const r = await fetch('/api/backups/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id, cloud_type: f.cloud_type, credentials: f.credentials, gdrive_folder_id: f.gdrive_folder_id }) })
|
||||
const result = await r.json()
|
||||
setTestResult(result)
|
||||
if (result.ok) await load() // log listesini güncelle
|
||||
if (result.ok) await load()
|
||||
setTesting(false)
|
||||
}
|
||||
|
||||
@@ -491,17 +507,14 @@ function BackupTab({ db }: { db: Db }) {
|
||||
if (!confirm('Otomatik yedeklemeyi kapat?')) return
|
||||
await fetch('/api/backups', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id }) })
|
||||
setConfig(null)
|
||||
setF({ schedule: '0 0 * * *', cloud_type: 'gdrive', credentials: '', gdrive_folder_id: '' })
|
||||
}
|
||||
|
||||
const serviceEmail = f.cloud_type === 'gdrive' ? extractGdriveEmail(f.credentials) : null
|
||||
|
||||
const copyEmail = () => {
|
||||
if (serviceEmail) { navigator.clipboard.writeText(serviceEmail); setCopied(true); setTimeout(() => setCopied(false), 2000) }
|
||||
setF({ schedule: '0 0 * * *', cloud_type: 'gcs', credentials: '', gdrive_folder_id: '' })
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-muted text-sm font-mono animate-pulse">Yükleniyor...</div>
|
||||
|
||||
const isGdrive = f.cloud_type === 'gdrive'
|
||||
const credentialsReady = isGdrive ? gdriveFields.connected : !!f.credentials.trim()
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 items-start fade-up">
|
||||
<div className="flex-1 space-y-4">
|
||||
@@ -513,17 +526,12 @@ function BackupTab({ db }: { db: Db }) {
|
||||
<div className="text-sm font-semibold mb-0.5">Manuel Yedek Al</div>
|
||||
<div className="text-[11px] text-muted font-mono">SQL dump olarak indir</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/db/backup?dbId=${db.id}`}
|
||||
download
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-md text-xs font-medium border border-border bg-transparent hover:bg-surface-2 text-text h-8 px-3 transition-colors"
|
||||
>
|
||||
<a href={`/api/db/backup?dbId=${db.id}`} download className="inline-flex items-center gap-1.5 rounded-md text-xs font-medium border border-border bg-transparent hover:bg-surface-2 text-text h-8 px-3 transition-colors">
|
||||
<Download className="w-3.5 h-3.5" /> İndir
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cloud ayarları */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">Otomatik Yedekleme</CardTitle>
|
||||
@@ -531,151 +539,153 @@ function BackupTab({ db }: { db: Db }) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
|
||||
{/* Provider */}
|
||||
{/* Provider seçimi */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Depolama</label>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ id: 'gdrive', label: '📁 Google Drive' },
|
||||
{ id: 'dropbox', label: '📦 Dropbox' },
|
||||
].map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => { setF(prev => ({ ...prev, cloud_type: p.id })); setTestResult(null) }}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-all ${
|
||||
f.cloud_type === p.id
|
||||
? 'bg-accent/10 border-accent/40 text-accent'
|
||||
: 'border-border/50 text-muted hover:text-text hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{[{ id: 'gcs', label: '☁️ Google Cloud Storage' }, { id: 'dropbox', label: '📦 Dropbox' }, { id: 'gdrive', label: '📁 Google Drive' }].map(p => (
|
||||
<button key={p.id} onClick={() => { setF(prev => ({ ...prev, cloud_type: p.id, credentials: '', gdrive_folder_id: '' })); setTestResult(null) }}
|
||||
className={`flex-1 py-2 rounded-lg border text-xs font-medium transition-all ${f.cloud_type === p.id ? 'bg-accent/10 border-accent/40 text-accent' : 'border-border/50 text-muted hover:text-text hover:border-border'}`}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Drive kurulum rehberi */}
|
||||
{f.cloud_type === 'gdrive' && (
|
||||
<div className="rounded-xl border border-border/50 bg-black/20 p-4 space-y-3">
|
||||
<div className="text-[10px] font-mono text-muted uppercase tracking-wider">Google Drive Kurulumu</div>
|
||||
{[
|
||||
{/* ── GCS ─────────────────────────────────────────────────────── */}
|
||||
{f.cloud_type === 'gcs' && (
|
||||
<>
|
||||
<StepGuide steps={[
|
||||
{ n: 1, text: 'console.cloud.google.com → Proje seç/oluştur' },
|
||||
{ n: 2, text: '"APIs & Services" → "Enable APIs" → "Google Drive API" aç' },
|
||||
{ n: 3, text: '"Credentials" → "Service Accounts" → Yeni hesap oluştur → JSON key indir' },
|
||||
{ n: 4, text: 'Google Drive\'da bir klasör oluştur (örn. "VPS Backups")' },
|
||||
{ n: 5, text: 'Klasörü sağ tıkla → "Paylaş" → aşağıdaki service account e-postasını "Düzenleyici" olarak ekle', highlight: true },
|
||||
{ n: 6, text: 'Klasörü aç → URL\'deki /folders/XXXXX kısmını Klasör ID alanına yapıştır', highlight: true },
|
||||
].map(s => (
|
||||
<div key={s.n} className={`flex gap-3 text-[11px] font-mono leading-relaxed ${(s as any).highlight ? 'bg-yellow-500/5 border border-yellow-500/20 rounded-lg px-2 py-1.5 -mx-2' : ''}`}>
|
||||
<span className={`w-5 h-5 rounded-full border flex items-center justify-center text-[10px] shrink-0 mt-0.5 ${(s as any).highlight ? 'bg-yellow-500/20 border-yellow-500/40 text-yellow-400' : 'bg-accent/10 border-accent/20 text-accent'}`}>{s.n}</span>
|
||||
<span className={(s as any).highlight ? 'text-yellow-200/80' : 'text-muted/80'}>{s.text}</span>
|
||||
{ n: 2, text: '"APIs & Services" → "Cloud Storage API" → Enable' },
|
||||
{ n: 3, text: '"Cloud Storage" → "Buckets" → Yeni bucket oluştur → adını not et' },
|
||||
{ n: 4, text: '"IAM & Admin" → "Service Accounts" → Hesap oluştur → JSON key indir', hi: true },
|
||||
{ n: 5, text: 'Bucket → "Permissions" → "Grant Access" → service account e-postası → "Storage Object Admin"', hi: true },
|
||||
{ n: 6, text: 'Bucket adını aşağıya gir, JSON\'ı yapıştır → Şimdi Yedekle' },
|
||||
]} />
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bucket Adı</label>
|
||||
<Input value={f.gdrive_folder_id} onChange={e => setF(p => ({ ...p, gdrive_folder_id: e.target.value }))} placeholder="vps-backups" />
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Service Account JSON</label>
|
||||
<textarea className="flex w-full rounded-md border border-border bg-black/30 px-3 py-2 text-xs font-mono resize-y min-h-[100px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" value={f.credentials} onChange={e => { setF(p => ({ ...p, credentials: e.target.value })); setTestResult(null) }} placeholder={'{\n "type": "service_account",\n "client_email": "xxx@project.iam.gserviceaccount.com",\n ...\n}'} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Dropbox ──────────────────────────────────────────────────── */}
|
||||
{f.cloud_type === 'dropbox' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Access Token</label>
|
||||
<Input value={f.credentials} onChange={e => { setF(p => ({ ...p, credentials: e.target.value })); setTestResult(null) }} placeholder="sl.xxxxxxxxxxxxxxxx..." />
|
||||
<div className="text-[10px] text-muted/60 font-mono">dropbox.com/developers → Apps → Create app → Generate token</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credentials */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">
|
||||
{f.cloud_type === 'gdrive' ? 'Service Account JSON' : 'Dropbox Access Token'}
|
||||
</label>
|
||||
<textarea
|
||||
className="flex w-full rounded-md border border-border bg-black/30 px-3 py-2 text-xs shadow-sm transition-colors placeholder:text-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent font-mono resize-y min-h-[120px]"
|
||||
value={f.credentials}
|
||||
onChange={e => { setF(p => ({ ...p, credentials: e.target.value })); setTestResult(null) }}
|
||||
placeholder={f.cloud_type === 'gdrive'
|
||||
? '{\n "type": "service_account",\n "project_id": "...",\n "private_key": "-----BEGIN RSA PRIVATE KEY-----\\n...",\n "client_email": "xxx@project.iam.gserviceaccount.com",\n ...\n}'
|
||||
: 'sl.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Extracted email banner */}
|
||||
{serviceEmail && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-accent/20 bg-accent/5 px-4 py-3">
|
||||
{/* ── Google Drive OAuth ───────────────────────────────────────── */}
|
||||
{isGdrive && (
|
||||
<>
|
||||
{gdriveFields.connected ? (
|
||||
/* Bağlı durumu */
|
||||
<div className="flex items-center justify-between rounded-xl border border-success/30 bg-success/5 px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||
<div>
|
||||
<div className="text-[10px] text-muted font-mono uppercase tracking-wider mb-1">Bu e-postayı Drive klasörünüzle paylaşın</div>
|
||||
<div className="text-sm font-mono text-accent">{serviceEmail}</div>
|
||||
<div className="text-sm font-semibold text-success">Google hesabı bağlı</div>
|
||||
<div className="text-[10px] text-muted font-mono mt-0.5">OAuth refresh token aktif</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyEmail}
|
||||
className="p-1.5 rounded text-muted hover:text-accent transition-colors shrink-0"
|
||||
title="Kopyala"
|
||||
>
|
||||
{copied ? <CheckCircle2 className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
||||
</div>
|
||||
<button onClick={connectGDrive} className="text-[10px] font-mono text-muted hover:text-text border border-border rounded px-2 py-1 transition-colors">
|
||||
Yeniden Bağla
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Bağlantı kurulum formu */
|
||||
<>
|
||||
<StepGuide steps={[
|
||||
{ n: 1, text: 'console.cloud.google.com → Proje seç/oluştur' },
|
||||
{ n: 2, text: '"APIs & Services" → "Enable APIs" → "Google Drive API" aç' },
|
||||
{ n: 3, text: '"Credentials" → "Create Credentials" → "OAuth 2.0 Client IDs" → Web application seç' },
|
||||
{ n: 4, text: '"Authorized redirect URIs" → aşağıdaki URL\'yi ekle', hi: true },
|
||||
{ n: 5, text: 'Client ID ve Client Secret\'i kopyala → aşağıya yapıştır', hi: true },
|
||||
{ n: 6, text: '"Google ile Bağlan" a tıkla → izin ver → otomatik geri dön' },
|
||||
]} />
|
||||
|
||||
{/* Redirect URI göster */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Authorized Redirect URI (Google Console'a ekle)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md border border-accent/30 bg-accent/5 px-3 py-2 text-xs font-mono text-accent truncate">{redirectUri}</div>
|
||||
<button onClick={() => { navigator.clipboard.writeText(redirectUri); setCopiedUri(true); setTimeout(() => setCopiedUri(false), 2000) }}
|
||||
className="p-2 rounded border border-border hover:bg-surface-2 text-muted hover:text-text transition-colors shrink-0">
|
||||
{copiedUri ? <CheckCircle2 className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client ID</label>
|
||||
<Input value={gdriveFields.client_id} onChange={e => updateGdriveField('client_id', e.target.value)} placeholder="xxxxxxx.apps.googleusercontent.com" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client Secret</label>
|
||||
<Input type="password" value={gdriveFields.client_secret} onChange={e => updateGdriveField('client_secret', e.target.value)} placeholder="GOCSPX-..." />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Klasör ID */}
|
||||
{f.cloud_type === 'gdrive' && (
|
||||
{/* Klasör ID — her zaman göster */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Klasör ID</label>
|
||||
<Input
|
||||
value={f.gdrive_folder_id}
|
||||
onChange={e => { setF(p => ({ ...p, gdrive_folder_id: e.target.value })); setTestResult(null) }}
|
||||
placeholder="1A2b3C4d5E6f7G8h9I0jKlMnOpQrStUvWx"
|
||||
/>
|
||||
<div className="text-[10px] text-muted/60 font-mono">
|
||||
Drive klasörünü açınca URL'deki <span className="text-muted">/folders/</span> sonrasındaki kısım
|
||||
</div>
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Klasör ID (Opsiyonel)</label>
|
||||
<Input value={f.gdrive_folder_id} onChange={e => setF(p => ({ ...p, gdrive_folder_id: e.target.value }))} placeholder="1A2b3C4d5E6f7G8h9I0j..." />
|
||||
<div className="text-[10px] text-muted/60 font-mono">Belirtilmezse My Drive köküne yükler. Klasör URL'sindeki /folders/ID kısmı.</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Test sonucu */}
|
||||
{testResult && (
|
||||
<div className={`flex items-start gap-2.5 rounded-lg border px-4 py-3 text-xs font-mono ${
|
||||
testResult.ok
|
||||
? 'border-success/30 bg-success/5 text-success'
|
||||
: 'border-destructive/30 bg-destructive/5 text-destructive'
|
||||
}`}>
|
||||
{testResult.ok
|
||||
? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
: <ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />}
|
||||
<div className={`flex items-start gap-2.5 rounded-lg border px-4 py-3 text-xs font-mono ${testResult.ok ? 'border-success/30 bg-success/5 text-success' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
|
||||
{testResult.ok ? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" /> : <ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />}
|
||||
<span>{testResult.ok ? testResult.detail : testResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule */}
|
||||
{/* Zamanlama */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Zamanlama</label>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{CRON_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setF(prev => ({ ...prev, schedule: p.value }))}
|
||||
className={`text-[10px] font-mono px-2.5 py-1 rounded-md border transition-all ${
|
||||
f.schedule === p.value
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-border/50 text-muted hover:text-text hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<button key={p.value} onClick={() => setF(prev => ({ ...prev, schedule: p.value }))}
|
||||
className={`text-[10px] font-mono px-2.5 py-1 rounded-md border transition-all ${f.schedule === p.value ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border/50 text-muted hover:text-text hover:border-border'}`}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" className="font-mono text-sm" />
|
||||
<div className="text-[10px] text-muted/60 font-mono">cron format: dakika saat gün ay haftaGünü</div>
|
||||
</div>
|
||||
|
||||
{/* Butonlar */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={testCredentials}
|
||||
disabled={testing || !f.credentials}
|
||||
className="gap-1.5"
|
||||
title="Şimdi yedek al ve buluta yükle"
|
||||
>
|
||||
{testing
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <FlaskConical className="w-3.5 h-3.5" />}
|
||||
{isGdrive && !gdriveFields.connected ? (
|
||||
<Button onClick={connectGDrive} disabled={saving || !gdriveFields.client_id || !gdriveFields.client_secret} className="flex-1 gap-1.5">
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
|
||||
{saving ? 'Kaydediliyor...' : 'Google ile Bağlan →'}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={testNow} disabled={testing || !credentialsReady} className="gap-1.5" title="Şimdi yedek al ve yükle">
|
||||
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
|
||||
{testing ? 'Yedekleniyor...' : 'Şimdi Yedekle'}
|
||||
</Button>
|
||||
<Button onClick={saveConfig} disabled={saving || !f.credentials} className="flex-1">
|
||||
<Button onClick={saveConfig} disabled={saving || !credentialsReady} className="flex-1">
|
||||
{saving ? 'Kaydediliyor...' : config ? 'Güncelle' : 'Etkinleştir'}
|
||||
</Button>
|
||||
{config && (
|
||||
<Button variant="danger" onClick={deleteConfig}>Kapat</Button>
|
||||
{config && <Button variant="danger" onClick={deleteConfig}>Kapat</Button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -730,6 +740,19 @@ function BackupTab({ db }: { db: Db }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StepGuide({ steps }: { steps: { n: number; text: string; hi?: boolean }[] }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 bg-black/20 p-4 space-y-3">
|
||||
{steps.map(s => (
|
||||
<div key={s.n} className={`flex gap-3 text-[11px] font-mono leading-relaxed ${s.hi ? 'bg-yellow-500/5 border border-yellow-500/20 rounded-lg px-2 py-1.5 -mx-2' : ''}`}>
|
||||
<span className={`w-5 h-5 rounded-full border flex items-center justify-center text-[10px] shrink-0 mt-0.5 ${s.hi ? 'bg-yellow-500/20 border-yellow-500/40 text-yellow-400' : 'bg-accent/10 border-accent/20 text-accent'}`}>{s.n}</span>
|
||||
<span className={s.hi ? 'text-yellow-200/80' : 'text-muted/80'}>{s.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityIcon(props: any) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
+98
-108
@@ -29,24 +29,10 @@ export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
|
||||
|
||||
if (db.db_type === 'mysql') {
|
||||
command = 'mysqldump'
|
||||
args = [
|
||||
'-h', db.host,
|
||||
'-P', db.port.toString(),
|
||||
'-u', db.username,
|
||||
`--password=${db.password}`,
|
||||
'--no-tablespaces',
|
||||
db.database,
|
||||
]
|
||||
args = ['-h', db.host, '-P', db.port.toString(), '-u', db.username, `--password=${db.password}`, '--no-tablespaces', db.database]
|
||||
} else {
|
||||
command = 'pg_dump'
|
||||
args = [
|
||||
'-h', db.host,
|
||||
'-p', db.port.toString(),
|
||||
'-U', db.username,
|
||||
'-d', db.database,
|
||||
'-F', 'p',
|
||||
'--no-owner',
|
||||
]
|
||||
args = ['-h', db.host, '-p', db.port.toString(), '-U', db.username, '-d', db.database, '-F', 'p', '--no-owner']
|
||||
env.PGPASSWORD = db.password
|
||||
}
|
||||
|
||||
@@ -56,151 +42,155 @@ export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
|
||||
|
||||
child.stdout.on('data', (data) => chunks.push(data))
|
||||
child.stderr.on('data', (data) => { errorOutput += data.toString() })
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve(Buffer.concat(chunks))
|
||||
else reject(new Error(`${command} exited with code ${code}: ${errorOutput}`))
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(new Error(`Failed to start ${command}: ${err.message}`))
|
||||
})
|
||||
child.on('error', (err) => reject(new Error(`Failed to start ${command}: ${err.message}`)))
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadToDropbox(token: string, dbName: string, filename: string, content: Buffer) {
|
||||
// Path: /DBAdı/2024-01-15_10-30-00.sql
|
||||
const path = `/${dbName}/${filename}`
|
||||
// ─── Dropbox ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function uploadToDropbox(token: string, dbName: string, filename: string, content: Buffer) {
|
||||
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Dropbox-API-Arg': JSON.stringify({
|
||||
path,
|
||||
mode: 'add',
|
||||
autorename: true,
|
||||
mute: false,
|
||||
}),
|
||||
'Dropbox-API-Arg': JSON.stringify({ path: `/${dbName}/${filename}`, mode: 'add', autorename: true, mute: false }),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: new Uint8Array(content),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text()
|
||||
throw new Error(`Dropbox upload failed: ${response.status} ${errText}`)
|
||||
}
|
||||
return await response.json()
|
||||
if (!response.ok) throw new Error(`Dropbox upload failed: ${response.status} ${await response.text()}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function getGoogleAccessToken(credentials: Record<string, string>): Promise<string> {
|
||||
// ─── Google ortak token ────────────────────────────────────────────────────
|
||||
|
||||
async function getGoogleAccessToken(credentials: Record<string, string>, scope: string): Promise<string> {
|
||||
const privateKey = await importPKCS8(credentials.private_key, 'RS256')
|
||||
const jwt = await new SignJWT({
|
||||
iss: credentials.client_email,
|
||||
scope: 'https://www.googleapis.com/auth/drive',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
})
|
||||
const jwt = await new SignJWT({ iss: credentials.client_email, scope, aud: 'https://oauth2.googleapis.com/token' })
|
||||
.setProtectedHeader({ alg: 'RS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(privateKey)
|
||||
|
||||
const tokenReq = await fetch('https://oauth2.googleapis.com/token', {
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: jwt,
|
||||
}),
|
||||
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
|
||||
})
|
||||
|
||||
if (!tokenReq.ok) {
|
||||
throw new Error(`Google OAuth token alınamadı: ${await tokenReq.text()}`)
|
||||
if (!res.ok) throw new Error(`Google OAuth token alınamadı: ${await res.text()}`)
|
||||
return (await res.json()).access_token
|
||||
}
|
||||
|
||||
const { access_token } = await tokenReq.json()
|
||||
return access_token
|
||||
}
|
||||
// ─── Google Cloud Storage ──────────────────────────────────────────────────
|
||||
|
||||
export async function uploadToGoogleDrive(
|
||||
export async function uploadToGCS(
|
||||
serviceAccountJsonStr: string,
|
||||
bucketName: string,
|
||||
dbName: string,
|
||||
filename: string,
|
||||
content: Buffer
|
||||
) {
|
||||
if (!bucketName?.trim()) throw new Error('Bucket adı zorunlu.')
|
||||
|
||||
const credentials = JSON.parse(serviceAccountJsonStr)
|
||||
const accessToken = await getGoogleAccessToken(credentials, 'https://www.googleapis.com/auth/devstorage.read_write')
|
||||
|
||||
// Object path: DBAdı/dosyaadı.sql
|
||||
const objectPath = encodeURIComponent(`${dbName}/${filename}`)
|
||||
|
||||
const response = await fetch(
|
||||
`https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucketName)}/o?uploadType=media&name=${objectPath}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: new Uint8Array(content),
|
||||
}
|
||||
)
|
||||
if (!response.ok) throw new Error(`GCS upload failed: ${response.status} ${await response.text()}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function testGCSCredentials(
|
||||
serviceAccountJsonStr: string,
|
||||
bucketName?: string
|
||||
): Promise<{ ok: boolean; detail?: string; error?: string }> {
|
||||
try {
|
||||
const credentials = JSON.parse(serviceAccountJsonStr)
|
||||
if (!credentials.private_key || !credentials.client_email) {
|
||||
return { ok: false, error: 'JSON geçersiz: private_key veya client_email eksik' }
|
||||
}
|
||||
|
||||
const accessToken = await getGoogleAccessToken(credentials, 'https://www.googleapis.com/auth/devstorage.read_write')
|
||||
|
||||
if (bucketName?.trim()) {
|
||||
const res = await fetch(
|
||||
`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName)}?fields=name`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
)
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `Token alındı ama bucket'a erişilemiyor. "${credentials.client_email}" için bucket üzerinde "Storage Object Admin" yetkisi verildi mi?` }
|
||||
}
|
||||
const b = await res.json()
|
||||
return { ok: true, detail: `Bağlantı OK — Bucket: "${b.name}" — Hesap: ${credentials.client_email}` }
|
||||
}
|
||||
|
||||
return { ok: true, detail: `Token alındı — Hesap: ${credentials.client_email}` }
|
||||
} catch (e: any) {
|
||||
if (e instanceof SyntaxError) return { ok: false, error: 'JSON formatı hatalı.' }
|
||||
return { ok: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Google Drive — OAuth2 (personal account) ────────────────────────────────
|
||||
// credentials JSON: { client_id, client_secret, refresh_token }
|
||||
|
||||
export async function uploadToGoogleDriveOAuth(
|
||||
credentialsJsonStr: string,
|
||||
filename: string,
|
||||
content: Buffer,
|
||||
parentFolderId?: string
|
||||
) {
|
||||
if (!parentFolderId) {
|
||||
throw new Error('Klasör ID zorunlu. Drive\'da bir klasör oluşturun, service account ile paylaşın ve ID\'sini girin.')
|
||||
}
|
||||
const { client_id, client_secret, refresh_token } = JSON.parse(credentialsJsonStr)
|
||||
if (!refresh_token) throw new Error('Google Drive bağlantısı yok — önce hesabı bağlayın.')
|
||||
|
||||
const credentials = JSON.parse(serviceAccountJsonStr)
|
||||
const accessToken = await getGoogleAccessToken(credentials)
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ client_id, client_secret, refresh_token, grant_type: 'refresh_token' }),
|
||||
})
|
||||
if (!tokenRes.ok) throw new Error(`Drive token yenilenemedi: ${await tokenRes.text()}`)
|
||||
const { access_token, error: tokenErr } = await tokenRes.json()
|
||||
if (tokenErr) throw new Error(`Drive token hatası: ${tokenErr}`)
|
||||
|
||||
const boundary = 'vps_panel_backup_boundary'
|
||||
// parents açıkça belirtilmezse service account kendi kotasız alanına yüklemeye çalışır
|
||||
const meta = JSON.stringify({ name: filename, parents: [parentFolderId] })
|
||||
const meta: Record<string, unknown> = { name: filename }
|
||||
if (parentFolderId) meta.parents = [parentFolderId]
|
||||
|
||||
const parts = Buffer.concat([
|
||||
Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${meta}\r\n`),
|
||||
Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(meta)}\r\n`),
|
||||
Buffer.from(`--${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`),
|
||||
content,
|
||||
Buffer.from(`\r\n--${boundary}--`),
|
||||
])
|
||||
|
||||
const response = await fetch(
|
||||
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name&supportsAllDrives=true',
|
||||
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,webViewLink',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
'Content-Type': `multipart/related; boundary=${boundary}`,
|
||||
},
|
||||
body: new Uint8Array(parts),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Google Drive upload failed: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Sadece credential doğrulama + klasör erişim testi (gerçek yedek almaz)
|
||||
export async function testGoogleDriveCredentials(
|
||||
serviceAccountJsonStr: string,
|
||||
folderId?: string
|
||||
): Promise<{ ok: boolean; detail?: string; error?: string }> {
|
||||
try {
|
||||
const credentials = JSON.parse(serviceAccountJsonStr)
|
||||
|
||||
if (!credentials.private_key || !credentials.client_email) {
|
||||
return { ok: false, error: 'JSON geçersiz: private_key veya client_email eksik' }
|
||||
}
|
||||
|
||||
const accessToken = await getGoogleAccessToken(credentials)
|
||||
|
||||
if (folderId) {
|
||||
const folderRes = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${folderId}?fields=id,name&supportsAllDrives=true`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
)
|
||||
if (!folderRes.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Token alındı ama Drive'a erişilemiyor. Service account'u Paylaşımlı Drive'a üye olarak eklediniz mi? (${credentials.client_email})`,
|
||||
}
|
||||
}
|
||||
const folder = await folderRes.json()
|
||||
return { ok: true, detail: `Bağlantı OK — Drive: "${folder.name}" — Hesap: ${credentials.client_email}` }
|
||||
}
|
||||
|
||||
return { ok: true, detail: `Token alındı — Hesap: ${credentials.client_email}` }
|
||||
} catch (e: any) {
|
||||
if (e instanceof SyntaxError) {
|
||||
return { ok: false, error: 'JSON formatı hatalı. Geçerli bir Service Account JSON yapıştırın.' }
|
||||
}
|
||||
return { ok: false, error: e.message }
|
||||
}
|
||||
if (!response.ok) throw new Error(`Google Drive upload failed: ${response.status} ${await response.text()}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as cron from 'node-cron'
|
||||
import pool from './appDb'
|
||||
import { readConfig } from './config'
|
||||
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDrive, backupFilename } from './backup'
|
||||
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from './backup'
|
||||
|
||||
export function startBackupCron() {
|
||||
console.log('[AutoBackup] Arka plan cron servisi başlatıldı.')
|
||||
@@ -34,8 +34,10 @@ export async function reloadBackupCrons() {
|
||||
try {
|
||||
if (row.cloud_type === 'dropbox') {
|
||||
await uploadToDropbox(row.credentials, db.name, filename, buffer)
|
||||
} else if (row.cloud_type === 'gcs') {
|
||||
await uploadToGCS(row.credentials, row.gdrive_folder_id, db.name, filename, buffer)
|
||||
} else if (row.cloud_type === 'gdrive') {
|
||||
await uploadToGoogleDrive(row.credentials, db.name, filename, buffer, row.gdrive_folder_id)
|
||||
await uploadToGoogleDriveOAuth(row.credentials, filename, buffer, row.gdrive_folder_id || undefined)
|
||||
}
|
||||
} catch (uploadErr: any) {
|
||||
console.error(`[Backup] Upload hatası:`, uploadErr)
|
||||
|
||||
Reference in New Issue
Block a user