Mastodon clients can sign in: OAuth with a persona per token
OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
scopes including the granular ones, non-expiring reference tokens,
`created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
then asks which persona the application acts as. The token's subject
is that persona's id and nothing else; no root id reaches a token, an
authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
and every API request checks the same.
/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.
/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
e6a6fe46d7
commit
4d9be37c1c
22 files changed
+1394
-7
No files matched your search
@@ -0,0 +1,37 @@
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
public static class MastodonScopes
|
||||
{
|
||||
public static readonly string[] All =
|
||||
{
|
||||
"read", "write", "follow", "push", "profile",
|
||||
"read:accounts", "read:blocks", "read:bookmarks", "read:favourites", "read:filters", "read:follows", "read:lists",
|
||||
"read:mutes", "read:notifications", "read:search", "read:statuses",
|
||||
"write:accounts", "write:blocks", "write:bookmarks", "write:conversations", "write:favourites", "write:filters",
|
||||
"write:follows", "write:lists", "write:media", "write:mutes", "write:notifications", "write:reports", "write:statuses"
|
||||
};
|
||||
|
||||
public static IReadOnlyList<string> Parse(string scopes)
|
||||
{
|
||||
var parsed = (scopes ?? string.Empty).Split(new[] { ' ', '+', ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(s => All.Contains(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return parsed.Count == 0 ? new[] { "read" } : parsed;
|
||||
}
|
||||
|
||||
public static bool Grants(IEnumerable<string> granted, string required)
|
||||
{
|
||||
var scopes = granted as ICollection<string> ?? granted.ToList();
|
||||
if (scopes.Contains(required))
|
||||
return true;
|
||||
var colon = required.IndexOf(':');
|
||||
if (colon > 0 && scopes.Contains(required[..colon]))
|
||||
return true;
|
||||
return required.StartsWith("read:follows") || required.StartsWith("write:follows") || required.StartsWith("write:blocks")
|
||||
|| required.StartsWith("read:blocks") || required.StartsWith("write:mutes") || required.StartsWith("read:mutes")
|
||||
? scopes.Contains("follow")
|
||||
: false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
|
||||
using PrivaPub.Models;
|
||||
|
||||
using System.Net;
|
||||
|
||||
using static OpenIddict.Server.OpenIddictServerEvents;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
public static class OAuthSetup
|
||||
{
|
||||
public const string LoginScheme = "PrivaPub.OAuth";
|
||||
public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
|
||||
|
||||
public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment)
|
||||
{
|
||||
services.AddSingleton<IMongoDatabase>(_ => DB.Default.Database());
|
||||
|
||||
services.AddAuthentication()
|
||||
.AddCookie(LoginScheme, options =>
|
||||
{
|
||||
options.Cookie.Name = "privapub.oauth";
|
||||
options.Cookie.Path = "/oauth";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.ExpireTimeSpan = TimeSpan.FromMinutes(15);
|
||||
options.SlidingExpiration = false;
|
||||
options.LoginPath = "/oauth/login";
|
||||
});
|
||||
|
||||
services.AddOpenIddict()
|
||||
.AddCore(options => options.UseMongoDb())
|
||||
.AddServer(options =>
|
||||
{
|
||||
options.SetAuthorizationEndpointUris("/oauth/authorize")
|
||||
.SetTokenEndpointUris("/oauth/token")
|
||||
.SetRevocationEndpointUris("/oauth/revoke")
|
||||
.SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server");
|
||||
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow();
|
||||
options.RegisterScopes(MastodonScopes.All);
|
||||
options.UseReferenceAccessTokens();
|
||||
options.SetAccessTokenLifetime(null);
|
||||
options.SetAuthorizationCodeLifetime(TimeSpan.FromMinutes(10));
|
||||
options.DisableAccessTokenEncryption();
|
||||
|
||||
var aspNetCore = options.UseAspNetCore()
|
||||
.EnableAuthorizationEndpointPassthrough()
|
||||
.EnableTokenEndpointPassthrough();
|
||||
if (!environment.IsProduction())
|
||||
aspNetCore.DisableTransportSecurityRequirement();
|
||||
|
||||
options.AddEventHandler<ApplyAuthorizationResponseContext>(builder => builder.UseInlineHandler(OutOfBandCode)
|
||||
.SetOrder(OpenIddictServerAspNetCoreHandlers.Authentication.ProcessFormPostResponse.Descriptor.Order - 1_000)
|
||||
.SetType(OpenIddictServerHandlerType.Custom));
|
||||
options.AddEventHandler<ApplyTokenResponseContext>(builder => builder.UseInlineHandler(context =>
|
||||
{
|
||||
if (context.Response.AccessToken != default)
|
||||
context.Response["created_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
return default;
|
||||
}));
|
||||
})
|
||||
.AddValidation(options =>
|
||||
{
|
||||
options.UseLocalServer();
|
||||
options.UseAspNetCore();
|
||||
});
|
||||
|
||||
services.AddOptions<OpenIddictServerOptions>().Configure<IOptionsMonitor<AppConfiguration>>((options, app) =>
|
||||
{
|
||||
options.Issuer = new Uri(app.CurrentValue.BackendBaseAddress.TrimEnd('/') + "/");
|
||||
options.SigningCredentials.Add(new SigningCredentials(OidcKey.Load(OidcKey.Signing), SecurityAlgorithms.RsaSha256));
|
||||
options.EncryptionCredentials.Add(new EncryptingCredentials(OidcKey.Load(OidcKey.Encryption),
|
||||
SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
static async ValueTask OutOfBandCode(ApplyAuthorizationResponseContext context)
|
||||
{
|
||||
if (context.RedirectUri != OutOfBand || context.Response.Code == default)
|
||||
return;
|
||||
var http = context.Transaction.GetHttpRequest()?.HttpContext;
|
||||
if (http == default)
|
||||
return;
|
||||
var code = WebUtility.HtmlEncode(context.Response.Code);
|
||||
http.Response.StatusCode = StatusCodes.Status200OK;
|
||||
http.Response.ContentType = "text/html; charset=utf-8";
|
||||
http.Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'";
|
||||
http.Response.Headers["Cache-Control"] = "no-store";
|
||||
await http.Response.WriteAsync($$"""
|
||||
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Authorization code</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:480px;margin:48px auto;padding:0 16px}
|
||||
code{display:block;font-size:1.1rem;padding:12px;border:1px solid #ccc;word-break:break-all}</style></head>
|
||||
<body><h1>Authorization code</h1><p>Copy this code into the application:</p><code>{{code}}</code></body></html>
|
||||
""");
|
||||
context.HandleRequest();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
public class OidcKey : Entity
|
||||
{
|
||||
public string Use { get; set; }
|
||||
public string PrivateKeyPem { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public const string Signing = "signing";
|
||||
public const string Encryption = "encryption";
|
||||
|
||||
public static RsaSecurityKey Load(string use)
|
||||
{
|
||||
var key = DB.Default.Find<OidcKey>().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
if (key == default)
|
||||
{
|
||||
using var created = RSA.Create(2048);
|
||||
key = new OidcKey { Use = use, PrivateKeyPem = created.ExportRSAPrivateKeyPem() };
|
||||
DB.Default.SaveAsync(key).GetAwaiter().GetResult();
|
||||
key = DB.Default.Find<OidcKey>().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(key.PrivateKeyPem);
|
||||
return new RsaSecurityKey(rsa) { KeyId = key.ID };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
[ApiController]
|
||||
public class TokenController : ControllerBase
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public TokenController(DbEntities dbEntities)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
[HttpPost("/oauth/token"), IgnoreAntiforgeryToken, Produces("application/json")]
|
||||
public async Task<IActionResult> Exchange(CancellationToken token)
|
||||
{
|
||||
var request = HttpContext.GetOpenIddictServerRequest();
|
||||
if (request == default)
|
||||
return BadRequest();
|
||||
|
||||
if (request.IsAuthorizationCodeGrantType())
|
||||
{
|
||||
var principal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
|
||||
if (principal == default || !await Usable(principal.GetClaim(Claims.Subject), token))
|
||||
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
|
||||
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
if (request.IsClientCredentialsGrantType())
|
||||
{
|
||||
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(Claims.Subject, "app:" + request.ClientId);
|
||||
identity.SetScopes(MastodonScopes.Parse(request.Scope));
|
||||
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
|
||||
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported.");
|
||||
}
|
||||
|
||||
async Task<bool> Usable(string avatarId, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarId))
|
||||
return false;
|
||||
var link = await _dbEntities.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
|
||||
var root = link == default ? default : await _dbEntities.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
|
||||
var avatar = await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token);
|
||||
return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: null };
|
||||
}
|
||||
|
||||
ForbidResult Refuse(string error, string description) => Forbid(new AuthenticationProperties(new Dictionary<string, string>
|
||||
{
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.Error] = error,
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description
|
||||
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public partial class AccountsController : MastodonController
|
||||
{
|
||||
readonly MastodonMapper _mapper;
|
||||
|
||||
public AccountsController(MastodonMapper mapper)
|
||||
{
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
|
||||
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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;
|
||||
|
||||
public AppsController(IOpenIddictApplicationManager applications)
|
||||
{
|
||||
_applications = applications;
|
||||
}
|
||||
|
||||
[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,
|
||||
ClientSecret = descriptor.ClientSecret
|
||||
});
|
||||
}
|
||||
|
||||
[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(),
|
||||
vapid_key = string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
static string Secret(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class InstanceController : MastodonController
|
||||
{
|
||||
public const string Version = "4.2.0 (compatible; PrivaPub)";
|
||||
const string Description = "A small ActivityPub server where one private login keeps several unlinkable public personas.";
|
||||
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public InstanceController(ILocalActorService localActors)
|
||||
{
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
string Domain => new Uri(_localActors.BaseAddress).Authority;
|
||||
|
||||
static object Statuses => new { max_characters = 5000, max_media_attachments = 4, characters_reserved_per_url = 23 };
|
||||
|
||||
static object MediaAttachments => new
|
||||
{
|
||||
supported_mime_types = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm", "audio/mpeg", "audio/ogg" },
|
||||
image_size_limit = 16 * 1024 * 1024,
|
||||
image_matrix_limit = 16_777_216,
|
||||
video_size_limit = 99 * 1024 * 1024,
|
||||
video_frame_rate_limit = 60,
|
||||
video_matrix_limit = 2_304_000
|
||||
};
|
||||
|
||||
static object Polls => new { max_options = 4, max_characters_per_option = 50, min_expiration = 300, max_expiration = 2_629_746 };
|
||||
|
||||
[HttpGet("/api/v1/instance"), AllowAnonymous]
|
||||
public async Task<IActionResult> V1(CancellationToken token)
|
||||
{
|
||||
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
|
||||
var statuses = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token);
|
||||
var domains = (await DB.Default.Find<ForeignAvatar, string>().Project(a => a.Domain).ExecuteAsync(token)).Distinct().Count();
|
||||
return Json(new
|
||||
{
|
||||
uri = Domain,
|
||||
title = "PrivaPub",
|
||||
short_description = Description,
|
||||
description = Description,
|
||||
email = string.Empty,
|
||||
version = Version,
|
||||
urls = new { streaming_api = $"wss://{Domain}" },
|
||||
stats = new { user_count = users, status_count = statuses, domain_count = domains },
|
||||
thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png",
|
||||
languages = new[] { "en" },
|
||||
registrations = false,
|
||||
approval_required = false,
|
||||
invites_enabled = false,
|
||||
configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
|
||||
contact_account = default(object),
|
||||
rules = Array.Empty<object>()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/instance"), AllowAnonymous]
|
||||
public IActionResult V2() => Json(new
|
||||
{
|
||||
domain = Domain,
|
||||
title = "PrivaPub",
|
||||
version = Version,
|
||||
source_url = "https://git.thepra.dev/thepra/SocialPub",
|
||||
description = Description,
|
||||
usage = new { users = new { active_month = 0 } },
|
||||
thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" },
|
||||
languages = new[] { "en" },
|
||||
configuration = new
|
||||
{
|
||||
urls = new { streaming = $"wss://{Domain}" },
|
||||
accounts = new { max_featured_tags = 0, max_pinned_statuses = 0 },
|
||||
statuses = Statuses,
|
||||
media_attachments = MediaAttachments,
|
||||
polls = Polls,
|
||||
translation = new { enabled = false }
|
||||
},
|
||||
registrations = new { enabled = false, approval_required = false, message = default(string) },
|
||||
contact = new { email = string.Empty, account = default(object) },
|
||||
rules = Array.Empty<object>()
|
||||
});
|
||||
|
||||
[HttpGet("/api/v1/instance/peers"), AllowAnonymous]
|
||||
public IActionResult Peers() => Json(Array.Empty<string>());
|
||||
|
||||
[HttpGet("/api/v1/instance/activity"), AllowAnonymous]
|
||||
public IActionResult Activity() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/instance/rules"), AllowAnonymous]
|
||||
public IActionResult Rules() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/instance/extended_description"), AllowAnonymous]
|
||||
public IActionResult ExtendedDescription() => Json(new { updated_at = MastodonJson.Day(DateTime.UtcNow), content = $"<p>{Description}</p>" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
namespace PrivaPub.Api.Mastodon.Entities
|
||||
{
|
||||
public class Account
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Username { get; set; }
|
||||
public string Acct { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public bool Locked { get; set; }
|
||||
public bool Bot { get; set; }
|
||||
public bool? Discoverable { get; set; }
|
||||
public bool Indexable { get; set; }
|
||||
public bool Group { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
public string Note { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Uri { get; set; }
|
||||
public string Avatar { get; set; }
|
||||
public string AvatarStatic { get; set; }
|
||||
public string Header { get; set; }
|
||||
public string HeaderStatic { get; set; }
|
||||
public int FollowersCount { get; set; }
|
||||
public int FollowingCount { get; set; }
|
||||
public int StatusesCount { get; set; }
|
||||
public string LastStatusAt { get; set; }
|
||||
public bool? HideCollections { get; set; }
|
||||
public bool Noindex { get; set; } = true;
|
||||
public List<object> Emojis { get; set; } = new();
|
||||
public List<object> Roles { get; set; } = new();
|
||||
public List<Field> Fields { get; set; } = new();
|
||||
public Source Source { get; set; }
|
||||
}
|
||||
|
||||
public class Field
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Value { get; set; }
|
||||
public string VerifiedAt { get; set; }
|
||||
}
|
||||
|
||||
public class Source
|
||||
{
|
||||
public string Privacy { get; set; } = "public";
|
||||
public bool Sensitive { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string Note { get; set; } = string.Empty;
|
||||
public List<Field> Fields { get; set; } = new();
|
||||
public int FollowRequestsCount { get; set; }
|
||||
public string AttributionDomains { get; set; }
|
||||
}
|
||||
|
||||
public class Status
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Uri { get; set; }
|
||||
public string Url { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public string InReplyToId { get; set; }
|
||||
public string InReplyToAccountId { get; set; }
|
||||
public Status Reblog { get; set; }
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public string CreatedAt { get; set; }
|
||||
public string EditedAt { get; set; }
|
||||
public List<object> Emojis { get; set; } = new();
|
||||
public int RepliesCount { get; set; }
|
||||
public int ReblogsCount { get; set; }
|
||||
public int FavouritesCount { get; set; }
|
||||
public bool Reblogged { get; set; }
|
||||
public bool Favourited { get; set; }
|
||||
public bool Muted { get; set; }
|
||||
public bool Bookmarked { get; set; }
|
||||
public bool Pinned { get; set; }
|
||||
public bool Sensitive { get; set; }
|
||||
public string SpoilerText { get; set; } = string.Empty;
|
||||
public string Visibility { get; set; }
|
||||
public List<MediaAttachment> MediaAttachments { get; set; } = new();
|
||||
public List<StatusMention> Mentions { get; set; } = new();
|
||||
public List<StatusTag> Tags { get; set; } = new();
|
||||
public object Card { get; set; }
|
||||
public object Poll { get; set; }
|
||||
public Application Application { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string Text { get; set; }
|
||||
public List<object> Filtered { get; set; } = new();
|
||||
}
|
||||
|
||||
public class MediaAttachment
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string PreviewUrl { get; set; }
|
||||
public string RemoteUrl { get; set; }
|
||||
public object Meta { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Blurhash { get; set; }
|
||||
}
|
||||
|
||||
public class StatusMention
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Username { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Acct { get; set; }
|
||||
}
|
||||
|
||||
public class StatusTag
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
public class Application
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Website { get; set; }
|
||||
public List<string> Scopes { get; set; } = new();
|
||||
public List<string> RedirectUris { get; set; } = new();
|
||||
public string RedirectUri { get; set; }
|
||||
public string ClientId { get; set; }
|
||||
public string ClientSecret { get; set; }
|
||||
public string VapidKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class Relationship
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public bool Following { get; set; }
|
||||
public bool ShowingReblogs { get; set; }
|
||||
public bool Notifying { get; set; }
|
||||
public List<string> Languages { get; set; }
|
||||
public bool FollowedBy { get; set; }
|
||||
public bool Blocking { get; set; }
|
||||
public bool BlockedBy { get; set; }
|
||||
public bool Muting { get; set; }
|
||||
public bool MutingNotifications { get; set; }
|
||||
public bool Requested { get; set; }
|
||||
public bool RequestedBy { get; set; }
|
||||
public bool DomainBlocking { get; set; }
|
||||
public bool Endorsed { get; set; }
|
||||
public string Note { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class Notification
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
public string GroupKey { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public Status Status { get; set; }
|
||||
}
|
||||
|
||||
public class Context
|
||||
{
|
||||
public List<Status> Ancestors { get; set; } = new();
|
||||
public List<Status> Descendants { get; set; } = new();
|
||||
}
|
||||
|
||||
public class StatusEdit
|
||||
{
|
||||
public string Content { get; set; }
|
||||
public string SpoilerText { get; set; }
|
||||
public bool Sensitive { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public List<MediaAttachment> MediaAttachments { get; set; } = new();
|
||||
public List<object> Emojis { get; set; } = new();
|
||||
}
|
||||
|
||||
public class StatusSource
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string SpoilerText { get; set; }
|
||||
}
|
||||
|
||||
public class SearchResults
|
||||
{
|
||||
public List<Account> Accounts { get; set; } = new();
|
||||
public List<Status> Statuses { get; set; } = new();
|
||||
public List<object> Hashtags { get; set; } = new();
|
||||
}
|
||||
|
||||
public class Conversation
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public bool Unread { get; set; }
|
||||
public List<Account> Accounts { get; set; } = new();
|
||||
public Status LastStatus { get; set; }
|
||||
}
|
||||
|
||||
public class Marker
|
||||
{
|
||||
public string LastReadId { get; set; }
|
||||
public int Version { get; set; }
|
||||
public string UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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)
|
||||
{
|
||||
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<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;
|
||||
return await services.GetRequiredService<ILocalActorService>().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<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
public static class MastodonJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DictionaryKeyPolicy = default,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
||||
};
|
||||
|
||||
public static string Time(DateTime value) =>
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
public static string Day(DateTime value) =>
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00.000Z", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
public sealed class MastodonParams
|
||||
{
|
||||
readonly Dictionary<string, List<string>> _values;
|
||||
|
||||
MastodonParams(Dictionary<string, List<string>> values)
|
||||
{
|
||||
_values = values;
|
||||
}
|
||||
|
||||
public static async Task<MastodonParams> Read(HttpRequest request, CancellationToken token)
|
||||
{
|
||||
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var pair in request.Query)
|
||||
foreach (var value in pair.Value)
|
||||
Add(values, pair.Key, value);
|
||||
|
||||
if (request.HasFormContentType)
|
||||
{
|
||||
var form = await request.ReadFormAsync(token);
|
||||
foreach (var pair in form)
|
||||
foreach (var value in pair.Value)
|
||||
Add(values, pair.Key, value);
|
||||
}
|
||||
else if (request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true && request.ContentLength != 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = await JsonDocument.ParseAsync(request.Body, cancellationToken: token);
|
||||
Flatten(values, default, document.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return new MastodonParams(values);
|
||||
}
|
||||
|
||||
public static MastodonParams From(IEnumerable<KeyValuePair<string, string>> pairs)
|
||||
{
|
||||
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var pair in pairs)
|
||||
Add(values, pair.Key, pair.Value);
|
||||
return new MastodonParams(values);
|
||||
}
|
||||
|
||||
public bool Has(string name) => _values.ContainsKey(name);
|
||||
|
||||
public string Get(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : default;
|
||||
|
||||
public IReadOnlyList<string> List(string name) => _values.TryGetValue(name, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
public bool? Bool(string name) => Get(name)?.ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "1" or "on" or "yes" => true,
|
||||
"false" or "0" or "off" or "no" or "" => false,
|
||||
_ => default
|
||||
};
|
||||
|
||||
public int? Int(string name) => int.TryParse(Get(name), out var value) ? value : default;
|
||||
|
||||
static void Add(Dictionary<string, List<string>> values, string key, string value)
|
||||
{
|
||||
key = key.EndsWith("[]", StringComparison.Ordinal) ? key[..^2] : key;
|
||||
if (!values.TryGetValue(key, out var list))
|
||||
values[key] = list = new List<string>();
|
||||
list.Add(value);
|
||||
}
|
||||
|
||||
static void Flatten(Dictionary<string, List<string>> values, string prefix, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var property in element.EnumerateObject())
|
||||
Flatten(values, prefix == default ? property.Name : $"{prefix}[{property.Name}]", property.Value);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
Flatten(values, prefix + "[]", item);
|
||||
else
|
||||
Add(values, prefix, Scalar(item));
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Null or JsonValueKind.Undefined:
|
||||
break;
|
||||
default:
|
||||
if (prefix != default)
|
||||
Add(values, prefix, Scalar(element));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static string Scalar(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Net;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Mappers
|
||||
{
|
||||
public class MastodonMapper
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
|
||||
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
|
||||
|
||||
public async Task<Account> Local(LocalActor actor, bool withSource, CancellationToken token)
|
||||
{
|
||||
var followers = await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted, token);
|
||||
var following = actor.Kind == LocalActorKind.Person
|
||||
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
|
||||
: 0;
|
||||
var latest = await _dbEntities.Posts
|
||||
.Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy
|
||||
&& !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null)
|
||||
.Sort(p => p.ID, Order.Descending)
|
||||
.Limit(1)
|
||||
.ExecuteFirstAsync(token);
|
||||
var statuses = latest == default
|
||||
? 0
|
||||
: await DB.Default.CountAsync<PostEntity>(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id)
|
||||
&& !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token);
|
||||
|
||||
var account = new Account
|
||||
{
|
||||
Id = actor.Id,
|
||||
Username = actor.UserName,
|
||||
Acct = actor.UserName,
|
||||
DisplayName = actor.Name ?? actor.UserName,
|
||||
Locked = actor.ManuallyApprovesFollowers,
|
||||
Discoverable = actor.Discoverable,
|
||||
Group = actor.Kind == LocalActorKind.Group,
|
||||
CreatedAt = MastodonJson.Day(actor.Published),
|
||||
Note = ActivityPubRenderer.Html(actor.Summary),
|
||||
Url = actor.HtmlUrl,
|
||||
Uri = actor.Uri,
|
||||
Avatar = actor.PictureURL ?? MissingAvatar,
|
||||
AvatarStatic = actor.PictureURL ?? MissingAvatar,
|
||||
Header = actor.ThumbnailURL ?? MissingHeader,
|
||||
HeaderStatic = actor.ThumbnailURL ?? MissingHeader,
|
||||
FollowersCount = (int)followers,
|
||||
FollowingCount = (int)following,
|
||||
StatusesCount = (int)statuses,
|
||||
LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"),
|
||||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList()
|
||||
};
|
||||
if (withSource)
|
||||
account.Source = new Source
|
||||
{
|
||||
Note = actor.Summary ?? string.Empty,
|
||||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
|
||||
};
|
||||
return account;
|
||||
}
|
||||
|
||||
public Account Foreign(ForeignAvatar foreign) => new()
|
||||
{
|
||||
Id = foreign.ID,
|
||||
Username = foreign.UserName,
|
||||
Acct = $"{foreign.UserName}@{foreign.Domain}",
|
||||
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
|
||||
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
|
||||
Group = foreign.AvatarType == AvatarType.Group,
|
||||
Discoverable = foreign.IsDiscoverable,
|
||||
CreatedAt = MastodonJson.Day(foreign.CreatedAt),
|
||||
Note = foreign.Biography ?? string.Empty,
|
||||
Url = foreign.Url ?? foreign.ActorURI,
|
||||
Uri = foreign.ActorURI,
|
||||
Avatar = foreign.PictureURL ?? MissingAvatar,
|
||||
AvatarStatic = foreign.PictureURL ?? MissingAvatar,
|
||||
Header = foreign.ThumbnailURL ?? MissingHeader,
|
||||
HeaderStatic = foreign.ThumbnailURL ?? MissingHeader
|
||||
};
|
||||
|
||||
public async Task<Account> Account(string id, CancellationToken token) =>
|
||||
(await Accounts(new[] { id }, token)).GetValueOrDefault(id);
|
||||
|
||||
public async Task<Dictionary<string, Account>> Accounts(IEnumerable<string> ids, CancellationToken token)
|
||||
{
|
||||
var wanted = ids.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
|
||||
var accounts = new Dictionary<string, Account>();
|
||||
if (wanted.Count == 0)
|
||||
return accounts;
|
||||
|
||||
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
|
||||
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
|
||||
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
|
||||
accounts[foreign.ID] = Foreign(foreign);
|
||||
return accounts;
|
||||
}
|
||||
|
||||
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
|
||||
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
|
||||
|
||||
public async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token)
|
||||
{
|
||||
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
|
||||
var originals = originalIds.Count == 0
|
||||
? new Dictionary<string, PostEntity>()
|
||||
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var all = posts.Concat(originals.Values).ToList();
|
||||
|
||||
var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList();
|
||||
var mentionAccounts = mentionUris.Count == 0
|
||||
? new Dictionary<string, ForeignAvatar>()
|
||||
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
|
||||
var accounts = await Accounts(all.Select(AuthorOf), token);
|
||||
|
||||
var ids = all.Select(p => p.ID).ToList();
|
||||
var favourited = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
|
||||
var reblogged = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||
.Select(p => p.ReblogOfPostId).ToHashSet();
|
||||
|
||||
Status Map(PostEntity post)
|
||||
{
|
||||
if (!accounts.TryGetValue(AuthorOf(post), out var account))
|
||||
return default;
|
||||
var status = new Status
|
||||
{
|
||||
Id = post.ID,
|
||||
Uri = post.ObjectURI,
|
||||
Url = post.Url ?? post.ObjectURI,
|
||||
Account = account,
|
||||
InReplyToId = post.AnsweringToPostId,
|
||||
InReplyToAccountId = post.InReplyToAccountId,
|
||||
Content = Content(post),
|
||||
CreatedAt = MastodonJson.Time(post.CreationDate),
|
||||
EditedAt = post.EditedAt.HasValue ? MastodonJson.Time(post.EditedAt.Value) : default,
|
||||
RepliesCount = post.RepliesCount,
|
||||
ReblogsCount = post.ReblogsCount,
|
||||
FavouritesCount = post.FavouritesCount,
|
||||
Favourited = favourited.Contains(post.ID),
|
||||
Reblogged = reblogged.Contains(post.ID),
|
||||
Sensitive = post.HasContentWarning,
|
||||
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
|
||||
Visibility = Visibility(post.Visibility),
|
||||
Language = post.Language,
|
||||
MediaAttachments = post.Media.Select(Media).ToList(),
|
||||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||||
};
|
||||
return status;
|
||||
}
|
||||
|
||||
var mapped = new List<Status>();
|
||||
foreach (var post in posts)
|
||||
{
|
||||
var status = Map(post);
|
||||
if (status == default)
|
||||
continue;
|
||||
if (post.ReblogOfPostId != default)
|
||||
{
|
||||
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
|
||||
continue;
|
||||
status.Reblog = inner;
|
||||
status.Content = string.Empty;
|
||||
status.Reblogged = reblogged.Contains(original.ID);
|
||||
}
|
||||
mapped.Add(status);
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
|
||||
|
||||
public static string Content(PostEntity post)
|
||||
{
|
||||
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
|
||||
return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy
|
||||
? content ?? string.Empty
|
||||
: $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
|
||||
}
|
||||
|
||||
public static string Visibility(PostVisibility visibility) => visibility switch
|
||||
{
|
||||
PostVisibility.Unlisted => "unlisted",
|
||||
PostVisibility.FollowersOnly or PostVisibility.Circle => "private",
|
||||
PostVisibility.Direct => "direct",
|
||||
_ => "public"
|
||||
};
|
||||
|
||||
StatusMention Mention(PostMention mention, IReadOnlyDictionary<string, ForeignAvatar> foreign)
|
||||
{
|
||||
if (mention.IsLocal)
|
||||
{
|
||||
var handle = mention.Handle?.TrimStart('@').Split('@')[0];
|
||||
return new StatusMention { Id = mention.AccountId, Username = handle, Acct = handle, Url = mention.ActorURI };
|
||||
}
|
||||
if (!foreign.TryGetValue(mention.ActorURI, out var account))
|
||||
return default;
|
||||
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
|
||||
}
|
||||
|
||||
static MediaAttachment Media(PostMedia media) => new()
|
||||
{
|
||||
Id = media.Id.ToString("N"),
|
||||
Type = media.ContentType switch
|
||||
{
|
||||
{ } type when type.StartsWith("image/gif") => "gifv",
|
||||
{ } type when type.StartsWith("image/") => "image",
|
||||
{ } type when type.StartsWith("video/") => "video",
|
||||
{ } type when type.StartsWith("audio/") => "audio",
|
||||
_ => "unknown"
|
||||
},
|
||||
Url = media.URL ?? media.RemoteURL,
|
||||
PreviewUrl = media.URL ?? media.RemoteURL,
|
||||
RemoteUrl = media.RemoteURL,
|
||||
Description = media.Description,
|
||||
Blurhash = media.Blurhash,
|
||||
Meta = media.Width.HasValue && media.Height.HasValue
|
||||
? new { original = new { width = media.Width, height = media.Height, aspect = (double)media.Width / media.Height.Value } }
|
||||
: default
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ namespace PrivaPub.Federation.Rendering
|
||||
return "@" + (handle.Contains('@') ? handle : $"{handle}@{new Uri(mention.ActorURI).Authority}");
|
||||
}
|
||||
|
||||
static string FieldValue(string value)
|
||||
public static string FieldValue(string value)
|
||||
{
|
||||
var encoded = WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
return Uri.TryCreate(value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
|
||||
using OpenIddict.Validation.AspNetCore;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Models;
|
||||
@@ -27,6 +29,8 @@ namespace PrivaPub.Middleware
|
||||
{
|
||||
public static class PrivaPubConfigurations
|
||||
{
|
||||
const string SchemeSelector = "PrivaPub";
|
||||
|
||||
public static IServiceCollection PrivaPubAppSettingsConfiguration(this IServiceCollection service, IConfiguration configuration)
|
||||
{
|
||||
return service
|
||||
@@ -89,10 +93,14 @@ namespace PrivaPub.Middleware
|
||||
})
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultAuthenticateScheme = SchemeSelector;
|
||||
options.DefaultChallengeScheme = SchemeSelector;
|
||||
options.DefaultScheme = SchemeSelector;
|
||||
})
|
||||
.AddPolicyScheme(SchemeSelector, SchemeSelector, options => options.ForwardDefaultSelector = context =>
|
||||
context.Request.Path.StartsWithSegments("/api")
|
||||
? OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme
|
||||
: JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddPrivaPubAuth(configuration)
|
||||
.Services
|
||||
.AddSingleton<AuthTokenManager>()
|
||||
@@ -179,7 +187,7 @@ namespace PrivaPub.Middleware
|
||||
configure.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.WithExposedHeaders("Link", "X-RateLimit-Remaining", "X-RateLimit-Reset")
|
||||
.DisallowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<PackageReference Include="Markdig" Version="1.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="MongoDB.Entities" Version="25.1.0" />
|
||||
<PackageReference Include="OpenIddict.AspNetCore" Version="7.7.1" />
|
||||
<PackageReference Include="OpenIddict.MongoDb" Version="7.7.1" />
|
||||
<PackageReference Include="PasswordGenerator" Version="3.0.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.MongoDB" Version="7.3.0" />
|
||||
|
||||
@@ -11,6 +11,7 @@ using Serilog;
|
||||
|
||||
using PrivaPub.Data;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Api.Mastodon.Auth;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Infrastructure.Cli;
|
||||
using PrivaPub.Infrastructure.Data;
|
||||
@@ -54,6 +55,8 @@ try
|
||||
.PrivaPubFederationConfiguration(builder.Configuration)
|
||||
.PrivaPubCORSConfiguration()
|
||||
.PrivaPubRateLimiting()
|
||||
.PrivaPubOAuth(builder.Environment)
|
||||
.AddScoped<PrivaPub.Api.Mastodon.Mappers.MastodonMapper>()
|
||||
.PrivaPubMiddlewareConfiguration();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace PrivaPub.Services
|
||||
try
|
||||
{
|
||||
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
|
||||
var webResult = new WebResult().Invalidate(localizer["Unauthorized: {0}", context.Exception], StatusCodes.Status401Unauthorized);
|
||||
var webResult = new WebResult().Invalidate(localizer["Unauthorized."], StatusCodes.Status401Unauthorized);
|
||||
context.Response.ContentType = contentType;
|
||||
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult)));
|
||||
}
|
||||
@@ -35,7 +35,7 @@ namespace PrivaPub.Services
|
||||
try
|
||||
{
|
||||
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
|
||||
var webResult = new WebResult().Invalidate(localizer["Forbidden: {0}", context.Result.None ? "N/A" : context.Result.Failure?.Message ?? "N/A"], StatusCodes.Status403Forbidden);
|
||||
var webResult = new WebResult().Invalidate(localizer["Forbidden."], StatusCodes.Status403Forbidden);
|
||||
context.Response.ContentType = contentType;
|
||||
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@page "/oauth/authorize"
|
||||
@model PrivaPub.Web.Pages.OAuth.AuthorizeModel
|
||||
@{
|
||||
ViewData["Title"] = "Authorize";
|
||||
}
|
||||
<header><h1>Authorize @Model.ApplicationName</h1></header>
|
||||
<p>It asks for: <strong>@string.Join(", ", Model.RequestedScopes)</strong>.</p>
|
||||
@if (Model.Avatars.Count == 0)
|
||||
{
|
||||
<p role="alert">This login has no persona yet. Create one first.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post">
|
||||
@foreach (var parameter in Model.Parameters)
|
||||
{
|
||||
<input type="hidden" name="@parameter.Key" value="@parameter.Value">
|
||||
}
|
||||
<fieldset>
|
||||
<legend>Act as</legend>
|
||||
@foreach (var avatar in Model.Avatars)
|
||||
{
|
||||
<p><label><input type="radio" name="avatarId" value="@avatar.Id" required @(avatar == Model.Avatars[0] ? "checked" : "")> @avatar.Name <span class="meta">@@@avatar.Handle</span></label></p>
|
||||
}
|
||||
</fieldset>
|
||||
<p class="meta">Each persona is a separate account to the application. Choosing one does not reveal the others.</p>
|
||||
<p><button type="submit" name="decision" value="allow">Allow</button> <button type="submit" name="decision" value="deny">Deny</button></p>
|
||||
</form>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@page "/oauth/login"
|
||||
@model PrivaPub.Web.Pages.OAuth.LoginModel
|
||||
@{
|
||||
ViewData["Title"] = "Sign in";
|
||||
}
|
||||
<header><h1>Sign in to PrivaPub</h1></header>
|
||||
<p class="meta">Sign in with your private login. You will then choose which persona the application acts as; the
|
||||
application never sees the login.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl">
|
||||
<p><label>Username<br><input name="UserName" autocomplete="username" required autofocus></label></p>
|
||||
<p><label>Password<br><input name="Password" type="password" autocomplete="current-password" required></label></p>
|
||||
@if (Model.Error != default)
|
||||
{
|
||||
<p role="alert">@Model.Error</p>
|
||||
}
|
||||
<p><button type="submit">Sign in</button></p>
|
||||
</form>
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Auth;
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
namespace PrivaPub.Web.Pages.OAuth
|
||||
{
|
||||
[EnableRateLimiting(RateLimiting.Accounts)]
|
||||
public class LoginModel : PageModel
|
||||
{
|
||||
readonly IRootUsersService _users;
|
||||
|
||||
public LoginModel(IRootUsersService users)
|
||||
{
|
||||
_users = users;
|
||||
}
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string ReturnUrl { get; set; }
|
||||
|
||||
public string Error { get; private set; }
|
||||
|
||||
public void OnGet() => Harden();
|
||||
|
||||
public async Task<IActionResult> OnPostAsync([FromForm] string userName, [FromForm] string password)
|
||||
{
|
||||
Harden();
|
||||
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
Error = "Enter your username and password.";
|
||||
return Page();
|
||||
}
|
||||
var result = await _users.LoginAsync(new LoginForm { UserName = userName, Password = password });
|
||||
if (!result.IsValid)
|
||||
{
|
||||
Error = "That username and password do not match.";
|
||||
return Page();
|
||||
}
|
||||
var (root, _) = ((RootUser, ClientModels.User.ViewUserSettings))result.Data;
|
||||
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, root.ID) }, OAuthSetup.LoginScheme);
|
||||
await HttpContext.SignInAsync(OAuthSetup.LoginScheme, new ClaimsPrincipal(identity));
|
||||
|
||||
var target = ReturnUrl?.StartsWith("/oauth/authorize?", StringComparison.Ordinal) == true ? ReturnUrl : "/oauth/authorize";
|
||||
return LocalRedirect(target + (target.Contains('?') ? "&" : "?") + "signed_in=1");
|
||||
}
|
||||
|
||||
void Harden()
|
||||
{
|
||||
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'";
|
||||
Response.Headers["Cache-Control"] = "no-store";
|
||||
}
|
||||
}
|
||||
|
||||
[IgnoreAntiforgeryToken]
|
||||
public class AuthorizeModel : PageModel
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly OpenIddict.Abstractions.IOpenIddictApplicationManager _applications;
|
||||
|
||||
public AuthorizeModel(DbEntities dbEntities, ILocalActorService localActors, OpenIddict.Abstractions.IOpenIddictApplicationManager applications)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_applications = applications;
|
||||
}
|
||||
|
||||
public string ApplicationName { get; private set; }
|
||||
public IReadOnlyList<string> RequestedScopes { get; private set; } = Array.Empty<string>();
|
||||
public IReadOnlyList<LocalActor> Avatars { get; private set; } = Array.Empty<LocalActor>();
|
||||
public IReadOnlyList<KeyValuePair<string, string>> Parameters { get; private set; } = Array.Empty<KeyValuePair<string, string>>();
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(CancellationToken token) => await Show(token);
|
||||
|
||||
public async Task<IActionResult> OnPostAsync([FromForm] string avatarId, [FromForm] string decision, CancellationToken token)
|
||||
{
|
||||
var request = HttpContext.GetOpenIddictServerRequest();
|
||||
var rootId = await SignedInRoot();
|
||||
if (request == default || rootId == default)
|
||||
return await Show(token);
|
||||
|
||||
if (decision != "allow")
|
||||
return Forbid(new AuthenticationProperties(new Dictionary<string, string>
|
||||
{
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied,
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user denied the request."
|
||||
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
|
||||
var owns = !string.IsNullOrEmpty(avatarId)
|
||||
&& await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
|
||||
var avatar = owns ? await _localActors.FindById(LocalActorKind.Person, avatarId, token) : default;
|
||||
if (avatar == default)
|
||||
return await Show(token);
|
||||
|
||||
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(Claims.Subject, avatar.Id);
|
||||
identity.SetClaim(Claims.Name, avatar.UserName);
|
||||
identity.SetScopes(MastodonScopes.Parse(request.Scope));
|
||||
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
|
||||
await HttpContext.SignOutAsync(OAuthSetup.LoginScheme);
|
||||
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Show(CancellationToken token)
|
||||
{
|
||||
var request = HttpContext.GetOpenIddictServerRequest();
|
||||
if (request == default)
|
||||
return BadRequest();
|
||||
|
||||
var rootId = await SignedInRoot();
|
||||
var forceLogin = string.Equals((string)request["force_login"], "true", StringComparison.OrdinalIgnoreCase)
|
||||
&& Request.Query["signed_in"] != "1";
|
||||
if (rootId == default || forceLogin)
|
||||
{
|
||||
var query = string.Join("&", Request.Query.Where(q => q.Key != "signed_in").Select(q => $"{Uri.EscapeDataString(q.Key)}={Uri.EscapeDataString(q.Value.ToString())}"));
|
||||
return Redirect($"/oauth/login?returnUrl={Uri.EscapeDataString("/oauth/authorize?" + query)}");
|
||||
}
|
||||
|
||||
var application = await _applications.FindByClientIdAsync(request.ClientId, token);
|
||||
ApplicationName = application == default ? "an application" : await _applications.GetDisplayNameAsync(application, token) ?? "an application";
|
||||
RequestedScopes = MastodonScopes.Parse(request.Scope);
|
||||
var avatarIds = (await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList();
|
||||
var avatars = new List<LocalActor>();
|
||||
foreach (var id in avatarIds)
|
||||
if (await _localActors.FindById(LocalActorKind.Person, id, token) is { } avatar)
|
||||
avatars.Add(avatar);
|
||||
Avatars = avatars;
|
||||
Parameters = request.GetParameters()
|
||||
.Where(p => p.Key is not ("avatarId" or "decision" or "signed_in"))
|
||||
.Select(p => new KeyValuePair<string, string>(p.Key, (string)p.Value))
|
||||
.ToList();
|
||||
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'";
|
||||
Response.Headers["Cache-Control"] = "no-store";
|
||||
return Page();
|
||||
}
|
||||
|
||||
async Task<string> SignedInRoot()
|
||||
{
|
||||
var login = await HttpContext.AuthenticateAsync(OAuthSetup.LoginScheme);
|
||||
var rootId = login.Succeeded ? login.Principal.FindFirstValue(ClaimTypes.NameIdentifier) : default;
|
||||
if (rootId == default)
|
||||
return default;
|
||||
var root = await _dbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync();
|
||||
return root is { IsBanned: false, DeletedAt: null } ? rootId : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Reference in new issue
Block a user