2026-10-01 11:52:31 +02:00
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
|
|
|
|
|
|
using OpenIddict.Abstractions;
|
|
|
|
|
|
|
|
|
|
using PrivaPub.Api.Mastodon.Auth;
|
|
|
|
|
using PrivaPub.Api.Mastodon.Entities;
|
|
|
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
|
|
|
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
|
|
|
|
|
using static OpenIddict.Abstractions.OpenIddictConstants;
|
|
|
|
|
|
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
|
|
|
{
|
|
|
|
|
public class AppsController : MastodonController
|
|
|
|
|
{
|
|
|
|
|
readonly IOpenIddictApplicationManager _applications;
|
2026-10-18 09:18:26 +02:00
|
|
|
readonly PrivaPub.Infrastructure.Push.IVapid _vapid;
|
2026-10-01 11:52:31 +02:00
|
|
|
|
2026-10-18 09:18:26 +02:00
|
|
|
public AppsController(IOpenIddictApplicationManager applications, PrivaPub.Infrastructure.Push.IVapid vapid)
|
2026-10-01 11:52:31 +02:00
|
|
|
{
|
|
|
|
|
_applications = applications;
|
2026-10-18 09:18:26 +02:00
|
|
|
_vapid = vapid;
|
2026-10-01 11:52:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPost("/api/v1/apps"), AllowAnonymous]
|
|
|
|
|
public async Task<IActionResult> Create(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var name = Params.Get("client_name")?.Trim();
|
|
|
|
|
if (string.IsNullOrEmpty(name) || name.Length > 200)
|
|
|
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Application name can't be blank");
|
|
|
|
|
|
|
|
|
|
var redirects = Params.List("redirect_uris").SelectMany(r => r.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Distinct().ToList();
|
|
|
|
|
if (redirects.Count == 0 || redirects.Any(r => !Uri.TryCreate(r, UriKind.Absolute, out var uri) || uri.Scheme is "javascript" or "data"))
|
|
|
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Redirect URI must be an absolute URI.");
|
|
|
|
|
|
|
|
|
|
var scopes = MastodonScopes.Parse(string.Join(' ', Params.List("scopes")));
|
|
|
|
|
var descriptor = new OpenIddictApplicationDescriptor
|
|
|
|
|
{
|
|
|
|
|
ClientId = Secret(32),
|
|
|
|
|
ClientSecret = Secret(32),
|
|
|
|
|
ClientType = ClientTypes.Confidential,
|
|
|
|
|
ConsentType = ConsentTypes.Explicit,
|
|
|
|
|
DisplayName = name,
|
|
|
|
|
Permissions =
|
|
|
|
|
{
|
|
|
|
|
Permissions.Endpoints.Authorization,
|
|
|
|
|
Permissions.Endpoints.Token,
|
|
|
|
|
Permissions.Endpoints.Revocation,
|
|
|
|
|
Permissions.GrantTypes.AuthorizationCode,
|
|
|
|
|
Permissions.GrantTypes.ClientCredentials,
|
|
|
|
|
Permissions.ResponseTypes.Code
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
foreach (var scope in scopes)
|
|
|
|
|
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope);
|
|
|
|
|
foreach (var redirect in redirects)
|
|
|
|
|
descriptor.RedirectUris.Add(new Uri(redirect));
|
|
|
|
|
var website = Params.Get("website");
|
|
|
|
|
if (Uri.TryCreate(website, UriKind.Absolute, out var site) && site.Scheme is "https" or "http")
|
|
|
|
|
descriptor.Properties["website"] = System.Text.Json.JsonSerializer.SerializeToElement(website);
|
|
|
|
|
|
|
|
|
|
var application = await _applications.CreateAsync(descriptor, token);
|
|
|
|
|
return Json(new Application
|
|
|
|
|
{
|
|
|
|
|
Id = await _applications.GetIdAsync(application, token),
|
|
|
|
|
Name = name,
|
|
|
|
|
Website = website,
|
|
|
|
|
Scopes = scopes.ToList(),
|
|
|
|
|
RedirectUris = redirects,
|
|
|
|
|
RedirectUri = string.Join("\n", redirects),
|
|
|
|
|
ClientId = descriptor.ClientId,
|
2026-10-18 09:18:26 +02:00
|
|
|
ClientSecret = descriptor.ClientSecret,
|
|
|
|
|
VapidKey = (await _vapid.Key(token)).ForApi//Web Push's server key, as Mastodon still gives it here
|
2026-10-01 11:52:31 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/apps/verify_credentials")]
|
|
|
|
|
public async Task<IActionResult> Verify(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var clientId = User.GetPresenters().FirstOrDefault() ?? User.GetClaim(Claims.ClientId);
|
|
|
|
|
var application = clientId == default ? default : await _applications.FindByClientIdAsync(clientId, token);
|
|
|
|
|
if (application == default)
|
|
|
|
|
return Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
|
|
|
|
|
var properties = await _applications.GetPropertiesAsync(application, token);
|
|
|
|
|
return Json(new
|
|
|
|
|
{
|
|
|
|
|
id = await _applications.GetIdAsync(application, token),
|
|
|
|
|
name = await _applications.GetDisplayNameAsync(application, token),
|
|
|
|
|
website = properties.TryGetValue("website", out var website) ? website.GetString() : default,
|
|
|
|
|
scopes = User.GetScopes().ToList(),
|
|
|
|
|
redirect_uris = (await _applications.GetRedirectUrisAsync(application, token)).ToList(),
|
2026-10-18 09:18:26 +02:00
|
|
|
vapid_key = (await _vapid.Key(token)).ForApi
|
2026-10-01 11:52:31 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string Secret(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
|
|
|
}
|
|
|
|
|
}
|