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 public class AccountController: ControllerBase
{ {
private IDataStore _data; private IDataStore _data;
private TokenAuthService _auth;
public AccountController(IDataStore data) public AccountController(IDataStore data, TokenAuthService auth)
{ {
_data = data; _data = data;
_auth = auth;
} }
[HttpGet("{aid}")] [HttpGet("{aid}")]
@ -23,7 +25,7 @@ namespace PluralKit.API.Controllers
var system = await _data.GetSystemByAccount(aid); var system = await _data.GetSystemByAccount(aid);
if (system == null) return NotFound("Account not found."); 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); var member = await _data.GetMemberByHid(hid);
if (member == null) return NotFound("Member not found."); if (member == null) return NotFound("Member not found.");
return Ok(member.ToJson()); return Ok(member.ToJson(_auth.ContextFor(member)));
} }
[HttpPost] [HttpPost]
@ -41,7 +41,7 @@ namespace PluralKit.API.Controllers
return BadRequest("Member name must be specified."); return BadRequest("Member name must be specified.");
// Enforce per-system member limit // Enforce per-system member limit
var memberCount = await _data.GetSystemMemberCount(system); var memberCount = await _data.GetSystemMemberCount(system, true);
if (memberCount >= Limits.MaxMemberCount) if (memberCount >= Limits.MaxMemberCount)
return BadRequest($"Member limit reached ({memberCount} / {Limits.MaxMemberCount})."); return BadRequest($"Member limit reached ({memberCount} / {Limits.MaxMemberCount}).");
@ -56,7 +56,7 @@ namespace PluralKit.API.Controllers
} }
await _data.SaveMember(member); await _data.SaveMember(member);
return Ok(member.ToJson()); return Ok(member.ToJson(_auth.ContextFor(member)));
} }
[HttpPatch("{hid}")] [HttpPatch("{hid}")]
@ -78,7 +78,7 @@ namespace PluralKit.API.Controllers
} }
await _data.SaveMember(member); await _data.SaveMember(member);
return Ok(member.ToJson()); return Ok(member.ToJson(_auth.ContextFor(member)));
} }
[HttpDelete("{hid}")] [HttpDelete("{hid}")]

View File

@ -25,10 +25,12 @@ namespace PluralKit.API.Controllers
public class MessageController: ControllerBase public class MessageController: ControllerBase
{ {
private IDataStore _data; private IDataStore _data;
private TokenAuthService _auth;
public MessageController(IDataStore _data) public MessageController(IDataStore _data, TokenAuthService auth)
{ {
this._data = _data; this._data = _data;
_auth = auth;
} }
[HttpGet("{mid}")] [HttpGet("{mid}")]
@ -43,8 +45,8 @@ namespace PluralKit.API.Controllers
Id = msg.Message.Mid.ToString(), Id = msg.Message.Mid.ToString(),
Channel = msg.Message.Channel.ToString(), Channel = msg.Message.Channel.ToString(),
Sender = msg.Message.Sender.ToString(), Sender = msg.Message.Sender.ToString(),
Member = msg.Member.ToJson(), Member = msg.Member.ToJson(_auth.ContextFor(msg.System)),
System = msg.System.ToJson(), System = msg.System.ToJson(_auth.ContextFor(msg.System)),
Original = msg.Message.OriginalMid?.ToString() Original = msg.Message.OriginalMid?.ToString()
}; };
} }

View File

@ -2,6 +2,8 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dapper; using Dapper;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@ -48,7 +50,7 @@ namespace PluralKit.API.Controllers
[RequiresSystem] [RequiresSystem]
public Task<ActionResult<JObject>> GetOwnSystem() 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}")] [HttpGet("{hid}")]
@ -56,7 +58,7 @@ namespace PluralKit.API.Controllers
{ {
var system = await _data.GetSystemByHid(hid); var system = await _data.GetSystemByHid(hid);
if (system == null) return NotFound("System not found."); if (system == null) return NotFound("System not found.");
return Ok(system.ToJson()); return Ok(system.ToJson(_auth.ContextFor(system)));
} }
[HttpGet("{hid}/members")] [HttpGet("{hid}/members")]
@ -65,8 +67,13 @@ namespace PluralKit.API.Controllers
var system = await _data.GetSystemByHid(hid); var system = await _data.GetSystemByHid(hid);
if (system == null) return NotFound("System not found."); 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); 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")] [HttpGet("{hid}/switches")]
@ -76,6 +83,9 @@ namespace PluralKit.API.Controllers
var system = await _data.GetSystemByHid(hid); var system = await _data.GetSystemByHid(hid);
if (system == null) return NotFound("System not found."); 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()) using (var conn = await _conn.Obtain())
{ {
@ -97,6 +107,9 @@ namespace PluralKit.API.Controllers
var system = await _data.GetSystemByHid(hid); var system = await _data.GetSystemByHid(hid);
if (system == null) return NotFound("System not found."); 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); var sw = await _data.GetLatestSwitch(system);
if (sw == null) return NotFound("System has no registered switches."); if (sw == null) return NotFound("System has no registered switches.");
@ -104,7 +117,7 @@ namespace PluralKit.API.Controllers
return Ok(new FrontersReturn return Ok(new FrontersReturn
{ {
Timestamp = sw.Timestamp, 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); await _data.SaveSystem(system);
return Ok(system.ToJson()); return Ok(system.ToJson(_auth.ContextFor(system)));
} }
[HttpPost("switches")] [HttpPost("switches")]

View File

@ -26,6 +26,7 @@ namespace PluralKit.API
services services
.AddTransient<IDataStore, PostgresDataStore>() .AddTransient<IDataStore, PostgresDataStore>()
.AddSingleton<SchemaService>()
.AddSingleton(svc => InitUtils.InitMetrics(svc.GetRequiredService<CoreConfig>(), "API")) .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. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{ {
SchemaService.Initialize();
if (env.IsDevelopment()) if (env.IsDevelopment())
{ {
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();

View File

@ -26,5 +26,11 @@ namespace PluralKit.API
await next.Invoke(context); await next.Invoke(context);
CurrentSystem = null; 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()) using (var services = BuildServiceProvider())
{ {
SchemaService.Initialize();
var logger = services.GetRequiredService<ILogger>().ForContext<Initialize>(); var logger = services.GetRequiredService<ILogger>().ForContext<Initialize>();
var coreConfig = services.GetRequiredService<CoreConfig>(); var coreConfig = services.GetRequiredService<CoreConfig>();
var botConfig = services.GetRequiredService<BotConfig>(); var botConfig = services.GetRequiredService<BotConfig>();

View File

@ -57,13 +57,13 @@ namespace PluralKit.Bot.CommandSystem
/// <summary> /// <summary>
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive. /// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
/// </summary> /// </summary>
public bool Match(params string[] potentialMatches) public bool Match(ref string used, params string[] potentialMatches)
{ {
foreach (var match in potentialMatches) foreach (var match in potentialMatches)
{ {
if (PeekArgument().Equals(match, StringComparison.InvariantCultureIgnoreCase)) if (PeekArgument().Equals(match, StringComparison.InvariantCultureIgnoreCase))
{ {
PopArgument(); used = PopArgument();
return true; return true;
} }
} }
@ -71,6 +71,15 @@ namespace PluralKit.Bot.CommandSystem
return false; 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) public async Task Execute<T>(Command commandDef, Func<T, Task> handler)
{ {
_currentCommand = commandDef; _currentCommand = commandDef;
@ -237,6 +246,15 @@ namespace PluralKit.Bot.CommandSystem
throw new PKError("This command can not be run in a DM."); 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() public ITextChannel MatchChannel()
{ {
if (!MentionUtils.TryParseChannel(PeekArgument(), out var channel)) return null; 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 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 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 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 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 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"); 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 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 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 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 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 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"); 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 = { public static Command[] SystemCommands = {
SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemDelete, SystemTimezone, SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemDelete, SystemTimezone,
SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent, SystemPrivacy
}; };
public static Command[] MemberCommands = { public static Command[] MemberCommands = {
@ -185,6 +187,8 @@ namespace PluralKit.Bot.Commands
await ctx.Execute<SystemCommands>(SystemFrontHistory, m => m.SystemFrontHistory(ctx, ctx.System)); await ctx.Execute<SystemCommands>(SystemFrontHistory, m => m.SystemFrontHistory(ctx, ctx.System));
else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown"))
await ctx.Execute<SystemCommands>(SystemFrontPercent, m => m.SystemFrontPercent(ctx, ctx.System)); 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")) else if (ctx.Match("commands", "help"))
await PrintCommandList(ctx, "systems", SystemCommands); await PrintCommandList(ctx, "systems", SystemCommands);
else if (!ctx.HasNext()) // Bare command else if (!ctx.HasNext()) // Bare command
@ -272,6 +276,8 @@ namespace PluralKit.Bot.Commands
await ctx.Execute<MemberCommands>(MemberServerName, m => m.MemberServerName(ctx, target)); await ctx.Execute<MemberCommands>(MemberServerName, m => m.MemberServerName(ctx, target));
else if (ctx.Match("keepproxy", "keeptags", "showtags")) else if (ctx.Match("keepproxy", "keeptags", "showtags"))
await ctx.Execute<MemberCommands>(MemberKeepProxy, m => m.MemberKeepProxy(ctx, target)); 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 else if (!ctx.HasNext()) // Bare command
await ctx.Execute<MemberCommands>(MemberInfo, m => m.ViewMember(ctx, target)); await ctx.Execute<MemberCommands>(MemberInfo, m => m.ViewMember(ctx, target));
else else

View File

@ -38,7 +38,7 @@ namespace PluralKit.Bot.Commands
} }
// Enforce per-system member limit // Enforce per-system member limit
var memberCount = await _data.GetSystemMemberCount(ctx.System); var memberCount = await _data.GetSystemMemberCount(ctx.System, true);
if (memberCount >= Limits.MaxMemberCount) if (memberCount >= Limits.MaxMemberCount)
throw Errors.MemberLimitReachedError; throw Errors.MemberLimitReachedError;
@ -69,7 +69,7 @@ namespace PluralKit.Bot.Commands
if (members == null || !members.Any()) if (members == null || !members.Any())
throw Errors.NoMembersError; throw Errors.NoMembersError;
var randInt = randGen.Next(members.Count); 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 ctx.Reply($"{Emojis.Success} Member proxy tags will now not be included in the resulting message when proxying.");
await _proxyCache.InvalidateResultsForSystem(ctx.System); 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) public async Task ViewMember(Context ctx, PKMember target)
{ {
var system = await _data.GetSystemById(target.System); 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) { public async Task Query(Context ctx, PKSystem system) {
if (system == null) throw Errors.NoSystemError; 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) public async Task New(Context ctx)
@ -149,27 +149,57 @@ namespace PluralKit.Bot.Commands
public async Task MemberShortList(Context ctx, PKSystem system) { public async Task MemberShortList(Context ctx, PKSystem system) {
if (system == null) throw Errors.NoSystemError; 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}`"; 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, 25,
embedTitle, embedTitle,
(eb, ms) => eb.Description = string.Join("\n", ms.Select((m) => { (eb, ms) =>
if (m.HasProxyTags) return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}** *({m.ProxyTagsString().SanitizeMentions()})*"; {
return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}**"; 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) { public async Task MemberLongList(Context ctx, PKSystem system) {
if (system == null) throw Errors.NoSystemError; 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}`"; 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, 5,
embedTitle, embedTitle,
(eb, ms) => { (eb, ms) => {
@ -179,8 +209,16 @@ namespace PluralKit.Bot.Commands
if (m.Birthday != null) profile += $"\n**Birthdate**: {m.BirthdayString}"; if (m.Birthday != null) profile += $"\n**Birthdate**: {m.BirthdayString}";
if (m.ProxyTags.Count > 0) profile += $"\n**Proxy tags:** {m.ProxyTagsString()}"; if (m.ProxyTags.Count > 0) profile += $"\n**Proxy tags:** {m.ProxyTagsString()}";
if (m.Description != null) profile += $"\n\n{m.Description}"; 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)); 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) public async Task SystemFronter(Context ctx, PKSystem system)
{ {
if (system == null) throw Errors.NoSystemError; if (system == null) throw Errors.NoSystemError;
ctx.CheckSystemPrivacy(system, system.FrontPrivacy);
var sw = await _data.GetLatestSwitch(system); var sw = await _data.GetLatestSwitch(system);
if (sw == null) throw Errors.NoRegisteredSwitches; if (sw == null) throw Errors.NoRegisteredSwitches;
@ -198,6 +237,7 @@ namespace PluralKit.Bot.Commands
public async Task SystemFrontHistory(Context ctx, PKSystem system) public async Task SystemFrontHistory(Context ctx, PKSystem system)
{ {
if (system == null) throw Errors.NoSystemError; if (system == null) throw Errors.NoSystemError;
ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy);
var sws = (await _data.GetSwitches(system, 10)).ToList(); var sws = (await _data.GetSwitches(system, 10)).ToList();
if (sws.Count == 0) throw Errors.NoRegisteredSwitches; if (sws.Count == 0) throw Errors.NoRegisteredSwitches;
@ -208,6 +248,7 @@ namespace PluralKit.Bot.Commands
public async Task SystemFrontPercent(Context ctx, PKSystem system) public async Task SystemFrontPercent(Context ctx, PKSystem system)
{ {
if (system == null) throw Errors.NoSystemError; if (system == null) throw Errors.NoSystemError;
ctx.CheckSystemPrivacy(system, system.FrontHistoryPrivacy);
string durationStr = ctx.RemainderOrNull() ?? "30d"; string durationStr = ctx.RemainderOrNull() ?? "30d";
@ -267,6 +308,80 @@ namespace PluralKit.Bot.Commands
await ctx.Reply($"System time zone changed to {zone.Id}."); 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) { 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. // First, if we're given a flag emoji, we extract the flag emoji code from it.
zoneStr = PluralKit.Utils.ExtractCountryFlag(zoneStr) ?? zoneStr; zoneStr = PluralKit.Utils.ExtractCountryFlag(zoneStr) ?? zoneStr;

View File

@ -20,13 +20,13 @@ namespace PluralKit.Bot {
_data = data; _data = data;
} }
public async Task<Embed> CreateSystemEmbed(PKSystem system) { public async Task<Embed> CreateSystemEmbed(PKSystem system, LookupContext ctx) {
var accounts = await _data.GetSystemAccounts(system); var accounts = await _data.GetSystemAccounts(system);
// Fetch/render info for all accounts simultaneously // 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 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() var eb = new EmbedBuilder()
.WithColor(Color.Blue) .WithColor(Color.Blue)
.WithTitle(system.Name ?? null) .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))}"); .WithFooter($"System ID: {system.Hid} | Created on {Formats.ZonedDateTimeFormat.Format(system.Created.InZone(system.Zone))}");
var latestSwitch = await _data.GetLatestSwitch(system); var latestSwitch = await _data.GetLatestSwitch(system);
if (latestSwitch != null) if (latestSwitch != null && system.FrontPrivacy.CanAccess(ctx))
{ {
var switchMembers = (await _data.GetSwitchMembers(latestSwitch)).ToList(); var switchMembers = (await _data.GetSwitchMembers(latestSwitch)).ToList();
if (switchMembers.Count > 0) if (switchMembers.Count > 0)
@ -44,9 +44,12 @@ namespace PluralKit.Bot {
if (system.Tag != null) eb.AddField("Tag", system.Tag); if (system.Tag != null) eb.AddField("Tag", system.Tag);
eb.AddField("Linked accounts", string.Join(", ", users).Truncate(1000), true); 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(); return eb.Build();
} }
@ -62,7 +65,7 @@ namespace PluralKit.Bot {
.Build(); .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; var name = member.Name;
if (system.Name != null) name = $"{member.Name} ({system.Name})"; if (system.Name != null) name = $"{member.Name} ({system.Name})";
@ -91,19 +94,21 @@ namespace PluralKit.Bot {
var eb = new EmbedBuilder() var eb = new EmbedBuilder()
// TODO: add URL of website when that's up // TODO: add URL of website when that's up
.WithAuthor(name, member.AvatarUrl) .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))}"); .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.AvatarUrl != null) eb.WithThumbnailUrl(member.AvatarUrl);
if (member.DisplayName != null) eb.AddField("Display Name", member.DisplayName.Truncate(1024), true); 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 (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.Birthday != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Birthdate", member.BirthdayString, true);
if (member.Pronouns != null) eb.AddField("Pronouns", member.Pronouns.Truncate(1024), true); if (member.Pronouns != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Pronouns", member.Pronouns.Truncate(1024), true);
if (messageCount > 0) eb.AddField("Message Count", messageCount, 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.HasProxyTags) eb.AddField("Proxy Tags", string.Join('\n', proxyTagsStr).Truncate(1024), true);
if (member.Color != null) eb.AddField("Color", $"#{member.Color}", true); if (member.Color != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true);
if (member.Description != null) eb.AddField("Description", member.Description, false); if (member.Description != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Description", member.Description, false);
return eb.Build(); 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

@ -17,6 +17,25 @@ namespace PluralKit
{ {
public PKParseError(string message): base(message) { } 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 struct ProxyTag
{ {
@ -58,14 +77,19 @@ namespace PluralKit
[JsonIgnore] public string Token { get; set; } [JsonIgnore] public string Token { get; set; }
[JsonProperty("created")] public Instant Created { get; set; } [JsonProperty("created")] public Instant Created { get; set; }
[JsonProperty("tz")] public string UiTz { 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); [JsonIgnore] public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz);
public JObject ToJson() public JObject ToJson(LookupContext ctx)
{ {
var o = new JObject(); var o = new JObject();
o.Add("id", Hid); o.Add("id", Hid);
o.Add("name", Name); o.Add("name", Name);
o.Add("description", Description); o.Add("description", DescriptionPrivacy.CanAccess(ctx) ? Description : null);
o.Add("tag", Tag); o.Add("tag", Tag);
o.Add("avatar_url", AvatarUrl); o.Add("avatar_url", AvatarUrl);
o.Add("created", Formats.TimestampExportFormat.Format(Created)); o.Add("created", Formats.TimestampExportFormat.Format(Created));
@ -100,6 +124,8 @@ namespace PluralKit
[JsonProperty("keep_proxy")] public bool KeepProxy { get; set; } [JsonProperty("keep_proxy")] public bool KeepProxy { get; set; }
[JsonProperty("created")] public Instant Created { 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 /// Returns a formatted string representing the member's birthday, taking into account that a year of "0001" is hidden
[JsonIgnore] public string BirthdayString [JsonIgnore] public string BirthdayString
{ {
@ -120,17 +146,17 @@ namespace PluralKit
return $"{guildDisplayName ?? DisplayName ?? Name} {systemTag}"; return $"{guildDisplayName ?? DisplayName ?? Name} {systemTag}";
} }
public JObject ToJson() public JObject ToJson(LookupContext ctx)
{ {
var o = new JObject(); var o = new JObject();
o.Add("id", Hid); o.Add("id", Hid);
o.Add("name", Name); o.Add("name", Name);
o.Add("color", Color); o.Add("color", MemberPrivacy.CanAccess(ctx) ? Color : null);
o.Add("display_name", DisplayName); o.Add("display_name", DisplayName);
o.Add("birthday", Birthday.HasValue ? Formats.DateExportFormat.Format(Birthday.Value) : null); o.Add("birthday", MemberPrivacy.CanAccess(ctx) && Birthday.HasValue ? Formats.DateExportFormat.Format(Birthday.Value) : null);
o.Add("pronouns", Pronouns); o.Add("pronouns", MemberPrivacy.CanAccess(ctx) ? Pronouns : null);
o.Add("avatar_url", AvatarUrl); o.Add("avatar_url", AvatarUrl);
o.Add("description", Description); o.Add("description", MemberPrivacy.CanAccess(ctx) ? Description : null);
var tagArray = new JArray(); var tagArray = new JArray();
foreach (var tag in ProxyTags) foreach (var tag in ProxyTags)

View File

@ -1,5 +1,4 @@
using System; using System;
using System.Data;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dapper; using Dapper;
@ -11,7 +10,7 @@ using Serilog;
namespace PluralKit { namespace PluralKit {
public class SchemaService public class SchemaService
{ {
private const int TargetSchemaVersion = 1; private const int TargetSchemaVersion = 2;
private DbConnectionFactory _conn; private DbConnectionFactory _conn;
private ILogger _logger; private ILogger _logger;
@ -22,6 +21,13 @@ namespace PluralKit {
_logger = logger.ForContext<SchemaService>(); _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() public async Task ApplyMigrations()
{ {
for (var version = 0; version <= TargetSchemaVersion; version++) for (var version = 0; version <= TargetSchemaVersion; version++)

View File

@ -116,7 +116,8 @@ namespace PluralKit {
/// <summary> /// <summary>
/// Gets the member count of a system. /// Gets the member count of a system.
/// </summary> /// </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> /// <summary>
/// Gets a list of members with proxy tags that conflict with the given tags. /// 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) { public async Task SaveSystem(PKSystem system) {
using (var conn = await _conn.Obtain()) 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); _logger.Information("Updated system {@System}", system);
} }
@ -590,7 +591,7 @@ namespace PluralKit {
public async Task SaveMember(PKMember member) { public async Task SaveMember(PKMember member) {
using (var conn = await _conn.Obtain()) 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); _logger.Information("Updated member {@Member}", member);
} }
@ -637,10 +638,13 @@ namespace PluralKit {
new { System = system.Id }); 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()) 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() 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 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`. 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 ## 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. 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> ### 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`. 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 #### Example request
GET https://api.pluralkit.me/v1/s/abcde 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 ### 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`. 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 #### Example request
GET https://api.pluralkit.me/v1/s/abcde/members 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 Optionally takes a `?before=` query parameter with an ISO-8601-formatted timestamp, and will only return switches
that happen before that timestamp. 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 #### Example request
GET https://api.pluralkit.me/v1/s/abcde/switches?before=2019-03-01T14:00:00Z 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 ### 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`. 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 #### Example request
GET https://api.pluralkit.me/v1/s/abcde/fronters 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> ### 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`. 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 #### Example request
GET https://api.pluralkit.me/v1/m/qwert 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> ### 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. 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 #### Example request
GET https://api.pluralkit.me/v1/a/466378653216014359 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). 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). 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 #### Example request
GET https://api.pluralkit.me/v1/msg/601014599386398700 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 ## 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 * 2019-12-28
* Changed behaviour of missing fields in PATCH responses, will now preserve the old value instead of clearing * 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. * This is technically a breaking change, but not *significantly* so, so I won't bump the version number.