diff --git a/.gitignore b/.gitignore index 33fbce6f..5259ec66 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ obj/ .vscode/ tags/ .DS_Store +mono_crash* # Dependencies node_modules/ diff --git a/PluralKit.API/APIJsonExt.cs b/PluralKit.API/APIJsonExt.cs new file mode 100644 index 00000000..ee57a500 --- /dev/null +++ b/PluralKit.API/APIJsonExt.cs @@ -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 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 Members { get; set; } + } + + public struct SwitchesReturnNew + { + [JsonProperty("id")] public Guid Uuid { get; set; } + [JsonProperty("timestamp")] public Instant Timestamp { get; set; } + [JsonProperty("members")] public IEnumerable Members { get; set; } + } + +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/PKControllerBase.cs b/PluralKit.API/Controllers/PKControllerBase.cs new file mode 100644 index 00000000..d2540ab4 --- /dev/null +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -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(); + _db = svc.GetRequiredService(); + _repo = svc.GetRequiredService(); + } + + protected Task 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(null); + } + + protected Task 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(null); + } + + protected Task 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(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; + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/MemberController.cs b/PluralKit.API/Controllers/v1/MemberController.cs index 8ca35626..5b7f5512 100644 --- a/PluralKit.API/Controllers/v1/MemberController.cs +++ b/PluralKit.API/Controllers/v1/MemberController.cs @@ -58,21 +58,21 @@ namespace PluralKit.API await using var tx = await conn.BeginTransactionAsync(); var member = await _repo.CreateMember(systemId, properties.Value("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); diff --git a/PluralKit.API/Controllers/v1/MetaController.cs b/PluralKit.API/Controllers/v1/MetaController.cs index 098bd8a8..0ac04974 100644 --- a/PluralKit.API/Controllers/v1/MetaController.cs +++ b/PluralKit.API/Controllers/v1/MetaController.cs @@ -28,7 +28,7 @@ namespace PluralKit.API public async Task> 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 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; - } - - } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/SystemController.cs b/PluralKit.API/Controllers/v1/SystemController.cs index 30f648aa..437d4994 100644 --- a/PluralKit.API/Controllers/v1/SystemController.cs +++ b/PluralKit.API/Controllers/v1/SystemController.cs @@ -32,6 +32,7 @@ namespace PluralKit.API public struct PostSwitchParams { + public Instant? Timestamp { get; set; } public ICollection 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); diff --git a/PluralKit.API/Controllers/v2/DiscordControllerV2.cs b/PluralKit.API/Controllers/v2/DiscordControllerV2.cs new file mode 100644 index 00000000..c44fc97d --- /dev/null +++ b/PluralKit.API/Controllers/v2/DiscordControllerV2.cs @@ -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 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 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("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 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 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> 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() + }; + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs new file mode 100644 index 00000000..4b351e67 --- /dev/null +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -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 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 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 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 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 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(); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs new file mode 100644 index 00000000..afed262d --- /dev/null +++ b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs @@ -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 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 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(); + + foreach (var JmemberRef in memberRefs) + { + var memberRef = JmemberRef.Value(); + 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 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(); + + foreach (var JmemberRef in memberRefs) + { + var memberRef = JmemberRef.Value(); + 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 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(); + + foreach (var JmemberRef in memberRefs) + { + var memberRef = JmemberRef.Value(); + 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 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 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(); + + foreach (var JgroupRef in groupRefs) + { + var groupRef = JgroupRef.Value(); + 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 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(); + + foreach (var JgroupRef in groupRefs) + { + var groupRef = JgroupRef.Value(); + 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 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(); + + foreach (var JgroupRef in groupRefs) + { + var groupRef = JgroupRef.Value(); + 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(); + } + + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs new file mode 100644 index 00000000..c7b8c090 --- /dev/null +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -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 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 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 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 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 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(); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/PrivateControllerV2.cs b/PluralKit.API/Controllers/v2/PrivateControllerV2.cs new file mode 100644 index 00000000..ce8c7772 --- /dev/null +++ b/PluralKit.API/Controllers/v2/PrivateControllerV2.cs @@ -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> 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); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs new file mode 100644 index 00000000..d291991c --- /dev/null +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -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 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( + @"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 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 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(); + + 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 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 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("timestamp").NullIfEmpty(); + if (valueStr == null) + throw new ModelParseError(new List() { 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 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(); + + foreach (var JmemberRef in data) + { + var memberRef = JmemberRef.Value(); + + 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 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(); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/SystemControllerV2.cs b/PluralKit.API/Controllers/v2/SystemControllerV2.cs new file mode 100644 index 00000000..b4ae8fc1 --- /dev/null +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -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 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 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)); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs new file mode 100644 index 00000000..90281455 --- /dev/null +++ b/PluralKit.API/Errors.cs @@ -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 _errors { get; init; } + public ModelParseError(IEnumerable 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; + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Middleware/AuthorizationTokenHandlerMiddleware.cs b/PluralKit.API/Middleware/AuthorizationTokenHandlerMiddleware.cs new file mode 100644 index 00000000..2c9bf0b6 --- /dev/null +++ b/PluralKit.API/Middleware/AuthorizationTokenHandlerMiddleware.cs @@ -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( + "select id from systems where token = @token", + new { token = authHeaders[0] } + )); + + if (systemId != null) + ctx.Items.Add("SystemId", systemId); + } + + await _next.Invoke(ctx); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index db755045..1a01356c 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -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(); builder.RegisterModule(new ConfigModule("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(); builder.RegisterModule(); @@ -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(); + + // 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(); + //app.UseHttpsRedirection(); app.UseCors(opts => opts.AllowAnyMethod().AllowAnyOrigin().WithHeaders("Content-Type", "Authorization")); diff --git a/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs b/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs index 992b0ed5..06ff7319 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs @@ -16,6 +16,15 @@ namespace PluralKit.Core return _db.QueryStream(query); } + public IAsyncEnumerable 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(query); + } + // todo: add this to metrics tracking public async Task AddGroupsToMember(MemberId member, IReadOnlyCollection 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); + } } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Guild.cs b/PluralKit.Core/Database/Repository/ModelRepository.Guild.cs index 5eb8ed26..851c83d2 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Guild.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Guild.cs @@ -21,8 +21,14 @@ namespace PluralKit.Core } - public Task GetSystemGuild(ulong guild, SystemId system) + public Task GetSystemGuild(ulong guild, SystemId system, bool defaultInsert = true) { + if (!defaultInsert) + return _db.QueryFirst(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 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(query, extraSql: "returning *"); } - public Task GetMemberGuild(ulong guild, MemberId member) + public Task GetMemberGuild(ulong guild, MemberId member, bool defaultInsert = true) { + if (!defaultInsert) + return _db.QueryFirst(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 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(query, extraSql: "returning *"); } } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Shards.cs b/PluralKit.Core/Database/Repository/ModelRepository.Shards.cs index d0ac6584..157174ca 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Shards.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Shards.cs @@ -9,8 +9,8 @@ namespace PluralKit.Core { public partial class ModelRepository { - public Task> GetShards(IPKConnection conn) => - conn.QueryAsync("select * from shards order by id"); + public Task> GetShards() => + _db.Execute(conn => conn.QueryAsync("select * from shards order by id")); public Task SetShardStatus(IPKConnection conn, int shard, PKShardInfo.ShardStatus status) => conn.ExecuteAsync( diff --git a/PluralKit.Core/Models/MemberGuildSettings.cs b/PluralKit.Core/Models/MemberGuildSettings.cs index 3347275e..db3dfba4 100644 --- a/PluralKit.Core/Models/MemberGuildSettings.cs +++ b/PluralKit.Core/Models/MemberGuildSettings.cs @@ -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; + } + } } \ No newline at end of file diff --git a/PluralKit.Core/Models/ModelTypes/APIVersion.cs b/PluralKit.Core/Models/ModelTypes/APIVersion.cs new file mode 100644 index 00000000..53670654 --- /dev/null +++ b/PluralKit.Core/Models/ModelTypes/APIVersion.cs @@ -0,0 +1,8 @@ +namespace PluralKit.Core +{ + public enum APIVersion + { + V1, + V2, + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/ModelTypes/Validation.cs b/PluralKit.Core/Models/ModelTypes/Validation.cs new file mode 100644 index 00000000..20e8e75d --- /dev/null +++ b/PluralKit.Core/Models/ModelTypes/Validation.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/PKGroup.cs b/PluralKit.Core/Models/PKGroup.cs index a08425fe..989a64eb 100644 --- a/PluralKit.Core/Models/PKGroup.cs +++ b/PluralKit.Core/Models/PKGroup.cs @@ -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); diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs index b8e555f0..caa9fcda 100644 --- a/PluralKit.Core/Models/PKMember.cs +++ b/PluralKit.Core/Models/PKMember.cs @@ -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; diff --git a/PluralKit.Core/Models/PKSystem.cs b/PluralKit.Core/Models/PKSystem.cs index fb0c7d07..3512669b 100644 --- a/PluralKit.Core/Models/PKSystem.cs +++ b/PluralKit.Core/Models/PKSystem.cs @@ -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; } } diff --git a/PluralKit.Core/Models/Patch/GroupPatch.cs b/PluralKit.Core/Models/Patch/GroupPatch.cs index 75db6773..293b3d66 100644 --- a/PluralKit.Core/Models/Patch/GroupPatch.cs +++ b/PluralKit.Core/Models/Patch/GroupPatch.cs @@ -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("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("name"); if (o.ContainsKey("display_name")) patch.DisplayName = o.Value("display_name").NullIfEmpty(); if (o.ContainsKey("description")) patch.Description = o.Value("description").NullIfEmpty(); if (o.ContainsKey("icon")) patch.Icon = o.Value("icon").NullIfEmpty(); @@ -74,16 +77,16 @@ namespace PluralKit.Core var privacy = o.Value("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; diff --git a/PluralKit.Core/Models/Patch/MemberGuildPatch.cs b/PluralKit.Core/Models/Patch/MemberGuildPatch.cs index 5207fb7e..7a51df25 100644 --- a/PluralKit.Core/Models/Patch/MemberGuildPatch.cs +++ b/PluralKit.Core/Models/Patch/MemberGuildPatch.cs @@ -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("display_name").NullIfEmpty(); + + if (o.ContainsKey("avatar_url")) + patch.AvatarUrl = o.Value("avatar_url").NullIfEmpty(); + + return patch; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/MemberPatch.cs b/PluralKit.Core/Models/Patch/MemberPatch.cs index b69577cd..edf47289 100644 --- a/PluralKit.Core/Models/Patch/MemberPatch.cs +++ b/PluralKit.Core/Models/Patch/MemberPatch.cs @@ -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("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("name"); if (o.ContainsKey("color")) patch.Color = o.Value("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("pronouns").NullIfEmpty(); if (o.ContainsKey("description")) patch.Description = o.Value("description").NullIfEmpty(); if (o.ContainsKey("keep_proxy")) patch.KeepProxy = o.Value("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("prefix"), o.Value("suffix")) }; - else if (o.ContainsKey("proxy_tags")) - patch.ProxyTags = o.Value("proxy_tags") - .OfType().Select(o => new ProxyTag(o.Value("prefix"), o.Value("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("prefix"), o.Value("suffix")) }; + else if (o.ContainsKey("proxy_tags")) + patch.ProxyTags = o.Value("proxy_tags") + .OfType().Select(o => new ProxyTag(o.Value("prefix"), o.Value("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("proxy_tags") + .OfType().Select(o => new ProxyTag(o.Value("prefix"), o.Value("suffix"))) + .Where(p => p.Valid) + .ToArray(); + + if (o.ContainsKey("privacy") && o["privacy"].Type != JTokenType.Null) + { + var privacy = o.Value("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; diff --git a/PluralKit.Core/Models/Patch/PatchObject.cs b/PluralKit.Core/Models/Patch/PatchObject.cs index f1ddc84a..0a35a513 100644 --- a/PluralKit.Core/Models/Patch/PatchObject.cs +++ b/PluralKit.Core/Models/Patch/PatchObject.cs @@ -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 Errors = new(); public abstract Query Apply(Query q); public void AssertIsValid() { } - protected bool AssertValid(string input, string name, int maxLength, Func? validate = null) + protected void AssertValid(string input, string name, int maxLength, Func? 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(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; } } } \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/SystemGuildPatch.cs b/PluralKit.Core/Models/Patch/SystemGuildPatch.cs index a5642f85..3410fc6b 100644 --- a/PluralKit.Core/Models/Patch/SystemGuildPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemGuildPatch.cs @@ -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("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("tag").NullIfEmpty(); + + if (o.ContainsKey("tag_enabled") && o["tag_enabled"].Type != JTokenType.Null) + patch.TagEnabled = o.Value("tag_enabled"); + + return patch; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/SystemPatch.cs b/PluralKit.Core/Models/Patch/SystemPatch.cs index a2ba3b3e..50802a1b 100644 --- a/PluralKit.Core/Models/Patch/SystemPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemPatch.cs @@ -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("name").NullIfEmpty(); @@ -81,16 +83,47 @@ namespace PluralKit.Core if (o.ContainsKey("avatar_url")) patch.AvatarUrl = o.Value("avatar_url").NullIfEmpty(); if (o.ContainsKey("banner")) patch.BannerImage = o.Value("banner").NullIfEmpty(); if (o.ContainsKey("color")) patch.Color = o.Value("color").NullIfEmpty(); - if (o.ContainsKey("timezone")) patch.UiTz = o.Value("tz") ?? "UTC"; + if (o.ContainsKey("timezone")) patch.UiTz = o.Value("timezone") ?? "UTC"; - // legacy: APIv1 uses "tz" instead of "timezone" - // todo: remove in APIv2 - if (o.ContainsKey("tz")) patch.UiTz = o.Value("tz") ?? "UTC"; + switch (v) + { + case APIVersion.V1: + { + if (o.ContainsKey("tz")) patch.UiTz = o.Value("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("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; } } diff --git a/PluralKit.Core/Models/Privacy/PrivacyLevel.cs b/PluralKit.Core/Models/Privacy/PrivacyLevel.cs index c04cf74c..5feffae7 100644 --- a/PluralKit.Core/Models/Privacy/PrivacyLevel.cs +++ b/PluralKit.Core/Models/Privacy/PrivacyLevel.cs @@ -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(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); - } - } } \ No newline at end of file diff --git a/PluralKit.Core/Models/SystemGuildSettings.cs b/PluralKit.Core/Models/SystemGuildSettings.cs index 83b6a2cd..14750760 100644 --- a/PluralKit.Core/Models/SystemGuildSettings.cs +++ b/PluralKit.Core/Models/SystemGuildSettings.cs @@ -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(); + + 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.")); + } + } + } } \ No newline at end of file diff --git a/PluralKit.Core/Modules/LoggingModule.cs b/PluralKit.Core/Modules/LoggingModule.cs index adab7624..7c13689d 100644 --- a/PluralKit.Core/Modules/LoggingModule.cs +++ b/PluralKit.Core/Modules/LoggingModule.cs @@ -17,11 +17,13 @@ namespace PluralKit.Core { private readonly string _component; private readonly Action _fn; + private LoggerConfiguration _cfg { get; init; } - public LoggingModule(string component, Action fn = null) + public LoggingModule(string component, Action 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() .Destructure.AsScalar() diff --git a/PluralKit.Core/Utils/BulkImporter/PluralKitImport.cs b/PluralKit.Core/Utils/BulkImporter/PluralKitImport.cs index 1412d612..6eaf0e33 100644 --- a/PluralKit.Core/Utils/BulkImporter/PluralKitImport.cs +++ b/PluralKit.Core/Utils/BulkImporter/PluralKitImport.cs @@ -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; diff --git a/PluralKit.Core/Utils/BulkImporter/TupperboxImport.cs b/PluralKit.Core/Utils/BulkImporter/TupperboxImport.cs index 4b2cd234..8c9b971a 100644 --- a/PluralKit.Core/Utils/BulkImporter/TupperboxImport.cs +++ b/PluralKit.Core/Utils/BulkImporter/TupperboxImport.cs @@ -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); diff --git a/docs/content/.vuepress/config.js b/docs/content/.vuepress/config.js index 739ed9cf..8764b640 100644 --- a/docs/content/.vuepress/config.js +++ b/docs/content/.vuepress/config.js @@ -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"], ] }, diff --git a/docs/content/api/changelog.md b/docs/content/api/changelog.md new file mode 100644 index 00000000..b98dc260 --- /dev/null +++ b/docs/content/api/changelog.md @@ -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 diff --git a/docs/content/api/endpoints.md b/docs/content/api/endpoints.md new file mode 100644 index 00000000..03ab7cb8 --- /dev/null +++ b/docs/content/api/endpoints.md @@ -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. \ No newline at end of file diff --git a/docs/content/api/errors.md b/docs/content/api/errors.md new file mode 100644 index 00000000..1e77d3a3 --- /dev/null +++ b/docs/content/api/errors.md @@ -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.| diff --git a/docs/content/api-documentation.md b/docs/content/api/legacy.md similarity index 83% rename from docs/content/api-documentation.md rename to docs/content/api/legacy.md index 84102551..46b4f2f2 100644 --- a/docs/content/api-documentation.md +++ b/docs/content/api/legacy.md @@ -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 +``` \ No newline at end of file diff --git a/docs/content/api/models.md b/docs/content/api/models.md new file mode 100644 index 00000000..fe0c7aef --- /dev/null +++ b/docs/content/api/models.md @@ -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| diff --git a/docs/content/api/reference.md b/docs/content/api/reference.md new file mode 100644 index 00000000..6001780e --- /dev/null +++ b/docs/content/api/reference.md @@ -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!