net10, the refactor finished, and federation that works
Build / Build (push) Successful in 28s
Deploy / privapub.thepra.dev (push) Successful in 19s

The tree had not compiled since its first commit: Group, DmGroup and
IGroupUsersService were referenced and never written, an IDE rename had
turned the user-settings DTO into the ViewAvatarServer enum, and the settings
were saved as an entity they no longer were.

Built now:
- Group (an ActivityPub Group actor with its own keys, members, invitation
  code and optional password) and DmGroup (a conversation), with
  /clientapi/group/{list,insert,update,join,leave,approve}.
- Posts and DMs: /clientapi/post/{list,insert,delete}, /clientapi/dm/{list,insert};
  DM recipients are local usernames or user@host handles resolved by WebFinger.
- Invitation sign-up and login against the group's invitation code, checking
  the password before any account is created.
- Federation: WebFinger, NodeInfo 2.0, actors at /peasants/{name} (Person,
  Group, and an Application instance actor) with SPKI keys, draft-cavage
  RSA-SHA256 HTTP signatures both ways, an inbox handling Follow (+Accept),
  Undo, Create, Delete and Update, an outbox, notes at /posts/{id}, and a
  persisted, retried, signed delivery queue. A post to a group is announced
  by the group to its followers (FEP-1b12).
- The unused, broken typed ActivityPub models are replaced by a renderer;
  NSign's HMAC setup, which could not federate, is gone.

Upgrade: net10.0, MongoDB.Entities 25.1 (instance DB API, Standard GUIDs),
Swashbuckle 10 / OpenApi 2, Serilog.AspNetCore 10, MailKit 4.18,
PasswordGenerator 3. The JWT keys are 64 bytes (IdentityModel 8 refuses
shorter for HS512). Production runs its own mongod on 127.0.0.1:27022, as
Sintopia's apps do, and deploys to privapub.thepra.dev from the build runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 08:05:56 +02:00
1 parent 56c5396106
commit 075c22228a
78 files changed
+3419 -1091

No files matched your search

@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Group;
using PrivaPub.Extensions;
using PrivaPub.Resources;
using PrivaPub.Services;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/group"),
Authorize(Policy = Policies.IsUser)]
public class GroupController : ControllerBase
{
readonly IGroupUsersService _groupUsersService;
readonly IStringLocalizer _localizer;
public GroupController(IGroupUsersService groupUsersService, IStringLocalizer<GenericRes> localizer)
{
_groupUsersService = groupUsersService;
_localizer = localizer;
}
[HttpGet, Route("/clientapi/group/list")]
public async Task<IActionResult> List([FromQuery] string avatarId, CancellationToken token) =>
Answer(await _groupUsersService.GetGroups(User.GetUserId(), avatarId, token));
[HttpPost, Route("/clientapi/group/insert")]
public async Task<IActionResult> Insert(InsertGroupForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.InsertGroup(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/group/update")]
public async Task<IActionResult> Update(UpdateGroupForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.UpdateGroup(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/group/join")]
public async Task<IActionResult> Join(JoinGroupForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.JoinGroup(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/group/leave")]
public async Task<IActionResult> Leave(GroupMembershipForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.LeaveGroup(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/group/approve")]
public async Task<IActionResult> Approve(GroupMembershipForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.ApproveMember(User.GetUserId(), form, token));
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
using PrivaPub.Extensions;
using PrivaPub.Resources;
using PrivaPub.Services;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/post"),
Authorize(Policy = Policies.IsUser)]
public class PostController : ControllerBase
{
readonly IPostsService _postsService;
readonly IStringLocalizer _localizer;
public PostController(IPostsService postsService, IStringLocalizer<GenericRes> localizer)
{
_postsService = postsService;
_localizer = localizer;
}
[HttpGet, Route("/clientapi/post/list")]
public async Task<IActionResult> List([FromQuery] string avatarId, [FromQuery] string groupId, CancellationToken token) =>
Answer(await _postsService.GetPosts(User.GetUserId(), avatarId, groupId, token));
[HttpPost, Route("/clientapi/post/insert")]
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/post/delete")]
public async Task<IActionResult> Delete(DeletePostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token));
[HttpGet, Route("/clientapi/dm/list")]
public async Task<IActionResult> Dms([FromQuery] string avatarId, [FromQuery] string dmGroupId, CancellationToken token) =>
Answer(await _postsService.GetDms(User.GetUserId(), avatarId, dmGroupId, token));
[HttpPost, Route("/clientapi/dm/insert")]
public async Task<IActionResult> InsertDm(InsertDmForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertDm(User.GetUserId(), form, token));
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
}
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using PrivaPub.ClientModels;
@@ -27,6 +27,13 @@ namespace PrivaPub.Controllers.ClientToServer
_logger = logger;
}
[HttpGet, Route("/clientapi/avatar/private/list")]
public async Task<IActionResult> GetAvatars(CancellationToken token)
{
var result = await _privateAvatarUsersService.GetRootAvatars(User.GetUserId(), token);
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
[HttpPost, Route("/clientapi/avatar/private/insert")]
public async Task<IActionResult> InsertAvatar(InsertAvatarForm model)
{
@@ -1,15 +1,20 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Resources;
using PrivaPub.ClientModels.Group;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Extensions;
using PrivaPub.Models;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.Services;
using PrivaPub.Services.ClientToServer.Private;
using PrivaPub.StaticServices;
using System.ComponentModel.DataAnnotations;
@@ -21,17 +26,26 @@ namespace PrivaPub.Controllers.ClientToServer
public class RootUserController : ControllerBase
{
readonly IRootUsersService UsersService;
readonly IGroupUsersService GroupUsersService;
readonly IPrivateAvatarUsersService AvatarUsersService;
readonly AuthTokenManager AuthTokenManager;
readonly IOptionsMonitor<AppConfiguration> AppConfiguration;
readonly ILogger<RootUserController> Logger;
readonly IStringLocalizer Localizer;
public RootUserController(IRootUsersService usersService,
IGroupUsersService groupUsersService,
IPrivateAvatarUsersService avatarUsersService,
AuthTokenManager authTokenManager,
IOptionsMonitor<AppConfiguration> appConfiguration,
IStringLocalizer<GenericRes> localizer,
ILogger<RootUserController> logger)
{
UsersService = usersService;
GroupUsersService = groupUsersService;
AvatarUsersService = avatarUsersService;
AuthTokenManager = authTokenManager;
AppConfiguration = appConfiguration;
Localizer = localizer;
Logger = logger;
}
@@ -51,7 +65,7 @@ namespace PrivaPub.Controllers.ClientToServer
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
(var user, var userSettings) = ((RootUser, ViewAvatarServer))result.Data;
(var user, var userSettings) = ((RootUser, ViewUserSettings))result.Data;
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
Logger.LogInformation(
$"{nameof(SignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
@@ -78,7 +92,7 @@ namespace PrivaPub.Controllers.ClientToServer
return StatusCode(result.StatusCode, result);
var (user, userSettings) =
((RootUser, ViewAvatarServer))result.Data;
((RootUser, ViewUserSettings))result.Data;
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
Logger.LogInformation(
$"{nameof(Login)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
@@ -92,99 +106,105 @@ namespace PrivaPub.Controllers.ClientToServer
}
}
//[HttpPost, Authorize(Policy = Policies.IsUser), AllowAnonymous]
//public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm)
//{
// if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions");
// var result = new WebResult();
// if (!ModelState.IsValid)
// return BadRequest(result.Invalidate(Localizer["Invalid model."]));
// try
// {
// result = await DiscussionsService.GetInvitationConfiguration(signUpForm.InvitationCode, includePassword: true);
[HttpPost, Route("/clientapi/user/invitation/signup"), AllowAnonymous]
public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token)
{
var result = new WebResult();
if (!ModelState.IsValid)
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
try
{
var invitation = await GroupUsersService.GetInvitation(signUpForm.InvitationCode, signUpForm.InvitationPassword, token);
if (!invitation.IsValid)
return StatusCode(invitation.StatusCode, invitation);
// if (!result.IsValid)
// return StatusCode(result.StatusCode, result);
result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode);
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
// var configuration = result.Data as InvitationConfiguration;
// if (configuration.IsPasswordRequired && signUpForm.InvitationPassword != configuration.Password)
// {
// result.Invalidate(Localizer["Invalid password."], StatusCodes.Status406NotAcceptable);
// return StatusCode(result.StatusCode, result);
// }
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
{
RootId = user.ID,
Name = user.UserName,
UserName = user.UserName,
Biography = string.Empty
});
if (!avatar.IsValid)
return StatusCode(avatar.StatusCode, avatar);
// result = await UsersService.SignUpAsync(new LoginForm
// {
// Username = signUpForm.Username,
// Password = signUpForm.Password,
// LightThemeIndexColour = signUpForm.ThemeIndexColour,
// ThemeIsDarkMode = signUpForm.ThemeIsDarkMode,
// InvitationPassword = signUpForm.InvitationPassword
// }, signUpForm.InvitationCode, configuration.IsPasswordRequired);
// if (!result.IsValid)
// return StatusCode(result.StatusCode, result);
var joined = await GroupUsersService.JoinGroup(user.ID, new JoinGroupForm
{
AvatarId = ((ViewAvatar)avatar.Data).Id,
InvitationCode = signUpForm.InvitationCode,
InvitationPassword = signUpForm.InvitationPassword
}, token);
if (!joined.IsValid)
return StatusCode(joined.StatusCode, joined);
// (var user, var userSettings) = ((User, ViewUserSettings))result.Data;
// var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
// Logger.LogInformation(
// $"{nameof(InvitationSignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
Logger.LogInformation(
$"{nameof(InvitationSignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()");
return BadRequest(result.Invalidate(ex.Message));
}
}
// return Ok(jwtUser);
// }
// catch (Exception ex)
// {
// Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()");
// return BadRequest(result.Invalidate(ex.Message));
// }
//}
[HttpPost, Route("/clientapi/user/invitation/login"), AllowAnonymous]
public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token)
{
var result = new WebResult();
if (!ModelState.IsValid)
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
try
{
var invitation = await GroupUsersService.GetInvitation(loginForm.InvitationCode, loginForm.InvitationPassword, token);
if (!invitation.IsValid)
return StatusCode(invitation.StatusCode, invitation);
//[HttpPost, Authorize(Policy = Policies.IsUser), AllowAnonymous]
//public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm)
//{
// if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions" + loginForm.InvitationCode);
// var result = new WebResult();
// if (!ModelState.IsValid)
// return BadRequest(result.Invalidate(Localizer["Invalid model."]));
// try
// {
// result = await DiscussionsService.GetInvitationConfiguration(loginForm.InvitationCode, includePassword: true);
result = await UsersService.LoginAsync(loginForm, loginForm.InvitationCode);
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
// if (!result.IsValid)
// return StatusCode(result.StatusCode, result);
var avatars = await AvatarUsersService.GetRootAvatars(user.ID, token);
var avatar = (avatars.Data as List<ViewAvatar>)?.FirstOrDefault();
if (avatar == default)
{
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
{
RootId = user.ID,
Name = user.UserName,
UserName = user.UserName,
Biography = string.Empty
});
if (!inserted.IsValid)
return StatusCode(inserted.StatusCode, inserted);
avatar = (ViewAvatar)inserted.Data;
}
// var configuration = result.Data as InvitationConfiguration;
// if (configuration.IsPasswordRequired && loginForm.InvitationPassword != configuration.Password)
// {
// result.Invalidate(Localizer["Invalid password."], StatusCodes.Status406NotAcceptable);
// return StatusCode(result.StatusCode, result);
// }
var joined = await GroupUsersService.JoinGroup(user.ID, new JoinGroupForm
{
AvatarId = avatar.Id,
InvitationCode = loginForm.InvitationCode,
InvitationPassword = loginForm.InvitationPassword
}, token);
if (!joined.IsValid)
return StatusCode(joined.StatusCode, joined);
// result = await UsersService.LoginAsync(new LoginForm
// {
// Username = loginForm.Username,
// Password = loginForm.Password,
// ThemeIsDarkMode = loginForm.ThemeIsDarkMode,
// LightThemeIndexColour = loginForm.ThemeIndexColour,
// InvitationPassword = loginForm.InvitationPassword
// }, loginForm.InvitationCode);
// if (!result.IsValid)
// return StatusCode(result.StatusCode, result);
// var (user, userSettings/*, userCurrentTier*/) = ((User, ViewUserSettings/*, UserCurrentTier*/))result.Data;
// var jwtUser = AuthTokenManager.GenerateToken(user, userSettings, 0
// /*userCurrentTier == null ? default : (userCurrentTier.EndPeriod - DateTime.UtcNow).Days*/);
// Logger.LogInformation(
// $"{nameof(InvitationLogin)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
// return Ok(jwtUser);
// }
// catch (Exception ex)
// {
// Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()");
// return BadRequest(result.Invalidate(ex.Message));
// }
//}
Logger.LogInformation(
$"{nameof(InvitationLogin)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()");
return BadRequest(result.Invalidate(ex.Message));
}
}
[HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)]
public IActionResult Logout()
@@ -223,7 +243,7 @@ namespace PrivaPub.Controllers.ClientToServer
}
[HttpPost, Route("/clientapi/user/update/settings"), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> UpdateUserSettings(ViewAvatarServer userSettings)
public async Task<IActionResult> UpdateUserSettings(ViewUserSettings userSettings)
{
var result = new WebResult();
if (!ModelState.IsValid)
@@ -309,7 +329,9 @@ namespace PrivaPub.Controllers.ClientToServer
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
try
{
var host = $"{Request.Scheme}://{Request.Host.Host}";
var host = string.IsNullOrEmpty(AppConfiguration.CurrentValue.FrontendBaseAddress)
? $"{Request.Scheme}://{Request.Host.Host}"
: AppConfiguration.CurrentValue.FrontendBaseAddress.TrimEnd('/');
result = await UsersService.SetupAndSendRecoveryEmail(passwordRecoveryForm, host);
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
@@ -1,110 +1,154 @@
using Markdig;
using Microsoft.AspNetCore.Mvc;
using PrivaPub.ClientModels.Resources;
using PrivaPub.Extensions;
using PrivaPub.Models.ActivityPub;
using PrivaPub.Services;
using MongoDB.Entities;
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using PrivaPub.Models.Federation;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Controllers.ServerToServer
{
[ApiController,
Route("peasants"), Produces("application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"; charset=utf-8")]
[ApiController, Route("peasants")]
public class PeasantsController : ControllerBase
{
readonly IGroupUsersService _groupUsersService;
const string ActivityContentType = "application/activity+json; charset=utf-8";
const int OutboxSize = 20;
public PeasantsController(IGroupUsersService groupUsersService)
readonly ILocalActorService _localActors;
readonly IInboxService _inbox;
readonly DbEntities _dbEntities;
readonly ILogger<PeasantsController> _logger;
public PeasantsController(ILocalActorService localActors, IInboxService inbox, DbEntities dbEntities,
ILogger<PeasantsController> logger)
{
_groupUsersService = groupUsersService;
_localActors = localActors;
_inbox = inbox;
_dbEntities = dbEntities;
_logger = logger;
}
[HttpGet, Route("{actor}")]
public async Task<IActionResult> GetActor(
[Required(ErrorMessageResourceName = "Required",
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor,
CancellationToken cancellation)
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
{
var getResult = await _groupUsersService.GetGroup(actor, cancellation);
if (!getResult.IsValid)
return StatusCode(getResult.StatusCode, getResult);
var actorResponse = new ActivityPubActor();
actorResponse.Context[1] = string.Format(actorResponse.Context[1].ToString(), HttpContext.Request.PathBase);
actorResponse.Id = HttpContext.GetHostWithPath();
actorResponse.ProfileURL = HttpContext.GetHostWithPath();
actorResponse.Inbox = $"{HttpContext.GetHostWithPath()}/mouth";
actorResponse.Outbox = $"{HttpContext.GetHostWithPath()}/anus";
actorResponse.Endpoints = new()
{
SharedInboxURL = $"{HttpContext.GetHostWithPath()}/human-centipede",
OAuthAuthorizationEndpoint = $"{HttpContext.GetHost()}/sniff/again",
};
actorResponse.PreferredUsername = actor;
actorResponse.Name = actor;
if (getResult.Data is DmGroup dmGroup)
{
actorResponse.Summary = Markdown.ToHtml(dmGroup.Description);
actorResponse.Published = dmGroup.CreationDate;
}
else if (getResult.Data is Group dbGroup)
{
actorResponse.Summary = Markdown.ToHtml(dbGroup.Description);
actorResponse.Published = dbGroup.CreationDate;
}
return Ok(actorResponse);
var local = await _localActors.FindByUserName(actor, token);
return local == default ? NotFound() : Activity(ActivityPubRenderer.Actor(local));
}
[HttpGet, Route("{actor}/anus")]
public async Task<IActionResult> Anus(
[FromQuery] bool? page,
[Required(ErrorMessageResourceName = "Required",
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor)
public async Task<IActionResult> Outbox(string actor, CancellationToken token)
{
if (!page.HasValue)
return Ok(new ActivityPubOrderedCollection
var local = await _localActors.FindByUserName(actor, token);
if (local == default)
return NotFound();
var total = local.Kind == LocalActorKind.Group
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id, token)
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy, token);
var latest = await (local.Kind == LocalActorKind.Group
? _dbEntities.Posts.Match(p => p.GroupId == local.Id)
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy))
.Sort(p => p.CreationDate, Order.Descending)
.Limit(OutboxSize)
.ExecuteAsync(token);
var items = new List<JsonNode>();
foreach (var post in latest)
{
if (local.Kind == LocalActorKind.Group)
{
Id = HttpContext.Request.Path,
FirstItem = $"{HttpContext.GetHostWithPath()}?page=true",
});
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
continue;
}
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = ActivityPubRenderer.Note(post, local, group, default);
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}"));
}
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, items));
}
return Ok();
[HttpGet, Route("{actor}/followers")]
public async Task<IActionResult> Followers(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local == default)
return NotFound();
var count = await DB.Default.CountAsync<Follower>(
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
}
[HttpGet, Route("{actor}/following")]
public async Task<IActionResult> Following(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
return local == default ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>()));
}
[HttpGet, Route("{actor}/posts/{postId}")]
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local == default || local.Kind != LocalActorKind.Person)
return NotFound();
var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy)
.ExecuteFirstAsync(token);
if (post == default)
return NotFound();
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var replyTo = await ReplyTarget(post.AnsweringToPostId, token);
var note = ActivityPubRenderer.Note(post, local, group, replyTo);
note["@context"] = ActivityPubRenderer.ActivityStreams;
return Activity(note);
}
[HttpPost, Route("{actor}/mouth")]
public async Task<IActionResult> Month(
[Required(ErrorMessageResourceName = "Required",
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor,
[FromBody] JsonDocument json)
public async Task<IActionResult> Inbox(string actor, CancellationToken token)
{
return Ok();
var local = await _localActors.FindByUserName(actor, token);
if (local == default)
return NotFound();
return Answer(await _inbox.Receive(Request, local, token));
}
[HttpPost, Route("{actor}/human-centipede")]
public async Task<IActionResult> HumanCentipede(
[Required(ErrorMessageResourceName = "Required",
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor,
[FromBody] JsonDocument json)
{
return Ok();
}
public async Task<IActionResult> ActorSharedInbox(string actor, CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token));
[HttpPost, Route("/human-centipede")]
public async Task<IActionResult> PublicHumanCentipede(
[FromBody] JsonDocument json)
{
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token));
return Ok();
async Task<string> ReplyTarget(string answeringToPostId, CancellationToken token)
{
if (string.IsNullOrEmpty(answeringToPostId))
return default;
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return answeringToPostId;
return (await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token))?.ObjectURI;
}
IActionResult Answer(InboxResult result)
{
if (result.Error != default)
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
}
ContentResult Activity(JsonObject document) => new()
{
Content = document.ToJsonString(),
ContentType = ActivityContentType,
StatusCode = StatusCodes.Status200OK
};
}
}
@@ -1,69 +1,12 @@
using Markdig;
using Microsoft.AspNetCore.Mvc;
using PrivaPub.ClientModels.Resources;
using PrivaPub.Extensions;
using PrivaPub.Models.ActivityPub;
using PrivaPub.Models.Group;
using PrivaPub.Services;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.Controllers.ServerToServer
{
[ApiController,
Route("users"), Produces("application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"; charset=utf-8")]
[ApiController, Route("users")]
public class UsersController : ControllerBase
{
readonly IRootUsersService _rootUsersService;
public UsersController(IRootUsersService rootUsersService)
{
_rootUsersService = rootUsersService;
}
[HttpGet, Route("{actor}")]
public async Task<IActionResult> GetActor(
[Required(ErrorMessageResourceName = "Required",
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor,
CancellationToken cancellation)
{
var getResult = await _groupUsersService.GetGroup(actor, cancellation);
if (!getResult.IsValid)
return StatusCode(getResult.StatusCode, getResult);
var actorResponse = new ActivityPubActor();
actorResponse.Context[1] = string.Format(actorResponse.Context[1].ToString(), HttpContext.Request.PathBase);
actorResponse.Id = $"{HttpContext.GetHost()}/peasants/{actor}";
actorResponse.ProfileURL = $"{HttpContext.GetHost()}/peasants/{actor}";
actorResponse.Inbox = $"{HttpContext.GetHost()}/peasants/{actor}/mouth";
actorResponse.Outbox = $"{HttpContext.GetHost()}/peasants/{actor}/anus";
actorResponse.Endpoints = new()
{
SharedInboxURL = $"{HttpContext.GetHost()}/peasants/{actor}/human-centipede",
OAuthAuthorizationEndpoint = $"{HttpContext.GetHost()}/sniff/again",
};
actorResponse.PreferredUsername = actor;
actorResponse.Name = actor;
if (getResult.Data is DmGroup dmGroup)
{
actorResponse.Summary = Markdown.ToHtml(dmGroup.Description);
actorResponse.Published = dmGroup.CreationDate;
}
else if (getResult.Data is Group dbGroup)
{
actorResponse.Summary = Markdown.ToHtml(dbGroup.Description);
actorResponse.Published = dbGroup.CreationDate;
}
return Ok(actorResponse);
}
#region Admin
#endregion
public IActionResult GetActor(string actor) =>
RedirectPermanent($"/peasants/{Uri.EscapeDataString(actor)}");
}
}
@@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Controllers.ServerToServer
{
[ApiController]
public class WellKnownController : ControllerBase
{
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities)
{
_localActors = localActors;
_dbEntities = dbEntities;
}
[HttpGet, Route("/.well-known/webfinger")]
public async Task<IActionResult> WebFinger([FromQuery] string resource, CancellationToken token)
{
if (string.IsNullOrEmpty(resource))
return BadRequest();
LocalActor actor;
if (resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase))
{
var parts = resource[5..].TrimStart('@').Split('@');
var domain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
return NotFound();
actor = await _localActors.FindByUserName(parts[0], token);
}
else
actor = await _localActors.FindByUri(resource, token);
if (actor == default)
return NotFound();
var document = new JsonObject
{
["subject"] = $"acct:{actor.Handle}",
["aliases"] = new JsonArray(actor.Uri),
["links"] = new JsonArray(
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri },
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.Uri })
};
return Content(document.ToJsonString(), "application/jrd+json; charset=utf-8");
}
[HttpGet, Route("/.well-known/nodeinfo")]
public IActionResult NodeInfoLinks()
{
var document = new JsonObject
{
["links"] = new JsonArray(new JsonObject
{
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0",
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0"
})
};
return Content(document.ToJsonString(), "application/json; charset=utf-8");
}
[HttpGet, Route("/nodeinfo/2.0")]
public async Task<IActionResult> NodeInfo(CancellationToken token)
{
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
var posts = await DB.Default.CountAsync<PostEntity>(p => !p.IsFederatedCopy, token);
var document = new JsonObject
{
["version"] = "2.0",
["software"] = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref },
["protocols"] = new JsonArray("activitypub"),
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
["openRegistrations"] = true,
["usage"] = new JsonObject
{
["users"] = new JsonObject { ["total"] = users },
["localPosts"] = posts
},
["metadata"] = new JsonObject()
};
return Content(document.ToJsonString(), "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/2.0#\"; charset=utf-8");
}
}
}
+2 -2
View File
@@ -90,7 +90,7 @@ namespace PrivaPub.Data
International2Code = languageRow.International2Code
});
await DB.SaveAsync(languages);
await DB.Default.SaveAsync(languages);
}
static async Task UpdateNewLanguages(DbEntities dbClient)
@@ -154,7 +154,7 @@ namespace PrivaPub.Data
{
var bgLang = await dbClient.Languages.Match(l => l.International2Code == languageToUpdate.Key).ExecuteFirstAsync();
bgLang.NativeName = languageToUpdate.Value;
await bgLang.SaveAsync();
await DB.Default.SaveAsync(bgLang);
}
}
+3 -48
View File
@@ -1,16 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Prng;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.X509;
using PrivaPub.ClientModels;
using System.Security.Cryptography.X509Certificates;
using Org.BouncyCastle.Math;
using PrivaPub.Models.User;
using System.Security.Claims;
@@ -18,15 +10,6 @@ namespace PrivaPub.Extensions
{
public static class Extensions
{
public static string GetLogsConnectionString(this IConfiguration configuration) =>
configuration.GetSection("Serilog")
?.GetSection("WriteTo")
?.GetChildren()
?.First()
?.GetSection("Args")
?.GetSection("databaseUrl")
?.Value;
public static AuthorizationPolicy IsAdminPolicy() =>
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
.RequireClaim(Policies.IsAdmin, true.ToString().ToLower())
@@ -51,42 +34,14 @@ namespace PrivaPub.Extensions
public static string GetHost(this HttpContext httpContext) =>
$"https://{httpContext.Request.Host}";
public static X509Certificate2 GetX509Certificate2(string certName)
{
var keypairgen = new RsaKeyPairGenerator();
keypairgen.Init(new KeyGenerationParameters(new SecureRandom(new CryptoApiRandomGenerator()), 512));
var keypair = keypairgen.GenerateKeyPair();
var gen = new X509V3CertificateGenerator();
var CN = new X509Name("CN=" + certName);
var SN = BigInteger.ProbablePrime(120, new Random());
gen.SetSerialNumber(SN);
gen.SetSubjectDN(CN);
gen.SetIssuerDN(CN);
gen.SetNotAfter(DateTime.MaxValue);
gen.SetNotBefore(DateTime.Now.Subtract(new TimeSpan(7, 0, 0, 0)));
gen.SetSignatureAlgorithm("MD5WithRSA");
gen.SetPublicKey(keypair.Public);
var newCert = gen.Generate(keypair.Private);
return new X509Certificate2(DotNetUtilities.ToX509Certificate((Org.BouncyCastle.X509.X509Certificate)newCert));
}
public static UserPolicyType GetHighestPolicy(this ClaimsPrincipal claimsPrincipal)
{
if (bool.Parse(claimsPrincipal.FindFirstValue(Policies.IsAdmin)))
if (claimsPrincipal.FindFirstValue(Policies.IsAdmin) == "true")
return UserPolicyType.IsAdmin;
if (bool.Parse(claimsPrincipal.FindFirstValue(Policies.IsModerator)))
if (claimsPrincipal.FindFirstValue(Policies.IsModerator) == "true")
return UserPolicyType.IsModerator;
if (bool.Parse(claimsPrincipal.FindFirstValue(Policies.IsUser)))
return UserPolicyType.IsUser;
return UserPolicyType.IsUser;
}
}
+23 -88
View File
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.ResponseCompression;
using PrivaPub.ClientModels;
@@ -8,13 +8,10 @@ using PrivaPub.Services;
using PrivaPub.StaticServices;
using System.Text.Json.Serialization;
using NSign.Providers;
using NSign;
using NSign.Signatures;
using NSign.Client;
using System.Text;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi;
using PrivaPub.Services.ClientToServer.Private;
using PrivaPub.Services.ClientToServer.Public;
using PrivaPub.Services.Federation;
namespace PrivaPub.Middleware
{
@@ -33,74 +30,19 @@ namespace PrivaPub.Middleware
//.AddHostedService<GroupsCleanerWorker>()
//.AddHostedService<PoliciesCleanerWorker>();
}
public static IServiceCollection PrivaPubHTTPSignature(this IServiceCollection service, IConfiguration configuration)
public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service)
{
//HTTP CLIENT
service.Configure<AddDigestOptions>(options => options.WithHash(AddDigestOptions.Hash.Sha256))
.ConfigureMessageSigningOptions(options =>
{
options.SignatureName = "PrivaPub";
options
.WithMandatoryComponent(SignatureComponent.Path)
.WithMandatoryComponent(SignatureComponent.RequestTarget)
.SetParameters = signatureParams => signatureParams.WithKeyId("keyId");
})
.Services.Configure<SignatureVerificationOptions>(options =>
{
})
.AddHttpClient<ActivityPubClient>(nameof(ActivityPubClient))
.ConfigureHttpClient(httpClient =>
{
httpClient.DefaultRequestHeaders.Accept.Add(new("application/ld+json"));
})
.AddDigestAndSigningHandlers()
//.AddSignatureVerifiationHandler()
.Services
.AddSingleton<ISigner>(new HmacSha256SignatureProvider(Encoding.UTF8.GetBytes(configuration["AppConfiguration:Jwt:Key"])));
//MESSAGE RESPONSE
return service;
//.Configure<RequestSignatureVerificationOptions>(options =>
//{
// options.SignaturesToVerify.Add("sample");
// options.RequiredSignatureComponents.Add(SignatureComponent.Path);
// options.RequiredSignatureComponents.Add(SignatureComponent.Method);
// options.RequiredSignatureComponents.Add(SignatureComponent.Digest);
//})
//.AddSignatureVerification(serviceProvider =>
//{
// var memoryCache = serviceProvider.GetRequiredService<IMemoryCache>();
// //var httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
// //httpContextAccessor.HttpContext.Request.
// var cert = memoryCache.GetOrCreate("PrivaPub", (cacheEntry) => Extensions.Extensions.GetX509Certificate2("PrivaPubCert"));
// return new RsaPkcs15Sha256SignatureProvider(cert, "anon");
//})
//.ConfigureMessageSigningOptions(options =>
//{
// options.WithMandatoryComponent(SignatureComponent.Path)
// .WithMandatoryComponent(SignatureComponent.Method)
// .WithMandatoryComponent(SignatureComponent.Digest)
// .WithOptionalComponent(new HttpHeaderDictionaryStructuredComponent(NSign.Constants.Headers.Signature, "sample", bindRequest: true));
// options.SignatureName = "resp";
// options.SetParameters = (sigParams) =>
// {
// sigParams.WithCreatedNow();
// };
//})
//.ValidateOnStart()
//.Services
//.AddHttpClient("ActivityPub", (serviceProvider, client) =>
//{
// client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("NSignSample", "0.1-beta"));
//}).Services;
//.AddSingleton<ISigner>(new RsaPssSha512SignatureProvider(
// new X509Certificate2(@"path\to\certificate.pfx", "PasswordForPfx"),
// "my-cert"));
service.AddHttpClient(RemoteActorService.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(20);
client.DefaultRequestHeaders.UserAgent.ParseAdd($"PrivaPub/{BuildInfo.Ref}");
});
return service
.AddSingleton<ILocalActorService, LocalActorService>()
.AddSingleton<IRemoteActorService, RemoteActorService>()
.AddSingleton<IDeliveryService, DeliveryService>()
.AddTransient<IInboxService, InboxService>()
.AddHostedService<DeliveryWorker>();
}
public static IServiceCollection PrivaPubAuthServicesConfiguration(this IServiceCollection service, IConfiguration configuration)
{
@@ -149,6 +91,9 @@ namespace PrivaPub.Middleware
.AddTransient<IDataService, DataService>()
.AddTransient<IRootUsersService, RootUsersService>()
.AddTransient<IPublicAvatarUsersService, PublicAvatarUsersService>()
.AddTransient<IPrivateAvatarUsersService, PrivateAvatarUsersService>()
.AddTransient<IGroupUsersService, GroupUsersService>()
.AddTransient<IPostsService, PostsService>()
.AddSingleton<AppConfigurationService>()
.AddHttpContextAccessor()
.AddMemoryCache()
@@ -161,28 +106,18 @@ namespace PrivaPub.Middleware
.AddEndpointsApiExplorer()
.AddSwaggerGen(c =>
{
c.AddSecurityDefinition("Bearer", new()
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Description = "Please enter a valid token",
Name = "Authorization",
Type = SecuritySchemeType.Http,
BearerFormat = "JWT",
Scheme = "Bearer"
Scheme = "bearer"
});
c.AddSecurityRequirement(new()
c.AddSecurityRequirement(document => new OpenApiSecurityRequirement
{
{
new()
{
Reference = new()
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
new string[]{}
}
[new OpenApiSecuritySchemeReference("Bearer", document)] = []
});
})
.AddControllers(options => { options.Filters.Add<OperationCancelledExceptionFilter>(); })
@@ -1,44 +0,0 @@
using PrivaPub.Models.ActivityPub.Extra;
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub
{
[JsonSerializable(typeof(ActivityPubActivity))]
public class ActivityPubActivity : ActivityPubObject
{
[JsonPropertyName("actor")]
public ActivityPubActor Actor { get; set; }
[JsonPropertyName("object")]
public ActivityPubObject Object { get; set; }
[JsonPropertyName("target")]
public ActivityPubObject Target { get; set; }
[JsonPropertyName("result")]
public ActivityPubResult Result { get; set; }
[JsonPropertyName("origin")]
public ActivityPubOrigin Origin { get; set; }
[JsonPropertyName("instrument")]
public ActivityPubInstrument Instrument { get; set; }
[JsonIgnore]
public bool NeedsObjectForInbox => Type is
ObjectType.Create or
ObjectType.Update or
ObjectType.Delete or
ObjectType.Follow or
ObjectType.Add or
ObjectType.Remove or
ObjectType.Like or
ObjectType.Block or
ObjectType.Undo;
[JsonIgnore]
public bool NeedsTargetForInbox => Type is
ObjectType.Add or
ObjectType.Remove;
}
}
@@ -1,79 +0,0 @@
using PrivaPub.Models.ActivityPub.Extra;
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub
{
[JsonSerializable(typeof(ActivityPubActor))]
public class ActivityPubActor : ActivityPubObject
{
[JsonPropertyName("type")]
public new ObjectType? Type => ObjectType.Person;
[JsonPropertyName("inbox")]
public string Inbox { get; set; }
[JsonPropertyName("outbox")]
public string Outbox { get; set; }
[JsonPropertyName("url")]
public string ProfileURL { get; set; }
[JsonPropertyName("preferredUsername")]
public string PreferredUsername { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("icon")]
public ActivityPubIcon Icon { get; set; }
[JsonPropertyName("image")]
public ActivityPubIcon Thumbnail { get; set; }
[JsonPropertyName("manuallyApprovesFollowers")]
public bool ManuallyApprovesFollowers { get; set; } = false;
[JsonPropertyName("discoverable")]
public bool Discoverable { get; set; } = true;
[JsonPropertyName("publicKey")]
public ActivityPubPublicKey PublicKey { get; set; } = new();
[JsonPropertyName("endpoints")]
public ActivityPubActorEndpoints Endpoints { get; set; } = new();
[JsonPropertyName("attachment")]
public IEnumerable<ActivityPubAttachment> Attachment { get; set; } = Enumerable.Empty<ActivityPubAttachment>();
[JsonPropertyName("tag")]
public IEnumerable<string> Tags => Enumerable.Empty<string>();
}
public class ActivityPubAttachment
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("value")]
public string Value { get; set; }
}
public class ActivityPubActorEndpoints
{
[JsonPropertyName("sharedInbox")]
public string SharedInboxURL { get; set; }
[JsonPropertyName("oauthAuthorizationEndpoint")]
public string OAuthAuthorizationEndpoint { get; set; }
[JsonExtensionData]
public Dictionary<string, object> OtherData { get; set; }
}
public class ActivityPubPublicKey
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("owner")]
public string Owner { get; set; }
[JsonPropertyName("publicKeyPem")]
public string PublicKeyPem { get; set; }
}
}
@@ -1,24 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub
{
[JsonSerializable(typeof(ActivityPubCollection))]
public class ActivityPubCollection : ActivityPubObject
{
[JsonPropertyName("type")]
public new ObjectType Type => ObjectType.Collection;
[JsonPropertyName("current")]
public string RecentlyUpdatedItem { get; set; }
[JsonPropertyName("first")]
public string FirstItem { get; set; }
[JsonPropertyName("last")]
public string LastItem { get; set; }
[JsonPropertyName("items")]
public List<ActivityPubLink> Items { get; set; }
[JsonPropertyName("totalItems")]
public int TotalItems => Items.Count;
}
}
@@ -1,18 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub
{
[JsonSerializable(typeof(ActivityPubCollectionPage))]
public class ActivityPubCollectionPage : ActivityPubCollection
{
[JsonPropertyName("type")]
public new ObjectType Type => ObjectType.CollectionPage;
[JsonPropertyName("partOf")]
public string BaseCollectionLink { get; set; }
[JsonPropertyName("next")]
public string NextCollectionLink { get; set; }
[JsonPropertyName("prev")]
public string PreviousCollectionLink { get; set; }
}
}
@@ -1,28 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub
{
[JsonSerializable(typeof(ActivityPubLink))]
public class ActivityPubLink : ActivityPubObject
{
[JsonPropertyName("id")]
public new ObjectType Type { get; set; } = ObjectType.Mention;
[JsonPropertyName("href")]
public string Href { get; set; }
[JsonPropertyName("preview")]
public string Preview { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("rel")]
public string Relation { get; set; }
[JsonPropertyName("hreflang")]
public string HrefLang { get; set; }
[JsonPropertyName("height")]
public int Height { get; set; }
[JsonPropertyName("width")]
public int Width { get; set; }
}
}
@@ -1,126 +0,0 @@
using PrivaPub.Models.ActivityPub.Extra;
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub
{
[JsonSerializable(typeof(ActivityPubObject))]
public partial class ActivityPubObject
{
[JsonPropertyName("@context")]
public List<object> Context => new()
{
"https://www.w3.org/ns/activitystreams",
"https://{0}/schemas/litepub-0.1.jsonld",
new ActivityPubContextLanguage()
};
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("type")]
public ObjectType? Type { get; set; }
[JsonPropertyName("mediaType")]
public string MediaType { get; set; }
[JsonPropertyName("published")]
public DateTime? Published { get; set; }
[JsonPropertyName("updated")]
public DateTime? Updated { get; set; }
[JsonExtensionData]
public Dictionary<string, object> OtherData { get; set; }
[JsonPropertyName("source")]
public ActivityPubSource Source { get; set; }
//and part of link
[JsonPropertyName("to")]
public string[] To { get; set; }
[JsonPropertyName("cc")]
public string[] Cc { get; set; }
[JsonPropertyName("bcc")]
public string[] Bcc { get; set; }
[JsonPropertyName("bto")]
public string[] Bto { get; set; }
[JsonPropertyName("audience")]
public ActivityPubAudience Audience { get; set; }
[JsonIgnore]
public MacroType MacroType => Type switch
{
ObjectType.Article or
ObjectType.Audio or
ObjectType.Document or
ObjectType.Event or
ObjectType.Image or
ObjectType.Note or
ObjectType.Page or
ObjectType.Place or
ObjectType.Profile or
ObjectType.Relationship or
ObjectType.Tombstone or
ObjectType.Video => MacroType.Object,
ObjectType.Mention => MacroType.Link,
ObjectType.Application or
ObjectType.Group or
ObjectType.Organization or
ObjectType.Person or
ObjectType.Service => MacroType.Actor,
ObjectType.Accept or
ObjectType.Add or
ObjectType.Announce or
ObjectType.Arrive or
ObjectType.Block or
ObjectType.Create or
ObjectType.Delete or
ObjectType.Dislike or
ObjectType.Flag or
ObjectType.Follow or
ObjectType.Ignore or
ObjectType.Invite or
ObjectType.Join or
ObjectType.Leave or
ObjectType.Like or
ObjectType.Listen or
ObjectType.Move or
ObjectType.Offer or
ObjectType.Question or
ObjectType.Reject or
ObjectType.Read or
ObjectType.Remove or
ObjectType.TentativeReject or
ObjectType.TentativeAccept or
ObjectType.Travel or
ObjectType.Undo or
ObjectType.Update or
ObjectType.View => MacroType.Activity,
ObjectType.Collection => MacroType.Collection,
ObjectType.CollectionPage => MacroType.CollectionPage,
ObjectType.OrderedCollection => MacroType.OrderedCollection,
ObjectType.OrderedCollectionPage => MacroType.OrderedCollectionPage,
_ => MacroType.Unknown
};
}
public class ActivityPubContextLanguage
{
[JsonPropertyName("@language")]
public string Language { get; set; } = "und";
}
public class ActivityPubSource
{
[JsonPropertyName("content")]
public string Content { get; set; }
[JsonPropertyName("mediaType")]
public string MediaType { get; set; }
}
}
@@ -1,17 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub
{
[JsonSerializable(typeof(ActivityPubOrderedCollection))]
public class ActivityPubOrderedCollection : ActivityPubCollection
{
[JsonPropertyName("type")]
public new ObjectType Type => ObjectType.OrderedCollection;
[JsonPropertyName("orderedItems")]
public List<ActivityPubLink> OrderedItems { get; set; }
[JsonPropertyName("totalItems")]
public new int TotalItems => OrderedItems?.Count ?? default;
}
}
@@ -1,27 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub
{
[JsonSerializable(typeof(ActivityPubOrderedCollectionPage))]
public class ActivityPubOrderedCollectionPage : ActivityPubCollection
{
[JsonPropertyName("type")]
public new ObjectType Type => ObjectType.OrderedCollectionPage;
[JsonPropertyName("partOf")]
public string BaseCollectionLink { get; set; }
[JsonPropertyName("next")]
public string NextCollectionLink { get; set; }
[JsonPropertyName("prev")]
public string PreviousCollectionLink { get; set; }
[JsonPropertyName("startIndex")]
public uint? StartIndex { get; set; }
[JsonPropertyName("orderedItems")]
public List<ActivityPubLink> OrderedItems { get; set; }
[JsonPropertyName("totalItems")]
public new int TotalItems => OrderedItems?.Count ?? default;
}
}
@@ -1,14 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub
{
[JsonSerializable(typeof(ActivityPubTombstone))]
public class ActivityPubTombstone : ActivityPubObject
{
[JsonPropertyName("formerType")]
public ObjectType FormerType { get; set; } = ObjectType.Unknown;
[JsonPropertyName("deleted")]
public DateTime Deleted { get; set; } = DateTime.UtcNow;
}
}
@@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub.Extra
{
[JsonSerializable(typeof(ActivityPubPublicKey))]
public class ActivityPubAudience
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
@@ -1,19 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub.Extra
{
[JsonSerializable(typeof(ActivityPubPublicKey))]
public class ActivityPubIcon
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("url")]
public string URL { get; set; }
[JsonPropertyName("width")]
public int Width { get; set; }
[JsonPropertyName("height")]
public int Height { get; set; }
}
}
@@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub.Extra
{
[JsonSerializable(typeof(ActivityPubPublicKey))]
public class ActivityPubInstrument
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
@@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub.Extra
{
[JsonSerializable(typeof(ActivityPubPublicKey))]
public class ActivityPubOrigin
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
@@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
namespace PrivaPub.Models.ActivityPub.Extra
{
[JsonSerializable(typeof(ActivityPubPublicKey))]
public class ActivityPubResult
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
-17
View File
@@ -1,17 +0,0 @@
namespace PrivaPub.Models.ActivityPub
{
public enum MacroType
{
Object,
Link,
Actor,
Activity,
Collection,
OrderedCollection,
CollectionPage,
OrderedCollectionPage,
Unknown
}
}
-63
View File
@@ -1,63 +0,0 @@
namespace PrivaPub.Models.ActivityPub
{
public enum ObjectType
{
//Object types
Article,
Audio,
Document,
Event,
Image,
Note,
Page,
Place,
Profile,
Relationship,
Tombstone,
Video,
//Link types
Mention,
//Actor
Application,
Group,
Organization,
Person,
Service,
//Activity
Accept,
Add,
Announce,
Arrive,
Block,
Create,
Delete,
Dislike,
Flag,
Follow,
Ignore,
Invite,
Join,
Leave,
Like,
Listen,
Move,
Offer,
Question,
Reject,
Read,
Remove,
TentativeReject,
TentativeAccept,
Travel,
Undo,
Update,
View,
//Collections
Collection,
OrderedCollection,
CollectionPage,
OrderedCollectionPage,
//NULL or default
Unknown
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
using MongoDB.Entities;
using MongoDB.Entities;
using PrivaPub.Models.Email;
using PrivaPub.StaticServices;
@@ -14,6 +14,7 @@ namespace PrivaPub.Models
public string ValidDiscussionFilesTypes { get; set; }
public List<string> SupportedLanguages { get; set; } = new();
public string BackendBaseAddress { get; set; }
public string FrontendBaseAddress { get; set; }
public EmailConfiguration EmailConfiguration { get; set; }
public HashingOptions HashingOptions { get; set; }
public DateTime LastUpdateDate { get; set; } = DateTime.UtcNow;
+18
View File
@@ -0,0 +1,18 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class Delivery : Entity
{
public string SignerId { get; set; }
public LocalActorKind SignerKind { get; set; }
public string InboxURL { get; set; }
public string Body { get; set; }
public int Attempts { get; set; }
public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow;
public DateTime? DeliveredAt { get; set; }
public DateTime? AbandonedAt { get; set; }
public string LastError { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
}
}
+23
View File
@@ -0,0 +1,23 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class Follower : Entity
{
public string LocalActorId { get; set; }
public LocalActorKind LocalActorKind { get; set; }
public string ActorURI { get; set; }
public string InboxURL { get; set; }
public string SharedInboxURL { get; set; }
public string FollowActivityURI { get; set; }
public bool IsAccepted { get; set; } = true;
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
}
public enum LocalActorKind
{
Person,
Group,
Application
}
}
@@ -0,0 +1,11 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class InstanceActor : Entity
{
public string PrivateKey { get; set; }
public string PublicKey { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
}
}
+15
View File
@@ -0,0 +1,15 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Group
{
public class DmGroup : Entity
{
public string Description { get; set; }
public List<GroupMember> Members { get; set; } = new();
public string ConversationURI { get; set; }//context of the federated thread
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletionAt { get; set; }
}
}
+47
View File
@@ -0,0 +1,47 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Group
{
public class Group : Entity
{
public string UserName { get; set; }//preferredUsername
public string Name { get; set; }//name
public string Description { get; set; }//summary, Markdown
public string Url { get; set; }
public string Domain { get; set; }
public string InboxURL { get; set; }
public string OutboxURL { get; set; }
public string PictureURL { get; set; }//icon
public string ThumbnailURL { get; set; }//image
public string PrivateKey { get; set; }
public string PublicKey { get; set; }
public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; }
public string OwnerAvatarId { get; set; }
public List<GroupMember> Members { get; set; } = new();
public string InvitationCode { get; set; }
public string HashedInvitationPassword { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletionAt { get; set; }
}
public class GroupMember
{
public string AvatarId { get; set; }//local Avatar.ID, or the actor URI when IsForeign
public bool IsForeign { get; set; }
public GroupRole Role { get; set; } = GroupRole.Member;
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
}
public enum GroupRole
{
Member,
Moderator,
Owner
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
using MongoDB.Entities;
using MongoDB.Entities;
namespace PrivaPub.Models.Post
{
@@ -14,6 +14,8 @@ namespace PrivaPub.Models.Post
public bool HasContentWarning { get; set; } = false;
public bool IsFederatedCopy { get; set; }
public string ObjectURI { get; set; }//the Note's id
public string ActorURI { get; set; }//attributedTo
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime? UpdateDate { get; set; } = DateTime.UtcNow;
+3 -1
View File
@@ -1,4 +1,4 @@
using MongoDB.Entities;
using MongoDB.Entities;
namespace PrivaPub.Models.Post
{
@@ -15,6 +15,8 @@ namespace PrivaPub.Models.Post
public bool HasContentWarning { get; set; } = false;
public bool IsFederatedCopy { get; set; }
public string ObjectURI { get; set; }//the Note's id
public string ActorURI { get; set; }//attributedTo
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime? UpdateDate { get; set; } = DateTime.UtcNow;
+4 -1
View File
@@ -1,4 +1,4 @@
using MongoDB.Entities;
using MongoDB.Entities;
namespace PrivaPub.Models.User
{
@@ -55,6 +55,9 @@ namespace PrivaPub.Models.User
public string ModerationNote { get; set; }
public bool IsDiscoverable { get; set; } = true;
public string ActorURI { get; set; }//id
public string PublicKeyId { get; set; }
public string SharedInboxURL { get; set; }
public string InboxURL { get; set; }
public string OutboxURL { get; set; }
public string MovedToURL { get; set; }
-24
View File
@@ -1,24 +0,0 @@
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Entities;
namespace PrivaPub.Models.Data
{
[BsonIgnoreExtraElements]
public class PrivaPub : Entity
{
public DateTime Timestamp { get; set; }
public string Level { get; set; }
public string RenderedMessage { get; set; }
[BsonIgnoreIfDefault, BsonIgnoreIfNull]
public DbLogProperties Properties { get; set; }
[BsonIgnoreIfDefault, BsonIgnoreIfNull]
public string Exception { get; set; }
}
[BsonIgnoreExtraElements]
public class DbLogProperties
{
public string SourceContext { get; set; }
}
}
+28 -25
View File
@@ -1,36 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="4.0.0" />
<PackageReference Include="Markdig" Version="0.31.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" />
<PackageReference Include="MongoDB.Entities" Version="21.0.2" />
<PackageReference Include="NSign.Abstractions" Version="0.16.0" />
<PackageReference Include="NSign.AspNetCore" Version="0.16.0" />
<PackageReference Include="NSign.Client" Version="0.16.0" />
<PackageReference Include="NSign.SignatureProviders" Version="0.16.0" />
<PackageReference Include="PasswordGenerator" Version="2.1.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="5.0.1" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.MongoDB" Version="5.3.1" />
<PackageReference Include="SshKeyGenerator" Version="1.1.51" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Helpers\" />
<Folder Include="Services\ServerToServer\" />
<Folder Include="Workers\" />
<PackageReference Include="MailKit" Version="4.18.0" />
<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="PasswordGenerator" Version="3.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.MongoDB" Version="7.3.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<ItemGroup>
@@ -52,4 +36,23 @@
</EmbeddedResource>
</ItemGroup>
<Target Name="GenerateBuildInfo" BeforeTargets="BeforeCompile">
<PropertyGroup>
<BuildInfoPath>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoPath>
</PropertyGroup>
<ItemGroup>
<BuildInfoLine Include="public static class BuildInfo" />
<BuildInfoLine Include="{" />
<BuildInfoLine Include=" public const string Commit = &quot;$(BuildCommit)&quot;%3B" />
<BuildInfoLine Include=" public const string Ref = &quot;$(BuildRef)&quot;%3B" />
<BuildInfoLine Include=" public const string BuiltAt = &quot;$(BuildTimeUtc)&quot;%3B" />
<BuildInfoLine Include="}" />
</ItemGroup>
<WriteLinesToFile File="$(BuildInfoPath)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
<ItemGroup>
<Compile Include="$(BuildInfoPath)" />
<FileWrites Include="$(BuildInfoPath)" />
</ItemGroup>
</Target>
</Project>
+15 -10
View File
@@ -1,6 +1,9 @@
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
using MongoDB.Entities;
@@ -10,7 +13,6 @@ using PrivaPub.Data;
using PrivaPub.Extensions;
using PrivaPub.Middleware;
using PrivaPub.Models;
using PrivaPub.Models.Data;
using PrivaPub.Services;
using PrivaPub.StaticServices;
@@ -45,7 +47,7 @@ try
.PrivaPubOptimizationConfiguration()
.PrivaPubDataBaseConfiguration()
.PrivaPubServicesConfiguration()
.PrivaPubHTTPSignature(builder.Configuration)
.PrivaPubFederationConfiguration()
.PrivaPubCORSConfiguration()
.PrivaPubMiddlewareConfiguration();
}
@@ -57,11 +59,9 @@ try
try
{
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
var logsConnectionString = builder.Configuration.GetLogsConnectionString();
await DB.InitAsync(mongoSettings.LogsDatabase, MongoClientSettings.FromConnectionString(logsConnectionString));
DB.DatabaseFor<PrivaPub>(mongoSettings.LogsDatabase);
}
catch (Exception ex)
{
@@ -91,11 +91,9 @@ try
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
}
else if(app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.UseCors("DefaultCORS");
@@ -112,6 +110,12 @@ try
// context.Request.Path.StartsWithSegments("/users"),
// app => app.UseSignatureVerification().UseDigestVerification());
app.MapGet("/build.json", () => Results.Json(new
{
commit = BuildInfo.Commit,
buildRef = BuildInfo.Ref,
builtAt = BuildInfo.BuiltAt,
}));
app.MapControllers();
//app.MapFallbackToFile("index.html");
}
@@ -138,5 +142,6 @@ try
catch (Exception ex)
{
Log.ForContext<Program>().Fatal(ex, $"{nameof(Program)}.{nameof(Program)}()");
Environment.ExitCode = 1;
}
+1 -1
View File
@@ -29,7 +29,7 @@ namespace PrivaPub.Services
}
dbAppConfiguration = _appConfigurationFromFile;
await dbAppConfiguration.SaveAsync();
await DB.Default.SaveAsync(dbAppConfiguration);
AppConfiguration = dbAppConfiguration;
}
}
@@ -1,37 +1,37 @@
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Models;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices;
using System.Security.Cryptography;
namespace PrivaPub.Services.ClientToServer.Private
{
public interface IPrivateAvatarUsersService
{
Task<WebResult> UpdateAvatar(UpdateAvatarForm form);
Task<WebResult> InsertAvatar(InsertAvatarForm form);
Task<WebResult> GetRootAvatars(string rootUserId, CancellationToken token);
}
public class PrivateAvatarUsersService : IPrivateAvatarUsersService
{
readonly AppConfigurationService _appConfiguration;
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PrivateAvatarUsersService> _logger;
public PrivateAvatarUsersService(AppConfigurationService appConfiguration,
public PrivateAvatarUsersService(ILocalActorService localActors,
DbEntities dbEntities,
IStringLocalizer<GenericRes> localizer,
ILogger<PrivateAvatarUsersService> logger)
{
_appConfiguration = appConfiguration;
_localActors = localActors;
_dbEntities = dbEntities;
_localizer = localizer;
_logger = logger;
@@ -42,54 +42,46 @@ namespace PrivaPub.Services.ClientToServer.Private
var result = new WebResult();
try
{
if (await _dbEntities.Avatars.Match(a => a.UserName == form.UserName).ExecuteAnyAsync())
return result.Invalidate(_localizer["The username '{0}' is already take.", form.UserName]);
var userName = form.UserName?.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(userName) || !userName.All(c => char.IsAsciiLetterOrDigit(c) || c == '_'))
return result.Invalidate(_localizer["The username may contain only letters, digits and underscores."]);
if (await _localActors.IsUserNameTaken(userName, default))
return result.Invalidate(_localizer["The username '{0}' is already take.", userName]);
if (!await IsValidRootUser(form.RootId))
return result.Invalidate(_localizer["You can't do this action because of your account status."]);
var (privateKey, publicKey) = Keys.NewKeyPair();
var newAvatar = new Avatar
{
Name = form.Name,
UserName = form.UserName,
UserName = userName,
Biography = form.Biography,
Fields = form.Fields,
PersonalNote = form.PersonalNote,
PrivateKey = privateKey,
PublicKey = publicKey,
Domain = _localActors.BaseAddress
};
if (!form.Settings.IsDefault)
newAvatar.Settings = new()
{
IsDefault = false,
DarkThemeIndexColour = form.Settings.DarkThemeIndexColour,
IconsThemeIndexColour = form.Settings.IconsThemeIndexColour,
LanguageCode = form.Settings.LanguageCode,
LightThemeIndexColour = form.Settings.LightThemeIndexColour,
PreferSystemTheming = form.Settings.PreferSystemTheming,
ThemeIsDarkGray = form.Settings.ThemeIsDarkGray,
ThemeIsDarkMode = form.Settings.ThemeIsDarkMode,
ThemeIsLightGray = form.Settings.ThemeIsLightGray
};
if (form.Settings is { IsDefault: false })
newAvatar.Settings = ToSettings(form.Settings);
if (_appConfiguration.AppConfiguration == default)
await _appConfiguration.Init();
var actor = _localActors.FromAvatar(newAvatar);
newAvatar.Url = actor.Uri;
newAvatar.InboxURL = actor.Inbox;
newAvatar.OutboxURL = actor.Outbox;
newAvatar.Url = _appConfiguration.AppConfiguration.BackendBaseAddress + $"/peasants/{form.UserName}";
newAvatar.Domain = _appConfiguration.AppConfiguration.BackendBaseAddress;
newAvatar.InboxURL = _appConfiguration.AppConfiguration.BackendBaseAddress + $"/peasants/{form.UserName}/mouth";
newAvatar.OutboxURL = _appConfiguration.AppConfiguration.BackendBaseAddress + $"/peasants/{form.UserName}/anus";
var rsa = RSA.Create();
newAvatar.PrivateKey = rsa.ExportRSAPrivateKeyPem();
newAvatar.PublicKey = rsa.ExportRSAPublicKeyPem();
await newAvatar.SaveAsync();
result.Data = newAvatar;
await DB.Default.SaveAsync(newAvatar);
await DB.Default.SaveAsync(new RootToAvatar { RootId = form.RootId, AvatarId = newAvatar.ID });
result.Data = ToView(newAvatar);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(InsertAvatar)}");
return result.Invalidate(_localizer["Error: {0}", ex.ToString()], exception: ex);
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
@@ -98,18 +90,106 @@ namespace PrivaPub.Services.ClientToServer.Private
var result = new WebResult();
try
{
if (!await IsValidRootUser(form.RootId))
return result.Invalidate(_localizer["You can't do this action because of your account status."]);
var owns = await _dbEntities.RootToAvatars
.Match(ra => ra.RootId == form.RootId && ra.AvatarId == form.AvatarId)
.ExecuteAnyAsync();
if (!owns)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var avatar = await _dbEntities.Avatars.MatchID(form.AvatarId).ExecuteFirstAsync();
if (avatar == default || avatar.DeletionAt.HasValue)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
avatar.Name = form.Name;
avatar.Biography = form.Biography;
avatar.Fields = form.Fields ?? new();
avatar.PersonalNote = form.PersonalNote;
if (form.Settings != default)
avatar.Settings = ToSettings(form.Settings);
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar);
result.Data = ToView(avatar);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(UpdateAvatar)}");
return result.Invalidate(_localizer["Error: {0}", ex.ToString()], exception: ex);
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> GetRootAvatars(string rootUserId, CancellationToken token)
{
var result = new WebResult();
try
{
var avatarIds = (await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId).ExecuteAsync(token))
.Select(ra => ra.AvatarId)
.ToList();
var avatars = avatarIds.Count == 0
? new List<Avatar>()
: await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token);
result.Data = avatars.Select(ToView).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(GetRootAvatars)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
async ValueTask<bool> IsValidRootUser(string rootUserId) =>
await _dbEntities.RootUsers.MatchID(rootUserId)
.Match(ru => !ru.DeletedAt.HasValue)
.ExecuteAnyAsync();
!string.IsNullOrEmpty(rootUserId)
&& await _dbEntities.RootUsers.MatchID(rootUserId)
.Match(ru => !ru.DeletedAt.HasValue && !ru.IsBanned)
.ExecuteAnyAsync();
static AvatarSettings ToSettings(ViewAvatarSettings settings) => new()
{
IsDefault = settings.IsDefault,
DarkThemeIndexColour = settings.DarkThemeIndexColour,
IconsThemeIndexColour = settings.IconsThemeIndexColour,
LanguageCode = settings.LanguageCode,
LightThemeIndexColour = settings.LightThemeIndexColour,
PreferSystemTheming = settings.PreferSystemTheming,
ThemeIsDarkGray = settings.ThemeIsDarkGray,
ThemeIsDarkMode = settings.ThemeIsDarkMode,
ThemeIsLightGray = settings.ThemeIsLightGray
};
ViewAvatar ToView(Avatar avatar) => new()
{
Id = avatar.ID,
Url = avatar.Url,
Handle = _localActors.FromAvatar(avatar).Handle,
Name = avatar.Name,
UserName = avatar.UserName,
Biography = avatar.Biography,
Fields = avatar.Fields,
PictureURL = avatar.PictureURL,
ThumbnailURL = avatar.ThumbnailURL,
SharedPersonalContacts = avatar.SharedPersonalContacts,
AccountState = (ViewAvatarState)(int)avatar.AccountState,
ServerType = ViewAvatarServer.PrivaPub,
Settings = new()
{
IsDefault = avatar.Settings.IsDefault,
LanguageCode = avatar.Settings.LanguageCode,
IconsThemeIndexColour = avatar.Settings.IconsThemeIndexColour,
LightThemeIndexColour = avatar.Settings.LightThemeIndexColour,
DarkThemeIndexColour = avatar.Settings.DarkThemeIndexColour,
PreferSystemTheming = avatar.Settings.PreferSystemTheming,
ThemeIsDarkMode = avatar.Settings.ThemeIsDarkMode,
ThemeIsDarkGray = avatar.Settings.ThemeIsDarkGray,
ThemeIsLightGray = avatar.Settings.ThemeIsLightGray
},
UpdatedAt = avatar.UpdatedAt,
CreatedAt = avatar.CreatedAt
};
}
}
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Localization;
using PrivaPub.ClientModels;
using PrivaPub.Models.User;
@@ -151,7 +151,7 @@ namespace PrivaPub.Services.ClientToServer.Public
{
try
{
var rootUser = _dbEntities.RootUsers.MatchID(rootUserId).ExecuteFirstAsync(token);
var rootUser = await _dbEntities.RootUsers.MatchID(rootUserId).ExecuteFirstAsync(token);
if (rootUser == default)
return Enumerable.Empty<Avatar>();
var rootToAvatars = await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId).ExecuteAsync(token);
@@ -0,0 +1,178 @@
using Markdig;
using PrivaPub.Models.Federation;
using System.Globalization;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
using DmPostEntity = PrivaPub.Models.Post.DmPost;
namespace PrivaPub.Services.Federation
{
public static class ActivityPubRenderer
{
public const string ActivityStreams = "https://www.w3.org/ns/activitystreams";
public const string Public = "https://www.w3.org/ns/activitystreams#Public";
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().Build();
public static string Html(string markdown) =>
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
public static string Timestamp(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
public static JsonObject Actor(LocalActor actor)
{
var document = new JsonObject
{
["@context"] = new JsonArray(ActivityStreams, "https://w3id.org/security/v1"),
["id"] = actor.Uri,
["type"] = actor.Kind switch
{
LocalActorKind.Group => "Group",
LocalActorKind.Application => "Application",
_ => "Person"
},
["preferredUsername"] = actor.UserName,
["name"] = actor.Name,
["summary"] = Html(actor.Summary),
["url"] = actor.Uri,
["inbox"] = actor.Inbox,
["outbox"] = actor.Outbox,
["followers"] = actor.Followers,
["following"] = actor.Following,
["published"] = Timestamp(actor.Published),
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
["discoverable"] = actor.Discoverable,
["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox },
["publicKey"] = new JsonObject
{
["id"] = actor.KeyId,
["owner"] = actor.Uri,
["publicKeyPem"] = Keys.ToSubjectPublicKeyInfoPem(actor.PublicKeyPem)
}
};
if (!string.IsNullOrEmpty(actor.PictureURL))
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
document["image"] = new JsonObject { ["type"] = "Image", ["url"] = actor.ThumbnailURL };
return document;
}
public static JsonObject Note(PostEntity post, LocalActor author, LocalActor group, string inReplyTo)
{
var to = new JsonArray(Public);
var cc = new JsonArray(author.Followers);
if (group != default)
cc.Add(group.Uri);
var note = NoteBody(author.PostUri(post.ID), author, post.Title, post.Text, post.HasContentWarning,
post.CreationDate, to, cc, inReplyTo);
if (group != default)
note["audience"] = group.Uri;
return note;
}
public static JsonObject DirectNote(DmPostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients,
string context)
{
var note = NoteBody(author.PostUri(post.ID), author, post.Title, post.Text, post.HasContentWarning,
post.CreationDate, new JsonArray(recipients.Select(r => (JsonNode)r.Uri).ToArray()), new JsonArray(), default);
note["tag"] = new JsonArray(recipients.Select(r => (JsonNode)new JsonObject
{
["type"] = "Mention",
["href"] = r.Uri,
["name"] = "@" + r.Handle
}).ToArray());
note["content"] = string.Join(" ", recipients.Select(r => $"<span class=\"h-card\"><a href=\"{r.Uri}\" class=\"u-url mention\">@{r.Handle}</a></span>"))
+ " " + note["content"]!.GetValue<string>();
if (!string.IsNullOrEmpty(context))
note["context"] = context;
return note;
}
static JsonObject NoteBody(string id, LocalActor author, string title, string text, bool sensitive,
DateTime published, JsonArray to, JsonArray cc, string inReplyTo)
{
var note = new JsonObject
{
["id"] = id,
["type"] = "Note",
["attributedTo"] = author.Uri,
["content"] = Html(text),
["published"] = Timestamp(published),
["url"] = id,
["to"] = to,
["cc"] = cc,
["sensitive"] = sensitive
};
if (sensitive && !string.IsNullOrEmpty(title))
note["summary"] = title;
else if (!string.IsNullOrEmpty(title))
note["name"] = title;
if (!string.IsNullOrEmpty(inReplyTo))
note["inReplyTo"] = inReplyTo;
return note;
}
public static JsonObject Create(LocalActor actor, JsonObject note, string activityId) => new()
{
["@context"] = ActivityStreams,
["id"] = actor.ActivityUri(activityId),
["type"] = "Create",
["actor"] = actor.Uri,
["published"] = note["published"]?.DeepClone(),
["to"] = note["to"]?.DeepClone(),
["cc"] = note["cc"]?.DeepClone(),
["object"] = note
};
public static JsonObject Announce(LocalActor group, string objectUri, string activityId) => new()
{
["@context"] = ActivityStreams,
["id"] = group.ActivityUri(activityId),
["type"] = "Announce",
["actor"] = group.Uri,
["published"] = Timestamp(DateTime.UtcNow),
["to"] = new JsonArray(Public),
["cc"] = new JsonArray(group.Followers),
["object"] = objectUri
};
public static JsonObject Delete(LocalActor actor, string objectUri, string activityId, JsonArray to, JsonArray cc) => new()
{
["@context"] = ActivityStreams,
["id"] = actor.ActivityUri(activityId),
["type"] = "Delete",
["actor"] = actor.Uri,
["to"] = to,
["cc"] = cc,
["object"] = new JsonObject { ["id"] = objectUri, ["type"] = "Tombstone" }
};
public static JsonObject Accept(LocalActor actor, JsonNode follow, string activityId) => new()
{
["@context"] = ActivityStreams,
["id"] = actor.ActivityUri(activityId),
["type"] = "Accept",
["actor"] = actor.Uri,
["object"] = follow.DeepClone()
};
public static JsonObject OrderedCollection(string id, int totalItems, IEnumerable<JsonNode> items)
{
var collection = new JsonObject
{
["@context"] = ActivityStreams,
["id"] = id,
["type"] = "OrderedCollection",
["totalItems"] = totalItems
};
if (items != default)
collection["orderedItems"] = new JsonArray(items.ToArray());
return collection;
}
}
}
@@ -0,0 +1,178 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.StaticServices;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Services.Federation
{
public interface IDeliveryService
{
Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token);
Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default);
Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token);
}
public class DeliveryService : IDeliveryService
{
readonly DbEntities _dbEntities;
public DeliveryService(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token)
{
var body = activity.ToJsonString();
var deliveries = inboxes
.Where(i => !string.IsNullOrEmpty(i) && !i.StartsWith(signer.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.Ordinal)
.Select(inbox => new Delivery
{
SignerId = signer.Id,
SignerKind = signer.Kind,
InboxURL = inbox,
Body = body
})
.ToList();
if (deliveries.Count > 0)
await DB.Default.SaveAsync(deliveries, token);
}
public async Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default)
{
var inboxes = (await FollowerInboxes(signer, token)).Concat(extraInboxes ?? Enumerable.Empty<string>());
await Enqueue(signer, inboxes, activity, token);
}
public async Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token)
{
var followers = await _dbEntities.Followers
.Match(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted)
.ExecuteAsync(token);
return followers.Select(f => string.IsNullOrEmpty(f.SharedInboxURL) ? f.InboxURL : f.SharedInboxURL)
.Distinct(StringComparer.Ordinal)
.ToList();
}
}
public class DeliveryWorker : BackgroundService
{
const int MaxAttempts = 8;
static readonly TimeSpan Poll = TimeSpan.FromSeconds(3);
readonly IServiceProvider _services;
readonly IHttpClientFactory _httpClientFactory;
readonly ILogger<DeliveryWorker> _logger;
public DeliveryWorker(IServiceProvider services, IHttpClientFactory httpClientFactory, ILogger<DeliveryWorker> logger)
{
_services = services;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await DeliverDue(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogError(ex, "{Worker} pass failed", nameof(DeliveryWorker));
}
await Task.Delay(Poll, stoppingToken);
}
}
async Task DeliverDue(CancellationToken token)
{
using var scope = _services.CreateScope();
var dbEntities = scope.ServiceProvider.GetRequiredService<DbEntities>();
var actors = scope.ServiceProvider.GetRequiredService<ILocalActorService>();
var now = DateTime.UtcNow;
var due = await dbEntities.Deliveries
.Match(d => !d.DeliveredAt.HasValue && !d.AbandonedAt.HasValue && d.NextAttemptAt <= now)
.Sort(d => d.NextAttemptAt, Order.Ascending)
.Limit(20)
.ExecuteAsync(token);
foreach (var delivery in due)
{
var signer = await actors.FindById(delivery.SignerKind, delivery.SignerId, token);
if (signer == default)
{
delivery.AbandonedAt = DateTime.UtcNow;
delivery.LastError = "the signing actor no longer exists";
await DB.Default.SaveAsync(delivery, token);
continue;
}
await Deliver(delivery, signer, token);
await DB.Default.SaveAsync(delivery, token);
}
}
async Task Deliver(Delivery delivery, LocalActor signer, CancellationToken token)
{
delivery.Attempts++;
try
{
if (!Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox) || !RemoteActorService.IsFetchable(inbox))
{
delivery.AbandonedAt = DateTime.UtcNow;
delivery.LastError = "not a deliverable inbox";
return;
}
var body = Encoding.UTF8.GetBytes(delivery.Body);
using var request = new HttpRequestMessage(HttpMethod.Post, inbox)
{
Content = new ByteArrayContent(body)
};
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson);
HttpSignatures.Sign(request, signer, body);
using var response = await _httpClientFactory.CreateClient(RemoteActorService.HttpClientName).SendAsync(request, token);
if (response.IsSuccessStatusCode)
{
delivery.DeliveredAt = DateTime.UtcNow;
delivery.LastError = default;
return;
}
delivery.LastError = $"{(int)response.StatusCode} {response.ReasonPhrase}";
if (response.StatusCode is HttpStatusCode.Gone or HttpStatusCode.NotFound or HttpStatusCode.BadRequest or HttpStatusCode.Forbidden)
{
delivery.AbandonedAt = DateTime.UtcNow;
_logger.LogWarning("Delivery {Id} to {Inbox} abandoned: {Status}", delivery.ID, delivery.InboxURL, delivery.LastError);
return;
}
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !token.IsCancellationRequested)
{
delivery.LastError = ex.Message;
}
if (delivery.Attempts >= MaxAttempts)
{
delivery.AbandonedAt = DateTime.UtcNow;
_logger.LogWarning("Delivery {Id} to {Inbox} abandoned after {Attempts} attempts: {Error}",
delivery.ID, delivery.InboxURL, delivery.Attempts, delivery.LastError);
return;
}
delivery.NextAttemptAt = DateTime.UtcNow.AddMinutes(Math.Pow(2, delivery.Attempts));
}
}
}
@@ -0,0 +1,183 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace PrivaPub.Services.Federation
{
public sealed record SignatureParameters(string KeyId, string Algorithm, string[] Headers, byte[] Signature,
string Created, string Expires);
public static class HttpSignatures
{
static readonly TimeSpan AllowedClockSkew = TimeSpan.FromHours(12);
public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body));
public static void Sign(HttpRequestMessage request, LocalActor signer, byte[] body)
{
var date = DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var signed = new List<(string Name, string Value)>
{
("(request-target)", $"{request.Method.Method.ToLowerInvariant()} {request.RequestUri.PathAndQuery}"),
("host", request.RequestUri.IsDefaultPort ? request.RequestUri.Host : request.RequestUri.Authority),
("date", date)
};
request.Headers.TryAddWithoutValidation("Date", date);
if (body != null)
{
var digest = Digest(body);
request.Headers.TryAddWithoutValidation("Digest", digest);
signed.Add(("digest", digest));
}
var signingString = string.Join("\n", signed.Select(h => $"{h.Name}: {h.Value}"));
using var rsa = RSA.Create();
rsa.ImportFromPem(signer.PrivateKeyPem);
var signature = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes(signingString),
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
request.Headers.TryAddWithoutValidation("Signature",
$"keyId=\"{signer.KeyId}\",algorithm=\"rsa-sha256\",headers=\"{string.Join(' ', signed.Select(h => h.Name))}\",signature=\"{signature}\"");
}
public static SignatureParameters Parse(string header)
{
if (string.IsNullOrWhiteSpace(header))
return default;
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var i = 0;
while (i < header.Length)
{
while (i < header.Length && (header[i] == ',' || header[i] == ' '))
i++;
var equals = header.IndexOf('=', i);
if (equals < 0)
break;
var key = header[i..equals].Trim();
i = equals + 1;
string value;
if (i < header.Length && header[i] == '"')
{
var close = header.IndexOf('"', i + 1);
if (close < 0)
return default;
value = header[(i + 1)..close];
i = close + 1;
}
else
{
var comma = header.IndexOf(',', i);
value = comma < 0 ? header[i..] : header[i..comma];
i = comma < 0 ? header.Length : comma;
}
values[key] = value.Trim();
}
if (!values.TryGetValue("keyId", out var keyId) || !values.TryGetValue("signature", out var signature))
return default;
byte[] signatureBytes;
try
{
signatureBytes = Convert.FromBase64String(signature);
}
catch (FormatException)
{
return default;
}
var headers = values.TryGetValue("headers", out var headerList)
? headerList.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(h => h.ToLowerInvariant()).ToArray()
: new[] { "date" };
return new(keyId, values.GetValueOrDefault("algorithm") ?? "hs2019", headers, signatureBytes,
values.GetValueOrDefault("created"), values.GetValueOrDefault("expires"));
}
public static string SigningString(HttpRequest request, SignatureParameters parameters)
{
var lines = new List<string>();
foreach (var header in parameters.Headers)
{
switch (header)
{
case "(request-target)":
lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {request.PathBase}{request.Path}{request.QueryString}");
break;
case "(created)":
lines.Add($"(created): {parameters.Created}");
break;
case "(expires)":
lines.Add($"(expires): {parameters.Expires}");
break;
case "host":
lines.Add($"host: {request.Host.Value}");
break;
default:
if (!request.Headers.TryGetValue(header, out var value))
return default;
lines.Add($"{header}: {string.Join(", ", value.Select(v => v.Trim()))}");
break;
}
}
return string.Join("\n", lines);
}
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body)
{
if (parameters.Algorithm is not ("rsa-sha256" or "hs2019"))
return $"unsupported signature algorithm '{parameters.Algorithm}'";
if (body is { Length: > 0 })
{
if (!parameters.Headers.Contains("digest"))
return "the digest is not signed";
var expectedHash = Convert.ToBase64String(SHA256.HashData(body));
var matches = request.Headers["Digest"].ToString().Split(',').Select(d => d.Trim())
.Any(d => d.StartsWith("SHA-256=", StringComparison.OrdinalIgnoreCase) && d[8..] == expectedHash);
if (!matches)
return "the digest does not match the body";
}
if (parameters.Headers.Contains("date"))
{
if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out var date))
return "unreadable Date header";
if ((DateTimeOffset.UtcNow - date).Duration() > AllowedClockSkew)
return "the Date header is outside the allowed window";
}
else if (!parameters.Headers.Contains("(created)"))
return "neither date nor (created) is signed";
if (!string.IsNullOrEmpty(parameters.Expires) && long.TryParse(parameters.Expires, out var expires)
&& DateTimeOffset.FromUnixTimeSeconds(expires) < DateTimeOffset.UtcNow - AllowedClockSkew)
return "the signature has expired";
return default;
}
public static bool Verify(string publicKeyPem, string signingString, byte[] signature)
{
if (string.IsNullOrEmpty(publicKeyPem) || signingString == null)
return false;
try
{
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
return rsa.VerifyData(Encoding.UTF8.GetBytes(signingString), signature,
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch (CryptographicException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
}
}
}
@@ -0,0 +1,398 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using DmPostEntity = PrivaPub.Models.Post.DmPost;
using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Services.Federation
{
public sealed record InboxResult(int StatusCode, string Error = default);
public interface IInboxService
{
Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token);
}
public class InboxService : IInboxService
{
const int MaxBodyBytes = 1024 * 1024;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly ILogger<InboxService> _logger;
public InboxService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
IDeliveryService delivery, ILogger<InboxService> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_logger = logger;
}
public async Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token)
{
if (request.ContentLength > MaxBodyBytes)
return new(StatusCodes.Status413PayloadTooLarge);
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer, token);
if (buffer.Length > MaxBodyBytes)
return new(StatusCodes.Status413PayloadTooLarge);
var body = buffer.ToArray();
JsonNode activity;
try
{
activity = JsonNode.Parse(body);
}
catch (JsonException)
{
return new(StatusCodes.Status400BadRequest, "the body is not JSON");
}
if (activity is not JsonObject)
return new(StatusCodes.Status400BadRequest, "the body is not an activity");
var type = Value(activity, "type");
var actorUri = Id(activity["actor"]);
if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri))
return new(StatusCodes.Status400BadRequest, "type and actor are required");
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
if (parameters == default)
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header");
var requestProblem = HttpSignatures.CheckRequest(request, parameters, body);
if (requestProblem != default)
return new(StatusCodes.Status401Unauthorized, requestProblem);
var signAs = recipient ?? await _localActors.GetInstanceActor(token);
var signingString = HttpSignatures.SigningString(request, parameters);
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: false, token);
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
return new(StatusCodes.Status202Accepted);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
{
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: true, token);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
}
if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal))
return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner");
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor}", recipient?.Handle ?? "shared", type, actorUri);
return type switch
{
"Follow" => await Follow(activity, keyOwner, token),
"Undo" => await Undo(activity, keyOwner, token),
"Create" => await Create(activity, keyOwner, signAs, token),
"Delete" => await Delete(activity, keyOwner, token),
"Update" => await Update(activity, keyOwner, signAs, token),
_ => new(StatusCodes.Status202Accepted)
};
}
async Task<InboxResult> Follow(JsonNode follow, ForeignAvatar follower, CancellationToken token)
{
var target = await _localActors.FindByUri(Id(follow["object"]), token);
if (target == default || target.Kind == LocalActorKind.Application)
return new(StatusCodes.Status404NotFound, "no such local actor");
var existing = await _dbEntities.Followers
.Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.ActorURI)
.ExecuteFirstAsync(token);
var record = existing ?? new Follower
{
LocalActorId = target.Id,
LocalActorKind = target.Kind,
ActorURI = follower.ActorURI
};
record.InboxURL = follower.InboxURL;
record.SharedInboxURL = follower.SharedInboxURL;
record.FollowActivityURI = Id(follow);
record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers;
await DB.Default.SaveAsync(record, token);
if (!record.IsAccepted)
return new(StatusCodes.Status202Accepted);
if (target.Kind == LocalActorKind.Group)
await AddForeignMember(target.Id, follower.ActorURI, token);
var accept = ActivityPubRenderer.Accept(target, follow, $"accept-{record.ID}-{DateTime.UtcNow.Ticks}");
await _delivery.Enqueue(target, new[] { follower.InboxURL }, accept, token);
return new(StatusCodes.Status202Accepted);
}
async Task<InboxResult> Undo(JsonNode undo, ForeignAvatar actor, CancellationToken token)
{
var inner = undo["object"];
var innerType = inner is JsonObject ? Value(inner, "type") : default;
var innerId = Id(inner);
if (innerType is not (null or "Follow"))
return new(StatusCodes.Status202Accepted);
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
var targetUri = inner is JsonObject ? Id(inner["object"]) : default;
foreach (var follower in followers)
{
var matchesActivity = innerId != default && follower.FollowActivityURI == innerId;
var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token);
var matchesTarget = target != default && target.Id == follower.LocalActorId && target.Kind == follower.LocalActorKind;
if (!matchesActivity && !matchesTarget)
continue;
await DB.Default.DeleteAsync<Follower>(follower.ID);
if (follower.LocalActorKind == LocalActorKind.Group)
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
}
return new(StatusCodes.Status202Accepted);
}
async Task<InboxResult> Create(JsonNode create, ForeignAvatar author, LocalActor signAs, CancellationToken token)
{
var note = create["object"];
if (note is JsonValue)
{
using var fetched = await _remoteActors.Fetch(Id(note), signAs, token);
note = fetched == default ? default : JsonNode.Parse(fetched.RootElement.GetRawText());
}
if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question"))
return new(StatusCodes.Status202Accepted);
var objectUri = Id(note);
if (string.IsNullOrEmpty(objectUri) || Id(note["attributedTo"]) != author.ActorURI)
return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor");
var addressed = Addresses(note).Concat(Addresses(create)).Distinct(StringComparer.Ordinal).ToList();
var isPublic = addressed.Contains(ActivityPubRenderer.Public) || addressed.Contains("as:Public") || addressed.Contains("Public");
var inReplyTo = Id(note["inReplyTo"]);
var localTargets = new List<LocalActor>();
foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct())
{
var local = await _localActors.FindByUri(uri, token);
if (local != default && localTargets.All(l => l.Id != local.Id))
localTargets.Add(local);
}
if (isPublic || addressed.Any(a => a == author.ActorURI + "/followers" || a.EndsWith("/followers")))
{
var group = localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group);
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
group = default;
if (group == default && localTargets.All(t => t.Kind != LocalActorKind.Person))
return new(StatusCodes.Status202Accepted);
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return new(StatusCodes.Status202Accepted);
var post = new PostEntity
{
ObjectURI = objectUri,
ActorURI = author.ActorURI,
GroupId = group?.Id,
Title = Value(note, "summary") ?? Value(note, "name"),
Text = Value(note, "content"),
HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s,
AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo,
IsFederatedCopy = true,
CreationDate = DateTime.TryParse(Value(note, "published"), out var published) ? published.ToUniversalTime() : DateTime.UtcNow
};
await DB.Default.SaveAsync(post, token);
if (group != default)
{
var announce = ActivityPubRenderer.Announce(group, objectUri, $"announce-{post.ID}");
await _delivery.EnqueueToFollowers(group, announce, token);
}
return new(StatusCodes.Status202Accepted);
}
var recipients = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
if (recipients.Count == 0)
return new(StatusCodes.Status202Accepted);
if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return new(StatusCodes.Status202Accepted);
var participants = addressed.Where(a => a != ActivityPubRenderer.Public).Append(author.ActorURI).ToList();
var dmGroup = await FindOrCreateDmGroup(participants, Value(note, "context") ?? Value(note, "conversation"), token);
var dm = new DmPostEntity
{
ObjectURI = objectUri,
ActorURI = author.ActorURI,
GroupId = dmGroup.ID,
Title = Value(note, "summary"),
Text = Value(note, "content"),
HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue<bool>(out var ds) && ds,
AnsweringToPostId = inReplyTo,
IsFederatedCopy = true,
CreationDate = DateTime.TryParse(Value(note, "published"), out var dmPublished) ? dmPublished.ToUniversalTime() : DateTime.UtcNow
};
await DB.Default.SaveAsync(dm, token);
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
return new(StatusCodes.Status202Accepted);
}
async Task<InboxResult> Delete(JsonNode delete, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(delete["object"]);
if (string.IsNullOrEmpty(objectUri))
return new(StatusCodes.Status202Accepted);
if (objectUri == actor.ActorURI)
{
actor.DeletionAt = DateTime.UtcNow;
actor.AccountState = AvatarAccountState.Deleted;
await DB.Default.SaveAsync(actor, token);
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
foreach (var follower in followers)
{
await DB.Default.DeleteAsync<Follower>(follower.ID);
if (follower.LocalActorKind == LocalActorKind.Group)
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
}
return new(StatusCodes.Status202Accepted);
}
await DB.Default.DeleteAsync<PostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
await DB.Default.DeleteAsync<DmPostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
return new(StatusCodes.Status202Accepted);
}
async Task<InboxResult> Update(JsonNode update, ForeignAvatar actor, LocalActor signAs, CancellationToken token)
{
var inner = update["object"];
if (Id(inner) == actor.ActorURI)
{
await _remoteActors.GetActor(actor.ActorURI, signAs, refresh: true, token);
return new(StatusCodes.Status202Accepted);
}
if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI)
return new(StatusCodes.Status202Accepted);
var objectUri = Id(inner);
var text = Value(inner, "content");
await DB.Default.Update<PostEntity>()
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
.Modify(p => p.Text, text)
.Modify(p => p.UpdateDate, DateTime.UtcNow)
.ExecuteAsync(token);
await DB.Default.Update<DmPostEntity>()
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
.Modify(p => p.Text, text)
.Modify(p => p.UpdateDate, DateTime.UtcNow)
.ExecuteAsync(token);
return new(StatusCodes.Status202Accepted);
}
async Task<bool> IsAcceptedFollower(LocalActor group, string actorUri, CancellationToken token) =>
await _dbEntities.Followers
.Match(f => f.LocalActorId == group.Id && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == actorUri && f.IsAccepted)
.ExecuteAnyAsync(token);
async Task AddForeignMember(string groupId, string actorUri, CancellationToken token)
{
var group = await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
if (group == default || group.Members.Any(m => m.IsForeign && m.AvatarId == actorUri))
return;
group.Members.Add(new GroupMember { AvatarId = actorUri, IsForeign = true });
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(group, token);
}
async Task RemoveForeignMember(string groupId, string actorUri, CancellationToken token)
{
var group = await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
if (group == default || group.Members.RemoveAll(m => m.IsForeign && m.AvatarId == actorUri) == 0)
return;
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync<GroupEntity>(group, token);
}
async Task<DmGroup> FindOrCreateDmGroup(List<string> participantUris, string context, CancellationToken token)
{
var members = new List<GroupMember>();
foreach (var uri in participantUris.Distinct(StringComparer.Ordinal))
{
var local = await _localActors.FindByUri(uri, token);
members.Add(local != default && local.Kind == LocalActorKind.Person
? new GroupMember { AvatarId = local.Id }
: new GroupMember { AvatarId = uri, IsForeign = true });
}
if (!string.IsNullOrEmpty(context))
{
var byContext = await _dbEntities.DmGroups.Match(g => g.ConversationURI == context).ExecuteFirstAsync(token);
if (byContext != default)
return byContext;
}
var keys = members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).ToList();
var candidates = await _dbEntities.DmGroups
.Match(g => !g.DeletionAt.HasValue && g.Members.Count == keys.Count)
.ExecuteAsync(token);
var match = candidates.FirstOrDefault(g => g.Members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).SequenceEqual(keys));
if (match != default)
return match;
var dmGroup = new DmGroup { Members = members, ConversationURI = context };
await DB.Default.SaveAsync(dmGroup, token);
return dmGroup;
}
async Task<string> LocalPostId(string uri, CancellationToken token)
{
if (string.IsNullOrEmpty(uri) || !uri.StartsWith(_localActors.BaseAddress + "/peasants/", StringComparison.OrdinalIgnoreCase))
return default;
var id = uri[(uri.LastIndexOf('/') + 1)..];
return await _dbEntities.Posts.MatchID(id).ExecuteAnyAsync(token) ? id : default;
}
static IEnumerable<string> Addresses(JsonNode node)
{
foreach (var field in new[] { "to", "cc", "bto", "bcc", "audience" })
{
switch (node[field])
{
case JsonArray array:
foreach (var item in array)
{
var id = Id(item);
if (id != default)
yield return id;
}
break;
case JsonNode single when Id(single) is { } id:
yield return id;
break;
}
}
}
public static string Id(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
JsonObject obj => Value(obj, "id") ?? Value(obj, "href"),
JsonArray array => array.Select(Id).FirstOrDefault(i => i != default),
_ => default
};
public static string Value(JsonNode node, string property) =>
node is JsonObject obj && obj[property] is JsonValue value && value.TryGetValue<string>(out var text) ? text : default;
}
}
@@ -0,0 +1,201 @@
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Security.Cryptography;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Services.Federation
{
public class LocalActor
{
public string Id { get; init; }
public LocalActorKind Kind { get; init; }
public string UserName { get; init; }
public string Name { get; init; }
public string Summary { get; init; }
public string PictureURL { get; init; }
public string ThumbnailURL { get; init; }
public string PrivateKeyPem { get; init; }
public string PublicKeyPem { get; init; }
public bool Discoverable { get; init; } = true;
public bool ManuallyApprovesFollowers { get; init; }
public DateTime Published { get; init; }
public string BaseAddress { get; init; }
public string Uri => $"{BaseAddress}/peasants/{UserName}";
public string KeyId => $"{Uri}#main-key";
public string Inbox => $"{Uri}/mouth";
public string Outbox => $"{Uri}/anus";
public string Followers => $"{Uri}/followers";
public string Following => $"{Uri}/following";
public string SharedInbox => $"{BaseAddress}/human-centipede";
public string Domain => new Uri(BaseAddress).Authority;
public string Handle => $"{UserName}@{Domain}";
public string PostUri(string postId) => $"{Uri}/posts/{postId}";
public string ActivityUri(string activityId) => $"{Uri}/activities/{activityId}";
}
public interface ILocalActorService
{
string BaseAddress { get; }
Task<LocalActor> FindByUserName(string userName, CancellationToken token);
Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token);
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
Task<LocalActor> GetInstanceActor(CancellationToken token);
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
LocalActor FromAvatar(Avatar avatar);
LocalActor FromGroup(GroupEntity group);
}
public class LocalActorService : ILocalActorService
{
public const string InstanceUserName = "privapub";
readonly DbEntities _dbEntities;
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
{
_dbEntities = dbEntities;
_appConfiguration = appConfiguration;
}
public string BaseAddress => _appConfiguration.CurrentValue.BackendBaseAddress?.TrimEnd('/');
public async Task<LocalActor> FindByUserName(string userName, CancellationToken token)
{
if (string.IsNullOrEmpty(userName))
return default;
userName = userName.ToLowerInvariant();
if (userName == InstanceUserName)
return await GetInstanceActor(token);
var avatar = await _dbEntities.Avatars
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
.ExecuteFirstAsync(token);
if (avatar != default)
return FromAvatar(avatar);
var group = await _dbEntities.Groups
.Match(g => g.UserName == userName && !g.DeletionAt.HasValue)
.ExecuteFirstAsync(token);
return group == default ? default : FromGroup(group);
}
public async Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token)
{
switch (kind)
{
case LocalActorKind.Person:
var avatar = await _dbEntities.Avatars.MatchID(id).ExecuteFirstAsync(token);
return avatar == default ? default : FromAvatar(avatar);
case LocalActorKind.Group:
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
return group == default ? default : FromGroup(group);
default:
return await GetInstanceActor(token);
}
}
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token)
{
var prefix = $"{BaseAddress}/peasants/";
if (string.IsNullOrEmpty(actorUri) || !actorUri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return Task.FromResult<LocalActor>(default);
var userName = actorUri[prefix.Length..].Split('/', '#', '?')[0];
return FindByUserName(userName, token);
}
public async Task<LocalActor> GetInstanceActor(CancellationToken token)
{
var instance = await _dbEntities.InstanceActors.ExecuteFirstAsync(token);
if (instance == default)
{
var (privateKey, publicKey) = Keys.NewKeyPair();
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(instance, token);
}
return new LocalActor
{
Id = instance.ID,
Kind = LocalActorKind.Application,
UserName = InstanceUserName,
Name = "PrivaPub",
Summary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for.",
PrivateKeyPem = instance.PrivateKey,
PublicKeyPem = instance.PublicKey,
Discoverable = false,
Published = instance.CreationDate,
BaseAddress = BaseAddress
};
}
public async Task<bool> IsUserNameTaken(string userName, CancellationToken token)
{
userName = userName?.ToLowerInvariant();
if (string.IsNullOrEmpty(userName) || userName == InstanceUserName)
return true;
if (await _dbEntities.Avatars.Match(a => a.UserName == userName).ExecuteAnyAsync(token))
return true;
return await _dbEntities.Groups.Match(g => g.UserName == userName).ExecuteAnyAsync(token);
}
public LocalActor FromAvatar(Avatar avatar) => new()
{
Id = avatar.ID,
Kind = LocalActorKind.Person,
UserName = avatar.UserName,
Name = string.IsNullOrEmpty(avatar.Name) ? avatar.UserName : avatar.Name,
Summary = avatar.Biography,
PictureURL = avatar.PictureURL,
ThumbnailURL = avatar.ThumbnailURL,
PrivateKeyPem = avatar.PrivateKey,
PublicKeyPem = avatar.PublicKey,
Published = avatar.CreatedAt,
BaseAddress = BaseAddress
};
public LocalActor FromGroup(GroupEntity group) => new()
{
Id = group.ID,
Kind = LocalActorKind.Group,
UserName = group.UserName,
Name = string.IsNullOrEmpty(group.Name) ? group.UserName : group.Name,
Summary = group.Description,
PictureURL = group.PictureURL,
ThumbnailURL = group.ThumbnailURL,
PrivateKeyPem = group.PrivateKey,
PublicKeyPem = group.PublicKey,
Discoverable = group.IsDiscoverable,
ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
Published = group.CreationDate,
BaseAddress = BaseAddress
};
}
public static class Keys
{
public static (string PrivateKeyPem, string PublicKeyPem) NewKeyPair()
{
using var rsa = RSA.Create(2048);
return (rsa.ExportRSAPrivateKeyPem(), rsa.ExportSubjectPublicKeyInfoPem());
}
public static string ToSubjectPublicKeyInfoPem(string publicKeyPem)
{
if (string.IsNullOrEmpty(publicKeyPem) || publicKeyPem.Contains("BEGIN PUBLIC KEY"))
return publicKeyPem;
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
return rsa.ExportSubjectPublicKeyInfoPem();
}
}
}
@@ -0,0 +1,191 @@
using MongoDB.Entities;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Net.Http.Headers;
using System.Text.Json;
namespace PrivaPub.Services.Federation
{
public interface IRemoteActorService
{
Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token);
Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token);
Task<string> ResolveHandle(string handle, CancellationToken token);
}
public class RemoteActorService : IRemoteActorService
{
public const string HttpClientName = "ActivityPub";
public const string ActivityJson = "application/activity+json";
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1);
readonly IHttpClientFactory _httpClientFactory;
readonly DbEntities _dbEntities;
readonly ILogger<RemoteActorService> _logger;
public RemoteActorService(IHttpClientFactory httpClientFactory, DbEntities dbEntities, ILogger<RemoteActorService> logger)
{
_httpClientFactory = httpClientFactory;
_dbEntities = dbEntities;
_logger = logger;
}
public async Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token)
{
if (!Uri.TryCreate(uri, UriKind.Absolute, out var target) || !IsFetchable(target))
return default;
using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.ParseAdd(Accept);
if (signAs != default)
HttpSignatures.Sign(request, signAs, body: null);
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
if (!response.IsSuccessStatusCode)
{
_logger.LogInformation("GET {Uri} answered {Status}", uri, (int)response.StatusCode);
return default;
}
await using var stream = await response.Content.ReadAsStreamAsync(token);
return await JsonDocument.ParseAsync(stream, cancellationToken: token);
}
public async Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token)
{
if (string.IsNullOrEmpty(actorUri))
return default;
actorUri = StripFragment(actorUri);
var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime)
return cached;
using var document = await Fetch(actorUri, signAs, token);
if (document == default)
return cached;
return await Upsert(document.RootElement, cached, token);
}
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token)
{
if (string.IsNullOrEmpty(keyId))
return default;
if (!refresh)
{
var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token);
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey))
return cached;
}
using var document = await Fetch(StripFragment(keyId), signAs, token);
if (document == default)
return default;
var root = document.RootElement;
if (root.TryGetProperty("publicKey", out _))
{
var actorUri = Text(root, "id");
var existing = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
return await Upsert(root, existing, token);
}
var owner = Text(root, "owner");
return owner == default ? default : await GetActor(owner, signAs, refresh: true, token);
}
public async Task<string> ResolveHandle(string handle, CancellationToken token)
{
var parts = handle?.TrimStart('@').Split('@');
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
return default;
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsFetchable(target))
return default;
using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/jrd+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
if (!response.IsSuccessStatusCode)
return default;
await using var stream = await response.Content.ReadAsStreamAsync(token);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: token);
if (!document.RootElement.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
return default;
foreach (var link in links.EnumerateArray())
{
var type = Text(link, "type") ?? string.Empty;
if (Text(link, "rel") == "self" && (type.Contains("activity+json") || type.Contains("ld+json")))
return Text(link, "href");
}
return default;
}
async Task<ForeignAvatar> Upsert(JsonElement actor, ForeignAvatar existing, CancellationToken token)
{
var actorUri = Text(actor, "id");
if (string.IsNullOrEmpty(actorUri))
return existing;
var avatar = existing ?? new ForeignAvatar { ActorURI = actorUri, CreatedAt = DateTime.UtcNow };
avatar.ActorURI = actorUri;
avatar.UserName = Text(actor, "preferredUsername");
avatar.Name = Text(actor, "name");
avatar.Biography = Text(actor, "summary");
avatar.Url = Text(actor, "url") ?? actorUri;
avatar.Domain = new Uri(actorUri).Authority;
avatar.InboxURL = Text(actor, "inbox");
avatar.OutboxURL = Text(actor, "outbox");
avatar.IsDiscoverable = !actor.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False;
avatar.AvatarType = Enum.TryParse<AvatarType>(Text(actor, "type"), out var type) ? type : AvatarType.Person;
if (actor.TryGetProperty("endpoints", out var endpoints) && endpoints.ValueKind == JsonValueKind.Object)
avatar.SharedInboxURL = Text(endpoints, "sharedInbox");
if (actor.TryGetProperty("publicKey", out var publicKey) && publicKey.ValueKind == JsonValueKind.Object)
{
avatar.PublicKeyId = Text(publicKey, "id");
avatar.PublicKey = Text(publicKey, "publicKeyPem");
}
if (actor.TryGetProperty("icon", out var icon) && icon.ValueKind == JsonValueKind.Object)
avatar.PictureURL = Text(icon, "url");
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar, token);
return avatar;
}
public static bool IsFetchable(Uri target) =>
target.Scheme == Uri.UriSchemeHttps
&& target.HostNameType == UriHostNameType.Dns
&& !target.IsLoopback
&& !target.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
&& target.Host.Contains('.');
public static string StripFragment(string uri)
{
var hash = uri.IndexOf('#');
return hash < 0 ? uri : uri[..hash];
}
public static string Text(JsonElement element, string property)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
return default;
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Object => Text(value, "id") ?? Text(value, "href"),
JsonValueKind.Array => value.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : Text(v, "id")).FirstOrDefault(v => v != null),
_ => default
};
}
}
}
+337
View File
@@ -0,0 +1,337 @@
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Group;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Resources;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Services
{
public interface IGroupUsersService
{
Task<WebResult> GetGroup(string actor, CancellationToken token);
Task<WebResult> GetGroups(string rootUserId, string avatarId, CancellationToken token);
Task<WebResult> InsertGroup(string rootUserId, InsertGroupForm form, CancellationToken token);
Task<WebResult> UpdateGroup(string rootUserId, UpdateGroupForm form, CancellationToken token);
Task<WebResult> JoinGroup(string rootUserId, JoinGroupForm form, CancellationToken token);
Task<WebResult> LeaveGroup(string rootUserId, GroupMembershipForm form, CancellationToken token);
Task<WebResult> ApproveMember(string rootUserId, GroupMembershipForm form, CancellationToken token);
Task<WebResult> GetInvitation(string invitationCode, string invitationPassword, CancellationToken token);
}
public class GroupUsersService : IGroupUsersService
{
readonly DbEntities _dbEntities;
readonly IPasswordHasher _passwordHasher;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<GroupUsersService> _logger;
public GroupUsersService(DbEntities dbEntities,
IPasswordHasher passwordHasher,
ILocalActorService localActors,
IDeliveryService delivery,
IStringLocalizer<GenericRes> localizer,
ILogger<GroupUsersService> logger)
{
_dbEntities = dbEntities;
_passwordHasher = passwordHasher;
_localActors = localActors;
_delivery = delivery;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> GetGroup(string actor, CancellationToken token)
{
var result = new WebResult();
try
{
var userName = actor?.ToLowerInvariant();
var group = await _dbEntities.Groups
.Match(g => g.UserName == userName && !g.DeletionAt.HasValue)
.ExecuteFirstAsync(token);
if (group == default)
return result.Invalidate(_localizer["Group '{0}' not found.", actor], StatusCodes.Status404NotFound);
result.Data = group;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> GetGroups(string rootUserId, string avatarId, CancellationToken token)
{
var result = new WebResult();
try
{
if (!await OwnsAvatar(rootUserId, avatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var groups = await _dbEntities.Groups
.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatarId))
.ExecuteAsync(token);
result.Data = groups.Select(g => ToView(g, avatarId)).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroups)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> InsertGroup(string rootUserId, InsertGroupForm form, CancellationToken token)
{
var result = new WebResult();
try
{
if (!await OwnsAvatar(rootUserId, form.AvatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var userName = form.UserName.ToLowerInvariant();
if (await _localActors.IsUserNameTaken(userName, token))
return result.Invalidate(_localizer["The username '{0}' is already take.", userName]);
var (privateKey, publicKey) = Keys.NewKeyPair();
var group = new GroupEntity
{
UserName = userName,
Name = form.Name,
Description = form.Description,
Domain = _localActors.BaseAddress,
PrivateKey = privateKey,
PublicKey = publicKey,
IsDiscoverable = form.IsDiscoverable,
ManuallyApprovesMembers = form.ManuallyApprovesMembers,
OwnerAvatarId = form.AvatarId,
InvitationCode = NewInvitationCode(),
HashedInvitationPassword = string.IsNullOrEmpty(form.InvitationPassword) ? default : _passwordHasher.Hash(form.InvitationPassword),
Members = new() { new GroupMember { AvatarId = form.AvatarId, Role = GroupRole.Owner } }
};
var actor = _localActors.FromGroup(group);
group.Url = actor.Uri;
group.InboxURL = actor.Inbox;
group.OutboxURL = actor.Outbox;
await DB.Default.SaveAsync(group, token);
result.Data = ToView(group, form.AvatarId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(InsertGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> UpdateGroup(string rootUserId, UpdateGroupForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var group = await ManagedGroup(rootUserId, form.AvatarId, form.GroupId, token);
if (group == default)
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
if (form.Name != default)
group.Name = form.Name;
if (form.Description != default)
group.Description = form.Description;
if (form.IsDiscoverable.HasValue)
group.IsDiscoverable = form.IsDiscoverable.Value;
if (form.ManuallyApprovesMembers.HasValue)
group.ManuallyApprovesMembers = form.ManuallyApprovesMembers.Value;
if (form.RemoveInvitationPassword)
group.HashedInvitationPassword = default;
else if (!string.IsNullOrEmpty(form.InvitationPassword))
group.HashedInvitationPassword = _passwordHasher.Hash(form.InvitationPassword);
if (form.RegenerateInvitationCode)
group.InvitationCode = NewInvitationCode();
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(group, token);
result.Data = ToView(group, form.AvatarId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(UpdateGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> JoinGroup(string rootUserId, JoinGroupForm form, CancellationToken token)
{
var result = new WebResult();
try
{
if (!await OwnsAvatar(rootUserId, form.AvatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var group = await _dbEntities.Groups
.Match(g => g.InvitationCode == form.InvitationCode && !g.DeletionAt.HasValue)
.ExecuteFirstAsync(token);
if (group == default)
return result.Invalidate(_localizer["Invalid invitation."], StatusCodes.Status404NotFound);
if (!string.IsNullOrEmpty(group.HashedInvitationPassword))
{
var (verified, _) = string.IsNullOrEmpty(form.InvitationPassword)
? (false, false)
: _passwordHasher.Check(group.HashedInvitationPassword, form.InvitationPassword);
if (!verified)
return result.Invalidate(_localizer["Invalid password."], StatusCodes.Status406NotAcceptable);
}
if (!group.Members.Any(m => !m.IsForeign && m.AvatarId == form.AvatarId))
{
group.Members.Add(new GroupMember { AvatarId = form.AvatarId });
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(group, token);
}
result.Data = ToView(group, form.AvatarId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(JoinGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> LeaveGroup(string rootUserId, GroupMembershipForm form, CancellationToken token)
{
var result = new WebResult();
try
{
if (!await OwnsAvatar(rootUserId, form.AvatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var group = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token);
if (group == default)
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
if (group.OwnerAvatarId == form.AvatarId)
return result.Invalidate(_localizer["The owner cannot leave the group."]);
group.Members.RemoveAll(m => !m.IsForeign && m.AvatarId == form.AvatarId);
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(group, token);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(LeaveGroup)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> ApproveMember(string rootUserId, GroupMembershipForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var group = await ManagedGroup(rootUserId, form.AvatarId, form.GroupId, token);
if (group == default)
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
var follower = await _dbEntities.Followers
.Match(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == form.MemberActorURI)
.ExecuteFirstAsync(token);
if (follower == default)
return result.Invalidate(_localizer["Request not found."], StatusCodes.Status404NotFound);
follower.IsAccepted = true;
await DB.Default.SaveAsync(follower, token);
if (!group.Members.Any(m => m.IsForeign && m.AvatarId == follower.ActorURI))
{
group.Members.Add(new GroupMember { AvatarId = follower.ActorURI, IsForeign = true });
await DB.Default.SaveAsync(group, token);
}
var actor = _localActors.FromGroup(group);
var follow = new System.Text.Json.Nodes.JsonObject
{
["id"] = follower.FollowActivityURI,
["type"] = "Follow",
["actor"] = follower.ActorURI,
["object"] = actor.Uri
};
var accept = ActivityPubRenderer.Accept(actor, follow, $"accept-{follower.ID}-{DateTime.UtcNow.Ticks}");
await _delivery.Enqueue(actor, new[] { follower.InboxURL }, accept, token);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(ApproveMember)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> GetInvitation(string invitationCode, string invitationPassword, CancellationToken token)
{
var result = new WebResult();
var group = string.IsNullOrEmpty(invitationCode)
? default
: await _dbEntities.Groups.Match(g => g.InvitationCode == invitationCode && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (group == default)
return result.Invalidate(_localizer["Invalid invitation."], StatusCodes.Status404NotFound);
if (!string.IsNullOrEmpty(group.HashedInvitationPassword)
&& (string.IsNullOrEmpty(invitationPassword) || !_passwordHasher.Check(group.HashedInvitationPassword, invitationPassword).verified))
return result.Invalidate(_localizer["Invalid password."], StatusCodes.Status406NotAcceptable);
result.Data = group;
return result;
}
async Task<GroupEntity> ManagedGroup(string rootUserId, string avatarId, string groupId, CancellationToken token)
{
if (!await OwnsAvatar(rootUserId, avatarId, token))
return default;
var group = await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
if (group == default || group.DeletionAt.HasValue)
return default;
var role = group.Members.FirstOrDefault(m => !m.IsForeign && m.AvatarId == avatarId)?.Role;
return role is GroupRole.Owner or GroupRole.Moderator ? group : default;
}
async Task<bool> OwnsAvatar(string rootUserId, string avatarId, CancellationToken token) =>
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
static string NewInvitationCode() => $"{Guid.NewGuid():N}{Guid.NewGuid():N}";
ViewGroup ToView(GroupEntity group, string avatarId)
{
var actor = _localActors.FromGroup(group);
var isManager = group.Members.Any(m => !m.IsForeign && m.AvatarId == avatarId && m.Role is GroupRole.Owner or GroupRole.Moderator);
return new ViewGroup
{
Id = group.ID,
UserName = group.UserName,
Name = group.Name,
Description = group.Description,
Url = actor.Uri,
Handle = actor.Handle,
IsDiscoverable = group.IsDiscoverable,
ManuallyApprovesMembers = group.ManuallyApprovesMembers,
IsOwner = group.OwnerAvatarId == avatarId,
InvitationCode = isManager ? group.InvitationCode : default,
IsPasswordRequired = !string.IsNullOrEmpty(group.HashedInvitationPassword),
MembersCount = group.Members.Count,
CreationDate = group.CreationDate
};
}
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
#pragma warning disable 8625
#pragma warning disable 8625
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
@@ -23,7 +23,7 @@ namespace PrivaPub.Services
Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default);
Task<WebResult> UpdateUserSettingsAsync(ViewAvatarServer userSettings, string userId);
Task<WebResult> UpdateUserSettingsAsync(ViewUserSettings userSettings, string userId);
Task<WebResult> SetupAndSendRecoveryEmail(PasswordRecoveryForm passwordRecoveryForm, string host);
+366
View File
@@ -0,0 +1,366 @@
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Resources;
using PrivaPub.Services.Federation;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using DmPostEntity = PrivaPub.Models.Post.DmPost;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Services
{
public interface IPostsService
{
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
}
public class PostsService : IPostsService
{
const int PageSize = 50;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PostsService> _logger;
public PostsService(DbEntities dbEntities,
ILocalActorService localActors,
IRemoteActorService remoteActors,
IDeliveryService delivery,
IStringLocalizer<GenericRes> localizer,
ILogger<PostsService> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (author == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
LocalActor group = default;
if (!string.IsNullOrEmpty(form.GroupId))
{
var groupEntity = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token);
if (groupEntity == default || groupEntity.DeletionAt.HasValue
|| !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == form.AvatarId))
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
group = _localActors.FromGroup(groupEntity);
}
var post = new PostEntity
{
GroupUserId = author.Id,
GroupId = group?.Id,
Title = form.Title,
Text = form.Text,
HasContentWarning = form.HasContentWarning,
AnsweringToPostId = form.AnsweringToPostId,
ActorURI = author.Uri
};
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
await DB.Default.SaveAsync(post, token);
var inReplyTo = await ReplyTarget(form.AnsweringToPostId, token);
var note = ActivityPubRenderer.Note(post, author, group, inReplyTo);
var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
await _delivery.EnqueueToFollowers(author, create, token);
if (group != default)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
result.Data = ToView(post);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertPost)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (author == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var post = await _dbEntities.Posts
.Match(p => p.ID == form.PostId && p.GroupUserId == author.Id && !p.IsFederatedCopy)
.ExecuteFirstAsync(token);
if (post == default)
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
await DB.Default.DeleteAsync<PostEntity>(post.ID);
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
var extraInboxes = Enumerable.Empty<string>();
if (!string.IsNullOrEmpty(post.GroupId))
{
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group != default)
extraInboxes = await _delivery.FollowerInboxes(group, token);
}
await _delivery.EnqueueToFollowers(author, delete, token, extraInboxes);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(DeletePost)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token)
{
var result = new WebResult();
try
{
var avatar = await OwnedAvatar(rootUserId, avatarId, token);
if (avatar == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var query = _dbEntities.Posts;
if (string.IsNullOrEmpty(groupId))
query.Match(p => p.GroupUserId == avatar.Id);
else
{
var isMember = await _dbEntities.Groups
.Match(g => g.ID == groupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id))
.ExecuteAnyAsync(token);
if (!isMember)
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
query.Match(p => p.GroupId == groupId);
}
var posts = await query.Sort(p => p.CreationDate, Order.Descending).Limit(PageSize).ExecuteAsync(token);
result.Data = posts.Select(ToView).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetPosts)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (author == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
DmGroup dmGroup;
if (!string.IsNullOrEmpty(form.DmGroupId))
{
dmGroup = await _dbEntities.DmGroups.MatchID(form.DmGroupId).ExecuteFirstAsync(token);
if (dmGroup == default || !dmGroup.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id))
return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound);
}
else
{
if (form.Recipients.Count == 0)
return result.Invalidate(_localizer["At least one recipient is required."]);
var members = new List<GroupMember> { new() { AvatarId = author.Id } };
foreach (var recipient in form.Recipients.Distinct(StringComparer.OrdinalIgnoreCase))
{
var member = await ResolveRecipient(recipient, token);
if (member == default)
return result.Invalidate(_localizer["Recipient '{0}' not found.", recipient], StatusCodes.Status404NotFound);
if (members.All(m => m.AvatarId != member.AvatarId))
members.Add(member);
}
dmGroup = new DmGroup { Members = members };
dmGroup.ID = (string)dmGroup.GenerateNewID();
dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}";
await DB.Default.SaveAsync(dmGroup, token);
}
var dm = new DmPostEntity
{
GroupUserId = author.Id,
GroupId = dmGroup.ID,
Text = form.Text,
HasContentWarning = form.HasContentWarning,
ActorURI = author.Uri
};
dm.ID = (string)dm.GenerateNewID();
dm.ObjectURI = author.PostUri(dm.ID);
await DB.Default.SaveAsync(dm, token);
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
var remote = new List<(string Uri, string Handle)>();
var inboxes = new List<string>();
foreach (var member in dmGroup.Members.Where(m => m.IsForeign))
{
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == member.AvatarId).ExecuteFirstAsync(token);
if (foreign == default)
continue;
remote.Add((foreign.ActorURI, $"{foreign.UserName}@{foreign.Domain}"));
inboxes.Add(foreign.InboxURL);
}
foreach (var member in dmGroup.Members.Where(m => !m.IsForeign && m.AvatarId != author.Id))
{
var local = await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token);
if (local != default)
remote.Add((local.Uri, local.Handle));
}
if (inboxes.Count > 0)
{
var note = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI);
var create = ActivityPubRenderer.Create(author, note, $"create-{dm.ID}");
await _delivery.Enqueue(author, inboxes, create, token);
}
result.Data = ToView(dm);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertDm)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
public async Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token)
{
var result = new WebResult();
try
{
var avatar = await OwnedAvatar(rootUserId, avatarId, token);
if (avatar == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
if (string.IsNullOrEmpty(dmGroupId))
{
var groups = await _dbEntities.DmGroups
.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id))
.Sort(g => g.UpdatedAt, Order.Descending)
.ExecuteAsync(token);
result.Data = groups.Select(g => new ViewDmGroup
{
Id = g.ID,
Members = g.Members.Select(m => m.AvatarId).ToList(),
UpdatedAt = g.UpdatedAt
}).ToList();
return result;
}
var isMember = await _dbEntities.DmGroups
.Match(g => g.ID == dmGroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id))
.ExecuteAnyAsync(token);
if (!isMember)
return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound);
var dms = await _dbEntities.DmPosts
.Match(p => p.GroupId == dmGroupId)
.Sort(p => p.CreationDate, Order.Descending)
.Limit(PageSize)
.ExecuteAsync(token);
result.Data = dms.Select(ToView).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetDms)}");
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
}
}
async Task<GroupMember> ResolveRecipient(string recipient, CancellationToken token)
{
var handle = recipient.Trim().TrimStart('@');
if (!handle.Contains('@') || handle.EndsWith("@" + new Uri(_localActors.BaseAddress).Authority, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(handle.Split('@')[0], token);
return local is { Kind: LocalActorKind.Person } ? new GroupMember { AvatarId = local.Id } : default;
}
var actorUri = await _remoteActors.ResolveHandle(handle, token);
if (actorUri == default)
return default;
var foreign = await _remoteActors.GetActor(actorUri, await _localActors.GetInstanceActor(token), refresh: false, token);
return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
}
async Task<string> ReplyTarget(string answeringToPostId, CancellationToken token)
{
if (string.IsNullOrEmpty(answeringToPostId))
return default;
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return answeringToPostId;
var parent = await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token);
return parent?.ObjectURI;
}
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId))
return default;
if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token))
return default;
return await _localActors.FindById(LocalActorKind.Person, avatarId, token);
}
static ViewPost ToView(PostEntity post) => new()
{
Id = post.ID,
ObjectURI = post.ObjectURI,
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
AuthorActorURI = post.ActorURI,
GroupId = post.GroupId,
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,
HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate
};
static ViewPost ToView(DmPostEntity post) => new()
{
Id = post.ID,
ObjectURI = post.ObjectURI,
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
AuthorActorURI = post.ActorURI,
DmGroupId = post.GroupId,
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,
HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate
};
}
}
+58 -61
View File
@@ -1,4 +1,4 @@
using MailKit.Net.Smtp;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Localization;
@@ -72,12 +72,10 @@ namespace PrivaPub.Services
newUser.Policies.Add(Policies.IsModerator);
}
await newUser.SaveAsync();
var cultureLanguage = CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
var language = await DbEntities.Languages.Match(l => l.International2Code == cultureLanguage).ExecuteFirstAsync();
var newRootUserSettings = new RootUserSettings
newUser.Settings = new RootUserSettings
{
RootUserId = newUser.ID,
LanguageCode = language?.International2Code ?? "en",
LightThemeIndexColour = signUpForm.LightThemeIndexColour,
DarkThemeIndexColour = signUpForm.DarkThemeIndexColour,
@@ -87,7 +85,7 @@ namespace PrivaPub.Services
PreferSystemTheming = signUpForm.PreferSystemTheming,
ThemeIsDarkMode = signUpForm.ThemeIsDarkMode
};
await newRootUserSettings.SaveAsync();
await DB.Default.SaveAsync(newUser);
//if (!string.IsNullOrEmpty(invitationCode))
//{
@@ -106,17 +104,7 @@ namespace PrivaPub.Services
// return result;
//}
result.Data = (newUser, new ViewAvatarServer
{
LanguageCode = newRootUserSettings.LanguageCode,
LightThemeIndexColour = newRootUserSettings.LightThemeIndexColour,
ThemeIsDarkMode = newRootUserSettings.ThemeIsDarkMode,
IconsThemeIndexColour = newRootUserSettings.IconsThemeIndexColour,
ThemeIsDarkGray = newRootUserSettings.ThemeIsDarkGray,
ThemeIsLightGray = newRootUserSettings.ThemeIsLightGray,
DarkThemeIndexColour = newRootUserSettings.DarkThemeIndexColour,
PreferSystemTheming = newRootUserSettings.PreferSystemTheming
});
result.Data = (newUser, ToViewSettings(newUser.Settings));
return result;
}
@@ -134,7 +122,7 @@ namespace PrivaPub.Services
try
{
loginForm.UserName = loginForm.UserName.ToLower();
if (!await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletionDate == null)
if (!await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletedAt == null)
.ExecuteAnyAsync())
return result.Invalidate(Localizer["Username '{0}' not found.", loginForm.UserName]);
@@ -151,7 +139,7 @@ namespace PrivaPub.Services
result.ErrorMessage = Localizer["Needs upgrade!"];
var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm);
var userSettings = (ViewAvatarServer)userSettingsResult.Data;
var userSettings = (ViewUserSettings)userSettingsResult.Data;
//if (!string.IsNullOrEmpty(invitationCode))
//{
@@ -194,7 +182,7 @@ namespace PrivaPub.Services
return result.Invalidate(Localizer["Email '{0}' already taken.", userForm.Email]);
}
await DB.Update<RootUser>()
await DB.Default.Update<RootUser>()
.Match(u => u.ID == userId)
.Modify(u => u.Email, userForm.Email)
.ExecuteAsync();
@@ -208,7 +196,7 @@ namespace PrivaPub.Services
}
}
public async Task<WebResult> UpdateUserSettingsAsync(ViewAvatarServer userSettings, string userId)
public async Task<WebResult> UpdateUserSettingsAsync(ViewUserSettings userSettings, string userId)
{
var result = new WebResult();
try
@@ -224,16 +212,17 @@ namespace PrivaPub.Services
var language = await DbEntities.Languages.Match(l => l.International2Code == userSettings.LanguageCode)
.ExecuteFirstAsync();
_ = await DB.Update<RootUserSettings>()
.Match(u => u.RootUserId == userId)
.Modify(us => us.LanguageCode, language.International2Code)
.Modify(us => us.LightThemeIndexColour, userSettings.LightThemeIndexColour)
.Modify(us => us.DarkThemeIndexColour, userSettings.DarkThemeIndexColour)
.Modify(us => us.PreferSystemTheming, userSettings.PreferSystemTheming)
.Modify(us => us.ThemeIsDarkMode, userSettings.ThemeIsDarkMode)
.Modify(us => us.ThemeIsDarkGray, userSettings.ThemeIsDarkGray)
.Modify(us => us.ThemeIsLightGray, userSettings.ThemeIsLightGray)
.Modify(us => us.IconsThemeIndexColour, userSettings.IconsThemeIndexColour)
_ = await DB.Default.Update<RootUser>()
.MatchID(userId)
.Modify(u => u.Settings.LanguageCode, language.International2Code)
.Modify(u => u.Settings.LightThemeIndexColour, userSettings.LightThemeIndexColour)
.Modify(u => u.Settings.DarkThemeIndexColour, userSettings.DarkThemeIndexColour)
.Modify(u => u.Settings.PreferSystemTheming, userSettings.PreferSystemTheming)
.Modify(u => u.Settings.ThemeIsDarkMode, userSettings.ThemeIsDarkMode)
.Modify(u => u.Settings.ThemeIsDarkGray, userSettings.ThemeIsDarkGray)
.Modify(u => u.Settings.ThemeIsLightGray, userSettings.ThemeIsLightGray)
.Modify(u => u.Settings.IconsThemeIndexColour, userSettings.IconsThemeIndexColour)
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
return result;
@@ -250,7 +239,7 @@ namespace PrivaPub.Services
var result = new WebResult();
try
{
var user = await DbEntities.RootUsers.Match(u => u.ID == userId && u.DeletionDate == null).ExecuteFirstAsync();
var user = await DbEntities.RootUsers.Match(u => u.ID == userId && u.DeletedAt == null).ExecuteFirstAsync();
if (user == null)
return result.Invalidate(Localizer["Username '{0}' not found.", userId]);
@@ -262,7 +251,8 @@ namespace PrivaPub.Services
var newPasswordHashed = PasswordHasher.Hash(userPasswordForm.NewPassword);
user.HashedPassword = newPasswordHashed;
await user.SaveAsync();
user.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(user);
return result;
}
@@ -278,7 +268,7 @@ namespace PrivaPub.Services
var result = new WebResult();
try
{
var users = await DbEntities.RootUsers.Match(u => usersIds.UserIdList.Contains(u.ID) && u.DeletionDate == default).ExecuteAsync();
var users = await DbEntities.RootUsers.Match(u => usersIds.UserIdList.Contains(u.ID) && u.DeletedAt == default).ExecuteAsync();
if (users == null || users.Count == 0)
return result.Invalidate(Localizer["User already deleted."]);
@@ -291,9 +281,9 @@ namespace PrivaPub.Services
user.IsEmailValidated = false;
//user.TempSecret = null;
user.UserName = Localizer["Deleted user"];
user.DeletionDate = DateTime.UtcNow;
user.DeletedAt = DateTime.UtcNow;
await user.SaveAsync();
await DB.Default.SaveAsync(user);
}
return result;
@@ -310,7 +300,7 @@ namespace PrivaPub.Services
var result = new WebResult();
try
{
await DB.Update<RootUser>()
await DB.Default.Update<RootUser>()
.Match(u => usersIds.UserIdList.Contains(u.ID))
.Modify(u => u.IsBanned, true)
.ExecuteAsync();
@@ -329,7 +319,7 @@ namespace PrivaPub.Services
var result = new WebResult();
try
{
await DB.Update<RootUser>()
await DB.Default.Update<RootUser>()
.Match(u => usersIds.UserIdList.Contains(u.ID))
.Modify(u => u.IsBanned, false)
.ExecuteAsync();
@@ -348,24 +338,20 @@ namespace PrivaPub.Services
var result = new WebResult();
try
{
var userSettings = await DbEntities.RootUsersSettings.Match(u => u.RootUserId == userId).ExecuteFirstAsync();
if (loginForm != default && (loginForm.ThemeIsDarkMode != userSettings.ThemeIsDarkMode))
var user = await DbEntities.RootUsers.MatchID(userId).ExecuteFirstAsync();
if (user == default)
return result.Invalidate(Localizer["User not found."], StatusCodes.Status404NotFound);
if (loginForm != default && loginForm.ThemeIsDarkMode != user.Settings.ThemeIsDarkMode)
{
userSettings.ThemeIsDarkMode = loginForm.ThemeIsDarkMode;
await userSettings.SaveAsync();
user.Settings.ThemeIsDarkMode = loginForm.ThemeIsDarkMode;
await DB.Default.Update<RootUser>()
.MatchID(userId)
.Modify(u => u.Settings.ThemeIsDarkMode, loginForm.ThemeIsDarkMode)
.ExecuteAsync();
}
result.Data = new ViewAvatarServer
{
LanguageCode = userSettings.LanguageCode,
LightThemeIndexColour = userSettings.LightThemeIndexColour,
DarkThemeIndexColour = userSettings.DarkThemeIndexColour,
PreferSystemTheming = userSettings.PreferSystemTheming,
ThemeIsDarkMode = userSettings.ThemeIsDarkMode,
IconsThemeIndexColour = userSettings.IconsThemeIndexColour,
ThemeIsDarkGray = userSettings.ThemeIsDarkGray,
ThemeIsLightGray = userSettings.ThemeIsLightGray
};
result.Data = ToViewSettings(user.Settings);
return result;
}
catch (Exception ex)
@@ -383,7 +369,7 @@ namespace PrivaPub.Services
var usernameExists = false;
if (passwordRecoveryForm.IsEmailDisabled)
{
if (!await DbEntities.RootUsers.Match(u => u.UserName == passwordRecoveryForm.UserName && u.DeletionDate == null)
if (!await DbEntities.RootUsers.Match(u => u.UserName == passwordRecoveryForm.UserName && u.DeletedAt == null)
.ExecuteAnyAsync())
return result.Invalidate(Localizer["Username '{0}' not found.", passwordRecoveryForm.UserName],
StatusCodes.Status404NotFound);
@@ -391,7 +377,7 @@ namespace PrivaPub.Services
}
else
{
if (!await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletionDate == null)
if (!await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletedAt == null)
.ExecuteAnyAsync())
return result.Invalidate(Localizer["Username '{0}' not found.", passwordRecoveryForm.UserName],
StatusCodes.Status404NotFound);
@@ -416,12 +402,12 @@ namespace PrivaPub.Services
{
RootUserId = user.ID
};
await emailRecovery.SaveAsync();
await DB.Default.SaveAsync(emailRecovery);
}
var recoveryCodeGenerator = new Password(true, true, true, false, 127);
using var recoveryCodeGenerator = new Password(true, true, true, false, 127);
emailRecovery.RecoveryCode = recoveryCodeGenerator.Next();
await emailRecovery.SaveAsync();
await DB.Default.SaveAsync(emailRecovery);
using (var smtpClient = new SmtpClient())
{
@@ -557,12 +543,12 @@ Eugene from collAnon, following is the password recovery link:
var newHashedPassword = PasswordHasher.Hash(newPasswordForm.NewPassword);
_ = await DB.Update<RootUser>()
.Match(u => u.ID == emailRecovery.RootUserId && u.DeletionDate == null)
_ = await DB.Default.Update<RootUser>()
.Match(u => u.ID == emailRecovery.RootUserId && u.DeletedAt == null)
.Modify(u => u.HashedPassword, newHashedPassword)
.ExecuteAsync();
_ = await DB.DeleteAsync<EmailRecovery>(er => er.RecoveryCode == newPasswordForm.RecoveryCode);
_ = await DB.Default.DeleteAsync<EmailRecovery>(er => er.RecoveryCode == newPasswordForm.RecoveryCode);
return result;
}
@@ -573,5 +559,16 @@ Eugene from collAnon, following is the password recovery link:
}
}
static ViewUserSettings ToViewSettings(RootUserSettings settings) => new()
{
LanguageCode = settings.LanguageCode,
LightThemeIndexColour = settings.LightThemeIndexColour,
DarkThemeIndexColour = settings.DarkThemeIndexColour,
PreferSystemTheming = settings.PreferSystemTheming,
ThemeIsDarkMode = settings.ThemeIsDarkMode,
IconsThemeIndexColour = settings.IconsThemeIndexColour,
ThemeIsDarkGray = settings.ThemeIsDarkGray,
ThemeIsLightGray = settings.ThemeIsLightGray
};
}
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Tokens;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
@@ -19,7 +19,7 @@ namespace PrivaPub.StaticServices
Configuration = configuration;
}
public JwtUser GenerateToken(RootUser user, ViewAvatarServer userSettings)
public JwtUser GenerateToken(RootUser user, ViewUserSettings userSettings)
{
var expiration = DateTime.UtcNow.AddHours(int.Parse(Configuration["AppConfiguration:Jwt:HoursTimeout"]));
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["AppConfiguration:Jwt:Key"]));
+20 -11
View File
@@ -1,7 +1,9 @@
using MongoDB.Entities;
using MongoDB.Entities;
using PrivaPub.Models;
using PrivaPub.Models.Data;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
@@ -9,19 +11,26 @@ namespace PrivaPub.StaticServices
{
public class DbEntities
{
public Find<RootUser> RootUsers { get { return DB.Find<RootUser>(); } }
public Find<RootUserNote> RootUserNotes { get { return DB.Find<RootUserNote>(); } }
public Find<RootUser> RootUsers { get { return DB.Default.Find<RootUser>(); } }
public Find<RootUserNote> RootUserNotes { get { return DB.Default.Find<RootUserNote>(); } }
public Find<AppConfiguration> AppConfiguration { get { return DB.Find<AppConfiguration>(); } }
public Find<Language> Languages { get { return DB.Find<Language>(); } }
public Find<EmailRecovery> EmailRecoveries { get { return DB.Find<EmailRecovery>(); } }
public Find<AppConfiguration> AppConfiguration { get { return DB.Default.Find<AppConfiguration>(); } }
public Find<Language> Languages { get { return DB.Default.Find<Language>(); } }
public Find<EmailRecovery> EmailRecoveries { get { return DB.Default.Find<EmailRecovery>(); } }
public Find<Post> Posts { get { return DB.Find<Post>(); } }
public Find<DmPost> DmPosts { get { return DB.Find<DmPost>(); } }
public Find<Post> Posts { get { return DB.Default.Find<Post>(); } }
public Find<DmPost> DmPosts { get { return DB.Default.Find<DmPost>(); } }
public Find<Avatar> Avatars { get { return DB.Find<Avatar>(); } }
public Find<ForeignAvatar> ForeignAvatars { get { return DB.Find<ForeignAvatar>(); } }
public Find<Avatar> Avatars { get { return DB.Default.Find<Avatar>(); } }
public Find<ForeignAvatar> ForeignAvatars { get { return DB.Default.Find<ForeignAvatar>(); } }
public Find<RootToAvatar> RootToAvatars { get { return DB.Find<RootToAvatar>(); } }
public Find<RootToAvatar> RootToAvatars { get { return DB.Default.Find<RootToAvatar>(); } }
public Find<Group> Groups { get { return DB.Default.Find<Group>(); } }
public Find<DmGroup> DmGroups { get { return DB.Default.Find<DmGroup>(); } }
public Find<Follower> Followers { get { return DB.Default.Find<Follower>(); } }
public Find<Delivery> Deliveries { get { return DB.Default.Find<Delivery>(); } }
public Find<InstanceActor> InstanceActors { get { return DB.Default.Find<InstanceActor>(); } }
}
}
+2 -2
View File
@@ -48,7 +48,7 @@
"Iterations": 10101
},
"Jwt": {
"Key": "ITNN8mPfS2ivOqr1eRWK0Rac3sRAchQdG8BUy0pK4vQ3",
"Key": "bbLOwQyh0jYtJ8Dn6WKCneydLJkqeElqUM03Qwtr7pEMyq/qCZD6v/Sh5Kwrq6YpvJm8GSTHfBiCajUmicfgKw==",
"Issuer": "https://localhost",
"Audience": "https://localhost",
"HoursTimeout": 24
@@ -77,7 +77,7 @@
},
"WriteTo": [
{
"Name": "MongoDB",
"Name": "MongoDBCapped",
"Args": {
"databaseUrl": "mongodb://localhost:27017/logs",
"collectionName": "collanonquick",
+9 -7
View File
@@ -2,10 +2,12 @@
"MongoSettings": {
"Database": "PrivaPub",
"LogsDatabase": "logs",
"ConnectionString": "mongodb://localhost:27017"
"ConnectionString": "mongodb://127.0.0.1:27022"
},
"AppConfiguration": {
"Version": "0.0.0",
"BackendBaseAddress": "https://privapub.thepra.dev",
"FrontendBaseAddress": "https://decepub.thepra.dev",
"MaxAllowedUploadFiles": 3,
"MaxAllowedFileSize": 2097152,
"ValidDiscussionFilesTypes": ".odt,.docx,.pdf,.xlsx,.ods,.odp,.pptx,.png,.jpg,.jpeg",
@@ -23,9 +25,9 @@
"Iterations": 10101
},
"Jwt": {
"Key": "ITNN8mPfS2ivOqr1eRWK0Rac3sRAchQdG8BUy0pK4vQ3",
"Issuer": "https://localhost",
"Audience": "https://localhost",
"Key": "FVkEHw8t84HVXE/+3fyY6gH1AJIlLVNE9UqOzFNBdRKsdFuKrCFBvys0IQfZu+d2Qgg6KtslgntiedPMO98iKg==",
"Issuer": "https://privapub.thepra.dev",
"Audience": "https://privapub.thepra.dev",
"HoursTimeout": 365
}
},
@@ -44,9 +46,9 @@
},
"WriteTo": [
{
"Name": "MongoDB",
"Name": "MongoDBCapped",
"Args": {
"databaseUrl": "mongodb://localhost:27017/logs",
"databaseUrl": "mongodb://127.0.0.1:27022/logs",
"collectionName": "PrivaPub",
"cappedMaxSizeMb": "1024",
"cappedMaxDocuments": "10000"
@@ -61,5 +63,5 @@
}
]
},
"AllowedHosts": "*"
"AllowedHosts": "privapub.thepra.dev;localhost;127.0.0.1"
}