net10, the refactor finished, and federation that works
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:
1 parent
56c5396106
commit
075c22228a
78 files changed
+3419
-1091
No files matched your search
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user