feat: add gdrive backup support and update backup logic

This commit is contained in:
mstfyldz
2026-06-06 00:18:07 +03:00
parent 8625a0fd84
commit 58d7fffc6b
6 changed files with 388 additions and 272 deletions
@@ -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)
}
+7 -15
View File
@@ -1,23 +1,17 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth' import { requireAuth } from '@/lib/auth'
import { readConfig } from '@/lib/config' 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' 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) { export async function POST(req: NextRequest) {
const authErr = await requireAuth(req) const authErr = await requireAuth(req)
if (authErr) return authErr if (authErr) return authErr
const { db_id, cloud_type, credentials, gdrive_folder_id } = await req.json() const { db_id, cloud_type, credentials, gdrive_folder_id } = await req.json()
if (!credentials?.trim()) { if (!credentials?.trim()) return NextResponse.json({ ok: false, error: 'Credentials boş olamaz.' })
return NextResponse.json({ ok: false, error: 'Credentials boş olamaz.' })
}
// DB bilgisini al
const config = await readConfig() const config = await readConfig()
const db = config.databases.find(d => d.id === db_id) const db = config.databases.find(d => d.id === db_id)
if (!db) return NextResponse.json({ ok: false, error: 'Veritabanı bulunamadı.' }) 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) const filename = backupFilename(db.name)
try { try {
// 1. Dump al
const buffer = await createDbDumpBuffer(db) const buffer = await createDbDumpBuffer(db)
// 2. Buluta yükle
if (cloud_type === 'dropbox') { if (cloud_type === 'dropbox') {
await uploadToDropbox(credentials.trim(), db.name, filename, buffer) 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') { } 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 { } else {
return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' }) return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' })
} }
// 3. Logla
await pool.query( await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, `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 const sizeStr = buffer.length > 1024 * 1024
@@ -49,14 +42,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
detail: `✓ Yedek alındı ve yüklendi — ${db.name}/${filename} (${sizeStr})`, detail: `✓ Yedek yüklendi — ${db.name}/${filename} (${sizeStr})`,
}) })
} catch (e: any) { } catch (e: any) {
await pool.query( await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, `INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[db_id, 'error', e.message, 0] [db_id, 'error', e.message, 0]
).catch(() => {}) ).catch(() => {})
return NextResponse.json({ ok: false, error: e.message }) return NextResponse.json({ ok: false, error: e.message })
} }
} }
+158 -135
View File
@@ -432,10 +432,6 @@ const CRON_PRESETS = [
{ label: 'Her Pazartesi', value: '0 0 * * 1' }, { 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 }) { function BackupTab({ db }: { db: Db }) {
const [config, setConfig] = useState<any>(null) const [config, setConfig] = useState<any>(null)
const [logs, setLogs] = useState<any[]>([]) const [logs, setLogs] = useState<any[]>([])
@@ -443,8 +439,9 @@ function BackupTab({ db }: { db: Db }) {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; detail?: string; error?: string } | null>(null) const [testResult, setTestResult] = useState<{ ok: boolean; detail?: string; error?: string } | null>(null)
const [copied, setCopied] = useState(false) const [copiedUri, setCopiedUri] = useState(false)
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'gdrive', credentials: '', gdrive_folder_id: '' }) const [redirectUri, setRedirectUri] = useState('')
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'gcs', credentials: '', gdrive_folder_id: '' })
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -453,37 +450,56 @@ function BackupTab({ db }: { db: Db }) {
const data = await r.json() const data = await r.json()
setConfig(data.config) setConfig(data.config)
setLogs(data.logs) setLogs(data.logs)
if (data.config) { 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 || '' })
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) setLoading(false)
}, [db.id]) }, [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 () => { const saveConfig = async () => {
setSaving(true) setSaving(true)
await fetch('/api/backups', { await fetch('/api/backups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id, ...f }) })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id, ...f }),
})
await load() await load()
setSaving(false) setSaving(false)
} }
const testCredentials = async () => { const connectGDrive = async () => {
setTesting(true) setSaving(true)
setTestResult(null) await fetch('/api/backups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id, ...f }) })
const r = await fetch('/api/backups/test', { setSaving(false)
method: 'POST', window.location.href = `/api/backups/gdrive/connect?db_id=${db.id}`
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 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() const result = await r.json()
setTestResult(result) setTestResult(result)
if (result.ok) await load() // log listesini güncelle if (result.ok) await load()
setTesting(false) setTesting(false)
} }
@@ -491,17 +507,14 @@ function BackupTab({ db }: { db: Db }) {
if (!confirm('Otomatik yedeklemeyi kapat?')) return if (!confirm('Otomatik yedeklemeyi kapat?')) return
await fetch('/api/backups', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id }) }) await fetch('/api/backups', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id }) })
setConfig(null) setConfig(null)
setF({ schedule: '0 0 * * *', cloud_type: 'gdrive', credentials: '', gdrive_folder_id: '' }) setF({ schedule: '0 0 * * *', cloud_type: 'gcs', 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) }
} }
if (loading) return <div className="text-muted text-sm font-mono animate-pulse">Yükleniyor...</div> 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 ( return (
<div className="flex gap-6 items-start fade-up"> <div className="flex gap-6 items-start fade-up">
<div className="flex-1 space-y-4"> <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-sm font-semibold mb-0.5">Manuel Yedek Al</div>
<div className="text-[11px] text-muted font-mono">SQL dump olarak indir</div> <div className="text-[11px] text-muted font-mono">SQL dump olarak indir</div>
</div> </div>
<a <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">
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"
>
<Download className="w-3.5 h-3.5" /> İndir <Download className="w-3.5 h-3.5" /> İndir
</a> </a>
</CardContent> </CardContent>
</Card> </Card>
{/* Cloud ayarları */}
<Card> <Card>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-base">Otomatik Yedekleme</CardTitle> <CardTitle className="text-base">Otomatik Yedekleme</CardTitle>
@@ -531,151 +539,153 @@ function BackupTab({ db }: { db: Db }) {
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
{/* Provider */} {/* Provider seçimi */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Depolama</label> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Depolama</label>
<div className="flex gap-2"> <div className="flex gap-2">
{[ {[{ id: 'gcs', label: '☁️ Google Cloud Storage' }, { id: 'dropbox', label: '📦 Dropbox' }, { id: 'gdrive', label: '📁 Google Drive' }].map(p => (
{ id: 'gdrive', label: '📁 Google Drive' }, <button key={p.id} onClick={() => { setF(prev => ({ ...prev, cloud_type: p.id, credentials: '', gdrive_folder_id: '' })); setTestResult(null) }}
{ id: 'dropbox', label: '📦 Dropbox' }, 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'}`}>
].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'
}`}
>
{p.label} {p.label}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* Google Drive kurulum rehberi */} {/* ── GCS ─────────────────────────────────────────────────────── */}
{f.cloud_type === 'gdrive' && ( {f.cloud_type === 'gcs' && (
<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> <StepGuide steps={[
{[
{ n: 1, text: 'console.cloud.google.com → Proje seç/oluştur' }, { n: 1, text: 'console.cloud.google.com → Proje seç/oluştur' },
{ n: 2, text: '"APIs & Services" → "Enable APIs" → "Google Drive API" aç' }, { n: 2, text: '"APIs & Services" → "Cloud Storage API" → Enable' },
{ n: 3, text: '"Credentials" → "Service Accounts" → Yeni hesap oluştur → JSON key indir' }, { n: 3, text: '"Cloud Storage" → "Buckets" → Yeni bucket oluştur → adını not et' },
{ n: 4, text: 'Google Drive\'da bir klasör oluştur (örn. "VPS Backups")' }, { n: 4, text: '"IAM & Admin" → "Service Accounts" → Hesap oluştur → JSON key indir', hi: true },
{ n: 5, text: 'Klasörü sağ tıkla → "Paylaş" → aşağıdaki service account e-postasını "Düzenleyici" olarak ekle', highlight: true }, { n: 5, text: 'Bucket → "Permissions" → "Grant Access" → service account e-postası → "Storage Object Admin"', hi: true },
{ n: 6, text: 'Klasörü aç → URL\'deki /folders/XXXXX kısmını Klasör ID alanına yapıştır', highlight: true }, { n: 6, text: 'Bucket adını aşağıya gir, JSON\'ı yapıştır → Şimdi Yedekle' },
].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' : ''}`}> <div className="space-y-1.5">
<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> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bucket Adı</label>
<span className={(s as any).highlight ? 'text-yellow-200/80' : 'text-muted/80'}>{s.text}</span> <Input value={f.gdrive_folder_id} onChange={e => setF(p => ({ ...p, gdrive_folder_id: e.target.value }))} placeholder="vps-backups" />
</div> </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> </div>
)} )}
{/* Credentials */} {/* ── Google Drive OAuth ───────────────────────────────────────── */}
<div className="space-y-1.5"> {isGdrive && (
<label className="text-[10px] font-mono text-muted uppercase tracking-wider"> <>
{f.cloud_type === 'gdrive' ? 'Service Account JSON' : 'Dropbox Access Token'} {gdriveFields.connected ? (
</label> /* Bağlı durumu */
<textarea <div className="flex items-center justify-between rounded-xl border border-success/30 bg-success/5 px-4 py-3">
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]" <div className="flex items-center gap-2.5">
value={f.credentials} <CheckCircle2 className="w-4 h-4 text-success" />
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">
<div> <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-semibold text-success">Google hesabı bağlı</div>
<div className="text-sm font-mono text-accent">{serviceEmail}</div> <div className="text-[10px] text-muted font-mono mt-0.5">OAuth refresh token aktif</div>
</div> </div>
<button </div>
onClick={copyEmail} <button onClick={connectGDrive} className="text-[10px] font-mono text-muted hover:text-text border border-border rounded px-2 py-1 transition-colors">
className="p-1.5 rounded text-muted hover:text-accent transition-colors shrink-0" Yeniden Bağla
title="Kopyala"
>
{copied ? <CheckCircle2 className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</button> </button>
</div> </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 */} {/* Klasör ID — her zaman göster */}
{f.cloud_type === 'gdrive' && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Klasör ID</label> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Klasör ID (Opsiyonel)</label>
<Input <Input value={f.gdrive_folder_id} onChange={e => setF(p => ({ ...p, gdrive_folder_id: e.target.value }))} placeholder="1A2b3C4d5E6f7G8h9I0j..." />
value={f.gdrive_folder_id} <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>
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>
</div> </div>
</>
)} )}
{/* Test sonucu */} {/* Test sonucu */}
{testResult && ( {testResult && (
<div className={`flex items-start gap-2.5 rounded-lg border px-4 py-3 text-xs font-mono ${ <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 {testResult.ok ? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" /> : <ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />}
? '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> <span>{testResult.ok ? testResult.detail : testResult.error}</span>
</div> </div>
)} )}
{/* Schedule */} {/* Zamanlama */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Zamanlama</label> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Zamanlama</label>
<div className="flex flex-wrap gap-1.5 mb-2"> <div className="flex flex-wrap gap-1.5 mb-2">
{CRON_PRESETS.map(p => ( {CRON_PRESETS.map(p => (
<button <button key={p.value} onClick={() => setF(prev => ({ ...prev, schedule: p.value }))}
key={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'}`}>
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} {p.label}
</button> </button>
))} ))}
</div> </div>
<Input value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" className="font-mono text-sm" /> <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> </div>
{/* Butonlar */} {/* Butonlar */}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button {isGdrive && !gdriveFields.connected ? (
variant="outline" <Button onClick={connectGDrive} disabled={saving || !gdriveFields.client_id || !gdriveFields.client_secret} className="flex-1 gap-1.5">
onClick={testCredentials} {saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
disabled={testing || !f.credentials} {saving ? 'Kaydediliyor...' : 'Google ile Bağlan →'}
className="gap-1.5" </Button>
title="Şimdi yedek al ve buluta yükle" ) : (
> <>
{testing <Button variant="outline" onClick={testNow} disabled={testing || !credentialsReady} className="gap-1.5" title="Şimdi yedek al ve yükle">
? <Loader2 className="w-3.5 h-3.5 animate-spin" /> {testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
: <FlaskConical className="w-3.5 h-3.5" />}
{testing ? 'Yedekleniyor...' : 'Şimdi Yedekle'} {testing ? 'Yedekleniyor...' : 'Şimdi Yedekle'}
</Button> </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'} {saving ? 'Kaydediliyor...' : config ? 'Güncelle' : 'Etkinleştir'}
</Button> </Button>
{config && ( {config && <Button variant="danger" onClick={deleteConfig}>Kapat</Button>}
<Button variant="danger" onClick={deleteConfig}>Kapat</Button> </>
)} )}
</div> </div>
</CardContent> </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) { function ActivityIcon(props: any) {
return ( return (
<svg <svg
+98 -108
View File
@@ -29,24 +29,10 @@ export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
if (db.db_type === 'mysql') { if (db.db_type === 'mysql') {
command = 'mysqldump' command = 'mysqldump'
args = [ args = ['-h', db.host, '-P', db.port.toString(), '-u', db.username, `--password=${db.password}`, '--no-tablespaces', db.database]
'-h', db.host,
'-P', db.port.toString(),
'-u', db.username,
`--password=${db.password}`,
'--no-tablespaces',
db.database,
]
} else { } else {
command = 'pg_dump' command = 'pg_dump'
args = [ args = ['-h', db.host, '-p', db.port.toString(), '-U', db.username, '-d', db.database, '-F', 'p', '--no-owner']
'-h', db.host,
'-p', db.port.toString(),
'-U', db.username,
'-d', db.database,
'-F', 'p',
'--no-owner',
]
env.PGPASSWORD = db.password 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.stdout.on('data', (data) => chunks.push(data))
child.stderr.on('data', (data) => { errorOutput += data.toString() }) child.stderr.on('data', (data) => { errorOutput += data.toString() })
child.on('close', (code) => { child.on('close', (code) => {
if (code === 0) resolve(Buffer.concat(chunks)) if (code === 0) resolve(Buffer.concat(chunks))
else reject(new Error(`${command} exited with code ${code}: ${errorOutput}`)) 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) { // ─── Dropbox ────────────────────────────────────────────────────────────────
// Path: /DBAdı/2024-01-15_10-30-00.sql
const path = `/${dbName}/${filename}`
export async function uploadToDropbox(token: string, dbName: string, filename: string, content: Buffer) {
const response = await fetch('https://content.dropboxapi.com/2/files/upload', { const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Dropbox-API-Arg': JSON.stringify({ 'Dropbox-API-Arg': JSON.stringify({ path: `/${dbName}/${filename}`, mode: 'add', autorename: true, mute: false }),
path,
mode: 'add',
autorename: true,
mute: false,
}),
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
}, },
body: new Uint8Array(content), body: new Uint8Array(content),
}) })
if (!response.ok) throw new Error(`Dropbox upload failed: ${response.status} ${await response.text()}`)
if (!response.ok) { return response.json()
const errText = await response.text()
throw new Error(`Dropbox upload failed: ${response.status} ${errText}`)
}
return await 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 privateKey = await importPKCS8(credentials.private_key, 'RS256')
const jwt = await new SignJWT({ const jwt = await new SignJWT({ iss: credentials.client_email, scope, aud: 'https://oauth2.googleapis.com/token' })
iss: credentials.client_email,
scope: 'https://www.googleapis.com/auth/drive',
aud: 'https://oauth2.googleapis.com/token',
})
.setProtectedHeader({ alg: 'RS256' }) .setProtectedHeader({ alg: 'RS256' })
.setIssuedAt() .setIssuedAt()
.setExpirationTime('1h') .setExpirationTime('1h')
.sign(privateKey) .sign(privateKey)
const tokenReq = await fetch('https://oauth2.googleapis.com/token', { const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
}) })
if (!res.ok) throw new Error(`Google OAuth token alınamadı: ${await res.text()}`)
if (!tokenReq.ok) { return (await res.json()).access_token
throw new Error(`Google OAuth token alınamadı: ${await tokenReq.text()}`)
} }
const { access_token } = await tokenReq.json() // ─── Google Cloud Storage ──────────────────────────────────────────────────
return access_token
}
export async function uploadToGoogleDrive( export async function uploadToGCS(
serviceAccountJsonStr: string, serviceAccountJsonStr: string,
bucketName: string,
dbName: string, dbName: string,
filename: 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, content: Buffer,
parentFolderId?: string parentFolderId?: string
) { ) {
if (!parentFolderId) { const { client_id, client_secret, refresh_token } = JSON.parse(credentialsJsonStr)
throw new Error('Klasör ID zorunlu. Drive\'da bir klasör oluşturun, service account ile paylaşın ve ID\'sini girin.') if (!refresh_token) throw new Error('Google Drive bağlantısı yok — önce hesabı bağlayın.')
}
const credentials = JSON.parse(serviceAccountJsonStr) const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
const accessToken = await getGoogleAccessToken(credentials) 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' const boundary = 'vps_panel_backup_boundary'
// parents açıkça belirtilmezse service account kendi kotasız alanına yüklemeye çalışır const meta: Record<string, unknown> = { name: filename }
const meta = JSON.stringify({ name: filename, parents: [parentFolderId] }) if (parentFolderId) meta.parents = [parentFolderId]
const parts = Buffer.concat([ 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`), Buffer.from(`--${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`),
content, content,
Buffer.from(`\r\n--${boundary}--`), Buffer.from(`\r\n--${boundary}--`),
]) ])
const response = await fetch( 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', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${access_token}`,
'Content-Type': `multipart/related; boundary=${boundary}`, 'Content-Type': `multipart/related; boundary=${boundary}`,
}, },
body: new Uint8Array(parts), body: new Uint8Array(parts),
} }
) )
if (!response.ok) throw new Error(`Google Drive upload failed: ${response.status} ${await response.text()}`)
if (!response.ok) { return response.json()
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 }
}
} }
+4 -2
View File
@@ -1,7 +1,7 @@
import * as cron from 'node-cron' import * as cron from 'node-cron'
import pool from './appDb' import pool from './appDb'
import { readConfig } from './config' import { readConfig } from './config'
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDrive, backupFilename } from './backup' import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from './backup'
export function startBackupCron() { export function startBackupCron() {
console.log('[AutoBackup] Arka plan cron servisi başlatıldı.') console.log('[AutoBackup] Arka plan cron servisi başlatıldı.')
@@ -34,8 +34,10 @@ export async function reloadBackupCrons() {
try { try {
if (row.cloud_type === 'dropbox') { if (row.cloud_type === 'dropbox') {
await uploadToDropbox(row.credentials, db.name, filename, buffer) 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') { } 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) { } catch (uploadErr: any) {
console.error(`[Backup] Upload hatası:`, uploadErr) console.error(`[Backup] Upload hatası:`, uploadErr)