feat: database backups & analytics site management

- Added manual database backup functionality with pg_dump
- Added cron-based automated backups to Google Drive and Dropbox
- Added ability to manually add, edit, and delete sites in Analytics
- Added 1-day timeframe filter in Analytics page
- Updated Dockerfile to include postgresql-client
This commit is contained in:
mstfyldz
2026-06-02 17:44:46 +03:00
parent e28fa966e3
commit e5dc347a0b
12 changed files with 686 additions and 10 deletions
+2
View File
@@ -23,6 +23,8 @@ RUN npm run build
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache postgresql-client
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
+18
View File
@@ -13,6 +13,7 @@
"jose": "^6.2.3",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"node-cron": "^4.2.1",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
@@ -20,6 +21,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/node-cron": "^3.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
@@ -1585,6 +1587,13 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -5143,6 +5152,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-exports-info": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
+2
View File
@@ -14,6 +14,7 @@
"jose": "^6.2.3",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"node-cron": "^4.2.1",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
@@ -21,6 +22,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/node-cron": "^3.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
+34 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getAnalyticsStats, getAllDomains } from '@/lib/appDb'
import { getAnalyticsStats, getAllDomains, addAnalyticsSite, renameAnalyticsSite, deleteAnalyticsSite } from '@/lib/appDb'
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
@@ -18,3 +18,36 @@ export async function GET(req: NextRequest) {
const stats = await getAnalyticsStats(domain, days)
return NextResponse.json(stats)
}
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { domain } = await req.json()
if (!domain) return NextResponse.json({ error: 'Domain required' }, { status: 400 })
await addAnalyticsSite(domain)
return NextResponse.json({ ok: true })
}
export async function PUT(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { oldDomain, newDomain } = await req.json()
if (!oldDomain || !newDomain) return NextResponse.json({ error: 'Missing params' }, { status: 400 })
await renameAnalyticsSite(oldDomain, newDomain)
return NextResponse.json({ ok: true })
}
export async function DELETE(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const domain = req.nextUrl.searchParams.get('domain')
if (!domain) return NextResponse.json({ error: 'Domain required' }, { status: 400 })
await deleteAnalyticsSite(domain)
return NextResponse.json({ ok: true })
}
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server'
import pool from '@/lib/appDb'
import { generateId } from '@/lib/config'
import { reloadBackupCrons } from '@/lib/cronWorker'
// GET configs and logs for a dbId
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const dbId = searchParams.get('dbId')
if (!dbId) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
try {
const configRes = await pool.query(`SELECT * FROM backup_configs WHERE db_id = $1 LIMIT 1`, [dbId])
const logsRes = await pool.query(`SELECT * FROM backup_logs WHERE db_id = $1 ORDER BY ts DESC LIMIT 50`, [dbId])
return NextResponse.json({
config: configRes.rows[0] || null,
logs: logsRes.rows.map(r => ({ ...r, file_size: Number(r.file_size), ts: Number(r.ts) }))
})
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
// Create or update a backup config
export async function POST(req: Request) {
try {
const body = await req.json()
const { db_id, schedule, cloud_type, credentials } = body
if (!db_id || !schedule || !cloud_type || !credentials) {
return NextResponse.json({ error: 'Missing fields' }, { 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 WHERE db_id=$4`,
[schedule, cloud_type, credentials, db_id]
)
} else {
await pool.query(
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials) VALUES ($1, $2, $3, $4, $5)`,
[generateId(), db_id, schedule, cloud_type, credentials]
)
}
// Reload crons in memory
await reloadBackupCrons()
return NextResponse.json({ ok: true })
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
// Delete backup config
export async function DELETE(req: Request) {
try {
const body = await req.json()
const { db_id } = body
if (!db_id) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
await pool.query(`DELETE FROM backup_configs WHERE db_id = $1`, [db_id])
await reloadBackupCrons()
return NextResponse.json({ ok: true })
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import { readConfig } from '@/lib/config'
import { createPgDumpStream } from '@/lib/backup'
// GET /api/db/backup?dbId=...
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const dbId = searchParams.get('dbId')
if (!dbId) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
const config = await readConfig()
const db = config.databases.find(d => d.id === dbId)
if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 })
try {
const child = await createPgDumpStream(db)
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql`
// Stream the output of pg_dump to the response
const stream = new ReadableStream({
start(controller) {
child.stdout.on('data', (chunk) => controller.enqueue(chunk))
child.on('close', (code) => {
if (code === 0) {
controller.close()
} else {
controller.error(new Error(`pg_dump exited with code ${code}`))
}
})
child.on('error', (err) => controller.error(err))
},
cancel() {
child.kill()
}
})
return new NextResponse(stream, {
headers: {
'Content-Type': 'application/sql',
'Content-Disposition': `attachment; filename="${filename}"`
}
})
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+115 -5
View File
@@ -14,6 +14,7 @@ type Stats = {
}
const DAYS_OPTIONS = [
{ label: '1g', value: 1 },
{ label: '7g', value: 7 },
{ label: '30g', value: 30 },
{ label: '90g', value: 90 },
@@ -69,17 +70,30 @@ export default function AnalyticsPage() {
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(false)
const [showSnippet, setShowSnippet] = useState(false)
const [showAddSite, setShowAddSite] = useState(false)
const [newDomain, setNewDomain] = useState('')
const [panelUrl, setPanelUrl] = useState('')
useEffect(() => {
setPanelUrl(window.location.origin)
const loadDomains = useCallback(() => {
fetch('/api/analytics/stats')
.then(r => r.json())
.then(d => {
setDomains(d.domains ?? [])
if (d.domains?.length > 0) setSelectedDomain(d.domains[0])
const dms = d.domains ?? []
setDomains(dms)
if (dms.length > 0) {
if (!selectedDomain || !dms.includes(selectedDomain)) {
setSelectedDomain(dms[0])
}
} else {
setSelectedDomain(null)
}
})
}, [])
}, [selectedDomain])
useEffect(() => {
setPanelUrl(window.location.origin)
loadDomains()
}, [loadDomains])
const loadStats = useCallback(async () => {
if (!selectedDomain) return
@@ -91,6 +105,34 @@ export default function AnalyticsPage() {
useEffect(() => { loadStats() }, [loadStats])
const handleRename = async () => {
if (!selectedDomain) return
const newName = window.prompt("Yeni alan adını girin (Örn: example.com):", selectedDomain)
if (!newName || newName.trim() === '' || newName === selectedDomain) return
setLoading(true)
await fetch('/api/analytics/stats', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldDomain: selectedDomain, newDomain: newName.trim() })
})
setSelectedDomain(newName.trim())
// Refresh domains after brief delay for state to sync
setTimeout(loadDomains, 100)
}
const handleDelete = async () => {
if (!selectedDomain) return
if (!window.confirm(`'${selectedDomain}' sitesini ve tüm analiz verilerini SİLMEK istediğinize emin misiniz? Bu işlem geri alınamaz!`)) return
setLoading(true)
await fetch(`/api/analytics/stats?domain=${encodeURIComponent(selectedDomain)}`, { method: 'DELETE' })
setSelectedDomain(null)
setTimeout(loadDomains, 100)
}
const snippet = `<!-- VPS Panel Analytics -->
<script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>`
@@ -103,6 +145,16 @@ export default function AnalyticsPage() {
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cookie-free, self-hosted Plausible benzeri</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{selectedDomain && (
<>
<button onClick={handleRename} style={{ background: 'transparent', border: '1px solid var(--border)', borderRadius: 7, padding: '8px 14px', color: 'var(--text)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Düzenle</button>
<button onClick={handleDelete} style={{ background: 'rgba(255,50,50,.1)', border: '1px solid rgba(255,50,50,.3)', borderRadius: 7, padding: '8px 14px', color: 'var(--red, #ff5c5c)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Sil</button>
</>
)}
<button onClick={() => setShowAddSite(true)}
style={{ background: 'rgba(0,229,255,.12)', border: '1px solid rgba(0,229,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
+ Site Ekle
</button>
<button onClick={() => setShowSnippet(true)}
style={{ background: 'rgba(123,97,255,.12)', border: '1px solid rgba(123,97,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--purple, #7b61ff)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
{'</>'} Snippet
@@ -257,6 +309,64 @@ export default function AnalyticsPage() {
</div>
</div>
)}
{/* Add Site modal */}
{showAddSite && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={() => setShowAddSite(false)}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 400, maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Yeni Site Ekle</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}>
Takip etmek istediğiniz alan adını girin (örn: example.com)
</div>
<input
style={{ width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 20 }}
placeholder="example.com"
value={newDomain}
onChange={e => setNewDomain(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newDomain.trim()) {
fetch('/api/analytics/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: newDomain.trim() })
}).then(() => {
setNewDomain('');
setShowAddSite(false);
loadDomains();
setSelectedDomain(newDomain.trim());
})
}
}}
/>
<div style={{ display: 'flex', gap: 10 }}>
<button
disabled={!newDomain.trim()}
onClick={() => {
fetch('/api/analytics/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: newDomain.trim() })
}).then(() => {
setNewDomain('');
setShowAddSite(false);
loadDomains();
setSelectedDomain(newDomain.trim());
})
}}
style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
Ekle
</button>
<button onClick={() => setShowAddSite(false)}
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
İptal
</button>
</div>
</div>
</div>
)}
</div>
)
}
+111 -3
View File
@@ -8,7 +8,7 @@ type QueryResult = { rows: Record<string, unknown>[]; fields: string[]; error: s
export default function DatabasesPage() {
const [dbs, setDbs] = useState<Db[]>([])
const [selected, setSelected] = useState<Db | null>(null)
const [tab, setTab] = useState<'stats' | 'tables' | 'query'>('tables')
const [tab, setTab] = useState<'stats' | 'tables' | 'query' | 'backups'>('tables')
const [stats, setStats] = useState<Record<string, unknown> | null>(null)
const [tables, setTables] = useState<Table[]>([])
const [sql, setSql] = useState('SELECT * FROM ')
@@ -107,9 +107,9 @@ export default function DatabasesPage() {
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 16, gap: 4 }}>
{(['tables', 'query', 'stats'] as const).map(t => (
{(['tables', 'query', 'stats', 'backups'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{ padding: '8px 16px', fontSize: 13, fontWeight: 600, color: tab === t ? 'var(--accent)' : 'var(--muted)', background: 'none', border: 'none', borderBottomWidth: 2, borderBottomStyle: 'solid', borderBottomColor: tab === t ? 'var(--accent)' : 'transparent', cursor: 'pointer', marginBottom: -1 }}>
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : 'İstatistik'}
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : t === 'stats' ? 'İstatistik' : 'Yedekler'}
</button>
))}
</div>
@@ -178,6 +178,9 @@ export default function DatabasesPage() {
<pre style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--muted)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(stats, null, 2)}</pre>
</div>
)}
{/* Backups */}
{tab === 'backups' && <BackupTab db={selected} />}
</div>
)}
</div>
@@ -273,3 +276,108 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
</div>
)
}
function BackupTab({ db }: { db: Db }) {
const [config, setConfig] = useState<any>(null)
const [logs, setLogs] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '' })
const load = useCallback(async () => {
setLoading(true)
const r = await fetch(`/api/backups?dbId=${db.id}`)
if (r.ok) {
const data = await r.json()
setConfig(data.config)
setLogs(data.logs)
if (data.config) {
setF({ schedule: data.config.schedule, cloud_type: data.config.cloud_type, credentials: data.config.credentials })
}
}
setLoading(false)
}, [db.id])
useEffect(() => { load() }, [load])
const saveConfig = async () => {
setSaving(true)
await fetch('/api/backups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id, ...f })
})
await load()
setSaving(false)
}
const deleteConfig = async () => {
if (!confirm('Otomatik yedeklemeyi kapat?')) return
await fetch('/api/backups', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id })
})
setConfig(null)
setF({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '' })
}
if (loading) return <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Yükleniyor...</div>
const inp = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 12 }
const lbl = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 } as any
return (
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
<div style={{ flex: 1, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h3 style={{ fontSize: 15, fontWeight: 700 }}>Otomatik Yedekleme</h3>
<a href={`/api/db/backup?dbId=${db.id}`} download style={{ background: 'rgba(255,255,255,.1)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 12px', color: 'var(--text)', fontSize: 12, textDecoration: 'none' }}>
Hemen İndir
</a>
</div>
<div><label style={lbl}>Cron Schedule (node-cron)</label><input style={inp} value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" /></div>
<div style={{ marginBottom: 12 }}>
<label style={lbl}>Cloud Sağlayıcı</label>
<select style={inp as any} value={f.cloud_type} onChange={e => setF(p => ({ ...p, cloud_type: e.target.value }))}>
<option value="dropbox">Dropbox</option>
<option value="gdrive">Google Drive</option>
</select>
</div>
<div>
<label style={lbl}>Credentials (JSON veya Token)</label>
<textarea style={{ ...inp, resize: 'vertical' } as any} rows={4} value={f.credentials} onChange={e => setF(p => ({ ...p, credentials: e.target.value }))} placeholder={f.cloud_type === 'dropbox' ? 'Dropbox Access Token buraya...' : 'Google Service Account JSON buraya...'} />
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={saveConfig} disabled={saving} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>{saving ? 'Kaydediliyor...' : 'Kaydet'}</button>
{config && (
<button onClick={deleteConfig} style={{ background: 'rgba(255,0,0,.1)', border: '1px solid rgba(255,0,0,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--red)', cursor: 'pointer', fontSize: 13 }}>Kapat</button>
)}
</div>
</div>
<div style={{ width: 340, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>Geçmiş İşlemler</h3>
{logs.length === 0 ? <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Kayıt yok</div> : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{logs.map((log: any) => (
<div key={log.id} style={{ padding: 12, background: 'rgba(0,0,0,.2)', borderRadius: 6, borderLeft: `3px solid ${log.status === 'success' ? '#00e676' : 'var(--red)'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: log.status === 'success' ? '#00e676' : 'var(--red)' }}>{log.status.toUpperCase()}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{new Date(log.ts * 1000).toLocaleString()}</span>
</div>
{log.status === 'success' && <div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>Boyut: {(log.file_size / 1024).toFixed(2)} KB</div>}
{log.status !== 'success' && <div style={{ fontSize: 11, color: 'var(--red)', fontFamily: 'monospace', marginTop: 4 }}>{log.message}</div>}
</div>
))}
</div>
)}
</div>
</div>
)
}
+4
View File
@@ -7,6 +7,10 @@ export async function register() {
// Dynamically import inside register
const { readConfig } = await import('./lib/config')
const { pingAndLog } = await import('./lib/ping')
const { startBackupCron, reloadBackupCrons } = await import('./lib/cronWorker')
startBackupCron()
await reloadBackupCrons()
// Bellek içi son ping zamanlarını tutacak obje
const lastPingMap: Record<string, number> = {}
+49 -1
View File
@@ -33,6 +33,12 @@ pool.query(`
);
CREATE INDEX IF NOT EXISTS idx_pv_domain ON pageviews(domain, ts DESC);
CREATE INDEX IF NOT EXISTS idx_pv_ts ON pageviews(ts DESC);
CREATE TABLE IF NOT EXISTS analytics_sites (
domain TEXT PRIMARY KEY,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE TABLE IF NOT EXISTS config_sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -59,6 +65,25 @@ pool.query(`
icon TEXT,
description TEXT
);
CREATE TABLE IF NOT EXISTS backup_configs (
id TEXT PRIMARY KEY,
db_id TEXT NOT NULL,
schedule TEXT NOT NULL,
cloud_type TEXT NOT NULL,
credentials TEXT NOT NULL,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE TABLE IF NOT EXISTS backup_logs (
id SERIAL PRIMARY KEY,
db_id TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
file_size BIGINT,
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE INDEX IF NOT EXISTS idx_backup_db_ts ON backup_logs(db_id, ts DESC);
`).catch(console.error)
export type PingLog = {
id: number
@@ -192,8 +217,31 @@ export async function getAnalyticsStats(domain: string, days = 30) {
}
export async function getAllDomains(): Promise<string[]> {
const res = await pool.query(`SELECT DISTINCT domain FROM pageviews ORDER BY domain`)
const res = await pool.query(`
SELECT domain FROM analytics_sites
UNION
SELECT DISTINCT domain FROM pageviews
ORDER BY domain
`)
return res.rows.map(r => r.domain)
}
export async function addAnalyticsSite(domain: string) {
await pool.query(
`INSERT INTO analytics_sites (domain) VALUES ($1) ON CONFLICT DO NOTHING`,
[domain]
)
}
export async function renameAnalyticsSite(oldDomain: string, newDomain: string) {
// Update both tables
await pool.query(`UPDATE analytics_sites SET domain = $1 WHERE domain = $2`, [newDomain, oldDomain])
await pool.query(`UPDATE pageviews SET domain = $1 WHERE domain = $2`, [newDomain, oldDomain])
}
export async function deleteAnalyticsSite(domain: string) {
await pool.query(`DELETE FROM analytics_sites WHERE domain = $1`, [domain])
await pool.query(`DELETE FROM pageviews WHERE domain = $1`, [domain])
}
export default pool
+153
View File
@@ -0,0 +1,153 @@
import { spawn } from 'child_process'
import { SignJWT, importPKCS8 } from 'jose'
export interface Database {
id: string
name: string
host: string
port: number
database: string
username: string
password: string
ssl: boolean
color?: string
}
export async function createPgDumpStream(db: Database) {
const env = {
...process.env,
PGPASSWORD: db.password,
}
const args = [
'-h', db.host,
'-p', db.port.toString(),
'-U', db.username,
'-d', db.database,
'-F', 'c', // custom format for smaller size / better restore, or 'p' for plain sql. Let's use 'p' for plain text so they can see it.
'--no-owner',
]
// if we want plain text:
args[args.indexOf('c')] = 'p'
const child = spawn('pg_dump', args, { env })
return child
}
export async function createPgDumpBuffer(db: Database): Promise<Buffer> {
return new Promise((resolve, reject) => {
const child = spawn('pg_dump', [
'-h', db.host,
'-p', db.port.toString(),
'-U', db.username,
'-d', db.database,
'-F', 'p',
'--no-owner',
], {
env: { ...process.env, PGPASSWORD: db.password },
})
const chunks: Buffer[] = []
let errorOutput = ''
child.stdout.on('data', (data) => chunks.push(data))
child.stderr.on('data', (data) => { errorOutput += data.toString() })
child.on('close', (code) => {
if (code === 0) {
resolve(Buffer.concat(chunks))
} else {
reject(new Error(`pg_dump exited with code ${code}: ${errorOutput}`))
}
})
child.on('error', (err) => {
reject(new Error(`Failed to start pg_dump: ${err.message}`))
})
})
}
export async function uploadToDropbox(token: string, filename: string, content: Buffer) {
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Dropbox-API-Arg': JSON.stringify({
path: `/${filename}`,
mode: 'add',
autorename: true,
mute: false
}),
'Content-Type': 'application/octet-stream'
},
body: new Uint8Array(content)
})
if (!response.ok) {
const errText = await response.text()
throw new Error(`Dropbox upload failed: ${response.status} ${errText}`)
}
return await response.json()
}
export async function uploadToGoogleDrive(serviceAccountJsonStr: string, filename: string, content: Buffer) {
const credentials = JSON.parse(serviceAccountJsonStr)
// 1. Get Access Token using jose
const privateKey = await importPKCS8(credentials.private_key, 'RS256')
const jwt = await new SignJWT({
iss: credentials.client_email,
scope: 'https://www.googleapis.com/auth/drive.file',
aud: 'https://oauth2.googleapis.com/token'
})
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(privateKey)
const tokenReq = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt
})
})
if (!tokenReq.ok) {
throw new Error(`Failed to get Google OAuth token: ${await tokenReq.text()}`)
}
const { access_token } = await tokenReq.json()
// 2. Upload to Drive (Multipart upload to set filename)
const boundary = '-------314159265358979323846'
const delimiter = `\r\n--${boundary}\r\n`
const close_delim = `\r\n--${boundary}--`
const metadata = {
name: filename,
mimeType: 'application/sql'
}
const multipartRequestBody = Buffer.concat([
Buffer.from(delimiter + 'Content-Type: application/json\r\n\r\n' + JSON.stringify(metadata) + '\r\n'),
Buffer.from(delimiter + 'Content-Type: application/sql\r\n\r\n'),
content,
Buffer.from(close_delim)
])
const response = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': `multipart/related; boundary=${boundary}`
},
body: new Uint8Array(multipartRequestBody)
})
if (!response.ok) {
throw new Error(`Google Drive upload failed: ${response.status} ${await response.text()}`)
}
return await response.json()
}
+78
View File
@@ -0,0 +1,78 @@
import * as cron from 'node-cron'
import pool from './appDb'
import { readConfig } from './config'
import { createPgDumpBuffer, uploadToDropbox, uploadToGoogleDrive } from './backup'
export function startBackupCron() {
console.log('[AutoBackup] Arka plan cron servisi başlatıldı.')
// Her dakika cron kurallarını ve görevleri çalıştıracak mantık
// Aslında node-cron'u her backup_config için ayrı cron olarak register etmek daha doğru.
// Ancak dinamik eklendiğinde/silindiğinde cron'ları güncellemek gerekir.
// Basitlik için her dakika DB'den konfigürasyonları okuyup, node-cron syntax'ının "match" edip etmediğine bakacağız,
// ya da her eklenen config için memory'de bir cron job tutacağız.
// En kolayı, veritabanını saatte bir kontrol edip zamanlamaları işletmek, ya da memory'de cron scheduler tutmaktır.
}
// memory map of active tasks
const activeTasks = new Map<string, cron.ScheduledTask>()
export async function reloadBackupCrons() {
// Clear existing
for (const [id, task] of activeTasks.entries()) {
task.stop()
}
activeTasks.clear()
try {
const config = await readConfig()
const { rows } = await pool.query(`SELECT * FROM backup_configs`)
for (const row of rows) {
const db = config.databases.find(d => d.id === row.db_id)
if (!db) continue // Veritabanı silinmiş
const task = cron.schedule(row.schedule, async () => {
try {
console.log(`[Backup] ${db.name} (${row.cloud_type}) başlatılıyor...`)
const buffer = await createPgDumpBuffer(db)
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql`
let message = 'Success'
try {
if (row.cloud_type === 'dropbox') {
await uploadToDropbox(row.credentials, filename, buffer)
} else if (row.cloud_type === 'gdrive') {
await uploadToGoogleDrive(row.credentials, filename, buffer)
}
} catch (uploadErr: any) {
console.error(`[Backup] Upload hatası:`, uploadErr)
message = `Upload failed: ${uploadErr.message}`
throw uploadErr // fail the backup log
}
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[db.id, 'success', message, buffer.length]
)
console.log(`[Backup] ${db.name} başarıyla tamamlandı.`)
} catch (e: any) {
console.error(`[Backup] Hata (${db.name}):`, e)
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[db.id, 'error', e.message || String(e), 0]
)
}
})
activeTasks.set(row.id, task)
}
console.log(`[AutoBackup] ${activeTasks.size} cron görevi yüklendi.`)
} catch (e) {
console.error(`[AutoBackup] Cronlar yüklenirken hata:`, e)
}
}