From 8a88b23021c39ffad60ca9e5410606b8d873616c Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 29 Sep 2021 22:30:20 -0400 Subject: [PATCH 01/24] feat(apiv2): stubs --- .gitignore | 1 + PluralKit.API/APIJsonExt.cs | 35 +++++++++ PluralKit.API/Controllers/PKControllerBase.cs | 33 ++++++++ .../Controllers/v1/MetaController.cs | 28 ------- .../Controllers/v2/GuildControllerV2.cs | 58 ++++++++++++++ .../Controllers/v2/MemberControllerV2.cs | 67 ++++++++++++++++ .../Controllers/v2/MiscControllerV2.cs | 40 ++++++++++ .../Controllers/v2/SwitchControllerV2.cs | 77 +++++++++++++++++++ .../Controllers/v2/SystemControllerV2.cs | 37 +++++++++ PluralKit.API/Startup.cs | 7 ++ 10 files changed, 355 insertions(+), 28 deletions(-) create mode 100644 PluralKit.API/APIJsonExt.cs create mode 100644 PluralKit.API/Controllers/PKControllerBase.cs create mode 100644 PluralKit.API/Controllers/v2/GuildControllerV2.cs create mode 100644 PluralKit.API/Controllers/v2/MemberControllerV2.cs create mode 100644 PluralKit.API/Controllers/v2/MiscControllerV2.cs create mode 100644 PluralKit.API/Controllers/v2/SwitchControllerV2.cs create mode 100644 PluralKit.API/Controllers/v2/SystemControllerV2.cs 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..46744297 --- /dev/null +++ b/PluralKit.API/APIJsonExt.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +using Newtonsoft.Json.Linq; + +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; + } + } +} \ 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..f28a3832 --- /dev/null +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -0,0 +1,33 @@ +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(); + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v1/MetaController.cs b/PluralKit.API/Controllers/v1/MetaController.cs index 098bd8a8..8bfae9e9 100644 --- a/PluralKit.API/Controllers/v1/MetaController.cs +++ b/PluralKit.API/Controllers/v1/MetaController.cs @@ -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/v2/GuildControllerV2.cs b/PluralKit.API/Controllers/v2/GuildControllerV2.cs new file mode 100644 index 00000000..ed0631cb --- /dev/null +++ b/PluralKit.API/Controllers/v2/GuildControllerV2.cs @@ -0,0 +1,58 @@ +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}")] + public class GuildControllerV2: PKControllerBase + { + public GuildControllerV2(IServiceProvider svc) : base(svc) { } + + + [HttpGet("systems/{system}/guilds/{guild_id}")] + public async Task SystemGuildGet(string system, ulong guild_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPatch("systems/{system}/guilds/{guild_id}")] + public async Task SystemGuildPatch(string system, ulong guild_id, [FromBody] JObject data) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpGet("members/{member}/guilds/{guild_id}")] + public async Task MemberGuildGet(string member, ulong guild_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPatch("members/{member}/guilds/{guild_id}")] + public async Task MemberGuildPatch(string member, ulong guild_id, [FromBody] JObject data) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + + } +} \ 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..abb65a4c --- /dev/null +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -0,0 +1,67 @@ +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}")] + public class MemberControllerV2: PKControllerBase + { + public MemberControllerV2(IServiceProvider svc) : base(svc) { } + + + [HttpGet("systems/{system}/members")] + public async Task GetSystemMembers(string system) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPost("members")] + public async Task MemberCreate([FromBody] JObject data) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpGet("members/{member}")] + public async Task MemberGet(string member) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPatch("members/{member}")] + public async Task MemberPatch(string member, [FromBody] JObject data) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpDelete("members/{member}")] + public async Task MemberDelete(string member) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/MiscControllerV2.cs b/PluralKit.API/Controllers/v2/MiscControllerV2.cs new file mode 100644 index 00000000..acabf6ea --- /dev/null +++ b/PluralKit.API/Controllers/v2/MiscControllerV2.cs @@ -0,0 +1,40 @@ +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}")] + public class MetaControllerV2: PKControllerBase + { + public MetaControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("meta")] + public async Task> Meta() + { + await using var conn = await _db.Obtain(); + var shards = await _repo.GetShards(conn); + + var o = new JObject(); + o.Add("shards", shards.ToJSON()); + + return Ok(o); + } + + [HttpGet("messages/{message_id}")] + public async Task MessageGet(ulong message_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + } +} \ 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..73e09bb2 --- /dev/null +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -0,0 +1,77 @@ +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}")] + public class SwitchControllerV2: PKControllerBase + { + public SwitchControllerV2(IServiceProvider svc) : base(svc) { } + + + [HttpGet("systems/{system}/switches")] + public async Task GetSystemSwitches(string system) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpGet("systems/{system}/fronters")] + public async Task GetSystemFronters(string system) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + + [HttpPost("systems/{system}/switches")] + public async Task SwitchCreate(string system, [FromBody] JObject data) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + + [HttpGet("systems/{system}/switches/{switch_id}")] + public async Task SwitchGet(string system, string switch_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPatch("systems/{system}/switches/{switch_id}")] + public async Task SwitchPatch(string system, [FromBody] JObject data) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpDelete("systems/{system}/switches/{switch_id}")] + public async Task SwitchDelete(string system, string switch_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + } +} \ 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..93e579c2 --- /dev/null +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -0,0 +1,37 @@ +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("{system}")] + public async Task SystemGet(string system) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPatch("{system}")] + public async Task SystemPatch(string system, [FromBody] JObject data) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + } +} \ No newline at end of file diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index db755045..22dce95c 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -117,6 +117,13 @@ 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.UseHttpsRedirection(); app.UseCors(opts => opts.AllowAnyMethod().AllowAnyOrigin().WithHeaders("Content-Type", "Authorization")); From 57722e035bec1726a2307a41bb6bf3aa56e4a8b4 Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 1 Oct 2021 21:50:01 -0400 Subject: [PATCH 02/24] feat(apiv2): group stubs, authentication middleware, /systems/:id endpoint --- PluralKit.API/Controllers/PKControllerBase.cs | 28 ++++++ .../Controllers/v2/GroupControllerV2.cs | 66 ++++++++++++++ .../Controllers/v2/GroupMemberControllerV2.cs | 90 +++++++++++++++++++ .../Controllers/v2/SystemControllerV2.cs | 11 ++- .../AuthorizationTokenHandlerMiddleware.cs | 36 ++++++++ PluralKit.API/Startup.cs | 2 + 6 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 PluralKit.API/Controllers/v2/GroupControllerV2.cs create mode 100644 PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs create mode 100644 PluralKit.API/Middleware/AuthorizationTokenHandlerMiddleware.cs diff --git a/PluralKit.API/Controllers/PKControllerBase.cs b/PluralKit.API/Controllers/PKControllerBase.cs index f28a3832..00856775 100644 --- a/PluralKit.API/Controllers/PKControllerBase.cs +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -29,5 +29,33 @@ namespace PluralKit.API _db = svc.GetRequiredService(); _repo = svc.GetRequiredService(); } + + protected Task ResolveSystem(string systemRef) + { + if (systemRef == "@me") + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) return null; + 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 null; + } + + public LookupContext LookupContextFor(PKSystem target) + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + if (systemId == null) return LookupContext.ByNonOwner; + return target.Id == (SystemId)systemId ? LookupContext.ByOwner : LookupContext.ByNonOwner; + } } } \ 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..ab1ff635 --- /dev/null +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -0,0 +1,66 @@ +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}")] + public class GroupControllerV2: PKControllerBase + { + public GroupControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("systems/{system_id}/groups")] + public async Task GetSystemGroups(string system_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPost("groups")] + public async Task GroupCreate(string group_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpGet("groups/{group_id}")] + public async Task GroupGet(string group_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPatch("groups/{group_id}")] + public async Task GroupPatch(string group_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpDelete("groups/{group_id}")] + public async Task GroupDelete(string group_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + + } +} \ 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..97a971c7 --- /dev/null +++ b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs @@ -0,0 +1,90 @@ +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 GroupMemberControllerV2: PKControllerBase + { + public GroupMemberControllerV2(IServiceProvider svc) : base(svc) { } + + [HttpGet("groups/{group_id}/members")] + public async Task GetGroupMembers(string group_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpGet("members/{member_id}/groups")] + public async Task GetMemberGroups(string member_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPut("groups/{group_id}/members/{member_id}")] + public async Task GroupMemberPut(string group_id, string member_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPut("groups/{group_id}/members")] + public async Task GroupMembersPut(string group_id, [FromBody] JArray members) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpDelete("groups/{group_id}/members/{member_id}")] + public async Task GroupMemberDelete(string group_id, string member_id) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpDelete("groups/{group_id}/members")] + public async Task GroupMembersDelete(string group_id, [FromBody] JArray members) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpPut("members/{member_id}/groups")] + public async Task MemberGroupsPut(string member_id, [FromBody] JArray groups) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + [HttpDelete("members/{member_id}/groups")] + public async Task MemberGroupsDelete(string member_id, [FromBody] JArray groups) + { + return new ObjectResult("Unimplemented") + { + StatusCode = 501 + }; + } + + } +} \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/SystemControllerV2.cs b/PluralKit.API/Controllers/v2/SystemControllerV2.cs index 93e579c2..f58b1c02 100644 --- a/PluralKit.API/Controllers/v2/SystemControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -16,13 +16,12 @@ namespace PluralKit.API { public SystemControllerV2(IServiceProvider svc) : base(svc) { } - [HttpGet("{system}")] - public async Task SystemGet(string system) + [HttpGet("{systemRef}")] + public async Task SystemGet(string systemRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var system = await ResolveSystem(systemRef); + if (system == null) return NotFound(); + else return Ok(system.ToJson(LookupContextFor(system))); } [HttpPatch("{system}")] 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 22dce95c..a19b67de 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -124,6 +124,8 @@ namespace PluralKit.API return next(); }); + app.UseMiddleware(); + //app.UseHttpsRedirection(); app.UseCors(opts => opts.AllowAnyMethod().AllowAnyOrigin().WithHeaders("Content-Type", "Authorization")); From c3e51d1a489b5e067ffe74fcdbaef2195d24c3de Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 12 Oct 2021 02:19:42 -0400 Subject: [PATCH 03/24] feat(apiv2): v2 json models --- .../Models/ModelTypes/APIVersion.cs | 8 ++ PluralKit.Core/Models/PKGroup.cs | 6 +- PluralKit.Core/Models/PKMember.cs | 71 ++++++++---- PluralKit.Core/Models/PKSystem.cs | 45 ++++++-- PluralKit.Core/Models/Patch/MemberPatch.cs | 106 +++++++++++++----- PluralKit.Core/Models/Patch/SystemPatch.cs | 48 ++++++-- 6 files changed, 215 insertions(+), 69 deletions(-) create mode 100644 PluralKit.Core/Models/ModelTypes/APIVersion.cs 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/PKGroup.cs b/PluralKit.Core/Models/PKGroup.cs index a08425fe..e34b1585 100644 --- a/PluralKit.Core/Models/PKGroup.cs +++ b/PluralKit.Core/Models/PKGroup.cs @@ -61,12 +61,16 @@ 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("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..26c41fc7 100644 --- a/PluralKit.Core/Models/PKMember.cs +++ b/PluralKit.Core/Models/PKMember.cs @@ -106,13 +106,17 @@ 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); o.Add("name", member.NameFor(ctx)); + + if (systemStr != null && v == APIVersion.V2) + o.Add("system", systemStr); + // 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); @@ -121,32 +125,59 @@ namespace PluralKit.Core 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..3e2f2b38 100644 --- a/PluralKit.Core/Models/PKSystem.cs +++ b/PluralKit.Core/Models/PKSystem.cs @@ -66,7 +66,7 @@ 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); @@ -77,13 +77,42 @@ 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("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/MemberPatch.cs b/PluralKit.Core/Models/Patch/MemberPatch.cs index b69577cd..d18c8772 100644 --- a/PluralKit.Core/Models/Patch/MemberPatch.cs +++ b/PluralKit.Core/Models/Patch/MemberPatch.cs @@ -82,7 +82,7 @@ namespace PluralKit.Core #nullable disable - public static MemberPatch FromJSON(JObject o) + public static MemberPatch FromJSON(JObject o, APIVersion v = APIVersion.V1) { var patch = new MemberPatch(); @@ -108,38 +108,82 @@ namespace PluralKit.Core 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 = o.ParsePrivacy("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 = 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"); + } + 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 = privacy.ParsePrivacy("visibility"); + + if (privacy.ContainsKey("name_privacy")) + patch.NamePrivacy = privacy.ParsePrivacy("name_privacy"); + + if (privacy.ContainsKey("description_privacy")) + patch.DescriptionPrivacy = privacy.ParsePrivacy("description_privacy"); + + if (privacy.ContainsKey("avatar_privacy")) + patch.AvatarPrivacy = privacy.ParsePrivacy("avatar_privacy"); + + if (privacy.ContainsKey("birthday_privacy")) + patch.BirthdayPrivacy = privacy.ParsePrivacy("birthday_privacy"); + + if (privacy.ContainsKey("pronoun_privacy")) + patch.PronounPrivacy = privacy.ParsePrivacy("pronoun_privacy"); + + if (privacy.ContainsKey("metadata_privacy")) + patch.MetadataPrivacy = privacy.ParsePrivacy("metadata_privacy"); + } + + break; + } } return patch; diff --git a/PluralKit.Core/Models/Patch/SystemPatch.cs b/PluralKit.Core/Models/Patch/SystemPatch.cs index a2ba3b3e..bff45971 100644 --- a/PluralKit.Core/Models/Patch/SystemPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemPatch.cs @@ -72,7 +72,9 @@ namespace PluralKit.Core throw new ValidationError("avatar_url"); } - 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,44 @@ 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 = 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"); + + 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 = privacy.ParsePrivacy("description_privacy"); + + if (privacy.ContainsKey("member_list_privacy")) + patch.DescriptionPrivacy = privacy.ParsePrivacy("member_list_privacy"); + + if (privacy.ContainsKey("front_privacy")) + patch.DescriptionPrivacy = privacy.ParsePrivacy("front_privacy"); + + if (privacy.ContainsKey("front_history_privacy")) + patch.DescriptionPrivacy = privacy.ParsePrivacy("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; } } From 9d47bfe0d83d9f8248b7cf1666c17ed4d8e8186d Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 12 Oct 2021 03:01:02 -0400 Subject: [PATCH 04/24] feat(apiv2): basic error handling --- PluralKit.API/Errors.cs | 51 +++++++++++++++++++++++++ PluralKit.API/Startup.cs | 25 +++++++++++- PluralKit.Core/Modules/LoggingModule.cs | 9 ++++- 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 PluralKit.API/Errors.cs diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs new file mode 100644 index 00000000..de0779f2 --- /dev/null +++ b/PluralKit.API/Errors.cs @@ -0,0 +1,51 @@ +using System; + +using Newtonsoft.Json.Linq; + +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 + { + public ModelParseError() : base(400, 0, "Error parsing JSON model") + { + // todo + } + + public new JObject ToJson() + { + var j = base.ToJson(); + + return j; + } + } + + public static class APIErrors + { + public static PKError GenericBadRequest = new(400, 0, "400: Bad Request"); + public static PKError SystemNotFound = new(404, 20001, "System not found."); + public static PKError MemberNotFound = new(404, 20002, "Member not found."); + public static PKError GroupNotFound = new(404, 20003, "Group not found."); + 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 Unimplemented = new(501, 50001, "Unimplemented"); + } +} \ No newline at end of file diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index a19b67de..f14dafba 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 @@ -91,7 +97,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(); @@ -124,6 +130,23 @@ namespace PluralKit.API return next(); }); + app.UseExceptionHandler(handler => handler.Run(async ctx => + { + var exc = ctx.Features.Get(); + if (exc.Error is not PKError) + { + ctx.Response.StatusCode = 500; + await ctx.Response.WriteAsync("{\"message\":\"500: Internal Server Error\",\"code\":0}"); + 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(); 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() From 11620d94c8263dbf66b659aed6d8cac8c60487bc Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 12 Oct 2021 04:25:06 -0400 Subject: [PATCH 05/24] feat(apiv2): actually add UUIDs to JSON models --- PluralKit.Core/Models/PKGroup.cs | 1 + PluralKit.Core/Models/PKMember.cs | 11 ++++++++--- PluralKit.Core/Models/PKSystem.cs | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/PluralKit.Core/Models/PKGroup.cs b/PluralKit.Core/Models/PKGroup.cs index e34b1585..989a64eb 100644 --- a/PluralKit.Core/Models/PKGroup.cs +++ b/PluralKit.Core/Models/PKGroup.cs @@ -66,6 +66,7 @@ namespace PluralKit.Core var o = new JObject(); o.Add("id", group.Hid); + o.Add("uuid", group.Uuid.ToString()); o.Add("name", group.Name); if (systemStr != null) diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs index 26c41fc7..0ed739cb 100644 --- a/PluralKit.Core/Models/PKMember.cs +++ b/PluralKit.Core/Models/PKMember.cs @@ -112,10 +112,15 @@ namespace PluralKit.Core var o = new JObject(); o.Add("id", member.Hid); - o.Add("name", member.NameFor(ctx)); - if (systemStr != null && v == APIVersion.V2) - o.Add("system", systemStr); + 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); diff --git a/PluralKit.Core/Models/PKSystem.cs b/PluralKit.Core/Models/PKSystem.cs index 3e2f2b38..4d770783 100644 --- a/PluralKit.Core/Models/PKSystem.cs +++ b/PluralKit.Core/Models/PKSystem.cs @@ -70,6 +70,9 @@ namespace PluralKit.Core { 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); From e2a56a198f8938165e6a95dc063746c54625693e Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 12 Oct 2021 05:17:54 -0400 Subject: [PATCH 06/24] feat(apiv2): GET endpoints except guilds - ResolveT methods in ControllerBase - ContextFor methods in ControllerBase --- PluralKit.API/Controllers/PKControllerBase.cs | 43 +++++++- .../Controllers/v2/GroupControllerV2.cs | 38 ++++--- .../Controllers/v2/GroupMemberControllerV2.cs | 51 +++++++--- .../Controllers/v2/MemberControllerV2.cs | 38 ++++--- .../Controllers/v2/MiscControllerV2.cs | 23 ++++- .../Controllers/v2/SwitchControllerV2.cs | 99 ++++++++++++++++--- .../Controllers/v2/SystemControllerV2.cs | 2 +- PluralKit.API/Errors.cs | 6 ++ .../Repository/ModelRepository.GroupMember.cs | 9 ++ 9 files changed, 249 insertions(+), 60 deletions(-) diff --git a/PluralKit.API/Controllers/PKControllerBase.cs b/PluralKit.API/Controllers/PKControllerBase.cs index 00856775..cd3db2f5 100644 --- a/PluralKit.API/Controllers/PKControllerBase.cs +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -35,7 +35,8 @@ namespace PluralKit.API if (systemRef == "@me") { HttpContext.Items.TryGetValue("SystemId", out var systemId); - if (systemId == null) return null; + if (systemId == null) + throw APIErrors.GenericAuthError; return _repo.GetSystem((SystemId)systemId); } @@ -51,11 +52,47 @@ namespace PluralKit.API return null; } - public LookupContext LookupContextFor(PKSystem target) + 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 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 null; + } + + public LookupContext ContextFor(PKSystem system) { HttpContext.Items.TryGetValue("SystemId", out var systemId); if (systemId == null) return LookupContext.ByNonOwner; - return target.Id == (SystemId)systemId ? LookupContext.ByOwner : LookupContext.ByNonOwner; + return ((SystemId)systemId) == system.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner; + } + + public 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; + } + + public 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/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs index ab1ff635..4a1f32ca 100644 --- a/PluralKit.API/Controllers/v2/GroupControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -16,13 +17,23 @@ namespace PluralKit.API { public GroupControllerV2(IServiceProvider svc) : base(svc) { } - [HttpGet("systems/{system_id}/groups")] - public async Task GetSystemGroups(string system_id) + [HttpGet("systems/{systemRef}/groups")] + public async Task GetSystemGroups(string systemRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var system = await ResolveSystem(systemRef); + if (system == null) + throw APIErrors.SystemNotFound; + + var ctx = this.ContextFor(system); + + if (!system.GroupListPrivacy.CanAccess(User.ContextFor(system))) + throw APIErrors.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")] @@ -34,13 +45,16 @@ namespace PluralKit.API }; } - [HttpGet("groups/{group_id}")] - public async Task GroupGet(string group_id) + [HttpGet("groups/{groupRef}")] + public async Task GroupGet(string groupRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var group = await ResolveGroup(groupRef); + if (group == null) + throw APIErrors.GroupNotFound; + + var system = await _repo.GetSystem(group.System); + + return Ok(group.ToJson(this.ContextFor(group), systemStr: system.Hid)); } [HttpPatch("groups/{group_id}")] diff --git a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs index 97a971c7..6b0ce6c9 100644 --- a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs @@ -1,10 +1,13 @@ using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; +using PluralKit.Core; + namespace PluralKit.API { [ApiController] @@ -14,22 +17,46 @@ namespace PluralKit.API { public GroupMemberControllerV2(IServiceProvider svc) : base(svc) { } - [HttpGet("groups/{group_id}/members")] - public async Task GetGroupMembers(string group_id) + [HttpGet("groups/{groupRef}/members")] + public async Task GetGroupMembers(string groupRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var group = await ResolveGroup(groupRef); + if (group == null) + throw APIErrors.GroupNotFound; + + var ctx = this.ContextFor(group); + + if (!group.ListPrivacy.CanAccess(ctx)) + throw APIErrors.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); } - [HttpGet("members/{member_id}/groups")] - public async Task GetMemberGroups(string member_id) + [HttpGet("members/{memberRef}/groups")] + public async Task GetMemberGroups(string memberRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var member = await ResolveMember(memberRef); + var ctx = this.ContextFor(member); + + var system = await _repo.GetSystem(member.System); + if (!system.GroupListPrivacy.CanAccess(ctx)) + throw APIErrors.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); } [HttpPut("groups/{group_id}/members/{member_id}")] diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs index abb65a4c..5b97ce78 100644 --- a/PluralKit.API/Controllers/v2/MemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -17,13 +18,23 @@ namespace PluralKit.API public MemberControllerV2(IServiceProvider svc) : base(svc) { } - [HttpGet("systems/{system}/members")] - public async Task GetSystemMembers(string system) + [HttpGet("systems/{systemRef}/members")] + public async Task GetSystemMembers(string systemRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var system = await ResolveSystem(systemRef); + if (system == null) + throw APIErrors.SystemNotFound; + + var ctx = this.ContextFor(system); + + if (!system.MemberListPrivacy.CanAccess(this.ContextFor(system))) + throw APIErrors.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")] @@ -35,13 +46,16 @@ namespace PluralKit.API }; } - [HttpGet("members/{member}")] - public async Task MemberGet(string member) + [HttpGet("members/{memberRef}")] + public async Task MemberGet(string memberRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var member = await ResolveMember(memberRef); + if (member == null) + throw APIErrors.MemberNotFound; + + var system = await _repo.GetSystem(member.System); + + return Ok(member.ToJson(this.ContextFor(member), systemStr: system.Hid, v: APIVersion.V2)); } [HttpPatch("members/{member}")] diff --git a/PluralKit.API/Controllers/v2/MiscControllerV2.cs b/PluralKit.API/Controllers/v2/MiscControllerV2.cs index acabf6ea..1e015b37 100644 --- a/PluralKit.API/Controllers/v2/MiscControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MiscControllerV2.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; +using NodaTime; + using PluralKit.Core; namespace PluralKit.API @@ -28,12 +30,25 @@ namespace PluralKit.API return Ok(o); } - [HttpGet("messages/{message_id}")] - public async Task MessageGet(ulong message_id) + [HttpGet("messages/{messageId}")] + public async Task> MessageGet(ulong messageId) { - return new ObjectResult("Unimplemented") + var msg = await _db.Execute(c => _repo.GetMessage(c, messageId)); + if (msg == null) + throw APIErrors.MessageNotFound; + + var ctx = this.ContextFor(msg.System); + + // todo: don't rely on v1 stuff + return new MessageReturn { - StatusCode = 501 + 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() }; } } diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs index 73e09bb2..8143104f 100644 --- a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -1,14 +1,28 @@ 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 { + public struct SwitchesReturnNew + { + [JsonProperty("timestamp")] public Instant Timestamp { get; set; } + [JsonProperty("id")] public Guid Uuid { get; set; } + [JsonProperty("members")] public IEnumerable Members { get; set; } + } + [ApiController] [ApiVersion("2.0")] [Route("v{version:apiVersion}")] @@ -17,22 +31,57 @@ namespace PluralKit.API public SwitchControllerV2(IServiceProvider svc) : base(svc) { } - [HttpGet("systems/{system}/switches")] - public async Task GetSystemSwitches(string system) + [HttpGet("systems/{systemRef}/switches")] + public async Task GetSystemSwitches(string systemRef, [FromQuery(Name = "before")] Instant? before, [FromQuery(Name = "limit")] int? limit) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var system = await ResolveSystem(systemRef); + if (system == null) + throw APIErrors.SystemNotFound; + + var ctx = this.ContextFor(system); + + if (!system.FrontHistoryPrivacy.CanAccess(ctx)) + throw APIErrors.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/{system}/fronters")] - public async Task GetSystemFronters(string system) + [HttpGet("systems/{systemRef}/fronters")] + public async Task GetSystemFronters(string systemRef) { - return new ObjectResult("Unimplemented") + var system = await ResolveSystem(systemRef); + if (system == null) + throw APIErrors.SystemNotFound; + + var ctx = this.ContextFor(system); + + if (!system.FrontPrivacy.CanAccess(ctx)) + throw APIErrors.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 FrontersReturn { - StatusCode = 501 - }; + Timestamp = sw.Timestamp, + Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync() + }); } @@ -46,13 +95,31 @@ namespace PluralKit.API } - [HttpGet("systems/{system}/switches/{switch_id}")] - public async Task SwitchGet(string system, string switch_id) + [HttpGet("systems/{systemRef}/switches/{switchRef}")] + public async Task SwitchGet(string systemRef, string switchRef) { - return new ObjectResult("Unimplemented") + if (!Guid.TryParse(switchRef, out var switchId)) + throw APIErrors.SwitchNotFound; + + var system = await ResolveSystem(systemRef); + if (system == null) + throw APIErrors.SystemNotFound; + + var ctx = this.ContextFor(system); + + if (!system.FrontHistoryPrivacy.CanAccess(ctx)) + throw APIErrors.UnauthorizedFrontHistory; + + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null) + throw APIErrors.SwitchNotFound; + + var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); + return Ok(new FrontersReturn { - StatusCode = 501 - }; + Timestamp = sw.Timestamp, + Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync() + }); } [HttpPatch("systems/{system}/switches/{switch_id}")] diff --git a/PluralKit.API/Controllers/v2/SystemControllerV2.cs b/PluralKit.API/Controllers/v2/SystemControllerV2.cs index f58b1c02..b7432166 100644 --- a/PluralKit.API/Controllers/v2/SystemControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -21,7 +21,7 @@ namespace PluralKit.API { var system = await ResolveSystem(systemRef); if (system == null) return NotFound(); - else return Ok(system.ToJson(LookupContextFor(system))); + else return Ok(system.ToJson(this.ContextFor(system), v: APIVersion.V2)); } [HttpPatch("{system}")] diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index de0779f2..4320eadd 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -41,11 +41,17 @@ namespace PluralKit.API public static class APIErrors { 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 GroupNotFound = new(404, 20003, "Group not found."); + public static PKError MessageNotFound = new(404, 20004, "Message not found."); + public static PKError SwitchNotFound = new(404, 20005, "Switch not found."); 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, 30004, "Unauthorized to view front history."); public static PKError Unimplemented = new(501, 50001, "Unimplemented"); } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs b/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs index 992b0ed5..a42ffb70 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) { From c164fad2ac803c29d8d6d4d76edc734a758c8c37 Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 12 Oct 2021 06:18:54 -0400 Subject: [PATCH 07/24] fix(apiv2): correctly apply privacy settings on switch get --- PluralKit.API/Controllers/v2/SwitchControllerV2.cs | 8 ++++---- PluralKit.API/Errors.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs index 8143104f..56660a0f 100644 --- a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -105,13 +105,13 @@ namespace PluralKit.API if (system == null) throw APIErrors.SystemNotFound; + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null || system.Id != sw.System) + throw APIErrors.SwitchNotFound; + var ctx = this.ContextFor(system); if (!system.FrontHistoryPrivacy.CanAccess(ctx)) - throw APIErrors.UnauthorizedFrontHistory; - - var sw = await _repo.GetSwitchByUuid(switchId); - if (sw == null) throw APIErrors.SwitchNotFound; var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index 4320eadd..600c63de 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -46,7 +46,7 @@ namespace PluralKit.API public static PKError MemberNotFound = new(404, 20002, "Member not found."); public static PKError GroupNotFound = new(404, 20003, "Group not found."); public static PKError MessageNotFound = new(404, 20004, "Message not found."); - public static PKError SwitchNotFound = new(404, 20005, "Switch not found."); + public static PKError SwitchNotFound = new(404, 20005, "Switch not found, switch is associated to different system, or unauthorized to view front history."); 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"); From 0aefafb62dc9efd52dc64d8ea9b92aec563ccf39 Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 12 Oct 2021 06:41:38 -0400 Subject: [PATCH 08/24] feat(apiv2): delete endpoints --- .../Controllers/v2/GroupControllerV2.cs | 21 ++++++++++++------- .../Controllers/v2/MemberControllerV2.cs | 21 ++++++++++++------- .../Controllers/v2/SwitchControllerV2.cs | 19 +++++++++++------ PluralKit.API/Errors.cs | 2 ++ 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs index 4a1f32ca..d29a31f9 100644 --- a/PluralKit.API/Controllers/v2/GroupControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -66,15 +66,20 @@ namespace PluralKit.API }; } - [HttpDelete("groups/{group_id}")] - public async Task GroupDelete(string group_id) + [HttpDelete("groups/{groupRef}")] + public async Task GroupDelete(string groupRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var group = await ResolveGroup(groupRef); + if (group == null) + throw APIErrors.GroupNotFound; + + var system = await ResolveSystem("@me"); + if (system.Id != group.System) + throw APIErrors.NotOwnGroupError; + + await _repo.DeleteGroup(group.Id); + + return NoContent(); } - - } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs index 5b97ce78..38b958d4 100644 --- a/PluralKit.API/Controllers/v2/MemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -67,15 +67,20 @@ namespace PluralKit.API }; } - [HttpDelete("members/{member}")] - public async Task MemberDelete(string member) + [HttpDelete("members/{memberRef}")] + public async Task MemberDelete(string memberRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var member = await ResolveMember(memberRef); + if (member == null) + throw APIErrors.MemberNotFound; + + var system = await ResolveSystem("@me"); + if (system.Id != member.System) + throw APIErrors.NotOwnMemberError; + + await _repo.DeleteMember(member.Id); + + return NoContent(); } - - } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs index 56660a0f..f7c2f88c 100644 --- a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -131,13 +131,20 @@ namespace PluralKit.API }; } - [HttpDelete("systems/{system}/switches/{switch_id}")] - public async Task SwitchDelete(string system, string switch_id) + [HttpDelete("systems/@me/switches/{switchRef}")] + public async Task SwitchDelete(string switchRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + if (!Guid.TryParse(switchRef, out var switchId)) + throw APIErrors.SwitchNotFound; + + var system = await ResolveSystem("@me"); + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null || system.Id != sw.System) + throw APIErrors.SwitchNotFound; + + await _repo.DeleteSwitch(sw.Id); + + return NoContent(); } } diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index 600c63de..43fee534 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -52,6 +52,8 @@ namespace PluralKit.API 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, 30004, "Unauthorized to view front history."); + public static PKError NotOwnMemberError = new(403, 40001, "Target member is not part of your system."); + public static PKError NotOwnGroupError = new(403, 40002, "Target group is not part of your system."); public static PKError Unimplemented = new(501, 50001, "Unimplemented"); } } \ No newline at end of file From 2d72fd6aa12473c6985a3207c18ca80596f5824f Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 12 Oct 2021 08:10:20 -0400 Subject: [PATCH 09/24] fix(apiv2): don't ISE on invalid entity references --- PluralKit.API/Controllers/PKControllerBase.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PluralKit.API/Controllers/PKControllerBase.cs b/PluralKit.API/Controllers/PKControllerBase.cs index cd3db2f5..50172df7 100644 --- a/PluralKit.API/Controllers/PKControllerBase.cs +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -49,7 +49,7 @@ namespace PluralKit.API if (_shortIdRegex.IsMatch(systemRef)) return _repo.GetSystemByHid(systemRef); - return null; + return Task.FromResult(null); } protected Task ResolveMember(string memberRef) @@ -60,7 +60,7 @@ namespace PluralKit.API if (_shortIdRegex.IsMatch(memberRef)) return _repo.GetMemberByHid(memberRef); - return null; + return Task.FromResult(null); } protected Task ResolveGroup(string groupRef) @@ -71,7 +71,7 @@ namespace PluralKit.API if (_shortIdRegex.IsMatch(groupRef)) return _repo.GetGroupByHid(groupRef); - return null; + return Task.FromResult(null); } public LookupContext ContextFor(PKSystem system) From a20276f6e64630cd0206486c2ea6f5a2ca5eeebe Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 12 Oct 2021 08:33:31 -0400 Subject: [PATCH 10/24] feat(apiv2): group member endpoints --- .../Controllers/v2/GroupMemberControllerV2.cs | 204 +++++++++++++++--- PluralKit.API/Errors.cs | 5 +- 2 files changed, 176 insertions(+), 33 deletions(-) diff --git a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs index 6b0ce6c9..48cf7787 100644 --- a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Dapper; + using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; @@ -59,58 +62,195 @@ namespace PluralKit.API return Ok(o); } - [HttpPut("groups/{group_id}/members/{member_id}")] - public async Task GroupMemberPut(string group_id, string member_id) + [HttpPut("groups/{groupRef}/members/{memberRef}")] + public async Task GroupMemberPut(string groupRef, string memberRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var system = await ResolveSystem("@me"); + + var group = await ResolveGroup(groupRef); + if (group == null) + throw APIErrors.GroupNotFound; + if (group.System != system.Id) + throw APIErrors.NotOwnGroupError; + + var member = await ResolveMember(memberRef); + Console.WriteLine(member); + if (member == null) + throw APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.NotOwnMemberError; + + var existingMembers = await _repo.GetGroupMembers(group.Id).Select(x => x.Id).ToListAsync(); + if (!existingMembers.Contains(member.Id)) + await _repo.AddMembersToGroup(group.Id, new List() { member.Id }); + + return NoContent(); } - [HttpPut("groups/{group_id}/members")] - public async Task GroupMembersPut(string group_id, [FromBody] JArray members) + [HttpPut("groups/{groupRef}/members")] + public async Task GroupMembersPut(string groupRef, [FromBody] JArray memberRefs) { - return new ObjectResult("Unimplemented") + if (memberRefs.Count == 0) + throw APIErrors.GenericBadRequest; + + var system = await ResolveSystem("@me"); + + var group = await ResolveGroup(groupRef); + if (group == null) + throw APIErrors.GroupNotFound; + if (group.System != system.Id) + throw APIErrors.NotOwnGroupError; + + var members = new List(); + + foreach (var JmemberRef in memberRefs) { - StatusCode = 501 - }; + var memberRef = JmemberRef.Value(); + var member = await ResolveMember(memberRef); + + if (member == null) + throw APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.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(); } - [HttpDelete("groups/{group_id}/members/{member_id}")] - public async Task GroupMemberDelete(string group_id, string member_id) + [HttpDelete("groups/{groupRef}/members/{memberRef}")] + public async Task GroupMemberDelete(string groupRef, string memberRef) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var system = await ResolveSystem("@me"); + + var group = await ResolveGroup(groupRef); + if (group == null) + throw APIErrors.GroupNotFound; + if (group.System != system.Id) + throw APIErrors.NotOwnGroupError; + + var member = await ResolveMember(memberRef); + if (member == null) + throw APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.NotOwnMemberError; + + await _repo.RemoveMembersFromGroup(group.Id, new List() { member.Id }); + + return NoContent(); } - [HttpDelete("groups/{group_id}/members")] - public async Task GroupMembersDelete(string group_id, [FromBody] JArray members) + [HttpDelete("groups/{groupRef}/members")] + public async Task GroupMembersDelete(string groupRef, [FromBody] JArray memberRefs) { - return new ObjectResult("Unimplemented") + if (memberRefs.Count == 0) + throw APIErrors.GenericBadRequest; + + var system = await ResolveSystem("@me"); + + var group = await ResolveGroup(groupRef); + if (group == null) + throw APIErrors.GroupNotFound; + if (group.System != system.Id) + throw APIErrors.NotOwnGroupError; + + var members = new List(); + + foreach (var JmemberRef in memberRefs) { - StatusCode = 501 - }; + var memberRef = JmemberRef.Value(); + var member = await ResolveMember(memberRef); + + if (member == null) + throw APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.NotOwnMemberError; + + members.Add(member.Id); + } + + await _repo.RemoveMembersFromGroup(group.Id, members); + + return NoContent(); } - [HttpPut("members/{member_id}/groups")] - public async Task MemberGroupsPut(string member_id, [FromBody] JArray groups) + [HttpPut("members/{memberRef}/groups")] + public async Task MemberGroupsPut(string memberRef, [FromBody] JArray groupRefs) { - return new ObjectResult("Unimplemented") + if (groupRefs.Count == 0) + throw APIErrors.GenericBadRequest; + + var system = await ResolveSystem("@me"); + + var member = await ResolveMember(memberRef); + if (member == null) + throw APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.NotOwnMemberError; + + var groups = new List(); + + foreach (var JgroupRef in groupRefs) { - StatusCode = 501 - }; + var groupRef = JgroupRef.Value(); + var group = await ResolveGroup(groupRef); + + if (group == null) + throw APIErrors.GroupNotFound; + if (group.System != system.Id) + throw APIErrors.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(); } - [HttpDelete("members/{member_id}/groups")] - public async Task MemberGroupsDelete(string member_id, [FromBody] JArray groups) + [HttpDelete("members/{memberRef}/groups")] + public async Task MemberGroupsDelete(string memberRef, [FromBody] JArray groupRefs) { - return new ObjectResult("Unimplemented") + if (groupRefs.Count == 0) + throw APIErrors.GenericBadRequest; + + var system = await ResolveSystem("@me"); + + var member = await ResolveMember(memberRef); + if (member == null) + throw APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.NotOwnMemberError; + + var groups = new List(); + + foreach (var JgroupRef in groupRefs) { - StatusCode = 501 - }; + var groupRef = JgroupRef.Value(); + var group = await ResolveGroup(groupRef); + + if (group == null) + throw APIErrors.GroupNotFound; + if (group.System != system.Id) + throw APIErrors.NotOwnGroupErrorWithRef(groupRef); + + groups.Add(group.Id); + } + + await _repo.RemoveGroupsFromMember(member.Id, groups); + + return NoContent(); } } diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index 43fee534..18d43edc 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -51,9 +51,12 @@ namespace PluralKit.API 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, 30004, "Unauthorized to view front history."); + public static PKError UnauthorizedFrontHistory = new(403, 30005, "Unauthorized to view front history."); public static PKError NotOwnMemberError = new(403, 40001, "Target member is not part of your system."); public static PKError NotOwnGroupError = new(403, 40002, "Target group is not part of your system."); + // todo: somehow add the memberRef to the JSON + public static PKError NotOwnMemberErrorWithRef(string memberRef) => new(403, 40003, $"Member '{memberRef}' is not part of your system."); + public static PKError NotOwnGroupErrorWithRef(string groupRef) => new(403, 40004, $"Group '{groupRef}' is not part of your system."); public static PKError Unimplemented = new(501, 50001, "Unimplemented"); } } \ No newline at end of file From eb05cbf76c7a83b3ffac656914ecf1362debb8bd Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 12 Oct 2021 08:34:28 -0400 Subject: [PATCH 11/24] feat(apiv2): ignore exception caused by invalid user-provided JSON return 400 bad request instead --- PluralKit.API/Startup.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index f14dafba..67626ffa 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -133,6 +133,15 @@ namespace PluralKit.API 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 is InvalidCastException && exc.Error.Message.Contains("Newtonsoft.Json")) + { + 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; From f602f22a3d1fad8c8f9379f4783bb974b3f5ce8c Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 13 Oct 2021 01:02:34 -0400 Subject: [PATCH 12/24] feat(apiv2): guild endpoints --- .../Controllers/v2/GuildControllerV2.cs | 123 ++++++++++++++---- PluralKit.API/Errors.cs | 13 +- .../Repository/ModelRepository.Guild.cs | 24 +++- PluralKit.Core/Models/MemberGuildSettings.cs | 15 +++ .../Models/Patch/MemberGuildPatch.cs | 25 ++++ .../Models/Patch/SystemGuildPatch.cs | 30 +++++ PluralKit.Core/Models/SystemGuildSettings.cs | 45 +++++++ 7 files changed, 242 insertions(+), 33 deletions(-) diff --git a/PluralKit.API/Controllers/v2/GuildControllerV2.cs b/PluralKit.API/Controllers/v2/GuildControllerV2.cs index ed0631cb..e3abd0de 100644 --- a/PluralKit.API/Controllers/v2/GuildControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GuildControllerV2.cs @@ -17,40 +17,119 @@ namespace PluralKit.API public GuildControllerV2(IServiceProvider svc) : base(svc) { } - [HttpGet("systems/{system}/guilds/{guild_id}")] - public async Task SystemGuildGet(string system, ulong guild_id) + [HttpGet("systems/@me/guilds/{guild_id}")] + public async Task SystemGuildGet(ulong guild_id) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var system = await ResolveSystem("@me"); + var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false); + if (settings == null) + throw APIErrors.SystemGuildNotFound; + + PKMember member = null; + if (settings.AutoproxyMember != null) + member = await _repo.GetMember(settings.AutoproxyMember.Value); + + return Ok(settings.ToJson(member?.Hid)); } - [HttpPatch("systems/{system}/guilds/{guild_id}")] - public async Task SystemGuildPatch(string system, ulong guild_id, [FromBody] JObject data) + [HttpPatch("systems/@me/guilds/{guild_id}")] + public async Task DoSystemGuildPatch(ulong guild_id, [FromBody] JObject data) { - return new ObjectResult("Unimplemented") + var system = await ResolveSystem("@me"); + var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false); + if (settings == null) + throw APIErrors.SystemGuildNotFound; + + MemberId? memberId = null; + if (data.ContainsKey("autoproxy_member")) { - StatusCode = 501 - }; + if (data["autoproxy_member"].Type != JTokenType.Null) + { + var member = await ResolveMember(data.Value("autoproxy_member")); + if (member == null) + throw APIErrors.MemberNotFound; + + memberId = member.Id; + } + } + else + memberId = settings.AutoproxyMember; + + SystemGuildPatch patch = null; + try + { + patch = SystemGuildPatch.FromJson(data, memberId); + patch.AssertIsValid(); + } + catch (ValidationError e) + { + // todo + return BadRequest(e.Message); + } + + // 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 APIErrors.MissingAutoproxyMember; + } + else if (settings.AutoproxyMode == AutoproxyMode.Member) + throw APIErrors.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/{member}/guilds/{guild_id}")] - public async Task MemberGuildGet(string member, ulong guild_id) + [HttpGet("members/{memberRef}/guilds/{guild_id}")] + public async Task MemberGuildGet(string memberRef, ulong guild_id) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + var system = await ResolveSystem("@me"); + var member = await ResolveMember(memberRef); + if (member == null) + throw APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.NotOwnMemberError; + + var settings = await _repo.GetMemberGuild(guild_id, member.Id, defaultInsert: false); + if (settings == null) + throw APIErrors.MemberGuildNotFound; + + return Ok(settings.ToJson()); } - [HttpPatch("members/{member}/guilds/{guild_id}")] - public async Task MemberGuildPatch(string member, ulong guild_id, [FromBody] JObject data) + [HttpPatch("members/{memberRef}/guilds/{guild_id}")] + public async Task DoMemberGuildPatch(string memberRef, ulong guild_id, [FromBody] JObject data) { - return new ObjectResult("Unimplemented") + var system = await ResolveSystem("@me"); + var member = await ResolveMember(memberRef); + if (member == null) + throw APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.NotOwnMemberError; + + var settings = await _repo.GetMemberGuild(guild_id, member.Id, defaultInsert: false); + if (settings == null) + throw APIErrors.MemberGuildNotFound; + + MemberGuildPatch patch = null; + try { - StatusCode = 501 - }; + patch = MemberGuildPatch.FromJson(data); + patch.AssertIsValid(); + } + catch (ValidationError e) + { + // todo + return BadRequest(e.Message); + } + + var newSettings = await _repo.UpdateMemberGuild(member.Id, guild_id, patch); + return Ok(newSettings.ToJson()); } diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index 18d43edc..01b2cd85 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -25,7 +25,7 @@ namespace PluralKit.API public class ModelParseError: PKError { - public ModelParseError() : base(400, 0, "Error parsing JSON model") + public ModelParseError() : base(400, 40001, "Error parsing JSON model") { // todo } @@ -47,16 +47,19 @@ namespace PluralKit.API public static PKError GroupNotFound = new(404, 20003, "Group not found."); public static PKError MessageNotFound = new(404, 20004, "Message not found."); public static PKError SwitchNotFound = new(404, 20005, "Switch not found, switch is associated to different system, or unauthorized to view front history."); + public static PKError SystemGuildNotFound = new(404, 20006, "No system guild settings found for target guild."); + public static PKError MemberGuildNotFound = new(404, 20007, "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, 40001, "Target member is not part of your system."); - public static PKError NotOwnGroupError = new(403, 40002, "Target group is not part of your system."); + public static PKError NotOwnMemberError = new(403, 30006, "Target member is not part of your system."); + public static PKError NotOwnGroupError = new(403, 30006, "Target group is not part of your system."); // todo: somehow add the memberRef to the JSON - public static PKError NotOwnMemberErrorWithRef(string memberRef) => new(403, 40003, $"Member '{memberRef}' is not part of your system."); - public static PKError NotOwnGroupErrorWithRef(string groupRef) => new(403, 40004, $"Group '{groupRef}' is not part of your system."); + 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 Unimplemented = new(501, 50001, "Unimplemented"); } } \ 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/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/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/SystemGuildPatch.cs b/PluralKit.Core/Models/Patch/SystemGuildPatch.cs index a5642f85..241b12d5 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,33 @@ 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") && o["autoproxy_mode"].ParseAutoproxyMode() is { } autoproxyMode) + patch.AutoproxyMode = autoproxyMode; + + 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/SystemGuildSettings.cs b/PluralKit.Core/Models/SystemGuildSettings.cs index 83b6a2cd..1d47d15e 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? ParseAutoproxyMode(this JToken o) + { + if (o.Type == JTokenType.Null) + return AutoproxyMode.Off; + else if (o.Type != JTokenType.String) + return null; + + var value = o.Value(); + + switch (value) + { + case "off": + return AutoproxyMode.Off; + case "front": + return AutoproxyMode.Front; + case "latch": + return AutoproxyMode.Latch; + case "member": + return AutoproxyMode.Member; + default: + throw new ValidationError($"Value '{value}' is not a valid autoproxy mode."); + } + } + } } \ No newline at end of file From 5add31c77e5162b004ae806bf1b1b00a605bd4dc Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 13 Oct 2021 05:29:33 -0400 Subject: [PATCH 13/24] feat(apiv2): switch endpoints --- PluralKit.API/APIJsonExt.cs | 19 +++ .../Controllers/v1/SystemController.cs | 1 + .../Controllers/v2/SwitchControllerV2.cs | 154 +++++++++++++++--- PluralKit.API/Errors.cs | 9 +- PluralKit.API/Startup.cs | 7 +- 5 files changed, 161 insertions(+), 29 deletions(-) diff --git a/PluralKit.API/APIJsonExt.cs b/PluralKit.API/APIJsonExt.cs index 46744297..29f446cf 100644 --- a/PluralKit.API/APIJsonExt.cs +++ b/PluralKit.API/APIJsonExt.cs @@ -1,7 +1,11 @@ +using System; using System.Collections.Generic; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using NodaTime; + using PluralKit.Core; namespace PluralKit.API @@ -32,4 +36,19 @@ namespace PluralKit.API 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/v1/SystemController.cs b/PluralKit.API/Controllers/v1/SystemController.cs index 30f648aa..f59f0670 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; } } diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs index f7c2f88c..6623edbc 100644 --- a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -16,13 +16,6 @@ using PluralKit.Core; namespace PluralKit.API { - public struct SwitchesReturnNew - { - [JsonProperty("timestamp")] public Instant Timestamp { get; set; } - [JsonProperty("id")] public Guid Uuid { get; set; } - [JsonProperty("members")] public IEnumerable Members { get; set; } - } - [ApiController] [ApiVersion("2.0")] [Route("v{version:apiVersion}")] @@ -77,21 +70,60 @@ namespace PluralKit.API return NoContent(); var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); - return Ok(new FrontersReturn + return Ok(new FrontersReturnNew { Timestamp = sw.Timestamp, - Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync() + Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync(), + Uuid = sw.Uuid, }); } - [HttpPost("systems/{system}/switches")] - public async Task SwitchCreate(string system, [FromBody] JObject data) + [HttpPost("systems/@me/switches")] + public async Task SwitchCreate([FromBody] PostSwitchParams data) { - return new ObjectResult("Unimplemented") + if (data.Members.Distinct().Count() != data.Members.Count) + throw APIErrors.DuplicateMembersInList; + + var system = await ResolveSystem("@me"); + + if (data.Timestamp != null && await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(data.Timestamp.Value)) + throw APIErrors.SameSwitchTimestampError; + + var members = new List(); + + foreach (var memberRef in data.Members) { - StatusCode = 501 - }; + var member = await ResolveMember(memberRef); + if (member == null) + // todo: which member + throw APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.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 APIErrors.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)), + }); } @@ -99,7 +131,7 @@ namespace PluralKit.API public async Task SwitchGet(string systemRef, string switchRef) { if (!Guid.TryParse(switchRef, out var switchId)) - throw APIErrors.SwitchNotFound; + throw APIErrors.InvalidSwitchId; var system = await ResolveSystem(systemRef); if (system == null) @@ -107,45 +139,115 @@ namespace PluralKit.API var sw = await _repo.GetSwitchByUuid(switchId); if (sw == null || system.Id != sw.System) - throw APIErrors.SwitchNotFound; + throw APIErrors.SwitchNotFoundPublic; var ctx = this.ContextFor(system); if (!system.FrontHistoryPrivacy.CanAccess(ctx)) - throw APIErrors.SwitchNotFound; + throw APIErrors.SwitchNotFoundPublic; var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); - return Ok(new FrontersReturn + return Ok(new FrontersReturnNew { + Uuid = sw.Uuid, Timestamp = sw.Timestamp, Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync() }); } - [HttpPatch("systems/{system}/switches/{switch_id}")] - public async Task SwitchPatch(string system, [FromBody] JObject data) + [HttpPatch("systems/@me/switches/{switchRef}")] + public async Task SwitchPatch(string switchRef, [FromBody] JObject data) { - return new ObjectResult("Unimplemented") + // 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 APIErrors.InvalidSwitchId; + + var value = data.Value("timestamp"); + if (value == null) + // todo + throw APIErrors.GenericBadRequest; + + var system = await ResolveSystem("@me"); + if (system == null) + throw APIErrors.SystemNotFound; + + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null || system.Id != sw.System) + throw APIErrors.SwitchNotFoundPublic; + + if (await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(value)) + throw APIErrors.SameSwitchTimestampError; + + await _repo.MoveSwitch(sw.Id, value); + + var members = await _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)).ToListAsync(); + return Ok(new FrontersReturnNew { - StatusCode = 501 - }; + 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 APIErrors.DuplicateMembersInList; + + var system = await ResolveSystem("@me"); + + var sw = await _repo.GetSwitchByUuid(switchId); + if (sw == null) + throw APIErrors.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 APIErrors.MemberNotFound; + if (member.System != system.Id) + throw APIErrors.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 APIErrors.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 APIErrors.SwitchNotFound; + throw APIErrors.InvalidSwitchId; var system = await ResolveSystem("@me"); var sw = await _repo.GetSwitchByUuid(switchId); if (sw == null || system.Id != sw.System) - throw APIErrors.SwitchNotFound; + throw APIErrors.SwitchNotFoundPublic; await _repo.DeleteSwitch(sw.Id); return NoContent(); } - } } \ No newline at end of file diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index 01b2cd85..39ee1122 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -46,7 +46,8 @@ namespace PluralKit.API public static PKError MemberNotFound = new(404, 20002, "Member not found."); public static PKError GroupNotFound = new(404, 20003, "Group not found."); public static PKError MessageNotFound = new(404, 20004, "Message not found."); - public static PKError SwitchNotFound = new(404, 20005, "Switch not found, switch is associated to different system, or unauthorized to view front history."); + public static PKError SwitchNotFound = new(404, 20005, "Switch not found."); + public static PKError SwitchNotFoundPublic = new(404, 20005, "Switch not found, switch associated with different system, or unauthorized to view front history."); public static PKError SystemGuildNotFound = new(404, 20006, "No system guild settings found for target guild."); public static PKError MemberGuildNotFound = new(404, 20007, "No member guild settings found for target guild."); public static PKError UnauthorizedMemberList = new(403, 30001, "Unauthorized to view member list"); @@ -55,11 +56,15 @@ namespace PluralKit.API 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, 30006, "Target group 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"); } } \ No newline at end of file diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index 67626ffa..e3c70023 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -57,7 +57,12 @@ 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; + }); services.AddApiVersioning(); From 098d804344956e2dbf106d4e6e0e40146cbcd1b2 Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 13 Oct 2021 08:37:34 -0400 Subject: [PATCH 14/24] feat(apiv2): better model validation error UX --- .../Controllers/v1/MemberController.cs | 50 ++++++++-------- .../Controllers/v1/SystemController.cs | 22 ++++--- .../Controllers/v2/GuildControllerV2.cs | 32 ++++------ PluralKit.API/Errors.cs | 41 ++++++++++++- PluralKit.API/Startup.cs | 9 +++ .../Models/ModelTypes/Validation.cs | 29 ++++++++++ PluralKit.Core/Models/Patch/GroupPatch.cs | 19 +++--- PluralKit.Core/Models/Patch/MemberPatch.cs | 44 +++++++------- PluralKit.Core/Models/Patch/PatchObject.cs | 44 +++++++------- .../Models/Patch/SystemGuildPatch.cs | 10 +++- PluralKit.Core/Models/Patch/SystemPatch.cs | 18 +++--- PluralKit.Core/Models/Privacy/PrivacyLevel.cs | 15 ----- PluralKit.Core/Models/SystemGuildSettings.cs | 16 ++--- .../Utils/BulkImporter/PluralKitImport.cs | 58 ++++++++++--------- .../Utils/BulkImporter/TupperboxImport.cs | 26 ++++----- 15 files changed, 247 insertions(+), 186 deletions(-) create mode 100644 PluralKit.Core/Models/ModelTypes/Validation.cs 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/SystemController.cs b/PluralKit.API/Controllers/v1/SystemController.cs index f59f0670..437d4994 100644 --- a/PluralKit.API/Controllers/v1/SystemController.cs +++ b/PluralKit.API/Controllers/v1/SystemController.cs @@ -133,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/GuildControllerV2.cs b/PluralKit.API/Controllers/v2/GuildControllerV2.cs index e3abd0de..6c098eff 100644 --- a/PluralKit.API/Controllers/v2/GuildControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GuildControllerV2.cs @@ -55,17 +55,11 @@ namespace PluralKit.API else memberId = settings.AutoproxyMember; - SystemGuildPatch patch = null; - try - { - patch = SystemGuildPatch.FromJson(data, memberId); - patch.AssertIsValid(); - } - catch (ValidationError e) - { - // todo - return BadRequest(e.Message); - } + 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) @@ -116,17 +110,11 @@ namespace PluralKit.API if (settings == null) throw APIErrors.MemberGuildNotFound; - MemberGuildPatch patch = null; - try - { - patch = MemberGuildPatch.FromJson(data); - patch.AssertIsValid(); - } - catch (ValidationError e) - { - // todo - return BadRequest(e.Message); - } + 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()); diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index 39ee1122..a2cfe8bb 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using Newtonsoft.Json.Linq; +using PluralKit.Core; + namespace PluralKit.API { public class PKError: Exception @@ -25,15 +28,49 @@ namespace PluralKit.API public class ModelParseError: PKError { - public ModelParseError() : base(400, 40001, "Error parsing JSON model") + private IEnumerable _errors { get; init; } + public ModelParseError(IEnumerable errors) : base(400, 40001, "Error parsing JSON model") { - // todo + _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) + { + if (e[err.Key].Type == JTokenType.Object) + { + var current = e[err.Key]; + e.Remove(err.Key); + e.Add(err.Key, new JArray()); + (e[err.Key] as JArray).Add(current); + } + + (e[err.Key] as JArray).Add(o); + } + else + e.Add(err.Key, o); + } + + j.Add("errors", e); return j; } } diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index e3c70023..04e6b434 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -154,6 +154,15 @@ namespace PluralKit.API 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; 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/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/MemberPatch.cs b/PluralKit.Core/Models/Patch/MemberPatch.cs index d18c8772..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,7 +77,7 @@ 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 @@ -86,8 +86,12 @@ namespace PluralKit.Core { 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,7 +105,7 @@ 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(); @@ -123,7 +127,7 @@ namespace PluralKit.Core if (o.ContainsKey("privacy")) { - var plevel = o.ParsePrivacy("privacy"); + var plevel = patch.ParsePrivacy(o, "privacy"); patch.Visibility = plevel; patch.NamePrivacy = plevel; @@ -136,14 +140,14 @@ namespace PluralKit.Core } 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("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 = o.ParsePrivacy("metadata_privacy"); + if (o.ContainsKey("metadata_privacy")) patch.MetadataPrivacy = patch.ParsePrivacy(o, "metadata_privacy"); } break; } @@ -161,25 +165,25 @@ namespace PluralKit.Core var privacy = o.Value("privacy"); if (privacy.ContainsKey("visibility")) - patch.Visibility = privacy.ParsePrivacy("visibility"); + patch.Visibility = patch.ParsePrivacy(privacy, "visibility"); if (privacy.ContainsKey("name_privacy")) - patch.NamePrivacy = privacy.ParsePrivacy("name_privacy"); + patch.NamePrivacy = patch.ParsePrivacy(privacy, "name_privacy"); if (privacy.ContainsKey("description_privacy")) - patch.DescriptionPrivacy = privacy.ParsePrivacy("description_privacy"); + patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy"); if (privacy.ContainsKey("avatar_privacy")) - patch.AvatarPrivacy = privacy.ParsePrivacy("avatar_privacy"); + patch.AvatarPrivacy = patch.ParsePrivacy(privacy, "avatar_privacy"); if (privacy.ContainsKey("birthday_privacy")) - patch.BirthdayPrivacy = privacy.ParsePrivacy("birthday_privacy"); + patch.BirthdayPrivacy = patch.ParsePrivacy(privacy, "birthday_privacy"); if (privacy.ContainsKey("pronoun_privacy")) - patch.PronounPrivacy = privacy.ParsePrivacy("pronoun_privacy"); + patch.PronounPrivacy = patch.ParsePrivacy(privacy, "pronoun_privacy"); if (privacy.ContainsKey("metadata_privacy")) - patch.MetadataPrivacy = privacy.ParsePrivacy("metadata_privacy"); + patch.MetadataPrivacy = patch.ParsePrivacy(privacy, "metadata_privacy"); } break; 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 241b12d5..3410fc6b 100644 --- a/PluralKit.Core/Models/Patch/SystemGuildPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemGuildPatch.cs @@ -36,8 +36,14 @@ namespace PluralKit.Core if (o.ContainsKey("proxying_enabled") && o["proxying_enabled"].Type != JTokenType.Null) patch.ProxyEnabled = o.Value("proxying_enabled"); - if (o.ContainsKey("autoproxy_mode") && o["autoproxy_mode"].ParseAutoproxyMode() is { } autoproxyMode) - patch.AutoproxyMode = autoproxyMode; + 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; diff --git a/PluralKit.Core/Models/Patch/SystemPatch.cs b/PluralKit.Core/Models/Patch/SystemPatch.cs index bff45971..486fa778 100644 --- a/PluralKit.Core/Models/Patch/SystemPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemPatch.cs @@ -69,7 +69,7 @@ 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")); } #nullable disable @@ -91,10 +91,10 @@ namespace PluralKit.Core { if (o.ContainsKey("tz")) patch.UiTz = o.Value("tz") ?? "UTC"; - 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"); + 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; } @@ -105,16 +105,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("member_list_privacy")) - patch.DescriptionPrivacy = privacy.ParsePrivacy("member_list_privacy"); + patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "member_list_privacy"); if (privacy.ContainsKey("front_privacy")) - patch.DescriptionPrivacy = privacy.ParsePrivacy("front_privacy"); + patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "front_privacy"); if (privacy.ContainsKey("front_history_privacy")) - patch.DescriptionPrivacy = privacy.ParsePrivacy("front_history_privacy"); + patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "front_history_privacy"); } break; 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 1d47d15e..14750760 100644 --- a/PluralKit.Core/Models/SystemGuildSettings.cs +++ b/PluralKit.Core/Models/SystemGuildSettings.cs @@ -41,27 +41,27 @@ namespace PluralKit.Core return o; } - public static AutoproxyMode? ParseAutoproxyMode(this JToken o) + public static (AutoproxyMode?, ValidationError?) ParseAutoproxyMode(this JToken o) { if (o.Type == JTokenType.Null) - return AutoproxyMode.Off; + return (AutoproxyMode.Off, null); else if (o.Type != JTokenType.String) - return null; + return (null, new ValidationError("autoproxy_mode")); var value = o.Value(); switch (value) { case "off": - return AutoproxyMode.Off; + return (AutoproxyMode.Off, null); case "front": - return AutoproxyMode.Front; + return (AutoproxyMode.Front, null); case "latch": - return AutoproxyMode.Latch; + return (AutoproxyMode.Latch, null); case "member": - return AutoproxyMode.Member; + return (AutoproxyMode.Member, null); default: - throw new ValidationError($"Value '{value}' is not a valid autoproxy mode."); + return (null, new ValidationError("autoproxy_mode", $"Value '{value}' is not a valid autoproxy mode.")); } } } 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 43d8eb3f..7ebf305f 100644 --- a/PluralKit.Core/Utils/BulkImporter/TupperboxImport.cs +++ b/PluralKit.Core/Utils/BulkImporter/TupperboxImport.cs @@ -87,6 +87,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)) { @@ -101,19 +114,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); From 431f7e8931fd139bbc9eb585986c9a793e79de36 Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 13 Oct 2021 08:59:42 -0400 Subject: [PATCH 15/24] fix(apiv2): correctly parse timestamp in SwitchPatch --- PluralKit.API/Controllers/v2/SwitchControllerV2.cs | 6 ++++-- PluralKit.API/Startup.cs | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs index 6623edbc..fe79760e 100644 --- a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -163,11 +163,13 @@ namespace PluralKit.API if (!Guid.TryParse(switchRef, out var switchId)) throw APIErrors.InvalidSwitchId; - var value = data.Value("timestamp"); - if (value == null) + var valueStr = data.Value("timestamp").NullIfEmpty(); + if (valueStr == null) // todo throw APIErrors.GenericBadRequest; + var value = Instant.FromDateTimeOffset(DateTime.Parse(valueStr).ToUniversalTime()); + var system = await ResolveSystem("@me"); if (system == null) throw APIErrors.SystemNotFound; diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index 04e6b434..c21b5a64 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -140,7 +140,10 @@ namespace PluralKit.API var exc = ctx.Features.Get(); // handle common ISEs that are generated by invalid user input - if (exc.Error is InvalidCastException && exc.Error.Message.Contains("Newtonsoft.Json")) + if ( + (exc.Error is InvalidCastException && exc.Error.Message.Contains("Newtonsoft.Json")) + || (exc.Error is FormatException && exc.Error.Message.Contains("was not recognized as a valid DateTime")) + ) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsync("{\"message\":\"400: Bad Request\",\"code\":0}"); From fd49e7e4ea10256e7d159efc999f6b11a5e5d9a6 Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 13 Oct 2021 09:08:17 -0400 Subject: [PATCH 16/24] refactor(apiv2): rename APIErrors to Errors, move IsUserError to helper method --- PluralKit.API/Controllers/PKControllerBase.cs | 2 +- .../Controllers/v2/GroupControllerV2.cs | 10 +-- .../Controllers/v2/GroupMemberControllerV2.cs | 62 +++++++++---------- .../Controllers/v2/GuildControllerV2.cs | 22 +++---- .../Controllers/v2/MemberControllerV2.cs | 10 +-- .../Controllers/v2/MiscControllerV2.cs | 2 +- .../Controllers/v2/SwitchControllerV2.cs | 50 +++++++-------- PluralKit.API/Errors.cs | 19 +++++- PluralKit.API/Startup.cs | 5 +- 9 files changed, 98 insertions(+), 84 deletions(-) diff --git a/PluralKit.API/Controllers/PKControllerBase.cs b/PluralKit.API/Controllers/PKControllerBase.cs index 50172df7..0e97e0a9 100644 --- a/PluralKit.API/Controllers/PKControllerBase.cs +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -36,7 +36,7 @@ namespace PluralKit.API { HttpContext.Items.TryGetValue("SystemId", out var systemId); if (systemId == null) - throw APIErrors.GenericAuthError; + throw Errors.GenericAuthError; return _repo.GetSystem((SystemId)systemId); } diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs index d29a31f9..3604ceaa 100644 --- a/PluralKit.API/Controllers/v2/GroupControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -22,12 +22,12 @@ namespace PluralKit.API { var system = await ResolveSystem(systemRef); if (system == null) - throw APIErrors.SystemNotFound; + throw Errors.SystemNotFound; var ctx = this.ContextFor(system); if (!system.GroupListPrivacy.CanAccess(User.ContextFor(system))) - throw APIErrors.UnauthorizedGroupList; + throw Errors.UnauthorizedGroupList; var groups = _repo.GetSystemGroups(system.Id); return Ok(await groups @@ -50,7 +50,7 @@ namespace PluralKit.API { var group = await ResolveGroup(groupRef); if (group == null) - throw APIErrors.GroupNotFound; + throw Errors.GroupNotFound; var system = await _repo.GetSystem(group.System); @@ -71,11 +71,11 @@ namespace PluralKit.API { var group = await ResolveGroup(groupRef); if (group == null) - throw APIErrors.GroupNotFound; + throw Errors.GroupNotFound; var system = await ResolveSystem("@me"); if (system.Id != group.System) - throw APIErrors.NotOwnGroupError; + throw Errors.NotOwnGroupError; await _repo.DeleteGroup(group.Id); diff --git a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs index 48cf7787..dd3da730 100644 --- a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs @@ -25,12 +25,12 @@ namespace PluralKit.API { var group = await ResolveGroup(groupRef); if (group == null) - throw APIErrors.GroupNotFound; + throw Errors.GroupNotFound; var ctx = this.ContextFor(group); if (!group.ListPrivacy.CanAccess(ctx)) - throw APIErrors.UnauthorizedGroupMemberList; + throw Errors.UnauthorizedGroupMemberList; var members = _repo.GetGroupMembers(group.Id).Where(m => m.MemberVisibility.CanAccess(ctx)); @@ -50,7 +50,7 @@ namespace PluralKit.API var system = await _repo.GetSystem(member.System); if (!system.GroupListPrivacy.CanAccess(ctx)) - throw APIErrors.UnauthorizedGroupList; + throw Errors.UnauthorizedGroupList; var groups = _repo.GetMemberGroups(member.Id).Where(g => g.Visibility.CanAccess(ctx)); @@ -69,16 +69,16 @@ namespace PluralKit.API var group = await ResolveGroup(groupRef); if (group == null) - throw APIErrors.GroupNotFound; + throw Errors.GroupNotFound; if (group.System != system.Id) - throw APIErrors.NotOwnGroupError; + throw Errors.NotOwnGroupError; var member = await ResolveMember(memberRef); Console.WriteLine(member); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberError; + throw Errors.NotOwnMemberError; var existingMembers = await _repo.GetGroupMembers(group.Id).Select(x => x.Id).ToListAsync(); if (!existingMembers.Contains(member.Id)) @@ -91,15 +91,15 @@ namespace PluralKit.API public async Task GroupMembersPut(string groupRef, [FromBody] JArray memberRefs) { if (memberRefs.Count == 0) - throw APIErrors.GenericBadRequest; + throw Errors.GenericBadRequest; var system = await ResolveSystem("@me"); var group = await ResolveGroup(groupRef); if (group == null) - throw APIErrors.GroupNotFound; + throw Errors.GroupNotFound; if (group.System != system.Id) - throw APIErrors.NotOwnGroupError; + throw Errors.NotOwnGroupError; var members = new List(); @@ -109,9 +109,9 @@ namespace PluralKit.API var member = await ResolveMember(memberRef); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberErrorWithRef(memberRef); + throw Errors.NotOwnMemberErrorWithRef(memberRef); members.Add(member.Id); } @@ -132,15 +132,15 @@ namespace PluralKit.API var group = await ResolveGroup(groupRef); if (group == null) - throw APIErrors.GroupNotFound; + throw Errors.GroupNotFound; if (group.System != system.Id) - throw APIErrors.NotOwnGroupError; + throw Errors.NotOwnGroupError; var member = await ResolveMember(memberRef); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberError; + throw Errors.NotOwnMemberError; await _repo.RemoveMembersFromGroup(group.Id, new List() { member.Id }); @@ -151,15 +151,15 @@ namespace PluralKit.API public async Task GroupMembersDelete(string groupRef, [FromBody] JArray memberRefs) { if (memberRefs.Count == 0) - throw APIErrors.GenericBadRequest; + throw Errors.GenericBadRequest; var system = await ResolveSystem("@me"); var group = await ResolveGroup(groupRef); if (group == null) - throw APIErrors.GroupNotFound; + throw Errors.GroupNotFound; if (group.System != system.Id) - throw APIErrors.NotOwnGroupError; + throw Errors.NotOwnGroupError; var members = new List(); @@ -169,9 +169,9 @@ namespace PluralKit.API var member = await ResolveMember(memberRef); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberError; + throw Errors.NotOwnMemberError; members.Add(member.Id); } @@ -185,15 +185,15 @@ namespace PluralKit.API public async Task MemberGroupsPut(string memberRef, [FromBody] JArray groupRefs) { if (groupRefs.Count == 0) - throw APIErrors.GenericBadRequest; + throw Errors.GenericBadRequest; var system = await ResolveSystem("@me"); var member = await ResolveMember(memberRef); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberError; + throw Errors.NotOwnMemberError; var groups = new List(); @@ -203,9 +203,9 @@ namespace PluralKit.API var group = await ResolveGroup(groupRef); if (group == null) - throw APIErrors.GroupNotFound; + throw Errors.GroupNotFound; if (group.System != system.Id) - throw APIErrors.NotOwnGroupErrorWithRef(groupRef); + throw Errors.NotOwnGroupErrorWithRef(groupRef); groups.Add(group.Id); } @@ -223,15 +223,15 @@ namespace PluralKit.API public async Task MemberGroupsDelete(string memberRef, [FromBody] JArray groupRefs) { if (groupRefs.Count == 0) - throw APIErrors.GenericBadRequest; + throw Errors.GenericBadRequest; var system = await ResolveSystem("@me"); var member = await ResolveMember(memberRef); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberError; + throw Errors.NotOwnMemberError; var groups = new List(); @@ -241,9 +241,9 @@ namespace PluralKit.API var group = await ResolveGroup(groupRef); if (group == null) - throw APIErrors.GroupNotFound; + throw Errors.GroupNotFound; if (group.System != system.Id) - throw APIErrors.NotOwnGroupErrorWithRef(groupRef); + throw Errors.NotOwnGroupErrorWithRef(groupRef); groups.Add(group.Id); } diff --git a/PluralKit.API/Controllers/v2/GuildControllerV2.cs b/PluralKit.API/Controllers/v2/GuildControllerV2.cs index 6c098eff..2f5ffc47 100644 --- a/PluralKit.API/Controllers/v2/GuildControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GuildControllerV2.cs @@ -23,7 +23,7 @@ namespace PluralKit.API var system = await ResolveSystem("@me"); var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false); if (settings == null) - throw APIErrors.SystemGuildNotFound; + throw Errors.SystemGuildNotFound; PKMember member = null; if (settings.AutoproxyMember != null) @@ -38,7 +38,7 @@ namespace PluralKit.API var system = await ResolveSystem("@me"); var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false); if (settings == null) - throw APIErrors.SystemGuildNotFound; + throw Errors.SystemGuildNotFound; MemberId? memberId = null; if (data.ContainsKey("autoproxy_member")) @@ -47,7 +47,7 @@ namespace PluralKit.API { var member = await ResolveMember(data.Value("autoproxy_member")); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; memberId = member.Id; } @@ -66,10 +66,10 @@ namespace PluralKit.API if (patch.AutoproxyMode.IsPresent) { if (patch.AutoproxyMode.Value == AutoproxyMode.Member) - throw APIErrors.MissingAutoproxyMember; + throw Errors.MissingAutoproxyMember; } else if (settings.AutoproxyMode == AutoproxyMode.Member) - throw APIErrors.MissingAutoproxyMember; + throw Errors.MissingAutoproxyMember; var newSettings = await _repo.UpdateSystemGuild(system.Id, guild_id, patch); @@ -85,13 +85,13 @@ namespace PluralKit.API var system = await ResolveSystem("@me"); var member = await ResolveMember(memberRef); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberError; + throw Errors.NotOwnMemberError; var settings = await _repo.GetMemberGuild(guild_id, member.Id, defaultInsert: false); if (settings == null) - throw APIErrors.MemberGuildNotFound; + throw Errors.MemberGuildNotFound; return Ok(settings.ToJson()); } @@ -102,13 +102,13 @@ namespace PluralKit.API var system = await ResolveSystem("@me"); var member = await ResolveMember(memberRef); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberError; + throw Errors.NotOwnMemberError; var settings = await _repo.GetMemberGuild(guild_id, member.Id, defaultInsert: false); if (settings == null) - throw APIErrors.MemberGuildNotFound; + throw Errors.MemberGuildNotFound; var patch = MemberGuildPatch.FromJson(data); diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs index 38b958d4..2b72618c 100644 --- a/PluralKit.API/Controllers/v2/MemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -23,12 +23,12 @@ namespace PluralKit.API { var system = await ResolveSystem(systemRef); if (system == null) - throw APIErrors.SystemNotFound; + throw Errors.SystemNotFound; var ctx = this.ContextFor(system); if (!system.MemberListPrivacy.CanAccess(this.ContextFor(system))) - throw APIErrors.UnauthorizedMemberList; + throw Errors.UnauthorizedMemberList; var members = _repo.GetSystemMembers(system.Id); return Ok(await members @@ -51,7 +51,7 @@ namespace PluralKit.API { var member = await ResolveMember(memberRef); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; var system = await _repo.GetSystem(member.System); @@ -72,11 +72,11 @@ namespace PluralKit.API { var member = await ResolveMember(memberRef); if (member == null) - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; var system = await ResolveSystem("@me"); if (system.Id != member.System) - throw APIErrors.NotOwnMemberError; + throw Errors.NotOwnMemberError; await _repo.DeleteMember(member.Id); diff --git a/PluralKit.API/Controllers/v2/MiscControllerV2.cs b/PluralKit.API/Controllers/v2/MiscControllerV2.cs index 1e015b37..3fdff9a7 100644 --- a/PluralKit.API/Controllers/v2/MiscControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MiscControllerV2.cs @@ -35,7 +35,7 @@ namespace PluralKit.API { var msg = await _db.Execute(c => _repo.GetMessage(c, messageId)); if (msg == null) - throw APIErrors.MessageNotFound; + throw Errors.MessageNotFound; var ctx = this.ContextFor(msg.System); diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs index fe79760e..14f908fe 100644 --- a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -29,12 +29,12 @@ namespace PluralKit.API { var system = await ResolveSystem(systemRef); if (system == null) - throw APIErrors.SystemNotFound; + throw Errors.SystemNotFound; var ctx = this.ContextFor(system); if (!system.FrontHistoryPrivacy.CanAccess(ctx)) - throw APIErrors.UnauthorizedFrontHistory; + throw Errors.UnauthorizedFrontHistory; if (before == null) before = SystemClock.Instance.GetCurrentInstant(); @@ -58,12 +58,12 @@ namespace PluralKit.API { var system = await ResolveSystem(systemRef); if (system == null) - throw APIErrors.SystemNotFound; + throw Errors.SystemNotFound; var ctx = this.ContextFor(system); if (!system.FrontPrivacy.CanAccess(ctx)) - throw APIErrors.UnauthorizedCurrentFronters; + throw Errors.UnauthorizedCurrentFronters; var sw = await _repo.GetLatestSwitch(system.Id); if (sw == null) @@ -83,12 +83,12 @@ namespace PluralKit.API public async Task SwitchCreate([FromBody] PostSwitchParams data) { if (data.Members.Distinct().Count() != data.Members.Count) - throw APIErrors.DuplicateMembersInList; + 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 APIErrors.SameSwitchTimestampError; + throw Errors.SameSwitchTimestampError; var members = new List(); @@ -97,9 +97,9 @@ namespace PluralKit.API var member = await ResolveMember(memberRef); if (member == null) // todo: which member - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberErrorWithRef(memberRef); + throw Errors.NotOwnMemberErrorWithRef(memberRef); members.Add(member); } @@ -111,7 +111,7 @@ namespace PluralKit.API // 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 APIErrors.SameSwitchMembersError; + throw Errors.SameSwitchMembersError; } var newSwitch = await _db.Execute(conn => _repo.AddSwitch(conn, system.Id, members.Select(m => m.Id).ToList())); @@ -131,20 +131,20 @@ namespace PluralKit.API public async Task SwitchGet(string systemRef, string switchRef) { if (!Guid.TryParse(switchRef, out var switchId)) - throw APIErrors.InvalidSwitchId; + throw Errors.InvalidSwitchId; var system = await ResolveSystem(systemRef); if (system == null) - throw APIErrors.SystemNotFound; + throw Errors.SystemNotFound; var sw = await _repo.GetSwitchByUuid(switchId); if (sw == null || system.Id != sw.System) - throw APIErrors.SwitchNotFoundPublic; + throw Errors.SwitchNotFoundPublic; var ctx = this.ContextFor(system); if (!system.FrontHistoryPrivacy.CanAccess(ctx)) - throw APIErrors.SwitchNotFoundPublic; + throw Errors.SwitchNotFoundPublic; var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); return Ok(new FrontersReturnNew @@ -161,25 +161,25 @@ namespace PluralKit.API // 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 APIErrors.InvalidSwitchId; + throw Errors.InvalidSwitchId; var valueStr = data.Value("timestamp").NullIfEmpty(); if (valueStr == null) // todo - throw APIErrors.GenericBadRequest; + throw Errors.GenericBadRequest; var value = Instant.FromDateTimeOffset(DateTime.Parse(valueStr).ToUniversalTime()); var system = await ResolveSystem("@me"); if (system == null) - throw APIErrors.SystemNotFound; + throw Errors.SystemNotFound; var sw = await _repo.GetSwitchByUuid(switchId); if (sw == null || system.Id != sw.System) - throw APIErrors.SwitchNotFoundPublic; + throw Errors.SwitchNotFoundPublic; if (await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(value)) - throw APIErrors.SameSwitchTimestampError; + throw Errors.SameSwitchTimestampError; await _repo.MoveSwitch(sw.Id, value); @@ -198,13 +198,13 @@ namespace PluralKit.API if (!Guid.TryParse(switchRef, out var switchId)) if (data.Distinct().Count() != data.Count) - throw APIErrors.DuplicateMembersInList; + throw Errors.DuplicateMembersInList; var system = await ResolveSystem("@me"); var sw = await _repo.GetSwitchByUuid(switchId); if (sw == null) - throw APIErrors.SwitchNotFound; + throw Errors.SwitchNotFound; var members = new List(); @@ -215,9 +215,9 @@ namespace PluralKit.API var member = await ResolveMember(memberRef); if (member == null) // todo: which member - throw APIErrors.MemberNotFound; + throw Errors.MemberNotFound; if (member.System != system.Id) - throw APIErrors.NotOwnMemberErrorWithRef(memberRef); + throw Errors.NotOwnMemberErrorWithRef(memberRef); members.Add(member); } @@ -225,7 +225,7 @@ namespace PluralKit.API 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 APIErrors.SameSwitchMembersError; + throw Errors.SameSwitchMembersError; await _db.Execute(conn => _repo.EditSwitch(conn, sw.Id, members.Select(x => x.Id).ToList())); return Ok(new FrontersReturnNew @@ -240,12 +240,12 @@ namespace PluralKit.API public async Task SwitchDelete(string switchRef) { if (!Guid.TryParse(switchRef, out var switchId)) - throw APIErrors.InvalidSwitchId; + throw Errors.InvalidSwitchId; var system = await ResolveSystem("@me"); var sw = await _repo.GetSwitchByUuid(switchId); if (sw == null || system.Id != sw.System) - throw APIErrors.SwitchNotFoundPublic; + throw Errors.SwitchNotFoundPublic; await _repo.DeleteSwitch(sw.Id); diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index a2cfe8bb..d1045a00 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -75,7 +75,7 @@ namespace PluralKit.API } } - public static class APIErrors + 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"); @@ -104,4 +104,21 @@ namespace PluralKit.API 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/Startup.cs b/PluralKit.API/Startup.cs index c21b5a64..df292497 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -140,10 +140,7 @@ namespace PluralKit.API var exc = ctx.Features.Get(); // handle common ISEs that are generated by invalid user input - if ( - (exc.Error is InvalidCastException && exc.Error.Message.Contains("Newtonsoft.Json")) - || (exc.Error is FormatException && exc.Error.Message.Contains("was not recognized as a valid DateTime")) - ) + if (exc.Error.IsUserError()) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsync("{\"message\":\"400: Bad Request\",\"code\":0}"); From e367ed68084178d7c372e87ddc610d1774e82dc3 Mon Sep 17 00:00:00 2001 From: spiral Date: Thu, 14 Oct 2021 09:35:20 -0400 Subject: [PATCH 17/24] feat(apiv2): post/patch endpoints --- .../Controllers/v2/GroupControllerV2.cs | 47 ++++++++++++++----- .../Controllers/v2/MemberControllerV2.cs | 45 ++++++++++++++---- .../Controllers/v2/SwitchControllerV2.cs | 3 +- .../Controllers/v2/SystemControllerV2.cs | 17 ++++--- 4 files changed, 83 insertions(+), 29 deletions(-) diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs index 3604ceaa..4b351e67 100644 --- a/PluralKit.API/Controllers/v2/GroupControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -37,12 +37,26 @@ namespace PluralKit.API } [HttpPost("groups")] - public async Task GroupCreate(string group_id) + public async Task GroupCreate([FromBody] JObject data) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + 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}")] @@ -57,13 +71,24 @@ namespace PluralKit.API return Ok(group.ToJson(this.ContextFor(group), systemStr: system.Hid)); } - [HttpPatch("groups/{group_id}")] - public async Task GroupPatch(string group_id) + [HttpPatch("groups/{groupRef}")] + public async Task DoGroupPatch(string groupRef, [FromBody] JObject data) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + 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}")] diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs index 2b72618c..c7b8c090 100644 --- a/PluralKit.API/Controllers/v2/MemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -40,10 +40,24 @@ namespace PluralKit.API [HttpPost("members")] public async Task MemberCreate([FromBody] JObject data) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + 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}")] @@ -58,13 +72,24 @@ namespace PluralKit.API return Ok(member.ToJson(this.ContextFor(member), systemStr: system.Hid, v: APIVersion.V2)); } - [HttpPatch("members/{member}")] - public async Task MemberPatch(string member, [FromBody] JObject data) + [HttpPatch("members/{memberRef}")] + public async Task DoMemberPatch(string memberRef, [FromBody] JObject data) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + 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}")] diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs index 14f908fe..d291991c 100644 --- a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -165,8 +165,7 @@ namespace PluralKit.API var valueStr = data.Value("timestamp").NullIfEmpty(); if (valueStr == null) - // todo - throw Errors.GenericBadRequest; + throw new ModelParseError(new List() { new ValidationError("timestamp", $"Key 'timestamp' is required.") }); var value = Instant.FromDateTimeOffset(DateTime.Parse(valueStr).ToUniversalTime()); diff --git a/PluralKit.API/Controllers/v2/SystemControllerV2.cs b/PluralKit.API/Controllers/v2/SystemControllerV2.cs index b7432166..4cbcc1c0 100644 --- a/PluralKit.API/Controllers/v2/SystemControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -24,13 +24,18 @@ namespace PluralKit.API else return Ok(system.ToJson(this.ContextFor(system), v: APIVersion.V2)); } - [HttpPatch("{system}")] - public async Task SystemPatch(string system, [FromBody] JObject data) + [HttpPatch] + public async Task DoSystemPatch([FromBody] JObject data) { - return new ObjectResult("Unimplemented") - { - StatusCode = 501 - }; + 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 From 8e1409bd173e449980bd17419e16bae6b1fb8493 Mon Sep 17 00:00:00 2001 From: spiral Date: Thu, 14 Oct 2021 10:24:44 -0400 Subject: [PATCH 18/24] fix(apiv2): get rid of large asp.net bad request errors should also fix it for v1 :) --- PluralKit.API/Startup.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PluralKit.API/Startup.cs b/PluralKit.API/Startup.cs index df292497..1a01356c 100644 --- a/PluralKit.API/Startup.cs +++ b/PluralKit.API/Startup.cs @@ -62,7 +62,11 @@ namespace PluralKit.API { // ... though by default it messes up timestamps in JSON opts.SerializerSettings.DateParseHandling = DateParseHandling.None; - }); + }) + .ConfigureApiBehaviorOptions(options => + options.InvalidModelStateResponseFactory = (context) => + throw Errors.GenericBadRequest + ); services.AddApiVersioning(); From 611fc65a994b0ab35198c30acf5e9dd22b120b1c Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 15 Oct 2021 04:29:04 -0400 Subject: [PATCH 19/24] fix(apiv2): don't erroneously expose helper methods as endpoints asp.net why --- PluralKit.API/Controllers/PKControllerBase.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PluralKit.API/Controllers/PKControllerBase.cs b/PluralKit.API/Controllers/PKControllerBase.cs index 0e97e0a9..d2540ab4 100644 --- a/PluralKit.API/Controllers/PKControllerBase.cs +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -74,21 +74,21 @@ namespace PluralKit.API return Task.FromResult(null); } - public LookupContext ContextFor(PKSystem system) + 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; } - public LookupContext ContextFor(PKMember member) + 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; } - public LookupContext ContextFor(PKGroup group) + protected LookupContext ContextFor(PKGroup group) { HttpContext.Items.TryGetValue("SystemId", out var systemId); if (systemId == null) return LookupContext.ByNonOwner; From 2bf1617737393733ac663fd7a945a0bdea3cecc2 Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 15 Oct 2021 07:08:41 -0400 Subject: [PATCH 20/24] feat(apiv2): reorganize controllers, add stats to meta endpoint --- PluralKit.API/APIJsonExt.cs | 13 +++++ .../Controllers/v1/MetaController.cs | 2 +- ...ControllerV2.cs => DiscordControllerV2.cs} | 22 ++++++++ .../Controllers/v2/MiscControllerV2.cs | 55 ------------------- .../Controllers/v2/PrivateControllerV2.cs | 30 ++++++++++ .../Repository/ModelRepository.Shards.cs | 4 +- 6 files changed, 68 insertions(+), 58 deletions(-) rename PluralKit.API/Controllers/v2/{GuildControllerV2.cs => DiscordControllerV2.cs} (82%) delete mode 100644 PluralKit.API/Controllers/v2/MiscControllerV2.cs create mode 100644 PluralKit.API/Controllers/v2/PrivateControllerV2.cs diff --git a/PluralKit.API/APIJsonExt.cs b/PluralKit.API/APIJsonExt.cs index 29f446cf..ee57a500 100644 --- a/PluralKit.API/APIJsonExt.cs +++ b/PluralKit.API/APIJsonExt.cs @@ -35,6 +35,19 @@ namespace PluralKit.API 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 diff --git a/PluralKit.API/Controllers/v1/MetaController.cs b/PluralKit.API/Controllers/v1/MetaController.cs index 8bfae9e9..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()); diff --git a/PluralKit.API/Controllers/v2/GuildControllerV2.cs b/PluralKit.API/Controllers/v2/DiscordControllerV2.cs similarity index 82% rename from PluralKit.API/Controllers/v2/GuildControllerV2.cs rename to PluralKit.API/Controllers/v2/DiscordControllerV2.cs index 2f5ffc47..c44fc97d 100644 --- a/PluralKit.API/Controllers/v2/GuildControllerV2.cs +++ b/PluralKit.API/Controllers/v2/DiscordControllerV2.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; +using NodaTime; + using PluralKit.Core; namespace PluralKit.API @@ -120,6 +122,26 @@ namespace PluralKit.API 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/MiscControllerV2.cs b/PluralKit.API/Controllers/v2/MiscControllerV2.cs deleted file mode 100644 index 3fdff9a7..00000000 --- a/PluralKit.API/Controllers/v2/MiscControllerV2.cs +++ /dev/null @@ -1,55 +0,0 @@ -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 MetaControllerV2: PKControllerBase - { - public MetaControllerV2(IServiceProvider svc) : base(svc) { } - - [HttpGet("meta")] - public async Task> Meta() - { - await using var conn = await _db.Obtain(); - var shards = await _repo.GetShards(conn); - - var o = new JObject(); - o.Add("shards", shards.ToJSON()); - - return Ok(o); - } - - [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/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.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( From 7d36a39a57df2bf27d7d6b9048481b7adac00870 Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 22 Oct 2021 11:20:26 -0400 Subject: [PATCH 21/24] feat(apiv2): documentation, misc fixes --- PluralKit.Core/Models/PKSystem.cs | 1 + PluralKit.Core/Models/Patch/SystemPatch.cs | 3 + docs/content/.vuepress/config.js | 14 +- docs/content/api/changelog.md | 33 +++ docs/content/api/endpoints.md | 261 ++++++++++++++++++ docs/content/api/errors.md | 57 ++++ .../{api-documentation.md => api/legacy.md} | 76 +---- docs/content/api/models.md | 122 ++++++++ docs/content/api/reference.md | 48 ++++ 9 files changed, 545 insertions(+), 70 deletions(-) create mode 100644 docs/content/api/changelog.md create mode 100644 docs/content/api/endpoints.md create mode 100644 docs/content/api/errors.md rename docs/content/{api-documentation.md => api/legacy.md} (83%) create mode 100644 docs/content/api/models.md create mode 100644 docs/content/api/reference.md diff --git a/PluralKit.Core/Models/PKSystem.cs b/PluralKit.Core/Models/PKSystem.cs index 4d770783..3512669b 100644 --- a/PluralKit.Core/Models/PKSystem.cs +++ b/PluralKit.Core/Models/PKSystem.cs @@ -104,6 +104,7 @@ namespace PluralKit.Core 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()); diff --git a/PluralKit.Core/Models/Patch/SystemPatch.cs b/PluralKit.Core/Models/Patch/SystemPatch.cs index 486fa778..50802a1b 100644 --- a/PluralKit.Core/Models/Patch/SystemPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemPatch.cs @@ -110,6 +110,9 @@ namespace PluralKit.Core 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"); 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..ad378dbf --- /dev/null +++ b/docs/content/api/endpoints.md @@ -0,0 +1,261 @@ +--- +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. + +::: note +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 + +PUT `/members/{memberRef}/groups` + +::: warn +Not all HTTP implementations support PUT requests with a body. If yours does not, consider using the [Add Member To Group](#add-member-to-group) endpoint instead. +::: + +### Remove Member From Groups + +DELETE `/members/{memberRef}/groups` + +::: warn +Not all HTTP implementations support DELETE requests with a body. If yours does not, consider using the [Remove Member From Group](#remove-member-from-group) endpoint instead. +::: + +### Get Member Guild Settings + +GET `/members/{memberRef}/guilds/{guild_id}` + +Returns a [member guild settings](/api/models#member-guild-settings) object. + +::: note +You must already have updated per-guild settings for a 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` + +### Add Member To Group + +PUT `/groups/{groupRef}/members/{memberRef}` + +### Add Members To Group + +PUT `/groups/{groupRef}/members` + +::: warn +Not all HTTP implementations support PUT requests with a body. If yours does not, consider using the [Add Member To Group](#add-group-member) endpoint instead. +::: + +### Remove Member From Group + +DELETE `/groups/{groupRef}/members/{memberRef}` + +### Remove Members From Group + +DELETE `/groups/{groupRef}/members` + +::: warn +Not all HTTP implementations support DELETE requests with a body. If yours does not, consider using the [Remove Member From Group](#remove-member-from-group) endpoint instead. +::: + + +--- +## 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..c4d5ec97 --- /dev/null +++ b/docs/content/api/errors.md @@ -0,0 +1,57 @@ +--- +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 keys to 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. Values can be individual error objects, or arrays of error objects. + +### 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|Group not found.| +|20004|404|Message not found.| +|20005|404|Switch not found.| +|20005|404|Switch not found, switch associated with different system, or unauthorized to view front history.| +|20006|404|No system guild settings found for target guild.| +|20007|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..ac771c13 --- /dev/null +++ b/docs/content/api/models.md @@ -0,0 +1,122 @@ +--- +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| +|---|---|---| +|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! From 8e0e393f5209b0b7c8e49e641e5c2c6da957ff13 Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 22 Oct 2021 17:54:47 -0400 Subject: [PATCH 22/24] feat(apiv2): docs fixes --- PluralKit.API/Controllers/v2/SystemControllerV2.cs | 2 +- PluralKit.Core/Models/PKMember.cs | 2 +- docs/content/api/endpoints.md | 14 +++++++------- docs/content/api/models.md | 1 + 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/PluralKit.API/Controllers/v2/SystemControllerV2.cs b/PluralKit.API/Controllers/v2/SystemControllerV2.cs index 4cbcc1c0..b4ae8fc1 100644 --- a/PluralKit.API/Controllers/v2/SystemControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -24,7 +24,7 @@ namespace PluralKit.API else return Ok(system.ToJson(this.ContextFor(system), v: APIVersion.V2)); } - [HttpPatch] + [HttpPatch("@me")] public async Task DoSystemPatch([FromBody] JObject data) { var system = await ResolveSystem("@me"); diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs index 0ed739cb..caa9fcda 100644 --- a/PluralKit.Core/Models/PKMember.cs +++ b/PluralKit.Core/Models/PKMember.cs @@ -123,8 +123,8 @@ namespace PluralKit.Core 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()); diff --git a/docs/content/api/endpoints.md b/docs/content/api/endpoints.md index ad378dbf..50606fc7 100644 --- a/docs/content/api/endpoints.md +++ b/docs/content/api/endpoints.md @@ -32,7 +32,7 @@ GET `/systems/@me/guilds/{guild_id}` Returns a [system guild settings](/api/models#system-guild-settings) object. -::: note +::: 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. ::: @@ -91,7 +91,7 @@ GET `/members/{memberRef}/groups` PUT `/members/{memberRef}/groups` -::: warn +::: warning Not all HTTP implementations support PUT requests with a body. If yours does not, consider using the [Add Member To Group](#add-member-to-group) endpoint instead. ::: @@ -99,7 +99,7 @@ Not all HTTP implementations support PUT requests with a body. If yours does not DELETE `/members/{memberRef}/groups` -::: warn +::: warning Not all HTTP implementations support DELETE requests with a body. If yours does not, consider using the [Remove Member From Group](#remove-member-from-group) endpoint instead. ::: @@ -109,8 +109,8 @@ GET `/members/{memberRef}/guilds/{guild_id}` Returns a [member guild settings](/api/models#member-guild-settings) object. -::: note -You must already have updated per-guild settings for a member in the target guild before being able to get or update them from the API. +::: 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 @@ -172,7 +172,7 @@ PUT `/groups/{groupRef}/members/{memberRef}` PUT `/groups/{groupRef}/members` -::: warn +::: warning Not all HTTP implementations support PUT requests with a body. If yours does not, consider using the [Add Member To Group](#add-group-member) endpoint instead. ::: @@ -184,7 +184,7 @@ DELETE `/groups/{groupRef}/members/{memberRef}` DELETE `/groups/{groupRef}/members` -::: warn +::: warning Not all HTTP implementations support DELETE requests with a body. If yours does not, consider using the [Remove Member From Group](#remove-member-from-group) endpoint instead. ::: diff --git a/docs/content/api/models.md b/docs/content/api/models.md index ac771c13..fe0c7aef 100644 --- a/docs/content/api/models.md +++ b/docs/content/api/models.md @@ -80,6 +80,7 @@ Every PluralKit entity has two IDs: a short (5-character) ID and a longer UUID. |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`). | From 93eef82a834e540f0ff4fd63005b8188cfa53619 Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 29 Oct 2021 20:06:09 -0400 Subject: [PATCH 23/24] refactor(apiv2): fix nonsense behaviour with error objects/arrays --- PluralKit.API/Errors.cs | 16 +++------------- docs/content/api/errors.md | 4 ++-- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/PluralKit.API/Errors.cs b/PluralKit.API/Errors.cs index d1045a00..2db7d3a8 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -54,20 +54,10 @@ namespace PluralKit.API else o.Add("message", $"Field {err.Key} is invalid."); - if (e[err.Key] != null) - { - if (e[err.Key].Type == JTokenType.Object) - { - var current = e[err.Key]; - e.Remove(err.Key); - e.Add(err.Key, new JArray()); - (e[err.Key] as JArray).Add(current); - } + if (e[err.Key] == null) + e.Add(err.Key, new JArray()); - (e[err.Key] as JArray).Add(o); - } - else - e.Add(err.Key, o); + (e[err.Key] as JArray).Add(o); } j.Add("errors", e); diff --git a/docs/content/api/errors.md b/docs/content/api/errors.md index c4d5ec97..a39630f0 100644 --- a/docs/content/api/errors.md +++ b/docs/content/api/errors.md @@ -13,10 +13,10 @@ When something goes wrong, the API will send back a 4xx HTTP status code, along |---|---|---| |code|int|numerical error code| |message|string|description of the error| -|?errors|map of keys to error objects*|details on 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. Values can be individual error objects, or arrays of error objects. +* Only returned for model parsing errors. ### Error Object From c7126840ed743aa3a6a2bbb32704eb7af930cb08 Mon Sep 17 00:00:00 2001 From: spiral Date: Sat, 30 Oct 2021 18:16:18 -0400 Subject: [PATCH 24/24] refactor(apiv2): rewrite group member endpoints to be cleaner --- .../Controllers/v2/GroupMemberControllerV2.cs | 272 ++++++++++-------- PluralKit.API/Errors.cs | 14 +- .../Repository/ModelRepository.GroupMember.cs | 16 ++ docs/content/api/endpoints.md | 47 +-- docs/content/api/errors.md | 18 +- 5 files changed, 208 insertions(+), 159 deletions(-) diff --git a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs index dd3da730..afed262d 100644 --- a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs @@ -42,6 +42,115 @@ namespace PluralKit.API 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) { @@ -62,127 +171,8 @@ namespace PluralKit.API return Ok(o); } - [HttpPut("groups/{groupRef}/members/{memberRef}")] - public async Task GroupMemberPut(string groupRef, string memberRef) - { - 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 member = await ResolveMember(memberRef); - Console.WriteLine(member); - if (member == null) - throw Errors.MemberNotFound; - if (member.System != system.Id) - throw Errors.NotOwnMemberError; - - var existingMembers = await _repo.GetGroupMembers(group.Id).Select(x => x.Id).ToListAsync(); - if (!existingMembers.Contains(member.Id)) - await _repo.AddMembersToGroup(group.Id, new List() { member.Id }); - - return NoContent(); - } - - [HttpPut("groups/{groupRef}/members")] - public async Task GroupMembersPut(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.MemberNotFound; - 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(); - } - - [HttpDelete("groups/{groupRef}/members/{memberRef}")] - public async Task GroupMemberDelete(string groupRef, string memberRef) - { - 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 member = await ResolveMember(memberRef); - if (member == null) - throw Errors.MemberNotFound; - if (member.System != system.Id) - throw Errors.NotOwnMemberError; - - await _repo.RemoveMembersFromGroup(group.Id, new List() { member.Id }); - - return NoContent(); - } - - [HttpDelete("groups/{groupRef}/members")] - public async Task GroupMembersDelete(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.MemberNotFound; - if (member.System != system.Id) - throw Errors.NotOwnMemberError; - - members.Add(member.Id); - } - - await _repo.RemoveMembersFromGroup(group.Id, members); - - return NoContent(); - } - - [HttpPut("members/{memberRef}/groups")] - public async Task MemberGroupsPut(string memberRef, [FromBody] JArray groupRefs) + [HttpPost("members/{memberRef}/groups/add")] + public async Task AddMemberGroups(string memberRef, [FromBody] JArray groupRefs) { if (groupRefs.Count == 0) throw Errors.GenericBadRequest; @@ -219,8 +209,8 @@ namespace PluralKit.API return NoContent(); } - [HttpDelete("members/{memberRef}/groups")] - public async Task MemberGroupsDelete(string memberRef, [FromBody] JArray groupRefs) + [HttpPost("members/{memberRef}/groups/remove")] + public async Task RemoveMemberGroups(string memberRef, [FromBody] JArray groupRefs) { if (groupRefs.Count == 0) throw Errors.GenericBadRequest; @@ -241,7 +231,7 @@ namespace PluralKit.API var group = await ResolveGroup(groupRef); if (group == null) - throw Errors.GroupNotFound; + throw Errors.GroupNotFoundWithRef(groupRef); if (group.System != system.Id) throw Errors.NotOwnGroupErrorWithRef(groupRef); @@ -253,5 +243,39 @@ namespace PluralKit.API 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/Errors.cs b/PluralKit.API/Errors.cs index 2db7d3a8..90281455 100644 --- a/PluralKit.API/Errors.cs +++ b/PluralKit.API/Errors.cs @@ -71,12 +71,14 @@ namespace PluralKit.API 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 GroupNotFound = new(404, 20003, "Group not found."); - public static PKError MessageNotFound = new(404, 20004, "Message not found."); - public static PKError SwitchNotFound = new(404, 20005, "Switch not found."); - public static PKError SwitchNotFoundPublic = new(404, 20005, "Switch not found, switch associated with different system, or unauthorized to view front history."); - public static PKError SystemGuildNotFound = new(404, 20006, "No system guild settings found for target guild."); - public static PKError MemberGuildNotFound = new(404, 20007, "No member guild settings found for target guild."); + 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"); diff --git a/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs b/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs index a42ffb70..06ff7319 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs @@ -76,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/docs/content/api/endpoints.md b/docs/content/api/endpoints.md index 50606fc7..03ab7cb8 100644 --- a/docs/content/api/endpoints.md +++ b/docs/content/api/endpoints.md @@ -89,20 +89,26 @@ GET `/members/{memberRef}/groups` ### Add Member To Groups -PUT `/members/{memberRef}/groups` +POST `/members/{memberRef}/groups/add` -::: warning -Not all HTTP implementations support PUT requests with a body. If yours does not, consider using the [Add Member To Group](#add-member-to-group) endpoint instead. -::: +Takes a list of group references as input. Returns 204 No Content on success. ### Remove Member From Groups -DELETE `/members/{memberRef}/groups` +POST `/members/{memberRef}/groups/remove` -::: warning -Not all HTTP implementations support DELETE requests with a body. If yours does not, consider using the [Remove Member From Group](#remove-member-from-group) endpoint instead. +::: 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}` @@ -164,30 +170,29 @@ Returns 204 No Content on success. GET `/groups/{groupRef}/members` -### Add Member To Group - -PUT `/groups/{groupRef}/members/{memberRef}` +Returns an array of [member objects](/api/models#member-model). ### Add Members To Group -PUT `/groups/{groupRef}/members` +POST `/groups/{groupRef}/members/add` -::: warning -Not all HTTP implementations support PUT requests with a body. If yours does not, consider using the [Add Member To Group](#add-group-member) endpoint instead. -::: +Takes an array of member references as input. Returns 204 No Content on success. ### Remove Member From Group -DELETE `/groups/{groupRef}/members/{memberRef}` +POST `/groups/{groupRef}/members/remove` -### Remove Members From Group - -DELETE `/groups/{groupRef}/members` - -::: warning -Not all HTTP implementations support DELETE requests with a body. If yours does not, consider using the [Remove Member From Group](#remove-member-from-group) endpoint instead. +::: 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 diff --git a/docs/content/api/errors.md b/docs/content/api/errors.md index a39630f0..1e77d3a3 100644 --- a/docs/content/api/errors.md +++ b/docs/content/api/errors.md @@ -35,12 +35,14 @@ When something goes wrong, the API will send back a 4xx HTTP status code, along |0|401|Missing or invalid Authorization header| |20001|404|System not found.| |20002|404|Member not found.| -|20003|404|Group not found.| -|20004|404|Message not found.| -|20005|404|Switch not found.| -|20005|404|Switch not found, switch associated with different system, or unauthorized to view front history.| -|20006|404|No system guild settings found for target guild.| -|20007|404|No member guild settings found for target guild.| +|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| @@ -48,8 +50,8 @@ When something goes wrong, the API will send back a 4xx HTTP status code, along |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.| +|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.|