mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-07 22:37:54 +00:00
[WIP] feat: scoped api keys
This commit is contained in:
parent
e7ee593a85
commit
06cb160f95
45 changed files with 1264 additions and 154 deletions
|
|
@ -33,6 +33,11 @@ public partial class CommandTree
|
|||
public static Command ConfigProxySwitch = new Command("config proxyswitch", "config proxyswitch [new|add|off]", "Switching behavior when proxy tags are used");
|
||||
public static Command ConfigNameFormat = new Command("config nameformat", "config nameformat [format]", "Changes your system's username formatting");
|
||||
public static Command ConfigServerNameFormat = new Command("config servernameformat", "config servernameformat [format]", "Changes your system's username formatting in the current server");
|
||||
public static Command ApiKeyCreate = new Command("system apikey new", "system apikey new <name> <type>", "Create a new API key");
|
||||
public static Command ApiKeyList = new Command("system apikey list", "system apikey list", "Show current API keys");
|
||||
public static Command ApiKeyRename = new Command("system apikey <key> rename", "system apikey <key> rename <name>", "Rename an existing API key");
|
||||
public static Command ApiKeyDelete = new Command("system apikey <key> delete", "system apikey <key> delete", "Delete an existing API key");
|
||||
public static Command ApiKeyDeleteAll = new Command("system apikey deleteall", "system apikey deleteall", "Delete all existing API keys");
|
||||
public static Command AutoproxySet = new Command("autoproxy", "autoproxy [off|front|latch|member]", "Sets your system's autoproxy mode for the current server");
|
||||
public static Command AutoproxyOff = new Command("autoproxy off", "autoproxy off", "Disables autoproxying for your system in the current server");
|
||||
public static Command AutoproxyFront = new Command("autoproxy front", "autoproxy front", "Sets your system's autoproxy in this server to proxy the first member currently registered as front");
|
||||
|
|
|
|||
|
|
@ -218,6 +218,8 @@ public partial class CommandTree
|
|||
// todo: these aren't deprecated but also shouldn't be here
|
||||
else if (ctx.Match("webhook", "hook"))
|
||||
await ctx.Execute<Api>(null, m => m.SystemWebhook(ctx));
|
||||
else if (ctx.Match("apikey", "apikeys", "apitoken", "apitokens"))
|
||||
await HandleSystemApiKeyCommand(ctx);
|
||||
else if (ctx.Match("proxy"))
|
||||
await ctx.Execute<SystemEdit>(SystemProxy, m => m.SystemProxy(ctx));
|
||||
|
||||
|
|
@ -322,6 +324,42 @@ public partial class CommandTree
|
|||
await ctx.CheckSystem(target).Execute<Random>(MemberRandom, m => m.Member(ctx, target));
|
||||
}
|
||||
|
||||
private async Task HandleSystemApiKeyCommand(Context ctx)
|
||||
{
|
||||
ctx.CheckSystem();
|
||||
if (ctx.Match("new", "n", "add", "create", "register"))
|
||||
await ctx.Execute<Api>(ApiKeyCreate, c => c.ApiKeyCreate(ctx));
|
||||
else if (ctx.Match("list", "ls", "l"))
|
||||
await ctx.Execute<Api>(ApiKeyList, c => c.ApiKeyList(ctx));
|
||||
else if (ctx.Match("deleteall", "removeall", "destroyall", "eraseall", "revokeall", "yeetall"))
|
||||
await ctx.Execute<Api>(ApiKeyDeleteAll, c => c.ApiKeyDeleteAll(ctx));
|
||||
else if (!ctx.HasNext())
|
||||
await PrintCommandExpectedError(ctx, ApiKeyCreate, ApiKeyList, ApiKeyRename, ApiKeyDelete, ApiKeyDeleteAll);
|
||||
else
|
||||
{
|
||||
PKApiKey? key = null!;
|
||||
var input = ctx.PeekArgument();
|
||||
if (Guid.TryParse(input, out var keyId))
|
||||
key = await ctx.Repository.GetApiKey(keyId);
|
||||
else if (await ctx.Repository.GetApiKeyByName(ctx.System.Id, input) is PKApiKey keyByName)
|
||||
key = keyByName;
|
||||
|
||||
if (key == null || key.System != ctx.System.Id)
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Error} API key with name \"{ctx.PopArgument()}\" not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.PopArgument();
|
||||
if (ctx.Match("rename", "name", "changename", "setname", "rn"))
|
||||
await ctx.Execute<Api>(ApiKeyRename, c => c.ApiKeyRename(ctx, key));
|
||||
else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet"))
|
||||
await ctx.Execute<Api>(ApiKeyDelete, c => c.ApiKeyDelete(ctx, key));
|
||||
else
|
||||
await PrintCommandNotFoundError(ctx, ApiKeyRename, ApiKeyDelete);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMemberCommand(Context ctx)
|
||||
{
|
||||
if (ctx.Match("new", "n", "add", "create", "register"))
|
||||
|
|
|
|||
|
|
@ -1,28 +1,41 @@
|
|||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using Myriad.Builders;
|
||||
using Myriad.Extensions;
|
||||
using Myriad.Rest.Exceptions;
|
||||
using Myriad.Rest.Types;
|
||||
using Myriad.Rest.Types.Requests;
|
||||
using Myriad.Types;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using SqlKata;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class Api
|
||||
{
|
||||
private record PaginatedApiKey(Guid Id, string Name, string[] Scopes, string? AppName, Instant Created);
|
||||
|
||||
private static readonly Regex _webhookRegex =
|
||||
new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)");
|
||||
|
||||
private readonly BotConfig _botConfig;
|
||||
private readonly DispatchService _dispatch;
|
||||
private readonly InteractionDispatchService _interactions;
|
||||
private readonly PrivateChannelService _dmCache;
|
||||
private readonly ApiKeyService _apiKey;
|
||||
|
||||
public Api(BotConfig botConfig, DispatchService dispatch, PrivateChannelService dmCache)
|
||||
public Api(BotConfig botConfig, DispatchService dispatch, InteractionDispatchService interactions, PrivateChannelService dmCache, ApiKeyService apiKey)
|
||||
{
|
||||
_botConfig = botConfig;
|
||||
_dispatch = dispatch;
|
||||
_interactions = interactions;
|
||||
_dmCache = dmCache;
|
||||
_apiKey = apiKey;
|
||||
}
|
||||
|
||||
public async Task GetToken(Context ctx)
|
||||
|
|
@ -172,4 +185,167 @@ public class Api
|
|||
|
||||
await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system.");
|
||||
}
|
||||
|
||||
public async Task ApiKeyCreate(Context ctx)
|
||||
{
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError($"An API key name must be provided.");
|
||||
|
||||
var rawScopes = ctx.MatchFlag("scopes", "scope");
|
||||
var keyName = ctx.PopArgument();
|
||||
List<string> keyScopes = new();
|
||||
|
||||
if (!ctx.HasNext())
|
||||
throw new PKSyntaxError($"A list of API key scopes must be provided.");
|
||||
|
||||
var scopestr = ctx.RemainderOrNull()!.NormalizeLineEndSpacing().Trim();
|
||||
if (rawScopes)
|
||||
keyScopes = scopestr.Split(" ").Distinct().ToList();
|
||||
else
|
||||
keyScopes.Add(scopestr switch
|
||||
{
|
||||
"full" => "write:all",
|
||||
"read private" => "read:all",
|
||||
"read public" => "readpublic:all",
|
||||
"identify" => "identify",
|
||||
_ => throw new PKError(
|
||||
$"Couldn't find a scope preset named {scopestr}."),
|
||||
});
|
||||
|
||||
string? check = null!;
|
||||
try
|
||||
{
|
||||
check = await _apiKey.CreateUserApiKey(ctx.System.Id, keyName, keyScopes.ToArray(), check: true);
|
||||
if (check != null)
|
||||
throw new PKError("API key validation failed: unknown error");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex.Message.StartsWith("API key"))
|
||||
throw new PKError(ex.Message);
|
||||
throw;
|
||||
}
|
||||
|
||||
async Task cb(InteractionContext ictx)
|
||||
{
|
||||
if (ictx.User.Id != ctx.Author.Id)
|
||||
{
|
||||
await ictx.Ignore();
|
||||
return;
|
||||
}
|
||||
|
||||
var newKey = await _apiKey.CreateUserApiKey(ctx.System.Id, keyName, keyScopes.ToArray());
|
||||
await ictx.Reply($"Your new API key is below. You will only be shown this once, so please save it!\n\n||`{newKey}`||");
|
||||
await ctx.Rest.EditMessage(ictx.ChannelId, ictx.MessageId!.Value, new MessageEditRequest
|
||||
{
|
||||
Components = new MessageComponent[] { },
|
||||
});
|
||||
}
|
||||
|
||||
var content =
|
||||
$"Ready to create a new API key named **{keyName}**, "
|
||||
+ $"with these scopes: {(String.Join(", ", keyScopes.Select(x => x.AsCode())))}\n"
|
||||
+ "To create this API key, press the button below.";
|
||||
|
||||
await ctx.Rest.CreateMessage(ctx.Channel.Id, new MessageRequest
|
||||
{
|
||||
Content = content,
|
||||
AllowedMentions = new() { Parse = new AllowedMentions.ParseType[] { }, RepliedUser = false },
|
||||
Components = new[] {
|
||||
new MessageComponent
|
||||
{
|
||||
Type = ComponentType.ActionRow,
|
||||
Components = new[]
|
||||
{
|
||||
new MessageComponent
|
||||
{
|
||||
Type = ComponentType.Button,
|
||||
Style = ButtonStyle.Primary,
|
||||
Label = "Create API key",
|
||||
CustomId = _interactions.Register(cb),
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async Task ApiKeyList(Context ctx)
|
||||
{
|
||||
var keys = await ctx.Repository.GetSystemApiKeys(ctx.System.Id)
|
||||
.Select(k => new PaginatedApiKey(k.Id, k.Name, k.Scopes, null, k.Created))
|
||||
.ToListAsync();
|
||||
|
||||
await ctx.Paginate<PaginatedApiKey>(
|
||||
keys.ToAsyncEnumerable(),
|
||||
keys.Count,
|
||||
10,
|
||||
"Current API keys for your system",
|
||||
ctx.System.Color,
|
||||
(eb, l) =>
|
||||
{
|
||||
var description = new StringBuilder();
|
||||
|
||||
foreach (var item in l)
|
||||
{
|
||||
description.Append($"**{item.Name}** (`{item.Id}`)");
|
||||
description.AppendLine();
|
||||
|
||||
description.Append("- Scopes: ");
|
||||
description.Append(String.Join(", ", item.Scopes.Select(sc => $"`{sc}`")));
|
||||
description.AppendLine();
|
||||
description.Append("- Created: ");
|
||||
description.Append(item.Created.FormatZoned(ctx.Zone));
|
||||
description.AppendLine();
|
||||
description.AppendLine();
|
||||
}
|
||||
|
||||
eb.Description(description.ToString());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task ApiKeyRename(Context ctx, PKApiKey key)
|
||||
{
|
||||
if (!ctx.HasNext())
|
||||
throw new PKError("You must provide a new name for this API key.");
|
||||
|
||||
var name = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
|
||||
await ctx.Repository.UpdateApiKey(key.Id, new ApiKeyPatch { Name = name });
|
||||
await ctx.Reply($"{Emojis.Success} API key renamed.");
|
||||
}
|
||||
|
||||
public async Task ApiKeyDelete(Context ctx, PKApiKey key)
|
||||
{
|
||||
if (!await ctx.PromptYesNo($"Really delete API key **{key.Name}** `{key.Id}`?", "Delete", matchFlag: false))
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Error} Deletion cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Repository.DeleteApiKey(key.Id);
|
||||
await ctx.Reply($"{Emojis.Success} Successfully deleted API key.");
|
||||
}
|
||||
|
||||
public async Task ApiKeyDeleteAll(Context ctx)
|
||||
{
|
||||
if (!await ctx.PromptYesNo($"Really delete *all manually-created* API keys for your system?", "Delete", matchFlag: false))
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Error} Deletion cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.BusyIndicator(async () =>
|
||||
{
|
||||
var query = new Query("api_keys")
|
||||
.AsDelete()
|
||||
.WhereRaw("[kind]::text not in ( 'dashboard', 'external_app' )")
|
||||
.Where("system", ctx.System.Id);
|
||||
|
||||
await ctx.Database.ExecuteQuery(query);
|
||||
});
|
||||
|
||||
await ctx.Reply($"{Emojis.Success} Successfully deleted all manually-created API keys.");
|
||||
}
|
||||
}
|
||||
|
|
@ -162,6 +162,7 @@ public class BotModule: Module
|
|||
builder.RegisterType<AvatarHostingService>().AsSelf().SingleInstance();
|
||||
builder.RegisterType<HttpListenerService>().AsSelf().SingleInstance();
|
||||
builder.RegisterType<RuntimeConfigService>().AsSelf().SingleInstance();
|
||||
builder.RegisterType<ApiKeyService>().AsSelf().SingleInstance();
|
||||
|
||||
// Sentry stuff
|
||||
builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue