From a4c58135df97d34f5a45086ca32c531ced90f25a Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Sat, 6 Jun 2026 00:31:10 +0300 Subject: [PATCH] feat: add cloud connections and update backups --- src/app/api/backups/route.ts | 21 +- src/app/api/backups/test/route.ts | 46 ++-- .../gdrive/callback/route.ts | 46 ++++ src/app/api/cloud-connections/gdrive/route.ts | 40 +++ src/app/api/cloud-connections/route.ts | 37 +++ src/app/dashboard/connections/page.tsx | 244 ++++++++++++++++++ src/app/dashboard/databases/page.tsx | 221 +++++----------- src/components/Sidebar.tsx | 3 +- src/lib/appDb.ts | 41 +++ src/lib/backup.ts | 45 +++- src/lib/cronWorker.ts | 66 +++-- 11 files changed, 589 insertions(+), 221 deletions(-) create mode 100644 src/app/api/cloud-connections/gdrive/callback/route.ts create mode 100644 src/app/api/cloud-connections/gdrive/route.ts create mode 100644 src/app/api/cloud-connections/route.ts create mode 100644 src/app/dashboard/connections/page.tsx diff --git a/src/app/api/backups/route.ts b/src/app/api/backups/route.ts index b7a2fd5..5d9481e 100644 --- a/src/app/api/backups/route.ts +++ b/src/app/api/backups/route.ts @@ -33,24 +33,27 @@ export async function POST(req: NextRequest) { try { 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) { - return NextResponse.json({ error: 'Missing fields' }, { status: 400 }) + if (!db_id || !schedule) { + 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]) - + if (existing.rows.length > 0) { await pool.query( - `UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3, gdrive_folder_id=$4 WHERE db_id=$5`, - [schedule, cloud_type, credentials, gdrive_folder_id || null, db_id] + `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, connection_id || null, db_id] ) } else { await pool.query( - `INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials, gdrive_folder_id) VALUES ($1, $2, $3, $4, $5, $6)`, - [generateId(), db_id, schedule, cloud_type, credentials, gdrive_folder_id || null] + `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, connection_id || null] ) } diff --git a/src/app/api/backups/test/route.ts b/src/app/api/backups/test/route.ts index 6a1eb5d..b4a645d 100644 --- a/src/app/api/backups/test/route.ts +++ b/src/app/api/backups/test/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { requireAuth } from '@/lib/auth' import { readConfig } from '@/lib/config' +import { getCloudConnection } from '@/lib/appDb' import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from '@/lib/backup' import pool from '@/lib/appDb' @@ -8,47 +9,56 @@ export async function POST(req: NextRequest) { const authErr = await requireAuth(req) if (authErr) return authErr - const { db_id, cloud_type, credentials, gdrive_folder_id } = await req.json() - - if (!credentials?.trim()) return NextResponse.json({ ok: false, error: 'Credentials boş olamaz.' }) + const body = await req.json() + const { db_id, connection_id, cloud_type: inlineType, credentials: inlineCredentials, gdrive_folder_id } = body const config = await readConfig() const db = config.databases.find(d => d.id === db_id) 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 { const buffer = await createDbDumpBuffer(db) - if (cloud_type === 'dropbox') { + if (type === 'dropbox') { await uploadToDropbox(credentials.trim(), db.name, filename, buffer) - } else if (cloud_type === 'gcs') { - await uploadToGCS(credentials, gdrive_folder_id, db.name, filename, buffer) - } else if (cloud_type === 'gdrive') { - await uploadToGoogleDriveOAuth(credentials, filename, buffer, gdrive_folder_id || undefined) + } 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 { return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' }) } await pool.query( `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 ? `${(buffer.length / (1024 * 1024)).toFixed(2)} MB` : `${(buffer.length / 1024).toFixed(1)} KB` - return NextResponse.json({ - ok: true, - detail: `✓ Yedek yüklendi — ${db.name}/${filename} (${sizeStr})`, - }) + const pathStr = type === 'gdrive' ? `${db.name}/${filename}` : filename + return NextResponse.json({ ok: true, detail: `✓ Yüklendi — ${pathStr} (${sizeStr})` }) } catch (e: any) { - await pool.query( - `INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, - [db_id, 'error', e.message, 0] - ).catch(() => {}) + await pool.query(`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, [db_id, 'error', e.message, 0]).catch(() => {}) return NextResponse.json({ ok: false, error: e.message }) } } diff --git a/src/app/api/cloud-connections/gdrive/callback/route.ts b/src/app/api/cloud-connections/gdrive/callback/route.ts new file mode 100644 index 0000000..1c13f1f --- /dev/null +++ b/src/app/api/cloud-connections/gdrive/callback/route.ts @@ -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}`) +} diff --git a/src/app/api/cloud-connections/gdrive/route.ts b/src/app/api/cloud-connections/gdrive/route.ts new file mode 100644 index 0000000..e1f3db2 --- /dev/null +++ b/src/app/api/cloud-connections/gdrive/route.ts @@ -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) +} diff --git a/src/app/api/cloud-connections/route.ts b/src/app/api/cloud-connections/route.ts new file mode 100644 index 0000000..7c0b2a9 --- /dev/null +++ b/src/app/api/cloud-connections/route.ts @@ -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 }) +} diff --git a/src/app/dashboard/connections/page.tsx b/src/app/dashboard/connections/page.tsx new file mode 100644 index 0000000..333ef0c --- /dev/null +++ b/src/app/dashboard/connections/page.tsx @@ -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 = { 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([]) + const [showAdd, setShowAdd] = useState(false) + const [editConn, setEditConn] = useState(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 ( +
+
+
+

Cloud Bağlantıları

+

Bir kere kur, tüm DB yedekleri için kullan

+
+ +
+ + {notice && ( +
+ {notice.ok ? : } + {notice.msg} + +
+ )} + + {conns.length === 0 ? ( + + + +
Henüz bağlantı yok
+

GCS, Google Drive veya Dropbox bağlantısı ekle.

+ +
+
+ ) : ( +
+ {conns.map(conn => { + const gdriveOk = conn.type === 'gdrive' && isGdriveConnected(conn.credentials) + return ( + + +
+
+ {conn.name} + + {TYPE_LABELS[conn.type] ?? conn.type} + + {conn.type === 'gdrive' && ( + + {gdriveOk ? '✓ Bağlı' : '⚠ Yetki Yok'} + + )} +
+
+ {conn.default_target ? `Hedef: ${conn.default_target}` : 'Varsayılan hedef yok'} +
+
+
+ {conn.type === 'gdrive' && ( + + )} + + +
+
+
+ ) + })} +
+ )} + + {(showAdd || editConn) && ( + { setShowAdd(false); setEditConn(null) }} + onSaved={() => { load(); setShowAdd(false); setEditConn(null) }} + /> + )} +
+ ) +} + +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 ( +
+ e.stopPropagation()}> + + {conn ? 'Bağlantı Düzenle' : 'Yeni Cloud Bağlantısı'} + + +
+ + setName(e.target.value)} placeholder="Production Drive, Backup GCS…" /> +
+ +
+ +
+ {(['gcs', 'gdrive', 'dropbox'] as const).map(t => ( + + ))} +
+
+ + {/* GCS */} + {type === 'gcs' && ( + <> +
+ +