diff --git a/api/backup.js b/api/backup.js new file mode 100644 index 0000000..253ab50 --- /dev/null +++ b/api/backup.js @@ -0,0 +1,136 @@ +const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co'; +const SUPABASE_ANON_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv'; + +const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY; +const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET; +const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN; +const BACKUP_SECRET = process.env.BACKUP_SECRET; + +const DROPBOX_BACKUP_FOLDER = '/CrazeBackups'; +const MAX_BACKUP_AGE_DAYS = 7; + +async function getDropboxToken() { + const res = await fetch('https://api.dropboxapi.com/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: DROPBOX_REFRESH_TOKEN, + client_id: DROPBOX_APP_KEY, + client_secret: DROPBOX_APP_SECRET, + }), + }); + const data = await res.json(); + if (!data.access_token) throw new Error('Dropbox token failed: ' + JSON.stringify(data)); + return data.access_token; +} + +async function fetchSupabaseTable(table) { + const PAGE_SIZE = 1000; + const rows = []; + let offset = 0; + for (let page = 0; page < 50; page++) { + const res = await fetch( + `${SUPABASE_URL}/rest/v1/${table}?select=*&limit=${PAGE_SIZE}&offset=${offset}`, + { headers: { apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}` } } + ); + if (!res.ok) throw new Error(`Supabase ${table} fetch failed: ${res.status}`); + const batch = await res.json(); + rows.push(...batch); + if (batch.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + return rows; +} + +async function uploadToDropbox(token, path, content) { + const res = await fetch('https://content.dropboxapi.com/2/files/upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/octet-stream', + 'Dropbox-API-Arg': JSON.stringify({ path, mode: 'overwrite', autorename: false }), + }, + body: content, + }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`Dropbox upload failed (${res.status}): ${txt}`); + } + return res.json(); +} + +async function deleteOldBackups(token) { + const listRes = await fetch('https://api.dropboxapi.com/2/files/list_folder', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: DROPBOX_BACKUP_FOLDER, limit: 200 }), + }); + if (!listRes.ok) return; // folder may not exist yet — skip + const list = await listRes.json(); + const cutoff = Date.now() - MAX_BACKUP_AGE_DAYS * 24 * 60 * 60 * 1000; + + for (const entry of list.entries || []) { + if (entry['.tag'] !== 'file') continue; + const modified = new Date(entry.server_modified).getTime(); + if (modified < cutoff) { + await fetch('https://api.dropboxapi.com/2/files/delete_v2', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: entry.path_lower }), + }); + } + } +} + +export default async function handler(req, res) { + // Allow cron (no origin) or requests with valid secret + const authHeader = req.headers['authorization']; + const isCron = !req.headers.origin; + const hasSecret = BACKUP_SECRET && authHeader === `Bearer ${BACKUP_SECRET}`; + + if (!isCron && !hasSecret) { + return res.status(403).json({ error: 'Forbidden' }); + } + + if (req.method !== 'GET' && req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const [syncedRows, history] = await Promise.all([ + fetchSupabaseTable('synced_rows'), + fetchSupabaseTable('item_history'), + ]); + + const now = new Date(); + const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `backup_${timestamp}.json`; + const path = `${DROPBOX_BACKUP_FOLDER}/${filename}`; + + const payload = JSON.stringify({ + created_at: now.toISOString(), + synced_rows_count: syncedRows.length, + history_count: history.length, + synced_rows: syncedRows, + history, + }); + + const token = await getDropboxToken(); + await Promise.all([ + uploadToDropbox(token, path, payload), + deleteOldBackups(token), + ]); + + console.log(`[backup] OK — ${filename} | rows: ${syncedRows.length} | history: ${history.length}`); + return res.json({ + success: true, + filename, + synced_rows_count: syncedRows.length, + history_count: history.length, + }); + } catch (err) { + console.error('[backup] error:', err.message); + return res.status(500).json({ success: false, error: err.message }); + } +} diff --git a/vercel.json b/vercel.json index 6d7ae89..221d9d4 100644 --- a/vercel.json +++ b/vercel.json @@ -3,7 +3,11 @@ { "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" }, { "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" }, { "source": "/api/bc-proxy", "destination": "api/bc-proxy.js" }, - { "source": "/api/bc-export", "destination": "api/bc-export.js" } + { "source": "/api/bc-export", "destination": "api/bc-export.js" }, + { "source": "/api/backup", "destination": "api/backup.js" } + ], + "crons": [ + { "path": "/api/backup", "schedule": "0 0,6,12,18 * * *" } ], "headers": [ {