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.
+
+
+ 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.
+