diff --git a/Catalogger.Backend/Api/GuildsController.Backup.cs b/Catalogger.Backend/Api/GuildsController.Backup.cs new file mode 100644 index 0000000..2c04fc4 --- /dev/null +++ b/Catalogger.Backend/Api/GuildsController.Backup.cs @@ -0,0 +1,117 @@ +// Copyright (C) 2021-present sam (starshines.gay) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +using System.Net; +using Catalogger.Backend.Api.Middleware; +using Catalogger.Backend.Database.Models; +using Microsoft.AspNetCore.Mvc; +using NodaTime; +using Remora.Discord.API; + +namespace Catalogger.Backend.Api; + +public partial class GuildsController +{ + [Authorize] + [HttpGet("config")] + public async Task ExportConfigAsync(string id) + { + var (guildId, _) = await ParseGuildAsync(id); + var guildConfig = await guildRepository.GetAsync(guildId); + + return Ok(await ToExport(guildConfig)); + } + + [Authorize] + [HttpPost("config")] + public async Task ImportConfigAsync( + string id, + [FromBody] GuildConfigExport export + ) + { + var (guildId, _) = await ParseGuildAsync(id); + if (export.Id != guildId.Value) + throw new ApiError( + HttpStatusCode.BadRequest, + ErrorCode.BadRequest, + "This backup is not from this server." + ); + + // Filter invites to *only* those that exist for this guild. + // Blame past me for not making (code, guild_id) a unique index >:| + var cachedInvites = (await inviteCache.TryGetAsync(guildId)).ToList(); + var invites = export.Invites.Where(i => cachedInvites.Any(ci => i.Code == ci.Code)); + + await guildRepository.ImportConfigAsync( + guildId.Value, + export.Channels, + export.BannedSystems, + export.KeyRoles + ); + + await inviteRepository.ImportInvitesAsync( + guildId, + invites.Select(i => new Invite + { + Code = i.Code, + Name = i.Name, + GuildId = guildId.Value, + }) + ); + + await watchlistRepository.ImportWatchlistAsync( + guildId, + export.Watchlist.Select(w => new Watchlist + { + GuildId = guildId.Value, + UserId = w.UserId, + ModeratorId = w.ModeratorId, + AddedAt = w.AddedAt, + Reason = w.Reason, + }) + ); + + return NoContent(); + } + + private async Task ToExport(Database.Models.Guild config) + { + var id = DiscordSnowflake.New(config.Id); + var invites = await inviteRepository.GetGuildInvitesAsync(id); + var watchlist = await watchlistRepository.GetGuildWatchlistAsync(id); + + return new GuildConfigExport( + config.Id, + config.Channels, + config.BannedSystems, + config.KeyRoles, + invites.Select(i => new InviteExport(i.Code, i.Name)), + watchlist.Select(w => new WatchlistExport(w.UserId, w.AddedAt, w.ModeratorId, w.Reason)) + ); + } + + public record GuildConfigExport( + ulong Id, + Database.Models.Guild.ChannelConfig Channels, + string[] BannedSystems, + ulong[] KeyRoles, + IEnumerable Invites, + IEnumerable Watchlist + ); + + public record InviteExport(string Code, string Name); + + public record WatchlistExport(ulong UserId, Instant AddedAt, ulong ModeratorId, string Reason); +} diff --git a/Catalogger.Backend/Api/GuildsController.Remove.cs b/Catalogger.Backend/Api/GuildsController.Remove.cs index 2d2ff5e..b877d6a 100644 --- a/Catalogger.Backend/Api/GuildsController.Remove.cs +++ b/Catalogger.Backend/Api/GuildsController.Remove.cs @@ -14,12 +14,15 @@ // along with this program. If not, see . using System.Net; +using System.Text; +using System.Text.Json; using Catalogger.Backend.Api.Middleware; using Catalogger.Backend.Bot; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Dapper; using Microsoft.AspNetCore.Mvc; +using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.Extensions.Embeds; namespace Catalogger.Backend.Api; @@ -40,6 +43,8 @@ public partial class GuildsController } var guildConfig = await guildRepository.GetAsync(guildId); + var export = await ToExport(guildConfig); + var logChannelId = webhookExecutor.GetLogChannel(guildConfig, LogChannelType.GuildUpdate) ?? webhookExecutor.GetLogChannel(guildConfig, LogChannelType.GuildMemberRemove); @@ -50,15 +55,25 @@ public partial class GuildsController var embed = new EmbedBuilder() .WithTitle("Catalogger is leaving this server") .WithDescription( - $"A moderator in this server ({currentUser.Tag}, <@{currentUser.Id}>) requested that Catalogger leave. " - + "All data related to this server will be deleted." + $""" + A moderator in this server ({currentUser.Tag}, <@{currentUser.Id}>) requested that Catalogger leave. + All data related to this server will be deleted. + + A backup of this server's configuration is attached to this message, + in case you want to use the bot again later. + """ ) .WithColour(DiscordUtils.Red) .WithCurrentTimestamp() .Build() .GetOrThrow(); - await webhookExecutor.SendLogAsync(logChannelId.Value, [embed], []); + var exportData = JsonSerializer.Serialize(export, JsonUtils.ApiJsonOptions); + var file = new FileData( + "config-backup.json", + new MemoryStream(Encoding.UTF8.GetBytes(exportData)) + ); + await webhookExecutor.SendLogAsync(logChannelId.Value, [embed], [file]); } else { @@ -125,7 +140,7 @@ public partial class GuildsController _logger.Information("Left guild {GuildId} and removed all data for it", guildId); - return NoContent(); + return Ok(export); } public record LeaveGuildRequest(string Name); diff --git a/Catalogger.Backend/Api/GuildsController.cs b/Catalogger.Backend/Api/GuildsController.cs index 4289bee..e2445e1 100644 --- a/Catalogger.Backend/Api/GuildsController.cs +++ b/Catalogger.Backend/Api/GuildsController.cs @@ -34,6 +34,8 @@ public partial class GuildsController( ILogger logger, DatabaseConnection dbConn, GuildRepository guildRepository, + InviteRepository inviteRepository, + WatchlistRepository watchlistRepository, GuildCache guildCache, EmojiCache emojiCache, ChannelCache channelCache, diff --git a/Catalogger.Backend/Cache/IWebhookCache.cs b/Catalogger.Backend/Cache/IWebhookCache.cs index 4267260..69a256a 100644 --- a/Catalogger.Backend/Cache/IWebhookCache.cs +++ b/Catalogger.Backend/Cache/IWebhookCache.cs @@ -24,6 +24,7 @@ public interface IWebhookCache { Task GetWebhookAsync(ulong channelId); Task SetWebhookAsync(ulong channelId, Webhook webhook); + Task RemoveWebhooksAsync(ulong[] channelIds); public async Task GetOrFetchWebhookAsync( ulong channelId, diff --git a/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs b/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs index b4f1a2f..3a6208b 100644 --- a/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs +++ b/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs @@ -33,4 +33,11 @@ public class InMemoryWebhookCache : IWebhookCache _cache[channelId] = webhook; return Task.CompletedTask; } + + public Task RemoveWebhooksAsync(ulong[] channelIds) + { + foreach (var id in channelIds) + _cache.TryRemove(id, out _); + return Task.CompletedTask; + } } diff --git a/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs b/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs index 9c07f62..0e2962c 100644 --- a/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs +++ b/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs @@ -26,5 +26,8 @@ public class RedisWebhookCache(RedisService redisService) : IWebhookCache public async Task SetWebhookAsync(ulong channelId, Webhook webhook) => await redisService.SetAsync(WebhookKey(channelId), webhook, 24.Hours()); + public async Task RemoveWebhooksAsync(ulong[] channelIds) => + await redisService.DeleteAsync(channelIds.Select(WebhookKey).ToArray()); + private static string WebhookKey(ulong channelId) => $"webhook:{channelId}"; } diff --git a/Catalogger.Backend/Database/Redis/RedisService.cs b/Catalogger.Backend/Database/Redis/RedisService.cs index 1ed24af..f493a4a 100644 --- a/Catalogger.Backend/Database/Redis/RedisService.cs +++ b/Catalogger.Backend/Database/Redis/RedisService.cs @@ -44,6 +44,9 @@ public class RedisService(Config config) await GetDatabase().StringSetAsync(key, json, expiry); } + public async Task DeleteAsync(string[] keys) => + await GetDatabase().KeyDeleteAsync(keys.Select(k => new RedisKey(k)).ToArray()); + public async Task GetAsync(string key) { var value = await GetDatabase().StringGetAsync(key); diff --git a/Catalogger.Backend/Database/Repositories/GuildRepository.cs b/Catalogger.Backend/Database/Repositories/GuildRepository.cs index 0cf83bf..2a95403 100644 --- a/Catalogger.Backend/Database/Repositories/GuildRepository.cs +++ b/Catalogger.Backend/Database/Repositories/GuildRepository.cs @@ -137,6 +137,23 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn) new { Id = id.Value, Channels = config } ); + public async Task ImportConfigAsync( + ulong id, + Guild.ChannelConfig channels, + string[] bannedSystems, + ulong[] keyRoles + ) => + await conn.ExecuteAsync( + "update guilds set channels = @channels::jsonb, banned_systems = @bannedSystems, key_roles = @keyRoles where id = @id", + new + { + id, + channels, + bannedSystems, + keyRoles, + } + ); + public void Dispose() { conn.Dispose(); diff --git a/Catalogger.Backend/Database/Repositories/InviteRepository.cs b/Catalogger.Backend/Database/Repositories/InviteRepository.cs index da88e6a..914588f 100644 --- a/Catalogger.Backend/Database/Repositories/InviteRepository.cs +++ b/Catalogger.Backend/Database/Repositories/InviteRepository.cs @@ -65,6 +65,34 @@ public class InviteRepository(ILogger logger, DatabaseConnection conn) new { GuildId = guildId.Value, Code = code } ); + /// + /// Bulk imports an array of invite codes and names. + /// The GuildId property in the Invite object is ignored. + /// + public async Task ImportInvitesAsync(Snowflake guildId, IEnumerable invites) + { + await using var tx = await conn.BeginTransactionAsync(); + foreach (var invite in invites) + { + await conn.ExecuteAsync( + """ + insert into invites (code, guild_id, name) + values (@Code, @GuildId, @Name) on conflict (code) + do update set name = @Name + """, + new + { + GuildId = guildId.Value, + invite.Code, + invite.Name, + }, + transaction: tx + ); + } + + await tx.CommitAsync(); + } + public void Dispose() { conn.Dispose(); diff --git a/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs b/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs index ce6bacd..653fd85 100644 --- a/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs +++ b/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs @@ -70,6 +70,39 @@ public class WatchlistRepository(ILogger logger, DatabaseConnection conn) ) ) != 0; + /// + /// Bulk imports an array of watchlist entries. + /// The GuildId property in the Watchlist object is ignored. + /// + public async Task ImportWatchlistAsync(Snowflake guildId, IEnumerable watchlist) + { + await using var tx = await conn.BeginTransactionAsync(); + foreach (var entry in watchlist) + { + await conn.ExecuteAsync( + """ + insert into watchlists (guild_id, user_id, added_at, moderator_id, reason) + values (@GuildId, @UserId, @AddedAt, @ModeratorId, @Reason) + on conflict (guild_id, user_id) do update + set added_at = @AddedAt, + moderator_id = @ModeratorId, + reason = @Reason + """, + new + { + GuildId = guildId.Value, + entry.UserId, + entry.AddedAt, + entry.ModeratorId, + entry.Reason, + }, + transaction: tx + ); + } + + await tx.CommitAsync(); + } + public void Dispose() { conn.Dispose(); diff --git a/Catalogger.Backend/JsonUtils.cs b/Catalogger.Backend/JsonUtils.cs new file mode 100644 index 0000000..4fa9a94 --- /dev/null +++ b/Catalogger.Backend/JsonUtils.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NodaTime; +using NodaTime.Serialization.SystemTextJson; +using NodaTime.Text; + +namespace Catalogger.Backend; + +public static class JsonUtils +{ + public static readonly NodaJsonSettings NodaTimeSettings = new NodaJsonSettings + { + InstantConverter = new NodaPatternConverter(InstantPattern.ExtendedIso), + }; + + public static readonly JsonSerializerOptions BaseJsonOptions = new JsonSerializerOptions + { + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }.ConfigureForNodaTime(NodaTimeSettings); + + public static readonly JsonSerializerOptions ApiJsonOptions = new JsonSerializerOptions + { + NumberHandling = + JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }.ConfigureForNodaTime(NodaTimeSettings); +} diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs index 45d9904..ed9292a 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -15,9 +15,11 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Catalogger.Backend; using Catalogger.Backend.Bot.Commands; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; +using NodaTime.Serialization.SystemTextJson; using Prometheus; using Remora.Commands.Extensions; using Remora.Discord.API.Abstractions.Gateway.Commands; @@ -45,6 +47,7 @@ builder options.JsonSerializerOptions.IncludeFields = true; options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString; + options.JsonSerializerOptions.ConfigureForNodaTime(JsonUtils.NodaTimeSettings); }); builder diff --git a/Catalogger.Backend/Services/PluralkitApiService.cs b/Catalogger.Backend/Services/PluralkitApiService.cs index 113374d..283200c 100644 --- a/Catalogger.Backend/Services/PluralkitApiService.cs +++ b/Catalogger.Backend/Services/PluralkitApiService.cs @@ -14,13 +14,9 @@ // along with this program. If not, see . using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading.RateLimiting; using Humanizer; using NodaTime; -using NodaTime.Serialization.SystemTextJson; -using NodaTime.Text; using Polly; namespace Catalogger.Backend.Services; @@ -32,17 +28,6 @@ public class PluralkitApiService(ILogger logger) private readonly HttpClient _client = new(); private readonly ILogger _logger = logger.ForContext(); - private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - NumberHandling = JsonNumberHandling.AllowReadingFromString, - }.ConfigureForNodaTime( - new NodaJsonSettings - { - InstantConverter = new NodaPatternConverter(InstantPattern.ExtendedIso), - } - ); - private readonly ResiliencePipeline _pipeline = new ResiliencePipelineBuilder() .AddRateLimiter( new FixedWindowRateLimiter( @@ -86,7 +71,7 @@ public class PluralkitApiService(ILogger logger) throw new CataloggerError("Non-200 status code from PluralKit API"); } - return await resp.Content.ReadFromJsonAsync(_jsonOptions, ct) + return await resp.Content.ReadFromJsonAsync(JsonUtils.ApiJsonOptions, ct) ?? throw new CataloggerError("JSON response from PluralKit API was null"); } diff --git a/Catalogger.Frontend/package.json b/Catalogger.Frontend/package.json index 1df9362..b36d8cd 100644 --- a/Catalogger.Frontend/package.json +++ b/Catalogger.Frontend/package.json @@ -17,11 +17,13 @@ "@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltestrap/sveltestrap": "^6.2.7", "@types/eslint": "^9.6.0", + "@types/file-saver": "^2.0.7", "@types/luxon": "^3.4.2", "bootstrap": "^5.3.3", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.36.0", + "file-saver": "^2.0.5", "globals": "^15.0.0", "luxon": "^3.5.0", "marked": "^14.1.3", @@ -38,4 +40,4 @@ "vite-plugin-markdown": "^2.2.0" }, "type": "module" -} \ No newline at end of file +} diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte index 6bdb009..6ec80b5 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte @@ -68,6 +68,12 @@ > Key roles + + Import/export settings + - import { Alert, Button, Input, InputGroup } from "@sveltestrap/sveltestrap"; + import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap"; import type { PageData } from "./$types"; - import { fastFetch, type ApiError } from "$lib/api"; + import apiFetch, { type ApiError } from "$lib/api"; import { addToast } from "$lib/toast"; import { goto } from "$app/navigation"; + import { saveAs } from "file-saver"; export let data: PageData; @@ -11,9 +12,16 @@ const deleteData = async () => { try { - await fastFetch("POST", `/api/guilds/${data.guild.id}/leave`, { - name: guildName, - }); + const backup = await apiFetch( + "POST", + `/api/guilds/${data.guild.id}/leave`, + { + name: guildName, + }, + ); + + downloadBackup(backup); + addToast({ header: "Left server", body: `Successfully left ${data.guild.name} and deleted all data related to it.`, @@ -27,28 +35,31 @@ }); } }; + + const downloadBackup = (data: any) => { + const backup = JSON.stringify(data); + const blob = new Blob([backup], { type: "text/plain;charset=utf-8" }); + saveAs(blob, "server-backup.json"); + };

Delete this server's data

To make Catalogger leave your server and delete all data from your server, - fill its name in below and press "Delete". + fill its name ({data.guild.name}) in below and press + "Delete".
- - This is irreversible. If you change your mind later, your data cannot be - restored. - -
- If you just want to make Catalogger leave your server but not delete data, simply - kick it via Discord. + You will get a backup of your server's settings which you can restore later if + you change your mind.

- -

This is irreversible!

- - We cannot help you recover data deleted in this way. -
+

+ + Message data is not backed up. If you change your mind, we cannot restore it + for you. + +

diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/import/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/import/+page.svelte new file mode 100644 index 0000000..7a8fccb --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/import/+page.svelte @@ -0,0 +1,83 @@ + + +

Import and export settings

+ +

+ You can create a backup of your server's configuration here. If you removed + the bot from your server before, you can import the backup you got then here + too. +

+ +

+ +

+ + + + + diff --git a/Catalogger.Frontend/yarn.lock b/Catalogger.Frontend/yarn.lock index 121c9a0..0111c04 100644 --- a/Catalogger.Frontend/yarn.lock +++ b/Catalogger.Frontend/yarn.lock @@ -500,6 +500,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/file-saver@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.7.tgz#8dbb2f24bdc7486c54aa854eb414940bbd056f7d" + integrity sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A== + "@types/json-schema@*", "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -1048,6 +1053,11 @@ file-entry-cache@^8.0.0: dependencies: flat-cache "^4.0.0" +file-saver@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38" + integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA== + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"