using Discord; using Discord.Interactions; using MareSynchronosShared.Data; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Discord.WebSocket; using Prometheus; using MareSynchronosShared.Models; using MareSynchronosShared.Utils; using MareSynchronosShared.Services; using StackExchange.Redis; using MareSynchronos.API.Data.Enum; using System.Net.Http.Headers; using MareSynchronosShared.Utils.Configuration; namespace MareSynchronosServices.Discord; public class LodestoneModal : IModal { public string Title => "Verify with Lodestone"; [InputLabel("Enter the Lodestone URL of your Character")] [ModalTextInput("lodestone_url", TextInputStyle.Short, "https://*.finalfantasyxiv.com/lodestone/character//")] public string LodestoneUrl { get; set; } } public class MareModule : InteractionModuleBase { private readonly ILogger _logger; private readonly IServiceProvider _services; private readonly DiscordBotServices _botServices; private readonly IConfigurationService _mareClientConfigurationService; private readonly IConfigurationService _mareServicesConfiguration; private readonly IConnectionMultiplexer _connectionMultiplexer; private readonly ServerTokenGenerator _serverTokenGenerator; private Random random = new(); public MareModule(ILogger logger, IServiceProvider services, DiscordBotServices botServices, IConfigurationService mareClientConfigurationService, IConfigurationService mareServicesConfiguration, IConnectionMultiplexer connectionMultiplexer, ServerTokenGenerator serverTokenGenerator) { _logger = logger; _services = services; _botServices = botServices; _mareClientConfigurationService = mareClientConfigurationService; _mareServicesConfiguration = mareServicesConfiguration; _connectionMultiplexer = connectionMultiplexer; _serverTokenGenerator = serverTokenGenerator; } //[SlashCommand("register", "Starts the registration process")] public async Task Register([Summary("overwrite", "Overwrites your old account")] bool overwrite = false) { _logger.LogInformation("SlashCommand:{userId}:{Method}:{params}", Context.Interaction.User.Id, nameof(Register), string.Join(",", new[] { $"{nameof(overwrite)}:{overwrite}" })); try { if (overwrite) { await DeletePreviousUserAccount(Context.User.Id).ConfigureAwait(false); } using var scope = _services.CreateScope(); using var db = scope.ServiceProvider.GetService(); if (db.LodeStoneAuth.Any(a => a.DiscordId == Context.User.Id)) { EmbedBuilder eb = new(); // user already in db eb.WithTitle("Registration failed"); eb.WithDescription("You are already registered. Use `/recover overwrite` to delete your old account and create a new one."); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } else { // Loporrit - Register immediately var user = new User(); var hasValidUid = false; while (!hasValidUid) { var uid = StringUtils.GenerateRandomString(7); if (db.Users.Any(u => u.UID == uid || u.Alias == uid)) continue; user.UID = uid; hasValidUid = true; } user.LastLoggedIn = DateTime.UtcNow; var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); var auth = new Auth() { HashedKey = StringUtils.Sha256String(computedHash), User = user, }; LodeStoneAuth lsAuth = new LodeStoneAuth() { DiscordId = Context.User.Id, HashedLodestoneId = null, LodestoneAuthString = null, User = user, StartedAt = null }; await db.Users.AddAsync(user).ConfigureAwait(false); await db.Auth.AddAsync(auth).ConfigureAwait(false); await db.LodeStoneAuth.AddAsync(lsAuth).ConfigureAwait(false); await db.SaveChangesAsync().ConfigureAwait(false); _botServices.Logger.LogInformation("User registered: {userUID}", user.UID); EmbedBuilder eb = new(); eb.WithTitle("Registration successful"); eb.WithDescription("This is your private secret key. Do not share this private secret key with anyone. **If you lose it, it is irrevocably lost.**" + Environment.NewLine + Environment.NewLine + $"**{computedHash}**" + Environment.NewLine + Environment.NewLine + "Enter this key in Snowcloak and hit Connect / Reconnect." + Environment.NewLine + "Have fun."); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } //[SlashCommand("createsecondaryuid", "Creates a new Secret Key to be used for alts")] public async Task AddSecondary() { try { var embed = await HandleAddSecondary(Context.User.Id).ConfigureAwait(false); await RespondAsync(embeds: new[] { embed }, ephemeral: true).ConfigureAwait(false); } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } //[SlashCommand("setvanityuid", "Sets your Vanity UID.")] public async Task SetVanityUid([Summary("vanity_uid", "Desired Vanity UID")] string vanityUid, [Summary("secondary_uid", "Will set the vanity UID for a secondary UID")] string? secondaryUid = null) { _logger.LogInformation("SlashCommand:{userId}:{Method}:{params}", Context.Interaction.User.Id, nameof(SetVanityUid), string.Join(",", new[] { $"{nameof(vanityUid)}:{vanityUid}" })); try { EmbedBuilder eb = new(); eb = await HandleVanityUid(eb, Context.User.Id, vanityUid, secondaryUid); await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } //[SlashCommand("setsyncshellvanityid", "Sets a Vanity GID for a Syncshell")] public async Task SetSyncshellVanityId( [Summary("syncshell_id", "Syncshell ID")] string syncshellId, [Summary("vanity_syncshell_id", "Desired Vanity Syncshell ID")] string vanityId) { _logger.LogInformation("SlashCommand:{userId}:{Method}:{params}", Context.Interaction.User.Id, nameof(SetSyncshellVanityId), string.Join(",", new[] { $"{nameof(syncshellId)}:{syncshellId}", $"{nameof(vanityId)}:{vanityId}" })); try { EmbedBuilder eb = new(); eb = await HandleVanityGid(eb, Context.User.Id, syncshellId, vanityId); await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } // Loporrit - Disable /verify command //[SlashCommand("verify", "Finishes the registration process for the Mare Synchronos server of this Discord")] public async Task Verify() { _logger.LogInformation("SlashCommand:{userId}:{Method}", Context.Interaction.User.Id, nameof(Verify)); try { EmbedBuilder eb = new(); if (_botServices.VerificationQueue.Any(u => u.Key == Context.User.Id)) { eb.WithTitle("Already queued for verfication"); eb.WithDescription("You are already queued for verification. Please wait."); await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } else if (!_botServices.DiscordLodestoneMapping.ContainsKey(Context.User.Id)) { eb.WithTitle("Cannot verify registration"); eb.WithDescription("You need to **/register** first before you can **/verify**" + Environment.NewLine + "If your registration got stuck for some reason, use **/register overwrite:true**"); await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } else { await DeferAsync(ephemeral: true).ConfigureAwait(false); _botServices.VerificationQueue.Enqueue(new KeyValuePair>(Context.User.Id, async (sp) => await HandleVerifyAsync((SocketSlashCommand)Context.Interaction, sp))); } } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } // Loporrit - Disable /verify command //[SlashCommand("verify_relink", "Finishes the relink process for your user on the Mare Synchronos server of this Discord")] public async Task VerifyRelink() { _logger.LogInformation("SlashCommand:{userId}:{Method}", Context.Interaction.User.Id, nameof(VerifyRelink)); try { EmbedBuilder eb = new(); if (_botServices.VerificationQueue.Any(u => u.Key == Context.User.Id)) { eb.WithTitle("Already queued for verfication"); eb.WithDescription("You are already queued for verification. Please wait."); await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } else if (!_botServices.DiscordRelinkLodestoneMapping.ContainsKey(Context.User.Id)) { eb.WithTitle("Cannot verify relink"); eb.WithDescription("You need to **/relink** first before you can **/verify_relink**"); await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } else { await DeferAsync(ephemeral: true).ConfigureAwait(false); _botServices.VerificationQueue.Enqueue(new KeyValuePair>(Context.User.Id, async (sp) => await HandleVerifyRelinkAsync((SocketSlashCommand)Context.Interaction, sp))); } } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } //[SlashCommand("recover", "Allows you to recover your account by generating a new secret key")] public async Task Recover([Summary("secondary_uid", "(Optional) Your secondary UID")] string? secondaryUid = null) { _logger.LogInformation("SlashCommand:{userId}:{Method}", Context.Interaction.User.Id, nameof(Recover)); await RespondWithModalAsync($"recover_modal:{secondaryUid ?? "-1"}").ConfigureAwait(false); } [SlashCommand("userinfo", "Shows you your user information")] public async Task UserInfo([Summary("secondary_uid", "(Optional) Your secondary UID")] string? secondaryUid = null, [Summary("discord_user", "ADMIN ONLY: Discord User to check for")] IUser? discordUser = null, [Summary("uid", "ADMIN ONLY: UID to check for")] string? uid = null) { _logger.LogInformation("SlashCommand:{userId}:{Method}", Context.Interaction.User.Id, nameof(UserInfo)); try { EmbedBuilder eb = new(); eb = await HandleUserInfo(eb, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid); await RespondAsync(embeds: new[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } //[SlashCommand("relink", "Allows you to link a new Discord account to an existing Mare account")] public async Task Relink() { _logger.LogInformation("SlashCommand:{userId}:{Method}", Context.Interaction.User.Id, nameof(Relink)); await RespondWithModalAsync("relink_modal").ConfigureAwait(false); } [SlashCommand("useradd", "ADMIN ONLY: add a user unconditionally to the Database")] public async Task UserAdd([Summary("desired_uid", "Desired UID")] string desiredUid) { _logger.LogInformation("SlashCommand:{userId}:{Method}:{params}", Context.Interaction.User.Id, nameof(UserAdd), string.Join(",", new[] { $"{nameof(desiredUid)}:{desiredUid}" })); try { var embed = await HandleUserAdd(desiredUid, Context.User.Id); await RespondAsync(embeds: new[] { embed }, ephemeral: true).ConfigureAwait(false); } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } [SlashCommand("message", "ADMIN ONLY: sends a message to clients")] public async Task SendMessageToClients([Summary("message", "Message to send")] string message, [Summary("severity", "Severity of the message")] MessageSeverity messageType = MessageSeverity.Information, [Summary("uid", "User ID to the person to send the message to")] string? uid = null) { _logger.LogInformation("SlashCommand:{userId}:{Method}:{message}:{type}:{uid}", Context.Interaction.User.Id, nameof(SendMessageToClients), message, messageType, uid); using var scope = _services.CreateScope(); using var db = scope.ServiceProvider.GetService(); if (!(await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(a => a.DiscordId == Context.Interaction.User.Id))?.User?.IsAdmin ?? true) { await RespondAsync("No permission", ephemeral: true).ConfigureAwait(false); return; } if (!string.IsNullOrEmpty(uid) && !await db.Users.AnyAsync(u => u.UID == uid)) { await RespondAsync("Specified UID does not exist", ephemeral: true).ConfigureAwait(false); return; } try { using HttpClient c = new HttpClient(); c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _serverTokenGenerator.Token); await c.PostAsJsonAsync(new Uri(_mareServicesConfiguration.GetValue (nameof(ServicesConfiguration.MainServerAddress)), "/msgc/sendMessage"), new ClientMessage(messageType, message, uid ?? string.Empty)) .ConfigureAwait(false); var discordChannelForMessages = _mareServicesConfiguration.GetValueOrDefault(nameof(ServicesConfiguration.DiscordChannelForMessages), null); if (uid == null && discordChannelForMessages != null) { var discordChannel = await Context.Guild.GetChannelAsync(discordChannelForMessages.Value) as IMessageChannel; if (discordChannel != null) { var embedColor = messageType switch { MessageSeverity.Information => Color.Blue, MessageSeverity.Warning => new Color(255, 255, 0), MessageSeverity.Error => Color.Red, _ => Color.Blue }; EmbedBuilder eb = new(); eb.WithTitle(messageType + " server message"); eb.WithColor(embedColor); eb.WithDescription(message); await discordChannel.SendMessageAsync(embed: eb.Build()); } } await RespondAsync("Message sent", ephemeral: true).ConfigureAwait(false); } catch (Exception ex) { await RespondAsync("Failed to send message: " + ex.ToString(), ephemeral: true).ConfigureAwait(false); } } //[ModalInteraction("recover_modal:*")] public async Task RecoverModal(string? secondaryUid, LodestoneModal modal) { _logger.LogInformation("Modal:{userId}:{Method}", Context.Interaction.User.Id, nameof(RecoverModal)); if (secondaryUid == "-1") secondaryUid = null; try { var embed = await HandleRecoverModalAsync(modal, Context.User.Id, secondaryUid).ConfigureAwait(false); await RespondAsync(embeds: new Embed[] { embed }, ephemeral: true).ConfigureAwait(false); } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } //[ModalInteraction("register_modal")] public async Task RegisterModal(LodestoneModal modal) { _logger.LogInformation("Modal:{userId}:{Method}", Context.Interaction.User.Id, nameof(RegisterModal)); try { var embed = await HandleRegisterModalAsync(modal, Context.User.Id).ConfigureAwait(false); await RespondAsync(embeds: new Embed[] { embed }, ephemeral: true).ConfigureAwait(false); } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } //[ModalInteraction("relink_modal")] public async Task RelinkModal(LodestoneModal modal) { _logger.LogInformation("Modal:{userId}:{Method}", Context.Interaction.User.Id, nameof(RelinkModal)); try { var embed = await HandleRelinkModalAsync(modal, Context.User.Id).ConfigureAwait(false); await RespondAsync(embeds: new Embed[] { embed }, ephemeral: true).ConfigureAwait(false); } catch (Exception ex) { EmbedBuilder eb = new(); eb.WithTitle("An error occured"); eb.WithDescription("Please report this error to bug-reports: " + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine); await RespondAsync(embeds: new Embed[] { eb.Build() }, ephemeral: true).ConfigureAwait(false); } } public async Task HandleAddSecondary(ulong discordUserId) { var embed = new EmbedBuilder(); using var scope = _services.CreateScope(); using var db = scope.ServiceProvider.GetService(); var lodestoneAuth = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(a => a.DiscordId == discordUserId).ConfigureAwait(false); if (lodestoneAuth == null) { embed.WithTitle("Failed to add secondary user"); embed.WithDescription("You have no registered account yet. Register an account first before trying to add secondary keys."); return embed.Build(); } var secondaryCount = await db.Auth.CountAsync(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ConfigureAwait(false); if (await db.Auth.CountAsync(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ConfigureAwait(false) >= 25) { embed.WithTitle("Failed to add secondary user"); embed.WithDescription("You already made 25 secondary UIDs, which is the limit."); return embed.Build(); } User newUser = new() { IsAdmin = false, IsModerator = false, LastLoggedIn = DateTime.UtcNow, }; var hasValidUid = false; while (!hasValidUid) { var uid = StringUtils.GenerateRandomString(7); if (await db.Users.AnyAsync(u => u.UID == uid || u.Alias == uid).ConfigureAwait(false)) continue; newUser.UID = uid; hasValidUid = true; } var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); var auth = new Auth() { HashedKey = StringUtils.Sha256String(computedHash), User = newUser, PrimaryUserUID = lodestoneAuth.User.UID, UserUID = newUser.UID }; await db.Users.AddAsync(newUser).ConfigureAwait(false); await db.Auth.AddAsync(auth).ConfigureAwait(false); await db.SaveChangesAsync().ConfigureAwait(false); embed.WithTitle("Secondary UID created"); embed.AddField("UID", newUser.UID); embed.AddField("Secret Key", computedHash); embed.AddField("Secondary UIDs", $"You now have {secondaryCount + 1}/25 secondary UIDs"); return embed.Build(); } public async Task HandleUserAdd(string desiredUid, ulong discordUserId) { var embed = new EmbedBuilder(); using var scope = _services.CreateScope(); using var db = scope.ServiceProvider.GetService(); if (!(await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(a => a.DiscordId == discordUserId))?.User?.IsAdmin ?? true) { embed.WithTitle("Failed to add user"); embed.WithDescription("No permission"); } else if (db.Users.Any(u => u.UID == desiredUid || u.Alias == desiredUid)) { embed.WithTitle("Failed to add user"); embed.WithDescription("Already in Database"); } else { User newUser = new() { IsAdmin = false, IsModerator = false, LastLoggedIn = DateTime.UtcNow, UID = desiredUid, }; var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); var auth = new Auth() { HashedKey = StringUtils.Sha256String(computedHash), User = newUser, }; await db.Users.AddAsync(newUser); await db.Auth.AddAsync(auth); await db.SaveChangesAsync(); embed.WithTitle("Successfully added " + desiredUid); embed.WithDescription("Secret Key: " + computedHash); } return embed.Build(); } private async Task HandleUserInfo(EmbedBuilder eb, ulong id, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null) { bool showForSecondaryUser = secondaryUserUid != null; using var scope = _services.CreateScope(); await using var db = scope.ServiceProvider.GetRequiredService(); var primaryUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == id).ConfigureAwait(false); ulong userToCheckForDiscordId = id; if (primaryUser == null) { eb.WithTitle("No account"); eb.WithDescription("No account was found associated to your Discord user"); return eb; } bool isAdminCall = primaryUser.User.IsModerator || primaryUser.User.IsAdmin; if ((optionalUser != null || uid != null) && !isAdminCall) { eb.WithTitle("Unauthorized"); eb.WithDescription("You are not authorized to view another users' information"); return eb; } else if ((optionalUser != null || uid != null) && isAdminCall) { LodeStoneAuth userInDb = null; if (optionalUser != null) { userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == optionalUser).ConfigureAwait(false); } else if (uid != null) { userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid || u.User.Alias == uid).ConfigureAwait(false); } if (userInDb == null) { eb.WithTitle("No account"); eb.WithDescription("The Discord user has no valid account"); return eb; } userToCheckForDiscordId = userInDb.DiscordId; } var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == userToCheckForDiscordId).ConfigureAwait(false); var dbUser = lodestoneUser.User; if (showForSecondaryUser) { dbUser = (await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.PrimaryUserUID == dbUser.UID && u.UserUID == secondaryUserUid))?.User; if (dbUser == null) { eb.WithTitle("No such secondary UID"); eb.WithDescription($"A secondary UID {secondaryUserUid} was not found attached to your primary UID {primaryUser.User.UID}."); return eb; } } var auth = await db.Auth.Include(u => u.PrimaryUser).SingleOrDefaultAsync(u => u.UserUID == dbUser.UID).ConfigureAwait(false); var groups = await db.Groups.Where(g => g.OwnerUID == dbUser.UID).ToListAsync().ConfigureAwait(false); var groupsJoined = await db.GroupPairs.Where(g => g.GroupUserUID == dbUser.UID).ToListAsync().ConfigureAwait(false); var identity = await _connectionMultiplexer.GetDatabase().StringGetAsync("UID:" + dbUser.UID).ConfigureAwait(false); eb.WithTitle("User Information"); eb.WithDescription("This is the user information for Discord User <@" + userToCheckForDiscordId + ">" + Environment.NewLine + Environment.NewLine + "If you want to verify your secret key is valid, go to https://emn178.github.io/online-tools/sha256.html and copy your secret key into there and compare it to the Hashed Secret Key provided below."); eb.AddField("UID", dbUser.UID); if (!string.IsNullOrEmpty(dbUser.Alias)) { eb.AddField("Vanity UID", dbUser.Alias); } if (showForSecondaryUser) { eb.AddField("Primary UID for " + dbUser.UID, auth.PrimaryUserUID); } else { var secondaryUIDs = await db.Auth.Where(p => p.PrimaryUserUID == dbUser.UID).Select(p => p.UserUID).ToListAsync(); if (secondaryUIDs.Any()) { eb.AddField("Secondary UIDs", string.Join(Environment.NewLine, secondaryUIDs)); } } eb.AddField("Last Online (UTC)", dbUser.LastLoggedIn.ToString("U")); eb.AddField("Currently online ", !string.IsNullOrEmpty(identity)); eb.AddField("Hashed Secret Key", auth.HashedKey); eb.AddField("Joined Syncshells", groupsJoined.Count); eb.AddField("Owned Syncshells", groups.Count); foreach (var group in groups) { var syncShellUserCount = await db.GroupPairs.CountAsync(g => g.GroupGID == group.GID).ConfigureAwait(false); if (!string.IsNullOrEmpty(group.Alias)) { eb.AddField("Owned Syncshell " + group.GID + " Vanity ID", group.Alias); } eb.AddField("Owned Syncshell " + group.GID + " User Count", syncShellUserCount); } if (isAdminCall && !string.IsNullOrEmpty(identity)) { eb.AddField("Character Ident", identity); } return eb; } private async Task HandleRecoverModalAsync(LodestoneModal arg, ulong userid, string? secondaryUid) { var embed = new EmbedBuilder(); var lodestoneId = ParseCharacterIdFromLodestoneUrl(arg.LodestoneUrl); if (lodestoneId == null) { embed.WithTitle("Invalid Lodestone URL"); embed.WithDescription("The lodestone URL was not valid. It should have following format:" + Environment.NewLine + "https://eu.finalfantasyxiv.com/lodestone/character/YOUR_LODESTONE_ID/"); } else { using var scope = _services.CreateScope(); var hashedLodestoneId = StringUtils.Sha256String(lodestoneId.ToString()); await using var db = scope.ServiceProvider.GetService(); var existingLodestoneAuth = await db.LodeStoneAuth.Include("User") .FirstOrDefaultAsync(a => a.DiscordId == userid && a.HashedLodestoneId == hashedLodestoneId) .ConfigureAwait(false); if (existingLodestoneAuth == null || existingLodestoneAuth.User == null) { embed.WithTitle("Recovery failed"); embed.WithDescription("This DiscordID or Lodestone account pair does not exist."); } else { string computedHash = string.Empty; Auth auth; if (string.IsNullOrEmpty(secondaryUid)) { var previousAuth = await db.Auth.FirstOrDefaultAsync(u => u.UserUID == existingLodestoneAuth.User.UID); if (previousAuth != null) { db.Auth.Remove(previousAuth); } computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); auth = new Auth() { HashedKey = StringUtils.Sha256String(computedHash), User = existingLodestoneAuth.User, }; await db.Auth.AddAsync(auth).ConfigureAwait(false); } else { var previousAuth = await db.Auth.Include(u => u.User).FirstOrDefaultAsync(u => u.PrimaryUserUID == existingLodestoneAuth.User.UID && u.UserUID == secondaryUid).ConfigureAwait(false); if (previousAuth == null) { embed.WithTitle("Recovery failed"); embed.WithDescription("This DiscordID has no secondary UID " + secondaryUid); return embed.Build(); } db.Auth.Remove(previousAuth); computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); auth = new Auth() { HashedKey = StringUtils.Sha256String(computedHash), User = previousAuth.User, PrimaryUserUID = existingLodestoneAuth.User.UID }; } embed.WithTitle("Recovery successful"); embed.WithDescription("This is your new private secret key. Do not share this private secret key with anyone. **If you lose it, it is irrevocably lost.**" + Environment.NewLine + Environment.NewLine + $"**{computedHash}**" + Environment.NewLine + Environment.NewLine + "Enter this key in Snowcloak and hit save to connect to the service."); await db.Auth.AddAsync(auth).ConfigureAwait(false); await db.SaveChangesAsync().ConfigureAwait(false); } } return embed.Build(); } private async Task HandleRegisterModalAsync(LodestoneModal arg, ulong userid) { var embed = new EmbedBuilder(); var lodestoneId = ParseCharacterIdFromLodestoneUrl(arg.LodestoneUrl); if (lodestoneId == null) { embed.WithTitle("Invalid Lodestone URL"); embed.WithDescription("The lodestone URL was not valid. It should have following format:" + Environment.NewLine + "https://eu.finalfantasyxiv.com/lodestone/character/YOUR_LODESTONE_ID/"); } else { // check if userid is already in db using var scope = _services.CreateScope(); var hashedLodestoneId = StringUtils.Sha256String(lodestoneId.ToString()); using var db = scope.ServiceProvider.GetService(); // check if discord id or lodestone id is banned if (db.BannedRegistrations.Any(a => a.DiscordIdOrLodestoneAuth == userid.ToString() || a.DiscordIdOrLodestoneAuth == hashedLodestoneId)) { embed.WithTitle("no"); embed.WithDescription("your account is banned"); } else if (db.LodeStoneAuth.Any(a => a.DiscordId == userid)) { // user already in db embed.WithTitle("Registration failed"); embed.WithDescription("You cannot register more than one lodestone character to your discord account."); } else if (db.LodeStoneAuth.Any(a => a.HashedLodestoneId == hashedLodestoneId)) { // character already in db embed.WithTitle("Registration failed"); embed.WithDescription("This lodestone character already exists in the Database. If you want to attach this character to your current Discord account use **/relink**."); } else { string lodestoneAuth = await GenerateLodestoneAuth(userid, hashedLodestoneId, db).ConfigureAwait(false); // check if lodestone id is already in db embed.WithTitle("Authorize your character"); embed.WithDescription("Add following key to your character profile at https://na.finalfantasyxiv.com/lodestone/my/setting/profile/" + Environment.NewLine + Environment.NewLine + $"**{lodestoneAuth}**" + Environment.NewLine + Environment.NewLine + $"**! THIS IS NOT THE KEY YOU HAVE TO ENTER IN MARE !**" + Environment.NewLine + Environment.NewLine + "Once added and saved, use command **/verify** to finish registration and receive a secret key to use for Mare Synchronos." + Environment.NewLine + "__You can delete the entry from your profile after verification.__" + Environment.NewLine + Environment.NewLine + "The verification will expire in approximately 15 minutes. If you fail to **/verify** the registration will be invalidated and you have to **/register** again."); _botServices.DiscordLodestoneMapping[userid] = lodestoneId.ToString(); } } return embed.Build(); } private async Task HandleRelinkModalAsync(LodestoneModal arg, ulong userid) { var embed = new EmbedBuilder(); var lodestoneId = ParseCharacterIdFromLodestoneUrl(arg.LodestoneUrl); if (lodestoneId == null) { embed.WithTitle("Invalid Lodestone URL"); embed.WithDescription("The lodestone URL was not valid. It should have following format:" + Environment.NewLine + "https://eu.finalfantasyxiv.com/lodestone/character/YOUR_LODESTONE_ID/"); } else { // check if userid is already in db using var scope = _services.CreateScope(); var hashedLodestoneId = StringUtils.Sha256String(lodestoneId.ToString()); using var db = scope.ServiceProvider.GetService(); // check if discord id or lodestone id is banned if (db.BannedRegistrations.Any(a => a.DiscordIdOrLodestoneAuth == userid.ToString() || a.DiscordIdOrLodestoneAuth == hashedLodestoneId)) { embed.WithTitle("no"); embed.WithDescription("your account is banned"); } else if (db.LodeStoneAuth.Any(a => a.DiscordId == userid)) { // user already in db embed.WithTitle("Relink failed"); embed.WithDescription("You cannot register more than one lodestone character to your discord account."); } else if (!db.LodeStoneAuth.Any(a => a.HashedLodestoneId == hashedLodestoneId)) { // character already in db embed.WithTitle("Relink failed"); embed.WithDescription("This lodestone character does not exist in the database."); } else { string lodestoneAuth = await GenerateLodestoneAuth(userid, hashedLodestoneId, db).ConfigureAwait(false); // check if lodestone id is already in db embed.WithTitle("Authorize your character for relinking"); embed.WithDescription("Add following key to your character profile at https://na.finalfantasyxiv.com/lodestone/my/setting/profile/" + Environment.NewLine + Environment.NewLine + $"**{lodestoneAuth}**" + Environment.NewLine + Environment.NewLine + $"**! THIS IS NOT THE KEY YOU HAVE TO ENTER IN MARE !**" + Environment.NewLine + Environment.NewLine + "Once added and saved, use command **/verify_relink** to finish relink and receive a new secret key to use for Mare Synchronos." + Environment.NewLine + "__You can delete the entry from your profile after verification.__" + Environment.NewLine + Environment.NewLine + "The verification will expire in approximately 15 minutes. If you fail to **/verify_relink** the relink will be invalidated and you have to **/relink** again."); _botServices.DiscordRelinkLodestoneMapping[userid] = lodestoneId.ToString(); } } return embed.Build(); } private async Task GenerateLodestoneAuth(ulong discordid, string hashedLodestoneId, MareDbContext dbContext) { var auth = StringUtils.GenerateRandomString(32); LodeStoneAuth lsAuth = new LodeStoneAuth() { DiscordId = discordid, HashedLodestoneId = hashedLodestoneId, LodestoneAuthString = auth, StartedAt = DateTime.UtcNow }; dbContext.Add(lsAuth); await dbContext.SaveChangesAsync().ConfigureAwait(false); return auth; } private int? ParseCharacterIdFromLodestoneUrl(string lodestoneUrl) { var regex = new Regex(@"https:\/\/(na|eu|de|fr|jp)\.finalfantasyxiv\.com\/lodestone\/character\/\d+"); var matches = regex.Match(lodestoneUrl); var isLodestoneUrl = matches.Success; if (!isLodestoneUrl || matches.Groups.Count < 1) return null; lodestoneUrl = matches.Groups[0].ToString(); var stringId = lodestoneUrl.Split('/', StringSplitOptions.RemoveEmptyEntries).Last(); if (!int.TryParse(stringId, out int lodestoneId)) { return null; } return lodestoneId; } private async Task HandleVanityUid(EmbedBuilder eb, ulong id, string newUid, string? secondaryUid) { if (_botServices.LastVanityChange.TryGetValue(id, out var lastChange)) { var timeRemaining = DateTime.UtcNow.Subtract(lastChange); if (timeRemaining.TotalHours < 24) { eb.WithTitle(("Failed to set Vanity UID")); eb.WithDescription( $"You can only change your vanity UID once every 24h. Your last change is {timeRemaining} ago."); } } Regex rgx = new(@"^[_\-a-zA-Z0-9]{5,15}$", RegexOptions.ECMAScript); if (!rgx.Match(newUid).Success || newUid.Length < 5 || newUid.Length > 15) { eb.WithTitle("Failed to set Vanity UID"); eb.WithDescription("The Vanity UID must be between 5 and 15 characters and only contain letters A-Z, numbers 0-9, as well as - and _."); return eb; } using var scope = _services.CreateScope(); await using var db = scope.ServiceProvider.GetRequiredService(); var lodestoneUser = await db.LodeStoneAuth.Include("User").SingleOrDefaultAsync(u => u.DiscordId == id).ConfigureAwait(false); if (lodestoneUser == null) { eb.WithTitle("Failed to set Vanity UID"); eb.WithDescription("You do not have a registered account on this server."); return eb; } var uidExists = await db.Users.AnyAsync(u => u.UID == newUid || u.Alias == newUid).ConfigureAwait(false); if (uidExists) { eb.WithTitle("Failed to set Vanity UID"); eb.WithDescription("This UID is already taken."); return eb; } if (secondaryUid != null) { var secondaryUser = await db.Auth.Include(u => u.PrimaryUser).Include(u => u.User) .SingleOrDefaultAsync(u => u.UserUID == secondaryUid && u.PrimaryUserUID == lodestoneUser.User.UID).ConfigureAwait(false); if (secondaryUser == null) { eb.WithTitle("No secondary UID found"); eb.WithDescription($"Did not find a secondary UID {secondaryUid} attached to your primary UID {lodestoneUser.User.UID}"); return eb; } secondaryUser.User.Alias = newUid; db.Update(secondaryUser); eb.WithTitle("Vanity UID set"); eb.WithDescription($"Your Vanity UID for the secondary UID {secondaryUid} was set to **{newUid}**." + Environment.NewLine + "For those changes to apply you will have to reconnect to Mare."); } else { var user = lodestoneUser.User; user.Alias = newUid; db.Update(user); eb.WithTitle("Vanity UID set"); eb.WithDescription("Your Vanity UID was set to **" + newUid + "**." + Environment.NewLine + "For those changes to apply you will have to reconnect to Mare."); } await db.SaveChangesAsync(); _botServices.LastVanityChange[id] = DateTime.UtcNow; return eb; } private async Task HandleVanityGid(EmbedBuilder eb, ulong id, string oldGid, string newGid) { if (_botServices.LastVanityGidChange.TryGetValue(oldGid, out var lastChange)) { var dateTimeDiff = DateTime.UtcNow.Subtract(lastChange); if (dateTimeDiff.TotalHours < 24) { eb.WithTitle(("Failed to set Vanity Syncshell Id")); eb.WithDescription( $"You can only change the Vanity Syncshell Id once every 24h. Your last change is {dateTimeDiff} ago."); } } Regex rgx = new(@"^[_\-a-zA-Z0-9]{5,20}$", RegexOptions.ECMAScript); if (!rgx.Match(newGid).Success || newGid.Length < 5 || newGid.Length > 20) { eb.WithTitle("Failed to set Vanity Syncshell Id"); eb.WithDescription("The Vanity Syncshell Id must be between 5 and 20 characters and only contain letters A-Z, numbers 0-9 as well as - and _."); return eb; } using var scope = _services.CreateScope(); await using var db = scope.ServiceProvider.GetRequiredService(); var lodestoneUser = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == id).ConfigureAwait(false); if (lodestoneUser == null) { eb.WithTitle("Failed to set Vanity Syncshell Id"); eb.WithDescription("You do not have a registered account on this server."); return eb; } var group = await db.Groups.FirstOrDefaultAsync(g => g.GID == oldGid || g.Alias == oldGid).ConfigureAwait(false); if (group == null) { eb.WithTitle("Failed to set Vanity Syncshell Id"); eb.WithDescription("The provided Syncshell Id does not exist."); return eb; } if (lodestoneUser.User.UID != group.OwnerUID) { eb.WithTitle("Failed to set Vanity Syncshell Id"); eb.WithDescription("You are not the owner of this Syncshell"); return eb; } var uidExists = await db.Groups.AnyAsync(u => u.GID == newGid || u.Alias == newGid).ConfigureAwait(false); if (uidExists) { eb.WithTitle("Failed to set Vanity Syncshell Id"); eb.WithDescription("This Syncshell Id is already taken."); return eb; } group.Alias = newGid; db.Update(group); await db.SaveChangesAsync(); _botServices.LastVanityGidChange[newGid] = DateTime.UtcNow; _botServices.LastVanityGidChange[oldGid] = DateTime.UtcNow; eb.WithTitle("Vanity Syncshell Id set"); eb.WithDescription("The Vanity Syncshell Id was set to **" + newGid + "**." + Environment.NewLine + "For those changes to apply you will have to reconnect to Mare."); return eb; } private async Task DeletePreviousUserAccount(ulong id) { using var scope = _services.CreateScope(); using var db = scope.ServiceProvider.GetService(); var discordAuthedUser = await db.LodeStoneAuth.Include(u => u.User).FirstOrDefaultAsync(u => u.DiscordId == id).ConfigureAwait(false); if (discordAuthedUser != null) { if (discordAuthedUser.User != null) { var maxGroupsByUser = _mareClientConfigurationService.GetValueOrDefault(nameof(ServerConfiguration.MaxGroupUserCount), 3); await SharedDbFunctions.PurgeUser(_logger, discordAuthedUser.User, db, maxGroupsByUser); } else { db.Remove(discordAuthedUser); } await db.SaveChangesAsync().ConfigureAwait(false); } } private async Task HandleVerifyRelinkAsync(SocketSlashCommand cmd, IServiceProvider serviceProvider) { var embedBuilder = new EmbedBuilder(); using var scope = serviceProvider.CreateScope(); var req = new HttpClient(); using var db = scope.ServiceProvider.GetService(); var lodestoneAuth = db.LodeStoneAuth.SingleOrDefault(u => u.DiscordId == cmd.User.Id); if (lodestoneAuth != null && _botServices.DiscordRelinkLodestoneMapping.ContainsKey(cmd.User.Id)) { var randomServer = _botServices.LodestoneServers[random.Next(_botServices.LodestoneServers.Length)]; var response = await req.GetAsync($"https://{randomServer}.finalfantasyxiv.com/lodestone/character/{_botServices.DiscordRelinkLodestoneMapping[cmd.User.Id]}").ConfigureAwait(false); if (response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (content.Contains(lodestoneAuth.LodestoneAuthString)) { _botServices.DiscordRelinkLodestoneMapping.TryRemove(cmd.User.Id, out _); var existingLodestoneAuth = db.LodeStoneAuth.Include(u => u.User).SingleOrDefault(u => u.DiscordId != cmd.User.Id && u.HashedLodestoneId == lodestoneAuth.HashedLodestoneId); var previousAuth = await db.Auth.FirstOrDefaultAsync(u => u.UserUID == existingLodestoneAuth.User.UID && string.IsNullOrEmpty(u.PrimaryUserUID)); if (previousAuth != null) { db.Auth.Remove(previousAuth); } var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); var auth = new Auth() { HashedKey = StringUtils.Sha256String(computedHash), User = existingLodestoneAuth.User, }; lodestoneAuth.StartedAt = null; lodestoneAuth.LodestoneAuthString = null; lodestoneAuth.User = existingLodestoneAuth.User; db.LodeStoneAuth.Remove(existingLodestoneAuth); await db.Auth.AddAsync(auth).ConfigureAwait(false); _botServices.Logger.LogInformation("User relinked: {userUID}", lodestoneAuth.User.UID); embedBuilder.WithTitle("Relink successful"); embedBuilder.WithDescription("This is your **new** private secret key. Do not share this private secret key with anyone. **If you lose it, it is irrevocably lost.**" + Environment.NewLine + Environment.NewLine + $"**{computedHash}**" + Environment.NewLine + Environment.NewLine + "Enter this key in Mare Synchronos and hit save to connect to the service."); } else { embedBuilder.WithTitle("Failed to verify your character"); embedBuilder.WithDescription("Did not find requested authentication key on your profile. Make sure you have saved *twice*, then do **/relink_verify** again."); lodestoneAuth.StartedAt = DateTime.UtcNow; } } await db.SaveChangesAsync().ConfigureAwait(false); } else { embedBuilder.WithTitle("Your auth has expired or something else went wrong"); embedBuilder.WithDescription("Start again with **/relink**"); _botServices.DiscordRelinkLodestoneMapping.TryRemove(cmd.User.Id, out _); } var dataEmbed = embedBuilder.Build(); await cmd.FollowupAsync(embed: dataEmbed, ephemeral: true).ConfigureAwait(false); } private async Task HandleVerifyAsync(SocketSlashCommand cmd, IServiceProvider serviceProvider) { var embedBuilder = new EmbedBuilder(); using var scope = serviceProvider.CreateScope(); var req = new HttpClient(); using var db = scope.ServiceProvider.GetService(); var lodestoneAuth = db.LodeStoneAuth.SingleOrDefault(u => u.DiscordId == cmd.User.Id); if (lodestoneAuth != null && _botServices.DiscordLodestoneMapping.ContainsKey(cmd.User.Id)) { var randomServer = _botServices.LodestoneServers[random.Next(_botServices.LodestoneServers.Length)]; var response = await req.GetAsync($"https://{randomServer}.finalfantasyxiv.com/lodestone/character/{_botServices.DiscordLodestoneMapping[cmd.User.Id]}").ConfigureAwait(false); if (response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (content.Contains(lodestoneAuth.LodestoneAuthString)) { _botServices.DiscordLodestoneMapping.TryRemove(cmd.User.Id, out _); var user = new User(); var hasValidUid = false; while (!hasValidUid) { var uid = StringUtils.GenerateRandomString(7); if (db.Users.Any(u => u.UID == uid || u.Alias == uid)) continue; user.UID = uid; hasValidUid = true; } // make the first registered user on the service to admin if (!await db.Users.AnyAsync().ConfigureAwait(false)) { user.IsAdmin = true; } user.LastLoggedIn = DateTime.UtcNow; var computedHash = StringUtils.Sha256String(StringUtils.GenerateRandomString(64) + DateTime.UtcNow.ToString()); var auth = new Auth() { HashedKey = StringUtils.Sha256String(computedHash), User = user, }; await db.Users.AddAsync(user).ConfigureAwait(false); await db.Auth.AddAsync(auth).ConfigureAwait(false); _botServices.Logger.LogInformation("User registered: {userUID}", user.UID); lodestoneAuth.StartedAt = null; lodestoneAuth.User = user; lodestoneAuth.LodestoneAuthString = null; embedBuilder.WithTitle("Registration successful"); embedBuilder.WithDescription("This is your private secret key. Do not share this private secret key with anyone. **If you lose it, it is irrevocably lost.**" + Environment.NewLine + Environment.NewLine + $"**{computedHash}**" + Environment.NewLine + Environment.NewLine + "Enter this key in to the plugin when prompted and hit save to connect to the service." + Environment.NewLine + "You should connect as soon as possible to not get caught by the automatic cleanup process." + Environment.NewLine + "Have fun."); } else { embedBuilder.WithTitle("Failed to verify your character"); embedBuilder.WithDescription("Did not find requested authentication key on your profile. Make sure you have saved *twice*, then do **/verify** again."); lodestoneAuth.StartedAt = DateTime.UtcNow; } } await db.SaveChangesAsync().ConfigureAwait(false); } else { embedBuilder.WithTitle("Your auth has expired or something else went wrong"); embedBuilder.WithDescription("Start again with **/register**"); _botServices.DiscordLodestoneMapping.TryRemove(cmd.User.Id, out _); } var dataEmbed = embedBuilder.Build(); await cmd.FollowupAsync(embed: dataEmbed, ephemeral: true).ConfigureAwait(false); } }