diff --git a/PluralKit.API/Controllers/AccountController.cs b/PluralKit.API/Controllers/AccountController.cs index c54f5849..6e32b431 100644 --- a/PluralKit.API/Controllers/AccountController.cs +++ b/PluralKit.API/Controllers/AccountController.cs @@ -11,10 +11,12 @@ namespace PluralKit.API.Controllers public class AccountController: ControllerBase { private IDataStore _data; + private TokenAuthService _auth; - public AccountController(IDataStore data) + public AccountController(IDataStore data, TokenAuthService auth) { _data = data; + _auth = auth; } [HttpGet("{aid}")] @@ -23,7 +25,7 @@ namespace PluralKit.API.Controllers var system = await _data.GetSystemByAccount(aid); if (system == null) return NotFound("Account not found."); - return Ok(system.ToJson()); + return Ok(system.ToJson(_auth.ContextFor(system))); } } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/MemberController.cs b/PluralKit.API/Controllers/MemberController.cs index 2f8513c2..ef632aa4 100644 --- a/PluralKit.API/Controllers/MemberController.cs +++ b/PluralKit.API/Controllers/MemberController.cs @@ -28,7 +28,7 @@ namespace PluralKit.API.Controllers var member = await _data.GetMemberByHid(hid); if (member == null) return NotFound("Member not found."); - return Ok(member.ToJson()); + return Ok(member.ToJson(_auth.ContextFor(member))); } [HttpPost] @@ -41,7 +41,7 @@ namespace PluralKit.API.Controllers return BadRequest("Member name must be specified."); // Enforce per-system member limit - var memberCount = await _data.GetSystemMemberCount(system); + var memberCount = await _data.GetSystemMemberCount(system, true); if (memberCount >= Limits.MaxMemberCount) return BadRequest($"Member limit reached ({memberCount} / {Limits.MaxMemberCount})."); @@ -56,7 +56,7 @@ namespace PluralKit.API.Controllers } await _data.SaveMember(member); - return Ok(member.ToJson()); + return Ok(member.ToJson(_auth.ContextFor(member))); } [HttpPatch("{hid}")] @@ -78,7 +78,7 @@ namespace PluralKit.API.Controllers } await _data.SaveMember(member); - return Ok(member.ToJson()); + return Ok(member.ToJson(_auth.ContextFor(member))); } [HttpDelete("{hid}")] diff --git a/PluralKit.API/Controllers/MessageController.cs b/PluralKit.API/Controllers/MessageController.cs index 82c0bcc5..419e541f 100644 --- a/PluralKit.API/Controllers/MessageController.cs +++ b/PluralKit.API/Controllers/MessageController.cs @@ -25,10 +25,12 @@ namespace PluralKit.API.Controllers public class MessageController: ControllerBase { private IDataStore _data; + private TokenAuthService _auth; - public MessageController(IDataStore _data) + public MessageController(IDataStore _data, TokenAuthService auth) { this._data = _data; + _auth = auth; } [HttpGet("{mid}")] @@ -43,8 +45,8 @@ namespace PluralKit.API.Controllers Id = msg.Message.Mid.ToString(), Channel = msg.Message.Channel.ToString(), Sender = msg.Message.Sender.ToString(), - Member = msg.Member.ToJson(), - System = msg.System.ToJson(), + Member = msg.Member.ToJson(_auth.ContextFor(msg.System)), + System = msg.System.ToJson(_auth.ContextFor(msg.System)), Original = msg.Message.OriginalMid?.ToString() }; } diff --git a/PluralKit.API/Controllers/SystemController.cs b/PluralKit.API/Controllers/SystemController.cs index 6e2f48e6..5c8d1a69 100644 --- a/PluralKit.API/Controllers/SystemController.cs +++ b/PluralKit.API/Controllers/SystemController.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Dapper; + +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -48,7 +50,7 @@ namespace PluralKit.API.Controllers [RequiresSystem] public Task> GetOwnSystem() { - return Task.FromResult>(Ok(_auth.CurrentSystem.ToJson())); + return Task.FromResult>(Ok(_auth.CurrentSystem.ToJson(_auth.ContextFor(_auth.CurrentSystem)))); } [HttpGet("{hid}")] @@ -56,7 +58,7 @@ namespace PluralKit.API.Controllers { var system = await _data.GetSystemByHid(hid); if (system == null) return NotFound("System not found."); - return Ok(system.ToJson()); + return Ok(system.ToJson(_auth.ContextFor(system))); } [HttpGet("{hid}/members")] @@ -65,8 +67,13 @@ namespace PluralKit.API.Controllers var system = await _data.GetSystemByHid(hid); if (system == null) return NotFound("System not found."); + if (!system.MemberListPrivacy.CanAccess(_auth.ContextFor(system))) + return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized to view member list."); + var members = await _data.GetSystemMembers(system); - return Ok(members.Select(m => m.ToJson())); + return Ok(members + .Where(m => m.MemberPrivacy.CanAccess(_auth.ContextFor(system))) + .Select(m => m.ToJson(_auth.ContextFor(system)))); } [HttpGet("{hid}/switches")] @@ -76,6 +83,9 @@ namespace PluralKit.API.Controllers var system = await _data.GetSystemByHid(hid); if (system == null) return NotFound("System not found."); + + if (!system.FrontHistoryPrivacy.CanAccess(_auth.ContextFor(system))) + return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized to view front history."); using (var conn = await _conn.Obtain()) { @@ -97,6 +107,9 @@ namespace PluralKit.API.Controllers var system = await _data.GetSystemByHid(hid); if (system == null) return NotFound("System not found."); + if (!system.FrontPrivacy.CanAccess(_auth.ContextFor(system))) + return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized to view fronter."); + var sw = await _data.GetLatestSwitch(system); if (sw == null) return NotFound("System has no registered switches."); @@ -104,7 +117,7 @@ namespace PluralKit.API.Controllers return Ok(new FrontersReturn { Timestamp = sw.Timestamp, - Members = members.Select(m => m.ToJson()) + Members = members.Select(m => m.ToJson(_auth.ContextFor(system))) }); } @@ -124,7 +137,7 @@ namespace PluralKit.API.Controllers } await _data.SaveSystem(system); - return Ok(system.ToJson()); + return Ok(system.ToJson(_auth.ContextFor(system))); } [HttpPost("switches")] diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index 4294f768..e007dc8e 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -26,6 +26,7 @@ namespace PluralKit.API services .AddTransient() + .AddSingleton() .AddSingleton(svc => InitUtils.InitMetrics(svc.GetRequiredService(), "API")) @@ -41,6 +42,8 @@ namespace PluralKit.API // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { + SchemaService.Initialize(); + if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); diff --git a/PluralKit.API/TokenAuthService.cs b/PluralKit.API/TokenAuthService.cs index e0c5d277..c70cc89e 100644 --- a/PluralKit.API/TokenAuthService.cs +++ b/PluralKit.API/TokenAuthService.cs @@ -26,5 +26,11 @@ namespace PluralKit.API await next.Invoke(context); CurrentSystem = null; } + + public LookupContext ContextFor(PKSystem system) => + system.Id == CurrentSystem?.Id ? LookupContext.ByOwner : LookupContext.API; + + public LookupContext ContextFor(PKMember member) => + member.System == CurrentSystem?.Id ? LookupContext.ByOwner : LookupContext.API; } } \ No newline at end of file diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 0551113a..da3ed512 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -48,6 +48,8 @@ namespace PluralKit.Bot using (var services = BuildServiceProvider()) { + SchemaService.Initialize(); + var logger = services.GetRequiredService().ForContext(); var coreConfig = services.GetRequiredService(); var botConfig = services.GetRequiredService(); diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index 7d990f7f..037173e2 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -57,13 +57,13 @@ namespace PluralKit.Bot.CommandSystem /// /// Checks if the next parameter is equal to one of the given keywords. Case-insensitive. /// - public bool Match(params string[] potentialMatches) + public bool Match(ref string used, params string[] potentialMatches) { foreach (var match in potentialMatches) { if (PeekArgument().Equals(match, StringComparison.InvariantCultureIgnoreCase)) { - PopArgument(); + used = PopArgument(); return true; } } @@ -71,6 +71,15 @@ namespace PluralKit.Bot.CommandSystem return false; } + /// + /// Checks if the next parameter is equal to one of the given keywords. Case-insensitive. + /// + public bool Match(params string[] potentialMatches) + { + string used = null; // Unused and unreturned, we just yeet it + return Match(ref used, potentialMatches); + } + public async Task Execute(Command commandDef, Func handler) { _currentCommand = commandDef; @@ -237,6 +246,15 @@ namespace PluralKit.Bot.CommandSystem throw new PKError("This command can not be run in a DM."); } + public LookupContext LookupContextFor(PKSystem target) => + System?.Id == target.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner; + + public Context CheckSystemPrivacy(PKSystem target, PrivacyLevel level) + { + if (level.CanAccess(LookupContextFor(target))) return this; + throw new PKError("You do not have permission to access this information."); + } + public ITextChannel MatchChannel() { if (!MentionUtils.TryParseChannel(PeekArgument(), out var channel)) return null; diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index bd634c6f..e62fbb96 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -22,6 +22,7 @@ namespace PluralKit.Bot.Commands public static Command SystemFronter = new Command("system fronter", "system [system] fronter", "Shows a system's fronter(s)"); public static Command SystemFrontHistory = new Command("system fronthistory", "system [system] fronthistory", "Shows a system's front history"); public static Command SystemFrontPercent = new Command("system frontpercent", "system [system] frontpercent [timespan]", "Shows a system's front breakdown"); + public static Command SystemPrivacy = new Command("system privacy", "system [system] privacy ", "Changes your system's privacy settings"); public static Command MemberInfo = new Command("member", "member ", "Looks up information about a member"); public static Command MemberNew = new Command("member new", "member new ", "Creates a new member"); public static Command MemberRename = new Command("member rename", "member rename ", "Renames a member"); @@ -36,6 +37,7 @@ namespace PluralKit.Bot.Commands public static Command MemberServerName = new Command("member servername", "member servername [server name]", "Changes a member's display name in the current server"); public static Command MemberKeepProxy = new Command("member keepproxy", "member keepproxy [on|off]", "Sets whether to include a member's proxy tags when proxying"); public static Command MemberRandom = new Command("random", "random", "Gets a random member from your system"); + public static Command MemberPrivacy = new Command("member privacy", "member privacy [on|off]", "Sets whether a member is private or public"); public static Command Switch = new Command("switch", "switch [member 2] [member 3...]", "Registers a switch"); public static Command SwitchOut = new Command("switch out", "switch out", "Registers a switch with no members"); public static Command SwitchMove = new Command("switch move", "switch move ", "Moves the latest switch in time"); @@ -58,7 +60,7 @@ namespace PluralKit.Bot.Commands public static Command[] SystemCommands = { SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemDelete, SystemTimezone, - SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent + SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent, SystemPrivacy }; public static Command[] MemberCommands = { @@ -185,6 +187,8 @@ namespace PluralKit.Bot.Commands await ctx.Execute(SystemFrontHistory, m => m.SystemFrontHistory(ctx, ctx.System)); else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) await ctx.Execute(SystemFrontPercent, m => m.SystemFrontPercent(ctx, ctx.System)); + else if (ctx.Match("privacy")) + await ctx.Execute(SystemPrivacy, m => m.SystemPrivacy(ctx)); else if (ctx.Match("commands", "help")) await PrintCommandList(ctx, "systems", SystemCommands); else if (!ctx.HasNext()) // Bare command @@ -272,6 +276,8 @@ namespace PluralKit.Bot.Commands await ctx.Execute(MemberServerName, m => m.MemberServerName(ctx, target)); else if (ctx.Match("keepproxy", "keeptags", "showtags")) await ctx.Execute(MemberKeepProxy, m => m.MemberKeepProxy(ctx, target)); + else if (ctx.Match("private", "privacy", "hidden")) + await ctx.Execute(MemberPrivacy, m => m.MemberPrivacy(ctx, target)); else if (!ctx.HasNext()) // Bare command await ctx.Execute(MemberInfo, m => m.ViewMember(ctx, target)); else diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 9e8f55e2..323e4651 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -38,7 +38,7 @@ namespace PluralKit.Bot.Commands } // Enforce per-system member limit - var memberCount = await _data.GetSystemMemberCount(ctx.System); + var memberCount = await _data.GetSystemMemberCount(ctx.System, true); if (memberCount >= Limits.MaxMemberCount) throw Errors.MemberLimitReachedError; @@ -69,7 +69,7 @@ namespace PluralKit.Bot.Commands if (members == null || !members.Any()) throw Errors.NoMembersError; var randInt = randGen.Next(members.Count); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild)); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); } @@ -429,11 +429,31 @@ namespace PluralKit.Bot.Commands await ctx.Reply($"{Emojis.Success} Member proxy tags will now not be included in the resulting message when proxying."); await _proxyCache.InvalidateResultsForSystem(ctx.System); } - + + public async Task MemberPrivacy(Context ctx, PKMember target) + { + if (ctx.System == null) throw Errors.NoSystemError; + if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + + bool newValue; + if (ctx.Match("private", "hide", "hidden", "on", "enable", "yes")) newValue = true; + else if (ctx.Match("public", "show", "shown", "displayed", "off", "disable", "no")) newValue = false; + else if (ctx.HasNext()) throw new PKSyntaxError("You must pass either \"private\" or \"public\"."); + else newValue = target.MemberPrivacy != PrivacyLevel.Private; + + target.MemberPrivacy = newValue ? PrivacyLevel.Private : PrivacyLevel.Public; + await _data.SaveMember(target); + + if (newValue) + await ctx.Reply($"{Emojis.Success} Member privacy set to **private**. This member will no longer show up in member lists and will return limited information when queried by other accounts."); + else + await ctx.Reply($"{Emojis.Success} Member privacy set to **public**. This member will now show up in member lists and will return all information when queried by other accounts."); + } + public async Task ViewMember(Context ctx, PKMember target) { var system = await _data.GetSystemById(target.System); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild)); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system))); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemCommands.cs b/PluralKit.Bot/Commands/SystemCommands.cs index 00760fbc..f11d6e41 100644 --- a/PluralKit.Bot/Commands/SystemCommands.cs +++ b/PluralKit.Bot/Commands/SystemCommands.cs @@ -29,7 +29,7 @@ namespace PluralKit.Bot.Commands public async Task Query(Context ctx, PKSystem system) { if (system == null) throw Errors.NoSystemError; - await ctx.Reply(embed: await _embeds.CreateSystemEmbed(system)); + await ctx.Reply(embed: await _embeds.CreateSystemEmbed(system, ctx.LookupContextFor(system))); } public async Task New(Context ctx) @@ -149,27 +149,57 @@ namespace PluralKit.Bot.Commands public async Task MemberShortList(Context ctx, PKSystem system) { if (system == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(system, system.MemberListPrivacy); + + var authCtx = ctx.LookupContextFor(system); + var shouldShowPrivate = authCtx == LookupContext.ByOwner && ctx.Match("all", "everyone", "private"); - var members = await _data.GetSystemMembers(system); + var members = (await _data.GetSystemMembers(system)).ToList(); var embedTitle = system.Name != null ? $"Members of {system.Name.SanitizeMentions()} (`{system.Hid}`)" : $"Members of `{system.Hid}`"; - await ctx.Paginate( - members.OrderBy(m => m.Name, StringComparer.InvariantCultureIgnoreCase).ToList(), + + var membersToDisplay = members + .Where(m => m.MemberPrivacy == PrivacyLevel.Public || shouldShowPrivate) + .OrderBy(m => m.Name, StringComparer.InvariantCultureIgnoreCase).ToList(); + var anyMembersHidden = members.Any(m => m.MemberPrivacy == PrivacyLevel.Private && !shouldShowPrivate); + + await ctx.Paginate( + membersToDisplay, 25, embedTitle, - (eb, ms) => eb.Description = string.Join("\n", ms.Select((m) => { - if (m.HasProxyTags) return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}** *({m.ProxyTagsString().SanitizeMentions()})*"; - return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}**"; - })) - ); + (eb, ms) => + { + eb.Description = string.Join("\n", ms.Select((m) => + { + if (m.HasProxyTags) + return + $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}** *({m.ProxyTagsString().SanitizeMentions()})*"; + return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}**"; + })); + + var footer = $"{membersToDisplay.Count} total."; + if (anyMembersHidden && authCtx == LookupContext.ByOwner) + footer += "Private members have been hidden. type \"pk;system list all\" to include them."; + eb.WithFooter(footer); + }); } public async Task MemberLongList(Context ctx, PKSystem system) { if (system == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(system, system.MemberListPrivacy); + + var authCtx = ctx.LookupContextFor(system); + var shouldShowPrivate = authCtx == LookupContext.ByOwner && ctx.Match("all", "everyone", "private"); - var members = await _data.GetSystemMembers(system); + var members = (await _data.GetSystemMembers(system)).ToList(); var embedTitle = system.Name != null ? $"Members of {system.Name} (`{system.Hid}`)" : $"Members of `{system.Hid}`"; - await ctx.Paginate( - members.OrderBy(m => m.Name, StringComparer.InvariantCultureIgnoreCase).ToList(), + + var membersToDisplay = members + .Where(m => m.MemberPrivacy == PrivacyLevel.Public || shouldShowPrivate) + .OrderBy(m => m.Name, StringComparer.InvariantCultureIgnoreCase).ToList(); + var anyMembersHidden = members.Any(m => m.MemberPrivacy == PrivacyLevel.Private && !shouldShowPrivate); + + await ctx.Paginate( + membersToDisplay, 5, embedTitle, (eb, ms) => { @@ -179,8 +209,16 @@ namespace PluralKit.Bot.Commands if (m.Birthday != null) profile += $"\n**Birthdate**: {m.BirthdayString}"; if (m.ProxyTags.Count > 0) profile += $"\n**Proxy tags:** {m.ProxyTagsString()}"; if (m.Description != null) profile += $"\n\n{m.Description}"; + if (m.MemberPrivacy == PrivacyLevel.Private) + profile += "*(this member is private)*"; + eb.AddField(m.Name, profile.Truncate(1024)); } + + var footer = $"{membersToDisplay.Count} total."; + if (anyMembersHidden && authCtx == LookupContext.ByOwner) + footer += " Private members have been hidden. type \"pk;system list full all\" to include them."; + eb.WithFooter(footer); } ); } @@ -188,6 +226,7 @@ namespace PluralKit.Bot.Commands public async Task SystemFronter(Context ctx, PKSystem system) { if (system == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(system, system.FrontPrivacy); var sw = await _data.GetLatestSwitch(system); if (sw == null) throw Errors.NoRegisteredSwitches; @@ -198,6 +237,7 @@ namespace PluralKit.Bot.Commands public async Task SystemFrontHistory(Context ctx, PKSystem system) { if (system == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy); var sws = (await _data.GetSwitches(system, 10)).ToList(); if (sws.Count == 0) throw Errors.NoRegisteredSwitches; @@ -208,6 +248,7 @@ namespace PluralKit.Bot.Commands public async Task SystemFrontPercent(Context ctx, PKSystem system) { if (system == null) throw Errors.NoSystemError; + ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy); string durationStr = ctx.RemainderOrNull() ?? "30d"; @@ -267,6 +308,80 @@ namespace PluralKit.Bot.Commands await ctx.Reply($"System time zone changed to {zone.Id}."); } + public async Task SystemPrivacy(Context ctx) + { + ctx.CheckSystem(); + + if (!ctx.HasNext()) + { + string PrivacyLevelString(PrivacyLevel level) => level switch + { + PrivacyLevel.Private => "**Private** (visible only when queried by you)", + PrivacyLevel.Public => "**Public** (visible to everyone)", + _ => throw new ArgumentOutOfRangeException(nameof(level), level, null) + }; + + var eb = new EmbedBuilder() + .WithTitle("Current privacy settings for your system") + .AddField("Description", PrivacyLevelString(ctx.System.DescriptionPrivacy)) + .AddField("Member list", PrivacyLevelString(ctx.System.MemberListPrivacy)) + .AddField("Current fronter(s)", PrivacyLevelString(ctx.System.FrontPrivacy)) + .AddField("Front/switch history", PrivacyLevelString(ctx.System.FrontHistoryPrivacy)) + .WithDescription("To edit privacy settings, use the command:\n`pk;system privacy `\n\n- `subject` is one of `description`, `list`, `front` or `fronthistory`\n- `level` is either `public` or `private`."); + await ctx.Reply(embed: eb.Build()); + return; + } + + PrivacyLevel PopPrivacyLevel(string subject, out string levelStr, out string levelExplanation) + { + if (ctx.Match("public", "show", "shown", "visible")) + { + levelStr = "public"; + levelExplanation = "be able to query"; + return PrivacyLevel.Public; + } + + if (ctx.Match("private", "hide", "hidden")) + { + levelStr = "private"; + levelExplanation = "*not* be able to query"; + return PrivacyLevel.Private; + } + + if (!ctx.HasNext()) + throw new PKSyntaxError($"You must pass a privacy level for `{subject}` (`public` or `private`)"); + throw new PKSyntaxError($"Invalid privacy level `{ctx.PopArgument().SanitizeMentions()}` (must be `public` or `private`)."); + } + + string levelStr, levelExplanation, subjectStr; + var subjectList = "`description`, `members`, `front` or `fronthistory`"; + if (ctx.Match("description", "desc", "text", "info")) + { + subjectStr = "description"; + ctx.System.DescriptionPrivacy = PopPrivacyLevel("description", out levelStr, out levelExplanation); + } + else if (ctx.Match("members", "memberlist", "list", "mlist")) + { + subjectStr = "member list"; + ctx.System.MemberListPrivacy = PopPrivacyLevel("members", out levelStr, out levelExplanation); + } + else if (ctx.Match("front", "fronter")) + { + subjectStr = "fronter(s)"; + ctx.System.FrontPrivacy = PopPrivacyLevel("front", out levelStr, out levelExplanation); + } + else if (ctx.Match("switch", "switches", "fronthistory", "fh")) + { + subjectStr = "front history"; + ctx.System.FrontHistoryPrivacy = PopPrivacyLevel("fronthistory", out levelStr, out levelExplanation); + } + else + throw new PKSyntaxError($"Invalid privacy subject `{ctx.PopArgument().SanitizeMentions()}` (must be {subjectList})."); + + await _data.SaveSystem(ctx.System); + await ctx.Reply($"System {subjectStr} privacy has been set to **{levelStr}**. Other accounts will now {levelExplanation} your system {subjectStr}."); + } + public async Task FindTimeZone(Context ctx, string zoneStr) { // First, if we're given a flag emoji, we extract the flag emoji code from it. zoneStr = PluralKit.Utils.ExtractCountryFlag(zoneStr) ?? zoneStr; diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 5f55d4aa..528c430c 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -20,13 +20,13 @@ namespace PluralKit.Bot { _data = data; } - public async Task CreateSystemEmbed(PKSystem system) { + public async Task CreateSystemEmbed(PKSystem system, LookupContext ctx) { var accounts = await _data.GetSystemAccounts(system); // Fetch/render info for all accounts simultaneously var users = await Task.WhenAll(accounts.Select(async uid => (await _client.GetUserAsync(uid))?.NameAndMention() ?? $"(deleted account {uid})")); - var memberCount = await _data.GetSystemMemberCount(system); + var memberCount = await _data.GetSystemMemberCount(system, false); var eb = new EmbedBuilder() .WithColor(Color.Blue) .WithTitle(system.Name ?? null) @@ -34,7 +34,7 @@ namespace PluralKit.Bot { .WithFooter($"System ID: {system.Hid} | Created on {Formats.ZonedDateTimeFormat.Format(system.Created.InZone(system.Zone))}"); var latestSwitch = await _data.GetLatestSwitch(system); - if (latestSwitch != null) + if (latestSwitch != null && system.FrontPrivacy.CanAccess(ctx)) { var switchMembers = (await _data.GetSwitchMembers(latestSwitch)).ToList(); if (switchMembers.Count > 0) @@ -44,9 +44,12 @@ namespace PluralKit.Bot { if (system.Tag != null) eb.AddField("Tag", system.Tag); eb.AddField("Linked accounts", string.Join(", ", users).Truncate(1000), true); - eb.AddField($"Members ({memberCount})", $"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)", true); + + if (system.MemberListPrivacy.CanAccess(ctx)) + eb.AddField($"Members ({memberCount})", $"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)", true); - if (system.Description != null) eb.AddField("Description", system.Description.Truncate(1024), false); + if (system.Description != null && system.DescriptionPrivacy.CanAccess(ctx)) + eb.AddField("Description", system.Description.Truncate(1024), false); return eb.Build(); } @@ -62,7 +65,7 @@ namespace PluralKit.Bot { .Build(); } - public async Task CreateMemberEmbed(PKSystem system, PKMember member, IGuild guild) + public async Task CreateMemberEmbed(PKSystem system, PKMember member, IGuild guild, LookupContext ctx) { var name = member.Name; if (system.Name != null) name = $"{member.Name} ({system.Name})"; @@ -91,19 +94,21 @@ namespace PluralKit.Bot { var eb = new EmbedBuilder() // TODO: add URL of website when that's up .WithAuthor(name, member.AvatarUrl) - .WithColor(color) + .WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : Color.Default) .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Created on {Formats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}"); + if (member.MemberPrivacy == PrivacyLevel.Private) eb.WithDescription("*(this member is private)*"); + if (member.AvatarUrl != null) eb.WithThumbnailUrl(member.AvatarUrl); if (member.DisplayName != null) eb.AddField("Display Name", member.DisplayName.Truncate(1024), true); if (guild != null && guildDisplayName != null) eb.AddField($"Server Nickname (for {guild.Name})", guildDisplayName.Truncate(1024), true); - if (member.Birthday != null) eb.AddField("Birthdate", member.BirthdayString, true); - if (member.Pronouns != null) eb.AddField("Pronouns", member.Pronouns.Truncate(1024), true); - if (messageCount > 0) eb.AddField("Message Count", messageCount, true); + if (member.Birthday != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Birthdate", member.BirthdayString, true); + if (member.Pronouns != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Pronouns", member.Pronouns.Truncate(1024), true); + if (messageCount > 0 && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Message Count", messageCount, true); if (member.HasProxyTags) eb.AddField("Proxy Tags", string.Join('\n', proxyTagsStr).Truncate(1024), true); - if (member.Color != null) eb.AddField("Color", $"#{member.Color}", true); - if (member.Description != null) eb.AddField("Description", member.Description, false); + if (member.Color != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true); + if (member.Description != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Description", member.Description, false); return eb.Build(); } diff --git a/PluralKit.Core/Migrations/2.sql b/PluralKit.Core/Migrations/2.sql new file mode 100644 index 00000000..d46f1546 --- /dev/null +++ b/PluralKit.Core/Migrations/2.sql @@ -0,0 +1,13 @@ +-- We're doing a psuedo-enum here since Dapper is wonky with enums +-- Still getting mapped to enums at the CLR level, though. +-- https://github.com/StackExchange/Dapper/issues/332 (from 2015, still unsolved!) +-- 1 = "public" +-- 2 = "private" +-- not doing a bool here since I want to open up for the possibliity of other privacy levels (eg. "mutuals only") +alter table systems add column description_privacy integer check (description_privacy in (1, 2)) not null default 1; +alter table systems add column member_list_privacy integer check (member_list_privacy in (1, 2)) not null default 1; +alter table systems add column front_privacy integer check (front_privacy in (1, 2)) not null default 1; +alter table systems add column front_history_privacy integer check (front_history_privacy in (1, 2)) not null default 1; +alter table members add column member_privacy integer check (member_privacy in (1, 2)) not null default 1; + +update info set schema_version = 2; \ No newline at end of file diff --git a/PluralKit.Core/Models.cs b/PluralKit.Core/Models.cs index 579dca2f..f044a3a8 100644 --- a/PluralKit.Core/Models.cs +++ b/PluralKit.Core/Models.cs @@ -17,6 +17,25 @@ namespace PluralKit { public PKParseError(string message): base(message) { } } + + public enum PrivacyLevel + { + Public = 1, + Private = 2 + } + + public static class PrivacyExt + { + public static bool CanAccess(this PrivacyLevel level, LookupContext ctx) => + level == PrivacyLevel.Public || ctx == LookupContext.ByOwner; + } + + public enum LookupContext + { + ByOwner, + ByNonOwner, + API + } public struct ProxyTag { @@ -58,14 +77,19 @@ namespace PluralKit [JsonIgnore] public string Token { get; set; } [JsonProperty("created")] public Instant Created { get; set; } [JsonProperty("tz")] public string UiTz { get; set; } + public PrivacyLevel DescriptionPrivacy { get; set; } + public PrivacyLevel MemberListPrivacy { get; set; } + public PrivacyLevel FrontPrivacy { get; set; } + public PrivacyLevel FrontHistoryPrivacy { get; set; } + [JsonIgnore] public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz); - public JObject ToJson() + public JObject ToJson(LookupContext ctx) { var o = new JObject(); o.Add("id", Hid); o.Add("name", Name); - o.Add("description", Description); + o.Add("description", DescriptionPrivacy.CanAccess(ctx) ? Description : null); o.Add("tag", Tag); o.Add("avatar_url", AvatarUrl); o.Add("created", Formats.TimestampExportFormat.Format(Created)); @@ -100,6 +124,8 @@ namespace PluralKit [JsonProperty("keep_proxy")] public bool KeepProxy { get; set; } [JsonProperty("created")] public Instant Created { get; set; } + public PrivacyLevel MemberPrivacy { get; set; } + /// Returns a formatted string representing the member's birthday, taking into account that a year of "0001" is hidden [JsonIgnore] public string BirthdayString { @@ -120,17 +146,17 @@ namespace PluralKit return $"{guildDisplayName ?? DisplayName ?? Name} {systemTag}"; } - public JObject ToJson() + public JObject ToJson(LookupContext ctx) { var o = new JObject(); o.Add("id", Hid); o.Add("name", Name); - o.Add("color", Color); + o.Add("color", MemberPrivacy.CanAccess(ctx) ? Color : null); o.Add("display_name", DisplayName); - o.Add("birthday", Birthday.HasValue ? Formats.DateExportFormat.Format(Birthday.Value) : null); - o.Add("pronouns", Pronouns); + o.Add("birthday", MemberPrivacy.CanAccess(ctx) && Birthday.HasValue ? Formats.DateExportFormat.Format(Birthday.Value) : null); + o.Add("pronouns", MemberPrivacy.CanAccess(ctx) ? Pronouns : null); o.Add("avatar_url", AvatarUrl); - o.Add("description", Description); + o.Add("description", MemberPrivacy.CanAccess(ctx) ? Description : null); var tagArray = new JArray(); foreach (var tag in ProxyTags) diff --git a/PluralKit.Core/SchemaService.cs b/PluralKit.Core/SchemaService.cs index ba2f8bf5..abcb34de 100644 --- a/PluralKit.Core/SchemaService.cs +++ b/PluralKit.Core/SchemaService.cs @@ -1,5 +1,4 @@ using System; -using System.Data; using System.IO; using System.Threading.Tasks; using Dapper; @@ -11,7 +10,7 @@ using Serilog; namespace PluralKit { public class SchemaService { - private const int TargetSchemaVersion = 1; + private const int TargetSchemaVersion = 2; private DbConnectionFactory _conn; private ILogger _logger; @@ -22,6 +21,13 @@ namespace PluralKit { _logger = logger.ForContext(); } + public static void Initialize() + { + // Without these it'll still *work* but break at the first launch + probably cause other small issues + NpgsqlConnection.GlobalTypeMapper.MapComposite("proxy_tag"); + NpgsqlConnection.GlobalTypeMapper.MapEnum("privacy_level"); + } + public async Task ApplyMigrations() { for (var version = 0; version <= TargetSchemaVersion; version++) diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 5d3bffcf..0e1d32ba 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -116,7 +116,8 @@ namespace PluralKit { /// /// Gets the member count of a system. /// - Task GetSystemMemberCount(PKSystem system); + /// Whether the returned count should include private members. + Task GetSystemMemberCount(PKSystem system, bool includePrivate); /// /// Gets a list of members with proxy tags that conflict with the given tags. @@ -488,7 +489,7 @@ namespace PluralKit { public async Task SaveSystem(PKSystem system) { using (var conn = await _conn.Obtain()) - await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz where id = @Id", system); + await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz, description_privacy = @DescriptionPrivacy, member_list_privacy = @MemberListPrivacy, front_privacy = @FrontPrivacy, front_history_privacy = @FrontHistoryPrivacy where id = @Id", system); _logger.Information("Updated system {@System}", system); } @@ -590,7 +591,7 @@ namespace PluralKit { public async Task SaveMember(PKMember member) { using (var conn = await _conn.Obtain()) - await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, proxy_tags = @ProxyTags, keep_proxy = @KeepProxy where id = @Id", member); + await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, proxy_tags = @ProxyTags, keep_proxy = @KeepProxy, member_privacy = @MemberPrivacy where id = @Id", member); _logger.Information("Updated member {@Member}", member); } @@ -637,10 +638,13 @@ namespace PluralKit { new { System = system.Id }); } - public async Task GetSystemMemberCount(PKSystem system) + public async Task GetSystemMemberCount(PKSystem system, bool includePrivate) { + var query = "select count(*) from members where system = @Id"; + if (includePrivate) query += " and member_privacy = 1"; // 1 = public + using (var conn = await _conn.Obtain()) - return await conn.ExecuteScalarAsync("select count(*) from members where system = @Id", system); + return await conn.ExecuteScalarAsync(query, system); } public async Task GetTotalMembers() diff --git a/docs/3-api-documentation.md b/docs/3-api-documentation.md index 7d51e473..567f948f 100644 --- a/docs/3-api-documentation.md +++ b/docs/3-api-documentation.md @@ -18,6 +18,11 @@ Authentication is done with a simple "system token". You can get your system tok Discord bot, either in a channel with the bot or in DMs. Then, pass this token in the `Authorization` HTTP header on requests that require it. Failure to do so on endpoints that require authentication will return a `401 Unauthorized`. +Some endpoints show information that a given system may have set to private. If this is a specific field +(eg. description), the field will simply contain `null` rather than the true value. If this applies to entire endpoint +responses (eg. fronter, switches, member list), the entire request will return `403 Forbidden`. Authenticating with the +system's token (as described above) will override these privacy settings and show the full information. + ## Models The following three models (usually represented in JSON format) represent the various objects in PluralKit's API. A `?` after the column type indicates an optional (nullable) parameter. @@ -99,6 +104,7 @@ Returns information about your own system. ### GET /s/\ Queries a system by its 5-character ID, and returns information about it. If the system doesn't exist, returns `404 Not Found`. +Some fields may be set to `null` if unauthenticated and the system has chosen to make those fields private. #### Example request GET https://api.pluralkit.me/v1/s/abcde @@ -118,6 +124,8 @@ Queries a system by its 5-character ID, and returns information about it. If the ### GET /s/\/members Queries a system's member list by its 5-character ID. If the system doesn't exist, returns `404 Not Found`. +If the system has chosen to hide its member list, this will return `403 Forbidden`, unless the request is authenticated with the system's token. +If the request is not authenticated with the system's token, members marked as private will *not* be returned. #### Example request GET https://api.pluralkit.me/v1/s/abcde/members @@ -145,6 +153,8 @@ Returns a system's switch history in newest-first chronological order, with a ma Optionally takes a `?before=` query parameter with an ISO-8601-formatted timestamp, and will only return switches that happen before that timestamp. +If the system has chosen to hide its switch history, this will return `403 Forbidden`, unless the request is authenticated with the system's token. + #### Example request GET https://api.pluralkit.me/v1/s/abcde/switches?before=2019-03-01T14:00:00Z @@ -168,6 +178,7 @@ that happen before that timestamp. ### GET /s/\/fronters Returns a system's current fronter(s), with fully hydrated member objects. If the system doesn't exist, *or* the system has no registered switches, returns `404 Not Found`. +If the system has chosen to hide its current fronters, this will return `403 Forbidden`, unless the request is authenticated with the system's token. If a returned member is private, and the request isn't properly authenticated, some fields may be null. #### Example request GET https://api.pluralkit.me/v1/s/abcde/fronters @@ -243,6 +254,7 @@ Registers a new switch to your own system given a list of member IDs. ### GET /m/\ Queries a member's information by its 5-character member ID. If the member does not exist, will return `404 Not Found`. +If this member is marked private, and the request isn't authenticated with the member's system's token, some fields (currently only `description`) will contain `null` rather than the true value. #### Example request GET https://api.pluralkit.me/v1/m/qwert @@ -354,6 +366,7 @@ Deletes a member from the database. Be careful as there is no confirmation and t ### GET /a/\ Queries a system by its linked Discord account ID (17/18-digit numeric snowflake). Returns `404 Not Found` if the account doesn't have a system linked. +Some fields may be set to `null` if unauthenticated and the system has chosen to make those fields private. #### Example request GET https://api.pluralkit.me/v1/a/466378653216014359 @@ -375,6 +388,8 @@ Queries a system by its linked Discord account ID (17/18-digit numeric snowflake Looks up a proxied message by its message ID. Returns `404 Not Found` if the message ID is invalid or wasn't found (eg. was deleted or not proxied by PK). You can also look messages up by their *trigger* message ID (useful for, say, logging bot integration). +The returned system and member's privacy settings will be respected, and as such, some fields may be set to null without the proper authentication. + #### Example request GET https://api.pluralkit.me/v1/msg/601014599386398700 @@ -411,6 +426,8 @@ You can also look messages up by their *trigger* message ID (useful for, say, lo ``` ## Version history +* 2020-01-08 + * Added privacy support, meaning some responses will now lack information or return 403s, depending on the specific system and member's privacy settings. * 2019-12-28 * Changed behaviour of missing fields in PATCH responses, will now preserve the old value instead of clearing * This is technically a breaking change, but not *significantly* so, so I won't bump the version number.