Add system and member privacy support

This commit is contained in:
Ske 2020-01-11 16:49:20 +01:00
parent f0cc5c5961
commit 98613c4287
17 changed files with 317 additions and 59 deletions

View File

@ -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)));
}
}
}

View File

@ -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}")]

View File

@ -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()
};
}

View File

@ -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<ActionResult<JObject>> GetOwnSystem()
{
return Task.FromResult<ActionResult<JObject>>(Ok(_auth.CurrentSystem.ToJson()));
return Task.FromResult<ActionResult<JObject>>(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")]
@ -77,6 +84,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())
{
var res = await conn.QueryAsync<SwitchesReturn>(
@ -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")]

View File

@ -26,6 +26,7 @@ namespace PluralKit.API
services
.AddTransient<IDataStore, PostgresDataStore>()
.AddSingleton<SchemaService>()
.AddSingleton(svc => InitUtils.InitMetrics(svc.GetRequiredService<CoreConfig>(), "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();

View File

@ -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;
}
}

View File

@ -48,6 +48,8 @@ namespace PluralKit.Bot
using (var services = BuildServiceProvider())
{
SchemaService.Initialize();
var logger = services.GetRequiredService<ILogger>().ForContext<Initialize>();
var coreConfig = services.GetRequiredService<CoreConfig>();
var botConfig = services.GetRequiredService<BotConfig>();

View File

@ -57,13 +57,13 @@ namespace PluralKit.Bot.CommandSystem
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
/// </summary>
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;
}
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
/// </summary>
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<T>(Command commandDef, Func<T, Task> 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;

View File

@ -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 <description|members|fronter|fronthistory> <public|private>", "Changes your system's privacy settings");
public static Command MemberInfo = new Command("member", "member <member>", "Looks up information about a member");
public static Command MemberNew = new Command("member new", "member new <name>", "Creates a new member");
public static Command MemberRename = new Command("member rename", "member <member> rename <new name>", "Renames a member");
@ -36,6 +37,7 @@ namespace PluralKit.Bot.Commands
public static Command MemberServerName = new Command("member servername", "member <member> servername [server name]", "Changes a member's display name in the current server");
public static Command MemberKeepProxy = new Command("member keepproxy", "member <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 <member> privacy [on|off]", "Sets whether a member is private or public");
public static Command Switch = new Command("switch", "switch <member> [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 <date/time>", "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<SystemCommands>(SystemFrontHistory, m => m.SystemFrontHistory(ctx, ctx.System));
else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown"))
await ctx.Execute<SystemCommands>(SystemFrontPercent, m => m.SystemFrontPercent(ctx, ctx.System));
else if (ctx.Match("privacy"))
await ctx.Execute<SystemCommands>(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<MemberCommands>(MemberServerName, m => m.MemberServerName(ctx, target));
else if (ctx.Match("keepproxy", "keeptags", "showtags"))
await ctx.Execute<MemberCommands>(MemberKeepProxy, m => m.MemberKeepProxy(ctx, target));
else if (ctx.Match("private", "privacy", "hidden"))
await ctx.Execute<MemberCommands>(MemberPrivacy, m => m.MemberPrivacy(ctx, target));
else if (!ctx.HasNext()) // Bare command
await ctx.Execute<MemberCommands>(MemberInfo, m => m.ViewMember(ctx, target));
else

View File

@ -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)));
}
@ -430,10 +430,30 @@ namespace PluralKit.Bot.Commands
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)));
}
}
}

View File

@ -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 members = await _data.GetSystemMembers(system);
var authCtx = ctx.LookupContextFor(system);
var shouldShowPrivate = authCtx == LookupContext.ByOwner && ctx.Match("all", "everyone", "private");
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<PKMember>(
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 members = await _data.GetSystemMembers(system);
var authCtx = ctx.LookupContextFor(system);
var shouldShowPrivate = authCtx == LookupContext.ByOwner && ctx.Match("all", "everyone", "private");
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<PKMember>(
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 <subject> <level>`\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<DateTimeZone> 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;

View File

@ -20,13 +20,13 @@ namespace PluralKit.Bot {
_data = data;
}
public async Task<Embed> CreateSystemEmbed(PKSystem system) {
public async Task<Embed> 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.Description != null) eb.AddField("Description", system.Description.Truncate(1024), false);
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 && 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<Embed> CreateMemberEmbed(PKSystem system, PKMember member, IGuild guild)
public async Task<Embed> 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();
}

View File

@ -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;

View File

@ -18,6 +18,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
{
public ProxyTag(string prefix, string suffix)
@ -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)

View File

@ -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<SchemaService>();
}
public static void Initialize()
{
// Without these it'll still *work* but break at the first launch + probably cause other small issues
NpgsqlConnection.GlobalTypeMapper.MapComposite<ProxyTag>("proxy_tag");
NpgsqlConnection.GlobalTypeMapper.MapEnum<PrivacyLevel>("privacy_level");
}
public async Task ApplyMigrations()
{
for (var version = 0; version <= TargetSchemaVersion; version++)

View File

@ -116,7 +116,8 @@ namespace PluralKit {
/// <summary>
/// Gets the member count of a system.
/// </summary>
Task<int> GetSystemMemberCount(PKSystem system);
/// <param name="includePrivate">Whether the returned count should include private members.</param>
Task<int> GetSystemMemberCount(PKSystem system, bool includePrivate);
/// <summary>
/// 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<int> GetSystemMemberCount(PKSystem system)
public async Task<int> 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<int>("select count(*) from members where system = @Id", system);
return await conn.ExecuteScalarAsync<int>(query, system);
}
public async Task<ulong> GetTotalMembers()

View File

@ -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/\<id>
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/\<id>/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/\<id>/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/\<id>
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/\<id>
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.