Merge branch 'feat/apiv2' into main
This commit is contained in:
commit
f44f83c809
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,6 +8,7 @@ obj/
|
||||
.vscode/
|
||||
tags/
|
||||
.DS_Store
|
||||
mono_crash*
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
67
PluralKit.API/APIJsonExt.cs
Normal file
67
PluralKit.API/APIJsonExt.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
public static class APIJsonExt
|
||||
{
|
||||
public static JArray ToJSON(this IEnumerable<PKShardInfo> shards)
|
||||
{
|
||||
var o = new JArray();
|
||||
|
||||
foreach (var shard in shards)
|
||||
{
|
||||
var s = new JObject();
|
||||
s.Add("id", shard.Id);
|
||||
|
||||
if (shard.Status == PKShardInfo.ShardStatus.Down)
|
||||
s.Add("status", "down");
|
||||
else
|
||||
s.Add("status", "up");
|
||||
|
||||
s.Add("ping", shard.Ping);
|
||||
s.Add("last_heartbeat", shard.LastHeartbeat.ToString());
|
||||
s.Add("last_connection", shard.LastConnection.ToString());
|
||||
|
||||
o.Add(s);
|
||||
}
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
public static JObject ToJson(this ModelRepository.Counts counts)
|
||||
{
|
||||
var o = new JObject();
|
||||
|
||||
o.Add("system_count", counts.SystemCount);
|
||||
o.Add("member_count", counts.MemberCount);
|
||||
o.Add("group_count", counts.GroupCount);
|
||||
o.Add("switch_count", counts.SwitchCount);
|
||||
o.Add("message_count", counts.MessageCount);
|
||||
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
||||
public struct FrontersReturnNew
|
||||
{
|
||||
[JsonProperty("id")] public Guid Uuid { get; set; }
|
||||
[JsonProperty("timestamp")] public Instant Timestamp { get; set; }
|
||||
[JsonProperty("members")] public IEnumerable<JObject> Members { get; set; }
|
||||
}
|
||||
|
||||
public struct SwitchesReturnNew
|
||||
{
|
||||
[JsonProperty("id")] public Guid Uuid { get; set; }
|
||||
[JsonProperty("timestamp")] public Instant Timestamp { get; set; }
|
||||
[JsonProperty("members")] public IEnumerable<string> Members { get; set; }
|
||||
}
|
||||
|
||||
}
|
98
PluralKit.API/Controllers/PKControllerBase.cs
Normal file
98
PluralKit.API/Controllers/PKControllerBase.cs
Normal file
@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
public class PKControllerBase: ControllerBase
|
||||
{
|
||||
private readonly Guid _requestId = Guid.NewGuid();
|
||||
private readonly Regex _shortIdRegex = new Regex("^[a-z]{5}$");
|
||||
private readonly Regex _snowflakeRegex = new Regex("^[0-9]{17,19}$");
|
||||
|
||||
protected readonly ApiConfig _config;
|
||||
protected readonly IDatabase _db;
|
||||
protected readonly ModelRepository _repo;
|
||||
|
||||
public PKControllerBase(IServiceProvider svc)
|
||||
{
|
||||
_config = svc.GetRequiredService<ApiConfig>();
|
||||
_db = svc.GetRequiredService<IDatabase>();
|
||||
_repo = svc.GetRequiredService<ModelRepository>();
|
||||
}
|
||||
|
||||
protected Task<PKSystem?> ResolveSystem(string systemRef)
|
||||
{
|
||||
if (systemRef == "@me")
|
||||
{
|
||||
HttpContext.Items.TryGetValue("SystemId", out var systemId);
|
||||
if (systemId == null)
|
||||
throw Errors.GenericAuthError;
|
||||
return _repo.GetSystem((SystemId)systemId);
|
||||
}
|
||||
|
||||
if (Guid.TryParse(systemRef, out var guid))
|
||||
return _repo.GetSystemByGuid(guid);
|
||||
|
||||
if (_snowflakeRegex.IsMatch(systemRef))
|
||||
return _repo.GetSystemByAccount(ulong.Parse(systemRef));
|
||||
|
||||
if (_shortIdRegex.IsMatch(systemRef))
|
||||
return _repo.GetSystemByHid(systemRef);
|
||||
|
||||
return Task.FromResult<PKSystem?>(null);
|
||||
}
|
||||
|
||||
protected Task<PKMember?> ResolveMember(string memberRef)
|
||||
{
|
||||
if (Guid.TryParse(memberRef, out var guid))
|
||||
return _repo.GetMemberByGuid(guid);
|
||||
|
||||
if (_shortIdRegex.IsMatch(memberRef))
|
||||
return _repo.GetMemberByHid(memberRef);
|
||||
|
||||
return Task.FromResult<PKMember?>(null);
|
||||
}
|
||||
|
||||
protected Task<PKGroup?> ResolveGroup(string groupRef)
|
||||
{
|
||||
if (Guid.TryParse(groupRef, out var guid))
|
||||
return _repo.GetGroupByGuid(guid);
|
||||
|
||||
if (_shortIdRegex.IsMatch(groupRef))
|
||||
return _repo.GetGroupByHid(groupRef);
|
||||
|
||||
return Task.FromResult<PKGroup?>(null);
|
||||
}
|
||||
|
||||
protected LookupContext ContextFor(PKSystem system)
|
||||
{
|
||||
HttpContext.Items.TryGetValue("SystemId", out var systemId);
|
||||
if (systemId == null) return LookupContext.ByNonOwner;
|
||||
return ((SystemId)systemId) == system.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner;
|
||||
}
|
||||
|
||||
protected LookupContext ContextFor(PKMember member)
|
||||
{
|
||||
HttpContext.Items.TryGetValue("SystemId", out var systemId);
|
||||
if (systemId == null) return LookupContext.ByNonOwner;
|
||||
return ((SystemId)systemId) == member.System ? LookupContext.ByOwner : LookupContext.ByNonOwner;
|
||||
}
|
||||
|
||||
protected LookupContext ContextFor(PKGroup group)
|
||||
{
|
||||
HttpContext.Items.TryGetValue("SystemId", out var systemId);
|
||||
if (systemId == null) return LookupContext.ByNonOwner;
|
||||
return ((SystemId)systemId) == group.System ? LookupContext.ByOwner : LookupContext.ByNonOwner;
|
||||
}
|
||||
}
|
||||
}
|
@ -58,21 +58,21 @@ namespace PluralKit.API
|
||||
await using var tx = await conn.BeginTransactionAsync();
|
||||
var member = await _repo.CreateMember(systemId, properties.Value<string>("name"), conn);
|
||||
|
||||
MemberPatch patch;
|
||||
try
|
||||
{
|
||||
patch = MemberPatch.FromJSON(properties);
|
||||
patch.AssertIsValid();
|
||||
}
|
||||
catch (FieldTooLongError e)
|
||||
var patch = MemberPatch.FromJSON(properties);
|
||||
|
||||
patch.AssertIsValid();
|
||||
if (patch.Errors.Count > 0)
|
||||
{
|
||||
await tx.RollbackAsync();
|
||||
return BadRequest(e.Message);
|
||||
}
|
||||
catch (ValidationError e)
|
||||
{
|
||||
await tx.RollbackAsync();
|
||||
return BadRequest($"Request field '{e.Message}' is invalid.");
|
||||
|
||||
var err = patch.Errors[0];
|
||||
if (err is FieldTooLongError)
|
||||
return BadRequest($"Field {err.Key} is too long "
|
||||
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
|
||||
else if (err.Text != null)
|
||||
return BadRequest(err.Text);
|
||||
else
|
||||
return BadRequest($"Field {err.Key} is invalid.");
|
||||
}
|
||||
|
||||
member = await _repo.UpdateMember(member.Id, patch, conn);
|
||||
@ -90,19 +90,19 @@ namespace PluralKit.API
|
||||
var res = await _auth.AuthorizeAsync(User, member, "EditMember");
|
||||
if (!res.Succeeded) return Unauthorized($"Member '{hid}' is not part of your system.");
|
||||
|
||||
MemberPatch patch;
|
||||
try
|
||||
var patch = MemberPatch.FromJSON(changes);
|
||||
|
||||
patch.AssertIsValid();
|
||||
if (patch.Errors.Count > 0)
|
||||
{
|
||||
patch = MemberPatch.FromJSON(changes);
|
||||
patch.AssertIsValid();
|
||||
}
|
||||
catch (FieldTooLongError e)
|
||||
{
|
||||
return BadRequest(e.Message);
|
||||
}
|
||||
catch (ValidationError e)
|
||||
{
|
||||
return BadRequest($"Request field '{e.Message}' is invalid.");
|
||||
var err = patch.Errors[0];
|
||||
if (err is FieldTooLongError)
|
||||
return BadRequest($"Field {err.Key} is too long "
|
||||
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
|
||||
else if (err.Text != null)
|
||||
return BadRequest(err.Text);
|
||||
else
|
||||
return BadRequest($"Field {err.Key} is invalid.");
|
||||
}
|
||||
|
||||
var newMember = await _repo.UpdateMember(member.Id, patch);
|
||||
|
@ -28,7 +28,7 @@ namespace PluralKit.API
|
||||
public async Task<ActionResult<JObject>> GetMeta()
|
||||
{
|
||||
await using var conn = await _db.Obtain();
|
||||
var shards = await _repo.GetShards(conn);
|
||||
var shards = await _repo.GetShards();
|
||||
|
||||
var o = new JObject();
|
||||
o.Add("shards", shards.ToJSON());
|
||||
@ -37,32 +37,4 @@ namespace PluralKit.API
|
||||
return Ok(o);
|
||||
}
|
||||
}
|
||||
|
||||
public static class MetaJsonExt
|
||||
{
|
||||
public static JArray ToJSON(this IEnumerable<PKShardInfo> shards)
|
||||
{
|
||||
var o = new JArray();
|
||||
|
||||
foreach (var shard in shards)
|
||||
{
|
||||
var s = new JObject();
|
||||
s.Add("id", shard.Id);
|
||||
|
||||
if (shard.Status == PKShardInfo.ShardStatus.Down)
|
||||
s.Add("status", "down");
|
||||
else
|
||||
s.Add("status", "up");
|
||||
|
||||
s.Add("ping", shard.Ping);
|
||||
s.Add("last_heartbeat", shard.LastHeartbeat.ToString());
|
||||
s.Add("last_connection", shard.LastConnection.ToString());
|
||||
|
||||
o.Add(s);
|
||||
}
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -32,6 +32,7 @@ namespace PluralKit.API
|
||||
|
||||
public struct PostSwitchParams
|
||||
{
|
||||
public Instant? Timestamp { get; set; }
|
||||
public ICollection<string> Members { get; set; }
|
||||
}
|
||||
|
||||
@ -132,19 +133,17 @@ namespace PluralKit.API
|
||||
{
|
||||
var system = await _repo.GetSystem(User.CurrentSystem());
|
||||
|
||||
SystemPatch patch;
|
||||
try
|
||||
var patch = SystemPatch.FromJSON(changes);
|
||||
|
||||
patch.AssertIsValid();
|
||||
if (patch.Errors.Count > 0)
|
||||
{
|
||||
patch = SystemPatch.FromJSON(changes);
|
||||
patch.AssertIsValid();
|
||||
}
|
||||
catch (FieldTooLongError e)
|
||||
{
|
||||
return BadRequest(e.Message);
|
||||
}
|
||||
catch (ValidationError e)
|
||||
{
|
||||
return BadRequest($"Request field '{e.Message}' is invalid.");
|
||||
var err = patch.Errors[0];
|
||||
if (err is FieldTooLongError)
|
||||
return BadRequest($"Field {err.Key} is too long "
|
||||
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
|
||||
|
||||
return BadRequest($"Field {err.Key} is invalid.");
|
||||
}
|
||||
|
||||
system = await _repo.UpdateSystem(system!.Id, patch);
|
||||
|
147
PluralKit.API/Controllers/v2/DiscordControllerV2.cs
Normal file
147
PluralKit.API/Controllers/v2/DiscordControllerV2.cs
Normal file
@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
[ApiController]
|
||||
[ApiVersion("2.0")]
|
||||
[Route("v{version:apiVersion}")]
|
||||
public class GuildControllerV2: PKControllerBase
|
||||
{
|
||||
public GuildControllerV2(IServiceProvider svc) : base(svc) { }
|
||||
|
||||
|
||||
[HttpGet("systems/@me/guilds/{guild_id}")]
|
||||
public async Task<IActionResult> SystemGuildGet(ulong guild_id)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false);
|
||||
if (settings == null)
|
||||
throw Errors.SystemGuildNotFound;
|
||||
|
||||
PKMember member = null;
|
||||
if (settings.AutoproxyMember != null)
|
||||
member = await _repo.GetMember(settings.AutoproxyMember.Value);
|
||||
|
||||
return Ok(settings.ToJson(member?.Hid));
|
||||
}
|
||||
|
||||
[HttpPatch("systems/@me/guilds/{guild_id}")]
|
||||
public async Task<IActionResult> DoSystemGuildPatch(ulong guild_id, [FromBody] JObject data)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
var settings = await _repo.GetSystemGuild(guild_id, system.Id, defaultInsert: false);
|
||||
if (settings == null)
|
||||
throw Errors.SystemGuildNotFound;
|
||||
|
||||
MemberId? memberId = null;
|
||||
if (data.ContainsKey("autoproxy_member"))
|
||||
{
|
||||
if (data["autoproxy_member"].Type != JTokenType.Null)
|
||||
{
|
||||
var member = await ResolveMember(data.Value<string>("autoproxy_member"));
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
|
||||
memberId = member.Id;
|
||||
}
|
||||
}
|
||||
else
|
||||
memberId = settings.AutoproxyMember;
|
||||
|
||||
var patch = SystemGuildPatch.FromJson(data, memberId);
|
||||
|
||||
patch.AssertIsValid();
|
||||
if (patch.Errors.Count > 0)
|
||||
throw new ModelParseError(patch.Errors);
|
||||
|
||||
// this is less than great, but at least it's legible
|
||||
if (patch.AutoproxyMember.Value == null)
|
||||
if (patch.AutoproxyMode.IsPresent)
|
||||
{
|
||||
if (patch.AutoproxyMode.Value == AutoproxyMode.Member)
|
||||
throw Errors.MissingAutoproxyMember;
|
||||
}
|
||||
else if (settings.AutoproxyMode == AutoproxyMode.Member)
|
||||
throw Errors.MissingAutoproxyMember;
|
||||
|
||||
var newSettings = await _repo.UpdateSystemGuild(system.Id, guild_id, patch);
|
||||
|
||||
PKMember? newMember = null;
|
||||
if (newSettings.AutoproxyMember != null)
|
||||
newMember = await _repo.GetMember(newSettings.AutoproxyMember.Value);
|
||||
return Ok(newSettings.ToJson(newMember?.Hid));
|
||||
}
|
||||
|
||||
[HttpGet("members/{memberRef}/guilds/{guild_id}")]
|
||||
public async Task<IActionResult> MemberGuildGet(string memberRef, ulong guild_id)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberError;
|
||||
|
||||
var settings = await _repo.GetMemberGuild(guild_id, member.Id, defaultInsert: false);
|
||||
if (settings == null)
|
||||
throw Errors.MemberGuildNotFound;
|
||||
|
||||
return Ok(settings.ToJson());
|
||||
}
|
||||
|
||||
[HttpPatch("members/{memberRef}/guilds/{guild_id}")]
|
||||
public async Task<IActionResult> DoMemberGuildPatch(string memberRef, ulong guild_id, [FromBody] JObject data)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberError;
|
||||
|
||||
var settings = await _repo.GetMemberGuild(guild_id, member.Id, defaultInsert: false);
|
||||
if (settings == null)
|
||||
throw Errors.MemberGuildNotFound;
|
||||
|
||||
var patch = MemberGuildPatch.FromJson(data);
|
||||
|
||||
patch.AssertIsValid();
|
||||
if (patch.Errors.Count > 0)
|
||||
throw new ModelParseError(patch.Errors);
|
||||
|
||||
var newSettings = await _repo.UpdateMemberGuild(member.Id, guild_id, patch);
|
||||
return Ok(newSettings.ToJson());
|
||||
}
|
||||
|
||||
[HttpGet("messages/{messageId}")]
|
||||
public async Task<ActionResult<MessageReturn>> MessageGet(ulong messageId)
|
||||
{
|
||||
var msg = await _db.Execute(c => _repo.GetMessage(c, messageId));
|
||||
if (msg == null)
|
||||
throw Errors.MessageNotFound;
|
||||
|
||||
var ctx = this.ContextFor(msg.System);
|
||||
|
||||
// todo: don't rely on v1 stuff
|
||||
return new MessageReturn
|
||||
{
|
||||
Timestamp = Instant.FromUnixTimeMilliseconds((long)(msg.Message.Mid >> 22) + 1420070400000),
|
||||
Id = msg.Message.Mid.ToString(),
|
||||
Channel = msg.Message.Channel.ToString(),
|
||||
Sender = msg.Message.Sender.ToString(),
|
||||
System = msg.System.ToJson(ctx, v: APIVersion.V2),
|
||||
Member = msg.Member.ToJson(ctx, v: APIVersion.V2),
|
||||
Original = msg.Message.OriginalMid?.ToString()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
110
PluralKit.API/Controllers/v2/GroupControllerV2.cs
Normal file
110
PluralKit.API/Controllers/v2/GroupControllerV2.cs
Normal file
@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
[ApiController]
|
||||
[ApiVersion("2.0")]
|
||||
[Route("v{version:apiVersion}")]
|
||||
public class GroupControllerV2: PKControllerBase
|
||||
{
|
||||
public GroupControllerV2(IServiceProvider svc) : base(svc) { }
|
||||
|
||||
[HttpGet("systems/{systemRef}/groups")]
|
||||
public async Task<IActionResult> GetSystemGroups(string systemRef)
|
||||
{
|
||||
var system = await ResolveSystem(systemRef);
|
||||
if (system == null)
|
||||
throw Errors.SystemNotFound;
|
||||
|
||||
var ctx = this.ContextFor(system);
|
||||
|
||||
if (!system.GroupListPrivacy.CanAccess(User.ContextFor(system)))
|
||||
throw Errors.UnauthorizedGroupList;
|
||||
|
||||
var groups = _repo.GetSystemGroups(system.Id);
|
||||
return Ok(await groups
|
||||
.Where(g => g.Visibility.CanAccess(ctx))
|
||||
.Select(g => g.ToJson(ctx))
|
||||
.ToListAsync());
|
||||
}
|
||||
|
||||
[HttpPost("groups")]
|
||||
public async Task<IActionResult> GroupCreate([FromBody] JObject data)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
var patch = GroupPatch.FromJson(data);
|
||||
patch.AssertIsValid();
|
||||
if (!patch.Name.IsPresent)
|
||||
patch.Errors.Add(new ValidationError("name", $"Key 'name' is required when creating new group."));
|
||||
if (patch.Errors.Count > 0)
|
||||
throw new ModelParseError(patch.Errors);
|
||||
|
||||
using var conn = await _db.Obtain();
|
||||
using var tx = await conn.BeginTransactionAsync();
|
||||
|
||||
var newGroup = await _repo.CreateGroup(system.Id, patch.Name.Value, conn);
|
||||
newGroup = await _repo.UpdateGroup(newGroup.Id, patch, conn);
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Ok(newGroup.ToJson(LookupContext.ByOwner));
|
||||
}
|
||||
|
||||
[HttpGet("groups/{groupRef}")]
|
||||
public async Task<IActionResult> GroupGet(string groupRef)
|
||||
{
|
||||
var group = await ResolveGroup(groupRef);
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFound;
|
||||
|
||||
var system = await _repo.GetSystem(group.System);
|
||||
|
||||
return Ok(group.ToJson(this.ContextFor(group), systemStr: system.Hid));
|
||||
}
|
||||
|
||||
[HttpPatch("groups/{groupRef}")]
|
||||
public async Task<IActionResult> DoGroupPatch(string groupRef, [FromBody] JObject data)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
var group = await ResolveGroup(groupRef);
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFound;
|
||||
if (group.System != system.Id)
|
||||
throw Errors.NotOwnGroupError;
|
||||
|
||||
var patch = GroupPatch.FromJson(data);
|
||||
|
||||
patch.AssertIsValid();
|
||||
if (patch.Errors.Count > 0)
|
||||
throw new ModelParseError(patch.Errors);
|
||||
|
||||
var newGroup = await _repo.UpdateGroup(group.Id, patch);
|
||||
return Ok(newGroup.ToJson(LookupContext.ByOwner));
|
||||
}
|
||||
|
||||
[HttpDelete("groups/{groupRef}")]
|
||||
public async Task<IActionResult> GroupDelete(string groupRef)
|
||||
{
|
||||
var group = await ResolveGroup(groupRef);
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFound;
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
if (system.Id != group.System)
|
||||
throw Errors.NotOwnGroupError;
|
||||
|
||||
await _repo.DeleteGroup(group.Id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
281
PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs
Normal file
281
PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs
Normal file
@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Dapper;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
[ApiController]
|
||||
[ApiVersion("2.0")]
|
||||
[Route("v{version:apiVersion}")]
|
||||
public class GroupMemberControllerV2: PKControllerBase
|
||||
{
|
||||
public GroupMemberControllerV2(IServiceProvider svc) : base(svc) { }
|
||||
|
||||
[HttpGet("groups/{groupRef}/members")]
|
||||
public async Task<IActionResult> GetGroupMembers(string groupRef)
|
||||
{
|
||||
var group = await ResolveGroup(groupRef);
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFound;
|
||||
|
||||
var ctx = this.ContextFor(group);
|
||||
|
||||
if (!group.ListPrivacy.CanAccess(ctx))
|
||||
throw Errors.UnauthorizedGroupMemberList;
|
||||
|
||||
var members = _repo.GetGroupMembers(group.Id).Where(m => m.MemberVisibility.CanAccess(ctx));
|
||||
|
||||
var o = new JArray();
|
||||
|
||||
await foreach (var member in members)
|
||||
o.Add(member.ToJson(ctx, v: APIVersion.V2));
|
||||
|
||||
return Ok(o);
|
||||
}
|
||||
|
||||
[HttpPost("groups/{groupRef}/members/add")]
|
||||
public async Task<IActionResult> AddGroupMembers(string groupRef, [FromBody] JArray memberRefs)
|
||||
{
|
||||
if (memberRefs.Count == 0)
|
||||
throw Errors.GenericBadRequest;
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
var group = await ResolveGroup(groupRef);
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFound;
|
||||
if (group.System != system.Id)
|
||||
throw Errors.NotOwnGroupError;
|
||||
|
||||
var members = new List<MemberId>();
|
||||
|
||||
foreach (var JmemberRef in memberRefs)
|
||||
{
|
||||
var memberRef = JmemberRef.Value<string>();
|
||||
var member = await ResolveMember(memberRef);
|
||||
|
||||
// todo: have a list of these errors instead of immediately throwing
|
||||
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFoundWithRef(memberRef);
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberErrorWithRef(memberRef);
|
||||
|
||||
members.Add(member.Id);
|
||||
}
|
||||
|
||||
var existingMembers = await _repo.GetGroupMembers(group.Id).Select(x => x.Id).ToListAsync();
|
||||
members = members.Where(x => !existingMembers.Contains(x)).ToList();
|
||||
|
||||
if (members.Count > 0)
|
||||
await _repo.AddMembersToGroup(group.Id, members);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("groups/{groupRef}/members/remove")]
|
||||
public async Task<IActionResult> RemoveGroupMembers(string groupRef, [FromBody] JArray memberRefs)
|
||||
{
|
||||
if (memberRefs.Count == 0)
|
||||
throw Errors.GenericBadRequest;
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
var group = await ResolveGroup(groupRef);
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFound;
|
||||
if (group.System != system.Id)
|
||||
throw Errors.NotOwnGroupError;
|
||||
|
||||
var members = new List<MemberId>();
|
||||
|
||||
foreach (var JmemberRef in memberRefs)
|
||||
{
|
||||
var memberRef = JmemberRef.Value<string>();
|
||||
var member = await ResolveMember(memberRef);
|
||||
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFoundWithRef(memberRef);
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberErrorWithRef(memberRef);
|
||||
|
||||
members.Add(member.Id);
|
||||
}
|
||||
|
||||
await _repo.RemoveMembersFromGroup(group.Id, members);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("groups/{groupRef}/members/overwrite")]
|
||||
public async Task<IActionResult> OverwriteGroupMembers(string groupRef, [FromBody] JArray memberRefs)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
var group = await ResolveGroup(groupRef);
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFound;
|
||||
if (group.System != system.Id)
|
||||
throw Errors.NotOwnGroupError;
|
||||
|
||||
var members = new List<MemberId>();
|
||||
|
||||
foreach (var JmemberRef in memberRefs)
|
||||
{
|
||||
var memberRef = JmemberRef.Value<string>();
|
||||
var member = await ResolveMember(memberRef);
|
||||
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFoundWithRef(memberRef);
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberErrorWithRef(memberRef);
|
||||
|
||||
members.Add(member.Id);
|
||||
}
|
||||
|
||||
await _repo.ClearGroupMembers(group.Id);
|
||||
|
||||
if (members.Count > 0)
|
||||
await _repo.AddMembersToGroup(group.Id, members);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("members/{memberRef}/groups")]
|
||||
public async Task<IActionResult> GetMemberGroups(string memberRef)
|
||||
{
|
||||
var member = await ResolveMember(memberRef);
|
||||
var ctx = this.ContextFor(member);
|
||||
|
||||
var system = await _repo.GetSystem(member.System);
|
||||
if (!system.GroupListPrivacy.CanAccess(ctx))
|
||||
throw Errors.UnauthorizedGroupList;
|
||||
|
||||
var groups = _repo.GetMemberGroups(member.Id).Where(g => g.Visibility.CanAccess(ctx));
|
||||
|
||||
var o = new JArray();
|
||||
|
||||
await foreach (var group in groups)
|
||||
o.Add(group.ToJson(ctx));
|
||||
|
||||
return Ok(o);
|
||||
}
|
||||
|
||||
[HttpPost("members/{memberRef}/groups/add")]
|
||||
public async Task<IActionResult> AddMemberGroups(string memberRef, [FromBody] JArray groupRefs)
|
||||
{
|
||||
if (groupRefs.Count == 0)
|
||||
throw Errors.GenericBadRequest;
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberError;
|
||||
|
||||
var groups = new List<GroupId>();
|
||||
|
||||
foreach (var JgroupRef in groupRefs)
|
||||
{
|
||||
var groupRef = JgroupRef.Value<string>();
|
||||
var group = await ResolveGroup(groupRef);
|
||||
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFound;
|
||||
if (group.System != system.Id)
|
||||
throw Errors.NotOwnGroupErrorWithRef(groupRef);
|
||||
|
||||
groups.Add(group.Id);
|
||||
}
|
||||
|
||||
var existingGroups = await _repo.GetMemberGroups(member.Id).Select(x => x.Id).ToListAsync();
|
||||
groups = groups.Where(x => !existingGroups.Contains(x)).ToList();
|
||||
|
||||
if (groups.Count > 0)
|
||||
await _repo.AddGroupsToMember(member.Id, groups);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("members/{memberRef}/groups/remove")]
|
||||
public async Task<IActionResult> RemoveMemberGroups(string memberRef, [FromBody] JArray groupRefs)
|
||||
{
|
||||
if (groupRefs.Count == 0)
|
||||
throw Errors.GenericBadRequest;
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberError;
|
||||
|
||||
var groups = new List<GroupId>();
|
||||
|
||||
foreach (var JgroupRef in groupRefs)
|
||||
{
|
||||
var groupRef = JgroupRef.Value<string>();
|
||||
var group = await ResolveGroup(groupRef);
|
||||
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFoundWithRef(groupRef);
|
||||
if (group.System != system.Id)
|
||||
throw Errors.NotOwnGroupErrorWithRef(groupRef);
|
||||
|
||||
groups.Add(group.Id);
|
||||
}
|
||||
|
||||
await _repo.RemoveGroupsFromMember(member.Id, groups);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("members/{memberRef}/groups/overwrite")]
|
||||
public async Task<IActionResult> OverwriteMemberGroups(string memberRef, [FromBody] JArray groupRefs)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberError;
|
||||
|
||||
var groups = new List<GroupId>();
|
||||
|
||||
foreach (var JgroupRef in groupRefs)
|
||||
{
|
||||
var groupRef = JgroupRef.Value<string>();
|
||||
var group = await ResolveGroup(groupRef);
|
||||
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFoundWithRef(groupRef);
|
||||
if (group.System != system.Id)
|
||||
throw Errors.NotOwnGroupErrorWithRef(groupRef);
|
||||
|
||||
groups.Add(group.Id);
|
||||
}
|
||||
|
||||
await _repo.ClearMemberGroups(member.Id);
|
||||
|
||||
if (groups.Count > 0)
|
||||
await _repo.AddGroupsToMember(member.Id, groups);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
111
PluralKit.API/Controllers/v2/MemberControllerV2.cs
Normal file
111
PluralKit.API/Controllers/v2/MemberControllerV2.cs
Normal file
@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
[ApiController]
|
||||
[ApiVersion("2.0")]
|
||||
[Route("v{version:apiVersion}")]
|
||||
public class MemberControllerV2: PKControllerBase
|
||||
{
|
||||
public MemberControllerV2(IServiceProvider svc) : base(svc) { }
|
||||
|
||||
|
||||
[HttpGet("systems/{systemRef}/members")]
|
||||
public async Task<IActionResult> GetSystemMembers(string systemRef)
|
||||
{
|
||||
var system = await ResolveSystem(systemRef);
|
||||
if (system == null)
|
||||
throw Errors.SystemNotFound;
|
||||
|
||||
var ctx = this.ContextFor(system);
|
||||
|
||||
if (!system.MemberListPrivacy.CanAccess(this.ContextFor(system)))
|
||||
throw Errors.UnauthorizedMemberList;
|
||||
|
||||
var members = _repo.GetSystemMembers(system.Id);
|
||||
return Ok(await members
|
||||
.Where(m => m.MemberVisibility.CanAccess(ctx))
|
||||
.Select(m => m.ToJson(ctx, v: APIVersion.V2))
|
||||
.ToListAsync());
|
||||
}
|
||||
|
||||
[HttpPost("members")]
|
||||
public async Task<IActionResult> MemberCreate([FromBody] JObject data)
|
||||
{
|
||||
var patch = MemberPatch.FromJSON(data);
|
||||
patch.AssertIsValid();
|
||||
if (!patch.Name.IsPresent)
|
||||
patch.Errors.Add(new ValidationError("name", $"Key 'name' is required when creating new member."));
|
||||
if (patch.Errors.Count > 0)
|
||||
throw new ModelParseError(patch.Errors);
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
using var conn = await _db.Obtain();
|
||||
using var tx = await conn.BeginTransactionAsync();
|
||||
|
||||
var newMember = await _repo.CreateMember(system.Id, patch.Name.Value, conn);
|
||||
newMember = await _repo.UpdateMember(newMember.Id, patch, conn);
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Ok(newMember.ToJson(LookupContext.ByOwner, v: APIVersion.V2));
|
||||
}
|
||||
|
||||
[HttpGet("members/{memberRef}")]
|
||||
public async Task<IActionResult> MemberGet(string memberRef)
|
||||
{
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
|
||||
var system = await _repo.GetSystem(member.System);
|
||||
|
||||
return Ok(member.ToJson(this.ContextFor(member), systemStr: system.Hid, v: APIVersion.V2));
|
||||
}
|
||||
|
||||
[HttpPatch("members/{memberRef}")]
|
||||
public async Task<IActionResult> DoMemberPatch(string memberRef, [FromBody] JObject data)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberError;
|
||||
|
||||
var patch = MemberPatch.FromJSON(data, APIVersion.V2);
|
||||
|
||||
patch.AssertIsValid();
|
||||
if (patch.Errors.Count > 0)
|
||||
throw new ModelParseError(patch.Errors);
|
||||
|
||||
var newMember = await _repo.UpdateMember(member.Id, patch);
|
||||
return Ok(newMember.ToJson(LookupContext.ByOwner, v: APIVersion.V2));
|
||||
}
|
||||
|
||||
[HttpDelete("members/{memberRef}")]
|
||||
public async Task<IActionResult> MemberDelete(string memberRef)
|
||||
{
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
if (system.Id != member.System)
|
||||
throw Errors.NotOwnMemberError;
|
||||
|
||||
await _repo.DeleteMember(member.Id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
30
PluralKit.API/Controllers/v2/PrivateControllerV2.cs
Normal file
30
PluralKit.API/Controllers/v2/PrivateControllerV2.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
[ApiController]
|
||||
[ApiVersion("2.0")]
|
||||
[Route("v{version:apiVersion}")]
|
||||
public class PrivateControllerV2: PKControllerBase
|
||||
{
|
||||
public PrivateControllerV2(IServiceProvider svc) : base(svc) { }
|
||||
|
||||
[HttpGet("meta")]
|
||||
public async Task<ActionResult<JObject>> Meta()
|
||||
{
|
||||
var shards = await _repo.GetShards();
|
||||
var stats = await _repo.GetStats();
|
||||
|
||||
var o = new JObject();
|
||||
o.Add("shards", shards.ToJSON());
|
||||
o.Add("stats", stats.ToJson());
|
||||
|
||||
return Ok(o);
|
||||
}
|
||||
}
|
||||
}
|
254
PluralKit.API/Controllers/v2/SwitchControllerV2.cs
Normal file
254
PluralKit.API/Controllers/v2/SwitchControllerV2.cs
Normal file
@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Dapper;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
[ApiController]
|
||||
[ApiVersion("2.0")]
|
||||
[Route("v{version:apiVersion}")]
|
||||
public class SwitchControllerV2: PKControllerBase
|
||||
{
|
||||
public SwitchControllerV2(IServiceProvider svc) : base(svc) { }
|
||||
|
||||
|
||||
[HttpGet("systems/{systemRef}/switches")]
|
||||
public async Task<IActionResult> GetSystemSwitches(string systemRef, [FromQuery(Name = "before")] Instant? before, [FromQuery(Name = "limit")] int? limit)
|
||||
{
|
||||
var system = await ResolveSystem(systemRef);
|
||||
if (system == null)
|
||||
throw Errors.SystemNotFound;
|
||||
|
||||
var ctx = this.ContextFor(system);
|
||||
|
||||
if (!system.FrontHistoryPrivacy.CanAccess(ctx))
|
||||
throw Errors.UnauthorizedFrontHistory;
|
||||
|
||||
if (before == null)
|
||||
before = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
if (limit == null || limit > 100)
|
||||
limit = 100;
|
||||
|
||||
var res = await _db.Execute(conn => conn.QueryAsync<SwitchesReturnNew>(
|
||||
@"select *, array(
|
||||
select members.hid from switch_members, members
|
||||
where switch_members.switch = switches.id and members.id = switch_members.member
|
||||
) as members from switches
|
||||
where switches.system = @System and switches.timestamp <= @Before
|
||||
order by switches.timestamp desc
|
||||
limit @Limit;", new { System = system.Id, Before = before, Limit = limit }));
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
[HttpGet("systems/{systemRef}/fronters")]
|
||||
public async Task<IActionResult> GetSystemFronters(string systemRef)
|
||||
{
|
||||
var system = await ResolveSystem(systemRef);
|
||||
if (system == null)
|
||||
throw Errors.SystemNotFound;
|
||||
|
||||
var ctx = this.ContextFor(system);
|
||||
|
||||
if (!system.FrontPrivacy.CanAccess(ctx))
|
||||
throw Errors.UnauthorizedCurrentFronters;
|
||||
|
||||
var sw = await _repo.GetLatestSwitch(system.Id);
|
||||
if (sw == null)
|
||||
return NoContent();
|
||||
|
||||
var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id));
|
||||
return Ok(new FrontersReturnNew
|
||||
{
|
||||
Timestamp = sw.Timestamp,
|
||||
Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync(),
|
||||
Uuid = sw.Uuid,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("systems/@me/switches")]
|
||||
public async Task<IActionResult> SwitchCreate([FromBody] PostSwitchParams data)
|
||||
{
|
||||
if (data.Members.Distinct().Count() != data.Members.Count)
|
||||
throw Errors.DuplicateMembersInList;
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
if (data.Timestamp != null && await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(data.Timestamp.Value))
|
||||
throw Errors.SameSwitchTimestampError;
|
||||
|
||||
var members = new List<PKMember>();
|
||||
|
||||
foreach (var memberRef in data.Members)
|
||||
{
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
// todo: which member
|
||||
throw Errors.MemberNotFound;
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberErrorWithRef(memberRef);
|
||||
members.Add(member);
|
||||
}
|
||||
|
||||
// We get the current switch, if it exists
|
||||
var latestSwitch = await _repo.GetLatestSwitch(system.Id);
|
||||
if (latestSwitch != null && (data.Timestamp == null || data.Timestamp > latestSwitch.Timestamp))
|
||||
{
|
||||
var latestSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, latestSwitch.Id));
|
||||
|
||||
// Bail if this switch is identical to the latest one
|
||||
if (await latestSwitchMembers.Select(m => m.Hid).SequenceEqualAsync(members.Select(m => m.Hid).ToAsyncEnumerable()))
|
||||
throw Errors.SameSwitchMembersError;
|
||||
}
|
||||
|
||||
var newSwitch = await _db.Execute(conn => _repo.AddSwitch(conn, system.Id, members.Select(m => m.Id).ToList()));
|
||||
if (data.Timestamp != null)
|
||||
await _repo.MoveSwitch(newSwitch.Id, data.Timestamp.Value);
|
||||
|
||||
return Ok(new FrontersReturnNew
|
||||
{
|
||||
Uuid = newSwitch.Uuid,
|
||||
Timestamp = data.Timestamp != null ? data.Timestamp.Value : newSwitch.Timestamp,
|
||||
Members = members.Select(x => x.ToJson(LookupContext.ByOwner, v: APIVersion.V2)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("systems/{systemRef}/switches/{switchRef}")]
|
||||
public async Task<IActionResult> SwitchGet(string systemRef, string switchRef)
|
||||
{
|
||||
if (!Guid.TryParse(switchRef, out var switchId))
|
||||
throw Errors.InvalidSwitchId;
|
||||
|
||||
var system = await ResolveSystem(systemRef);
|
||||
if (system == null)
|
||||
throw Errors.SystemNotFound;
|
||||
|
||||
var sw = await _repo.GetSwitchByUuid(switchId);
|
||||
if (sw == null || system.Id != sw.System)
|
||||
throw Errors.SwitchNotFoundPublic;
|
||||
|
||||
var ctx = this.ContextFor(system);
|
||||
|
||||
if (!system.FrontHistoryPrivacy.CanAccess(ctx))
|
||||
throw Errors.SwitchNotFoundPublic;
|
||||
|
||||
var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id));
|
||||
return Ok(new FrontersReturnNew
|
||||
{
|
||||
Uuid = sw.Uuid,
|
||||
Timestamp = sw.Timestamp,
|
||||
Members = await members.Select(m => m.ToJson(ctx, v: APIVersion.V2)).ToListAsync()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPatch("systems/@me/switches/{switchRef}")]
|
||||
public async Task<IActionResult> SwitchPatch(string switchRef, [FromBody] JObject data)
|
||||
{
|
||||
// for now, don't need to make a PatchObject for this, since it's only one param
|
||||
|
||||
if (!Guid.TryParse(switchRef, out var switchId))
|
||||
throw Errors.InvalidSwitchId;
|
||||
|
||||
var valueStr = data.Value<string>("timestamp").NullIfEmpty();
|
||||
if (valueStr == null)
|
||||
throw new ModelParseError(new List<ValidationError>() { new ValidationError("timestamp", $"Key 'timestamp' is required.") });
|
||||
|
||||
var value = Instant.FromDateTimeOffset(DateTime.Parse(valueStr).ToUniversalTime());
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
if (system == null)
|
||||
throw Errors.SystemNotFound;
|
||||
|
||||
var sw = await _repo.GetSwitchByUuid(switchId);
|
||||
if (sw == null || system.Id != sw.System)
|
||||
throw Errors.SwitchNotFoundPublic;
|
||||
|
||||
if (await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(value))
|
||||
throw Errors.SameSwitchTimestampError;
|
||||
|
||||
await _repo.MoveSwitch(sw.Id, value);
|
||||
|
||||
var members = await _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)).ToListAsync();
|
||||
return Ok(new FrontersReturnNew
|
||||
{
|
||||
Uuid = sw.Uuid,
|
||||
Timestamp = sw.Timestamp,
|
||||
Members = members.Select(x => x.ToJson(LookupContext.ByOwner, v: APIVersion.V2)),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPatch("systems/@me/switches/{switchRef}/members")]
|
||||
public async Task<IActionResult> SwitchMemberPatch(string switchRef, [FromBody] JArray data)
|
||||
{
|
||||
if (!Guid.TryParse(switchRef, out var switchId))
|
||||
|
||||
if (data.Distinct().Count() != data.Count)
|
||||
throw Errors.DuplicateMembersInList;
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
|
||||
var sw = await _repo.GetSwitchByUuid(switchId);
|
||||
if (sw == null)
|
||||
throw Errors.SwitchNotFound;
|
||||
|
||||
var members = new List<PKMember>();
|
||||
|
||||
foreach (var JmemberRef in data)
|
||||
{
|
||||
var memberRef = JmemberRef.Value<string>();
|
||||
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
// todo: which member
|
||||
throw Errors.MemberNotFound;
|
||||
if (member.System != system.Id)
|
||||
throw Errors.NotOwnMemberErrorWithRef(memberRef);
|
||||
|
||||
members.Add(member);
|
||||
}
|
||||
|
||||
var latestSwitchMembers = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id));
|
||||
|
||||
if (await latestSwitchMembers.Select(m => m.Hid).SequenceEqualAsync(members.Select(m => m.Hid).ToAsyncEnumerable()))
|
||||
throw Errors.SameSwitchMembersError;
|
||||
|
||||
await _db.Execute(conn => _repo.EditSwitch(conn, sw.Id, members.Select(x => x.Id).ToList()));
|
||||
return Ok(new FrontersReturnNew
|
||||
{
|
||||
Uuid = sw.Uuid,
|
||||
Timestamp = sw.Timestamp,
|
||||
Members = members.Select(x => x.ToJson(LookupContext.ByOwner, v: APIVersion.V2)),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("systems/@me/switches/{switchRef}")]
|
||||
public async Task<IActionResult> SwitchDelete(string switchRef)
|
||||
{
|
||||
if (!Guid.TryParse(switchRef, out var switchId))
|
||||
throw Errors.InvalidSwitchId;
|
||||
|
||||
var system = await ResolveSystem("@me");
|
||||
var sw = await _repo.GetSwitchByUuid(switchId);
|
||||
if (sw == null || system.Id != sw.System)
|
||||
throw Errors.SwitchNotFoundPublic;
|
||||
|
||||
await _repo.DeleteSwitch(sw.Id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
41
PluralKit.API/Controllers/v2/SystemControllerV2.cs
Normal file
41
PluralKit.API/Controllers/v2/SystemControllerV2.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
[ApiController]
|
||||
[ApiVersion("2.0")]
|
||||
[Route("v{version:apiVersion}/systems")]
|
||||
public class SystemControllerV2: PKControllerBase
|
||||
{
|
||||
public SystemControllerV2(IServiceProvider svc) : base(svc) { }
|
||||
|
||||
[HttpGet("{systemRef}")]
|
||||
public async Task<IActionResult> SystemGet(string systemRef)
|
||||
{
|
||||
var system = await ResolveSystem(systemRef);
|
||||
if (system == null) return NotFound();
|
||||
else return Ok(system.ToJson(this.ContextFor(system), v: APIVersion.V2));
|
||||
}
|
||||
|
||||
[HttpPatch("@me")]
|
||||
public async Task<IActionResult> DoSystemPatch([FromBody] JObject data)
|
||||
{
|
||||
var system = await ResolveSystem("@me");
|
||||
var patch = SystemPatch.FromJSON(data, APIVersion.V2);
|
||||
|
||||
patch.AssertIsValid();
|
||||
if (patch.Errors.Count > 0)
|
||||
throw new ModelParseError(patch.Errors);
|
||||
|
||||
var newSystem = await _repo.UpdateSystem(system.Id, patch);
|
||||
return Ok(newSystem.ToJson(LookupContext.ByOwner, v: APIVersion.V2));
|
||||
}
|
||||
}
|
||||
}
|
116
PluralKit.API/Errors.cs
Normal file
116
PluralKit.API/Errors.cs
Normal file
@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
public class PKError: Exception
|
||||
{
|
||||
public int ResponseCode { get; init; }
|
||||
public int JsonCode { get; init; }
|
||||
public PKError(int code, int json_code, string message) : base(message)
|
||||
{
|
||||
ResponseCode = code;
|
||||
JsonCode = json_code;
|
||||
}
|
||||
|
||||
public JObject ToJson()
|
||||
{
|
||||
var j = new JObject();
|
||||
j.Add("message", this.Message);
|
||||
j.Add("code", this.JsonCode);
|
||||
return j;
|
||||
}
|
||||
}
|
||||
|
||||
public class ModelParseError: PKError
|
||||
{
|
||||
private IEnumerable<ValidationError> _errors { get; init; }
|
||||
public ModelParseError(IEnumerable<ValidationError> errors) : base(400, 40001, "Error parsing JSON model")
|
||||
{
|
||||
_errors = errors;
|
||||
}
|
||||
|
||||
public new JObject ToJson()
|
||||
{
|
||||
var j = base.ToJson();
|
||||
var e = new JObject();
|
||||
|
||||
foreach (var err in _errors)
|
||||
{
|
||||
var o = new JObject();
|
||||
|
||||
if (err is FieldTooLongError fe)
|
||||
{
|
||||
o.Add("message", $"Field {err.Key} is too long.");
|
||||
o.Add("actual_length", fe.ActualLength);
|
||||
o.Add("max_length", fe.MaxLength);
|
||||
}
|
||||
else if (err.Text != null)
|
||||
o.Add("message", err.Text);
|
||||
else
|
||||
o.Add("message", $"Field {err.Key} is invalid.");
|
||||
|
||||
if (e[err.Key] == null)
|
||||
e.Add(err.Key, new JArray());
|
||||
|
||||
(e[err.Key] as JArray).Add(o);
|
||||
}
|
||||
|
||||
j.Add("errors", e);
|
||||
return j;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Errors
|
||||
{
|
||||
public static PKError GenericBadRequest = new(400, 0, "400: Bad Request");
|
||||
public static PKError GenericAuthError = new(401, 0, "401: Missing or invalid Authorization header");
|
||||
public static PKError SystemNotFound = new(404, 20001, "System not found.");
|
||||
public static PKError MemberNotFound = new(404, 20002, "Member not found.");
|
||||
public static PKError MemberNotFoundWithRef(string memberRef) => new(404, 20003, $"Member '{memberRef}' not found.");
|
||||
public static PKError GroupNotFound = new(404, 20004, "Group not found.");
|
||||
public static PKError GroupNotFoundWithRef(string groupRef) => new(404, 20005, $"Group '{groupRef}' not found.");
|
||||
public static PKError MessageNotFound = new(404, 20006, "Message not found.");
|
||||
public static PKError SwitchNotFound = new(404, 20007, "Switch not found.");
|
||||
public static PKError SwitchNotFoundPublic = new(404, 20008, "Switch not found, switch associated with different system, or unauthorized to view front history.");
|
||||
public static PKError SystemGuildNotFound = new(404, 20009, "No system guild settings found for target guild.");
|
||||
public static PKError MemberGuildNotFound = new(404, 20010, "No member guild settings found for target guild.");
|
||||
public static PKError UnauthorizedMemberList = new(403, 30001, "Unauthorized to view member list");
|
||||
public static PKError UnauthorizedGroupList = new(403, 30002, "Unauthorized to view group list");
|
||||
public static PKError UnauthorizedGroupMemberList = new(403, 30003, "Unauthorized to view group member list");
|
||||
public static PKError UnauthorizedCurrentFronters = new(403, 30004, "Unauthorized to view current fronters.");
|
||||
public static PKError UnauthorizedFrontHistory = new(403, 30005, "Unauthorized to view front history.");
|
||||
public static PKError NotOwnMemberError = new(403, 30006, "Target member is not part of your system.");
|
||||
public static PKError NotOwnGroupError = new(403, 30007, "Target group is not part of your system.");
|
||||
// todo: somehow add the memberRef to the JSON
|
||||
public static PKError NotOwnMemberErrorWithRef(string memberRef) => new(403, 30008, $"Member '{memberRef}' is not part of your system.");
|
||||
public static PKError NotOwnGroupErrorWithRef(string groupRef) => new(403, 30009, $"Group '{groupRef}' is not part of your system.");
|
||||
public static PKError MissingAutoproxyMember = new(400, 40002, "Missing autoproxy member for member-mode autoproxy.");
|
||||
public static PKError DuplicateMembersInList = new(400, 40003, "Duplicate members in member list.");
|
||||
public static PKError SameSwitchMembersError = new(400, 40004, "Member list identical to current fronter list.");
|
||||
public static PKError SameSwitchTimestampError = new(400, 40005, "Switch with provided timestamp already exists.");
|
||||
public static PKError InvalidSwitchId = new(400, 40006, "Invalid switch ID.");
|
||||
public static PKError Unimplemented = new(501, 50001, "Unimplemented");
|
||||
}
|
||||
|
||||
public static class APIErrorHandlerExt
|
||||
{
|
||||
public static bool IsUserError(this Exception exc)
|
||||
{
|
||||
// caused by users sending an incorrect JSON type (array where an object is expected, etc)
|
||||
if (exc is InvalidCastException && exc.Message.Contains("Newtonsoft.Json"))
|
||||
return true;
|
||||
|
||||
// Hacky parsing of timestamps results in hacky error handling. Probably fix this one at some point.
|
||||
if (exc is FormatException && exc.Message.Contains("was not recognized as a valid DateTime"))
|
||||
return true;
|
||||
|
||||
// This may expanded at some point.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
using Dapper;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
{
|
||||
public class AuthorizationTokenHandlerMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
public AuthorizationTokenHandlerMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task Invoke(HttpContext ctx, IDatabase db)
|
||||
{
|
||||
ctx.Request.Headers.TryGetValue("authorization", out var authHeaders);
|
||||
if (authHeaders.Count > 0)
|
||||
{
|
||||
var systemId = await db.Execute(conn => conn.QuerySingleOrDefaultAsync<SystemId?>(
|
||||
"select id from systems where token = @token",
|
||||
new { token = authHeaders[0] }
|
||||
));
|
||||
|
||||
if (systemId != null)
|
||||
ctx.Items.Add("SystemId", systemId);
|
||||
}
|
||||
|
||||
await _next.Invoke(ctx);
|
||||
}
|
||||
}
|
||||
}
|
@ -7,7 +7,9 @@ using Autofac;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Versioning;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@ -15,6 +17,10 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.OpenApi.Models;
|
||||
|
||||
using Newtonsoft.Json;
|
||||
|
||||
using Serilog;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.API
|
||||
@ -51,7 +57,16 @@ namespace PluralKit.API
|
||||
|
||||
services.AddControllers()
|
||||
.SetCompatibilityVersion(CompatibilityVersion.Latest)
|
||||
.AddNewtonsoftJson(); // sorry MS, this just does *more*
|
||||
// sorry MS, this just does *more*
|
||||
.AddNewtonsoftJson((opts) =>
|
||||
{
|
||||
// ... though by default it messes up timestamps in JSON
|
||||
opts.SerializerSettings.DateParseHandling = DateParseHandling.None;
|
||||
})
|
||||
.ConfigureApiBehaviorOptions(options =>
|
||||
options.InvalidModelStateResponseFactory = (context) =>
|
||||
throw Errors.GenericBadRequest
|
||||
);
|
||||
|
||||
services.AddApiVersioning();
|
||||
|
||||
@ -91,7 +106,7 @@ namespace PluralKit.API
|
||||
builder.RegisterInstance(InitUtils.BuildConfiguration(Environment.GetCommandLineArgs()).Build())
|
||||
.As<IConfiguration>();
|
||||
builder.RegisterModule(new ConfigModule<ApiConfig>("API"));
|
||||
builder.RegisterModule(new LoggingModule("api"));
|
||||
builder.RegisterModule(new LoggingModule("api", cfg: new LoggerConfiguration().Filter.ByExcluding(exc => exc.Exception is PKError)));
|
||||
builder.RegisterModule(new MetricsModule("API"));
|
||||
builder.RegisterModule<DataStoreModule>();
|
||||
builder.RegisterModule<APIModule>();
|
||||
@ -117,6 +132,50 @@ namespace PluralKit.API
|
||||
//app.UseHsts();
|
||||
}
|
||||
|
||||
// add X-PluralKit-Version header
|
||||
app.Use((ctx, next) =>
|
||||
{
|
||||
ctx.Response.Headers.Add("X-PluralKit-Version", BuildInfoService.Version);
|
||||
return next();
|
||||
});
|
||||
|
||||
app.UseExceptionHandler(handler => handler.Run(async ctx =>
|
||||
{
|
||||
var exc = ctx.Features.Get<IExceptionHandlerPathFeature>();
|
||||
|
||||
// handle common ISEs that are generated by invalid user input
|
||||
if (exc.Error.IsUserError())
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.WriteAsync("{\"message\":\"400: Bad Request\",\"code\":0}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (exc.Error is not PKError)
|
||||
{
|
||||
ctx.Response.StatusCode = 500;
|
||||
await ctx.Response.WriteAsync("{\"message\":\"500: Internal Server Error\",\"code\":0}");
|
||||
return;
|
||||
}
|
||||
|
||||
// for some reason, if we don't specifically cast to ModelParseError, it uses the base's ToJson method
|
||||
if (exc.Error is ModelParseError fe)
|
||||
{
|
||||
ctx.Response.StatusCode = fe.ResponseCode;
|
||||
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(fe.ToJson()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var err = (PKError)exc.Error;
|
||||
ctx.Response.StatusCode = err.ResponseCode;
|
||||
|
||||
var json = JsonConvert.SerializeObject(err.ToJson());
|
||||
await ctx.Response.WriteAsync(json);
|
||||
}));
|
||||
|
||||
app.UseMiddleware<AuthorizationTokenHandlerMiddleware>();
|
||||
|
||||
//app.UseHttpsRedirection();
|
||||
app.UseCors(opts => opts.AllowAnyMethod().AllowAnyOrigin().WithHeaders("Content-Type", "Authorization"));
|
||||
|
||||
|
@ -16,6 +16,15 @@ namespace PluralKit.Core
|
||||
return _db.QueryStream<PKGroup>(query);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<PKMember> GetGroupMembers(GroupId id)
|
||||
{
|
||||
var query = new Query("group_members")
|
||||
.Select("members.*")
|
||||
.Join("members", "group_members.member_id", "members.id")
|
||||
.Where("group_members.group_id", id);
|
||||
return _db.QueryStream<PKMember>(query);
|
||||
}
|
||||
|
||||
// todo: add this to metrics tracking
|
||||
public async Task AddGroupsToMember(MemberId member, IReadOnlyCollection<GroupId> groups)
|
||||
{
|
||||
@ -67,5 +76,21 @@ namespace PluralKit.Core
|
||||
.WhereIn("member_id", members);
|
||||
return _db.ExecuteQuery(query);
|
||||
}
|
||||
|
||||
public Task ClearGroupMembers(GroupId group)
|
||||
{
|
||||
_logger.Information("Cleared members of {GroupId}", group);
|
||||
var query = new Query("group_members").AsDelete()
|
||||
.Where("group_id", group);
|
||||
return _db.ExecuteQuery(query);
|
||||
}
|
||||
|
||||
public Task ClearMemberGroups(MemberId member)
|
||||
{
|
||||
_logger.Information("Cleared groups of {GroupId}", member);
|
||||
var query = new Query("group_members").AsDelete()
|
||||
.Where("member_id", member);
|
||||
return _db.ExecuteQuery(query);
|
||||
}
|
||||
}
|
||||
}
|
@ -21,8 +21,14 @@ namespace PluralKit.Core
|
||||
}
|
||||
|
||||
|
||||
public Task<SystemGuildSettings> GetSystemGuild(ulong guild, SystemId system)
|
||||
public Task<SystemGuildSettings> GetSystemGuild(ulong guild, SystemId system, bool defaultInsert = true)
|
||||
{
|
||||
if (!defaultInsert)
|
||||
return _db.QueryFirst<SystemGuildSettings>(new Query("system_guild")
|
||||
.Where("guild", guild)
|
||||
.Where("system", system)
|
||||
);
|
||||
|
||||
var query = new Query("system_guild").AsInsert(new
|
||||
{
|
||||
guild = guild,
|
||||
@ -33,16 +39,22 @@ namespace PluralKit.Core
|
||||
);
|
||||
}
|
||||
|
||||
public Task UpdateSystemGuild(SystemId system, ulong guild, SystemGuildPatch patch)
|
||||
public Task<SystemGuildSettings> UpdateSystemGuild(SystemId system, ulong guild, SystemGuildPatch patch)
|
||||
{
|
||||
_logger.Information("Updated {SystemId} in guild {GuildId}: {@SystemGuildPatch}", system, guild, patch);
|
||||
var query = patch.Apply(new Query("system_guild").Where("system", system).Where("guild", guild));
|
||||
return _db.ExecuteQuery(query, extraSql: "returning *");
|
||||
return _db.QueryFirst<SystemGuildSettings>(query, extraSql: "returning *");
|
||||
}
|
||||
|
||||
|
||||
public Task<MemberGuildSettings> GetMemberGuild(ulong guild, MemberId member)
|
||||
public Task<MemberGuildSettings> GetMemberGuild(ulong guild, MemberId member, bool defaultInsert = true)
|
||||
{
|
||||
if (!defaultInsert)
|
||||
return _db.QueryFirst<MemberGuildSettings>(new Query("member_guild")
|
||||
.Where("guild", guild)
|
||||
.Where("member", member)
|
||||
);
|
||||
|
||||
var query = new Query("member_guild").AsInsert(new
|
||||
{
|
||||
guild = guild,
|
||||
@ -53,11 +65,11 @@ namespace PluralKit.Core
|
||||
);
|
||||
}
|
||||
|
||||
public Task UpdateMemberGuild(MemberId member, ulong guild, MemberGuildPatch patch)
|
||||
public Task<MemberGuildSettings> UpdateMemberGuild(MemberId member, ulong guild, MemberGuildPatch patch)
|
||||
{
|
||||
_logger.Information("Updated {MemberId} in guild {GuildId}: {@MemberGuildPatch}", member, guild, patch);
|
||||
var query = patch.Apply(new Query("member_guild").Where("member", member).Where("guild", guild));
|
||||
return _db.ExecuteQuery(query, extraSql: "returning *");
|
||||
return _db.QueryFirst<MemberGuildSettings>(query, extraSql: "returning *");
|
||||
}
|
||||
}
|
||||
}
|
@ -9,8 +9,8 @@ namespace PluralKit.Core
|
||||
{
|
||||
public partial class ModelRepository
|
||||
{
|
||||
public Task<IEnumerable<PKShardInfo>> GetShards(IPKConnection conn) =>
|
||||
conn.QueryAsync<PKShardInfo>("select * from shards order by id");
|
||||
public Task<IEnumerable<PKShardInfo>> GetShards() =>
|
||||
_db.Execute(conn => conn.QueryAsync<PKShardInfo>("select * from shards order by id"));
|
||||
|
||||
public Task SetShardStatus(IPKConnection conn, int shard, PKShardInfo.ShardStatus status) =>
|
||||
conn.ExecuteAsync(
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
8
PluralKit.Core/Models/ModelTypes/APIVersion.cs
Normal file
8
PluralKit.Core/Models/ModelTypes/APIVersion.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace PluralKit.Core
|
||||
{
|
||||
public enum APIVersion
|
||||
{
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
}
|
29
PluralKit.Core/Models/ModelTypes/Validation.cs
Normal file
29
PluralKit.Core/Models/ModelTypes/Validation.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using System;
|
||||
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace PluralKit.Core
|
||||
{
|
||||
public class ValidationError
|
||||
{
|
||||
public string Key;
|
||||
public string? Text;
|
||||
public ValidationError(string key, string? text = null)
|
||||
{
|
||||
Key = key;
|
||||
Text = text;
|
||||
}
|
||||
}
|
||||
|
||||
public class FieldTooLongError: ValidationError
|
||||
{
|
||||
public int MaxLength;
|
||||
public int ActualLength;
|
||||
|
||||
public FieldTooLongError(string key, int maxLength, int actualLength) : base(key)
|
||||
{
|
||||
MaxLength = maxLength;
|
||||
ActualLength = actualLength;
|
||||
}
|
||||
}
|
||||
}
|
@ -61,12 +61,17 @@ namespace PluralKit.Core
|
||||
public static string? IconFor(this PKGroup group, LookupContext ctx) =>
|
||||
group.IconPrivacy.Get(ctx, group.Icon?.TryGetCleanCdnUrl());
|
||||
|
||||
public static JObject ToJson(this PKGroup group, LookupContext ctx, bool isExport = false)
|
||||
public static JObject ToJson(this PKGroup group, LookupContext ctx, string? systemStr = null, bool isExport = false)
|
||||
{
|
||||
var o = new JObject();
|
||||
|
||||
o.Add("id", group.Hid);
|
||||
o.Add("uuid", group.Uuid.ToString());
|
||||
o.Add("name", group.Name);
|
||||
|
||||
if (systemStr != null)
|
||||
o.Add("system", systemStr);
|
||||
|
||||
o.Add("display_name", group.DisplayName);
|
||||
o.Add("description", group.DescriptionPrivacy.Get(ctx, group.Description));
|
||||
o.Add("icon", group.Icon);
|
||||
|
@ -106,47 +106,83 @@ namespace PluralKit.Core
|
||||
public static int MessageCountFor(this PKMember member, LookupContext ctx) =>
|
||||
member.MetadataPrivacy.Get(ctx, member.MessageCount);
|
||||
|
||||
public static JObject ToJson(this PKMember member, LookupContext ctx, bool needsLegacyProxyTags = false)
|
||||
public static JObject ToJson(this PKMember member, LookupContext ctx, bool needsLegacyProxyTags = false, string systemStr = null, APIVersion v = APIVersion.V1)
|
||||
{
|
||||
var includePrivacy = ctx == LookupContext.ByOwner;
|
||||
|
||||
var o = new JObject();
|
||||
o.Add("id", member.Hid);
|
||||
|
||||
if (v == APIVersion.V2)
|
||||
{
|
||||
o.Add("uuid", member.Uuid.ToString());
|
||||
if (systemStr != null)
|
||||
o.Add("system", systemStr);
|
||||
}
|
||||
|
||||
o.Add("name", member.NameFor(ctx));
|
||||
|
||||
// o.Add("color", member.ColorPrivacy.CanAccess(ctx) ? member.Color : null);
|
||||
o.Add("color", member.Color);
|
||||
o.Add("display_name", member.NamePrivacy.CanAccess(ctx) ? member.DisplayName : null);
|
||||
o.Add("color", member.Color);
|
||||
o.Add("birthday", member.BirthdayFor(ctx)?.FormatExport());
|
||||
o.Add("pronouns", member.PronounsFor(ctx));
|
||||
o.Add("avatar_url", member.AvatarFor(ctx).TryGetCleanCdnUrl());
|
||||
o.Add("banner", member.DescriptionPrivacy.Get(ctx, member.BannerImage).TryGetCleanCdnUrl());
|
||||
o.Add("description", member.DescriptionFor(ctx));
|
||||
o.Add("created", member.CreatedFor(ctx)?.FormatExport());
|
||||
o.Add("keep_proxy", member.KeepProxy);
|
||||
|
||||
var tagArray = new JArray();
|
||||
foreach (var tag in member.ProxyTags)
|
||||
tagArray.Add(new JObject { { "prefix", tag.Prefix }, { "suffix", tag.Suffix } });
|
||||
o.Add("proxy_tags", tagArray);
|
||||
|
||||
o.Add("keep_proxy", member.KeepProxy);
|
||||
|
||||
o.Add("privacy", includePrivacy ? (member.MemberVisibility.LevelName()) : null);
|
||||
|
||||
o.Add("visibility", includePrivacy ? (member.MemberVisibility.LevelName()) : null);
|
||||
o.Add("name_privacy", includePrivacy ? (member.NamePrivacy.LevelName()) : null);
|
||||
o.Add("description_privacy", includePrivacy ? (member.DescriptionPrivacy.LevelName()) : null);
|
||||
o.Add("birthday_privacy", includePrivacy ? (member.BirthdayPrivacy.LevelName()) : null);
|
||||
o.Add("pronoun_privacy", includePrivacy ? (member.PronounPrivacy.LevelName()) : null);
|
||||
o.Add("avatar_privacy", includePrivacy ? (member.AvatarPrivacy.LevelName()) : null);
|
||||
// o.Add("color_privacy", ctx == LookupContext.ByOwner ? (member.ColorPrivacy.LevelName()) : null);
|
||||
o.Add("metadata_privacy", includePrivacy ? (member.MetadataPrivacy.LevelName()) : null);
|
||||
|
||||
o.Add("created", member.CreatedFor(ctx)?.FormatExport());
|
||||
|
||||
if (member.ProxyTags.Count > 0 && needsLegacyProxyTags)
|
||||
switch (v)
|
||||
{
|
||||
// Legacy compatibility only, TODO: remove at some point
|
||||
o.Add("prefix", member.ProxyTags?.FirstOrDefault().Prefix);
|
||||
o.Add("suffix", member.ProxyTags?.FirstOrDefault().Suffix);
|
||||
case APIVersion.V1:
|
||||
{
|
||||
o.Add("privacy", includePrivacy ? (member.MemberVisibility.LevelName()) : null);
|
||||
|
||||
o.Add("visibility", includePrivacy ? (member.MemberVisibility.LevelName()) : null);
|
||||
o.Add("name_privacy", includePrivacy ? (member.NamePrivacy.LevelName()) : null);
|
||||
o.Add("description_privacy", includePrivacy ? (member.DescriptionPrivacy.LevelName()) : null);
|
||||
o.Add("birthday_privacy", includePrivacy ? (member.BirthdayPrivacy.LevelName()) : null);
|
||||
o.Add("pronoun_privacy", includePrivacy ? (member.PronounPrivacy.LevelName()) : null);
|
||||
o.Add("avatar_privacy", includePrivacy ? (member.AvatarPrivacy.LevelName()) : null);
|
||||
// o.Add("color_privacy", ctx == LookupContext.ByOwner ? (member.ColorPrivacy.LevelName()) : null);
|
||||
o.Add("metadata_privacy", includePrivacy ? (member.MetadataPrivacy.LevelName()) : null);
|
||||
|
||||
if (member.ProxyTags.Count > 0 && needsLegacyProxyTags)
|
||||
{
|
||||
// Legacy compatibility only, TODO: remove at some point
|
||||
o.Add("prefix", member.ProxyTags?.FirstOrDefault().Prefix);
|
||||
o.Add("suffix", member.ProxyTags?.FirstOrDefault().Suffix);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case APIVersion.V2:
|
||||
{
|
||||
if (includePrivacy)
|
||||
{
|
||||
var p = new JObject();
|
||||
|
||||
p.Add("visibility", member.MemberVisibility.ToJsonString());
|
||||
p.Add("name_privacy", member.NamePrivacy.ToJsonString());
|
||||
p.Add("description_privacy", member.DescriptionPrivacy.ToJsonString());
|
||||
p.Add("birthday_privacy", member.BirthdayPrivacy.ToJsonString());
|
||||
p.Add("pronoun_privacy", member.PronounPrivacy.ToJsonString());
|
||||
p.Add("avatar_privacy", member.AvatarPrivacy.ToJsonString());
|
||||
p.Add("metadata_privacy", member.MetadataPrivacy.ToJsonString());
|
||||
|
||||
o.Add("privacy", p);
|
||||
}
|
||||
else
|
||||
o.Add("privacy", null);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return o;
|
||||
|
@ -66,10 +66,13 @@ namespace PluralKit.Core
|
||||
public static string DescriptionFor(this PKSystem system, LookupContext ctx) =>
|
||||
system.DescriptionPrivacy.Get(ctx, system.Description);
|
||||
|
||||
public static JObject ToJson(this PKSystem system, LookupContext ctx)
|
||||
public static JObject ToJson(this PKSystem system, LookupContext ctx, APIVersion v = APIVersion.V1)
|
||||
{
|
||||
var o = new JObject();
|
||||
o.Add("id", system.Hid);
|
||||
if (v == APIVersion.V2)
|
||||
o.Add("uuid", system.Uuid.ToString());
|
||||
|
||||
o.Add("name", system.Name);
|
||||
o.Add("description", system.DescriptionFor(ctx));
|
||||
o.Add("tag", system.Tag);
|
||||
@ -77,13 +80,43 @@ namespace PluralKit.Core
|
||||
o.Add("banner", system.DescriptionPrivacy.Get(ctx, system.BannerImage).TryGetCleanCdnUrl());
|
||||
o.Add("color", system.Color);
|
||||
o.Add("created", system.Created.FormatExport());
|
||||
// todo: change this to "timezone"
|
||||
o.Add("tz", system.UiTz);
|
||||
// todo: just don't include these if not ByOwner
|
||||
o.Add("description_privacy", ctx == LookupContext.ByOwner ? system.DescriptionPrivacy.ToJsonString() : null);
|
||||
o.Add("member_list_privacy", ctx == LookupContext.ByOwner ? system.MemberListPrivacy.ToJsonString() : null);
|
||||
o.Add("front_privacy", ctx == LookupContext.ByOwner ? system.FrontPrivacy.ToJsonString() : null);
|
||||
o.Add("front_history_privacy", ctx == LookupContext.ByOwner ? system.FrontHistoryPrivacy.ToJsonString() : null);
|
||||
|
||||
switch (v)
|
||||
{
|
||||
case APIVersion.V1:
|
||||
{
|
||||
o.Add("tz", system.UiTz);
|
||||
|
||||
o.Add("description_privacy", ctx == LookupContext.ByOwner ? system.DescriptionPrivacy.ToJsonString() : null);
|
||||
o.Add("member_list_privacy", ctx == LookupContext.ByOwner ? system.MemberListPrivacy.ToJsonString() : null);
|
||||
o.Add("front_privacy", ctx == LookupContext.ByOwner ? system.FrontPrivacy.ToJsonString() : null);
|
||||
o.Add("front_history_privacy", ctx == LookupContext.ByOwner ? system.FrontHistoryPrivacy.ToJsonString() : null);
|
||||
|
||||
break;
|
||||
}
|
||||
case APIVersion.V2:
|
||||
{
|
||||
o.Add("timezone", system.UiTz);
|
||||
|
||||
if (ctx == LookupContext.ByOwner)
|
||||
{
|
||||
var p = new JObject();
|
||||
|
||||
p.Add("description_privacy", system.DescriptionPrivacy.ToJsonString());
|
||||
p.Add("member_list_privacy", system.MemberListPrivacy.ToJsonString());
|
||||
p.Add("group_list_privacy", system.GroupListPrivacy.ToJsonString());
|
||||
p.Add("front_privacy", system.FrontPrivacy.ToJsonString());
|
||||
p.Add("front_history_privacy", system.FrontHistoryPrivacy.ToJsonString());
|
||||
|
||||
o.Add("privacy", p);
|
||||
}
|
||||
else
|
||||
o.Add("privacy", null);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ namespace PluralKit.Core
|
||||
|
||||
public new void AssertIsValid()
|
||||
{
|
||||
if (Name.IsPresent)
|
||||
if (Name.Value != null)
|
||||
AssertValid(Name.Value, "name", Limits.MaxGroupNameLength);
|
||||
if (DisplayName.Value != null)
|
||||
AssertValid(DisplayName.Value, "display_name", Limits.MaxGroupNameLength);
|
||||
@ -59,10 +59,13 @@ namespace PluralKit.Core
|
||||
{
|
||||
var patch = new GroupPatch();
|
||||
|
||||
if (o.ContainsKey("name") && o["name"].Type == JTokenType.Null)
|
||||
throw new ValidationError("Group name can not be set to null.");
|
||||
if (o.ContainsKey("name"))
|
||||
{
|
||||
patch.Name = o.Value<string>("name").NullIfEmpty();
|
||||
if (patch.Name.Value == null)
|
||||
patch.Errors.Add(new ValidationError("name", "Group name can not be set to null."));
|
||||
}
|
||||
|
||||
if (o.ContainsKey("name")) patch.Name = o.Value<string>("name");
|
||||
if (o.ContainsKey("display_name")) patch.DisplayName = o.Value<string>("display_name").NullIfEmpty();
|
||||
if (o.ContainsKey("description")) patch.Description = o.Value<string>("description").NullIfEmpty();
|
||||
if (o.ContainsKey("icon")) patch.Icon = o.Value<string>("icon").NullIfEmpty();
|
||||
@ -74,16 +77,16 @@ namespace PluralKit.Core
|
||||
var privacy = o.Value<JObject>("privacy");
|
||||
|
||||
if (privacy.ContainsKey("description_privacy"))
|
||||
patch.DescriptionPrivacy = privacy.ParsePrivacy("description_privacy");
|
||||
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy");
|
||||
|
||||
if (privacy.ContainsKey("icon_privacy"))
|
||||
patch.IconPrivacy = privacy.ParsePrivacy("icon_privacy");
|
||||
patch.IconPrivacy = patch.ParsePrivacy(privacy, "icon_privacy");
|
||||
|
||||
if (privacy.ContainsKey("list_privacy"))
|
||||
patch.ListPrivacy = privacy.ParsePrivacy("list_privacy");
|
||||
patch.ListPrivacy = patch.ParsePrivacy(privacy, "list_privacy");
|
||||
|
||||
if (privacy.ContainsKey("visibility"))
|
||||
patch.Visibility = privacy.ParsePrivacy("visibility");
|
||||
patch.Visibility = patch.ParsePrivacy(privacy, "visibility");
|
||||
}
|
||||
|
||||
return patch;
|
||||
|
@ -1,5 +1,7 @@
|
||||
#nullable enable
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using SqlKata;
|
||||
|
||||
namespace PluralKit.Core
|
||||
@ -13,5 +15,28 @@ namespace PluralKit.Core
|
||||
.With("display_name", DisplayName)
|
||||
.With("avatar_url", AvatarUrl)
|
||||
);
|
||||
|
||||
public new void AssertIsValid()
|
||||
{
|
||||
if (DisplayName.Value != null)
|
||||
AssertValid(DisplayName.Value, "display_name", Limits.MaxMemberNameLength);
|
||||
if (AvatarUrl.Value != null)
|
||||
AssertValid(AvatarUrl.Value, "avatar_url", Limits.MaxUriLength,
|
||||
s => MiscUtils.TryMatchUri(s, out var avatarUri));
|
||||
}
|
||||
|
||||
#nullable disable
|
||||
public static MemberGuildPatch FromJson(JObject o)
|
||||
{
|
||||
var patch = new MemberGuildPatch();
|
||||
|
||||
if (o.ContainsKey("display_name"))
|
||||
patch.DisplayName = o.Value<string>("display_name").NullIfEmpty();
|
||||
|
||||
if (o.ContainsKey("avatar_url"))
|
||||
patch.AvatarUrl = o.Value<string>("avatar_url").NullIfEmpty();
|
||||
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
}
|
@ -58,7 +58,7 @@ namespace PluralKit.Core
|
||||
|
||||
public new void AssertIsValid()
|
||||
{
|
||||
if (Name.IsPresent)
|
||||
if (Name.Value != null)
|
||||
AssertValid(Name.Value, "name", Limits.MaxMemberNameLength);
|
||||
if (DisplayName.Value != null)
|
||||
AssertValid(DisplayName.Value, "display_name", Limits.MaxMemberNameLength);
|
||||
@ -77,17 +77,21 @@ namespace PluralKit.Core
|
||||
if (ProxyTags.IsPresent && (ProxyTags.Value.Length > 100 ||
|
||||
ProxyTags.Value.Any(tag => tag.ProxyString.IsLongerThan(100))))
|
||||
// todo: have a better error for this
|
||||
throw new ValidationError("proxy_tags");
|
||||
Errors.Add(new ValidationError("proxy_tags"));
|
||||
}
|
||||
|
||||
#nullable disable
|
||||
|
||||
public static MemberPatch FromJSON(JObject o)
|
||||
public static MemberPatch FromJSON(JObject o, APIVersion v = APIVersion.V1)
|
||||
{
|
||||
var patch = new MemberPatch();
|
||||
|
||||
if (o.ContainsKey("name") && o["name"].Type == JTokenType.Null)
|
||||
throw new ValidationError("Member name can not be set to null.");
|
||||
if (o.ContainsKey("name"))
|
||||
{
|
||||
patch.Name = o.Value<string>("name").NullIfEmpty();
|
||||
if (patch.Name.Value == null)
|
||||
patch.Errors.Add(new ValidationError("name", "Member name can not be set to null."));
|
||||
}
|
||||
|
||||
if (o.ContainsKey("name")) patch.Name = o.Value<string>("name");
|
||||
if (o.ContainsKey("color")) patch.Color = o.Value<string>("color").NullIfEmpty()?.ToLower();
|
||||
@ -101,45 +105,89 @@ namespace PluralKit.Core
|
||||
var res = DateTimeFormats.DateExportFormat.Parse(str);
|
||||
if (res.Success) patch.Birthday = res.Value;
|
||||
else if (str == null) patch.Birthday = null;
|
||||
else throw new ValidationError("birthday");
|
||||
else patch.Errors.Add(new ValidationError("birthday"));
|
||||
}
|
||||
|
||||
if (o.ContainsKey("pronouns")) patch.Pronouns = o.Value<string>("pronouns").NullIfEmpty();
|
||||
if (o.ContainsKey("description")) patch.Description = o.Value<string>("description").NullIfEmpty();
|
||||
if (o.ContainsKey("keep_proxy")) patch.KeepProxy = o.Value<bool>("keep_proxy");
|
||||
|
||||
// legacy: used in old export files and APIv1
|
||||
if (o.ContainsKey("prefix") || o.ContainsKey("suffix") && !o.ContainsKey("proxy_tags"))
|
||||
patch.ProxyTags = new[] { new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")) };
|
||||
else if (o.ContainsKey("proxy_tags"))
|
||||
patch.ProxyTags = o.Value<JArray>("proxy_tags")
|
||||
.OfType<JObject>().Select(o => new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")))
|
||||
.Where(p => p.Valid)
|
||||
.ToArray();
|
||||
|
||||
if (o.ContainsKey("privacy")) //TODO: Deprecate this completely in api v2
|
||||
switch (v)
|
||||
{
|
||||
var plevel = o.ParsePrivacy("privacy");
|
||||
case APIVersion.V1:
|
||||
{
|
||||
// legacy: used in old export files and APIv1
|
||||
if (o.ContainsKey("prefix") || o.ContainsKey("suffix") && !o.ContainsKey("proxy_tags"))
|
||||
patch.ProxyTags = new[] { new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")) };
|
||||
else if (o.ContainsKey("proxy_tags"))
|
||||
patch.ProxyTags = o.Value<JArray>("proxy_tags")
|
||||
.OfType<JObject>().Select(o => new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")))
|
||||
.Where(p => p.Valid)
|
||||
.ToArray();
|
||||
|
||||
patch.Visibility = plevel;
|
||||
patch.NamePrivacy = plevel;
|
||||
patch.AvatarPrivacy = plevel;
|
||||
patch.DescriptionPrivacy = plevel;
|
||||
patch.BirthdayPrivacy = plevel;
|
||||
patch.PronounPrivacy = plevel;
|
||||
// member.ColorPrivacy = plevel;
|
||||
patch.MetadataPrivacy = plevel;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (o.ContainsKey("visibility")) patch.Visibility = o.ParsePrivacy("visibility");
|
||||
if (o.ContainsKey("name_privacy")) patch.NamePrivacy = o.ParsePrivacy("name_privacy");
|
||||
if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = o.ParsePrivacy("description_privacy");
|
||||
if (o.ContainsKey("avatar_privacy")) patch.AvatarPrivacy = o.ParsePrivacy("avatar_privacy");
|
||||
if (o.ContainsKey("birthday_privacy")) patch.BirthdayPrivacy = o.ParsePrivacy("birthday_privacy");
|
||||
if (o.ContainsKey("pronoun_privacy")) patch.PronounPrivacy = o.ParsePrivacy("pronoun_privacy");
|
||||
// if (o.ContainsKey("color_privacy")) member.ColorPrivacy = o.ParsePrivacy("member");
|
||||
if (o.ContainsKey("metadata_privacy")) patch.MetadataPrivacy = o.ParsePrivacy("metadata_privacy");
|
||||
if (o.ContainsKey("privacy"))
|
||||
{
|
||||
var plevel = patch.ParsePrivacy(o, "privacy");
|
||||
|
||||
patch.Visibility = plevel;
|
||||
patch.NamePrivacy = plevel;
|
||||
patch.AvatarPrivacy = plevel;
|
||||
patch.DescriptionPrivacy = plevel;
|
||||
patch.BirthdayPrivacy = plevel;
|
||||
patch.PronounPrivacy = plevel;
|
||||
// member.ColorPrivacy = plevel;
|
||||
patch.MetadataPrivacy = plevel;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (o.ContainsKey("visibility")) patch.Visibility = patch.ParsePrivacy(o, "visibility");
|
||||
if (o.ContainsKey("name_privacy")) patch.NamePrivacy = patch.ParsePrivacy(o, "name_privacy");
|
||||
if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = patch.ParsePrivacy(o, "description_privacy");
|
||||
if (o.ContainsKey("avatar_privacy")) patch.AvatarPrivacy = patch.ParsePrivacy(o, "avatar_privacy");
|
||||
if (o.ContainsKey("birthday_privacy")) patch.BirthdayPrivacy = patch.ParsePrivacy(o, "birthday_privacy");
|
||||
if (o.ContainsKey("pronoun_privacy")) patch.PronounPrivacy = patch.ParsePrivacy(o, "pronoun_privacy");
|
||||
// if (o.ContainsKey("color_privacy")) member.ColorPrivacy = o.ParsePrivacy("member");
|
||||
if (o.ContainsKey("metadata_privacy")) patch.MetadataPrivacy = patch.ParsePrivacy(o, "metadata_privacy");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case APIVersion.V2:
|
||||
{
|
||||
|
||||
if (o.ContainsKey("proxy_tags"))
|
||||
patch.ProxyTags = o.Value<JArray>("proxy_tags")
|
||||
.OfType<JObject>().Select(o => new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")))
|
||||
.Where(p => p.Valid)
|
||||
.ToArray();
|
||||
|
||||
if (o.ContainsKey("privacy") && o["privacy"].Type != JTokenType.Null)
|
||||
{
|
||||
var privacy = o.Value<JObject>("privacy");
|
||||
|
||||
if (privacy.ContainsKey("visibility"))
|
||||
patch.Visibility = patch.ParsePrivacy(privacy, "visibility");
|
||||
|
||||
if (privacy.ContainsKey("name_privacy"))
|
||||
patch.NamePrivacy = patch.ParsePrivacy(privacy, "name_privacy");
|
||||
|
||||
if (privacy.ContainsKey("description_privacy"))
|
||||
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy");
|
||||
|
||||
if (privacy.ContainsKey("avatar_privacy"))
|
||||
patch.AvatarPrivacy = patch.ParsePrivacy(privacy, "avatar_privacy");
|
||||
|
||||
if (privacy.ContainsKey("birthday_privacy"))
|
||||
patch.BirthdayPrivacy = patch.ParsePrivacy(privacy, "birthday_privacy");
|
||||
|
||||
if (privacy.ContainsKey("pronoun_privacy"))
|
||||
patch.PronounPrivacy = patch.ParsePrivacy(privacy, "pronoun_privacy");
|
||||
|
||||
if (privacy.ContainsKey("metadata_privacy"))
|
||||
patch.MetadataPrivacy = patch.ParsePrivacy(privacy, "metadata_privacy");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return patch;
|
||||
|
@ -1,50 +1,46 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using SqlKata;
|
||||
|
||||
namespace PluralKit.Core
|
||||
{
|
||||
public abstract class PatchObject
|
||||
{
|
||||
public List<ValidationError> Errors = new();
|
||||
public abstract Query Apply(Query q);
|
||||
|
||||
public void AssertIsValid() { }
|
||||
|
||||
protected bool AssertValid(string input, string name, int maxLength, Func<string, bool>? validate = null)
|
||||
protected void AssertValid(string input, string name, int maxLength, Func<string, bool>? validate = null)
|
||||
{
|
||||
if (input.Length > maxLength)
|
||||
throw new FieldTooLongError(name, maxLength, input.Length);
|
||||
Errors.Add(new FieldTooLongError(name, maxLength, input.Length));
|
||||
if (validate != null && !validate(input))
|
||||
throw new ValidationError(name);
|
||||
return true;
|
||||
Errors.Add(new ValidationError(name));
|
||||
}
|
||||
|
||||
protected bool AssertValid(string input, string name, string pattern)
|
||||
protected void AssertValid(string input, string name, string pattern)
|
||||
{
|
||||
if (!Regex.IsMatch(input, pattern))
|
||||
throw new ValidationError(name);
|
||||
return true;
|
||||
Errors.Add(new ValidationError(name));
|
||||
}
|
||||
}
|
||||
|
||||
public class ValidationError: Exception
|
||||
{
|
||||
public ValidationError(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class FieldTooLongError: ValidationError
|
||||
{
|
||||
public string Name;
|
||||
public int MaxLength;
|
||||
public int ActualLength;
|
||||
|
||||
public FieldTooLongError(string name, int maxLength, int actualLength) :
|
||||
base($"{name} too long ({actualLength} > {maxLength})")
|
||||
public PrivacyLevel ParsePrivacy(JObject o, string propertyName)
|
||||
{
|
||||
Name = name;
|
||||
MaxLength = maxLength;
|
||||
ActualLength = actualLength;
|
||||
var input = o.Value<string>(propertyName);
|
||||
|
||||
if (input == null) return PrivacyLevel.Public;
|
||||
if (input == "") return PrivacyLevel.Private;
|
||||
if (input == "private") return PrivacyLevel.Private;
|
||||
if (input == "public") return PrivacyLevel.Public;
|
||||
|
||||
Errors.Add(new ValidationError(propertyName));
|
||||
// unused, but the compiler will complain if this isn't here
|
||||
return PrivacyLevel.Private;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
#nullable enable
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using SqlKata;
|
||||
|
||||
namespace PluralKit.Core
|
||||
@ -19,5 +21,39 @@ namespace PluralKit.Core
|
||||
.With("tag", Tag)
|
||||
.With("tag_enabled", TagEnabled)
|
||||
);
|
||||
|
||||
public new void AssertIsValid()
|
||||
{
|
||||
if (Tag.Value != null)
|
||||
AssertValid(Tag.Value, "tag", Limits.MaxSystemTagLength);
|
||||
}
|
||||
|
||||
#nullable disable
|
||||
public static SystemGuildPatch FromJson(JObject o, MemberId? memberId)
|
||||
{
|
||||
var patch = new SystemGuildPatch();
|
||||
|
||||
if (o.ContainsKey("proxying_enabled") && o["proxying_enabled"].Type != JTokenType.Null)
|
||||
patch.ProxyEnabled = o.Value<bool>("proxying_enabled");
|
||||
|
||||
if (o.ContainsKey("autoproxy_mode"))
|
||||
{
|
||||
var (val, err) = o["autoproxy_mode"].ParseAutoproxyMode();
|
||||
if (err != null)
|
||||
patch.Errors.Add(err);
|
||||
else
|
||||
patch.AutoproxyMode = val.Value;
|
||||
}
|
||||
|
||||
patch.AutoproxyMember = memberId;
|
||||
|
||||
if (o.ContainsKey("tag"))
|
||||
patch.Tag = o.Value<string>("tag").NullIfEmpty();
|
||||
|
||||
if (o.ContainsKey("tag_enabled") && o["tag_enabled"].Type != JTokenType.Null)
|
||||
patch.TagEnabled = o.Value<bool>("tag_enabled");
|
||||
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
}
|
@ -69,10 +69,12 @@ namespace PluralKit.Core
|
||||
if (Color.Value != null)
|
||||
AssertValid(Color.Value, "color", "^[0-9a-fA-F]{6}$");
|
||||
if (UiTz.IsPresent && DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz.Value) == null)
|
||||
throw new ValidationError("avatar_url");
|
||||
Errors.Add(new ValidationError("timezone"));
|
||||
}
|
||||
|
||||
public static SystemPatch FromJSON(JObject o)
|
||||
#nullable disable
|
||||
|
||||
public static SystemPatch FromJSON(JObject o, APIVersion v = APIVersion.V1)
|
||||
{
|
||||
var patch = new SystemPatch();
|
||||
if (o.ContainsKey("name")) patch.Name = o.Value<string>("name").NullIfEmpty();
|
||||
@ -81,16 +83,47 @@ namespace PluralKit.Core
|
||||
if (o.ContainsKey("avatar_url")) patch.AvatarUrl = o.Value<string>("avatar_url").NullIfEmpty();
|
||||
if (o.ContainsKey("banner")) patch.BannerImage = o.Value<string>("banner").NullIfEmpty();
|
||||
if (o.ContainsKey("color")) patch.Color = o.Value<string>("color").NullIfEmpty();
|
||||
if (o.ContainsKey("timezone")) patch.UiTz = o.Value<string>("tz") ?? "UTC";
|
||||
if (o.ContainsKey("timezone")) patch.UiTz = o.Value<string>("timezone") ?? "UTC";
|
||||
|
||||
// legacy: APIv1 uses "tz" instead of "timezone"
|
||||
// todo: remove in APIv2
|
||||
if (o.ContainsKey("tz")) patch.UiTz = o.Value<string>("tz") ?? "UTC";
|
||||
switch (v)
|
||||
{
|
||||
case APIVersion.V1:
|
||||
{
|
||||
if (o.ContainsKey("tz")) patch.UiTz = o.Value<string>("tz") ?? "UTC";
|
||||
|
||||
if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = patch.ParsePrivacy(o, "description_privacy");
|
||||
if (o.ContainsKey("member_list_privacy")) patch.MemberListPrivacy = patch.ParsePrivacy(o, "member_list_privacy");
|
||||
if (o.ContainsKey("front_privacy")) patch.FrontPrivacy = patch.ParsePrivacy(o, "front_privacy");
|
||||
if (o.ContainsKey("front_history_privacy")) patch.FrontHistoryPrivacy = patch.ParsePrivacy(o, "front_history_privacy");
|
||||
|
||||
break;
|
||||
}
|
||||
case APIVersion.V2:
|
||||
{
|
||||
if (o.ContainsKey("privacy") && o["privacy"].Type != JTokenType.Null)
|
||||
{
|
||||
var privacy = o.Value<JObject>("privacy");
|
||||
|
||||
if (privacy.ContainsKey("description_privacy"))
|
||||
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy");
|
||||
|
||||
if (privacy.ContainsKey("member_list_privacy"))
|
||||
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "member_list_privacy");
|
||||
|
||||
if (privacy.ContainsKey("group_list_privacy"))
|
||||
patch.GroupListPrivacy = patch.ParsePrivacy(privacy, "group_list_privacy");
|
||||
|
||||
if (privacy.ContainsKey("front_privacy"))
|
||||
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "front_privacy");
|
||||
|
||||
if (privacy.ContainsKey("front_history_privacy"))
|
||||
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "front_history_privacy");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (o.ContainsKey("description_privacy")) patch.DescriptionPrivacy = o.ParsePrivacy("description_privacy");
|
||||
if (o.ContainsKey("member_list_privacy")) patch.MemberListPrivacy = o.ParsePrivacy("member_list_privacy");
|
||||
if (o.ContainsKey("front_privacy")) patch.FrontPrivacy = o.ParsePrivacy("front_privacy");
|
||||
if (o.ContainsKey("front_history_privacy")) patch.FrontHistoryPrivacy = o.ParsePrivacy("front_history_privacy");
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,5 @@
|
||||
using System;
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace PluralKit.Core
|
||||
{
|
||||
public enum PrivacyLevel
|
||||
@ -42,18 +40,5 @@ namespace PluralKit.Core
|
||||
}
|
||||
|
||||
public static string ToJsonString(this PrivacyLevel level) => level.LevelName();
|
||||
|
||||
public static PrivacyLevel ParsePrivacy(this JObject o, string propertyName)
|
||||
{
|
||||
var input = o.Value<string>(propertyName);
|
||||
|
||||
if (input == null) return PrivacyLevel.Public;
|
||||
if (input == "") return PrivacyLevel.Private;
|
||||
if (input == "private") return PrivacyLevel.Private;
|
||||
if (input == "public") return PrivacyLevel.Public;
|
||||
|
||||
throw new ValidationError(propertyName);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -1,5 +1,10 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace PluralKit.Core
|
||||
{
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public enum AutoproxyMode
|
||||
{
|
||||
Off = 1,
|
||||
@ -20,4 +25,44 @@ namespace PluralKit.Core
|
||||
public string? Tag { get; }
|
||||
public bool TagEnabled { get; }
|
||||
}
|
||||
|
||||
public static class SystemGuildExt
|
||||
{
|
||||
public static JObject ToJson(this SystemGuildSettings settings, string? memberHid = null)
|
||||
{
|
||||
var o = new JObject();
|
||||
|
||||
o.Add("proxying_enabled", settings.ProxyEnabled);
|
||||
o.Add("autoproxy_mode", settings.AutoproxyMode.ToString().ToLower());
|
||||
o.Add("autoproxy_member", memberHid);
|
||||
o.Add("tag", settings.Tag);
|
||||
o.Add("tag_enabled", settings.TagEnabled);
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
public static (AutoproxyMode?, ValidationError?) ParseAutoproxyMode(this JToken o)
|
||||
{
|
||||
if (o.Type == JTokenType.Null)
|
||||
return (AutoproxyMode.Off, null);
|
||||
else if (o.Type != JTokenType.String)
|
||||
return (null, new ValidationError("autoproxy_mode"));
|
||||
|
||||
var value = o.Value<string>();
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case "off":
|
||||
return (AutoproxyMode.Off, null);
|
||||
case "front":
|
||||
return (AutoproxyMode.Front, null);
|
||||
case "latch":
|
||||
return (AutoproxyMode.Latch, null);
|
||||
case "member":
|
||||
return (AutoproxyMode.Member, null);
|
||||
default:
|
||||
return (null, new ValidationError("autoproxy_mode", $"Value '{value}' is not a valid autoproxy mode."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -17,11 +17,13 @@ namespace PluralKit.Core
|
||||
{
|
||||
private readonly string _component;
|
||||
private readonly Action<LoggerConfiguration> _fn;
|
||||
private LoggerConfiguration _cfg { get; init; }
|
||||
|
||||
public LoggingModule(string component, Action<LoggerConfiguration> fn = null)
|
||||
public LoggingModule(string component, Action<LoggerConfiguration> fn = null, LoggerConfiguration cfg = null)
|
||||
{
|
||||
_component = component;
|
||||
_fn = fn ?? (_ => { });
|
||||
_cfg = cfg ?? new LoggerConfiguration();
|
||||
}
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
@ -44,7 +46,7 @@ namespace PluralKit.Core
|
||||
var consoleTemplate = "[{Timestamp:HH:mm:ss.fff}] {Level:u3} {Message:lj}{NewLine}{Exception}";
|
||||
var outputTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.ffffff}] {Level:u3} {Message:lj}{NewLine}{Exception}";
|
||||
|
||||
var logCfg = new LoggerConfiguration()
|
||||
var logCfg = _cfg
|
||||
.Enrich.FromLogContext()
|
||||
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)
|
||||
.Enrich.WithProperty("Component", _component)
|
||||
@ -53,6 +55,9 @@ namespace PluralKit.Core
|
||||
// Don't want App.Metrics/D#+ spam
|
||||
.MinimumLevel.Override("App.Metrics", LogEventLevel.Information)
|
||||
|
||||
// nor ASP.NET spam
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
|
||||
|
||||
// Actual formatting for these is handled in ScalarFormatting
|
||||
.Destructure.AsScalar<SystemId>()
|
||||
.Destructure.AsScalar<MemberId>()
|
||||
|
@ -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;
|
||||
|
@ -88,6 +88,19 @@ namespace PluralKit.Core
|
||||
patch.DisplayName = $"{name} {tag}";
|
||||
}
|
||||
|
||||
patch.AssertIsValid();
|
||||
if (patch.Errors.Count > 0)
|
||||
{
|
||||
var err = patch.Errors[0];
|
||||
if (err is FieldTooLongError)
|
||||
throw new ImportException($"Field {err.Key} in tupper {name} is too long "
|
||||
+ $"({(err as FieldTooLongError).ActualLength} > {(err as FieldTooLongError).MaxLength}).");
|
||||
else if (err.Text != null)
|
||||
throw new ImportException($"tupper {name}: {err.Text}");
|
||||
else
|
||||
throw new ImportException($"Field {err.Key} in tupper {name} is invalid.");
|
||||
}
|
||||
|
||||
var isNewMember = false;
|
||||
if (!_existingMemberNames.TryGetValue(name, out var memberId))
|
||||
{
|
||||
@ -102,19 +115,6 @@ namespace PluralKit.Core
|
||||
_logger.Debug("Importing member with identifier {FileId} to system {System} (is creating new member? {IsCreatingNewMember})",
|
||||
name, _system.Id, isNewMember);
|
||||
|
||||
try
|
||||
{
|
||||
patch.AssertIsValid();
|
||||
}
|
||||
catch (FieldTooLongError e)
|
||||
{
|
||||
throw new ImportException($"Field {e.Name} in tupper {name} is too long ({e.ActualLength} > {e.MaxLength}).");
|
||||
}
|
||||
catch (ValidationError e)
|
||||
{
|
||||
throw new ImportException($"Field {e.Message} in tupper {name} is invalid.");
|
||||
}
|
||||
|
||||
await _repo.UpdateMember(memberId, patch, _conn);
|
||||
|
||||
return (lastSetTag, multipleTags, hasGroup);
|
||||
|
@ -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"],
|
||||
]
|
||||
},
|
||||
|
33
docs/content/api/changelog.md
Normal file
33
docs/content/api/changelog.md
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Changelog
|
||||
permalink: /api/changelog
|
||||
---
|
||||
|
||||
# Version history
|
||||
|
||||
* 2020-07-28
|
||||
* The unversioned API endpoints have been removed.
|
||||
* 2020-06-17 (v1.1)
|
||||
* The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `avatar_privacy`, `birthday_privacy`, `pronoun_privacy`, `metadata_privacy`. All are strings and accept the values of `public`, `private` and `null`.
|
||||
* The `privacy` field has now been deprecated and should not be used. It's still returned (mirroring the `visibility` field), and writing to it will write to *all privacy options*.
|
||||
* 2020-05-07
|
||||
* The API (v1) is now formally(ish) defined with OpenAPI v3.0. [The definition file can be found here.](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml)
|
||||
* 2020-02-10
|
||||
* Birthdates with no year can now be stored using `0004` as a year, for better leap year support. Both options remain valid and either may be returned by the API.
|
||||
* Added privacy set/get support, meaning you will now see privacy values in authed requests and can set them.
|
||||
* 2020-01-08
|
||||
* Added privacy support, meaning some responses will now lack information or return 403s, depending on the specific system and member's privacy settings.
|
||||
* 2019-12-28
|
||||
* Changed behaviour of missing fields in PATCH responses, will now preserve the old value instead of clearing
|
||||
* This is technically a breaking change, but not *significantly* so, so I won't bump the version number.
|
||||
* 2019-10-31
|
||||
* Added `proxy_tags` field to members
|
||||
* Added `keep_proxy` field to members
|
||||
* Deprecated `prefix` and `suffix` member fields, will be removed at some point (tm)
|
||||
* 2019-07-17
|
||||
* Added endpoint for querying system by account
|
||||
* Added endpoint for querying message contents
|
||||
* 2019-07-10 **(v1)**
|
||||
* First specified version
|
||||
* (prehistory)
|
||||
* Initial release
|
266
docs/content/api/endpoints.md
Normal file
266
docs/content/api/endpoints.md
Normal file
@ -0,0 +1,266 @@
|
||||
---
|
||||
name: Endpoints
|
||||
permalink: /api/endpoints
|
||||
---
|
||||
|
||||
# Endpoints
|
||||
|
||||
The base URL for the PluralKit API is `https://api.pluralkit.me/v2`. Endpoint URLs should be added to the base URL to get a full URL to query.
|
||||
|
||||
---
|
||||
## Systems
|
||||
|
||||
*`systemRef` can be a system's short (5-character) ID, a system's UUID, the ID of a Discord account linked to the system, or the string `@me` to refer to the currently authenticated system.*
|
||||
|
||||
### Get System
|
||||
|
||||
GET `/systems/{systemRef}`
|
||||
|
||||
Returns a [system object](/api/models#system-model).
|
||||
|
||||
### Update System
|
||||
|
||||
PATCH `/systems/{systemRef}`
|
||||
|
||||
Takes a partial [system object](/api/models#system-model).
|
||||
|
||||
Returns a [system object](/api/models#system-model).
|
||||
|
||||
### Get System Guild Settings
|
||||
|
||||
GET `/systems/@me/guilds/{guild_id}`
|
||||
|
||||
Returns a [system guild settings](/api/models#system-guild-settings) object.
|
||||
|
||||
::: tip
|
||||
You must already have updated per-guild settings for your system in the target guild before being able to get or update them from the API.
|
||||
:::
|
||||
|
||||
### Update System Guild Settings
|
||||
|
||||
PATCH `/systems/@me/guilds/{guild_id}`
|
||||
|
||||
Takes a partial [system guild settings](/api/models#system-guild-settings) object.
|
||||
|
||||
Returns a [system guild settings](/api/models#system-guild-settings) object on success.
|
||||
|
||||
---
|
||||
## Members
|
||||
|
||||
*`memberRef` can be a member's short (5-character ID) or a member's UUID.*
|
||||
|
||||
### Get System Members
|
||||
|
||||
GET `/systems/{systemRef}/members`
|
||||
|
||||
Returns a list of [member objects](/api/models#member-model).
|
||||
|
||||
### Create Member
|
||||
|
||||
POST `/members`
|
||||
|
||||
Takes a partial [member object](/api/models#member-model) as input. Key `name` is required.
|
||||
|
||||
Returns a [member object](/api/models#member-model) on success.
|
||||
|
||||
### Get Member
|
||||
|
||||
GET `/members/{memberRef}`
|
||||
|
||||
Returns a [member object](/api/models#member-model).
|
||||
|
||||
### Update Member
|
||||
|
||||
PATCH `/members/{memberRef}`
|
||||
|
||||
Takes a partial [member object](/api/models#member-model) as input.
|
||||
|
||||
Returns a [member object](/api/models#member-model) on success.
|
||||
|
||||
### Delete Member
|
||||
|
||||
DELETE `/members/{memberRef}`
|
||||
|
||||
Returns 204 No Content on success.
|
||||
|
||||
### Get Member Groups
|
||||
|
||||
GET `/members/{memberRef}/groups`
|
||||
|
||||
### Add Member To Groups
|
||||
|
||||
POST `/members/{memberRef}/groups/add`
|
||||
|
||||
Takes a list of group references as input. Returns 204 No Content on success.
|
||||
|
||||
### Remove Member From Groups
|
||||
|
||||
POST `/members/{memberRef}/groups/remove`
|
||||
|
||||
::: tip
|
||||
If you want to remove *all* groups from a member, consider using the [Overwrite Member Groups](#overwrite-member-groups) endpoint instead.
|
||||
:::
|
||||
|
||||
Takes a list of group references as input. Returns 204 No Content on success.
|
||||
|
||||
### Overwrite Member Groups
|
||||
|
||||
POST `/members/{memberRef}/groups/overwrite`
|
||||
|
||||
Takes a list of group references as input. (An empty list is accepted.) Returns 204 No Content on success.
|
||||
|
||||
### Get Member Guild Settings
|
||||
|
||||
GET `/members/{memberRef}/guilds/{guild_id}`
|
||||
|
||||
Returns a [member guild settings](/api/models#member-guild-settings) object.
|
||||
|
||||
::: tip
|
||||
You must already have updated per-guild settings for the target member in the target guild before being able to get or update them from the API.
|
||||
:::
|
||||
|
||||
### Update Member Guild Settings
|
||||
|
||||
PATCH `/members/{memberRef}/guilds/{guild_id}`
|
||||
|
||||
Takes a partial [member guild settings](/api/models#member-guild-settings) object.
|
||||
|
||||
Returns a [member guild settings](/api/models#member-guild-settings) object on success.
|
||||
|
||||
---
|
||||
## Groups
|
||||
|
||||
*`groupRef` can be a group's short (5-character ID) or a group's UUID.*
|
||||
|
||||
### Get System Groups
|
||||
|
||||
GET `/systems/{systemRef}/groups`
|
||||
|
||||
Returns a list of [group objects](/api/models/#group-model).
|
||||
|
||||
### Create Group
|
||||
|
||||
POST `/groups`
|
||||
|
||||
Takes a partial [group object](/api/models#group-model) as input. Key `name` is required.
|
||||
|
||||
Returns a [group object](/api/models#group-model) on success, or an error object on failure.
|
||||
|
||||
### Get Group
|
||||
|
||||
GET `/groups/{groupRef}`
|
||||
|
||||
Returns a [group object](/api/models/#group-model).
|
||||
|
||||
### Update Group
|
||||
|
||||
PATCH `/groups/{groupRef}`
|
||||
|
||||
Takes a partial [group object](/api/models#group-model) as input.
|
||||
|
||||
Returns a [group object](/api/models#group-model) on success, or an error object on failure.
|
||||
|
||||
### Delete Group
|
||||
|
||||
DELETE `/groups/{groupRef}`
|
||||
|
||||
Returns 204 No Content on success.
|
||||
|
||||
### Get Group Members
|
||||
|
||||
GET `/groups/{groupRef}/members`
|
||||
|
||||
Returns an array of [member objects](/api/models#member-model).
|
||||
|
||||
### Add Members To Group
|
||||
|
||||
POST `/groups/{groupRef}/members/add`
|
||||
|
||||
Takes an array of member references as input. Returns 204 No Content on success.
|
||||
|
||||
### Remove Member From Group
|
||||
|
||||
POST `/groups/{groupRef}/members/remove`
|
||||
|
||||
::: tip
|
||||
If you want to remove *all* members from a group, consider using the [Overwrite Group Members](#overwrite-group-members) endpoint instead.
|
||||
:::
|
||||
|
||||
Takes an array of member references as input. Returns 204 No Content on success.
|
||||
|
||||
### Overwrite Group Members
|
||||
|
||||
POST `/groups/{groupRef}/members/overwrite`
|
||||
|
||||
Takes an array of member references as input. (An empty list is accepted.) Returns 204 No Content on success.
|
||||
|
||||
---
|
||||
## Switches
|
||||
|
||||
*`switchRef` must be a switch's UUID. On POST/PATCH/DELETE endpoints, `systemRef` must be `@me`.*
|
||||
|
||||
### Get System Switches
|
||||
|
||||
GET `/systems/{systemRef}/switches`
|
||||
|
||||
Query String Parameters
|
||||
|
||||
|key|type|description|
|
||||
|---|---|---|
|
||||
|before|timestamp|date to get latest switch from (inclusive)|
|
||||
|limit|int|number of switches to get|
|
||||
|
||||
Returns a [switch object](/api/models#switch-model) containing a list of IDs.
|
||||
|
||||
### Get Current System Fronters
|
||||
|
||||
GET `/systems/{systemRef}/fronters`
|
||||
|
||||
Returns a [switch object](/api/models#switch-model) containing a list of member objects.
|
||||
|
||||
### Create Switch
|
||||
|
||||
POST `/systems/{systemRef}/switches`
|
||||
|
||||
JSON Body Parameters
|
||||
|
||||
|key|type|description|
|
||||
|---|---|---|
|
||||
|?timestamp|datetime*|when the switch started|
|
||||
|members|list of strings**|members present in the switch (or empty list for switch-out)|
|
||||
|
||||
* Defaults to "now" when missing.
|
||||
|
||||
** Can be short IDs or UUIDs.
|
||||
|
||||
### Get Switch
|
||||
|
||||
GET `/systems/{systemRef}/switches/{switchRef}`
|
||||
|
||||
Returns a [switch object](/api/models#switch-model) containing a list of member objects.
|
||||
|
||||
### Update Switch
|
||||
|
||||
PATCH `/systems/{systemRef}/switches/{switchRef}`
|
||||
|
||||
JSON Body Parameters
|
||||
|
||||
|key|type|description|
|
||||
|---|---|---|
|
||||
|timestamp|datetime|when the switch started|
|
||||
|
||||
Returns a [switch object](/api/models#switch-model) containing a list of member objects on success.
|
||||
|
||||
### Update Switch Members
|
||||
|
||||
PATCH `/systems/{systemRef}/switches/{switchRef}/members`
|
||||
|
||||
Takes a list of member short IDs or UUIDs as input.
|
||||
|
||||
Returns a [switch object](/api/models#switch-model) containing a list of member objects on success.
|
||||
|
||||
### Delete Switch
|
||||
|
||||
DELETE `/systems/{systemRef}/switches/{switchRef}`
|
||||
|
||||
Returns 204 No Content on success.
|
59
docs/content/api/errors.md
Normal file
59
docs/content/api/errors.md
Normal file
@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Errors and Status Codes
|
||||
permalink: /api/errors
|
||||
---
|
||||
|
||||
# Errors and Status Codes
|
||||
|
||||
When something goes wrong, the API will send back a 4xx HTTP status code, along with a JSON object describing the error.
|
||||
|
||||
### Error Response Model
|
||||
|
||||
|key|type|description|
|
||||
|---|---|---|
|
||||
|code|int|numerical error code|
|
||||
|message|string|description of the error|
|
||||
|?errors|map of entity keys to list of error objects*|details on the error|
|
||||
|?retry_after|int|if this is a rate limit error, the number of milliseconds after which you can retry the request|
|
||||
|
||||
* Only returned for model parsing errors.
|
||||
|
||||
### Error Object
|
||||
|
||||
|key|type|description|
|
||||
|---|---|---|
|
||||
|message|string|error description|
|
||||
|?max_length|int|if this is an error indicating a key is too long, the maximum allowed length for the key|
|
||||
|?actual_length|int|if this is an error indicating a key is too long, the length of the provided value|
|
||||
|
||||
## JSON error codes
|
||||
|
||||
|code|HTTP response code|meaning|
|
||||
|---|---|---|
|
||||
|0|500|Internal server error, try again later|
|
||||
|0|400|Bad Request (usually invalid JSON)|
|
||||
|0|401|Missing or invalid Authorization header|
|
||||
|20001|404|System not found.|
|
||||
|20002|404|Member not found.|
|
||||
|20003|404|Member '{memberRef}' not found.|
|
||||
|20004|404|Group not found.|
|
||||
|20005|404|Group '{groupRef}' not found.|
|
||||
|20006|404|Message not found.|
|
||||
|20007|404|Switch not found.|
|
||||
|20008|404|Switch not found, switch associated with different system, or unauthorized to view front history.|
|
||||
|20009|404|No system guild settings found for target guild.|
|
||||
|20010|404|No member guild settings found for target guild.|
|
||||
|30001|403|Unauthorized to view member list|
|
||||
|30002|403|Unauthorized to view group list|
|
||||
|30003|403|Unauthorized to view group member list|
|
||||
|30004|403|Unauthorized to view current fronters.|
|
||||
|30005|403|Unauthorized to view front history.|
|
||||
|30006|403|Target member is not part of your system.|
|
||||
|30007|403|Target group is not part of your system.|
|
||||
|30008|403|Member '{memberRef}' is not part of your system.|
|
||||
|30009|403|Group '{groupRef}' is not part of your system.|
|
||||
|40002|400|Missing autoproxy member for member-mode autoproxy.|
|
||||
|40003|400|Duplicate members in member list.|
|
||||
|40004|400|Member list identical to current fronter list.|
|
||||
|40005|400|Switch with provided timestamp already exists.|
|
||||
|40006|400|Invalid switch ID.|
|
@ -1,47 +1,13 @@
|
||||
---
|
||||
title: API documentation
|
||||
description: PluralKit's API documentation.
|
||||
permalink: /api
|
||||
title: Legacy API documentation
|
||||
permalink: /api/legacy
|
||||
---
|
||||
|
||||
**2020-05-07**: [The PluralKit API is now documented on Swagger.](https://app.swaggerhub.com/apis-docs/xSke/PluralKit/1.1)
|
||||
Accompanying it is an [OpenAPI v3.0 definition](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml). It's mostly complete, but is still subject to change - so don't go generating API clients and mock servers with it quite yet. It may still be useful, though :)
|
||||
# Legacy API documentation
|
||||
|
||||
# API documentation
|
||||
|
||||
PluralKit has a basic HTTP REST API for querying and modifying your system.
|
||||
The root endpoint of the API is `https://api.pluralkit.me/v1/`.
|
||||
|
||||
#### Authorization header token example
|
||||
```
|
||||
Authorization: z865MC7JNhLtZuSq1NXQYVe+FgZJHBfeBCXOPYYRwH4liDCDrsd7zdOuR45mX257
|
||||
```
|
||||
|
||||
Endpoints will always return all fields, using `null` when a value is missing. On `PATCH` endpoints,
|
||||
missing fields from the JSON request will be ignored and preserved as is, but on `POST` endpoints will
|
||||
be set to `null` or cleared.
|
||||
|
||||
Endpoints taking JSON bodies (eg. most `PATCH` and `PUT` endpoints) require the `Content-Type: application/json` header set.
|
||||
|
||||
## Community API Libraries
|
||||
|
||||
The following API libraries have been created by members of our community. Please contact the developer of each library if you need support.
|
||||
|
||||
- **Python:** *PluralKit.py* ([PyPI](https://pypi.org/project/pluralkit/) | [Docs](https://pluralkit.readthedocs.io/en/latest/source/quickstart.html) | [Source code](https://github.com/almonds0166/pluralkit.py))
|
||||
- **JavaScript:** *pkapi.js* ([npmjs](https://npmjs.com/package/pkapi.js) | [Docs](https://github.com/greysdawn/pk.js/wiki) | [Source code](https://github.com/greysdawn/pk.js))
|
||||
- **Golang:** *pkgo* (install: `go get github.com/starshine-sys/pkgo` | [Docs (godoc)](https://godocs.io/github.com/starshine-sys/pkgo) | [Docs (pkg.go.dev)](https://pkg.go.dev/github.com/starshine-sys/pkgo) | [Source code](https://github.com/starshine-sys/pkgo))
|
||||
|
||||
Do let us know in the support server if you made a new library and would like to see it listed here!
|
||||
|
||||
## Authentication
|
||||
Authentication is done with a simple "system token". You can get your system token by running `pk;token` using the
|
||||
Discord bot, either in a channel with the bot or in DMs. Then, pass this token in the `Authorization` HTTP header
|
||||
on requests that require it. Failure to do so on endpoints that require authentication will return a `401 Unauthorized`.
|
||||
|
||||
Some endpoints show information that a given system may have set to private. If this is a specific field
|
||||
(eg. description), the field will simply contain `null` rather than the true value. If this applies to entire endpoint
|
||||
responses (eg. fronter, switches, member list), the entire request will return `403 Forbidden`. Authenticating with the
|
||||
system's token (as described above) will override these privacy settings and show the full information.
|
||||
::: warning
|
||||
This is the documentation for v1 of the PluralKit API. Please use v2 going forwards - v1 is deprecated and will be removed eventually.
|
||||
:::
|
||||
|
||||
## Models
|
||||
The following three models (usually represented in JSON format) represent the various objects in PluralKit's API.
|
||||
@ -536,32 +502,4 @@ The returned system and member's privacy settings will be respected, and as such
|
||||
"metadata_privacy": "private"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Version history
|
||||
* 2020-07-28
|
||||
* The unversioned API endpoints have been removed.
|
||||
* 2020-06-17 (v1.1)
|
||||
* The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `avatar_privacy`, `birthday_privacy`, `pronoun_privacy`, `metadata_privacy`. All are strings and accept the values of `public`, `private` and `null`.
|
||||
* The `privacy` field has now been deprecated and should not be used. It's still returned (mirroring the `visibility` field), and writing to it will write to *all privacy options*.
|
||||
* 2020-05-07
|
||||
* The API (v1) is now formally(ish) defined with OpenAPI v3.0. [The definition file can be found here.](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml)
|
||||
* 2020-02-10
|
||||
* Birthdates with no year can now be stored using `0004` as a year, for better leap year support. Both options remain valid and either may be returned by the API.
|
||||
* Added privacy set/get support, meaning you will now see privacy values in authed requests and can set them.
|
||||
* 2020-01-08
|
||||
* Added privacy support, meaning some responses will now lack information or return 403s, depending on the specific system and member's privacy settings.
|
||||
* 2019-12-28
|
||||
* Changed behaviour of missing fields in PATCH responses, will now preserve the old value instead of clearing
|
||||
* This is technically a breaking change, but not *significantly* so, so I won't bump the version number.
|
||||
* 2019-10-31
|
||||
* Added `proxy_tags` field to members
|
||||
* Added `keep_proxy` field to members
|
||||
* Deprecated `prefix` and `suffix` member fields, will be removed at some point (tm)
|
||||
* 2019-07-17
|
||||
* Added endpoint for querying system by account
|
||||
* Added endpoint for querying message contents
|
||||
* 2019-07-10 **(v1)**
|
||||
* First specified version
|
||||
* (prehistory)
|
||||
* Initial release
|
||||
```
|
123
docs/content/api/models.md
Normal file
123
docs/content/api/models.md
Normal file
@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Models
|
||||
permalink: /api/models
|
||||
---
|
||||
|
||||
# Models
|
||||
|
||||
A question mark (`?`) next to the *key name* means the key is optional - it may be omitted in API responses. A question mark next to the *key type* means the key is nullable - API responses may return `null` for that key, instead of the specified type.
|
||||
|
||||
In PATCH endpoints, all keys are optional. However, providing an object with no keys (or no valid keys) will result in 500 internal server error.
|
||||
|
||||
Privacy objects (`privacy` key in models) contain values "private" or "public". Patching a privacy value to `null` will set to public. If you do not have access to view the privacy object of the member, the value of the `privacy` key will be null, rather than the values of individual privacy keys.
|
||||
|
||||
#### Notes on IDs
|
||||
|
||||
Every PluralKit entity has two IDs: a short (5-character) ID and a longer UUID. The short ID is unique across the resource (a member can have the same short ID as a system, for example), while the UUID is consistent for the lifetime of the entity and globally unique across the bot.
|
||||
|
||||
### System model
|
||||
|
||||
|key|type|notes|
|
||||
|---|---|---|
|
||||
|id|string||
|
||||
|uuid|string||
|
||||
|name|string|100-character limit|
|
||||
|description|?string|1000-character limit|
|
||||
|tag|string||
|
||||
|avatar_url|?string|256-character limit, must be a publicly-accessible URL|
|
||||
|banner|?string|256-character limit, must be a publicly-accessible URL|
|
||||
|color|string|6-character hex code, no `#` at the beginning|
|
||||
|created|datetime||
|
||||
|timezone|string|defaults to `UTC`|
|
||||
|privacy|?system privacy object||
|
||||
|
||||
* System privacy keys: `description_privacy`, `member_list_privacy`, `group_list_privacy`, `front_privacy`, `front_history_privacy`
|
||||
|
||||
### Member model
|
||||
|
||||
|key|type|notes|
|
||||
|---|---|---|
|
||||
|id|string||
|
||||
|uuid|string||
|
||||
|name|string|100-character limit|
|
||||
|display_name|?string|100-character limit|
|
||||
|color|string|6-character hex code, no `#` at the beginning|
|
||||
|birthday|?string|`YYYY-MM-DD` format, 0004 hides the year|
|
||||
|pronouns|?string|100-character-limit|
|
||||
|avatar_url|?string|256-character limit, must be a publicly-accessible URL|
|
||||
|banner|?string|256-character limit, must be a publicly-accessible URL|
|
||||
|description|?string|1000-character limit|
|
||||
|created|?datetime||
|
||||
|keep_proxy|boolean||
|
||||
|privacy|?member privacy object||
|
||||
|
||||
* Member privacy keys: `visibility`, `name_privacy`, `description_privacy`, `birthday_privacy`, `pronoun_privacy`, `avatar_privacy`, `metadata_privacy`
|
||||
|
||||
#### ProxyTag object
|
||||
|
||||
| Key | Type |
|
||||
| ------ | ------- |
|
||||
| prefix | ?string |
|
||||
| suffix | ?string |
|
||||
|
||||
### Group model
|
||||
|
||||
|key|type|notes|
|
||||
|---|---|---|
|
||||
|id|string||
|
||||
|uuid|string||
|
||||
|name|string|100-character limit|
|
||||
|display_name|?string|100-character limit|
|
||||
|description|?string|1000-character limit|
|
||||
|icon|?string|256-character limit, must be a publicly-accessible URL|
|
||||
|banner|?string|256-character limit, must be a publicly-accessible URL|
|
||||
|color|string|6-character hex code, no `#` at the beginning|
|
||||
|privacy|?group privacy object||
|
||||
|
||||
* Group privacy keys: `description_privacy`, `icon_privacy`, `list_privacy`, `visibility`
|
||||
|
||||
### Switch model
|
||||
|
||||
|key|type|notes|
|
||||
|---|---|---|
|
||||
|id|uuid||
|
||||
|timestamp|datetime||
|
||||
| members | list of id/Member | Is sometimes in plain ID list form (eg. `GET /systems/:id/switches`), sometimes includes the full Member model (eg. `GET /systems/:id/fronters`). |
|
||||
|
||||
### Message model
|
||||
|
||||
|key|type|notes|
|
||||
|---|---|---|
|
||||
|timestamp|datetime||
|
||||
|id|snowflake|The ID of the message sent by the webhook. Encoded as string for precision reasons.|
|
||||
|original| snowflake|The ID of the (now-deleted) message that triggered the proxy. Encoded as string for precision reasons.|
|
||||
|sender|snowflake|The user ID of the account that triggered the proxy. Encoded as string for precision reasons.|
|
||||
|channel|snowflake|The ID of the channel the message was sent in. Encoded as string for precision reasons.|
|
||||
|system|full System object|The system that proxied the message.|
|
||||
|member|full Member object|The member that proxied the message.|
|
||||
|
||||
### System guild settings model
|
||||
|
||||
|key|type|notes|
|
||||
|---|---|---|
|
||||
|proxying_enabled|boolean||
|
||||
|autoproxy_mode|autoproxy mode enum||
|
||||
|autoproxy_member|?string|must be set if autoproxy_mode is `member`|
|
||||
|tag|?string|79-character limit|
|
||||
|tag_enabled|boolean||
|
||||
|
||||
#### Autoproxy mode enum
|
||||
|
||||
|key|description|
|
||||
|---|---|
|
||||
|off|autoproxy is disabled|
|
||||
|front|autoproxy is set to the first member in the current fronters list, or disabled if the current switch contains no members|
|
||||
|latch|autoproxy is set to the last member who sent a proxied message in the server|
|
||||
|member|autoproxy is set to a specific member (see `autoproxy_member` key)|
|
||||
|
||||
### Member guild settings model
|
||||
|
||||
|key|type|notes|
|
||||
|---|---|---|
|
||||
|display_name|?string|100-character limit|
|
||||
|avatar_url|?string|256-character limit, must be a publicly-accessible URL|
|
48
docs/content/api/reference.md
Normal file
48
docs/content/api/reference.md
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Reference
|
||||
permalink: /api/reference
|
||||
---
|
||||
|
||||
# API Reference
|
||||
|
||||
PluralKit has a basic HTTP REST API for querying and modifying your system.
|
||||
The root endpoint of the API is `https://api.pluralkit.me/v2/`.
|
||||
|
||||
#### Authorization header token example
|
||||
```
|
||||
Authorization: z865MC7JNhLtZuSq1NXQYVe+FgZJHBfeBCXOPYYRwH4liDCDrsd7zdOuR45mX257
|
||||
```
|
||||
|
||||
Endpoints will always return all fields, using `null` when a value is missing. On `PATCH` endpoints,
|
||||
missing fields from the JSON request will be ignored and preserved as is, but on `POST` endpoints will
|
||||
be set to `null` or cleared.
|
||||
|
||||
For models that have them, the keys `id`, `uuid` and `created` are **not** user-settable.
|
||||
|
||||
Endpoints taking JSON bodies (eg. most `PATCH` and `PUT` endpoints) require the `Content-Type: application/json` header set.
|
||||
|
||||
## Authentication
|
||||
Authentication is done with a simple "system token". You can get your system token by running `pk;token` using the
|
||||
Discord bot, either in a channel with the bot or in DMs. Then, pass this token in the `Authorization` HTTP header
|
||||
on requests that require it. Failure to do so on endpoints that require authentication will return a `401 Unauthorized`.
|
||||
|
||||
Some endpoints show information that a given system may have set to private. If this is a specific field
|
||||
(eg. description), the field will simply contain `null` rather than the true value. If this applies to entire endpoint
|
||||
responses (eg. fronter, switches, member list), the entire request will return `403 Forbidden`. Authenticating with the
|
||||
system's token (as described above) will override these privacy settings and show the full information.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
By default, there is a per-IP limit of 2 requests per second across the API. If you exceed this limit, you will get a 429 response code with a [rate limit error](#) body .....
|
||||
|
||||
todo: this isn't implemented yet.
|
||||
|
||||
## Community API Libraries
|
||||
|
||||
The following API libraries have been created by members of our community. Please contact the developer of each library if you need support.
|
||||
|
||||
- **Python:** *PluralKit.py* ([PyPI](https://pypi.org/project/pluralkit/) | [Docs](https://pluralkit.readthedocs.io/en/latest/source/quickstart.html) | [Source code](https://github.com/almonds0166/pluralkit.py))
|
||||
- **JavaScript:** *pkapi.js* ([npmjs](https://npmjs.com/package/pkapi.js) | [Docs](https://github.com/greysdawn/pk.js/wiki) | [Source code](https://github.com/greysdawn/pk.js))
|
||||
- **Golang:** *pkgo* (install: `go get github.com/starshine-sys/pkgo` | [Docs (godoc)](https://godocs.io/github.com/starshine-sys/pkgo) | [Docs (pkg.go.dev)](https://pkg.go.dev/github.com/starshine-sys/pkgo) | [Source code](https://github.com/starshine-sys/pkgo))
|
||||
|
||||
Do let us know in the support server if you made a new library and would like to see it listed here!
|
Loading…
Reference in New Issue
Block a user