feat: add cloud connections and update backups

This commit is contained in:
mstfyldz
2026-06-06 00:31:10 +03:00
parent 58d7fffc6b
commit a4c58135df
11 changed files with 589 additions and 221 deletions
+12 -9
View File
@@ -33,24 +33,27 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.json() const body = await req.json()
const { db_id, schedule, cloud_type, credentials, gdrive_folder_id } = body const { db_id, schedule, connection_id, cloud_type, credentials, gdrive_folder_id } = body
if (!db_id || !schedule || !cloud_type || !credentials) { if (!db_id || !schedule) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 }) return NextResponse.json({ error: 'db_id ve schedule zorunlu' }, { status: 400 })
}
// connection_id VEYA credentials zorunlu
if (!connection_id && !credentials) {
return NextResponse.json({ error: 'connection_id veya credentials gerekli' }, { status: 400 })
} }
// Check if exists
const existing = await pool.query(`SELECT id FROM backup_configs WHERE db_id = $1`, [db_id]) const existing = await pool.query(`SELECT id FROM backup_configs WHERE db_id = $1`, [db_id])
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
await pool.query( await pool.query(
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3, gdrive_folder_id=$4 WHERE db_id=$5`, `UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3, gdrive_folder_id=$4, connection_id=$5 WHERE db_id=$6`,
[schedule, cloud_type, credentials, gdrive_folder_id || null, db_id] [schedule, cloud_type || '', credentials || '', gdrive_folder_id || null, connection_id || null, db_id]
) )
} else { } else {
await pool.query( await pool.query(
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials, gdrive_folder_id) VALUES ($1, $2, $3, $4, $5, $6)`, `INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials, gdrive_folder_id, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[generateId(), db_id, schedule, cloud_type, credentials, gdrive_folder_id || null] [generateId(), db_id, schedule, cloud_type || '', credentials || '', gdrive_folder_id || null, connection_id || null]
) )
} }
+28 -18
View File
@@ -1,6 +1,7 @@
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 { getCloudConnection } from '@/lib/appDb'
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from '@/lib/backup' import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from '@/lib/backup'
import pool from '@/lib/appDb' import pool from '@/lib/appDb'
@@ -8,47 +9,56 @@ 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 body = await req.json()
const { db_id, connection_id, cloud_type: inlineType, credentials: inlineCredentials, gdrive_folder_id } = body
if (!credentials?.trim()) return NextResponse.json({ ok: false, error: 'Credentials boş olamaz.' })
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ı.' })
const filename = backupFilename(db.name) // Bağlantı kaynağı: global connection veya inline
let type: string, credentials: string, target: string
if (connection_id) {
const conn = await getCloudConnection(connection_id)
if (!conn) return NextResponse.json({ ok: false, error: 'Cloud bağlantısı bulunamadı.' })
type = conn.type
credentials = conn.credentials
target = gdrive_folder_id || conn.default_target || ''
} else {
if (!inlineCredentials?.trim()) return NextResponse.json({ ok: false, error: 'Credentials boş.' })
type = inlineType
credentials = inlineCredentials
target = gdrive_folder_id || ''
}
const filename = type === 'gdrive' ? backupFilename() : backupFilename(db.name)
try { try {
const buffer = await createDbDumpBuffer(db) const buffer = await createDbDumpBuffer(db)
if (cloud_type === 'dropbox') { if (type === 'dropbox') {
await uploadToDropbox(credentials.trim(), db.name, filename, buffer) await uploadToDropbox(credentials.trim(), db.name, filename, buffer)
} else if (cloud_type === 'gcs') { } else if (type === 'gcs') {
await uploadToGCS(credentials, gdrive_folder_id, db.name, filename, buffer) await uploadToGCS(credentials, target, db.name, filename, buffer)
} else if (cloud_type === 'gdrive') { } else if (type === 'gdrive') {
await uploadToGoogleDriveOAuth(credentials, filename, buffer, gdrive_folder_id || undefined) await uploadToGoogleDriveOAuth(credentials, db.name, filename, buffer, target || undefined)
} else { } else {
return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' }) return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' })
} }
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 yedek — ${cloud_type}`, buffer.length] [db_id, 'success', `Manuel yedek — ${type}`, buffer.length]
) )
const sizeStr = buffer.length > 1024 * 1024 const sizeStr = buffer.length > 1024 * 1024
? `${(buffer.length / (1024 * 1024)).toFixed(2)} MB` ? `${(buffer.length / (1024 * 1024)).toFixed(2)} MB`
: `${(buffer.length / 1024).toFixed(1)} KB` : `${(buffer.length / 1024).toFixed(1)} KB`
return NextResponse.json({ const pathStr = type === 'gdrive' ? `${db.name}/${filename}` : filename
ok: true, return NextResponse.json({ ok: true, detail: `✓ Yüklendi — ${pathStr} (${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)`, [db_id, 'error', e.message, 0]).catch(() => {})
`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 }) return NextResponse.json({ ok: false, error: e.message })
} }
} }
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCloudConnection, upsertCloudConnection } from '@/lib/appDb'
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl
const code = searchParams.get('code')
const connection_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 origin = `${proto}://${host}`
const redirectUri = `${origin}/api/cloud-connections/gdrive/callback`
const connectionsUrl = `${origin}/dashboard/connections`
if (error) return NextResponse.redirect(`${connectionsUrl}?gdrive_error=${encodeURIComponent(error)}`)
if (!code || !connection_id) return NextResponse.redirect(`${connectionsUrl}?gdrive_error=missing_params`)
const conn = await getCloudConnection(connection_id)
if (!conn) return NextResponse.redirect(`${connectionsUrl}?gdrive_error=connection_not_found`)
let client_id: string, client_secret: string
try { const c = JSON.parse(conn.credentials); client_id = c.client_id; client_secret = c.client_secret }
catch { return NextResponse.redirect(`${connectionsUrl}?gdrive_error=invalid_credentials`) }
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 e = await tokenRes.text()
return NextResponse.redirect(`${connectionsUrl}?gdrive_error=${encodeURIComponent(e.slice(0, 80))}`)
}
const tokens = await tokenRes.json()
if (!tokens.refresh_token) return NextResponse.redirect(`${connectionsUrl}?gdrive_error=no_refresh_token`)
await upsertCloudConnection({
...conn,
credentials: JSON.stringify({ client_id, client_secret, refresh_token: tokens.refresh_token }),
})
return NextResponse.redirect(`${connectionsUrl}?gdrive_connected=${connection_id}`)
}
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getCloudConnection, upsertCloudConnection } from '@/lib/appDb'
function panelOrigin(req: NextRequest) {
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
return `${proto}://${host}`
}
// GET /api/cloud-connections/gdrive?connection_id=xxx → Google OAuth redirect
export async function GET(req: NextRequest) {
const authErr = await requireAuth(req)
if (authErr) return authErr
const connection_id = req.nextUrl.searchParams.get('connection_id')
if (!connection_id) return NextResponse.json({ error: 'connection_id gerekli' }, { status: 400 })
const conn = await getCloudConnection(connection_id)
if (!conn) return NextResponse.json({ error: 'Bağlantı bulunamadı' }, { status: 404 })
let client_id: string
try { client_id = JSON.parse(conn.credentials).client_id }
catch { return NextResponse.json({ error: 'Credentials geçersiz' }, { status: 400 }) }
const origin = panelOrigin(req)
const redirectUri = `${origin}/api/cloud-connections/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: connection_id,
})
return NextResponse.redirect(oauthUrl)
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getCloudConnections, upsertCloudConnection, deleteCloudConnection } from '@/lib/appDb'
import { generateId } from '@/lib/config'
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
return NextResponse.json(await getCloudConnections())
}
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const body = await req.json()
const conn = { id: generateId(), name: body.name, type: body.type, credentials: body.credentials ?? '', default_target: body.default_target ?? null }
await upsertCloudConnection(conn)
return NextResponse.json(conn)
}
export async function PUT(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const body = await req.json()
if (!body.id) return NextResponse.json({ error: 'id gerekli' }, { status: 400 })
await upsertCloudConnection({ id: body.id, name: body.name, type: body.type, credentials: body.credentials ?? '', default_target: body.default_target ?? null })
return NextResponse.json({ ok: true })
}
export async function DELETE(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const id = req.nextUrl.searchParams.get('id')
if (!id) return NextResponse.json({ error: 'id gerekli' }, { status: 400 })
await deleteCloudConnection(id)
return NextResponse.json({ ok: true })
}
+244
View File
@@ -0,0 +1,244 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, CheckCircle2, Link2, RefreshCw, ShieldAlert, Copy } from 'lucide-react'
type Conn = { id: string; name: string; type: 'gcs' | 'gdrive' | 'dropbox'; credentials: string; default_target: string | null }
const TYPE_LABELS: Record<string, string> = { gcs: '☁️ Google Cloud Storage', gdrive: '📁 Google Drive', dropbox: '📦 Dropbox' }
function isGdriveConnected(credentials: string) {
try { return !!JSON.parse(credentials).refresh_token } catch { return false }
}
function parseGdriveCreds(credentials: string) {
try { const c = JSON.parse(credentials); return { client_id: c.client_id || '', client_secret: c.client_secret || '' } }
catch { return { client_id: '', client_secret: '' } }
}
export default function ConnectionsPage() {
const [conns, setConns] = useState<Conn[]>([])
const [showAdd, setShowAdd] = useState(false)
const [editConn, setEditConn] = useState<Conn | null>(null)
const [notice, setNotice] = useState<{ ok: boolean; msg: string } | null>(null)
const load = useCallback(async () => {
const r = await fetch('/api/cloud-connections')
if (r.ok) setConns(await r.json())
}, [])
useEffect(() => {
load()
const params = new URLSearchParams(window.location.search)
if (params.get('gdrive_connected')) {
setNotice({ ok: true, msg: 'Google Drive bağlantısı başarıyla kuruldu.' })
window.history.replaceState({}, '', window.location.pathname)
load()
} else if (params.get('gdrive_error')) {
setNotice({ ok: false, msg: `Google bağlantı hatası: ${params.get('gdrive_error')}` })
window.history.replaceState({}, '', window.location.pathname)
}
}, [load])
const del = async (id: string) => {
if (!confirm('Bu bağlantıyı sil?')) return
await fetch(`/api/cloud-connections?id=${id}`, { method: 'DELETE' })
load()
}
return (
<div style={{ padding: 28, maxWidth: 900 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Cloud Bağlantıları</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Bir kere kur, tüm DB yedekleri için kullan</p>
</div>
<Button onClick={() => setShowAdd(true)} className="gap-1.5"><Plus className="w-4 h-4" /> Yeni Bağlantı</Button>
</div>
{notice && (
<div className={`flex items-center gap-2.5 rounded-xl border px-4 py-3 mb-6 text-sm font-mono ${notice.ok ? 'border-success/30 bg-success/5 text-success' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
{notice.ok ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <ShieldAlert className="w-4 h-4 shrink-0" />}
{notice.msg}
<button onClick={() => setNotice(null)} className="ml-auto opacity-60 hover:opacity-100"></button>
</div>
)}
{conns.length === 0 ? (
<Card className="border-dashed border-2 bg-surface/30">
<CardContent className="flex flex-col items-center py-16 text-center">
<Link2 className="w-10 h-10 text-muted/30 mb-4" />
<div className="text-sm font-semibold mb-2">Henüz bağlantı yok</div>
<p className="text-xs text-muted mb-5">GCS, Google Drive veya Dropbox bağlantısı ekle.</p>
<Button onClick={() => setShowAdd(true)}><Plus className="w-4 h-4 mr-1.5" /> Ekle</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{conns.map(conn => {
const gdriveOk = conn.type === 'gdrive' && isGdriveConnected(conn.credentials)
return (
<Card key={conn.id} className="bg-surface/50">
<CardContent className="p-5 flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
<span className="font-semibold text-sm">{conn.name}</span>
<span className="text-[10px] font-mono bg-white/5 border border-border/50 px-2 py-0.5 rounded-full text-muted">
{TYPE_LABELS[conn.type] ?? conn.type}
</span>
{conn.type === 'gdrive' && (
<span className={`text-[10px] font-mono px-2 py-0.5 rounded-full border ${gdriveOk ? 'bg-success/10 border-success/30 text-success' : 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'}`}>
{gdriveOk ? '✓ Bağlı' : '⚠ Yetki Yok'}
</span>
)}
</div>
<div className="text-[11px] text-muted font-mono">
{conn.default_target ? `Hedef: ${conn.default_target}` : 'Varsayılan hedef yok'}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{conn.type === 'gdrive' && (
<Button variant="outline" size="sm" className="h-8 text-xs gap-1.5"
onClick={() => { window.location.href = `/api/cloud-connections/gdrive?connection_id=${conn.id}` }}>
<RefreshCw className="w-3 h-3" /> {gdriveOk ? 'Yenile' : 'Bağlan'}
</Button>
)}
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setEditConn(conn)}>Düzenle</Button>
<button onClick={() => del(conn.id)} className="p-1.5 text-muted hover:text-destructive transition-colors rounded">
<Trash2 className="w-4 h-4" />
</button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{(showAdd || editConn) && (
<ConnModal
conn={editConn}
onClose={() => { setShowAdd(false); setEditConn(null) }}
onSaved={() => { load(); setShowAdd(false); setEditConn(null) }}
/>
)}
</div>
)
}
function ConnModal({ conn, onClose, onSaved }: { conn: Conn | null; onClose: () => void; onSaved: () => void }) {
const [type, setType] = useState<'gcs' | 'gdrive' | 'dropbox'>(conn?.type ?? 'gcs')
const [name, setName] = useState(conn?.name ?? '')
const [credentials, setCredentials] = useState(conn?.type === 'gdrive' ? '' : (conn?.credentials ?? ''))
const [defaultTarget, setDefaultTarget] = useState(conn?.default_target ?? '')
const [clientId, setClientId] = useState(() => parseGdriveCreds(conn?.credentials ?? '').client_id)
const [clientSecret, setClientSecret] = useState(() => parseGdriveCreds(conn?.credentials ?? '').client_secret)
const [saving, setSaving] = useState(false)
const [copiedUri, setCopiedUri] = useState(false)
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/api/cloud-connections/gdrive/callback' : ''
const submit = async () => {
setSaving(true)
const creds = type === 'gdrive'
? (() => { try { const existing = JSON.parse(conn?.credentials ?? '{}'); return JSON.stringify({ ...existing, client_id: clientId, client_secret: clientSecret }) } catch { return JSON.stringify({ client_id: clientId, client_secret: clientSecret }) } })()
: credentials
const method = conn ? 'PUT' : 'POST'
const body = { id: conn?.id, name, type, credentials: creds, default_target: defaultTarget || null }
const r = await fetch('/api/cloud-connections', { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
setSaving(false)
if (r.ok) onSaved()
}
return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[520px] shadow-2xl" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/50">
<CardTitle>{conn ? 'Bağlantı Düzenle' : 'Yeni Cloud Bağlantısı'}</CardTitle>
</CardHeader>
<CardContent className="pt-5 space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bağlantı Adı</label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="Production Drive, Backup GCS…" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Tür</label>
<div className="flex gap-2">
{(['gcs', 'gdrive', 'dropbox'] as const).map(t => (
<button key={t} onClick={() => setType(t)}
className={`flex-1 py-2 rounded-lg border text-xs font-medium transition-all ${type === t ? 'bg-accent/10 border-accent/40 text-accent' : 'border-border/50 text-muted hover:text-text hover:border-border'}`}>
{TYPE_LABELS[t]}
</button>
))}
</div>
</div>
{/* GCS */}
{type === 'gcs' && (
<>
<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-[90px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" value={credentials} onChange={e => setCredentials(e.target.value)} placeholder={'{\n "type": "service_account",\n ...\n}'} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Varsayılan Bucket</label>
<Input value={defaultTarget} onChange={e => setDefaultTarget(e.target.value)} placeholder="my-backups-bucket" />
</div>
</>
)}
{/* Dropbox */}
{type === 'dropbox' && (
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Access Token</label>
<Input value={credentials} onChange={e => setCredentials(e.target.value)} placeholder="sl.xxxxxxxxxxxxxxxx..." />
<div className="text-[10px] text-muted/60 font-mono">dropbox.com/developers Apps Generate token</div>
</div>
)}
{/* Google Drive */}
{type === 'gdrive' && (
<>
<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 shrink-0 transition-colors">
{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={clientId} onChange={e => setClientId(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={clientSecret} onChange={e => setClientSecret(e.target.value)} placeholder="GOCSPX-..." />
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Varsayılan Klasör ID (Opsiyonel)</label>
<Input value={defaultTarget} onChange={e => setDefaultTarget(e.target.value)} placeholder="1A2b3C4d5E6f7G8h9I0j..." />
</div>
<div className="rounded-lg bg-yellow-500/5 border border-yellow-500/20 px-3 py-2.5 text-[11px] font-mono text-yellow-200/70">
Kaydet → ardından "Bağlan" butonuyla Google hesabını yetkilendir.
</div>
</>
)}
<div className="flex gap-3 pt-2">
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button>
<Button onClick={submit} disabled={saving || !name} className="flex-1">
{saving ? 'Kaydediliyor...' : conn ? 'Güncelle' : 'Kaydet'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+62 -159
View File
@@ -435,68 +435,51 @@ const CRON_PRESETS = [
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[]>([])
const [connections, setConnections] = useState<{ id: string; name: string; type: string }[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
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 [copiedUri, setCopiedUri] = useState(false) const [f, setF] = useState({ schedule: '0 0 * * *', connection_id: '', target_override: '' })
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)
const r = await fetch(`/api/backups?dbId=${db.id}`) const [backupRes, connsRes] = await Promise.all([
if (r.ok) { fetch(`/api/backups?dbId=${db.id}`),
const data = await r.json() fetch('/api/cloud-connections'),
])
if (connsRes.ok) setConnections(await connsRes.json())
if (backupRes.ok) {
const data = await backupRes.json()
setConfig(data.config) setConfig(data.config)
setLogs(data.logs) 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, connection_id: data.config.connection_id || '', target_override: data.config.gdrive_folder_id || '' })
}
} }
setLoading(false) setLoading(false)
}, [db.id]) }, [db.id])
useEffect(() => { useEffect(() => { load() }, [load])
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 selectedConn = connections.find(c => c.id === f.connection_id)
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', { 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, schedule: f.schedule, connection_id: f.connection_id, gdrive_folder_id: f.target_override, credentials: '_via_connection_' }),
})
await load() await load()
setSaving(false) setSaving(false)
} }
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 () => { const testNow = async () => {
setTesting(true); setTestResult(null) 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 r = await fetch('/api/backups/test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id, connection_id: f.connection_id, gdrive_folder_id: f.target_override }),
})
const result = await r.json() const result = await r.json()
setTestResult(result) setTestResult(result)
if (result.ok) await load() if (result.ok) await load()
@@ -507,14 +490,11 @@ 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: 'gcs', credentials: '', gdrive_folder_id: '' }) setF({ schedule: '0 0 * * *', connection_id: '', target_override: '' })
} }
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">
@@ -539,113 +519,45 @@ function BackupTab({ db }: { db: Db }) {
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
{/* Provider seçimi */} {/* Bağlantı 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> <div className="flex items-center justify-between">
<div className="flex gap-2"> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Cloud Bağlantısı</label>
{[{ id: 'gcs', label: '☁️ Google Cloud Storage' }, { id: 'dropbox', label: '📦 Dropbox' }, { id: 'gdrive', label: '📁 Google Drive' }].map(p => ( <a href="/dashboard/connections" className="text-[10px] font-mono text-accent hover:underline">+ Yeni Bağlantı </a>
<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>
{connections.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-4 py-5 text-center">
<div className="text-xs text-muted font-mono mb-3">Henüz cloud bağlantısı yok.</div>
<a href="/dashboard/connections" className="text-xs text-accent hover:underline font-mono">Bağlantılar sayfasında ekle </a>
</div>
) : (
<select
value={f.connection_id}
onChange={e => { setF(p => ({ ...p, connection_id: e.target.value })); setTestResult(null) }}
className="flex h-9 w-full rounded-md border border-border bg-black/30 px-3 py-1 text-sm shadow-sm font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent"
>
<option value=""> Seç </option>
{connections.map(c => (
<option key={c.id} value={c.id}>{c.name} ({c.type})</option>
))}
</select>
)}
</div> </div>
{/* ── GCS ─────────────────────────────────────────────────────── */} {/* Per-DB hedef override */}
{f.cloud_type === 'gcs' && ( {selectedConn && (
<>
<StepGuide steps={[
{ n: 1, text: 'console.cloud.google.com → Proje seç/oluştur' },
{ 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"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Access Token</label> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">
<Input value={f.credentials} onChange={e => { setF(p => ({ ...p, credentials: e.target.value })); setTestResult(null) }} placeholder="sl.xxxxxxxxxxxxxxxx..." /> {selectedConn.type === 'gcs' ? 'Bucket Override' : selectedConn.type === 'gdrive' ? 'Klasör ID Override' : 'Hedef Override'} (Opsiyonel)
<div className="text-[10px] text-muted/60 font-mono">dropbox.com/developers Apps Create app Generate token</div> </label>
<Input
value={f.target_override}
onChange={e => setF(p => ({ ...p, target_override: e.target.value }))}
placeholder="Boş bırakırsan bağlantının varsayılan hedefi kullanılır"
/>
</div> </div>
)} )}
{/* ── 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-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>
</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 — her zaman göster */}
<div className="space-y-1.5">
<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 */} {/* Test sonucu */}
{testResult && ( {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'}`}> <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'}`}>
@@ -670,23 +582,14 @@ function BackupTab({ db }: { db: Db }) {
{/* Butonlar */} {/* Butonlar */}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
{isGdrive && !gdriveFields.connected ? ( <Button variant="outline" onClick={testNow} disabled={testing || !f.connection_id} className="gap-1.5">
<Button onClick={connectGDrive} disabled={saving || !gdriveFields.client_id || !gdriveFields.client_secret} className="flex-1 gap-1.5"> {testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null} {testing ? 'Yedekleniyor...' : 'Şimdi Yedekle'}
{saving ? 'Kaydediliyor...' : 'Google ile Bağlan →'} </Button>
</Button> <Button onClick={saveConfig} disabled={saving || !f.connection_id} className="flex-1">
) : ( {saving ? 'Kaydediliyor...' : config ? 'Güncelle' : 'Etkinleştir'}
<> </Button>
<Button variant="outline" onClick={testNow} disabled={testing || !credentialsReady} className="gap-1.5" title="Şimdi yedek al ve yükle"> {config && <Button variant="danger" onClick={deleteConfig}>Kapat</Button>}
{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 || !credentialsReady} className="flex-1">
{saving ? 'Kaydediliyor...' : config ? 'Güncelle' : 'Etkinleştir'}
</Button>
{config && <Button variant="danger" onClick={deleteConfig}>Kapat</Button>}
</>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+2 -1
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { LayoutDashboard, Activity, Database, LineChart, Server, LogOut, Box } from 'lucide-react' import { LayoutDashboard, Activity, Database, LineChart, Server, LogOut, Box, Cloud } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const nav = [ const nav = [
@@ -11,6 +11,7 @@ const nav = [
{ href: '/dashboard/analytics', label: 'Analytics', icon: LineChart }, { href: '/dashboard/analytics', label: 'Analytics', icon: LineChart },
{ href: '/dashboard/services', label: 'Servisler', icon: Server }, { href: '/dashboard/services', label: 'Servisler', icon: Server },
{ href: '/dashboard/docker', label: 'Docker', icon: Box }, { href: '/dashboard/docker', label: 'Docker', icon: Box },
{ href: '/dashboard/connections', label: 'Bağlantılar', icon: Cloud },
] ]
export default function Sidebar() { export default function Sidebar() {
+41
View File
@@ -104,6 +104,16 @@ pool.query(`
telegram_chat_id TEXT, telegram_chat_id TEXT,
enabled BOOLEAN NOT NULL DEFAULT true enabled BOOLEAN NOT NULL DEFAULT true
); );
CREATE TABLE IF NOT EXISTS cloud_connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
credentials TEXT NOT NULL DEFAULT '',
default_target TEXT
);
ALTER TABLE backup_configs ADD COLUMN IF NOT EXISTS connection_id TEXT;
`).catch(console.error) `).catch(console.error)
export type PingLog = { export type PingLog = {
id: number id: number
@@ -276,6 +286,37 @@ export async function getNotificationSettings(): Promise<NotificationSettings |
return res.rows[0] ?? null return res.rows[0] ?? null
} }
export interface CloudConnection {
id: string
name: string
type: 'gcs' | 'gdrive' | 'dropbox'
credentials: string
default_target: string | null
}
export async function getCloudConnections(): Promise<CloudConnection[]> {
const res = await pool.query(`SELECT * FROM cloud_connections ORDER BY name`)
return res.rows as CloudConnection[]
}
export async function getCloudConnection(id: string): Promise<CloudConnection | null> {
const res = await pool.query(`SELECT * FROM cloud_connections WHERE id = $1`, [id])
return (res.rows[0] as CloudConnection) ?? null
}
export async function upsertCloudConnection(conn: CloudConnection): Promise<void> {
await pool.query(`
INSERT INTO cloud_connections (id, name, type, credentials, default_target)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE
SET name = $2, type = $3, credentials = $4, default_target = $5
`, [conn.id, conn.name, conn.type, conn.credentials, conn.default_target ?? null])
}
export async function deleteCloudConnection(id: string): Promise<void> {
await pool.query(`DELETE FROM cloud_connections WHERE id = $1`, [id])
}
export async function saveNotificationSettings(s: Omit<NotificationSettings, never>): Promise<void> { export async function saveNotificationSettings(s: Omit<NotificationSettings, never>): Promise<void> {
await pool.query(` await pool.query(`
INSERT INTO notification_settings (id, webhook_url, telegram_token, telegram_chat_id, enabled) INSERT INTO notification_settings (id, webhook_url, telegram_token, telegram_chat_id, enabled)
+37 -8
View File
@@ -151,8 +151,38 @@ export async function testGCSCredentials(
// ─── Google Drive — OAuth2 (personal account) ──────────────────────────────── // ─── Google Drive — OAuth2 (personal account) ────────────────────────────────
// credentials JSON: { client_id, client_secret, refresh_token } // credentials JSON: { client_id, client_secret, refresh_token }
async function getOrCreateFolderOAuth(
accessToken: string,
folderName: string,
parentId?: string
): Promise<string> {
const parentQ = parentId ? ` and '${parentId}' in parents` : ''
const q = `name='${folderName.replace(/'/g, "\\'")}' and mimeType='application/vnd.google-apps.folder' and trashed=false${parentQ}`
const searchRes = await fetch(
`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(q)}&fields=files(id,name)`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
if (searchRes.ok) {
const { files } = await searchRes.json()
if (files?.length > 0) return files[0].id
}
const meta: Record<string, unknown> = { name: folderName, mimeType: 'application/vnd.google-apps.folder' }
if (parentId) meta.parents = [parentId]
const createRes = await fetch('https://www.googleapis.com/drive/v3/files?fields=id', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
})
if (!createRes.ok) throw new Error(`Drive klasör oluşturulamadı: ${await createRes.text()}`)
return (await createRes.json()).id
}
export async function uploadToGoogleDriveOAuth( export async function uploadToGoogleDriveOAuth(
credentialsJsonStr: string, credentialsJsonStr: string,
dbName: string,
filename: string, filename: string,
content: Buffer, content: Buffer,
parentFolderId?: string parentFolderId?: string
@@ -169,25 +199,24 @@ export async function uploadToGoogleDriveOAuth(
const { access_token, error: tokenErr } = await tokenRes.json() const { access_token, error: tokenErr } = await tokenRes.json()
if (tokenErr) throw new Error(`Drive token hatası: ${tokenErr}`) if (tokenErr) throw new Error(`Drive token hatası: ${tokenErr}`)
// DB adında alt klasör bul veya oluştur (OAuth ile yapıldığı için kullanıcıya ait — kota sorunu yok)
const targetFolderId = await getOrCreateFolderOAuth(access_token, dbName, parentFolderId || undefined)
const boundary = 'vps_panel_backup_boundary' const boundary = 'vps_panel_backup_boundary'
const meta: Record<string, unknown> = { name: filename } const meta = JSON.stringify({ name: filename, parents: [targetFolderId] })
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${JSON.stringify(meta)}\r\n`), 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/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,webViewLink', 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name',
{ {
method: 'POST', method: 'POST',
headers: { headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': `multipart/related; boundary=${boundary}` },
Authorization: `Bearer ${access_token}`,
'Content-Type': `multipart/related; boundary=${boundary}`,
},
body: new Uint8Array(parts), body: new Uint8Array(parts),
} }
) )
+40 -26
View File
@@ -1,5 +1,6 @@
import * as cron from 'node-cron' import * as cron from 'node-cron'
import pool from './appDb' import pool from './appDb'
import { getCloudConnection } from './appDb'
import { readConfig } from './config' import { readConfig } from './config'
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from './backup' import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from './backup'
@@ -9,48 +10,61 @@ export function startBackupCron() {
const activeTasks = new Map<string, cron.ScheduledTask>() const activeTasks = new Map<string, cron.ScheduledTask>()
export async function reloadBackupCrons() { async function runUpload(row: any, db: { name: string }, buffer: Buffer, filename: string) {
for (const [id, task] of activeTasks.entries()) { // connection_id varsa global bağlantıdan al, yoksa eski inline credentials kullan
task.stop() let type = row.cloud_type as string
let credentials = row.credentials as string
let target = (row.gdrive_folder_id as string) || ''
if (row.connection_id) {
const conn = await getCloudConnection(row.connection_id)
if (!conn) throw new Error(`Cloud bağlantısı bulunamadı: ${row.connection_id}`)
type = conn.type
credentials = conn.credentials
// Per-DB override yoksa bağlantının default_target'ını kullan
if (!target && conn.default_target) target = conn.default_target
} }
if (type === 'dropbox') {
await uploadToDropbox(credentials.trim(), db.name, filename, buffer)
} else if (type === 'gcs') {
await uploadToGCS(credentials, target, db.name, filename, buffer)
} else if (type === 'gdrive') {
await uploadToGoogleDriveOAuth(credentials, db.name, filename, buffer, target || undefined)
} else {
throw new Error(`Bilinmeyen cloud tipi: ${type}`)
}
}
export async function reloadBackupCrons() {
for (const [, task] of activeTasks.entries()) task.stop()
activeTasks.clear() activeTasks.clear()
try { try {
const config = await readConfig() const config = await readConfig()
const { rows } = await pool.query(`SELECT * FROM backup_configs`) const { rows } = await pool.query(`SELECT * FROM backup_configs`)
for (const row of rows) { for (const row of rows) {
const db = config.databases.find(d => d.id === row.db_id) const db = config.databases.find(d => d.id === row.db_id)
if (!db) continue if (!db) continue
const task = cron.schedule(row.schedule, async () => { const task = cron.schedule(row.schedule, async () => {
try { const effectiveType = row.connection_id
console.log(`[Backup] ${db.name} (${row.cloud_type}) başlatılıyor...`) ? (await getCloudConnection(row.connection_id))?.type ?? row.cloud_type
: row.cloud_type
const buffer = await createDbDumpBuffer(db)
const filename = backupFilename(db.name)
let message = 'Success'
try { console.log(`[Backup] ${db.name} (${effectiveType}) başlatılıyor...`)
if (row.cloud_type === 'dropbox') { try {
await uploadToDropbox(row.credentials, db.name, filename, buffer) const buffer = await createDbDumpBuffer(db)
} else if (row.cloud_type === 'gcs') { const filename = effectiveType === 'gdrive' ? backupFilename() : backupFilename(db.name)
await uploadToGCS(row.credentials, row.gdrive_folder_id, db.name, filename, buffer)
} else if (row.cloud_type === 'gdrive') { await runUpload(row, db, buffer, filename)
await uploadToGoogleDriveOAuth(row.credentials, filename, buffer, row.gdrive_folder_id || undefined)
}
} catch (uploadErr: any) {
console.error(`[Backup] Upload hatası:`, uploadErr)
message = `Upload failed: ${uploadErr.message}`
throw uploadErr // fail the backup log
}
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', message, buffer.length] [db.id, 'success', 'Otomatik yedek', buffer.length]
) )
console.log(`[Backup] ${db.name} başarıyla tamamlandı.`) console.log(`[Backup] ${db.name} tamamlandı.`)
} catch (e: any) { } catch (e: any) {
console.error(`[Backup] Hata (${db.name}):`, e) console.error(`[Backup] Hata (${db.name}):`, e)
await pool.query( await pool.query(