using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using MongoDB.Entities; using OpenIddict.Abstractions; using OpenIddict.Validation.AspNetCore; using PrivaPub.Api.Mastodon.Auth; using PrivaPub.Federation.Actors; using PrivaPub.Models.Federation; using PrivaPub.Models.User; using PrivaPub.StaticServices; using static OpenIddict.Abstractions.OpenIddictConstants; namespace PrivaPub.Api.Mastodon.Infrastructure { [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] public sealed class ScopeAttribute : Attribute { public ScopeAttribute(string scope, bool requiresUser = true) { Scope = scope; RequiresUser = requiresUser; } public string Scope { get; } public bool RequiresUser { get; } } [ApiController, Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] public abstract class MastodonController : ControllerBase, IAsyncActionFilter { MastodonParams _params; protected LocalActor Me { get; private set; } protected string MyId => Me?.Id; protected MastodonParams Params => _params; [NonAction] public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { _params = await MastodonParams.Read(Request, HttpContext.RequestAborted); var scope = context.ActionDescriptor.EndpointMetadata.OfType().LastOrDefault(); if (User.Identity?.IsAuthenticated == true) { // (a scope written a|b is either) if (scope != default && !scope.Scope.Split('|').Any(one => MastodonScopes.Grants(User.GetScopes(), one))) { context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes"); return; } var subject = User.GetClaim(Claims.Subject); Me = await Resolve(subject, HttpContext.RequestAborted); // a persona's token whose persona can no longer act (suspended, its login banned or deleted) is refused // everywhere, as Mastodon refuses a revoked token; an application's own token has no persona if (Me == default && !string.IsNullOrEmpty(subject) && !subject.StartsWith("app:", StringComparison.Ordinal)) { context.Result = Error(StatusCodes.Status401Unauthorized, "The access token is invalid"); return; } } if (scope?.RequiresUser == true && Me == default) { context.Result = Error(StatusCodes.Status401Unauthorized, "This method requires an authenticated user"); return; } await next(); } async Task Resolve(string avatarId, CancellationToken token) { if (string.IsNullOrEmpty(avatarId)) return default; var services = HttpContext.RequestServices; var db = services.GetRequiredService(); var link = await db.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token); if (link == default) return default; var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token); if (root is not { IsBanned: false, DeletedAt: null }) return default; var avatar = await db.Avatars.MatchID(avatarId).ExecuteFirstAsync(token); if (avatar is not { DeletionAt: null, SuspendedAt: null }) return default; return services.GetRequiredService().FromAvatar(avatar) is { Suspended: false } me ? me : default; } protected JsonResult Json(object value, int status = StatusCodes.Status200OK) => new(value, MastodonJson.Options) { StatusCode = status }; protected static JsonResult Error(int status, string message) => new(new { error = message }, MastodonJson.Options) { StatusCode = status }; protected JsonResult NotFoundError() => Error(StatusCodes.Status404NotFound, "Record not found"); protected void Link(string path, string maxId, string minId) { var links = new List(); var baseUrl = $"{Request.Scheme}://{Request.Host}{path}"; var query = Request.Query.Where(q => q.Key is not ("max_id" or "min_id" or "since_id")).Select(q => $"{q.Key}={Uri.EscapeDataString(q.Value.ToString())}").ToList(); string With(string key, string value) => baseUrl + "?" + string.Join("&", query.Append($"{key}={value}")); if (maxId != default) links.Add($"<{With("max_id", maxId)}>; rel=\"next\""); if (minId != default) links.Add($"<{With("min_id", minId)}>; rel=\"prev\""); if (links.Count > 0) Response.Headers["Link"] = string.Join(", ", links); } protected int Limit(int fallback = 20, int max = 40) => Math.Clamp(Params.Int("limit") ?? fallback, 1, max); } }