The server's VAPID key (PushKeys, made on first need, in Mongo) is the instance API's vapid.public_key and every app's vapid_key. /api/v1/push/subscription (POST, GET, PUT, DELETE) keeps one subscription per access token, so per persona; every notification kept (never a filtered one) is queued as a SendPush job for the subscriptions whose alert and policy admit it, and sent through the `push` client (the safe handler, https only) encrypted as RFC 8291 aes128gcm or the aesgcm draft with an ES256 VAPID JWT. 404/410 delete the subscription, 429/5xx/timeouts retry, other answers drop the push. Subscriptions end with their tokens (revocation, sessions ended, suspension, deletion, a restore); backups hold neither keys nor subscriptions. The walk of every Mastodon 4.3-4.5 client route (docs/MASTODON-API.md) added conversations/:id/unread, domain_blocks/preview, v2 notifications clear and PUT policy, /oauth/userinfo and stubs, so both instance APIs now say 4.5.0 (compatible; PrivaPub <release>). The pasture gets a push sink (pushsink.test) and scenarios/push.sh in batch A. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
118 lines
4.5 KiB
C#
118 lines
4.5 KiB
C#
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<ScopeAttribute>().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<LocalActor> Resolve(string avatarId, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(avatarId))
|
|
return default;
|
|
var services = HttpContext.RequestServices;
|
|
var db = services.GetRequiredService<DbEntities>();
|
|
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<ILocalActorService>().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<string>();
|
|
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);
|
|
}
|
|
}
|