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
+3412
-1084
No files matched your search
@@ -0,0 +1,16 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build
|
||||
run: dotnet build PrivaPub.sln -c Release
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
UNIT: privapub
|
||||
WEB_ROOT: /var/www/privapub.thepra.dev
|
||||
BACKUPS: /var/backups/privapub.thepra.dev
|
||||
LOCAL_URL: http://127.0.0.1:6970
|
||||
PUBLIC_URL: https://privapub.thepra.dev
|
||||
|
||||
jobs:
|
||||
site:
|
||||
name: privapub.thepra.dev
|
||||
runs-on: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve the build identity
|
||||
run: |
|
||||
echo "BUILD_COMMIT=$(echo "$GITHUB_SHA" | cut -c1-8)" >> "$GITHUB_ENV"
|
||||
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
|
||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
rm -rf "$GITHUB_WORKSPACE/publish"
|
||||
dotnet publish PrivaPub/PrivaPub.csproj -c Release -r linux-x64 --self-contained true \
|
||||
-o "$GITHUB_WORKSPACE/publish" \
|
||||
-p:BuildCommit="$BUILD_COMMIT" -p:BuildRef="$BUILD_REF" -p:BuildTimeUtc="$BUILD_TIME"
|
||||
|
||||
- name: Assert the publish actually produced a build
|
||||
run: |
|
||||
P="$GITHUB_WORKSPACE/publish"
|
||||
for f in PrivaPub PrivaPub.dll PrivaPub.ClientModels.dll appsettings.Production.json Data/languagesNative.json; do
|
||||
[ -e "$P/$f" ] || { echo "::error::publish output is missing $f"; exit 1; }
|
||||
done
|
||||
grep -q '"includedFrameworks"' "$P/PrivaPub.runtimeconfig.json" \
|
||||
|| { echo "::error::publish is not self-contained"; exit 1; }
|
||||
age=$(( $(date +%s) - $(stat -c %Y "$P/PrivaPub.dll") ))
|
||||
[ "$age" -lt 3600 ] || { echo "::error::PrivaPub.dll is ${age}s old - the publish reused a stale artifact"; exit 1; }
|
||||
echo "publish OK, $(du -sh "$P" | cut -f1)"
|
||||
|
||||
- name: Snapshot the live directory
|
||||
run: |
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||
rsync -a "$WEB_ROOT/" "$BACKUPS/site-$STAMP/"
|
||||
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
|
||||
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
|
||||
|
||||
- name: Stop, sync, start
|
||||
run: |
|
||||
sudo systemctl stop "$UNIT"
|
||||
rsync -a --delete --exclude 'appsettings.Development.json' "$GITHUB_WORKSPACE/publish/" "$WEB_ROOT/"
|
||||
chgrp www-data "$WEB_ROOT/appsettings.Production.json"
|
||||
chmod 640 "$WEB_ROOT/appsettings.Production.json"
|
||||
chmod +x "$WEB_ROOT/PrivaPub"
|
||||
sudo systemctl start "$UNIT"
|
||||
|
||||
- name: Health check, roll back on failure
|
||||
run: |
|
||||
ok=0
|
||||
for i in $(seq 1 40); do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$LOCAL_URL/build.json" || true)
|
||||
if [ "$code" = "200" ]; then ok=1; break; fi
|
||||
sleep 3
|
||||
done
|
||||
if [ "$ok" != "1" ]; then
|
||||
echo "::error::the site did not come back healthy - rolling back to $SNAPSHOT"
|
||||
sudo systemctl stop "$UNIT"
|
||||
rsync -a --delete "$SNAPSHOT/" "$WEB_ROOT/"
|
||||
sudo systemctl start "$UNIT" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify what is being served
|
||||
run: |
|
||||
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))")
|
||||
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub")
|
||||
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
|
||||
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
|
||||
echo "::notice::serving $served"
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<BuildCommit Condition="'$(BuildCommit)' == ''">local</BuildCommit>
|
||||
<BuildRef Condition="'$(BuildRef)' == ''">local</BuildRef>
|
||||
<BuildTimeUtc Condition="'$(BuildTimeUtc)' == ''"></BuildTimeUtc>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
using PrivaPub.ClientModels.ValidatorAttributes;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Group
|
||||
{
|
||||
public class InsertGroupForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
NoWhiteSpaces]
|
||||
public string UserName { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Name { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Description { get; set; }
|
||||
|
||||
public string InvitationPassword { get; set; }
|
||||
public bool IsDiscoverable { get; set; } = true;
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Group
|
||||
{
|
||||
public class JoinGroupForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength,
|
||||
ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string InvitationCode { get; set; }
|
||||
|
||||
public string InvitationPassword { get; set; }
|
||||
}
|
||||
|
||||
public class GroupMembershipForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string GroupId { get; set; }
|
||||
public string MemberActorURI { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Group
|
||||
{
|
||||
public class UpdateGroupForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string GroupId { get; set; }
|
||||
|
||||
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Name { get; set; }
|
||||
[StringLength(2000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Description { get; set; }
|
||||
|
||||
public string InvitationPassword { get; set; }
|
||||
public bool RemoveInvitationPassword { get; set; }
|
||||
public bool RegenerateInvitationCode { get; set; }
|
||||
public bool? IsDiscoverable { get; set; }
|
||||
public bool? ManuallyApprovesMembers { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace PrivaPub.ClientModels.Group
|
||||
{
|
||||
public class ViewGroup
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Handle { get; set; }
|
||||
public bool IsDiscoverable { get; set; }
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
public bool IsOwner { get; set; }
|
||||
public string InvitationCode { get; set; }
|
||||
public bool IsPasswordRequired { get; set; }
|
||||
public int MembersCount { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels
|
||||
{
|
||||
public class InvitationLoginForm : LoginForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength,
|
||||
ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string InvitationCode { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Post
|
||||
{
|
||||
public class InsertPostForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(5000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Text { get; set; }
|
||||
|
||||
[StringLength(200, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Title { get; set; }
|
||||
|
||||
public string GroupId { get; set; }
|
||||
public string AnsweringToPostId { get; set; }
|
||||
public bool HasContentWarning { get; set; }
|
||||
}
|
||||
|
||||
public class InsertDmForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(5000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Text { get; set; }
|
||||
|
||||
public string DmGroupId { get; set; }
|
||||
public List<string> Recipients { get; set; } = new();//local usernames or user@host handles
|
||||
public bool HasContentWarning { get; set; }
|
||||
}
|
||||
|
||||
public class DeletePostForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string PostId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace PrivaPub.ClientModels.Post
|
||||
{
|
||||
public class ViewPost
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string ObjectURI { get; set; }
|
||||
public string AuthorAvatarId { get; set; }
|
||||
public string AuthorActorURI { get; set; }
|
||||
public string GroupId { get; set; }
|
||||
public string DmGroupId { get; set; }
|
||||
public string AnsweringToPostId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Text { get; set; }
|
||||
public bool HasContentWarning { get; set; }
|
||||
public bool IsFederatedCopy { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
}
|
||||
|
||||
public class ViewDmGroup
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public List<string> Members { get; set; } = new();
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!--<Nullable>enable</Nullable>-->
|
||||
</PropertyGroup>
|
||||
|
||||
+25
-1
@@ -1,4 +1,4 @@
|
||||
//------------------------------------------------------------------------------
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
@@ -396,6 +396,30 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Name.
|
||||
/// </summary>
|
||||
public static string AvatarId {
|
||||
get {
|
||||
return ResourceManager.GetString("AvatarId", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string Biography {
|
||||
get {
|
||||
return ResourceManager.GetString("Biography", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string LanguageCode {
|
||||
get {
|
||||
return ResourceManager.GetString("LanguageCode", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string UserName {
|
||||
get {
|
||||
return ResourceManager.GetString("UserName", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string Name {
|
||||
get {
|
||||
return ResourceManager.GetString("Name", resourceCulture);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
@@ -315,4 +315,16 @@
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Soprannomi da ricercare e aggiungere</value>
|
||||
</data>
|
||||
<data name="AvatarId" xml:space="preserve">
|
||||
<value>Avatar</value>
|
||||
</data>
|
||||
<data name="Biography" xml:space="preserve">
|
||||
<value>Biografia</value>
|
||||
</data>
|
||||
<data name="LanguageCode" xml:space="preserve">
|
||||
<value>Lingua</value>
|
||||
</data>
|
||||
<data name="UserName" xml:space="preserve">
|
||||
<value>Nome utente</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
@@ -315,4 +315,16 @@
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Usernames to search and add</value>
|
||||
</data>
|
||||
<data name="AvatarId" xml:space="preserve">
|
||||
<value>Avatar</value>
|
||||
</data>
|
||||
<data name="Biography" xml:space="preserve">
|
||||
<value>Biography</value>
|
||||
</data>
|
||||
<data name="LanguageCode" xml:space="preserve">
|
||||
<value>Language</value>
|
||||
</data>
|
||||
<data name="UserName" xml:space="preserve">
|
||||
<value>Username</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.ClientModels.User.Avatar
|
||||
{
|
||||
[JsonSerializable(typeof(ViewAvatar))]
|
||||
public class ViewAvatar
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Handle { get; set; }
|
||||
public string Url { get; set; }//url
|
||||
public string Name { get; set; }//name
|
||||
public string UserName { get; set; }//preferredUsername
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace PrivaPub.ClientModels.User
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
public class JwtUser
|
||||
{
|
||||
@@ -8,6 +8,6 @@
|
||||
public List<string> Policies { get; set; } = new();
|
||||
public string Token { get; set; }
|
||||
public long Expiration { get; set; }
|
||||
public ViewAvatarServer UserSettings { get; set; } = new();
|
||||
public ViewUserSettings UserSettings { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
public class ViewUserSettings
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = nameof(LanguageCode), ResourceType = typeof(FieldsNameResource))]
|
||||
public string LanguageCode { get; set; } = "en";
|
||||
|
||||
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public short LightThemeIndexColour { get; set; } = 25;
|
||||
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public short DarkThemeIndexColour { get; set; } = 215;
|
||||
[Range(-2, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public short IconsThemeIndexColour { get; set; } = 25;
|
||||
|
||||
public bool PreferSystemTheming { get; set; } = false;
|
||||
public bool ThemeIsDarkMode { get; set; } = false;
|
||||
public bool ThemeIsLightGray { get; set; } = false;
|
||||
public bool ThemeIsDarkGray { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Group;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Services;
|
||||
|
||||
namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
[ApiController,
|
||||
Route("clientapi/group"),
|
||||
Authorize(Policy = Policies.IsUser)]
|
||||
public class GroupController : ControllerBase
|
||||
{
|
||||
readonly IGroupUsersService _groupUsersService;
|
||||
readonly IStringLocalizer _localizer;
|
||||
|
||||
public GroupController(IGroupUsersService groupUsersService, IStringLocalizer<GenericRes> localizer)
|
||||
{
|
||||
_groupUsersService = groupUsersService;
|
||||
_localizer = localizer;
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/group/list")]
|
||||
public async Task<IActionResult> List([FromQuery] string avatarId, CancellationToken token) =>
|
||||
Answer(await _groupUsersService.GetGroups(User.GetUserId(), avatarId, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/group/insert")]
|
||||
public async Task<IActionResult> Insert(InsertGroupForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.InsertGroup(User.GetUserId(), form, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/group/update")]
|
||||
public async Task<IActionResult> Update(UpdateGroupForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.UpdateGroup(User.GetUserId(), form, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/group/join")]
|
||||
public async Task<IActionResult> Join(JoinGroupForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.JoinGroup(User.GetUserId(), form, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/group/leave")]
|
||||
public async Task<IActionResult> Leave(GroupMembershipForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.LeaveGroup(User.GetUserId(), form, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/group/approve")]
|
||||
public async Task<IActionResult> Approve(GroupMembershipForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.ApproveMember(User.GetUserId(), form, token));
|
||||
|
||||
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
||||
|
||||
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Services;
|
||||
|
||||
namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
[ApiController,
|
||||
Route("clientapi/post"),
|
||||
Authorize(Policy = Policies.IsUser)]
|
||||
public class PostController : ControllerBase
|
||||
{
|
||||
readonly IPostsService _postsService;
|
||||
readonly IStringLocalizer _localizer;
|
||||
|
||||
public PostController(IPostsService postsService, IStringLocalizer<GenericRes> localizer)
|
||||
{
|
||||
_postsService = postsService;
|
||||
_localizer = localizer;
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/post/list")]
|
||||
public async Task<IActionResult> List([FromQuery] string avatarId, [FromQuery] string groupId, CancellationToken token) =>
|
||||
Answer(await _postsService.GetPosts(User.GetUserId(), avatarId, groupId, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/post/insert")]
|
||||
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/post/delete")]
|
||||
public async Task<IActionResult> Delete(DeletePostForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token));
|
||||
|
||||
[HttpGet, Route("/clientapi/dm/list")]
|
||||
public async Task<IActionResult> Dms([FromQuery] string avatarId, [FromQuery] string dmGroupId, CancellationToken token) =>
|
||||
Answer(await _postsService.GetDms(User.GetUserId(), avatarId, dmGroupId, token));
|
||||
|
||||
[HttpPost, Route("/clientapi/dm/insert")]
|
||||
public async Task<IActionResult> InsertDm(InsertDmForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertDm(User.GetUserId(), form, token));
|
||||
|
||||
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
||||
|
||||
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using PrivaPub.ClientModels;
|
||||
@@ -27,6 +27,13 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/avatar/private/list")]
|
||||
public async Task<IActionResult> GetAvatars(CancellationToken token)
|
||||
{
|
||||
var result = await _privateAvatarUsersService.GetRootAvatars(User.GetUserId(), token);
|
||||
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/avatar/private/insert")]
|
||||
public async Task<IActionResult> InsertAvatar(InsertAvatarForm model)
|
||||
{
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
using PrivaPub.ClientModels.Group;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.ClientModels.User.Avatar;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.Services.ClientToServer.Private;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
@@ -21,17 +26,26 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
public class RootUserController : ControllerBase
|
||||
{
|
||||
readonly IRootUsersService UsersService;
|
||||
readonly IGroupUsersService GroupUsersService;
|
||||
readonly IPrivateAvatarUsersService AvatarUsersService;
|
||||
readonly AuthTokenManager AuthTokenManager;
|
||||
readonly IOptionsMonitor<AppConfiguration> AppConfiguration;
|
||||
readonly ILogger<RootUserController> Logger;
|
||||
readonly IStringLocalizer Localizer;
|
||||
|
||||
public RootUserController(IRootUsersService usersService,
|
||||
IGroupUsersService groupUsersService,
|
||||
IPrivateAvatarUsersService avatarUsersService,
|
||||
AuthTokenManager authTokenManager,
|
||||
IOptionsMonitor<AppConfiguration> appConfiguration,
|
||||
IStringLocalizer<GenericRes> localizer,
|
||||
ILogger<RootUserController> logger)
|
||||
{
|
||||
UsersService = usersService;
|
||||
GroupUsersService = groupUsersService;
|
||||
AvatarUsersService = avatarUsersService;
|
||||
AuthTokenManager = authTokenManager;
|
||||
AppConfiguration = appConfiguration;
|
||||
Localizer = localizer;
|
||||
Logger = logger;
|
||||
}
|
||||
@@ -51,7 +65,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
(var user, var userSettings) = ((RootUser, ViewAvatarServer))result.Data;
|
||||
(var user, var userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
|
||||
Logger.LogInformation(
|
||||
$"{nameof(SignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
@@ -78,7 +92,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
var (user, userSettings) =
|
||||
((RootUser, ViewAvatarServer))result.Data;
|
||||
((RootUser, ViewUserSettings))result.Data;
|
||||
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
|
||||
Logger.LogInformation(
|
||||
$"{nameof(Login)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
@@ -92,99 +106,105 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
//[HttpPost, Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
||||
//public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm)
|
||||
//{
|
||||
// if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions");
|
||||
// var result = new WebResult();
|
||||
// if (!ModelState.IsValid)
|
||||
// return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
// try
|
||||
// {
|
||||
// result = await DiscussionsService.GetInvitationConfiguration(signUpForm.InvitationCode, includePassword: true);
|
||||
[HttpPost, Route("/clientapi/user/invitation/signup"), AllowAnonymous]
|
||||
public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
try
|
||||
{
|
||||
var invitation = await GroupUsersService.GetInvitation(signUpForm.InvitationCode, signUpForm.InvitationPassword, token);
|
||||
if (!invitation.IsValid)
|
||||
return StatusCode(invitation.StatusCode, invitation);
|
||||
|
||||
// if (!result.IsValid)
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
|
||||
// var configuration = result.Data as InvitationConfiguration;
|
||||
// if (configuration.IsPasswordRequired && signUpForm.InvitationPassword != configuration.Password)
|
||||
// {
|
||||
// result.Invalidate(Localizer["Invalid password."], StatusCodes.Status406NotAcceptable);
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
// }
|
||||
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
||||
{
|
||||
RootId = user.ID,
|
||||
Name = user.UserName,
|
||||
UserName = user.UserName,
|
||||
Biography = string.Empty
|
||||
});
|
||||
if (!avatar.IsValid)
|
||||
return StatusCode(avatar.StatusCode, avatar);
|
||||
|
||||
// result = await UsersService.SignUpAsync(new LoginForm
|
||||
// {
|
||||
// Username = signUpForm.Username,
|
||||
// Password = signUpForm.Password,
|
||||
// LightThemeIndexColour = signUpForm.ThemeIndexColour,
|
||||
// ThemeIsDarkMode = signUpForm.ThemeIsDarkMode,
|
||||
// InvitationPassword = signUpForm.InvitationPassword
|
||||
// }, signUpForm.InvitationCode, configuration.IsPasswordRequired);
|
||||
// if (!result.IsValid)
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
var joined = await GroupUsersService.JoinGroup(user.ID, new JoinGroupForm
|
||||
{
|
||||
AvatarId = ((ViewAvatar)avatar.Data).Id,
|
||||
InvitationCode = signUpForm.InvitationCode,
|
||||
InvitationPassword = signUpForm.InvitationPassword
|
||||
}, token);
|
||||
if (!joined.IsValid)
|
||||
return StatusCode(joined.StatusCode, joined);
|
||||
|
||||
// (var user, var userSettings) = ((User, ViewUserSettings))result.Data;
|
||||
// var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
|
||||
// Logger.LogInformation(
|
||||
// $"{nameof(InvitationSignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
Logger.LogInformation(
|
||||
$"{nameof(InvitationSignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
// return Ok(jwtUser);
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()");
|
||||
// return BadRequest(result.Invalidate(ex.Message));
|
||||
// }
|
||||
//}
|
||||
[HttpPost, Route("/clientapi/user/invitation/login"), AllowAnonymous]
|
||||
public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
try
|
||||
{
|
||||
var invitation = await GroupUsersService.GetInvitation(loginForm.InvitationCode, loginForm.InvitationPassword, token);
|
||||
if (!invitation.IsValid)
|
||||
return StatusCode(invitation.StatusCode, invitation);
|
||||
|
||||
//[HttpPost, Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
||||
//public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm)
|
||||
//{
|
||||
// if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions" + loginForm.InvitationCode);
|
||||
// var result = new WebResult();
|
||||
// if (!ModelState.IsValid)
|
||||
// return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
// try
|
||||
// {
|
||||
// result = await DiscussionsService.GetInvitationConfiguration(loginForm.InvitationCode, includePassword: true);
|
||||
result = await UsersService.LoginAsync(loginForm, loginForm.InvitationCode);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
|
||||
// if (!result.IsValid)
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
var avatars = await AvatarUsersService.GetRootAvatars(user.ID, token);
|
||||
var avatar = (avatars.Data as List<ViewAvatar>)?.FirstOrDefault();
|
||||
if (avatar == default)
|
||||
{
|
||||
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
||||
{
|
||||
RootId = user.ID,
|
||||
Name = user.UserName,
|
||||
UserName = user.UserName,
|
||||
Biography = string.Empty
|
||||
});
|
||||
if (!inserted.IsValid)
|
||||
return StatusCode(inserted.StatusCode, inserted);
|
||||
avatar = (ViewAvatar)inserted.Data;
|
||||
}
|
||||
|
||||
// var configuration = result.Data as InvitationConfiguration;
|
||||
// if (configuration.IsPasswordRequired && loginForm.InvitationPassword != configuration.Password)
|
||||
// {
|
||||
// result.Invalidate(Localizer["Invalid password."], StatusCodes.Status406NotAcceptable);
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
// }
|
||||
var joined = await GroupUsersService.JoinGroup(user.ID, new JoinGroupForm
|
||||
{
|
||||
AvatarId = avatar.Id,
|
||||
InvitationCode = loginForm.InvitationCode,
|
||||
InvitationPassword = loginForm.InvitationPassword
|
||||
}, token);
|
||||
if (!joined.IsValid)
|
||||
return StatusCode(joined.StatusCode, joined);
|
||||
|
||||
// result = await UsersService.LoginAsync(new LoginForm
|
||||
// {
|
||||
// Username = loginForm.Username,
|
||||
// Password = loginForm.Password,
|
||||
// ThemeIsDarkMode = loginForm.ThemeIsDarkMode,
|
||||
// LightThemeIndexColour = loginForm.ThemeIndexColour,
|
||||
// InvitationPassword = loginForm.InvitationPassword
|
||||
// }, loginForm.InvitationCode);
|
||||
|
||||
// if (!result.IsValid)
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
|
||||
// var (user, userSettings/*, userCurrentTier*/) = ((User, ViewUserSettings/*, UserCurrentTier*/))result.Data;
|
||||
// var jwtUser = AuthTokenManager.GenerateToken(user, userSettings, 0
|
||||
// /*userCurrentTier == null ? default : (userCurrentTier.EndPeriod - DateTime.UtcNow).Days*/);
|
||||
// Logger.LogInformation(
|
||||
// $"{nameof(InvitationLogin)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
|
||||
// return Ok(jwtUser);
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()");
|
||||
// return BadRequest(result.Invalidate(ex.Message));
|
||||
// }
|
||||
//}
|
||||
Logger.LogInformation(
|
||||
$"{nameof(InvitationLogin)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)]
|
||||
public IActionResult Logout()
|
||||
@@ -223,7 +243,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/update/settings"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> UpdateUserSettings(ViewAvatarServer userSettings)
|
||||
public async Task<IActionResult> UpdateUserSettings(ViewUserSettings userSettings)
|
||||
{
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
@@ -309,7 +329,9 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
try
|
||||
{
|
||||
var host = $"{Request.Scheme}://{Request.Host.Host}";
|
||||
var host = string.IsNullOrEmpty(AppConfiguration.CurrentValue.FrontendBaseAddress)
|
||||
? $"{Request.Scheme}://{Request.Host.Host}"
|
||||
: AppConfiguration.CurrentValue.FrontendBaseAddress.TrimEnd('/');
|
||||
result = await UsersService.SetupAndSendRecoveryEmail(passwordRecoveryForm, host);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
@@ -1,110 +1,154 @@
|
||||
using Markdig;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Models.ActivityPub;
|
||||
using PrivaPub.Services;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Services.Federation;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Controllers.ServerToServer
|
||||
{
|
||||
[ApiController,
|
||||
Route("peasants"), Produces("application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"; charset=utf-8")]
|
||||
[ApiController, Route("peasants")]
|
||||
public class PeasantsController : ControllerBase
|
||||
{
|
||||
readonly IGroupUsersService _groupUsersService;
|
||||
const string ActivityContentType = "application/activity+json; charset=utf-8";
|
||||
const int OutboxSize = 20;
|
||||
|
||||
public PeasantsController(IGroupUsersService groupUsersService)
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IInboxService _inbox;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILogger<PeasantsController> _logger;
|
||||
|
||||
public PeasantsController(ILocalActorService localActors, IInboxService inbox, DbEntities dbEntities,
|
||||
ILogger<PeasantsController> logger)
|
||||
{
|
||||
_groupUsersService = groupUsersService;
|
||||
_localActors = localActors;
|
||||
_inbox = inbox;
|
||||
_dbEntities = dbEntities;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}")]
|
||||
public async Task<IActionResult> GetActor(
|
||||
[Required(ErrorMessageResourceName = "Required",
|
||||
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor,
|
||||
CancellationToken cancellation)
|
||||
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
|
||||
{
|
||||
var getResult = await _groupUsersService.GetGroup(actor, cancellation);
|
||||
if (!getResult.IsValid)
|
||||
return StatusCode(getResult.StatusCode, getResult);
|
||||
|
||||
var actorResponse = new ActivityPubActor();
|
||||
actorResponse.Context[1] = string.Format(actorResponse.Context[1].ToString(), HttpContext.Request.PathBase);
|
||||
actorResponse.Id = HttpContext.GetHostWithPath();
|
||||
actorResponse.ProfileURL = HttpContext.GetHostWithPath();
|
||||
actorResponse.Inbox = $"{HttpContext.GetHostWithPath()}/mouth";
|
||||
actorResponse.Outbox = $"{HttpContext.GetHostWithPath()}/anus";
|
||||
actorResponse.Endpoints = new()
|
||||
{
|
||||
SharedInboxURL = $"{HttpContext.GetHostWithPath()}/human-centipede",
|
||||
OAuthAuthorizationEndpoint = $"{HttpContext.GetHost()}/sniff/again",
|
||||
};
|
||||
actorResponse.PreferredUsername = actor;
|
||||
actorResponse.Name = actor;
|
||||
|
||||
if (getResult.Data is DmGroup dmGroup)
|
||||
{
|
||||
actorResponse.Summary = Markdown.ToHtml(dmGroup.Description);
|
||||
actorResponse.Published = dmGroup.CreationDate;
|
||||
}
|
||||
else if (getResult.Data is Group dbGroup)
|
||||
{
|
||||
actorResponse.Summary = Markdown.ToHtml(dbGroup.Description);
|
||||
actorResponse.Published = dbGroup.CreationDate;
|
||||
}
|
||||
|
||||
return Ok(actorResponse);
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
return local == default ? NotFound() : Activity(ActivityPubRenderer.Actor(local));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/anus")]
|
||||
public async Task<IActionResult> Anus(
|
||||
[FromQuery] bool? page,
|
||||
[Required(ErrorMessageResourceName = "Required",
|
||||
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor)
|
||||
public async Task<IActionResult> Outbox(string actor, CancellationToken token)
|
||||
{
|
||||
if (!page.HasValue)
|
||||
return Ok(new ActivityPubOrderedCollection
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default)
|
||||
return NotFound();
|
||||
|
||||
var total = local.Kind == LocalActorKind.Group
|
||||
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id, token)
|
||||
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy, token);
|
||||
|
||||
var latest = await (local.Kind == LocalActorKind.Group
|
||||
? _dbEntities.Posts.Match(p => p.GroupId == local.Id)
|
||||
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy))
|
||||
.Sort(p => p.CreationDate, Order.Descending)
|
||||
.Limit(OutboxSize)
|
||||
.ExecuteAsync(token);
|
||||
|
||||
var items = new List<JsonNode>();
|
||||
foreach (var post in latest)
|
||||
{
|
||||
Id = HttpContext.Request.Path,
|
||||
FirstItem = $"{HttpContext.GetHostWithPath()}?page=true",
|
||||
});
|
||||
if (local.Kind == LocalActorKind.Group)
|
||||
{
|
||||
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
|
||||
continue;
|
||||
}
|
||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
var note = ActivityPubRenderer.Note(post, local, group, default);
|
||||
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}"));
|
||||
}
|
||||
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, items));
|
||||
}
|
||||
|
||||
return Ok();
|
||||
[HttpGet, Route("{actor}/followers")]
|
||||
public async Task<IActionResult> Followers(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default)
|
||||
return NotFound();
|
||||
var count = await DB.Default.CountAsync<Follower>(
|
||||
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/following")]
|
||||
public async Task<IActionResult> Following(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
return local == default ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>()));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/posts/{postId}")]
|
||||
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default || local.Kind != LocalActorKind.Person)
|
||||
return NotFound();
|
||||
|
||||
var post = await _dbEntities.Posts
|
||||
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return NotFound();
|
||||
|
||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
var replyTo = await ReplyTarget(post.AnsweringToPostId, token);
|
||||
var note = ActivityPubRenderer.Note(post, local, group, replyTo);
|
||||
note["@context"] = ActivityPubRenderer.ActivityStreams;
|
||||
return Activity(note);
|
||||
}
|
||||
|
||||
[HttpPost, Route("{actor}/mouth")]
|
||||
public async Task<IActionResult> Month(
|
||||
[Required(ErrorMessageResourceName = "Required",
|
||||
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor,
|
||||
[FromBody] JsonDocument json)
|
||||
public async Task<IActionResult> Inbox(string actor, CancellationToken token)
|
||||
{
|
||||
|
||||
return Ok();
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default)
|
||||
return NotFound();
|
||||
return Answer(await _inbox.Receive(Request, local, token));
|
||||
}
|
||||
|
||||
[HttpPost, Route("{actor}/human-centipede")]
|
||||
public async Task<IActionResult> HumanCentipede(
|
||||
[Required(ErrorMessageResourceName = "Required",
|
||||
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor,
|
||||
[FromBody] JsonDocument json)
|
||||
{
|
||||
|
||||
return Ok();
|
||||
}
|
||||
public async Task<IActionResult> ActorSharedInbox(string actor, CancellationToken token) =>
|
||||
Answer(await _inbox.Receive(Request, default, token));
|
||||
|
||||
[HttpPost, Route("/human-centipede")]
|
||||
public async Task<IActionResult> PublicHumanCentipede(
|
||||
[FromBody] JsonDocument json)
|
||||
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
|
||||
Answer(await _inbox.Receive(Request, default, token));
|
||||
|
||||
async Task<string> ReplyTarget(string answeringToPostId, CancellationToken token)
|
||||
{
|
||||
|
||||
return Ok();
|
||||
if (string.IsNullOrEmpty(answeringToPostId))
|
||||
return default;
|
||||
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return answeringToPostId;
|
||||
return (await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token))?.ObjectURI;
|
||||
}
|
||||
|
||||
IActionResult Answer(InboxResult result)
|
||||
{
|
||||
if (result.Error != default)
|
||||
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
|
||||
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
|
||||
}
|
||||
|
||||
ContentResult Activity(JsonObject document) => new()
|
||||
{
|
||||
Content = document.ToJsonString(),
|
||||
ContentType = ActivityContentType,
|
||||
StatusCode = StatusCodes.Status200OK
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,12 @@
|
||||
using Markdig;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Models.ActivityPub;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Services;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.Controllers.ServerToServer
|
||||
{
|
||||
[ApiController,
|
||||
Route("users"), Produces("application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"; charset=utf-8")]
|
||||
[ApiController, Route("users")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
readonly IRootUsersService _rootUsersService;
|
||||
|
||||
public UsersController(IRootUsersService rootUsersService)
|
||||
{
|
||||
_rootUsersService = rootUsersService;
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}")]
|
||||
public async Task<IActionResult> GetActor(
|
||||
[Required(ErrorMessageResourceName = "Required",
|
||||
ErrorMessageResourceType = typeof(FieldsNameResource))] string actor,
|
||||
CancellationToken cancellation)
|
||||
{
|
||||
var getResult = await _groupUsersService.GetGroup(actor, cancellation);
|
||||
if (!getResult.IsValid)
|
||||
return StatusCode(getResult.StatusCode, getResult);
|
||||
|
||||
var actorResponse = new ActivityPubActor();
|
||||
actorResponse.Context[1] = string.Format(actorResponse.Context[1].ToString(), HttpContext.Request.PathBase);
|
||||
actorResponse.Id = $"{HttpContext.GetHost()}/peasants/{actor}";
|
||||
actorResponse.ProfileURL = $"{HttpContext.GetHost()}/peasants/{actor}";
|
||||
actorResponse.Inbox = $"{HttpContext.GetHost()}/peasants/{actor}/mouth";
|
||||
actorResponse.Outbox = $"{HttpContext.GetHost()}/peasants/{actor}/anus";
|
||||
actorResponse.Endpoints = new()
|
||||
{
|
||||
SharedInboxURL = $"{HttpContext.GetHost()}/peasants/{actor}/human-centipede",
|
||||
OAuthAuthorizationEndpoint = $"{HttpContext.GetHost()}/sniff/again",
|
||||
};
|
||||
actorResponse.PreferredUsername = actor;
|
||||
actorResponse.Name = actor;
|
||||
|
||||
if (getResult.Data is DmGroup dmGroup)
|
||||
{
|
||||
actorResponse.Summary = Markdown.ToHtml(dmGroup.Description);
|
||||
actorResponse.Published = dmGroup.CreationDate;
|
||||
}
|
||||
else if (getResult.Data is Group dbGroup)
|
||||
{
|
||||
actorResponse.Summary = Markdown.ToHtml(dbGroup.Description);
|
||||
actorResponse.Published = dbGroup.CreationDate;
|
||||
}
|
||||
|
||||
return Ok(actorResponse);
|
||||
}
|
||||
|
||||
#region Admin
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
public IActionResult GetActor(string actor) =>
|
||||
RedirectPermanent($"/peasants/{Uri.EscapeDataString(actor)}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Services.Federation;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Controllers.ServerToServer
|
||||
{
|
||||
[ApiController]
|
||||
public class WellKnownController : ControllerBase
|
||||
{
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities)
|
||||
{
|
||||
_localActors = localActors;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
[HttpGet, Route("/.well-known/webfinger")]
|
||||
public async Task<IActionResult> WebFinger([FromQuery] string resource, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resource))
|
||||
return BadRequest();
|
||||
|
||||
LocalActor actor;
|
||||
if (resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = resource[5..].TrimStart('@').Split('@');
|
||||
var domain = new Uri(_localActors.BaseAddress).Authority;
|
||||
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
|
||||
return NotFound();
|
||||
actor = await _localActors.FindByUserName(parts[0], token);
|
||||
}
|
||||
else
|
||||
actor = await _localActors.FindByUri(resource, token);
|
||||
|
||||
if (actor == default)
|
||||
return NotFound();
|
||||
|
||||
var document = new JsonObject
|
||||
{
|
||||
["subject"] = $"acct:{actor.Handle}",
|
||||
["aliases"] = new JsonArray(actor.Uri),
|
||||
["links"] = new JsonArray(
|
||||
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri },
|
||||
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.Uri })
|
||||
};
|
||||
return Content(document.ToJsonString(), "application/jrd+json; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpGet, Route("/.well-known/nodeinfo")]
|
||||
public IActionResult NodeInfoLinks()
|
||||
{
|
||||
var document = new JsonObject
|
||||
{
|
||||
["links"] = new JsonArray(new JsonObject
|
||||
{
|
||||
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0"
|
||||
})
|
||||
};
|
||||
return Content(document.ToJsonString(), "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpGet, Route("/nodeinfo/2.0")]
|
||||
public async Task<IActionResult> NodeInfo(CancellationToken token)
|
||||
{
|
||||
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
|
||||
var posts = await DB.Default.CountAsync<PostEntity>(p => !p.IsFederatedCopy, token);
|
||||
var document = new JsonObject
|
||||
{
|
||||
["version"] = "2.0",
|
||||
["software"] = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref },
|
||||
["protocols"] = new JsonArray("activitypub"),
|
||||
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
|
||||
["openRegistrations"] = true,
|
||||
["usage"] = new JsonObject
|
||||
{
|
||||
["users"] = new JsonObject { ["total"] = users },
|
||||
["localPosts"] = posts
|
||||
},
|
||||
["metadata"] = new JsonObject()
|
||||
};
|
||||
return Content(document.ToJsonString(), "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/2.0#\"; charset=utf-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ namespace PrivaPub.Data
|
||||
International2Code = languageRow.International2Code
|
||||
});
|
||||
|
||||
await DB.SaveAsync(languages);
|
||||
await DB.Default.SaveAsync(languages);
|
||||
}
|
||||
|
||||
static async Task UpdateNewLanguages(DbEntities dbClient)
|
||||
@@ -154,7 +154,7 @@ namespace PrivaPub.Data
|
||||
{
|
||||
var bgLang = await dbClient.Languages.Match(l => l.International2Code == languageToUpdate.Key).ExecuteFirstAsync();
|
||||
bgLang.NativeName = languageToUpdate.Value;
|
||||
await bgLang.SaveAsync();
|
||||
await DB.Default.SaveAsync(bgLang);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
using Org.BouncyCastle.Asn1.X509;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Prng;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Security;
|
||||
using Org.BouncyCastle.X509;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Org.BouncyCastle.Math;
|
||||
using PrivaPub.Models.User;
|
||||
using System.Security.Claims;
|
||||
|
||||
@@ -18,15 +10,6 @@ namespace PrivaPub.Extensions
|
||||
{
|
||||
public static class Extensions
|
||||
{
|
||||
public static string GetLogsConnectionString(this IConfiguration configuration) =>
|
||||
configuration.GetSection("Serilog")
|
||||
?.GetSection("WriteTo")
|
||||
?.GetChildren()
|
||||
?.First()
|
||||
?.GetSection("Args")
|
||||
?.GetSection("databaseUrl")
|
||||
?.Value;
|
||||
|
||||
public static AuthorizationPolicy IsAdminPolicy() =>
|
||||
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
|
||||
.RequireClaim(Policies.IsAdmin, true.ToString().ToLower())
|
||||
@@ -51,42 +34,14 @@ namespace PrivaPub.Extensions
|
||||
public static string GetHost(this HttpContext httpContext) =>
|
||||
$"https://{httpContext.Request.Host}";
|
||||
|
||||
public static X509Certificate2 GetX509Certificate2(string certName)
|
||||
{
|
||||
var keypairgen = new RsaKeyPairGenerator();
|
||||
keypairgen.Init(new KeyGenerationParameters(new SecureRandom(new CryptoApiRandomGenerator()), 512));
|
||||
|
||||
var keypair = keypairgen.GenerateKeyPair();
|
||||
|
||||
var gen = new X509V3CertificateGenerator();
|
||||
|
||||
var CN = new X509Name("CN=" + certName);
|
||||
var SN = BigInteger.ProbablePrime(120, new Random());
|
||||
|
||||
gen.SetSerialNumber(SN);
|
||||
gen.SetSubjectDN(CN);
|
||||
gen.SetIssuerDN(CN);
|
||||
gen.SetNotAfter(DateTime.MaxValue);
|
||||
gen.SetNotBefore(DateTime.Now.Subtract(new TimeSpan(7, 0, 0, 0)));
|
||||
gen.SetSignatureAlgorithm("MD5WithRSA");
|
||||
gen.SetPublicKey(keypair.Public);
|
||||
|
||||
var newCert = gen.Generate(keypair.Private);
|
||||
|
||||
return new X509Certificate2(DotNetUtilities.ToX509Certificate((Org.BouncyCastle.X509.X509Certificate)newCert));
|
||||
}
|
||||
|
||||
public static UserPolicyType GetHighestPolicy(this ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
if (bool.Parse(claimsPrincipal.FindFirstValue(Policies.IsAdmin)))
|
||||
if (claimsPrincipal.FindFirstValue(Policies.IsAdmin) == "true")
|
||||
return UserPolicyType.IsAdmin;
|
||||
|
||||
if (bool.Parse(claimsPrincipal.FindFirstValue(Policies.IsModerator)))
|
||||
if (claimsPrincipal.FindFirstValue(Policies.IsModerator) == "true")
|
||||
return UserPolicyType.IsModerator;
|
||||
|
||||
if (bool.Parse(claimsPrincipal.FindFirstValue(Policies.IsUser)))
|
||||
return UserPolicyType.IsUser;
|
||||
|
||||
return UserPolicyType.IsUser;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
@@ -8,13 +8,10 @@ using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using NSign.Providers;
|
||||
using NSign;
|
||||
using NSign.Signatures;
|
||||
using NSign.Client;
|
||||
using System.Text;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using PrivaPub.Services.ClientToServer.Private;
|
||||
using PrivaPub.Services.ClientToServer.Public;
|
||||
using PrivaPub.Services.Federation;
|
||||
|
||||
namespace PrivaPub.Middleware
|
||||
{
|
||||
@@ -33,74 +30,19 @@ namespace PrivaPub.Middleware
|
||||
//.AddHostedService<GroupsCleanerWorker>()
|
||||
//.AddHostedService<PoliciesCleanerWorker>();
|
||||
}
|
||||
public static IServiceCollection PrivaPubHTTPSignature(this IServiceCollection service, IConfiguration configuration)
|
||||
public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service)
|
||||
{
|
||||
//HTTP CLIENT
|
||||
service.Configure<AddDigestOptions>(options => options.WithHash(AddDigestOptions.Hash.Sha256))
|
||||
.ConfigureMessageSigningOptions(options =>
|
||||
service.AddHttpClient(RemoteActorService.HttpClientName, client =>
|
||||
{
|
||||
options.SignatureName = "PrivaPub";
|
||||
options
|
||||
.WithMandatoryComponent(SignatureComponent.Path)
|
||||
.WithMandatoryComponent(SignatureComponent.RequestTarget)
|
||||
.SetParameters = signatureParams => signatureParams.WithKeyId("keyId");
|
||||
})
|
||||
.Services.Configure<SignatureVerificationOptions>(options =>
|
||||
{
|
||||
|
||||
})
|
||||
.AddHttpClient<ActivityPubClient>(nameof(ActivityPubClient))
|
||||
.ConfigureHttpClient(httpClient =>
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Accept.Add(new("application/ld+json"));
|
||||
})
|
||||
.AddDigestAndSigningHandlers()
|
||||
//.AddSignatureVerifiationHandler()
|
||||
.Services
|
||||
.AddSingleton<ISigner>(new HmacSha256SignatureProvider(Encoding.UTF8.GetBytes(configuration["AppConfiguration:Jwt:Key"])));
|
||||
|
||||
//MESSAGE RESPONSE
|
||||
|
||||
|
||||
return service;
|
||||
//.Configure<RequestSignatureVerificationOptions>(options =>
|
||||
//{
|
||||
// options.SignaturesToVerify.Add("sample");
|
||||
// options.RequiredSignatureComponents.Add(SignatureComponent.Path);
|
||||
// options.RequiredSignatureComponents.Add(SignatureComponent.Method);
|
||||
// options.RequiredSignatureComponents.Add(SignatureComponent.Digest);
|
||||
//})
|
||||
//.AddSignatureVerification(serviceProvider =>
|
||||
//{
|
||||
// var memoryCache = serviceProvider.GetRequiredService<IMemoryCache>();
|
||||
// //var httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
|
||||
|
||||
// //httpContextAccessor.HttpContext.Request.
|
||||
|
||||
// var cert = memoryCache.GetOrCreate("PrivaPub", (cacheEntry) => Extensions.Extensions.GetX509Certificate2("PrivaPubCert"));
|
||||
// return new RsaPkcs15Sha256SignatureProvider(cert, "anon");
|
||||
//})
|
||||
//.ConfigureMessageSigningOptions(options =>
|
||||
//{
|
||||
// options.WithMandatoryComponent(SignatureComponent.Path)
|
||||
// .WithMandatoryComponent(SignatureComponent.Method)
|
||||
// .WithMandatoryComponent(SignatureComponent.Digest)
|
||||
// .WithOptionalComponent(new HttpHeaderDictionaryStructuredComponent(NSign.Constants.Headers.Signature, "sample", bindRequest: true));
|
||||
// options.SignatureName = "resp";
|
||||
// options.SetParameters = (sigParams) =>
|
||||
// {
|
||||
// sigParams.WithCreatedNow();
|
||||
// };
|
||||
//})
|
||||
//.ValidateOnStart()
|
||||
//.Services
|
||||
//.AddHttpClient("ActivityPub", (serviceProvider, client) =>
|
||||
//{
|
||||
// client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("NSignSample", "0.1-beta"));
|
||||
//}).Services;
|
||||
//.AddSingleton<ISigner>(new RsaPssSha512SignatureProvider(
|
||||
// new X509Certificate2(@"path\to\certificate.pfx", "PasswordForPfx"),
|
||||
// "my-cert"));
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd($"PrivaPub/{BuildInfo.Ref}");
|
||||
});
|
||||
return service
|
||||
.AddSingleton<ILocalActorService, LocalActorService>()
|
||||
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
||||
.AddSingleton<IDeliveryService, DeliveryService>()
|
||||
.AddTransient<IInboxService, InboxService>()
|
||||
.AddHostedService<DeliveryWorker>();
|
||||
}
|
||||
public static IServiceCollection PrivaPubAuthServicesConfiguration(this IServiceCollection service, IConfiguration configuration)
|
||||
{
|
||||
@@ -149,6 +91,9 @@ namespace PrivaPub.Middleware
|
||||
.AddTransient<IDataService, DataService>()
|
||||
.AddTransient<IRootUsersService, RootUsersService>()
|
||||
.AddTransient<IPublicAvatarUsersService, PublicAvatarUsersService>()
|
||||
.AddTransient<IPrivateAvatarUsersService, PrivateAvatarUsersService>()
|
||||
.AddTransient<IGroupUsersService, GroupUsersService>()
|
||||
.AddTransient<IPostsService, PostsService>()
|
||||
.AddSingleton<AppConfigurationService>()
|
||||
.AddHttpContextAccessor()
|
||||
.AddMemoryCache()
|
||||
@@ -161,28 +106,18 @@ namespace PrivaPub.Middleware
|
||||
.AddEndpointsApiExplorer()
|
||||
.AddSwaggerGen(c =>
|
||||
{
|
||||
c.AddSecurityDefinition("Bearer", new()
|
||||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Please enter a valid token",
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
BearerFormat = "JWT",
|
||||
Scheme = "Bearer"
|
||||
Scheme = "bearer"
|
||||
});
|
||||
c.AddSecurityRequirement(new()
|
||||
c.AddSecurityRequirement(document => new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new()
|
||||
{
|
||||
Reference = new()
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
},
|
||||
new string[]{}
|
||||
}
|
||||
[new OpenApiSecuritySchemeReference("Bearer", document)] = []
|
||||
});
|
||||
})
|
||||
.AddControllers(options => { options.Filters.Add<OperationCancelledExceptionFilter>(); })
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
using PrivaPub.Models.ActivityPub.Extra;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubActivity))]
|
||||
public class ActivityPubActivity : ActivityPubObject
|
||||
{
|
||||
[JsonPropertyName("actor")]
|
||||
public ActivityPubActor Actor { get; set; }
|
||||
|
||||
[JsonPropertyName("object")]
|
||||
public ActivityPubObject Object { get; set; }
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public ActivityPubObject Target { get; set; }
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public ActivityPubResult Result { get; set; }
|
||||
|
||||
[JsonPropertyName("origin")]
|
||||
public ActivityPubOrigin Origin { get; set; }
|
||||
|
||||
[JsonPropertyName("instrument")]
|
||||
public ActivityPubInstrument Instrument { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool NeedsObjectForInbox => Type is
|
||||
ObjectType.Create or
|
||||
ObjectType.Update or
|
||||
ObjectType.Delete or
|
||||
ObjectType.Follow or
|
||||
ObjectType.Add or
|
||||
ObjectType.Remove or
|
||||
ObjectType.Like or
|
||||
ObjectType.Block or
|
||||
ObjectType.Undo;
|
||||
[JsonIgnore]
|
||||
public bool NeedsTargetForInbox => Type is
|
||||
ObjectType.Add or
|
||||
ObjectType.Remove;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using PrivaPub.Models.ActivityPub.Extra;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubActor))]
|
||||
public class ActivityPubActor : ActivityPubObject
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public new ObjectType? Type => ObjectType.Person;
|
||||
|
||||
[JsonPropertyName("inbox")]
|
||||
public string Inbox { get; set; }
|
||||
[JsonPropertyName("outbox")]
|
||||
public string Outbox { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string ProfileURL { get; set; }
|
||||
[JsonPropertyName("preferredUsername")]
|
||||
public string PreferredUsername { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
[JsonPropertyName("summary")]
|
||||
public string Summary { get; set; }
|
||||
[JsonPropertyName("icon")]
|
||||
public ActivityPubIcon Icon { get; set; }
|
||||
[JsonPropertyName("image")]
|
||||
public ActivityPubIcon Thumbnail { get; set; }
|
||||
|
||||
[JsonPropertyName("manuallyApprovesFollowers")]
|
||||
public bool ManuallyApprovesFollowers { get; set; } = false;
|
||||
[JsonPropertyName("discoverable")]
|
||||
public bool Discoverable { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("publicKey")]
|
||||
public ActivityPubPublicKey PublicKey { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("endpoints")]
|
||||
public ActivityPubActorEndpoints Endpoints { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("attachment")]
|
||||
public IEnumerable<ActivityPubAttachment> Attachment { get; set; } = Enumerable.Empty<ActivityPubAttachment>();
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public IEnumerable<string> Tags => Enumerable.Empty<string>();
|
||||
}
|
||||
|
||||
public class ActivityPubAttachment
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; }
|
||||
}
|
||||
|
||||
public class ActivityPubActorEndpoints
|
||||
{
|
||||
[JsonPropertyName("sharedInbox")]
|
||||
public string SharedInboxURL { get; set; }
|
||||
[JsonPropertyName("oauthAuthorizationEndpoint")]
|
||||
public string OAuthAuthorizationEndpoint { get; set; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, object> OtherData { get; set; }
|
||||
}
|
||||
|
||||
public class ActivityPubPublicKey
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; }
|
||||
[JsonPropertyName("owner")]
|
||||
public string Owner { get; set; }
|
||||
[JsonPropertyName("publicKeyPem")]
|
||||
public string PublicKeyPem { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubCollection))]
|
||||
public class ActivityPubCollection : ActivityPubObject
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public new ObjectType Type => ObjectType.Collection;
|
||||
|
||||
[JsonPropertyName("current")]
|
||||
public string RecentlyUpdatedItem { get; set; }
|
||||
[JsonPropertyName("first")]
|
||||
public string FirstItem { get; set; }
|
||||
[JsonPropertyName("last")]
|
||||
public string LastItem { get; set; }
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public List<ActivityPubLink> Items { get; set; }
|
||||
|
||||
[JsonPropertyName("totalItems")]
|
||||
public int TotalItems => Items.Count;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubCollectionPage))]
|
||||
public class ActivityPubCollectionPage : ActivityPubCollection
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public new ObjectType Type => ObjectType.CollectionPage;
|
||||
|
||||
[JsonPropertyName("partOf")]
|
||||
public string BaseCollectionLink { get; set; }
|
||||
[JsonPropertyName("next")]
|
||||
public string NextCollectionLink { get; set; }
|
||||
[JsonPropertyName("prev")]
|
||||
public string PreviousCollectionLink { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubLink))]
|
||||
public class ActivityPubLink : ActivityPubObject
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public new ObjectType Type { get; set; } = ObjectType.Mention;
|
||||
|
||||
[JsonPropertyName("href")]
|
||||
public string Href { get; set; }
|
||||
[JsonPropertyName("preview")]
|
||||
public string Preview { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
[JsonPropertyName("rel")]
|
||||
public string Relation { get; set; }
|
||||
[JsonPropertyName("hreflang")]
|
||||
public string HrefLang { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; }
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
using PrivaPub.Models.ActivityPub.Extra;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubObject))]
|
||||
public partial class ActivityPubObject
|
||||
{
|
||||
[JsonPropertyName("@context")]
|
||||
public List<object> Context => new()
|
||||
{
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://{0}/schemas/litepub-0.1.jsonld",
|
||||
new ActivityPubContextLanguage()
|
||||
};
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; }
|
||||
[JsonPropertyName("type")]
|
||||
public ObjectType? Type { get; set; }
|
||||
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string MediaType { get; set; }
|
||||
|
||||
[JsonPropertyName("published")]
|
||||
public DateTime? Published { get; set; }
|
||||
|
||||
[JsonPropertyName("updated")]
|
||||
public DateTime? Updated { get; set; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, object> OtherData { get; set; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public ActivityPubSource Source { get; set; }
|
||||
|
||||
//and part of link
|
||||
[JsonPropertyName("to")]
|
||||
public string[] To { get; set; }
|
||||
[JsonPropertyName("cc")]
|
||||
public string[] Cc { get; set; }
|
||||
[JsonPropertyName("bcc")]
|
||||
public string[] Bcc { get; set; }
|
||||
[JsonPropertyName("bto")]
|
||||
public string[] Bto { get; set; }
|
||||
|
||||
[JsonPropertyName("audience")]
|
||||
public ActivityPubAudience Audience { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public MacroType MacroType => Type switch
|
||||
{
|
||||
ObjectType.Article or
|
||||
ObjectType.Audio or
|
||||
ObjectType.Document or
|
||||
ObjectType.Event or
|
||||
ObjectType.Image or
|
||||
ObjectType.Note or
|
||||
ObjectType.Page or
|
||||
ObjectType.Place or
|
||||
ObjectType.Profile or
|
||||
ObjectType.Relationship or
|
||||
ObjectType.Tombstone or
|
||||
ObjectType.Video => MacroType.Object,
|
||||
|
||||
ObjectType.Mention => MacroType.Link,
|
||||
|
||||
ObjectType.Application or
|
||||
ObjectType.Group or
|
||||
ObjectType.Organization or
|
||||
ObjectType.Person or
|
||||
ObjectType.Service => MacroType.Actor,
|
||||
|
||||
ObjectType.Accept or
|
||||
ObjectType.Add or
|
||||
ObjectType.Announce or
|
||||
ObjectType.Arrive or
|
||||
ObjectType.Block or
|
||||
ObjectType.Create or
|
||||
ObjectType.Delete or
|
||||
ObjectType.Dislike or
|
||||
ObjectType.Flag or
|
||||
ObjectType.Follow or
|
||||
ObjectType.Ignore or
|
||||
ObjectType.Invite or
|
||||
ObjectType.Join or
|
||||
ObjectType.Leave or
|
||||
ObjectType.Like or
|
||||
ObjectType.Listen or
|
||||
ObjectType.Move or
|
||||
ObjectType.Offer or
|
||||
ObjectType.Question or
|
||||
ObjectType.Reject or
|
||||
ObjectType.Read or
|
||||
ObjectType.Remove or
|
||||
ObjectType.TentativeReject or
|
||||
ObjectType.TentativeAccept or
|
||||
ObjectType.Travel or
|
||||
ObjectType.Undo or
|
||||
ObjectType.Update or
|
||||
ObjectType.View => MacroType.Activity,
|
||||
|
||||
ObjectType.Collection => MacroType.Collection,
|
||||
ObjectType.CollectionPage => MacroType.CollectionPage,
|
||||
ObjectType.OrderedCollection => MacroType.OrderedCollection,
|
||||
ObjectType.OrderedCollectionPage => MacroType.OrderedCollectionPage,
|
||||
|
||||
_ => MacroType.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
public class ActivityPubContextLanguage
|
||||
{
|
||||
[JsonPropertyName("@language")]
|
||||
public string Language { get; set; } = "und";
|
||||
}
|
||||
|
||||
public class ActivityPubSource
|
||||
{
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; set; }
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string MediaType { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubOrderedCollection))]
|
||||
public class ActivityPubOrderedCollection : ActivityPubCollection
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public new ObjectType Type => ObjectType.OrderedCollection;
|
||||
|
||||
[JsonPropertyName("orderedItems")]
|
||||
public List<ActivityPubLink> OrderedItems { get; set; }
|
||||
|
||||
[JsonPropertyName("totalItems")]
|
||||
public new int TotalItems => OrderedItems?.Count ?? default;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubOrderedCollectionPage))]
|
||||
public class ActivityPubOrderedCollectionPage : ActivityPubCollection
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public new ObjectType Type => ObjectType.OrderedCollectionPage;
|
||||
|
||||
[JsonPropertyName("partOf")]
|
||||
public string BaseCollectionLink { get; set; }
|
||||
[JsonPropertyName("next")]
|
||||
public string NextCollectionLink { get; set; }
|
||||
[JsonPropertyName("prev")]
|
||||
public string PreviousCollectionLink { get; set; }
|
||||
|
||||
[JsonPropertyName("startIndex")]
|
||||
public uint? StartIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("orderedItems")]
|
||||
public List<ActivityPubLink> OrderedItems { get; set; }
|
||||
|
||||
[JsonPropertyName("totalItems")]
|
||||
public new int TotalItems => OrderedItems?.Count ?? default;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubTombstone))]
|
||||
public class ActivityPubTombstone : ActivityPubObject
|
||||
{
|
||||
[JsonPropertyName("formerType")]
|
||||
public ObjectType FormerType { get; set; } = ObjectType.Unknown;
|
||||
|
||||
[JsonPropertyName("deleted")]
|
||||
public DateTime Deleted { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub.Extra
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubPublicKey))]
|
||||
public class ActivityPubAudience
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub.Extra
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubPublicKey))]
|
||||
public class ActivityPubIcon
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
[JsonPropertyName("url")]
|
||||
public string URL { get; set; }
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; }
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub.Extra
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubPublicKey))]
|
||||
public class ActivityPubInstrument
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub.Extra
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubPublicKey))]
|
||||
public class ActivityPubOrigin
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Models.ActivityPub.Extra
|
||||
{
|
||||
[JsonSerializable(typeof(ActivityPubPublicKey))]
|
||||
public class ActivityPubResult
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
public enum MacroType
|
||||
{
|
||||
Object,
|
||||
Link,
|
||||
Actor,
|
||||
Activity,
|
||||
|
||||
Collection,
|
||||
OrderedCollection,
|
||||
CollectionPage,
|
||||
OrderedCollectionPage,
|
||||
|
||||
Unknown
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
namespace PrivaPub.Models.ActivityPub
|
||||
{
|
||||
public enum ObjectType
|
||||
{
|
||||
//Object types
|
||||
Article,
|
||||
Audio,
|
||||
Document,
|
||||
Event,
|
||||
Image,
|
||||
Note,
|
||||
Page,
|
||||
Place,
|
||||
Profile,
|
||||
Relationship,
|
||||
Tombstone,
|
||||
Video,
|
||||
//Link types
|
||||
Mention,
|
||||
//Actor
|
||||
Application,
|
||||
Group,
|
||||
Organization,
|
||||
Person,
|
||||
Service,
|
||||
//Activity
|
||||
Accept,
|
||||
Add,
|
||||
Announce,
|
||||
Arrive,
|
||||
Block,
|
||||
Create,
|
||||
Delete,
|
||||
Dislike,
|
||||
Flag,
|
||||
Follow,
|
||||
Ignore,
|
||||
Invite,
|
||||
Join,
|
||||
Leave,
|
||||
Like,
|
||||
Listen,
|
||||
Move,
|
||||
Offer,
|
||||
Question,
|
||||
Reject,
|
||||
Read,
|
||||
Remove,
|
||||
TentativeReject,
|
||||
TentativeAccept,
|
||||
Travel,
|
||||
Undo,
|
||||
Update,
|
||||
View,
|
||||
//Collections
|
||||
Collection,
|
||||
OrderedCollection,
|
||||
CollectionPage,
|
||||
OrderedCollectionPage,
|
||||
//NULL or default
|
||||
Unknown
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using MongoDB.Entities;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Email;
|
||||
using PrivaPub.StaticServices;
|
||||
@@ -14,6 +14,7 @@ namespace PrivaPub.Models
|
||||
public string ValidDiscussionFilesTypes { get; set; }
|
||||
public List<string> SupportedLanguages { get; set; } = new();
|
||||
public string BackendBaseAddress { get; set; }
|
||||
public string FrontendBaseAddress { get; set; }
|
||||
public EmailConfiguration EmailConfiguration { get; set; }
|
||||
public HashingOptions HashingOptions { get; set; }
|
||||
public DateTime LastUpdateDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Federation
|
||||
{
|
||||
public class Delivery : Entity
|
||||
{
|
||||
public string SignerId { get; set; }
|
||||
public LocalActorKind SignerKind { get; set; }
|
||||
public string InboxURL { get; set; }
|
||||
public string Body { get; set; }
|
||||
public int Attempts { get; set; }
|
||||
public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? DeliveredAt { get; set; }
|
||||
public DateTime? AbandonedAt { get; set; }
|
||||
public string LastError { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Federation
|
||||
{
|
||||
public class Follower : Entity
|
||||
{
|
||||
public string LocalActorId { get; set; }
|
||||
public LocalActorKind LocalActorKind { get; set; }
|
||||
public string ActorURI { get; set; }
|
||||
public string InboxURL { get; set; }
|
||||
public string SharedInboxURL { get; set; }
|
||||
public string FollowActivityURI { get; set; }
|
||||
public bool IsAccepted { get; set; } = true;
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public enum LocalActorKind
|
||||
{
|
||||
Person,
|
||||
Group,
|
||||
Application
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Federation
|
||||
{
|
||||
public class InstanceActor : Entity
|
||||
{
|
||||
public string PrivateKey { get; set; }
|
||||
public string PublicKey { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Group
|
||||
{
|
||||
public class DmGroup : Entity
|
||||
{
|
||||
public string Description { get; set; }
|
||||
public List<GroupMember> Members { get; set; } = new();
|
||||
public string ConversationURI { get; set; }//context of the federated thread
|
||||
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? DeletionAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Group
|
||||
{
|
||||
public class Group : Entity
|
||||
{
|
||||
public string UserName { get; set; }//preferredUsername
|
||||
public string Name { get; set; }//name
|
||||
public string Description { get; set; }//summary, Markdown
|
||||
|
||||
public string Url { get; set; }
|
||||
public string Domain { get; set; }
|
||||
public string InboxURL { get; set; }
|
||||
public string OutboxURL { get; set; }
|
||||
public string PictureURL { get; set; }//icon
|
||||
public string ThumbnailURL { get; set; }//image
|
||||
public string PrivateKey { get; set; }
|
||||
public string PublicKey { get; set; }
|
||||
|
||||
public bool IsDiscoverable { get; set; } = true;
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
public string OwnerAvatarId { get; set; }
|
||||
public List<GroupMember> Members { get; set; } = new();
|
||||
|
||||
public string InvitationCode { get; set; }
|
||||
public string HashedInvitationPassword { get; set; }
|
||||
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? DeletionAt { get; set; }
|
||||
}
|
||||
|
||||
public class GroupMember
|
||||
{
|
||||
public string AvatarId { get; set; }//local Avatar.ID, or the actor URI when IsForeign
|
||||
public bool IsForeign { get; set; }
|
||||
public GroupRole Role { get; set; } = GroupRole.Member;
|
||||
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public enum GroupRole
|
||||
{
|
||||
Member,
|
||||
Moderator,
|
||||
Owner
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using MongoDB.Entities;
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Post
|
||||
{
|
||||
@@ -14,6 +14,8 @@ namespace PrivaPub.Models.Post
|
||||
public bool HasContentWarning { get; set; } = false;
|
||||
|
||||
public bool IsFederatedCopy { get; set; }
|
||||
public string ObjectURI { get; set; }//the Note's id
|
||||
public string ActorURI { get; set; }//attributedTo
|
||||
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdateDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MongoDB.Entities;
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Post
|
||||
{
|
||||
@@ -15,6 +15,8 @@ namespace PrivaPub.Models.Post
|
||||
public bool HasContentWarning { get; set; } = false;
|
||||
|
||||
public bool IsFederatedCopy { get; set; }
|
||||
public string ObjectURI { get; set; }//the Note's id
|
||||
public string ActorURI { get; set; }//attributedTo
|
||||
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdateDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MongoDB.Entities;
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.User
|
||||
{
|
||||
@@ -55,6 +55,9 @@ namespace PrivaPub.Models.User
|
||||
public string ModerationNote { get; set; }
|
||||
public bool IsDiscoverable { get; set; } = true;
|
||||
|
||||
public string ActorURI { get; set; }//id
|
||||
public string PublicKeyId { get; set; }
|
||||
public string SharedInboxURL { get; set; }
|
||||
public string InboxURL { get; set; }
|
||||
public string OutboxURL { get; set; }
|
||||
public string MovedToURL { get; set; }
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Data
|
||||
{
|
||||
[BsonIgnoreExtraElements]
|
||||
public class PrivaPub : Entity
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string Level { get; set; }
|
||||
public string RenderedMessage { get; set; }
|
||||
|
||||
[BsonIgnoreIfDefault, BsonIgnoreIfNull]
|
||||
public DbLogProperties Properties { get; set; }
|
||||
[BsonIgnoreIfDefault, BsonIgnoreIfNull]
|
||||
public string Exception { get; set; }
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public class DbLogProperties
|
||||
{
|
||||
public string SourceContext { get; set; }
|
||||
}
|
||||
}
|
||||
+28
-25
@@ -1,36 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.0.0" />
|
||||
<PackageReference Include="Markdig" Version="0.31.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" />
|
||||
<PackageReference Include="MongoDB.Entities" Version="21.0.2" />
|
||||
<PackageReference Include="NSign.Abstractions" Version="0.16.0" />
|
||||
<PackageReference Include="NSign.AspNetCore" Version="0.16.0" />
|
||||
<PackageReference Include="NSign.Client" Version="0.16.0" />
|
||||
<PackageReference Include="NSign.SignatureProviders" Version="0.16.0" />
|
||||
<PackageReference Include="PasswordGenerator" Version="2.1.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="5.0.1" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.MongoDB" Version="5.3.1" />
|
||||
<PackageReference Include="SshKeyGenerator" Version="1.1.51" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Helpers\" />
|
||||
<Folder Include="Services\ServerToServer\" />
|
||||
<Folder Include="Workers\" />
|
||||
<PackageReference Include="MailKit" Version="4.18.0" />
|
||||
<PackageReference Include="Markdig" Version="1.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="MongoDB.Entities" Version="25.1.0" />
|
||||
<PackageReference Include="PasswordGenerator" Version="3.0.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.MongoDB" Version="7.3.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -52,4 +36,23 @@
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="GenerateBuildInfo" BeforeTargets="BeforeCompile">
|
||||
<PropertyGroup>
|
||||
<BuildInfoPath>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<BuildInfoLine Include="public static class BuildInfo" />
|
||||
<BuildInfoLine Include="{" />
|
||||
<BuildInfoLine Include=" public const string Commit = "$(BuildCommit)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Ref = "$(BuildRef)"%3B" />
|
||||
<BuildInfoLine Include=" public const string BuiltAt = "$(BuildTimeUtc)"%3B" />
|
||||
<BuildInfoLine Include="}" />
|
||||
</ItemGroup>
|
||||
<WriteLinesToFile File="$(BuildInfoPath)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
|
||||
<ItemGroup>
|
||||
<Compile Include="$(BuildInfoPath)" />
|
||||
<FileWrites Include="$(BuildInfoPath)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
+13
-8
@@ -1,6 +1,9 @@
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization;
|
||||
using MongoDB.Bson.Serialization.Serializers;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
@@ -10,7 +13,6 @@ using PrivaPub.Data;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Middleware;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Data;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
@@ -45,7 +47,7 @@ try
|
||||
.PrivaPubOptimizationConfiguration()
|
||||
.PrivaPubDataBaseConfiguration()
|
||||
.PrivaPubServicesConfiguration()
|
||||
.PrivaPubHTTPSignature(builder.Configuration)
|
||||
.PrivaPubFederationConfiguration()
|
||||
.PrivaPubCORSConfiguration()
|
||||
.PrivaPubMiddlewareConfiguration();
|
||||
}
|
||||
@@ -57,11 +59,9 @@ try
|
||||
|
||||
try
|
||||
{
|
||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
|
||||
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
|
||||
var logsConnectionString = builder.Configuration.GetLogsConnectionString();
|
||||
await DB.InitAsync(mongoSettings.LogsDatabase, MongoClientSettings.FromConnectionString(logsConnectionString));
|
||||
DB.DatabaseFor<PrivaPub>(mongoSettings.LogsDatabase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -91,11 +91,9 @@ try
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||
});
|
||||
}
|
||||
else if(app.Environment.IsDevelopment())
|
||||
{
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseCors("DefaultCORS");
|
||||
@@ -112,6 +110,12 @@ try
|
||||
// context.Request.Path.StartsWithSegments("/users"),
|
||||
// app => app.UseSignatureVerification().UseDigestVerification());
|
||||
|
||||
app.MapGet("/build.json", () => Results.Json(new
|
||||
{
|
||||
commit = BuildInfo.Commit,
|
||||
buildRef = BuildInfo.Ref,
|
||||
builtAt = BuildInfo.BuiltAt,
|
||||
}));
|
||||
app.MapControllers();
|
||||
//app.MapFallbackToFile("index.html");
|
||||
}
|
||||
@@ -138,5 +142,6 @@ try
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.ForContext<Program>().Fatal(ex, $"{nameof(Program)}.{nameof(Program)}()");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
!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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.User;
|
||||
@@ -19,7 +19,7 @@ namespace PrivaPub.StaticServices
|
||||
Configuration = configuration;
|
||||
}
|
||||
|
||||
public JwtUser GenerateToken(RootUser user, ViewAvatarServer userSettings)
|
||||
public JwtUser GenerateToken(RootUser user, ViewUserSettings userSettings)
|
||||
{
|
||||
var expiration = DateTime.UtcNow.AddHours(int.Parse(Configuration["AppConfiguration:Jwt:HoursTimeout"]));
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["AppConfiguration:Jwt:Key"]));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using MongoDB.Entities;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Data;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
@@ -9,19 +11,26 @@ namespace PrivaPub.StaticServices
|
||||
{
|
||||
public class DbEntities
|
||||
{
|
||||
public Find<RootUser> RootUsers { get { return DB.Find<RootUser>(); } }
|
||||
public Find<RootUserNote> RootUserNotes { get { return DB.Find<RootUserNote>(); } }
|
||||
public Find<RootUser> RootUsers { get { return DB.Default.Find<RootUser>(); } }
|
||||
public Find<RootUserNote> RootUserNotes { get { return DB.Default.Find<RootUserNote>(); } }
|
||||
|
||||
public Find<AppConfiguration> AppConfiguration { get { return DB.Find<AppConfiguration>(); } }
|
||||
public Find<Language> Languages { get { return DB.Find<Language>(); } }
|
||||
public Find<EmailRecovery> EmailRecoveries { get { return DB.Find<EmailRecovery>(); } }
|
||||
public Find<AppConfiguration> AppConfiguration { get { return DB.Default.Find<AppConfiguration>(); } }
|
||||
public Find<Language> Languages { get { return DB.Default.Find<Language>(); } }
|
||||
public Find<EmailRecovery> EmailRecoveries { get { return DB.Default.Find<EmailRecovery>(); } }
|
||||
|
||||
public Find<Post> Posts { get { return DB.Find<Post>(); } }
|
||||
public Find<DmPost> DmPosts { get { return DB.Find<DmPost>(); } }
|
||||
public Find<Post> Posts { get { return DB.Default.Find<Post>(); } }
|
||||
public Find<DmPost> DmPosts { get { return DB.Default.Find<DmPost>(); } }
|
||||
|
||||
public Find<Avatar> Avatars { get { return DB.Find<Avatar>(); } }
|
||||
public Find<ForeignAvatar> ForeignAvatars { get { return DB.Find<ForeignAvatar>(); } }
|
||||
public Find<Avatar> Avatars { get { return DB.Default.Find<Avatar>(); } }
|
||||
public Find<ForeignAvatar> ForeignAvatars { get { return DB.Default.Find<ForeignAvatar>(); } }
|
||||
|
||||
public Find<RootToAvatar> RootToAvatars { get { return DB.Find<RootToAvatar>(); } }
|
||||
public Find<RootToAvatar> RootToAvatars { get { return DB.Default.Find<RootToAvatar>(); } }
|
||||
|
||||
public Find<Group> Groups { get { return DB.Default.Find<Group>(); } }
|
||||
public Find<DmGroup> DmGroups { get { return DB.Default.Find<DmGroup>(); } }
|
||||
|
||||
public Find<Follower> Followers { get { return DB.Default.Find<Follower>(); } }
|
||||
public Find<Delivery> Deliveries { get { return DB.Default.Find<Delivery>(); } }
|
||||
public Find<InstanceActor> InstanceActors { get { return DB.Default.Find<InstanceActor>(); } }
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
"Iterations": 10101
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "ITNN8mPfS2ivOqr1eRWK0Rac3sRAchQdG8BUy0pK4vQ3",
|
||||
"Key": "bbLOwQyh0jYtJ8Dn6WKCneydLJkqeElqUM03Qwtr7pEMyq/qCZD6v/Sh5Kwrq6YpvJm8GSTHfBiCajUmicfgKw==",
|
||||
"Issuer": "https://localhost",
|
||||
"Audience": "https://localhost",
|
||||
"HoursTimeout": 24
|
||||
@@ -77,7 +77,7 @@
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "MongoDB",
|
||||
"Name": "MongoDBCapped",
|
||||
"Args": {
|
||||
"databaseUrl": "mongodb://localhost:27017/logs",
|
||||
"collectionName": "collanonquick",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
"MongoSettings": {
|
||||
"Database": "PrivaPub",
|
||||
"LogsDatabase": "logs",
|
||||
"ConnectionString": "mongodb://localhost:27017"
|
||||
"ConnectionString": "mongodb://127.0.0.1:27022"
|
||||
},
|
||||
"AppConfiguration": {
|
||||
"Version": "0.0.0",
|
||||
"BackendBaseAddress": "https://privapub.thepra.dev",
|
||||
"FrontendBaseAddress": "https://decepub.thepra.dev",
|
||||
"MaxAllowedUploadFiles": 3,
|
||||
"MaxAllowedFileSize": 2097152,
|
||||
"ValidDiscussionFilesTypes": ".odt,.docx,.pdf,.xlsx,.ods,.odp,.pptx,.png,.jpg,.jpeg",
|
||||
@@ -23,9 +25,9 @@
|
||||
"Iterations": 10101
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "ITNN8mPfS2ivOqr1eRWK0Rac3sRAchQdG8BUy0pK4vQ3",
|
||||
"Issuer": "https://localhost",
|
||||
"Audience": "https://localhost",
|
||||
"Key": "FVkEHw8t84HVXE/+3fyY6gH1AJIlLVNE9UqOzFNBdRKsdFuKrCFBvys0IQfZu+d2Qgg6KtslgntiedPMO98iKg==",
|
||||
"Issuer": "https://privapub.thepra.dev",
|
||||
"Audience": "https://privapub.thepra.dev",
|
||||
"HoursTimeout": 365
|
||||
}
|
||||
},
|
||||
@@ -44,9 +46,9 @@
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "MongoDB",
|
||||
"Name": "MongoDBCapped",
|
||||
"Args": {
|
||||
"databaseUrl": "mongodb://localhost:27017/logs",
|
||||
"databaseUrl": "mongodb://127.0.0.1:27022/logs",
|
||||
"collectionName": "PrivaPub",
|
||||
"cappedMaxSizeMb": "1024",
|
||||
"cappedMaxDocuments": "10000"
|
||||
@@ -61,5 +63,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "privapub.thepra.dev;localhost;127.0.0.1"
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time root setup on Max for PrivaPub at privapub.thepra.dev. Idempotent.
|
||||
# rsync -a -e $MAX/ssh.sh deploy/ root@nuvola.xyz:/root/privapub-deploy/
|
||||
# $MAX/run.sh bash /root/privapub-deploy/max/setup.sh
|
||||
set -euo pipefail
|
||||
SRC="${1:-/root/privapub-deploy}"
|
||||
HOST=privapub.thepra.dev
|
||||
UNIT=privapub
|
||||
RUNNER=build-runner
|
||||
ACME=/root/.acme.sh/acme.sh
|
||||
|
||||
echo "== directories"
|
||||
install -d -o "$RUNNER" -g www-data -m 755 /var/www/$HOST
|
||||
install -d -o "$RUNNER" -g "$RUNNER" -m 750 /var/backups/$HOST
|
||||
install -d -o www-data -g www-data -m 750 /var/lib/privapub /var/lib/privapub/mongo
|
||||
|
||||
echo "== sudoers"
|
||||
SUDOERS=/etc/sudoers.d/$RUNNER
|
||||
touch "$SUDOERS"; chmod 440 "$SUDOERS"
|
||||
grep -qF "systemctl start $UNIT," "$SUDOERS" || echo "$RUNNER ALL=(root) NOPASSWD: /usr/bin/systemctl start $UNIT, /usr/bin/systemctl stop $UNIT, /usr/bin/systemctl restart $UNIT, /usr/bin/systemctl is-active $UNIT, /usr/bin/systemctl show $UNIT*, /usr/bin/systemctl status $UNIT*" >> "$SUDOERS"
|
||||
visudo -cf "$SUDOERS"
|
||||
|
||||
echo "== units"
|
||||
install -m 644 "$SRC/systemd/privapub-mongod.service" /etc/systemd/system/privapub-mongod.service
|
||||
install -m 644 "$SRC/systemd/$UNIT.service" /etc/systemd/system/$UNIT.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now privapub-mongod >/dev/null
|
||||
systemctl enable $UNIT >/dev/null
|
||||
systemctl is-active privapub-mongod
|
||||
|
||||
echo "== nginx snippet and bootstrap vhost"
|
||||
install -m 644 "$SRC/nginx/privapub-headers.conf" /etc/nginx/snippets/privapub-headers.conf
|
||||
if [ -f /root/.acme.sh/${HOST}_ecc/fullchain.cer ]; then
|
||||
install -m 644 "$SRC/nginx/$HOST.conf" /etc/nginx/sites-available/$HOST.conf
|
||||
else
|
||||
awk '/^server \{/{n++} n==1' "$SRC/nginx/$HOST.conf" > /etc/nginx/sites-available/$HOST.conf
|
||||
fi
|
||||
ln -sf /etc/nginx/sites-available/$HOST.conf /etc/nginx/sites-enabled/$HOST.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "== certificate"
|
||||
if [ -f /root/.acme.sh/${HOST}_ecc/fullchain.cer ]; then
|
||||
echo "$HOST: certificate present"
|
||||
else
|
||||
$ACME --issue --server letsencrypt -d $HOST -w /var/www/acme --renew-hook "systemctl reload nginx"
|
||||
fi
|
||||
|
||||
echo "== full vhost"
|
||||
install -m 644 "$SRC/nginx/$HOST.conf" /etc/nginx/sites-available/$HOST.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
echo "setup complete"
|
||||
@@ -0,0 +1,4 @@
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
@@ -0,0 +1,48 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name privapub.thepra.dev;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/acme;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen 8444 ssl proxy_protocol;
|
||||
listen [::]:443 ssl;
|
||||
server_name privapub.thepra.dev;
|
||||
http2 on;
|
||||
|
||||
include /etc/nginx/ssl.conf;
|
||||
ssl_certificate /root/.acme.sh/privapub.thepra.dev_ecc/fullchain.cer;
|
||||
ssl_certificate_key /root/.acme.sh/privapub.thepra.dev_ecc/privapub.thepra.dev.key;
|
||||
include /etc/nginx/snippets/privapub-headers.conf;
|
||||
|
||||
access_log /var/log/nginx/privapub.thepra.dev.access.log;
|
||||
error_log /var/log/nginx/privapub.thepra.dev.error.log;
|
||||
|
||||
client_max_body_size 2m;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/acme;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:6970;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=PrivaPub's own mongod (127.0.0.1:27022)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=www-data
|
||||
Group=www-data
|
||||
ExecStart=/usr/bin/mongod --dbpath /var/lib/privapub/mongo --port 27022 --bind_ip 127.0.0.1 --noauth --wiredTigerCacheSizeGB 0.25 --quiet
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=64000
|
||||
SyslogIdentifier=privapub-mongod
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=PrivaPub ActivityPub server (privapub.thepra.dev)
|
||||
After=network-online.target privapub-mongod.service
|
||||
Wants=network-online.target
|
||||
Requires=privapub-mongod.service
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/var/www/privapub.thepra.dev
|
||||
ExecStart=/var/www/privapub.thepra.dev/PrivaPub
|
||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
SyslogIdentifier=privapub
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestFeature",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user