From 03c30058f1bc84bc432bb08e08cb941984f31a3d Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Sat, 13 Dec 2025 04:15:06 +0000 Subject: [PATCH] Add server backup and restore functionality for administrators Implements a new backup command with subcommands for creating, listing, viewing, restoring, deleting, configuring auto-backups, and comparing server states. Adds API endpoints for managing backups and integrates backup features into the dashboard UI. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 5b902c89-a0d5-43f0-923c-324d0f4e4e7d Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/TibkBZT Replit-Helium-Checkpoint-Created: true --- aethex-bot/bot.js | 184 +++++++++ aethex-bot/commands/backup.js | 677 +++++++++++++++++++++++++++++++ aethex-bot/public/dashboard.html | 278 +++++++++++++ aethex-bot/server/webServer.js | 169 ++++++++ 4 files changed, 1308 insertions(+) create mode 100644 aethex-bot/commands/backup.js diff --git a/aethex-bot/bot.js b/aethex-bot/bot.js index 94d7c9a..bf0c846 100644 --- a/aethex-bot/bot.js +++ b/aethex-bot/bot.js @@ -984,6 +984,106 @@ client.on("interactionCreate", async (interaction) => { } } } + + if (action === 'backup') { + const backupAction = params[0]; + + if (backupAction === 'restore' || backupAction === 'delete') { + const backupId = params[1]; + + if (!interaction.member.permissions.has(PermissionFlagsBits.Administrator)) { + return interaction.reply({ content: 'Only administrators can manage backups.', ephemeral: true }); + } + + if (backupAction === 'delete') { + try { + const { data: backup } = await supabase + .from('server_backups') + .select('name') + .eq('id', backupId) + .eq('guild_id', interaction.guildId) + .single(); + + if (!backup) { + return interaction.reply({ content: 'Backup not found.', ephemeral: true }); + } + + await supabase.from('server_backups').delete().eq('id', backupId); + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('Backup Deleted') + .setDescription(`Successfully deleted backup: **${backup.name}**`) + .setTimestamp(); + + await interaction.update({ embeds: [embed], components: [] }); + } catch (err) { + console.error('Backup delete button error:', err); + await interaction.reply({ content: 'Failed to delete backup.', ephemeral: true }).catch(() => {}); + } + } + } + + if (backupAction === 'confirm') { + if (params[1] === 'restore') { + const backupId = params[2]; + const components = params[3] || 'all'; + + try { + await interaction.deferUpdate(); + + const { data: backup } = await supabase + .from('server_backups') + .select('*') + .eq('id', backupId) + .eq('guild_id', interaction.guildId) + .single(); + + if (!backup) { + return interaction.editReply({ content: 'Backup not found.', embeds: [], components: [] }); + } + + const { createBackup, performRestore } = require('./commands/backup'); + + const preRestoreBackup = await createBackup(interaction.guild, supabase, interaction.guildId); + await supabase.from('server_backups').insert({ + guild_id: interaction.guildId, + name: `Pre-restore backup (${new Date().toLocaleDateString()})`, + description: 'Automatic backup before restore', + backup_type: 'auto', + created_by: interaction.user.id, + data: preRestoreBackup, + roles_count: preRestoreBackup.roles?.length || 0, + channels_count: preRestoreBackup.channels?.length || 0, + size_bytes: JSON.stringify(preRestoreBackup).length + }); + + const results = await performRestore(interaction.guild, backup.data, components, supabase); + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('Restore Complete') + .setDescription(`Restored from backup: **${backup.name}**`) + .addFields( + { name: 'Roles', value: `Created: ${results.roles.created} | Skipped: ${results.roles.skipped} | Failed: ${results.roles.failed}`, inline: false }, + { name: 'Channels', value: `Created: ${results.channels.created} | Skipped: ${results.channels.skipped} | Failed: ${results.channels.failed}`, inline: false }, + { name: 'Bot Config', value: results.botConfig ? 'Restored' : 'Not restored', inline: true } + ) + .setFooter({ text: 'A backup was created before this restore' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed], components: [] }); + } catch (err) { + console.error('Backup restore button error:', err); + await interaction.editReply({ content: 'Failed to restore backup.', embeds: [], components: [] }).catch(() => {}); + } + } + } + + if (backupAction === 'cancel') { + await interaction.update({ content: 'Restore cancelled.', embeds: [], components: [] }); + } + } } if (interaction.isModalSubmit()) { @@ -2549,8 +2649,92 @@ client.once("clientReady", async () => { } sendAlert(`Warden is now online! Watching ${client.guilds.cache.size} servers.`); + + // Start automatic backup scheduler + if (supabase) { + startAutoBackupScheduler(client, supabase); + } }); +// ============================================================================= +// AUTOMATIC BACKUP SCHEDULER +// ============================================================================= + +async function startAutoBackupScheduler(discordClient, supabaseClient) { + console.log('[Backup] Starting automatic backup scheduler...'); + + // Check every hour for servers that need backups + setInterval(async () => { + try { + const { data: settings } = await supabaseClient + .from('backup_settings') + .select('*') + .eq('auto_enabled', true); + + if (!settings || settings.length === 0) return; + + for (const setting of settings) { + const guild = discordClient.guilds.cache.get(setting.guild_id); + if (!guild) continue; + + // Check if backup is needed + const { data: lastBackup } = await supabaseClient + .from('server_backups') + .select('created_at') + .eq('guild_id', setting.guild_id) + .eq('backup_type', 'auto') + .order('created_at', { ascending: false }) + .limit(1) + .single(); + + const intervalMs = (setting.interval_hours || 24) * 60 * 60 * 1000; + const lastBackupTime = lastBackup ? new Date(lastBackup.created_at).getTime() : 0; + const now = Date.now(); + + if (now - lastBackupTime >= intervalMs) { + console.log(`[Backup] Creating auto backup for guild ${guild.name}`); + + const { createBackup } = require('./commands/backup'); + const backupData = await createBackup(guild, supabaseClient, setting.guild_id); + + await supabaseClient.from('server_backups').insert({ + guild_id: setting.guild_id, + name: `Auto Backup - ${new Date().toLocaleDateString()}`, + description: 'Scheduled automatic backup', + backup_type: 'auto', + created_by: null, + data: backupData, + roles_count: backupData.roles?.length || 0, + channels_count: backupData.channels?.length || 0, + size_bytes: JSON.stringify(backupData).length + }); + + // Clean up old backups + const maxBackups = setting.max_backups || 7; + const { data: allBackups } = await supabaseClient + .from('server_backups') + .select('id') + .eq('guild_id', setting.guild_id) + .eq('backup_type', 'auto') + .order('created_at', { ascending: false }); + + if (allBackups && allBackups.length > maxBackups) { + const toDelete = allBackups.slice(maxBackups).map(b => b.id); + await supabaseClient.from('server_backups').delete().in('id', toDelete); + console.log(`[Backup] Cleaned up ${toDelete.length} old backups for ${guild.name}`); + } + + console.log(`[Backup] Auto backup complete for ${guild.name}`); + } + } + } catch (error) { + console.error('[Backup] Auto backup error:', error.message); + } + }, 60 * 60 * 1000); // Check every hour + + console.log('[Backup] Scheduler started - checking every hour'); +} + // ============================================================================= // ERROR HANDLING // ============================================================================= diff --git a/aethex-bot/commands/backup.js b/aethex-bot/commands/backup.js new file mode 100644 index 0000000..e3d543a --- /dev/null +++ b/aethex-bot/commands/backup.js @@ -0,0 +1,677 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } = require('discord.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('backup') + .setDescription('Server backup and restore system') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(subcommand => + subcommand + .setName('create') + .setDescription('Create a backup of server configuration') + .addStringOption(option => + option.setName('name') + .setDescription('Name for this backup') + .setRequired(false) + ) + .addStringOption(option => + option.setName('description') + .setDescription('Description of this backup') + .setRequired(false) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('List all available backups') + ) + .addSubcommand(subcommand => + subcommand + .setName('view') + .setDescription('View details of a specific backup') + .addStringOption(option => + option.setName('id') + .setDescription('Backup ID to view') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('restore') + .setDescription('Restore from a backup') + .addStringOption(option => + option.setName('id') + .setDescription('Backup ID to restore') + .setRequired(true) + ) + .addStringOption(option => + option.setName('components') + .setDescription('What to restore') + .setRequired(false) + .addChoices( + { name: 'Everything', value: 'all' }, + { name: 'Roles Only', value: 'roles' }, + { name: 'Channels Only', value: 'channels' }, + { name: 'Bot Config Only', value: 'bot_config' } + ) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('delete') + .setDescription('Delete a backup') + .addStringOption(option => + option.setName('id') + .setDescription('Backup ID to delete') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('auto') + .setDescription('Configure automatic backups') + .addBooleanOption(option => + option.setName('enabled') + .setDescription('Enable or disable auto backups') + .setRequired(true) + ) + .addIntegerOption(option => + option.setName('interval') + .setDescription('Backup interval in hours (default: 24)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(168) + ) + .addIntegerOption(option => + option.setName('keep') + .setDescription('Number of backups to keep (default: 7)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(30) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('compare') + .setDescription('Compare current server state with a backup') + .addStringOption(option => + option.setName('id') + .setDescription('Backup ID to compare against') + .setRequired(true) + ) + ), + + async execute(interaction, supabase, client) { + const subcommand = interaction.options.getSubcommand(); + + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Database Not Connected') + .setDescription('Backup system requires database connection.') + .setTimestamp(); + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + + if (subcommand === 'create') { + await interaction.deferReply(); + + const backupName = interaction.options.getString('name') || `Backup ${new Date().toLocaleDateString()}`; + const description = interaction.options.getString('description') || 'Manual backup'; + + try { + const backupData = await createBackup(interaction.guild, supabase, interaction.guildId); + + const { data: backup, error } = await supabase.from('server_backups').insert({ + guild_id: interaction.guildId, + name: backupName, + description: description, + backup_type: 'manual', + created_by: interaction.user.id, + data: backupData, + roles_count: backupData.roles?.length || 0, + channels_count: backupData.channels?.length || 0, + size_bytes: JSON.stringify(backupData).length + }).select().single(); + + if (error) throw error; + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('Backup Created Successfully') + .setDescription(`Your server configuration has been saved.`) + .addFields( + { name: 'Backup ID', value: `\`${backup.id}\``, inline: true }, + { name: 'Name', value: backupName, inline: true }, + { name: 'Created By', value: `<@${interaction.user.id}>`, inline: true }, + { name: 'Roles Saved', value: `${backupData.roles?.length || 0}`, inline: true }, + { name: 'Channels Saved', value: `${backupData.channels?.length || 0}`, inline: true }, + { name: 'Size', value: formatBytes(backup.size_bytes), inline: true } + ) + .setFooter({ text: `Use /backup restore id:${backup.id} to restore` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Backup creation error:', error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Backup Failed') + .setDescription(`Failed to create backup: ${error.message}`) + .setTimestamp(); + await interaction.editReply({ embeds: [embed] }); + } + } + + else if (subcommand === 'list') { + try { + const { data: backups, error } = await supabase + .from('server_backups') + .select('id, name, description, backup_type, created_at, created_by, roles_count, channels_count, size_bytes') + .eq('guild_id', interaction.guildId) + .order('created_at', { ascending: false }) + .limit(15); + + if (error) throw error; + + if (!backups || backups.length === 0) { + const embed = new EmbedBuilder() + .setColor(0xffa500) + .setTitle('No Backups Found') + .setDescription('This server has no backups. Create one with `/backup create`') + .setTimestamp(); + return interaction.reply({ embeds: [embed] }); + } + + const backupList = backups.map((b, i) => { + const date = new Date(b.created_at).toLocaleDateString(); + const typeIcon = b.backup_type === 'auto' ? '🔄' : '📁'; + return `${typeIcon} **${b.name}**\n> ID: \`${b.id}\` | ${date} | ${b.roles_count} roles, ${b.channels_count} channels`; + }).join('\n\n'); + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle(`Server Backups (${backups.length})`) + .setDescription(backupList) + .setFooter({ text: 'Use /backup view id: for details' }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } catch (error) { + console.error('List backups error:', error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Error') + .setDescription('Failed to list backups.') + .setTimestamp(); + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + } + + else if (subcommand === 'view') { + const backupId = interaction.options.getString('id'); + + try { + const { data: backup, error } = await supabase + .from('server_backups') + .select('*') + .eq('id', backupId) + .eq('guild_id', interaction.guildId) + .single(); + + if (error || !backup) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Backup Not Found') + .setDescription('Could not find a backup with that ID for this server.') + .setTimestamp(); + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + + const data = backup.data; + const rolesPreview = data.roles?.slice(0, 5).map(r => r.name).join(', ') || 'None'; + const channelsPreview = data.channels?.slice(0, 5).map(c => `#${c.name}`).join(', ') || 'None'; + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle(`Backup: ${backup.name}`) + .setDescription(backup.description || 'No description') + .addFields( + { name: 'Backup ID', value: `\`${backup.id}\``, inline: true }, + { name: 'Type', value: backup.backup_type === 'auto' ? '🔄 Automatic' : '📁 Manual', inline: true }, + { name: 'Created', value: ``, inline: true }, + { name: 'Created By', value: backup.created_by ? `<@${backup.created_by}>` : 'System', inline: true }, + { name: 'Size', value: formatBytes(backup.size_bytes), inline: true }, + { name: '\u200b', value: '\u200b', inline: true }, + { name: `Roles (${backup.roles_count})`, value: rolesPreview + (data.roles?.length > 5 ? '...' : ''), inline: false }, + { name: `Channels (${backup.channels_count})`, value: channelsPreview + (data.channels?.length > 5 ? '...' : ''), inline: false } + ) + .setFooter({ text: `Use /backup restore id:${backup.id} to restore` }) + .setTimestamp(); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`backup_restore_${backup.id}`) + .setLabel('Restore This Backup') + .setStyle(ButtonStyle.Primary) + .setEmoji('🔄'), + new ButtonBuilder() + .setCustomId(`backup_delete_${backup.id}`) + .setLabel('Delete') + .setStyle(ButtonStyle.Danger) + .setEmoji('🗑️') + ); + + await interaction.reply({ embeds: [embed], components: [row] }); + } catch (error) { + console.error('View backup error:', error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Error') + .setDescription('Failed to view backup.') + .setTimestamp(); + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + } + + else if (subcommand === 'restore') { + const backupId = interaction.options.getString('id'); + const components = interaction.options.getString('components') || 'all'; + + try { + const { data: backup, error } = await supabase + .from('server_backups') + .select('*') + .eq('id', backupId) + .eq('guild_id', interaction.guildId) + .single(); + + if (error || !backup) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Backup Not Found') + .setDescription('Could not find a backup with that ID for this server.') + .setTimestamp(); + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + + const confirmEmbed = new EmbedBuilder() + .setColor(0xffa500) + .setTitle('⚠️ Confirm Restore') + .setDescription(`Are you sure you want to restore from **${backup.name}**?\n\nThis will restore: **${components === 'all' ? 'Everything' : components}**\n\n**Warning:** This action may modify your server's roles and channels. A new backup will be created first.`) + .addFields( + { name: 'Backup Date', value: ``, inline: true }, + { name: 'Components', value: components, inline: true } + ) + .setTimestamp(); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`backup_confirm_restore_${backupId}_${components}`) + .setLabel('Yes, Restore') + .setStyle(ButtonStyle.Danger) + .setEmoji('✅'), + new ButtonBuilder() + .setCustomId('backup_cancel_restore') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + ); + + await interaction.reply({ embeds: [confirmEmbed], components: [row], ephemeral: true }); + } catch (error) { + console.error('Restore backup error:', error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Error') + .setDescription('Failed to initiate restore.') + .setTimestamp(); + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + } + + else if (subcommand === 'delete') { + const backupId = interaction.options.getString('id'); + + try { + const { data: backup, error: fetchError } = await supabase + .from('server_backups') + .select('id, name') + .eq('id', backupId) + .eq('guild_id', interaction.guildId) + .single(); + + if (fetchError || !backup) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Backup Not Found') + .setDescription('Could not find a backup with that ID for this server.') + .setTimestamp(); + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + + const { error: deleteError } = await supabase + .from('server_backups') + .delete() + .eq('id', backupId); + + if (deleteError) throw deleteError; + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('Backup Deleted') + .setDescription(`Successfully deleted backup: **${backup.name}**`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } catch (error) { + console.error('Delete backup error:', error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Error') + .setDescription('Failed to delete backup.') + .setTimestamp(); + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + } + + else if (subcommand === 'auto') { + const enabled = interaction.options.getBoolean('enabled'); + const interval = interaction.options.getInteger('interval') || 24; + const keep = interaction.options.getInteger('keep') || 7; + + try { + await supabase.from('backup_settings').upsert({ + guild_id: interaction.guildId, + auto_enabled: enabled, + interval_hours: interval, + max_backups: keep, + updated_at: new Date().toISOString(), + updated_by: interaction.user.id + }); + + const embed = new EmbedBuilder() + .setColor(enabled ? 0x00ff00 : 0xffa500) + .setTitle(`Auto Backups ${enabled ? 'Enabled' : 'Disabled'}`) + .setDescription(enabled + ? `Automatic backups are now **enabled** for this server.` + : `Automatic backups have been **disabled**.` + ) + .setTimestamp(); + + if (enabled) { + embed.addFields( + { name: 'Interval', value: `Every ${interval} hour${interval === 1 ? '' : 's'}`, inline: true }, + { name: 'Backups Kept', value: `${keep}`, inline: true } + ); + } + + await interaction.reply({ embeds: [embed] }); + } catch (error) { + console.error('Auto backup settings error:', error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Error') + .setDescription('Failed to update auto backup settings.') + .setTimestamp(); + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + } + + else if (subcommand === 'compare') { + await interaction.deferReply(); + const backupId = interaction.options.getString('id'); + + try { + const { data: backup, error } = await supabase + .from('server_backups') + .select('*') + .eq('id', backupId) + .eq('guild_id', interaction.guildId) + .single(); + + if (error || !backup) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Backup Not Found') + .setDescription('Could not find a backup with that ID for this server.') + .setTimestamp(); + return interaction.editReply({ embeds: [embed] }); + } + + const currentData = await createBackup(interaction.guild, supabase, interaction.guildId); + const backupData = backup.data; + + const currentRoleNames = new Set(currentData.roles.map(r => r.name)); + const backupRoleNames = new Set(backupData.roles?.map(r => r.name) || []); + const addedRoles = currentData.roles.filter(r => !backupRoleNames.has(r.name)).map(r => r.name); + const removedRoles = (backupData.roles || []).filter(r => !currentRoleNames.has(r.name)).map(r => r.name); + + const currentChannelNames = new Set(currentData.channels.map(c => c.name)); + const backupChannelNames = new Set(backupData.channels?.map(c => c.name) || []); + const addedChannels = currentData.channels.filter(c => !backupChannelNames.has(c.name)).map(c => `#${c.name}`); + const removedChannels = (backupData.channels || []).filter(c => !currentChannelNames.has(c.name)).map(c => `#${c.name}`); + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle(`Comparison: ${backup.name}`) + .setDescription(`Comparing current server state against backup from `) + .addFields( + { name: '➕ Roles Added Since Backup', value: addedRoles.length > 0 ? addedRoles.slice(0, 10).join(', ') + (addedRoles.length > 10 ? '...' : '') : 'None', inline: false }, + { name: '➖ Roles Removed Since Backup', value: removedRoles.length > 0 ? removedRoles.slice(0, 10).join(', ') + (removedRoles.length > 10 ? '...' : '') : 'None', inline: false }, + { name: '➕ Channels Added', value: addedChannels.length > 0 ? addedChannels.slice(0, 10).join(', ') + (addedChannels.length > 10 ? '...' : '') : 'None', inline: false }, + { name: '➖ Channels Removed', value: removedChannels.length > 0 ? removedChannels.slice(0, 10).join(', ') + (removedChannels.length > 10 ? '...' : '') : 'None', inline: false } + ) + .setFooter({ text: 'Note: Permission and setting changes are not shown in this comparison' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Compare backup error:', error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Error') + .setDescription('Failed to compare backup.') + .setTimestamp(); + await interaction.editReply({ embeds: [embed] }); + } + } + } +}; + +async function createBackup(guild, supabase, guildId) { + const roles = guild.roles.cache + .filter(r => !r.managed && r.id !== guild.id) + .map(role => ({ + id: role.id, + name: role.name, + color: role.color, + hoist: role.hoist, + position: role.position, + permissions: role.permissions.bitfield.toString(), + mentionable: role.mentionable + })) + .sort((a, b) => b.position - a.position); + + const channels = guild.channels.cache + .filter(c => c.type !== 4) + .map(channel => ({ + id: channel.id, + name: channel.name, + type: channel.type, + position: channel.position, + parentId: channel.parentId, + topic: channel.topic || null, + nsfw: channel.nsfw || false, + rateLimitPerUser: channel.rateLimitPerUser || 0, + permissionOverwrites: channel.permissionOverwrites?.cache.map(po => ({ + id: po.id, + type: po.type, + allow: po.allow.bitfield.toString(), + deny: po.deny.bitfield.toString() + })) || [] + })) + .sort((a, b) => a.position - b.position); + + const categories = guild.channels.cache + .filter(c => c.type === 4) + .map(cat => ({ + id: cat.id, + name: cat.name, + position: cat.position, + permissionOverwrites: cat.permissionOverwrites?.cache.map(po => ({ + id: po.id, + type: po.type, + allow: po.allow.bitfield.toString(), + deny: po.deny.bitfield.toString() + })) || [] + })) + .sort((a, b) => a.position - b.position); + + let botConfig = null; + if (supabase) { + const { data } = await supabase + .from('server_config') + .select('*') + .eq('guild_id', guildId) + .single(); + botConfig = data; + } + + let levelRoles = []; + if (supabase) { + const { data } = await supabase + .from('level_roles') + .select('*') + .eq('guild_id', guildId); + levelRoles = data || []; + } + + let automodConfig = null; + if (supabase) { + const { data } = await supabase + .from('automod_config') + .select('*') + .eq('guild_id', guildId) + .single(); + automodConfig = data; + } + + return { + guildInfo: { + name: guild.name, + icon: guild.iconURL(), + ownerId: guild.ownerId, + memberCount: guild.memberCount + }, + roles, + channels, + categories, + botConfig, + levelRoles, + automodConfig, + backupVersion: '1.0' + }; +} + +async function performRestore(guild, backupData, components, supabase) { + const results = { + roles: { created: 0, failed: 0, skipped: 0 }, + channels: { created: 0, failed: 0, skipped: 0 }, + botConfig: false + }; + + if (components === 'all' || components === 'roles') { + const existingRoles = new Set(guild.roles.cache.map(r => r.name)); + + for (const roleData of backupData.roles || []) { + if (existingRoles.has(roleData.name)) { + results.roles.skipped++; + continue; + } + + try { + await guild.roles.create({ + name: roleData.name, + color: roleData.color, + hoist: roleData.hoist, + permissions: BigInt(roleData.permissions), + mentionable: roleData.mentionable, + reason: 'Restored from backup' + }); + results.roles.created++; + } catch (e) { + results.roles.failed++; + } + } + } + + if (components === 'all' || components === 'channels') { + const existingChannels = new Set(guild.channels.cache.map(c => c.name)); + + for (const catData of backupData.categories || []) { + if (existingChannels.has(catData.name)) continue; + + try { + await guild.channels.create({ + name: catData.name, + type: 4, + reason: 'Restored from backup' + }); + } catch (e) { + console.error('Failed to create category:', e.message); + } + } + + for (const channelData of backupData.channels || []) { + if (existingChannels.has(channelData.name)) { + results.channels.skipped++; + continue; + } + + try { + const parent = channelData.parentId + ? guild.channels.cache.find(c => c.name === (backupData.categories?.find(cat => cat.id === channelData.parentId)?.name)) + : null; + + await guild.channels.create({ + name: channelData.name, + type: channelData.type, + topic: channelData.topic, + nsfw: channelData.nsfw, + rateLimitPerUser: channelData.rateLimitPerUser, + parent: parent?.id, + reason: 'Restored from backup' + }); + results.channels.created++; + } catch (e) { + results.channels.failed++; + } + } + } + + if ((components === 'all' || components === 'bot_config') && supabase && backupData.botConfig) { + try { + await supabase.from('server_config').upsert({ + ...backupData.botConfig, + guild_id: guild.id, + updated_at: new Date().toISOString() + }); + results.botConfig = true; + } catch (e) { + console.error('Failed to restore bot config:', e.message); + } + } + + return results; +} + +function formatBytes(bytes) { + if (!bytes || bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +module.exports.createBackup = createBackup; +module.exports.performRestore = performRestore; diff --git a/aethex-bot/public/dashboard.html b/aethex-bot/public/dashboard.html index fa9bec9..9415138 100644 --- a/aethex-bot/public/dashboard.html +++ b/aethex-bot/public/dashboard.html @@ -1196,6 +1196,9 @@ + @@ -2268,6 +2271,108 @@ + + @@ -2452,6 +2557,7 @@ case 'analytics': loadAnalyticsData(); break; case 'activity-roles': loadActivityRoles(); break; case 'cooldowns': loadCooldowns(); break; + case 'backups': loadBackups(); break; } } @@ -4231,6 +4337,178 @@ } } + async function loadBackups() { + if (!currentGuild) return; + + try { + const [backupsRes, settingsRes] = await Promise.all([ + fetch('/api/guild/' + currentGuild + '/backups'), + fetch('/api/guild/' + currentGuild + '/backup-settings') + ]); + + const backupsData = await backupsRes.json(); + const settingsData = await settingsRes.json(); + const backups = backupsData.backups || []; + const settings = settingsData.settings || {}; + + document.getElementById('backupTotalCount').textContent = backups.length; + document.getElementById('backupAutoCount').textContent = backups.filter(b => b.backup_type === 'auto').length; + + if (backups.length > 0) { + const lastBackup = new Date(backups[0].created_at); + document.getElementById('backupLastTime').textContent = lastBackup.toLocaleDateString(); + } else { + document.getElementById('backupLastTime').textContent = 'Never'; + } + + document.getElementById('backupAutoEnabled').value = settings.auto_enabled ? 'true' : 'false'; + document.getElementById('backupInterval').value = settings.interval_hours || 24; + document.getElementById('backupMaxKeep').value = settings.max_backups || 7; + + const tbody = document.getElementById('backupsList'); + if (backups.length === 0) { + tbody.innerHTML = 'No backups yet. Create your first backup above.'; + return; + } + + tbody.innerHTML = backups.map(b => { + const date = new Date(b.created_at).toLocaleString(); + const typeIcon = b.backup_type === 'auto' ? '🔄 Auto' : '📁 Manual'; + const size = formatBytes(b.size_bytes); + return '' + + '' + escapeHtml(b.name) + '
' + escapeHtml(b.description || '') + '' + + '' + typeIcon + '' + + '' + b.roles_count + '' + + '' + b.channels_count + '' + + '' + size + '' + + '' + date + '' + + '' + + '' + + ''; + }).join(''); + } catch (e) { + console.error('Failed to load backups:', e); + document.getElementById('backupsList').innerHTML = 'Failed to load backups'; + } + } + + function formatBytes(bytes) { + if (!bytes || bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } + + async function createBackup() { + if (!currentGuild) return; + + const name = document.getElementById('backupName').value || 'Dashboard Backup'; + const description = document.getElementById('backupDescription').value || ''; + + try { + const res = await fetch('/api/guild/' + currentGuild + '/backups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + + if (res.ok) { + document.getElementById('backupName').value = ''; + document.getElementById('backupDescription').value = ''; + await loadBackups(); + alert('Backup created successfully!'); + } else { + const err = await res.json(); + alert(err.error || 'Failed to create backup'); + } + } catch (e) { + alert('Failed to create backup'); + } + } + + async function saveBackupSettings() { + if (!currentGuild) return; + + const auto_enabled = document.getElementById('backupAutoEnabled').value === 'true'; + const interval_hours = parseInt(document.getElementById('backupInterval').value) || 24; + const max_backups = parseInt(document.getElementById('backupMaxKeep').value) || 7; + + try { + const res = await fetch('/api/guild/' + currentGuild + '/backup-settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ auto_enabled, interval_hours, max_backups }) + }); + + if (res.ok) { + alert('Backup settings saved!'); + } else { + alert('Failed to save settings'); + } + } catch (e) { + alert('Failed to save settings'); + } + } + + async function viewBackup(backupId) { + if (!currentGuild) return; + + try { + const res = await fetch('/api/guild/' + currentGuild + '/backups/' + backupId); + const data = await res.json(); + + if (!data.backup) { + alert('Backup not found'); + return; + } + + const b = data.backup; + const bd = b.data; + + document.getElementById('backupModalTitle').textContent = 'Backup: ' + b.name; + document.getElementById('backupModalBody').innerHTML = + '
Created: ' + new Date(b.created_at).toLocaleString() + '
' + + '
Type: ' + (b.backup_type === 'auto' ? 'Automatic' : 'Manual') + '
' + + '
Description: ' + escapeHtml(b.description || 'None') + '
' + + '

Roles (' + (bd.roles?.length || 0) + ')

' + + '
' + + (bd.roles?.map(r => '' + escapeHtml(r.name) + '').join('') || 'No roles') + + '
' + + '

Channels (' + (bd.channels?.length || 0) + ')

' + + '
' + + (bd.channels?.map(c => '#' + escapeHtml(c.name) + '').join('') || 'No channels') + + '
'; + + document.getElementById('backupModal').classList.remove('hidden'); + } catch (e) { + alert('Failed to load backup details'); + } + } + + function closeBackupModal() { + document.getElementById('backupModal').classList.add('hidden'); + } + + async function deleteBackup(backupId) { + if (!confirm('Are you sure you want to delete this backup? This cannot be undone.')) return; + if (!currentGuild) return; + + try { + const res = await fetch('/api/guild/' + currentGuild + '/backups/' + backupId, { + method: 'DELETE' + }); + + if (res.ok) { + await loadBackups(); + } else { + alert('Failed to delete backup'); + } + } catch (e) { + alert('Failed to delete backup'); + } + } + init(); diff --git a/aethex-bot/server/webServer.js b/aethex-bot/server/webServer.js index 9436c00..72cb6af 100644 --- a/aethex-bot/server/webServer.js +++ b/aethex-bot/server/webServer.js @@ -2592,6 +2592,175 @@ function createWebServer(discordClient, supabase, options = {}) { } }); + // ============================================================================= + // BACKUP MANAGEMENT API + // ============================================================================= + + app.get('/api/guild/:guildId/backups', async (req, res) => { + const { guildId } = req.params; + + if (!supabase) { + return res.status(503).json({ error: 'Database not connected' }); + } + + try { + const { data: backups, error } = await supabase + .from('server_backups') + .select('id, name, description, backup_type, created_at, created_by, roles_count, channels_count, size_bytes') + .eq('guild_id', guildId) + .order('created_at', { ascending: false }) + .limit(50); + + if (error) throw error; + + res.json({ backups: backups || [] }); + } catch (error) { + console.error('Failed to get backups:', error); + res.status(500).json({ error: 'Failed to fetch backups' }); + } + }); + + app.get('/api/guild/:guildId/backups/:backupId', async (req, res) => { + const { guildId, backupId } = req.params; + + if (!supabase) { + return res.status(503).json({ error: 'Database not connected' }); + } + + try { + const { data: backup, error } = await supabase + .from('server_backups') + .select('*') + .eq('id', backupId) + .eq('guild_id', guildId) + .single(); + + if (error || !backup) { + return res.status(404).json({ error: 'Backup not found' }); + } + + res.json({ backup }); + } catch (error) { + console.error('Failed to get backup:', error); + res.status(500).json({ error: 'Failed to fetch backup' }); + } + }); + + app.post('/api/guild/:guildId/backups', async (req, res) => { + const { guildId } = req.params; + const { name, description } = req.body; + + if (!supabase) { + return res.status(503).json({ error: 'Database not connected' }); + } + + try { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) { + return res.status(404).json({ error: 'Guild not found' }); + } + + const { createBackup } = require('../commands/backup'); + const backupData = await createBackup(guild, supabase, guildId); + + const { data: backup, error } = await supabase.from('server_backups').insert({ + guild_id: guildId, + name: name || `Dashboard Backup - ${new Date().toLocaleDateString()}`, + description: description || 'Created from dashboard', + backup_type: 'manual', + created_by: req.session?.user?.id || null, + data: backupData, + roles_count: backupData.roles?.length || 0, + channels_count: backupData.channels?.length || 0, + size_bytes: JSON.stringify(backupData).length + }).select().single(); + + if (error) throw error; + + res.json({ success: true, backup }); + } catch (error) { + console.error('Failed to create backup:', error); + res.status(500).json({ error: 'Failed to create backup' }); + } + }); + + app.delete('/api/guild/:guildId/backups/:backupId', async (req, res) => { + const { guildId, backupId } = req.params; + + if (!supabase) { + return res.status(503).json({ error: 'Database not connected' }); + } + + try { + const { error } = await supabase + .from('server_backups') + .delete() + .eq('id', backupId) + .eq('guild_id', guildId); + + if (error) throw error; + + res.json({ success: true }); + } catch (error) { + console.error('Failed to delete backup:', error); + res.status(500).json({ error: 'Failed to delete backup' }); + } + }); + + app.get('/api/guild/:guildId/backup-settings', async (req, res) => { + const { guildId } = req.params; + + if (!supabase) { + return res.status(503).json({ error: 'Database not connected' }); + } + + try { + const { data: settings } = await supabase + .from('backup_settings') + .select('*') + .eq('guild_id', guildId) + .single(); + + res.json({ + settings: settings || { + auto_enabled: false, + interval_hours: 24, + max_backups: 7 + } + }); + } catch (error) { + console.error('Failed to get backup settings:', error); + res.status(500).json({ error: 'Failed to fetch backup settings' }); + } + }); + + app.post('/api/guild/:guildId/backup-settings', async (req, res) => { + const { guildId } = req.params; + const { auto_enabled, interval_hours, max_backups } = req.body; + + if (!supabase) { + return res.status(503).json({ error: 'Database not connected' }); + } + + try { + const { error } = await supabase.from('backup_settings').upsert({ + guild_id: guildId, + auto_enabled: auto_enabled ?? false, + interval_hours: interval_hours || 24, + max_backups: max_backups || 7, + updated_at: new Date().toISOString(), + updated_by: req.session?.user?.id || null + }); + + if (error) throw error; + + res.json({ success: true }); + } catch (error) { + console.error('Failed to update backup settings:', error); + res.status(500).json({ error: 'Failed to update backup settings' }); + } + }); + app.get('/health', (req, res) => { res.json({ status: 'ok',