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) { if (scope != default && !MastodonScopes.Grants(User.GetScopes(), scope.Scope)) { context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes"); return; } Me = await Resolve(User.GetClaim(Claims.Subject), HttpContext.RequestAborted); } 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; return await services.GetRequiredService().FindById(LocalActorKind.Person, avatarId, token); } 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); } }