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
This commit is contained in:
sirpiglr 2025-12-13 04:15:06 +00:00
parent 6a7bbb3066
commit 03c30058f1
4 changed files with 1308 additions and 0 deletions

View file

@ -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
// =============================================================================

View file

@ -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:<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: `<t:${Math.floor(new Date(backup.created_at).getTime() / 1000)}:R>`, 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: `<t:${Math.floor(new Date(backup.created_at).getTime() / 1000)}:F>`, 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 <t:${Math.floor(new Date(backup.created_at).getTime() / 1000)}:R>`)
.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;

View file

@ -1196,6 +1196,9 @@
<div class="nav-item" data-page="federation">
<span class="nav-icon">🌐</span> Federation
</div>
<div class="nav-item" data-page="backups">
<span class="nav-icon">💾</span> Backups
</div>
</div>
</nav>
@ -2268,6 +2271,108 @@
</div>
</div>
</div>
<div id="page-backups" class="page hidden">
<div class="page-header">
<h1 class="page-title">Server <span class="text-gradient">Backups</span></h1>
<p class="page-subtitle">Create and manage configuration backups for your server</p>
</div>
<div class="stats-grid" id="backupStatsGrid">
<div class="stat-card">
<div class="stat-label">Total Backups</div>
<div class="stat-value" id="backupTotalCount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Auto Backups</div>
<div class="stat-value" id="backupAutoCount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Last Backup</div>
<div class="stat-value" id="backupLastTime">-</div>
</div>
</div>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<h3 class="card-title">Create Backup</h3>
<button class="btn btn-primary" onclick="createBackup()">Create New Backup</button>
</div>
<div class="card-body">
<div class="form-group" style="margin-bottom:1rem">
<label class="form-label">Backup Name</label>
<input type="text" class="form-input" id="backupName" placeholder="My Server Backup">
</div>
<div class="form-group">
<label class="form-label">Description (optional)</label>
<input type="text" class="form-input" id="backupDescription" placeholder="Before major changes...">
</div>
</div>
</div>
<div class="card" style="margin-top:1.5rem">
<div class="card-header">
<h3 class="card-title">Automatic Backups</h3>
</div>
<div class="card-body">
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;align-items:end">
<div class="form-group" style="flex:1;min-width:150px">
<label class="form-label">Auto Backup</label>
<select class="form-select" id="backupAutoEnabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</div>
<div class="form-group" style="flex:1;min-width:150px">
<label class="form-label">Interval (hours)</label>
<input type="number" class="form-input" id="backupInterval" value="24" min="1" max="168">
</div>
<div class="form-group" style="flex:1;min-width:150px">
<label class="form-label">Keep Backups</label>
<input type="number" class="form-input" id="backupMaxKeep" value="7" min="1" max="30">
</div>
<button class="btn btn-primary" onclick="saveBackupSettings()">Save Settings</button>
</div>
</div>
</div>
<div class="card" style="margin-top:1.5rem">
<div class="card-header">
<h3 class="card-title">Available Backups</h3>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Roles</th>
<th>Channels</th>
<th>Size</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="backupsList">
<tr><td colspan="7" class="empty-state">Loading backups...</td></tr>
</tbody>
</table>
</div>
</div>
<div id="backupModal" class="modal hidden">
<div class="modal-backdrop" onclick="closeBackupModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="backupModalTitle">Backup Details</h3>
<button class="modal-close" onclick="closeBackupModal()">&times;</button>
</div>
<div class="modal-body" id="backupModalBody">
Loading...
</div>
</div>
</div>
</div>
</main>
</div>
@ -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 = '<tr><td colspan="7" class="empty-state">No backups yet. Create your first backup above.</td></tr>';
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 '<tr>' +
'<td><strong>' + escapeHtml(b.name) + '</strong><br><small style="color:var(--muted)">' + escapeHtml(b.description || '') + '</small></td>' +
'<td>' + typeIcon + '</td>' +
'<td>' + b.roles_count + '</td>' +
'<td>' + b.channels_count + '</td>' +
'<td>' + size + '</td>' +
'<td>' + date + '</td>' +
'<td><button class="btn btn-secondary" style="padding:0.4rem 0.6rem;margin-right:0.25rem" onclick="viewBackup(\'' + b.id + '\')">View</button>' +
'<button class="btn btn-secondary" style="padding:0.4rem 0.6rem;background:var(--danger)" onclick="deleteBackup(\'' + b.id + '\')">Delete</button></td>' +
'</tr>';
}).join('');
} catch (e) {
console.error('Failed to load backups:', e);
document.getElementById('backupsList').innerHTML = '<tr><td colspan="7" class="empty-state">Failed to load backups</td></tr>';
}
}
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 =
'<div style="margin-bottom:1rem"><strong>Created:</strong> ' + new Date(b.created_at).toLocaleString() + '</div>' +
'<div style="margin-bottom:1rem"><strong>Type:</strong> ' + (b.backup_type === 'auto' ? 'Automatic' : 'Manual') + '</div>' +
'<div style="margin-bottom:1rem"><strong>Description:</strong> ' + escapeHtml(b.description || 'None') + '</div>' +
'<h4 style="margin:1rem 0 0.5rem">Roles (' + (bd.roles?.length || 0) + ')</h4>' +
'<div style="max-height:150px;overflow-y:auto;background:var(--secondary);padding:0.75rem;border-radius:8px;font-size:0.85rem">' +
(bd.roles?.map(r => '<span style="display:inline-block;margin:0.2rem;padding:0.2rem 0.5rem;background:var(--card);border-radius:4px;color:#' + (r.color || 'ffffff').toString(16).padStart(6,'0') + '">' + escapeHtml(r.name) + '</span>').join('') || 'No roles') +
'</div>' +
'<h4 style="margin:1rem 0 0.5rem">Channels (' + (bd.channels?.length || 0) + ')</h4>' +
'<div style="max-height:150px;overflow-y:auto;background:var(--secondary);padding:0.75rem;border-radius:8px;font-size:0.85rem">' +
(bd.channels?.map(c => '<span style="display:inline-block;margin:0.2rem;padding:0.2rem 0.5rem;background:var(--card);border-radius:4px">#' + escapeHtml(c.name) + '</span>').join('') || 'No channels') +
'</div>';
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();
</script>
</body>

View file

@ -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',