Merge branch 'feat/apiv2' into main

This commit is contained in:
spiral 2021-10-30 18:18:08 -04:00
commit f44f83c809
43 changed files with 2456 additions and 310 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@ obj/
.vscode/
tags/
.DS_Store
mono_crash*
# Dependencies
node_modules/

View File

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NodaTime;
using PluralKit.Core;
namespace PluralKit.API
{
public static class APIJsonExt
{
public static JArray ToJSON(this IEnumerable<PKShardInfo> shards)
{
var o = new JArray();
foreach (var shard in shards)
{
var s = new JObject();
s.Add("id", shard.Id);
if (shard.Status == PKShardInfo.ShardStatus.Down)
s.Add("status", "down");
else
s.Add("status", "up");
s.Add("ping", shard.Ping);
s.Add("last_heartbeat", shard.LastHeartbeat.ToString());
s.Add("last_connection", shard.LastConnection.ToString());
o.Add(s);
}
return o;
}
public static JObject ToJson(this ModelRepository.Counts counts)
{
var o = new JObject();
o.Add("system_count", counts.SystemCount);
o.Add("member_count", counts.MemberCount);
o.Add("group_count", counts.GroupCount);
o.Add("switch_count", counts.SwitchCount);
o.Add("message_count", counts.MessageCount);
return o;
}
}
public struct FrontersReturnNew
{
[JsonProperty("id")] public Guid Uuid { get; set; }
[JsonProperty("timestamp")] public Instant Timestamp { get; set; }
[JsonProperty("members")] public IEnumerable<JObject> Members { get; set; }
}
public struct SwitchesReturnNew
{
[JsonProperty("id")] public Guid Uuid { get; set; }
[JsonProperty("timestamp")] public Instant Timestamp { get; set; }
[JsonProperty("members")] public IEnumerable<string> Members { get; set; }
}
}

View File

@ -0,0 +1,98 @@
using System;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using PluralKit.Core;
namespace PluralKit.API
{
public class PKControllerBase: ControllerBase
{
private readonly Guid _requestId = Guid.NewGuid();
private readonly Regex _shortIdRegex = new Regex("^[a-z]{5}$");
private readonly Regex _snowflakeRegex = new Regex("^[0-9]{17,19}$");
protected readonly ApiConfig _config;
protected readonly IDatabase _db;
protected readonly ModelRepository _repo;
public PKControllerBase(IServiceProvider svc)
{
_config = svc.GetRequiredService<ApiConfig>();
_db = svc.GetRequiredService<IDatabase>();
_repo = svc.GetRequiredService<ModelRepository>();
}
protected Task<PKSystem?> ResolveSystem(string systemRef)
{
if (systemRef == "@me")
{
HttpContext.Items.TryGetValue("SystemId", out var systemId);
if (systemId == null)
throw Errors.GenericAuthError;
return _repo.GetSystem((SystemId)systemId);
}
if (Guid.TryParse(systemRef, out var guid))
return _repo.GetSystemByGuid(guid);
if (_snowflakeRegex.IsMatch(systemRef))
return _repo.GetSystemByAccount(ulong.Parse(systemRef));
if (_shortIdRegex.IsMatch(systemRef))
return _repo.GetSystemByHid(systemRef);
return Task.FromResult<PKSystem?>(null);
}
protected Task<PKMember?> ResolveMember(string memberRef)
{
if (Guid.TryParse(memberRef, out var guid))
return _repo.GetMemberByGuid(guid);
if (_shortIdRegex.IsMatch(memberRef))
return _repo.GetMemberByHid(memberRef);
return Task.FromResult<PKMember?>(null);
}
protected Task<PKGroup?> ResolveGroup(string groupRef)
{
if (Guid.TryParse(groupRef, out var guid))
return _repo.GetGroupByGuid(guid);
if (_shortIdRegex.IsMatch(groupRef))
return _repo.GetGroupByHid(groupRef);
return Task.FromResult<PKGroup?>(null);
}
protected LookupContext ContextFor(PKSystem system)
{
HttpContext.Items.TryGetValue("SystemId", out var systemId);
if (systemId == null) return LookupContext.ByNonOwner;
return ((SystemId)systemId) == system.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner;
}
protected LookupContext ContextFor(PKMember member)
{
HttpContext.Items.TryGetValue("SystemId", out var systemId);
if (systemId == null) return LookupContext.ByNonOwner;
return ((SystemId)systemId) == member.System ? LookupContext.ByOwner : LookupContext.ByNonOwner;
}
protected LookupContext ContextFor(PKGroup group)
{
HttpContext.Items.TryGetValue("SystemId", out var systemId);
if (systemId == null) return LookupContext.ByNonOwner;
return ((SystemId)systemId) == group.System ? LookupContext.ByOwner : LookupContext.ByNonOwner;
}
}
}

View File

@ -58,21 +58,21 @@ namespace PluralKit.API
await using var tx = await conn.BeginTransactionAsync();
var member = await _repo.CreateMember(systemId, properties.Value<string>("name"), conn);
MemberPatch patch;
try
{
patch = MemberPatch.FromJSON(properties);
patch.AssertIsValid();
}
catch (FieldTooLongError e)
var patch = MemberPatch.FromJSON(properties);
patch.AssertIsValid();
if (patch.Errors.Count > 0)
{
await tx.RollbackAsync();
return BadRequest(e.Message);
}
catch (ValidationError e)
{
await tx.RollbackAsync();
return BadRequest($"Request field '{e.Message}' is invalid.");
var err = patch.Errors[0];
if (err is FieldTooLongError)
return BadRequest($"Field {err.Key} is too long "
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
else if (err.Text != null)
return BadRequest(err.Text);
else
return BadRequest($"Field {err.Key} is invalid.");
}
member = await _repo.UpdateMember(member.Id, patch, conn);
@ -90,19 +90,19 @@ namespace PluralKit.API
var res = await _auth.AuthorizeAsync(User, member, "EditMember");
if (!res.Succeeded) return Unauthorized($"Member '{hid}' is not part of your system.");
MemberPatch patch;
try
var patch = MemberPatch.FromJSON(changes);
patch.AssertIsValid();
if (patch.Errors.Count > 0)
{
patch = MemberPatch.FromJSON(changes);
patch.AssertIsValid();
}
catch (FieldTooLongError e)
{
return BadRequest(e.Message);
}
catch (ValidationError e)
{
return BadRequest($"Request field '{e.Message}' is invalid.");
var err = patch.Errors[0];
if (err is FieldTooLongError)
return BadRequest($"Field {err.Key} is too long "
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
else if (err.Text != null)
return BadRequest(err.Text);
else
return BadRequest($"Field {err.Key} is invalid.");
}
var newMember = await _repo.UpdateMember(member.Id, patch);

View File

@ -28,7 +28,7 @@ namespace PluralKit.API
public async Task<ActionResult<JObject>> GetMeta()
{
await using var conn = await _db.Obtain();
var shards = await _repo.GetShards(conn);
var shards = await _repo.GetShards();
var o = new JObject();
o.Add("shards", shards.ToJSON());
@ -37,32 +37,4 @@ namespace PluralKit.API
return Ok(o);
}
}
public static class MetaJsonExt
{
public static JArray ToJSON(this IEnumerable<PKShardInfo> shards)
{
var o = new JArray();
foreach (var shard in shards)
{
var s = new JObject();
s.Add("id", shard.Id);
if (shard.Status == PKShardInfo.ShardStatus.Down)
s.Add("status", "down");
else
s.Add("status", "up");
s.Add("ping", shard.Ping);
s.Add("last_heartbeat", shard.LastHeartbeat.ToString());
s.Add("last_connection", shard.LastConnection.ToString());
o.Add(s);
}
return o;
}
}
}

View File

@ -32,6 +32,7 @@ namespace PluralKit.API
public struct PostSwitchParams
{
public Instant? Timestamp { get; set; }
public ICollection<string> Members { get; set; }
}
@ -132,19 +133,17 @@ namespace PluralKit.API
{
var system = await _repo.GetSystem(User.CurrentSystem());
SystemPatch patch;
try
var patch = SystemPatch.FromJSON(changes);
patch.AssertIsValid();
if (patch.Errors.Count > 0)
{
patch = SystemPatch.FromJSON(changes);
patch.AssertIsValid();
}
catch (FieldTooLongError e)
{
return BadRequest(e.Message);
}
catch (ValidationError e)
{
return BadRequest($"Request field '{e.Message}' is invalid.");
var err = patch.Errors[0];
if (err is FieldTooLongError)
return BadRequest($"Field {err.Key} is too long "
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
return BadRequest($"Field {err.Key} is invalid.");
}
system = await _repo.UpdateSystem(system!.Id, patch);

View File

@ -0,0 +1,147 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using NodaTime;
using PluralKit.Core;
namespace PluralKit.API
{
[ApiController]
[ApiVersion("2.0")]
[Route("v{version:apiVersion}")]
public class GuildControllerV2: PKControllerBase
{
public GuildControllerV2(IServiceProvider svc) : base(svc) { }
[HttpGet("systems/@me/guilds/{guild_id}")]
public async Task<IActionResult> SystemGuildGet(ulong guild_id)
{
var system = await ResolveSystem("@me");
var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false);
if (settings == null)
throw Errors.SystemGuildNotFound;
PKMember member = null;
if (settings.AutoproxyMember != null)
member = await _repo.GetMember(settings.AutoproxyMember.Value);
return Ok(settings.ToJson(member?.Hid));
}
[HttpPatch("systems/@me/guilds/{guild_id}")]
public async Task<IActionResult> DoSystemGuildPatch(ulong guild_id, [FromBody] JObject data)
{
var system = await ResolveSystem("@me");
var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false);
if (settings == null)
throw Errors.SystemGuildNotFound;
MemberId? memberId = null;
if (data.ContainsKey("autoproxy_member"))
{
if (data["autoproxy_member"].Type != JTokenType.Null)
{
var member = await ResolveMember(data.Value<string>("autoproxy_member"));
if (member == null)
throw Errors.MemberNotFound;
memberId = member.Id;
}
}
else
memberId = settings.AutoproxyMember;
var patch = SystemGuildPatch.FromJson(data, memberId);
patch.AssertIsValid();
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);
// this is less than great, but at least it's legible
if (patch.AutoproxyMember.Value == null)
if (patch.AutoproxyMode.IsPresent)
{
if (patch.AutoproxyMode.Value == AutoproxyMode.Member)
throw Errors.MissingAutoproxyMember;
}
else if (settings.AutoproxyMode == AutoproxyMode.Member)
throw Errors.MissingAutoproxyMember;
var newSettings = await _repo.UpdateSystemGuild(system.Id, guild_id, patch);
PKMember? newMember = null;
if (newSettings.AutoproxyMember != null)
newMember = await _repo.GetMember(newSettings.AutoproxyMember.Value);
return Ok(newSettings.ToJson(newMember?.Hid));
}
[HttpGet("members/{memberRef}/guilds/{guild_id}")]
public async Task<IActionResult> MemberGuildGet(string memberRef, ulong guild_id)
{
var system = await ResolveSystem("@me");
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFound;
if (member.System != system.Id)
throw Errors.NotOwnMemberError;
var settings = await _repo.GetMemberGuild(guild_id, member.Id, defaultInsert: false);
if (settings == null)
throw Errors.MemberGuildNotFound;
return Ok(settings.ToJson());
}
[HttpPatch("members/{memberRef}/guilds/{guild_id}")]
public async Task<IActionResult> DoMemberGuildPatch(string memberRef, ulong guild_id, [FromBody] JObject data)
{
var system = await ResolveSystem("@me");
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFound;
if (member.System != system.Id)
throw Errors.NotOwnMemberError;
var settings = await _repo.GetMemberGuild(guild_id, member.Id, defaultInsert: false);
if (settings == null)
throw Errors.MemberGuildNotFound;
var patch = MemberGuildPatch.FromJson(data);
patch.AssertIsValid();
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);
var newSettings = await _repo.UpdateMemberGuild(member.Id, guild_id, patch);
return Ok(newSettings.ToJson());
}
[HttpGet("messages/{messageId}")]
public async Task<ActionResult<MessageReturn>> MessageGet(ulong messageId)
{
var msg = await _db.Execute(c => _repo.GetMessage(c, messageId));
if (msg == null)
throw Errors.MessageNotFound;
var ctx = this.ContextFor(msg.System);
// todo: don't rely on v1 stuff
return new MessageReturn
{
Timestamp = Instant.FromUnixTimeMilliseconds((long)(msg.Message.Mid >> 22) + 1420070400000),
Id = msg.Message.Mid.ToString(),
Channel = msg.Message.Channel.ToString(),
Sender = msg.Message.Sender.ToString(),
System = msg.System.ToJson(ctx, v: APIVersion.V2),
Member = msg.Member.ToJson(ctx, v: APIVersion.V2),
Original = msg.Message.OriginalMid?.ToString()
};
}
}
}

View File

@ -0,0 +1,110 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using PluralKit.Core;
namespace PluralKit.API
{
[ApiController]
[ApiVersion("2.0")]
[Route("v{version:apiVersion}")]
public class GroupControllerV2: PKControllerBase
{
public GroupControllerV2(IServiceProvider svc) : base(svc) { }
[HttpGet("systems/{systemRef}/groups")]
public async Task<IActionResult> GetSystemGroups(string systemRef)
{
var system = await ResolveSystem(systemRef);
if (system == null)
throw Errors.SystemNotFound;
var ctx = this.ContextFor(system);
if (!system.GroupListPrivacy.CanAccess(User.ContextFor(system)))
throw Errors.UnauthorizedGroupList;
var groups = _repo.GetSystemGroups(system.Id);
return Ok(await groups
.Where(g => g.Visibility.CanAccess(ctx))
.Select(g => g.ToJson(ctx))
.ToListAsync());
}
[HttpPost("groups")]
public async Task<IActionResult> GroupCreate([FromBody] JObject data)
{
var system = await ResolveSystem("@me");
var patch = GroupPatch.FromJson(data);
patch.AssertIsValid();
if (!patch.Name.IsPresent)
patch.Errors.Add(new ValidationError("name", $"Key 'name' is required when creating new group."));
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);
using var conn = await _db.Obtain();
using var tx = await conn.BeginTransactionAsync();
var newGroup = await _repo.CreateGroup(system.Id, patch.Name.Value, conn);
newGroup = await _repo.UpdateGroup(newGroup.Id, patch, conn);
await tx.CommitAsync();
return Ok(newGroup.ToJson(LookupContext.ByOwner));
}
[HttpGet("groups/{groupRef}")]
public async Task<IActionResult> GroupGet(string groupRef)
{
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFound;
var system = await _repo.GetSystem(group.System);
return Ok(group.ToJson(this.ContextFor(group), systemStr: system.Hid));
}
[HttpPatch("groups/{groupRef}")]
public async Task<IActionResult> DoGroupPatch(string groupRef, [FromBody] JObject data)
{
var system = await ResolveSystem("@me");
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFound;
if (group.System != system.Id)
throw Errors.NotOwnGroupError;
var patch = GroupPatch.FromJson(data);
patch.AssertIsValid();
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);
var newGroup = await _repo.UpdateGroup(group.Id, patch);
return Ok(newGroup.ToJson(LookupContext.ByOwner));
}
[HttpDelete("groups/{groupRef}")]
public async Task<IActionResult> GroupDelete(string groupRef)
{
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFound;
var system = await ResolveSystem("@me");
if (system.Id != group.System)
throw Errors.NotOwnGroupError;
await _repo.DeleteGroup(group.Id);
return NoContent();
}
}
}

View File

@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using PluralKit.Core;
namespace PluralKit.API
{
[ApiController]
[ApiVersion("2.0")]
[Route("v{version:apiVersion}")]
public class GroupMemberControllerV2: PKControllerBase
{
public GroupMemberControllerV2(IServiceProvider svc) : base(svc) { }
[HttpGet("groups/{groupRef}/members")]
public async Task<IActionResult> GetGroupMembers(string groupRef)
{
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFound;
var ctx = this.ContextFor(group);
if (!group.ListPrivacy.CanAccess(ctx))
throw Errors.UnauthorizedGroupMemberList;
var members = _repo.GetGroupMembers(group.Id).Where(m => m.MemberVisibility.CanAccess(ctx));
var o = new JArray();
await foreach (var member in members)
o.Add(member.ToJson(ctx, v: APIVersion.V2));
return Ok(o);
}
[HttpPost("groups/{groupRef}/members/add")]
public async Task<IActionResult> AddGroupMembers(string groupRef, [FromBody] JArray memberRefs)
{
if (memberRefs.Count == 0)
throw Errors.GenericBadRequest;
var system = await ResolveSystem("@me");
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFound;
if (group.System != system.Id)
throw Errors.NotOwnGroupError;
var members = new List<MemberId>();
foreach (var JmemberRef in memberRefs)
{
var memberRef = JmemberRef.Value<string>();
var member = await ResolveMember(memberRef);
// todo: have a list of these errors instead of immediately throwing
if (member == null)
throw Errors.MemberNotFoundWithRef(memberRef);
if (member.System != system.Id)
throw Errors.NotOwnMemberErrorWithRef(memberRef);
members.Add(member.Id);
}
var existingMembers = await _repo.GetGroupMembers(group.Id).Select(x => x.Id).ToListAsync();
members = members.Where(x => !existingMembers.Contains(x)).ToList();
if (members.Count > 0)
await _repo.AddMembersToGroup(group.Id, members);
return NoContent();
}
[HttpPost("groups/{groupRef}/members/remove")]
public async Task<IActionResult> RemoveGroupMembers(string groupRef, [FromBody] JArray memberRefs)
{
if (memberRefs.Count == 0)
throw Errors.GenericBadRequest;
var system = await ResolveSystem("@me");
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFound;
if (group.System != system.Id)
throw Errors.NotOwnGroupError;
var members = new List<MemberId>();
foreach (var JmemberRef in memberRefs)
{
var memberRef = JmemberRef.Value<string>();
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFoundWithRef(memberRef);
if (member.System != system.Id)
throw Errors.NotOwnMemberErrorWithRef(memberRef);
members.Add(member.Id);
}
await _repo.RemoveMembersFromGroup(group.Id, members);
return NoContent();
}
[HttpPost("groups/{groupRef}/members/overwrite")]
public async Task<IActionResult> OverwriteGroupMembers(string groupRef, [FromBody] JArray memberRefs)
{
var system = await ResolveSystem("@me");
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFound;
if (group.System != system.Id)
throw Errors.NotOwnGroupError;
var members = new List<MemberId>();
foreach (var JmemberRef in memberRefs)
{
var memberRef = JmemberRef.Value<string>();
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFoundWithRef(memberRef);
if (member.System != system.Id)
throw Errors.NotOwnMemberErrorWithRef(memberRef);
members.Add(member.Id);
}
await _repo.ClearGroupMembers(group.Id);
if (members.Count > 0)
await _repo.AddMembersToGroup(group.Id, members);
return NoContent();
}
[HttpGet("members/{memberRef}/groups")]
public async Task<IActionResult> GetMemberGroups(string memberRef)
{
var member = await ResolveMember(memberRef);
var ctx = this.ContextFor(member);
var system = await _repo.GetSystem(member.System);
if (!system.GroupListPrivacy.CanAccess(ctx))
throw Errors.UnauthorizedGroupList;
var groups = _repo.GetMemberGroups(member.Id).Where(g => g.Visibility.CanAccess(ctx));
var o = new JArray();
await foreach (var group in groups)
o.Add(group.ToJson(ctx));
return Ok(o);
}
[HttpPost("members/{memberRef}/groups/add")]
public async Task<IActionResult> AddMemberGroups(string memberRef, [FromBody] JArray groupRefs)
{
if (groupRefs.Count == 0)
throw Errors.GenericBadRequest;
var system = await ResolveSystem("@me");
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFound;
if (member.System != system.Id)
throw Errors.NotOwnMemberError;
var groups = new List<GroupId>();
foreach (var JgroupRef in groupRefs)
{
var groupRef = JgroupRef.Value<string>();
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFound;
if (group.System != system.Id)
throw Errors.NotOwnGroupErrorWithRef(groupRef);
groups.Add(group.Id);
}
var existingGroups = await _repo.GetMemberGroups(member.Id).Select(x => x.Id).ToListAsync();
groups = groups.Where(x => !existingGroups.Contains(x)).ToList();
if (groups.Count > 0)
await _repo.AddGroupsToMember(member.Id, groups);
return NoContent();
}
[HttpPost("members/{memberRef}/groups/remove")]
public async Task<IActionResult> RemoveMemberGroups(string memberRef, [FromBody] JArray groupRefs)
{
if (groupRefs.Count == 0)
throw Errors.GenericBadRequest;
var system = await ResolveSystem("@me");
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFound;
if (member.System != system.Id)
throw Errors.NotOwnMemberError;
var groups = new List<GroupId>();
foreach (var JgroupRef in groupRefs)
{
var groupRef = JgroupRef.Value<string>();
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFoundWithRef(groupRef);
if (group.System != system.Id)
throw Errors.NotOwnGroupErrorWithRef(groupRef);
groups.Add(group.Id);
}
await _repo.RemoveGroupsFromMember(member.Id, groups);
return NoContent();
}
[HttpPost("members/{memberRef}/groups/overwrite")]
public async Task<IActionResult> OverwriteMemberGroups(string memberRef, [FromBody] JArray groupRefs)
{
var system = await ResolveSystem("@me");
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFound;
if (member.System != system.Id)
throw Errors.NotOwnMemberError;
var groups = new List<GroupId>();
foreach (var JgroupRef in groupRefs)
{
var groupRef = JgroupRef.Value<string>();
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFoundWithRef(groupRef);
if (group.System != system.Id)
throw Errors.NotOwnGroupErrorWithRef(groupRef);
groups.Add(group.Id);
}
await _repo.ClearMemberGroups(member.Id);
if (groups.Count > 0)
await _repo.AddGroupsToMember(member.Id, groups);
return NoContent();
}
}
}

View File

@ -0,0 +1,111 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using PluralKit.Core;
namespace PluralKit.API
{
[ApiController]
[ApiVersion("2.0")]
[Route("v{version:apiVersion}")]
public class MemberControllerV2: PKControllerBase
{
public MemberControllerV2(IServiceProvider svc) : base(svc) { }
[HttpGet("systems/{systemRef}/members")]
public async Task<IActionResult> GetSystemMembers(string systemRef)
{
var system = await ResolveSystem(systemRef);
if (system == null)
throw Errors.SystemNotFound;
var ctx = this.ContextFor(system);
if (!system.MemberListPrivacy.CanAccess(this.ContextFor(system)))
throw Errors.UnauthorizedMemberList;
var members = _repo.GetSystemMembers(system.Id);
return Ok(await members
.Where(m => m.MemberVisibility.CanAccess(ctx))
.Select(m => m.ToJson(ctx, v: APIVersion.V2))
.ToListAsync());
}
[HttpPost("members")]
public async Task<IActionResult> MemberCreate([FromBody] JObject data)
{
var patch = MemberPatch.FromJSON(data);
patch.AssertIsValid();
if (!patch.Name.IsPresent)
patch.Errors.Add(new ValidationError("name", $"Key 'name' is required when creating new member."));
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);
var system = await ResolveSystem("@me");
using var conn = await _db.Obtain();
using var tx = await conn.BeginTransactionAsync();
var newMember = await _repo.CreateMember(system.Id, patch.Name.Value, conn);
newMember = await _repo.UpdateMember(newMember.Id, patch, conn);
await tx.CommitAsync();
return Ok(newMember.ToJson(LookupContext.ByOwner, v: APIVersion.V2));
}
[HttpGet("members/{memberRef}")]
public async Task<IActionResult> MemberGet(string memberRef)
{
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFound;
var system = await _repo.GetSystem(member.System);
return Ok(member.ToJson(this.ContextFor(member), systemStr: system.Hid, v: APIVersion.V2));
}
[HttpPatch("members/{memberRef}")]
public async Task<IActionResult> DoMemberPatch(string memberRef, [FromBody] JObject data)
{
var system = await ResolveSystem("@me");
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFound;
if (member.System != system.Id)
throw Errors.NotOwnMemberError;
var patch = MemberPatch.FromJSON(data, APIVersion.V2);
patch.AssertIsValid();
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);
var newMember = await _repo.UpdateMember(member.Id, patch);
return Ok(newMember.ToJson(LookupContext.ByOwner, v: APIVersion.V2));
}
[HttpDelete("members/{memberRef}")]
public async Task<IActionResult> MemberDelete(string memberRef)
{
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFound;
var system = await ResolveSystem("@me");
if (system.Id != member.System)
throw Errors.NotOwnMemberError;
await _repo.DeleteMember(member.Id);
return NoContent();
}
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
namespace PluralKit.API
{
[ApiController]
[ApiVersion("2.0")]
[Route("v{version:apiVersion}")]
public class PrivateControllerV2: PKControllerBase
{
public PrivateControllerV2(IServiceProvider svc) : base(svc) { }
[HttpGet("meta")]
public async Task<ActionResult<JObject>> Meta()
{
var shards = await _repo.GetShards();
var stats = await _repo.GetStats();
var o = new JObject();
o.Add("shards", shards.ToJSON());
o.Add("stats", stats.ToJson());
return Ok(o);
}
}
}

View File

@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NodaTime;
using PluralKit.Core;
namespace PluralKit.API
{
[ApiController]
[ApiVersion("2.0")]
[Route("v{version:apiVersion}")]
public class SwitchControllerV2: PKControllerBase
{
public SwitchControllerV2(IServiceProvider svc) : base(svc) { }
[HttpGet("systems/{systemRef}/switches")]
public async Task<IActionResult> GetSystemSwitches(string systemRef, [FromQuery(Name = "before")] Instant? before, [FromQuery(Name = "limit")] int? limit)
{
var system = await ResolveSystem(systemRef);
if (system == null)
throw Errors.SystemNotFound;
var ctx = this.ContextFor(system);
if (!system.FrontHistoryPrivacy.CanAccess(ctx))
throw Errors.UnauthorizedFrontHistory;
if (before == null)
before = SystemClock.Instance.GetCurrentInstant();
if (limit == null || limit > 100)
limit = 100;
var res = await _db.Execute(conn => conn.QueryAsync<SwitchesReturnNew>(
@"select *, array(
select members.hid from switch_members, members
where switch_members.switch = switches.id and members.id = switch_members.member
) as members from switches
where switches.system = @System and switches.timestamp <= @Before
order by switches.timestamp desc
limit @Limit;", new { System = system.Id, Before = before, Limit = limit }));
return Ok(res);
}
[HttpGet("systems/{systemRef}/fronters")]
public async Task<IActionResult> GetSystemFronters(string systemRef)
{
var system = await ResolveSystem(systemRef);
if (system == null)
throw Errors.SystemNotFound;
var ctx = this.ContextFor(system);
if (!system.FrontPrivacy.CanAccess(ctx))
throw Errors.UnauthorizedCurrentFronters;
var sw = await _repo.GetLatestSwitch(system.Id);
if (sw == null)
return NoContent();
var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id));
return Ok(new FrontersReturnNew
{
Timestamp = sw.Timestamp,
Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync(),
Uuid = sw.Uuid,
});
}
[HttpPost("systems/@me/switches")]
public async Task<IActionResult> SwitchCreate([FromBody] PostSwitchParams data)
{
if (data.Members.Distinct().Count() != data.Members.Count)
throw Errors.DuplicateMembersInList;
var system = await ResolveSystem("@me");
if (data.Timestamp != null && await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(data.Timestamp.Value))
throw Errors.SameSwitchTimestampError;
var members = new List<PKMember>();
foreach (var memberRef in data.Members)
{
var member = await ResolveMember(memberRef);
if (member == null)
// todo: which member
throw Errors.MemberNotFound;
if (member.System != system.Id)
throw Errors.NotOwnMemberErrorWithRef(memberRef);
members.Add(member);
}
// We get the current switch, if it exists
var latestSwitch = await _repo.GetLatestSwitch(system.Id);
if (latestSwitch != null && (data.Timestamp == null || data.Timestamp > latestSwitch.Timestamp))
{
var latestSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, latestSwitch.Id));
// Bail if this switch is identical to the latest one
if (await latestSwitchMembers.Select(m => m.Hid).SequenceEqualAsync(members.Select(m => m.Hid).ToAsyncEnumerable()))
throw Errors.SameSwitchMembersError;
}
var newSwitch = await _db.Execute(conn => _repo.AddSwitch(conn, system.Id, members.Select(m => m.Id).ToList()));
if (data.Timestamp != null)
await _repo.MoveSwitch(newSwitch.Id, data.Timestamp.Value);
return Ok(new FrontersReturnNew
{
Uuid = newSwitch.Uuid,
Timestamp = data.Timestamp != null ? data.Timestamp.Value : newSwitch.Timestamp,
Members = members.Select(x => x.ToJson(LookupContext.ByOwner, v: APIVersion.V2)),
});
}
[HttpGet("systems/{systemRef}/switches/{switchRef}")]
public async Task<IActionResult> SwitchGet(string systemRef, string switchRef)
{
if (!Guid.TryParse(switchRef, out var switchId))
throw Errors.InvalidSwitchId;
var system = await ResolveSystem(systemRef);
if (system == null)
throw Errors.SystemNotFound;
var sw = await _repo.GetSwitchByUuid(switchId);
if (sw == null || system.Id != sw.System)
throw Errors.SwitchNotFoundPublic;
var ctx = this.ContextFor(system);
if (!system.FrontHistoryPrivacy.CanAccess(ctx))
throw Errors.SwitchNotFoundPublic;
var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id));
return Ok(new FrontersReturnNew
{
Uuid = sw.Uuid,
Timestamp = sw.Timestamp,
Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync()
});
}
[HttpPatch("systems/@me/switches/{switchRef}")]
public async Task<IActionResult> SwitchPatch(string switchRef, [FromBody] JObject data)
{
// for now, don't need to make a PatchObject for this, since it's only one param
if (!Guid.TryParse(switchRef, out var switchId))
throw Errors.InvalidSwitchId;
var valueStr = data.Value<string>("timestamp").NullIfEmpty();
if (valueStr == null)
throw new ModelParseError(new List<ValidationError>() { new ValidationError("timestamp", $"Key 'timestamp' is required.") });
var value = Instant.FromDateTimeOffset(DateTime.Parse(valueStr).ToUniversalTime());
var system = await ResolveSystem("@me");
if (system == null)
throw Errors.SystemNotFound;
var sw = await _repo.GetSwitchByUuid(switchId);
if (sw == null || system.Id != sw.System)
throw Errors.SwitchNotFoundPublic;
if (await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(value))
throw Errors.SameSwitchTimestampError;
await _repo.MoveSwitch(sw.Id, value);
var members = await _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)).ToListAsync();
return Ok(new FrontersReturnNew
{
Uuid = sw.Uuid,
Timestamp = sw.Timestamp,
Members = members.Select(x => x.ToJson(LookupContext.ByOwner, v: APIVersion.V2)),
});
}
[HttpPatch("systems/@me/switches/{switchRef}/members")]
public async Task<IActionResult> SwitchMemberPatch(string switchRef, [FromBody] JArray data)
{
if (!Guid.TryParse(switchRef, out var switchId))
if (data.Distinct().Count() != data.Count)
throw Errors.DuplicateMembersInList;
var system = await ResolveSystem("@me");
var sw = await _repo.GetSwitchByUuid(switchId);
if (sw == null)
throw Errors.SwitchNotFound;
var members = new List<PKMember>();
foreach (var JmemberRef in data)
{
var memberRef = JmemberRef.Value<string>();
var member = await ResolveMember(memberRef);
if (member == null)
// todo: which member
throw Errors.MemberNotFound;
if (member.System != system.Id)
throw Errors.NotOwnMemberErrorWithRef(memberRef);
members.Add(member);
}
var latestSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id));
if (await latestSwitchMembers.Select(m => m.Hid).SequenceEqualAsync(members.Select(m => m.Hid).ToAsyncEnumerable()))
throw Errors.SameSwitchMembersError;
await _db.Execute(conn => _repo.EditSwitch(conn, sw.Id, members.Select(x => x.Id).ToList()));
return Ok(new FrontersReturnNew
{
Uuid = sw.Uuid,
Timestamp = sw.Timestamp,
Members = members.Select(x => x.ToJson(LookupContext.ByOwner, v: APIVersion.V2)),
});
}
[HttpDelete("systems/@me/switches/{switchRef}")]
public async Task<IActionResult> SwitchDelete(string switchRef)
{
if (!Guid.TryParse(switchRef, out var switchId))
throw Errors.InvalidSwitchId;
var system = await ResolveSystem("@me");
var sw = await _repo.GetSwitchByUuid(switchId);
if (sw == null || system.Id != sw.System)
throw Errors.SwitchNotFoundPublic;
await _repo.DeleteSwitch(sw.Id);
return NoContent();
}
}
}

View File

@ -0,0 +1,41 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using PluralKit.Core;
namespace PluralKit.API
{
[ApiController]
[ApiVersion("2.0")]
[Route("v{version:apiVersion}/systems")]
public class SystemControllerV2: PKControllerBase
{
public SystemControllerV2(IServiceProvider svc) : base(svc) { }
[HttpGet("{systemRef}")]
public async Task<IActionResult> SystemGet(string systemRef)
{
var system = await ResolveSystem(systemRef);
if (system == null) return NotFound();
else return Ok(system.ToJson(this.ContextFor(system), v: APIVersion.V2));
}
[HttpPatch("@me")]
public async Task<IActionResult> DoSystemPatch([FromBody] JObject data)
{
var system = await ResolveSystem("@me");
var patch = SystemPatch.FromJSON(data, APIVersion.V2);
patch.AssertIsValid();
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);
var newSystem = await _repo.UpdateSystem(system.Id, patch);
return Ok(newSystem.ToJson(LookupContext.ByOwner, v: APIVersion.V2));
}
}
}

116
PluralKit.API/Errors.cs Normal file
View File

@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using PluralKit.Core;
namespace PluralKit.API
{
public class PKError: Exception
{
public int ResponseCode { get; init; }
public int JsonCode { get; init; }
public PKError(int code, int json_code, string message) : base(message)
{
ResponseCode = code;
JsonCode = json_code;
}
public JObject ToJson()
{
var j = new JObject();
j.Add("message", this.Message);
j.Add("code", this.JsonCode);
return j;
}
}
public class ModelParseError: PKError
{
private IEnumerable<ValidationError> _errors { get; init; }
public ModelParseError(IEnumerable<ValidationError> errors) : base(400, 40001, "Error parsing JSON model")
{
_errors = errors;
}
public new JObject ToJson()
{
var j = base.ToJson();
var e = new JObject();
foreach (var err in _errors)
{
var o = new JObject();
if (err is FieldTooLongError fe)
{
o.Add("message", $"Field {err.Key} is too long.");
o.Add("actual_length", fe.ActualLength);
o.Add("max_length", fe.MaxLength);
}
else if (err.Text != null)
o.Add("message", err.Text);
else
o.Add("message", $"Field {err.Key} is invalid.");
if (e[err.Key] == null)
e.Add(err.Key, new JArray());
(e[err.Key] as JArray).Add(o);
}
j.Add("errors", e);
return j;
}
}
public static class Errors
{
public static PKError GenericBadRequest = new(400, 0, "400: Bad Request");
public static PKError GenericAuthError = new(401, 0, "401: Missing or invalid Authorization header");
public static PKError SystemNotFound = new(404, 20001, "System not found.");
public static PKError MemberNotFound = new(404, 20002, "Member not found.");
public static PKError MemberNotFoundWithRef(string memberRef) => new(404, 20003, $"Member '{memberRef}' not found.");
public static PKError GroupNotFound = new(404, 20004, "Group not found.");
public static PKError GroupNotFoundWithRef(string groupRef) => new(404, 20005, $"Group '{groupRef}' not found.");
public static PKError MessageNotFound = new(404, 20006, "Message not found.");
public static PKError SwitchNotFound = new(404, 20007, "Switch not found.");
public static PKError SwitchNotFoundPublic = new(404, 20008, "Switch not found, switch associated with different system, or unauthorized to view front history.");
public static PKError SystemGuildNotFound = new(404, 20009, "No system guild settings found for target guild.");
public static PKError MemberGuildNotFound = new(404, 20010, "No member guild settings found for target guild.");
public static PKError UnauthorizedMemberList = new(403, 30001, "Unauthorized to view member list");
public static PKError UnauthorizedGroupList = new(403, 30002, "Unauthorized to view group list");
public static PKError UnauthorizedGroupMemberList = new(403, 30003, "Unauthorized to view group member list");
public static PKError UnauthorizedCurrentFronters = new(403, 30004, "Unauthorized to view current fronters.");
public static PKError UnauthorizedFrontHistory = new(403, 30005, "Unauthorized to view front history.");
public static PKError NotOwnMemberError = new(403, 30006, "Target member is not part of your system.");
public static PKError NotOwnGroupError = new(403, 30007, "Target group is not part of your system.");
// todo: somehow add the memberRef to the JSON
public static PKError NotOwnMemberErrorWithRef(string memberRef) => new(403, 30008, $"Member '{memberRef}' is not part of your system.");
public static PKError NotOwnGroupErrorWithRef(string groupRef) => new(403, 30009, $"Group '{groupRef}' is not part of your system.");
public static PKError MissingAutoproxyMember = new(400, 40002, "Missing autoproxy member for member-mode autoproxy.");
public static PKError DuplicateMembersInList = new(400, 40003, "Duplicate members in member list.");
public static PKError SameSwitchMembersError = new(400, 40004, "Member list identical to current fronter list.");
public static PKError SameSwitchTimestampError = new(400, 40005, "Switch with provided timestamp already exists.");
public static PKError InvalidSwitchId = new(400, 40006, "Invalid switch ID.");
public static PKError Unimplemented = new(501, 50001, "Unimplemented");
}
public static class APIErrorHandlerExt
{
public static bool IsUserError(this Exception exc)
{
// caused by users sending an incorrect JSON type (array where an object is expected, etc)
if (exc is InvalidCastException && exc.Message.Contains("Newtonsoft.Json"))
return true;
// Hacky parsing of timestamps results in hacky error handling. Probably fix this one at some point.
if (exc is FormatException && exc.Message.Contains("was not recognized as a valid DateTime"))
return true;
// This may expanded at some point.
return false;
}
}
}

View File

@ -0,0 +1,36 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Dapper;
using PluralKit.Core;
namespace PluralKit.API
{
public class AuthorizationTokenHandlerMiddleware
{
private readonly RequestDelegate _next;
public AuthorizationTokenHandlerMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext ctx, IDatabase db)
{
ctx.Request.Headers.TryGetValue("authorization", out var authHeaders);
if (authHeaders.Count > 0)
{
var systemId = await db.Execute(conn => conn.QuerySingleOrDefaultAsync<SystemId?>(
"select id from systems where token = @token",
new { token = authHeaders[0] }
));
if (systemId != null)
ctx.Items.Add("SystemId", systemId);
}
await _next.Invoke(ctx);
}
}
}

View File

@ -7,7 +7,9 @@ using Autofac;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Versioning;
using Microsoft.Extensions.Configuration;
@ -15,6 +17,10 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
using Newtonsoft.Json;
using Serilog;
using PluralKit.Core;
namespace PluralKit.API
@ -51,7 +57,16 @@ namespace PluralKit.API
services.AddControllers()
.SetCompatibilityVersion(CompatibilityVersion.Latest)
.AddNewtonsoftJson(); // sorry MS, this just does *more*
// sorry MS, this just does *more*
.AddNewtonsoftJson((opts) =>
{
// ... though by default it messes up timestamps in JSON
opts.SerializerSettings.DateParseHandling = DateParseHandling.None;
})
.ConfigureApiBehaviorOptions(options =>
options.InvalidModelStateResponseFactory = (context) =>
throw Errors.GenericBadRequest
);
services.AddApiVersioning();
@ -91,7 +106,7 @@ namespace PluralKit.API
builder.RegisterInstance(InitUtils.BuildConfiguration(Environment.GetCommandLineArgs()).Build())
.As<IConfiguration>();
builder.RegisterModule(new ConfigModule<ApiConfig>("API"));
builder.RegisterModule(new LoggingModule("api"));
builder.RegisterModule(new LoggingModule("api", cfg: new LoggerConfiguration().Filter.ByExcluding(exc => exc.Exception is PKError)));
builder.RegisterModule(new MetricsModule("API"));
builder.RegisterModule<DataStoreModule>();
builder.RegisterModule<APIModule>();
@ -117,6 +132,50 @@ namespace PluralKit.API
//app.UseHsts();
}
// add X-PluralKit-Version header
app.Use((ctx, next) =>
{
ctx.Response.Headers.Add("X-PluralKit-Version", BuildInfoService.Version);
return next();
});
app.UseExceptionHandler(handler => handler.Run(async ctx =>
{
var exc = ctx.Features.Get<IExceptionHandlerPathFeature>();
// handle common ISEs that are generated by invalid user input
if (exc.Error.IsUserError())
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("{\"message\":\"400: Bad Request\",\"code\":0}");
return;
}
if (exc.Error is not PKError)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync("{\"message\":\"500: Internal Server Error\",\"code\":0}");
return;
}
// for some reason, if we don't specifically cast to ModelParseError, it uses the base's ToJson method
if (exc.Error is ModelParseError fe)
{
ctx.Response.StatusCode = fe.ResponseCode;
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(fe.ToJson()));
return;
}
var err = (PKError)exc.Error;
ctx.Response.StatusCode = err.ResponseCode;
var json = JsonConvert.SerializeObject(err.ToJson());
await ctx.Response.WriteAsync(json);
}));
app.UseMiddleware<AuthorizationTokenHandlerMiddleware>();
//app.UseHttpsRedirection();
app.UseCors(opts => opts.AllowAnyMethod().AllowAnyOrigin().WithHeaders("Content-Type", "Authorization"));

View File

@ -16,6 +16,15 @@ namespace PluralKit.Core
return _db.QueryStream<PKGroup>(query);
}
public IAsyncEnumerable<PKMember> GetGroupMembers(GroupId id)
{
var query = new Query("group_members")
.Select("members.*")
.Join("members", "group_members.member_id", "members.id")
.Where("group_members.group_id", id);
return _db.QueryStream<PKMember>(query);
}
// todo: add this to metrics tracking
public async Task AddGroupsToMember(MemberId member, IReadOnlyCollection<GroupId> groups)
{
@ -67,5 +76,21 @@ namespace PluralKit.Core
.WhereIn("member_id", members);
return _db.ExecuteQuery(query);
}
public Task ClearGroupMembers(GroupId group)
{
_logger.Information("Cleared members of {GroupId}", group);
var query = new Query("group_members").AsDelete()
.Where("group_id", group);
return _db.ExecuteQuery(query);
}
public Task ClearMemberGroups(MemberId member)
{
_logger.Information("Cleared groups of {GroupId}", member);
var query = new Query("group_members").AsDelete()
.Where("member_id", member);
return _db.ExecuteQuery(query);
}
}
}

View File

@ -21,8 +21,14 @@ namespace PluralKit.Core
}
public Task<SystemGuildSettings> GetSystemGuild(ulong guild, SystemId system)
public Task<SystemGuildSettings> GetSystemGuild(ulong guild, SystemId system, bool defaultInsert = true)
{
if (!defaultInsert)
return _db.QueryFirst<SystemGuildSettings>(new Query("system_guild")
.Where("guild", guild)
.Where("system", system)
);
var query = new Query("system_guild").AsInsert(new
{
guild = guild,
@ -33,16 +39,22 @@ namespace PluralKit.Core
);
}
public Task UpdateSystemGuild(SystemId system, ulong guild, SystemGuildPatch patch)
public Task<SystemGuildSettings> UpdateSystemGuild(SystemId system, ulong guild, SystemGuildPatch patch)
{
_logger.Information("Updated {SystemId} in guild {GuildId}: {@SystemGuildPatch}", system, guild, patch);
var query = patch.Apply(new Query("system_guild").Where("system", system).Where("guild", guild));
return _db.ExecuteQuery(query, extraSql: "returning *");
return _db.QueryFirst<SystemGuildSettings>(query, extraSql: "returning *");
}
public Task<MemberGuildSettings> GetMemberGuild(ulong guild, MemberId member)
public Task<MemberGuildSettings> GetMemberGuild(ulong guild, MemberId member, bool defaultInsert = true)
{
if (!defaultInsert)
return _db.QueryFirst<MemberGuildSettings>(new Query("member_guild")
.Where("guild", guild)
.Where("member", member)
);
var query = new Query("member_guild").AsInsert(new
{
guild = guild,
@ -53,11 +65,11 @@ namespace PluralKit.Core
);
}
public Task UpdateMemberGuild(MemberId member, ulong guild, MemberGuildPatch patch)
public Task<MemberGuildSettings> UpdateMemberGuild(MemberId member, ulong guild, MemberGuildPatch patch)
{
_logger.Information("Updated {MemberId} in guild {GuildId}: {@MemberGuildPatch}", member, guild, patch);
var query = patch.Apply(new Query("member_guild").Where("member", member).Where("guild", guild));
return _db.ExecuteQuery(query, extraSql: "returning *");
return _db.QueryFirst<MemberGuildSettings>(query, extraSql: "returning *");
}
}
}

View File

@ -9,8 +9,8 @@ namespace PluralKit.Core
{
public partial class ModelRepository
{
public Task<IEnumerable<PKShardInfo>> GetShards(IPKConnection conn) =>
conn.QueryAsync<PKShardInfo>("select * from shards order by id");
public Task<IEnumerable<PKShardInfo>> GetShards() =>
_db.Execute(conn => conn.QueryAsync<PKShardInfo>("select * from shards order by id"));
public Task SetShardStatus(IPKConnection conn, int shard, PKShardInfo.ShardStatus status) =>
conn.ExecuteAsync(

View File

@ -1,3 +1,5 @@
using Newtonsoft.Json.Linq;
#nullable enable
namespace PluralKit.Core
{
@ -8,4 +10,17 @@ namespace PluralKit.Core
public string? DisplayName { get; }
public string? AvatarUrl { get; }
}
public static class MemberGuildExt
{
public static JObject ToJson(this MemberGuildSettings settings)
{
var o = new JObject();
o.Add("display_name", settings.DisplayName);
o.Add("avatar_url", settings.AvatarUrl);
return o;
}
}
}

View File

@ -0,0 +1,8 @@
namespace PluralKit.Core
{
public enum APIVersion
{
V1,
V2,
}
}

View File

@ -0,0 +1,29 @@
using System;
using Newtonsoft.Json;
namespace PluralKit.Core
{
public class ValidationError
{
public string Key;
public string? Text;
public ValidationError(string key, string? text = null)
{
Key = key;
Text = text;
}
}
public class FieldTooLongError: ValidationError
{
public int MaxLength;
public int ActualLength;
public FieldTooLongError(string key, int maxLength, int actualLength) : base(key)
{
MaxLength = maxLength;
ActualLength = actualLength;
}
}
}

View File

@ -61,12 +61,17 @@ namespace PluralKit.Core
public static string? IconFor(this PKGroup group, LookupContext ctx) =>
group.IconPrivacy.Get(ctx, group.Icon?.TryGetCleanCdnUrl());
public static JObject ToJson(this PKGroup group, LookupContext ctx, bool isExport = false)
public static JObject ToJson(this PKGroup group, LookupContext ctx, string? systemStr = null, bool isExport = false)
{
var o = new JObject();
o.Add("id", group.Hid);
o.Add("uuid", group.Uuid.ToString());
o.Add("name", group.Name);
if (systemStr != null)
o.Add("system", systemStr);
o.Add("display_name", group.DisplayName);
o.Add("description", group.DescriptionPrivacy.Get(ctx, group.Description));
o.Add("icon", group.Icon);

View File

@ -106,47 +106,83 @@ namespace PluralKit.Core
public static int MessageCountFor(this PKMember member, LookupContext ctx) =>
member.MetadataPrivacy.Get(ctx, member.MessageCount);
public static JObject ToJson(this PKMember member, LookupContext ctx, bool needsLegacyProxyTags = false)
public static JObject ToJson(this PKMember member, LookupContext ctx, bool needsLegacyProxyTags = false, string systemStr = null, APIVersion v = APIVersion.V1)
{
var includePrivacy = ctx == LookupContext.ByOwner;
var o = new JObject();
o.Add("id", member.Hid);
if (v == APIVersion.V2)
{
o.Add("uuid", member.Uuid.ToString());
if (systemStr != null)
o.Add("system", systemStr);
}
o.Add("name", member.NameFor(ctx));
// o.Add("color", member.ColorPrivacy.CanAccess(ctx) ? member.Color : null);
o.Add("color", member.Color);
o.Add("display_name", member.NamePrivacy.CanAccess(ctx) ? member.DisplayName : null);
o.Add("color", member.Color);
o.Add("birthday", member.BirthdayFor(ctx)?.FormatExport());
o.Add("pronouns", member.PronounsFor(ctx));
o.Add("avatar_url", member.AvatarFor(ctx).TryGetCleanCdnUrl());
o.Add("banner", member.DescriptionPrivacy.Get(ctx, member.BannerImage).TryGetCleanCdnUrl());
o.Add("description", member.DescriptionFor(ctx));
o.Add("created", member.CreatedFor(ctx)?.FormatExport());
o.Add("keep_proxy", member.KeepProxy);
var tagArray = new JArray();
foreach (var tag in member.ProxyTags)
tagArray.Add(new JObject { { "prefix", tag.Prefix }, { "suffix", tag.Suffix } });
o.Add("proxy_tags", tagArray);
o.Add("keep_proxy", member.KeepProxy);
o.Add("privacy", includePrivacy ? (member.MemberVisibility.LevelName()) : null);
o.Add("visibility", includePrivacy ? (member.MemberVisibility.LevelName()) : null);
o.Add("name_privacy", includePrivacy ? (member.NamePrivacy.LevelName()) : null);
o.Add("description_privacy", includePrivacy ? (member.DescriptionPrivacy.LevelName()) : null);
o.Add("birthday_privacy", includePrivacy ? (member.BirthdayPrivacy.LevelName()) : null);
o.Add("pronoun_privacy", includePrivacy ? (member.PronounPrivacy.LevelName()) : null);
o.Add("avatar_privacy", includePrivacy ? (member.AvatarPrivacy.LevelName()) : null);
// o.Add("color_privacy", ctx == LookupContext.ByOwner ? (member.ColorPrivacy.LevelName()) : null);
o.Add("metadata_privacy", includePrivacy ? (member.MetadataPrivacy.LevelName()) : null);
o.Add("created", member.CreatedFor(ctx)?.FormatExport());
if (member.ProxyTags.Count > 0 && needsLegacyProxyTags)
switch (v)
{
// Legacy compatibility only, TODO: remove at some point
o.Add("prefix", member.ProxyTags?.FirstOrDefault().Prefix);
o.Add("suffix", member.ProxyTags?.FirstOrDefault().Suffix);
case APIVersion.V1:
{
o.Add("privacy", includePrivacy ? (member.MemberVisibility.LevelName()) : null);
o.Add("visibility", includePrivacy ? (member.MemberVisibility.LevelName()) : null);
o.Add("name_privacy", includePrivacy ? (member.NamePrivacy.LevelName()) : null);
o.Add("description_privacy", includePrivacy ? (member.DescriptionPrivacy.LevelName()) : null);
o.Add("birthday_privacy", includePrivacy ? (member.BirthdayPrivacy.LevelName()) : null);
o.Add("pronoun_privacy", includePrivacy ? (member.PronounPrivacy.LevelName()) : null);
o.Add("avatar_privacy", includePrivacy ? (member.AvatarPrivacy.LevelName()) : null);
// o.Add("color_privacy", ctx == LookupContext.ByOwner ? (member.ColorPrivacy.LevelName()) : null);
o.Add("metadata_privacy", includePrivacy ? (member.MetadataPrivacy.LevelName()) : null);
if (member.ProxyTags.Count > 0 && needsLegacyProxyTags)
{
// Legacy compatibility only, TODO: remove at some point
o.Add("prefix", member.ProxyTags?.FirstOrDefault().Prefix);
o.Add("suffix", member.ProxyTags?.FirstOrDefault().Suffix);
}
break;
}
case APIVersion.V2:
{
if (includePrivacy)
{
var p = new JObject();
p.Add("visibility", member.MemberVisibility.ToJsonString());
p.Add("name_privacy", member.NamePrivacy.ToJsonString());
p.Add("description_privacy", member.DescriptionPrivacy.ToJsonString());
p.Add("birthday_privacy", member.BirthdayPrivacy.ToJsonString());
p.Add("pronoun_privacy", member.PronounPrivacy.ToJsonString());
p.Add("avatar_privacy", member.AvatarPrivacy.ToJsonString());
p.Add("metadata_privacy", member.MetadataPrivacy.ToJsonString());
o.Add("privacy", p);
}
else
o.Add("privacy", null);
break;
}
}
return o;

View File

@ -66,10 +66,13 @@ namespace PluralKit.Core
public static string DescriptionFor(this PKSystem system, LookupContext ctx) =>
system.DescriptionPrivacy.Get(ctx, system.Description);
public static JObject ToJson(this PKSystem system, LookupContext ctx)
public static JObject ToJson(this PKSystem system, LookupContext ctx, APIVersion v = APIVersion.V1)
{
var o = new JObject();
o.Add("id", system.Hid);
if (v == APIVersion.V2)
o.Add("uuid", system.Uuid.ToString());
o.Add("name", system.Name);
o.Add("description", system.DescriptionFor(ctx));
o.Add("tag", system.Tag);
@ -77,13 +80,43 @@ namespace PluralKit.Core
o.Add("banner", system.DescriptionPrivacy.Get(ctx, system.BannerImage).TryGetCleanCdnUrl());
o.Add("color", system.Color);
o.Add("created", system.Created.FormatExport());
// todo: change this to "timezone"
o.Add("tz", system.UiTz);
// todo: just don't include these if not ByOwner
o.Add("description_privacy", ctx == LookupContext.ByOwner ? system.DescriptionPrivacy.ToJsonString() : null);
o.Add("member_list_privacy", ctx == LookupContext.ByOwner ? system.MemberListPrivacy.ToJsonString() : null);
o.Add("front_privacy", ctx == LookupContext.ByOwner ? system.FrontPrivacy.ToJsonString() : null);
o.Add("front_history_privacy", ctx == LookupContext.ByOwner ? system.FrontHistoryPrivacy.ToJsonString() : null);
switch (v)
{
case APIVersion.V1:
{
o.Add("tz", system.UiTz);
o.Add("description_privacy", ctx == LookupContext.ByOwner ? system.DescriptionPrivacy.ToJsonString() : null);
o.Add("member_list_privacy", ctx == LookupContext.ByOwner ? system.MemberListPrivacy.ToJsonString() : null);
o.Add("front_privacy", ctx == LookupContext.ByOwner ? system.FrontPrivacy.ToJsonString() : null);
o.Add("front_history_privacy", ctx == LookupContext.ByOwner ? system.FrontHistoryPrivacy.ToJsonString() : null);
break;
}
case APIVersion.V2:
{
o.Add("timezone", system.UiTz);
if (ctx == LookupContext.ByOwner)
{
var p = new JObject();
p.Add("description_privacy", system.DescriptionPrivacy.ToJsonString());
p.Add("member_list_privacy", system.MemberListPrivacy.ToJsonString());
p.Add("group_list_privacy", system.GroupListPrivacy.ToJsonString());
p.Add("front_privacy", system.FrontPrivacy.ToJsonString());
p.Add("front_history_privacy", system.FrontHistoryPrivacy.ToJsonString());
o.Add("privacy", p);
}
else
o.Add("privacy", null);
break;
}
}
return o;
}
}

View File

@ -38,7 +38,7 @@ namespace PluralKit.Core
public new void AssertIsValid()
{
if (Name.IsPresent)
if (Name.Value != null)
AssertValid(Name.Value, "name", Limits.MaxGroupNameLength);
if (DisplayName.Value != null)
AssertValid(DisplayName.Value, "display_name", Limits.MaxGroupNameLength);
@ -59,10 +59,13 @@ namespace PluralKit.Core
{
var patch = new GroupPatch();
if (o.ContainsKey("name") && o["name"].Type == JTokenType.Null)
throw new ValidationError("Group name can not be set to null.");
if (o.ContainsKey("name"))
{
patch.Name = o.Value<string>("name").NullIfEmpty();
if (patch.Name.Value == null)
patch.Errors.Add(new ValidationError("name", "Group name can not be set to null."));
}
if (o.ContainsKey("name")) patch.Name = o.Value<string>("name");
if (o.ContainsKey("display_name")) patch.DisplayName = o.Value<string>("display_name").NullIfEmpty();
if (o.ContainsKey("description")) patch.Description = o.Value<string>("description").NullIfEmpty();
if (o.ContainsKey("icon")) patch.Icon = o.Value<string>("icon").NullIfEmpty();
@ -74,16 +77,16 @@ namespace PluralKit.Core
var privacy = o.Value<JObject>("privacy");
if (privacy.ContainsKey("description_privacy"))
patch.DescriptionPrivacy = privacy.ParsePrivacy("description_privacy");
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy");
if (privacy.ContainsKey("icon_privacy"))
patch.IconPrivacy = privacy.ParsePrivacy("icon_privacy");
patch.IconPrivacy = patch.ParsePrivacy(privacy, "icon_privacy");
if (privacy.ContainsKey("list_privacy"))
patch.ListPrivacy = privacy.ParsePrivacy("list_privacy");
patch.ListPrivacy = patch.ParsePrivacy(privacy, "list_privacy");
if (privacy.ContainsKey("visibility"))
patch.Visibility = privacy.ParsePrivacy("visibility");
patch.Visibility = patch.ParsePrivacy(privacy, "visibility");
}
return patch;

View File

@ -1,5 +1,7 @@
#nullable enable
using Newtonsoft.Json.Linq;
using SqlKata;
namespace PluralKit.Core
@ -13,5 +15,28 @@ namespace PluralKit.Core
.With("display_name", DisplayName)
.With("avatar_url", AvatarUrl)
);
public new void AssertIsValid()
{
if (DisplayName.Value != null)
AssertValid(DisplayName.Value, "display_name", Limits.MaxMemberNameLength);
if (AvatarUrl.Value != null)
AssertValid(AvatarUrl.Value, "avatar_url", Limits.MaxUriLength,
s => MiscUtils.TryMatchUri(s, out var avatarUri));
}
#nullable disable
public static MemberGuildPatch FromJson(JObject o)
{
var patch = new MemberGuildPatch();
if (o.ContainsKey("display_name"))
patch.DisplayName = o.Value<string>("display_name").NullIfEmpty();
if (o.ContainsKey("avatar_url"))
patch.AvatarUrl = o.Value<string>("avatar_url").NullIfEmpty();
return patch;
}
}
}

View File

@ -58,7 +58,7 @@ namespace PluralKit.Core
public new void AssertIsValid()
{
if (Name.IsPresent)
if (Name.Value != null)
AssertValid(Name.Value, "name", Limits.MaxMemberNameLength);
if (DisplayName.Value != null)
AssertValid(DisplayName.Value, "display_name", Limits.MaxMemberNameLength);
@ -77,17 +77,21 @@ namespace PluralKit.Core
if (ProxyTags.IsPresent && (ProxyTags.Value.Length > 100 ||
ProxyTags.Value.Any(tag => tag.ProxyString.IsLongerThan(100))))
// todo: have a better error for this
throw new ValidationError("proxy_tags");
Errors.Add(new ValidationError("proxy_tags"));
}
#nullable disable
public static MemberPatch FromJSON(JObject o)
public static MemberPatch FromJSON(JObject o, APIVersion v = APIVersion.V1)
{
var patch = new MemberPatch();
if (o.ContainsKey("name") && o["name"].Type == JTokenType.Null)
throw new ValidationError("Member name can not be set to null.");
if (o.ContainsKey("name"))
{
patch.Name = o.Value<string>("name").NullIfEmpty();
if (patch.Name.Value == null)
patch.Errors.Add(new ValidationError("name", "Member name can not be set to null."));
}
if (o.ContainsKey("name")) patch.Name = o.Value<string>("name");
if (o.ContainsKey("color")) patch.Color = o.Value<string>("color").NullIfEmpty()?.ToLower();
@ -101,45 +105,89 @@ namespace PluralKit.Core
var res = DateTimeFormats.DateExportFormat.Parse(str);
if (res.Success) patch.Birthday = res.Value;
else if (str == null) patch.Birthday = null;
else throw new ValidationError("birthday");
else patch.Errors.Add(new ValidationError("birthday"));
}
if (o.ContainsKey("pronouns")) patch.Pronouns = o.Value<string>("pronouns").NullIfEmpty();
if (o.ContainsKey("description")) patch.Description = o.Value<string>("description").NullIfEmpty();
if (o.ContainsKey("keep_proxy")) patch.KeepProxy = o.Value<bool>("keep_proxy");
// legacy: used in old export files and APIv1
if (o.ContainsKey("prefix") || o.ContainsKey("suffix") && !o.ContainsKey("proxy_tags"))
patch.ProxyTags = new[] { new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")) };
else if (o.ContainsKey("proxy_tags"))
patch.ProxyTags = o.Value<JArray>("proxy_tags")
.OfType<JObject>().Select(o => new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")))
.Where(p => p.Valid)
.ToArray();
if (o.ContainsKey("privacy")) //TODO: Deprecate this completely in api v2
switch (v)
{
var plevel = o.ParsePrivacy("privacy");
case APIVersion.V1:
{
// legacy: used in old export files and APIv1
if (o.ContainsKey("prefix") || o.ContainsKey("suffix") && !o.ContainsKey("proxy_tags"))
patch.ProxyTags = new[] { new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")) };
else if (o.ContainsKey("proxy_tags"))
patch.ProxyTags = o.Value<JArray>("proxy_tags")
.OfType<JObject>().Select(o => new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")))
.Where(p => p.Valid)
.ToArray();
patch.Visibility = plevel;
patch.NamePrivacy = plevel;
patch.AvatarPrivacy = plevel;
patch.DescriptionPrivacy = plevel;
patch.BirthdayPrivacy = plevel;
patch.PronounPrivacy = plevel;
// member.ColorPrivacy = plevel;
patch.MetadataPrivacy = plevel;
}
else
{
if (o.ContainsKey("visibility")) patch.Visibility = o.ParsePrivacy("visibility");
if (o.ContainsKey("name_privacy")) patch.NamePrivacy = o.ParsePrivacy("name_privacy");
if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = o.ParsePrivacy("description_privacy");
if (o.ContainsKey("avatar_privacy")) patch.AvatarPrivacy = o.ParsePrivacy("avatar_privacy");
if (o.ContainsKey("birthday_privacy")) patch.BirthdayPrivacy = o.ParsePrivacy("birthday_privacy");
if (o.ContainsKey("pronoun_privacy")) patch.PronounPrivacy = o.ParsePrivacy("pronoun_privacy");
// if (o.ContainsKey("color_privacy")) member.ColorPrivacy = o.ParsePrivacy("member");
if (o.ContainsKey("metadata_privacy")) patch.MetadataPrivacy = o.ParsePrivacy("metadata_privacy");
if (o.ContainsKey("privacy"))
{
var plevel = patch.ParsePrivacy(o, "privacy");
patch.Visibility = plevel;
patch.NamePrivacy = plevel;
patch.AvatarPrivacy = plevel;
patch.DescriptionPrivacy = plevel;
patch.BirthdayPrivacy = plevel;
patch.PronounPrivacy = plevel;
// member.ColorPrivacy = plevel;
patch.MetadataPrivacy = plevel;
}
else
{
if (o.ContainsKey("visibility")) patch.Visibility = patch.ParsePrivacy(o, "visibility");
if (o.ContainsKey("name_privacy")) patch.NamePrivacy = patch.ParsePrivacy(o, "name_privacy");
if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = patch.ParsePrivacy(o, "description_privacy");
if (o.ContainsKey("avatar_privacy")) patch.AvatarPrivacy = patch.ParsePrivacy(o, "avatar_privacy");
if (o.ContainsKey("birthday_privacy")) patch.BirthdayPrivacy = patch.ParsePrivacy(o, "birthday_privacy");
if (o.ContainsKey("pronoun_privacy")) patch.PronounPrivacy = patch.ParsePrivacy(o, "pronoun_privacy");
// if (o.ContainsKey("color_privacy")) member.ColorPrivacy = o.ParsePrivacy("member");
if (o.ContainsKey("metadata_privacy")) patch.MetadataPrivacy = patch.ParsePrivacy(o, "metadata_privacy");
}
break;
}
case APIVersion.V2:
{
if (o.ContainsKey("proxy_tags"))
patch.ProxyTags = o.Value<JArray>("proxy_tags")
.OfType<JObject>().Select(o => new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")))
.Where(p => p.Valid)
.ToArray();
if (o.ContainsKey("privacy") && o["privacy"].Type != JTokenType.Null)
{
var privacy = o.Value<JObject>("privacy");
if (privacy.ContainsKey("visibility"))
patch.Visibility = patch.ParsePrivacy(privacy, "visibility");
if (privacy.ContainsKey("name_privacy"))
patch.NamePrivacy = patch.ParsePrivacy(privacy, "name_privacy");
if (privacy.ContainsKey("description_privacy"))
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy");
if (privacy.ContainsKey("avatar_privacy"))
patch.AvatarPrivacy = patch.ParsePrivacy(privacy, "avatar_privacy");
if (privacy.ContainsKey("birthday_privacy"))
patch.BirthdayPrivacy = patch.ParsePrivacy(privacy, "birthday_privacy");
if (privacy.ContainsKey("pronoun_privacy"))
patch.PronounPrivacy = patch.ParsePrivacy(privacy, "pronoun_privacy");
if (privacy.ContainsKey("metadata_privacy"))
patch.MetadataPrivacy = patch.ParsePrivacy(privacy, "metadata_privacy");
}
break;
}
}
return patch;

View File

@ -1,50 +1,46 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
using SqlKata;
namespace PluralKit.Core
{
public abstract class PatchObject
{
public List<ValidationError> Errors = new();
public abstract Query Apply(Query q);
public void AssertIsValid() { }
protected bool AssertValid(string input, string name, int maxLength, Func<string, bool>? validate = null)
protected void AssertValid(string input, string name, int maxLength, Func<string, bool>? validate = null)
{
if (input.Length > maxLength)
throw new FieldTooLongError(name, maxLength, input.Length);
Errors.Add(new FieldTooLongError(name, maxLength, input.Length));
if (validate != null && !validate(input))
throw new ValidationError(name);
return true;
Errors.Add(new ValidationError(name));
}
protected bool AssertValid(string input, string name, string pattern)
protected void AssertValid(string input, string name, string pattern)
{
if (!Regex.IsMatch(input, pattern))
throw new ValidationError(name);
return true;
Errors.Add(new ValidationError(name));
}
}
public class ValidationError: Exception
{
public ValidationError(string message) : base(message) { }
}
public class FieldTooLongError: ValidationError
{
public string Name;
public int MaxLength;
public int ActualLength;
public FieldTooLongError(string name, int maxLength, int actualLength) :
base($"{name} too long ({actualLength} > {maxLength})")
public PrivacyLevel ParsePrivacy(JObject o, string propertyName)
{
Name = name;
MaxLength = maxLength;
ActualLength = actualLength;
var input = o.Value<string>(propertyName);
if (input == null) return PrivacyLevel.Public;
if (input == "") return PrivacyLevel.Private;
if (input == "private") return PrivacyLevel.Private;
if (input == "public") return PrivacyLevel.Public;
Errors.Add(new ValidationError(propertyName));
// unused, but the compiler will complain if this isn't here
return PrivacyLevel.Private;
}
}
}

View File

@ -1,5 +1,7 @@
#nullable enable
using Newtonsoft.Json.Linq;
using SqlKata;
namespace PluralKit.Core
@ -19,5 +21,39 @@ namespace PluralKit.Core
.With("tag", Tag)
.With("tag_enabled", TagEnabled)
);
public new void AssertIsValid()
{
if (Tag.Value != null)
AssertValid(Tag.Value, "tag", Limits.MaxSystemTagLength);
}
#nullable disable
public static SystemGuildPatch FromJson(JObject o, MemberId? memberId)
{
var patch = new SystemGuildPatch();
if (o.ContainsKey("proxying_enabled") && o["proxying_enabled"].Type != JTokenType.Null)
patch.ProxyEnabled = o.Value<bool>("proxying_enabled");
if (o.ContainsKey("autoproxy_mode"))
{
var (val, err) = o["autoproxy_mode"].ParseAutoproxyMode();
if (err != null)
patch.Errors.Add(err);
else
patch.AutoproxyMode = val.Value;
}
patch.AutoproxyMember = memberId;
if (o.ContainsKey("tag"))
patch.Tag = o.Value<string>("tag").NullIfEmpty();
if (o.ContainsKey("tag_enabled") && o["tag_enabled"].Type != JTokenType.Null)
patch.TagEnabled = o.Value<bool>("tag_enabled");
return patch;
}
}
}

View File

@ -69,10 +69,12 @@ namespace PluralKit.Core
if (Color.Value != null)
AssertValid(Color.Value, "color", "^[0-9a-fA-F]{6}$");
if (UiTz.IsPresent && DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz.Value) == null)
throw new ValidationError("avatar_url");
Errors.Add(new ValidationError("timezone"));
}
public static SystemPatch FromJSON(JObject o)
#nullable disable
public static SystemPatch FromJSON(JObject o, APIVersion v = APIVersion.V1)
{
var patch = new SystemPatch();
if (o.ContainsKey("name")) patch.Name = o.Value<string>("name").NullIfEmpty();
@ -81,16 +83,47 @@ namespace PluralKit.Core
if (o.ContainsKey("avatar_url")) patch.AvatarUrl = o.Value<string>("avatar_url").NullIfEmpty();
if (o.ContainsKey("banner")) patch.BannerImage = o.Value<string>("banner").NullIfEmpty();
if (o.ContainsKey("color")) patch.Color = o.Value<string>("color").NullIfEmpty();
if (o.ContainsKey("timezone")) patch.UiTz = o.Value<string>("tz") ?? "UTC";
if (o.ContainsKey("timezone")) patch.UiTz = o.Value<string>("timezone") ?? "UTC";
// legacy: APIv1 uses "tz" instead of "timezone"
// todo: remove in APIv2
if (o.ContainsKey("tz")) patch.UiTz = o.Value<string>("tz") ?? "UTC";
switch (v)
{
case APIVersion.V1:
{
if (o.ContainsKey("tz")) patch.UiTz = o.Value<string>("tz") ?? "UTC";
if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = patch.ParsePrivacy(o, "description_privacy");
if (o.ContainsKey("member_list_privacy")) patch.MemberListPrivacy = patch.ParsePrivacy(o, "member_list_privacy");
if (o.ContainsKey("front_privacy")) patch.FrontPrivacy = patch.ParsePrivacy(o, "front_privacy");
if (o.ContainsKey("front_history_privacy")) patch.FrontHistoryPrivacy = patch.ParsePrivacy(o, "front_history_privacy");
break;
}
case APIVersion.V2:
{
if (o.ContainsKey("privacy") && o["privacy"].Type != JTokenType.Null)
{
var privacy = o.Value<JObject>("privacy");
if (privacy.ContainsKey("description_privacy"))
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy");
if (privacy.ContainsKey("member_list_privacy"))
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "member_list_privacy");
if (privacy.ContainsKey("group_list_privacy"))
patch.GroupListPrivacy = patch.ParsePrivacy(privacy, "group_list_privacy");
if (privacy.ContainsKey("front_privacy"))
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "front_privacy");
if (privacy.ContainsKey("front_history_privacy"))
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "front_history_privacy");
}
break;
}
}
if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = o.ParsePrivacy("description_privacy");
if (o.ContainsKey("member_list_privacy")) patch.MemberListPrivacy = o.ParsePrivacy("member_list_privacy");
if (o.ContainsKey("front_privacy")) patch.FrontPrivacy = o.ParsePrivacy("front_privacy");
if (o.ContainsKey("front_history_privacy")) patch.FrontHistoryPrivacy = o.ParsePrivacy("front_history_privacy");
return patch;
}
}

View File

@ -1,7 +1,5 @@
using System;
using Newtonsoft.Json.Linq;
namespace PluralKit.Core
{
public enum PrivacyLevel
@ -42,18 +40,5 @@ namespace PluralKit.Core
}
public static string ToJsonString(this PrivacyLevel level) => level.LevelName();
public static PrivacyLevel ParsePrivacy(this JObject o, string propertyName)
{
var input = o.Value<string>(propertyName);
if (input == null) return PrivacyLevel.Public;
if (input == "") return PrivacyLevel.Private;
if (input == "private") return PrivacyLevel.Private;
if (input == "public") return PrivacyLevel.Public;
throw new ValidationError(propertyName);
}
}
}

View File

@ -1,5 +1,10 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Converters;
namespace PluralKit.Core
{
[JsonConverter(typeof(StringEnumConverter))]
public enum AutoproxyMode
{
Off = 1,
@ -20,4 +25,44 @@ namespace PluralKit.Core
public string? Tag { get; }
public bool TagEnabled { get; }
}
public static class SystemGuildExt
{
public static JObject ToJson(this SystemGuildSettings settings, string? memberHid = null)
{
var o = new JObject();
o.Add("proxying_enabled", settings.ProxyEnabled);
o.Add("autoproxy_mode", settings.AutoproxyMode.ToString().ToLower());
o.Add("autoproxy_member", memberHid);
o.Add("tag", settings.Tag);
o.Add("tag_enabled", settings.TagEnabled);
return o;
}
public static (AutoproxyMode?, ValidationError?) ParseAutoproxyMode(this JToken o)
{
if (o.Type == JTokenType.Null)
return (AutoproxyMode.Off, null);
else if (o.Type != JTokenType.String)
return (null, new ValidationError("autoproxy_mode"));
var value = o.Value<string>();
switch (value)
{
case "off":
return (AutoproxyMode.Off, null);
case "front":
return (AutoproxyMode.Front, null);
case "latch":
return (AutoproxyMode.Latch, null);
case "member":
return (AutoproxyMode.Member, null);
default:
return (null, new ValidationError("autoproxy_mode", $"Value '{value}' is not a valid autoproxy mode."));
}
}
}
}

View File

@ -17,11 +17,13 @@ namespace PluralKit.Core
{
private readonly string _component;
private readonly Action<LoggerConfiguration> _fn;
private LoggerConfiguration _cfg { get; init; }
public LoggingModule(string component, Action<LoggerConfiguration> fn = null)
public LoggingModule(string component, Action<LoggerConfiguration> fn = null, LoggerConfiguration cfg = null)
{
_component = component;
_fn = fn ?? (_ => { });
_cfg = cfg ?? new LoggerConfiguration();
}
protected override void Load(ContainerBuilder builder)
@ -44,7 +46,7 @@ namespace PluralKit.Core
var consoleTemplate = "[{Timestamp:HH:mm:ss.fff}] {Level:u3} {Message:lj}{NewLine}{Exception}";
var outputTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.ffffff}] {Level:u3} {Message:lj}{NewLine}{Exception}";
var logCfg = new LoggerConfiguration()
var logCfg = _cfg
.Enrich.FromLogContext()
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)
.Enrich.WithProperty("Component", _component)
@ -53,6 +55,9 @@ namespace PluralKit.Core
// Don't want App.Metrics/D#+ spam
.MinimumLevel.Override("App.Metrics", LogEventLevel.Information)
// nor ASP.NET spam
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
// Actual formatting for these is handled in ScalarFormatting
.Destructure.AsScalar<SystemId>()
.Destructure.AsScalar<MemberId>()

View File

@ -20,13 +20,17 @@ namespace PluralKit.Core
{
var patch = SystemPatch.FromJSON(importFile);
try
patch.AssertIsValid();
if (patch.Errors.Count > 0)
{
patch.AssertIsValid();
}
catch (ValidationError e)
{
throw new ImportException($"Field {e.Message} in export file is invalid.");
var err = patch.Errors[0];
if (err is FieldTooLongError)
throw new ImportException($"Field {err.Key} in export file is too long "
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
else if (err.Text != null)
throw new ImportException(err.Text);
else
throw new ImportException($"Field {err.Key} in export file is invalid.");
}
await _repo.UpdateSystem(_system.Id, patch, _conn);
@ -87,17 +91,18 @@ namespace PluralKit.Core
);
var patch = MemberPatch.FromJSON(member);
try
patch.AssertIsValid();
if (patch.Errors.Count > 0)
{
patch.AssertIsValid();
}
catch (FieldTooLongError e)
{
throw new ImportException($"Field {e.Name} in member {referenceName} is too long ({e.ActualLength} > {e.MaxLength}).");
}
catch (ValidationError e)
{
throw new ImportException($"Field {e.Message} in member {referenceName} is invalid.");
var err = patch.Errors[0];
if (err is FieldTooLongError)
throw new ImportException($"Field {err.Key} in member {name} is too long "
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
else if (err.Text != null)
throw new ImportException($"member {name}: {err.Text}");
else
throw new ImportException($"Field {err.Key} in member {name} is invalid.");
}
MemberId? memberId = found;
@ -128,17 +133,18 @@ namespace PluralKit.Core
);
var patch = GroupPatch.FromJson(group);
try
patch.AssertIsValid();
if (patch.Errors.Count > 0)
{
patch.AssertIsValid();
}
catch (FieldTooLongError e)
{
throw new ImportException($"Field {e.Name} in group {referenceName} is too long ({e.ActualLength} > {e.MaxLength}).");
}
catch (ValidationError e)
{
throw new ImportException($"Field {e.Message} in group {referenceName} is invalid.");
var err = patch.Errors[0];
if (err is FieldTooLongError)
throw new ImportException($"Field {err.Key} in group {name} is too long "
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
else if (err.Text != null)
throw new ImportException($"group {name}: {err.Text}");
else
throw new ImportException($"Field {err.Key} in group {name} is invalid.");
}
GroupId? groupId = found;

View File

@ -88,6 +88,19 @@ namespace PluralKit.Core
patch.DisplayName = $"{name} {tag}";
}
patch.AssertIsValid();
if (patch.Errors.Count > 0)
{
var err = patch.Errors[0];
if (err is FieldTooLongError)
throw new ImportException($"Field {err.Key} in tupper {name} is too long "
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
else if (err.Text != null)
throw new ImportException($"tupper {name}: {err.Text}");
else
throw new ImportException($"Field {err.Key} in tupper {name} is invalid.");
}
var isNewMember = false;
if (!_existingMemberNames.TryGetValue(name, out var memberId))
{
@ -102,19 +115,6 @@ namespace PluralKit.Core
_logger.Debug("Importing member with identifier {FileId} to system {System} (is creating new member? {IsCreatingNewMember})",
name, _system.Id, isNewMember);
try
{
patch.AssertIsValid();
}
catch (FieldTooLongError e)
{
throw new ImportException($"Field {e.Name} in tupper {name} is too long ({e.ActualLength} > {e.MaxLength}).");
}
catch (ValidationError e)
{
throw new ImportException($"Field {e.Message} in tupper {name} is invalid.");
}
await _repo.UpdateMember(memberId, patch, _conn);
return (lastSetTag, multipleTags, hasGroup);

View File

@ -41,7 +41,6 @@ module.exports = {
"/getting-started",
"/user-guide",
"/command-list",
"/api-documentation",
"/privacy-policy",
"/faq",
"/tips-and-tricks"
@ -58,6 +57,19 @@ module.exports = {
"/staff/compatibility",
]
},
{
title: "API Documentation",
collapsable: false,
children: [
"/api/changelog",
"/api/reference",
"/api/endpoints",
"/api/models",
"/api/errors",
// "/api/integrations",
"/api/legacy"
]
},
["https://discord.gg/PczBt78", "Join the support server"],
]
},

View File

@ -0,0 +1,33 @@
---
title: Changelog
permalink: /api/changelog
---
# Version history
* 2020-07-28
* The unversioned API endpoints have been removed.
* 2020-06-17 (v1.1)
* The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `avatar_privacy`, `birthday_privacy`, `pronoun_privacy`, `metadata_privacy`. All are strings and accept the values of `public`, `private` and `null`.
* The `privacy` field has now been deprecated and should not be used. It's still returned (mirroring the `visibility` field), and writing to it will write to *all privacy options*.
* 2020-05-07
* The API (v1) is now formally(ish) defined with OpenAPI v3.0. [The definition file can be found here.](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml)
* 2020-02-10
* Birthdates with no year can now be stored using `0004` as a year, for better leap year support. Both options remain valid and either may be returned by the API.
* Added privacy set/get support, meaning you will now see privacy values in authed requests and can set them.
* 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.
* 2019-10-31
* Added `proxy_tags` field to members
* Added `keep_proxy` field to members
* Deprecated `prefix` and `suffix` member fields, will be removed at some point (tm)
* 2019-07-17
* Added endpoint for querying system by account
* Added endpoint for querying message contents
* 2019-07-10 **(v1)**
* First specified version
* (prehistory)
* Initial release

View File

@ -0,0 +1,266 @@
---
name: Endpoints
permalink: /api/endpoints
---
# Endpoints
The base URL for the PluralKit API is `https://api.pluralkit.me/v2`. Endpoint URLs should be added to the base URL to get a full URL to query.
---
## Systems
*`systemRef` can be a system's short (5-character) ID, a system's UUID, the ID of a Discord account linked to the system, or the string `@me` to refer to the currently authenticated system.*
### Get System
GET `/systems/{systemRef}`
Returns a [system object](/api/models#system-model).
### Update System
PATCH `/systems/{systemRef}`
Takes a partial [system object](/api/models#system-model).
Returns a [system object](/api/models#system-model).
### Get System Guild Settings
GET `/systems/@me/guilds/{guild_id}`
Returns a [system guild settings](/api/models#system-guild-settings) object.
::: tip
You must already have updated per-guild settings for your system in the target guild before being able to get or update them from the API.
:::
### Update System Guild Settings
PATCH `/systems/@me/guilds/{guild_id}`
Takes a partial [system guild settings](/api/models#system-guild-settings) object.
Returns a [system guild settings](/api/models#system-guild-settings) object on success.
---
## Members
*`memberRef` can be a member's short (5-character ID) or a member's UUID.*
### Get System Members
GET `/systems/{systemRef}/members`
Returns a list of [member objects](/api/models#member-model).
### Create Member
POST `/members`
Takes a partial [member object](/api/models#member-model) as input. Key `name` is required.
Returns a [member object](/api/models#member-model) on success.
### Get Member
GET `/members/{memberRef}`
Returns a [member object](/api/models#member-model).
### Update Member
PATCH `/members/{memberRef}`
Takes a partial [member object](/api/models#member-model) as input.
Returns a [member object](/api/models#member-model) on success.
### Delete Member
DELETE `/members/{memberRef}`
Returns 204 No Content on success.
### Get Member Groups
GET `/members/{memberRef}/groups`
### Add Member To Groups
POST `/members/{memberRef}/groups/add`
Takes a list of group references as input. Returns 204 No Content on success.
### Remove Member From Groups
POST `/members/{memberRef}/groups/remove`
::: tip
If you want to remove *all* groups from a member, consider using the [Overwrite Member Groups](#overwrite-member-groups) endpoint instead.
:::
Takes a list of group references as input. Returns 204 No Content on success.
### Overwrite Member Groups
POST `/members/{memberRef}/groups/overwrite`
Takes a list of group references as input. (An empty list is accepted.) Returns 204 No Content on success.
### Get Member Guild Settings
GET `/members/{memberRef}/guilds/{guild_id}`
Returns a [member guild settings](/api/models#member-guild-settings) object.
::: tip
You must already have updated per-guild settings for the target member in the target guild before being able to get or update them from the API.
:::
### Update Member Guild Settings
PATCH `/members/{memberRef}/guilds/{guild_id}`
Takes a partial [member guild settings](/api/models#member-guild-settings) object.
Returns a [member guild settings](/api/models#member-guild-settings) object on success.
---
## Groups
*`groupRef` can be a group's short (5-character ID) or a group's UUID.*
### Get System Groups
GET `/systems/{systemRef}/groups`
Returns a list of [group objects](/api/models/#group-model).
### Create Group
POST `/groups`
Takes a partial [group object](/api/models#group-model) as input. Key `name` is required.
Returns a [group object](/api/models#group-model) on success, or an error object on failure.
### Get Group
GET `/groups/{groupRef}`
Returns a [group object](/api/models/#group-model).
### Update Group
PATCH `/groups/{groupRef}`
Takes a partial [group object](/api/models#group-model) as input.
Returns a [group object](/api/models#group-model) on success, or an error object on failure.
### Delete Group
DELETE `/groups/{groupRef}`
Returns 204 No Content on success.
### Get Group Members
GET `/groups/{groupRef}/members`
Returns an array of [member objects](/api/models#member-model).
### Add Members To Group
POST `/groups/{groupRef}/members/add`
Takes an array of member references as input. Returns 204 No Content on success.
### Remove Member From Group
POST `/groups/{groupRef}/members/remove`
::: tip
If you want to remove *all* members from a group, consider using the [Overwrite Group Members](#overwrite-group-members) endpoint instead.
:::
Takes an array of member references as input. Returns 204 No Content on success.
### Overwrite Group Members
POST `/groups/{groupRef}/members/overwrite`
Takes an array of member references as input. (An empty list is accepted.) Returns 204 No Content on success.
---
## Switches
*`switchRef` must be a switch's UUID. On POST/PATCH/DELETE endpoints, `systemRef` must be `@me`.*
### Get System Switches
GET `/systems/{systemRef}/switches`
Query String Parameters
|key|type|description|
|---|---|---|
|before|timestamp|date to get latest switch from (inclusive)|
|limit|int|number of switches to get|
Returns a [switch object](/api/models#switch-model) containing a list of IDs.
### Get Current System Fronters
GET `/systems/{systemRef}/fronters`
Returns a [switch object](/api/models#switch-model) containing a list of member objects.
### Create Switch
POST `/systems/{systemRef}/switches`
JSON Body Parameters
|key|type|description|
|---|---|---|
|?timestamp|datetime*|when the switch started|
|members|list of strings**|members present in the switch (or empty list for switch-out)|
* Defaults to "now" when missing.
** Can be short IDs or UUIDs.
### Get Switch
GET `/systems/{systemRef}/switches/{switchRef}`
Returns a [switch object](/api/models#switch-model) containing a list of member objects.
### Update Switch
PATCH `/systems/{systemRef}/switches/{switchRef}`
JSON Body Parameters
|key|type|description|
|---|---|---|
|timestamp|datetime|when the switch started|
Returns a [switch object](/api/models#switch-model) containing a list of member objects on success.
### Update Switch Members
PATCH `/systems/{systemRef}/switches/{switchRef}/members`
Takes a list of member short IDs or UUIDs as input.
Returns a [switch object](/api/models#switch-model) containing a list of member objects on success.
### Delete Switch
DELETE `/systems/{systemRef}/switches/{switchRef}`
Returns 204 No Content on success.

View File

@ -0,0 +1,59 @@
---
title: Errors and Status Codes
permalink: /api/errors
---
# Errors and Status Codes
When something goes wrong, the API will send back a 4xx HTTP status code, along with a JSON object describing the error.
### Error Response Model
|key|type|description|
|---|---|---|
|code|int|numerical error code|
|message|string|description of the error|
|?errors|map of entity keys to list of error objects*|details on the error|
|?retry_after|int|if this is a rate limit error, the number of milliseconds after which you can retry the request|
* Only returned for model parsing errors.
### Error Object
|key|type|description|
|---|---|---|
|message|string|error description|
|?max_length|int|if this is an error indicating a key is too long, the maximum allowed length for the key|
|?actual_length|int|if this is an error indicating a key is too long, the length of the provided value|
## JSON error codes
|code|HTTP response code|meaning|
|---|---|---|
|0|500|Internal server error, try again later|
|0|400|Bad Request (usually invalid JSON)|
|0|401|Missing or invalid Authorization header|
|20001|404|System not found.|
|20002|404|Member not found.|
|20003|404|Member '{memberRef}' not found.|
|20004|404|Group not found.|
|20005|404|Group '{groupRef}' not found.|
|20006|404|Message not found.|
|20007|404|Switch not found.|
|20008|404|Switch not found, switch associated with different system, or unauthorized to view front history.|
|20009|404|No system guild settings found for target guild.|
|20010|404|No member guild settings found for target guild.|
|30001|403|Unauthorized to view member list|
|30002|403|Unauthorized to view group list|
|30003|403|Unauthorized to view group member list|
|30004|403|Unauthorized to view current fronters.|
|30005|403|Unauthorized to view front history.|
|30006|403|Target member is not part of your system.|
|30007|403|Target group is not part of your system.|
|30008|403|Member '{memberRef}' is not part of your system.|
|30009|403|Group '{groupRef}' is not part of your system.|
|40002|400|Missing autoproxy member for member-mode autoproxy.|
|40003|400|Duplicate members in member list.|
|40004|400|Member list identical to current fronter list.|
|40005|400|Switch with provided timestamp already exists.|
|40006|400|Invalid switch ID.|

View File

@ -1,47 +1,13 @@
---
title: API documentation
description: PluralKit's API documentation.
permalink: /api
title: Legacy API documentation
permalink: /api/legacy
---
**2020-05-07**: [The PluralKit API is now documented on Swagger.](https://app.swaggerhub.com/apis-docs/xSke/PluralKit/1.1)
Accompanying it is an [OpenAPI v3.0 definition](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml). It's mostly complete, but is still subject to change - so don't go generating API clients and mock servers with it quite yet. It may still be useful, though :)
# Legacy API documentation
# API documentation
PluralKit has a basic HTTP REST API for querying and modifying your system.
The root endpoint of the API is `https://api.pluralkit.me/v1/`.
#### Authorization header token example
```
Authorization: z865MC7JNhLtZuSq1NXQYVe+FgZJHBfeBCXOPYYRwH4liDCDrsd7zdOuR45mX257
```
Endpoints will always return all fields, using `null` when a value is missing. On `PATCH` endpoints,
missing fields from the JSON request will be ignored and preserved as is, but on `POST` endpoints will
be set to `null` or cleared.
Endpoints taking JSON bodies (eg. most `PATCH` and `PUT` endpoints) require the `Content-Type: application/json` header set.
## Community API Libraries
The following API libraries have been created by members of our community. Please contact the developer of each library if you need support.
- **Python:** *PluralKit.py* ([PyPI](https://pypi.org/project/pluralkit/) | [Docs](https://pluralkit.readthedocs.io/en/latest/source/quickstart.html) | [Source code](https://github.com/almonds0166/pluralkit.py))
- **JavaScript:** *pkapi.js* ([npmjs](https://npmjs.com/package/pkapi.js) | [Docs](https://github.com/greysdawn/pk.js/wiki) | [Source code](https://github.com/greysdawn/pk.js))
- **Golang:** *pkgo* (install: `go get github.com/starshine-sys/pkgo` | [Docs (godoc)](https://godocs.io/github.com/starshine-sys/pkgo) | [Docs (pkg.go.dev)](https://pkg.go.dev/github.com/starshine-sys/pkgo) | [Source code](https://github.com/starshine-sys/pkgo))
Do let us know in the support server if you made a new library and would like to see it listed here!
## Authentication
Authentication is done with a simple "system token". You can get your system token by running `pk;token` using the
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.
::: warning
This is the documentation for v1 of the PluralKit API. Please use v2 going forwards - v1 is deprecated and will be removed eventually.
:::
## Models
The following three models (usually represented in JSON format) represent the various objects in PluralKit's API.
@ -536,32 +502,4 @@ The returned system and member's privacy settings will be respected, and as such
"metadata_privacy": "private"
}
}
```
## Version history
* 2020-07-28
* The unversioned API endpoints have been removed.
* 2020-06-17 (v1.1)
* The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `avatar_privacy`, `birthday_privacy`, `pronoun_privacy`, `metadata_privacy`. All are strings and accept the values of `public`, `private` and `null`.
* The `privacy` field has now been deprecated and should not be used. It's still returned (mirroring the `visibility` field), and writing to it will write to *all privacy options*.
* 2020-05-07
* The API (v1) is now formally(ish) defined with OpenAPI v3.0. [The definition file can be found here.](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml)
* 2020-02-10
* Birthdates with no year can now be stored using `0004` as a year, for better leap year support. Both options remain valid and either may be returned by the API.
* Added privacy set/get support, meaning you will now see privacy values in authed requests and can set them.
* 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.
* 2019-10-31
* Added `proxy_tags` field to members
* Added `keep_proxy` field to members
* Deprecated `prefix` and `suffix` member fields, will be removed at some point (tm)
* 2019-07-17
* Added endpoint for querying system by account
* Added endpoint for querying message contents
* 2019-07-10 **(v1)**
* First specified version
* (prehistory)
* Initial release
```

123
docs/content/api/models.md Normal file
View File

@ -0,0 +1,123 @@
---
title: Models
permalink: /api/models
---
# Models
A question mark (`?`) next to the *key name* means the key is optional - it may be omitted in API responses. A question mark next to the *key type* means the key is nullable - API responses may return `null` for that key, instead of the specified type.
In PATCH endpoints, all keys are optional. However, providing an object with no keys (or no valid keys) will result in 500 internal server error.
Privacy objects (`privacy` key in models) contain values "private" or "public". Patching a privacy value to `null` will set to public. If you do not have access to view the privacy object of the member, the value of the `privacy` key will be null, rather than the values of individual privacy keys.
#### Notes on IDs
Every PluralKit entity has two IDs: a short (5-character) ID and a longer UUID. The short ID is unique across the resource (a member can have the same short ID as a system, for example), while the UUID is consistent for the lifetime of the entity and globally unique across the bot.
### System model
|key|type|notes|
|---|---|---|
|id|string||
|uuid|string||
|name|string|100-character limit|
|description|?string|1000-character limit|
|tag|string||
|avatar_url|?string|256-character limit, must be a publicly-accessible URL|
|banner|?string|256-character limit, must be a publicly-accessible URL|
|color|string|6-character hex code, no `#` at the beginning|
|created|datetime||
|timezone|string|defaults to `UTC`|
|privacy|?system privacy object||
* System privacy keys: `description_privacy`, `member_list_privacy`, `group_list_privacy`, `front_privacy`, `front_history_privacy`
### Member model
|key|type|notes|
|---|---|---|
|id|string||
|uuid|string||
|name|string|100-character limit|
|display_name|?string|100-character limit|
|color|string|6-character hex code, no `#` at the beginning|
|birthday|?string|`YYYY-MM-DD` format, 0004 hides the year|
|pronouns|?string|100-character-limit|
|avatar_url|?string|256-character limit, must be a publicly-accessible URL|
|banner|?string|256-character limit, must be a publicly-accessible URL|
|description|?string|1000-character limit|
|created|?datetime||
|keep_proxy|boolean||
|privacy|?member privacy object||
* Member privacy keys: `visibility`, `name_privacy`, `description_privacy`, `birthday_privacy`, `pronoun_privacy`, `avatar_privacy`, `metadata_privacy`
#### ProxyTag object
| Key | Type |
| ------ | ------- |
| prefix | ?string |
| suffix | ?string |
### Group model
|key|type|notes|
|---|---|---|
|id|string||
|uuid|string||
|name|string|100-character limit|
|display_name|?string|100-character limit|
|description|?string|1000-character limit|
|icon|?string|256-character limit, must be a publicly-accessible URL|
|banner|?string|256-character limit, must be a publicly-accessible URL|
|color|string|6-character hex code, no `#` at the beginning|
|privacy|?group privacy object||
* Group privacy keys: `description_privacy`, `icon_privacy`, `list_privacy`, `visibility`
### Switch model
|key|type|notes|
|---|---|---|
|id|uuid||
|timestamp|datetime||
| members | list of id/Member | Is sometimes in plain ID list form (eg. `GET /systems/:id/switches`), sometimes includes the full Member model (eg. `GET /systems/:id/fronters`). |
### Message model
|key|type|notes|
|---|---|---|
|timestamp|datetime||
|id|snowflake|The ID of the message sent by the webhook. Encoded as string for precision reasons.|
|original| snowflake|The ID of the (now-deleted) message that triggered the proxy. Encoded as string for precision reasons.|
|sender|snowflake|The user ID of the account that triggered the proxy. Encoded as string for precision reasons.|
|channel|snowflake|The ID of the channel the message was sent in. Encoded as string for precision reasons.|
|system|full System object|The system that proxied the message.|
|member|full Member object|The member that proxied the message.|
### System guild settings model
|key|type|notes|
|---|---|---|
|proxying_enabled|boolean||
|autoproxy_mode|autoproxy mode enum||
|autoproxy_member|?string|must be set if autoproxy_mode is `member`|
|tag|?string|79-character limit|
|tag_enabled|boolean||
#### Autoproxy mode enum
|key|description|
|---|---|
|off|autoproxy is disabled|
|front|autoproxy is set to the first member in the current fronters list, or disabled if the current switch contains no members|
|latch|autoproxy is set to the last member who sent a proxied message in the server|
|member|autoproxy is set to a specific member (see `autoproxy_member` key)|
### Member guild settings model
|key|type|notes|
|---|---|---|
|display_name|?string|100-character limit|
|avatar_url|?string|256-character limit, must be a publicly-accessible URL|

View File

@ -0,0 +1,48 @@
---
title: Reference
permalink: /api/reference
---
# API Reference
PluralKit has a basic HTTP REST API for querying and modifying your system.
The root endpoint of the API is `https://api.pluralkit.me/v2/`.
#### Authorization header token example
```
Authorization: z865MC7JNhLtZuSq1NXQYVe+FgZJHBfeBCXOPYYRwH4liDCDrsd7zdOuR45mX257
```
Endpoints will always return all fields, using `null` when a value is missing. On `PATCH` endpoints,
missing fields from the JSON request will be ignored and preserved as is, but on `POST` endpoints will
be set to `null` or cleared.
For models that have them, the keys `id`, `uuid` and `created` are **not** user-settable.
Endpoints taking JSON bodies (eg. most `PATCH` and `PUT` endpoints) require the `Content-Type: application/json` header set.
## Authentication
Authentication is done with a simple "system token". You can get your system token by running `pk;token` using the
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.
## Rate Limiting
By default, there is a per-IP limit of 2 requests per second across the API. If you exceed this limit, you will get a 429 response code with a [rate limit error](#) body .....
todo: this isn't implemented yet.
## Community API Libraries
The following API libraries have been created by members of our community. Please contact the developer of each library if you need support.
- **Python:** *PluralKit.py* ([PyPI](https://pypi.org/project/pluralkit/) | [Docs](https://pluralkit.readthedocs.io/en/latest/source/quickstart.html) | [Source code](https://github.com/almonds0166/pluralkit.py))
- **JavaScript:** *pkapi.js* ([npmjs](https://npmjs.com/package/pkapi.js) | [Docs](https://github.com/greysdawn/pk.js/wiki) | [Source code](https://github.com/greysdawn/pk.js))
- **Golang:** *pkgo* (install: `go get github.com/starshine-sys/pkgo` | [Docs (godoc)](https://godocs.io/github.com/starshine-sys/pkgo) | [Docs (pkg.go.dev)](https://pkg.go.dev/github.com/starshine-sys/pkgo) | [Source code](https://github.com/starshine-sys/pkgo))
Do let us know in the support server if you made a new library and would like to see it listed here!