This repository has been archived on 2023-05-27. You can view files and clone it, but cannot push or open issues or pull requests.
BirdsiteLIVE/src/BirdsiteLive.Domain/UserService.cs

529 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using BirdsiteLive.ActivityPub;
using BirdsiteLive.ActivityPub.Converters;
using BirdsiteLive.ActivityPub.Models;
using BirdsiteLive.Common.Regexes;
using BirdsiteLive.Common.Settings;
using BirdsiteLive.Cryptography;
using BirdsiteLive.DAL.Contracts;
using BirdsiteLive.Domain.BusinessUseCases;
using BirdsiteLive.Domain.Repository;
using BirdsiteLive.Domain.Statistics;
using BirdsiteLive.Domain.Tools;
using BirdsiteLive.Twitter;
using BirdsiteLive.Twitter.Models;
using Tweetinvi.Core.Exceptions;
using Tweetinvi.Models;
namespace BirdsiteLive.Domain
{
public interface IUserService
{
Actor GetUser(TwitterUser twitterUser);
Task<bool> FollowRequestedAsync(
string signature,
string method,
string path,
string queryString,
Dictionary<string, string> requestHeaders,
ActivityFollow activity,
string body
);
Task<bool> UndoFollowRequestedAsync(
string signature,
string method,
string path,
string queryString,
Dictionary<string, string> requestHeaders,
ActivityUndoFollow activity,
string body
);
Task<bool> SendRejectFollowAsync(ActivityFollow activity, string followerHost);
}
public class UserService : IUserService
{
private readonly IProcessFollowUser _processFollowUser;
private readonly IProcessUndoFollowUser _processUndoFollowUser;
private readonly InstanceSettings _instanceSettings;
private readonly ICryptoService _cryptoService;
private readonly IActivityPubService _activityPubService;
private readonly IStatusExtractor _statusExtractor;
private readonly IExtractionStatisticsHandler _statisticsHandler;
private readonly ITwitterUserService _twitterUserService;
private readonly IModerationRepository _moderationRepository;
private readonly IFollowersDal _followerDal;
#region Ctor
public UserService(
InstanceSettings instanceSettings,
ICryptoService cryptoService,
IActivityPubService activityPubService,
IProcessFollowUser processFollowUser,
IProcessUndoFollowUser processUndoFollowUser,
IStatusExtractor statusExtractor,
IExtractionStatisticsHandler statisticsHandler,
ITwitterUserService twitterUserService,
IModerationRepository moderationRepository,
IFollowersDal followerDal
)
{
_instanceSettings = instanceSettings;
_cryptoService = cryptoService;
_activityPubService = activityPubService;
_processFollowUser = processFollowUser;
_processUndoFollowUser = processUndoFollowUser;
_statusExtractor = statusExtractor;
_statisticsHandler = statisticsHandler;
_twitterUserService = twitterUserService;
_moderationRepository = moderationRepository;
_followerDal = followerDal;
}
#endregion
public Actor GetUser(TwitterUser twitterUser)
{
var actorUrl = UrlFactory.GetActorUrl(_instanceSettings.Domain, twitterUser.Acct);
var acct = twitterUser.Acct.ToLowerInvariant();
// Extract links, mentions, etc
var description = twitterUser.Description;
if (!string.IsNullOrWhiteSpace(description))
{
var extracted = _statusExtractor.Extract(
description,
_instanceSettings.ResolveMentionsInProfiles
);
description = extracted.content;
_statisticsHandler.ExtractedDescription(
extracted.tags.Count(x => x.type == "Mention")
);
}
var attachments = new List<UserAttachment>();
attachments.Add(
new UserAttachment
{
type = "PropertyValue",
name =
_instanceSettings.TwitterDomainLabel != ""
? _instanceSettings.TwitterDomainLabel
: _instanceSettings.TwitterDomain,
value =
$"<a href=\"https://{_instanceSettings.TwitterDomain}/{acct}\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">{_instanceSettings.TwitterDomain}/{acct}</span></a>"
}
);
if (_instanceSettings.TwitterDomain != "twitter.com")
{
attachments.Add(
new UserAttachment
{
type = "PropertyValue",
name = "Twitter",
value = $"twitter.com/{acct}"
}
);
}
if (_instanceSettings.ShowAboutInstanceOnProfiles)
{
attachments.Add(
new UserAttachment
{
type = "PropertyValue",
name = $"About {_instanceSettings.Name}",
value =
$"<a href=\"https://{_instanceSettings.Domain}/About\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">{_instanceSettings.Domain}/About</span></a>"
}
);
}
var user = new Actor
{
id = actorUrl,
type = "Service",
followers = $"{actorUrl}/followers",
preferredUsername = acct,
name = twitterUser.Name,
inbox = $"{actorUrl}/inbox",
summary = description,
url = actorUrl,
manuallyApprovesFollowers = twitterUser.Protected,
publicKey = new PublicKey()
{
id = $"{actorUrl}#main-key",
owner = actorUrl,
publicKeyPem = _cryptoService.GetUserPem(acct)
},
icon = new Image { mediaType = "image/jpeg", url = twitterUser.ProfileImageUrl },
image = new Image { mediaType = "image/jpeg", url = twitterUser.ProfileBannerURL },
attachment = attachments.ToArray(),
endpoints = new EndPoints
{
sharedInbox = $"https://{_instanceSettings.Domain}/inbox"
}
};
if (twitterUser.Verified)
{
user.tag = new List<Tag>
{
new Tag
{
icon = new TagResource
{
type = "Image",
url = "https://" + _instanceSettings.Domain + "/verified.png"
},
id = "https://" + _instanceSettings.Domain + "/verified.png",
name = ":verified:",
type = "Emoji"
}
};
user.name += " :verified:";
}
return user;
}
public async Task<bool> FollowRequestedAsync(
string signature,
string method,
string path,
string queryString,
Dictionary<string, string> requestHeaders,
ActivityFollow activity,
string body
)
{
// Validate
var sigValidation = await ValidateSignature(
activity.actor,
signature,
method,
path,
queryString,
requestHeaders,
body
);
if (!sigValidation.SignatureIsValidated)
return false;
// Prepare data
var followerUserName = sigValidation.User.preferredUsername.ToLowerInvariant().Trim();
var followerHost = sigValidation.User.url
.Replace("https://", string.Empty)
.Split('/')
.First();
var followerInbox = sigValidation.User.inbox;
var followerSharedInbox = sigValidation.User?.endpoints?.sharedInbox;
var twitterUser = activity.apObject
.Split('/')
.Last()
.Replace("@", string.Empty)
.ToLowerInvariant()
.Trim();
// Make sure to only keep routes
followerInbox = OnlyKeepRoute(followerInbox, followerHost);
followerSharedInbox = OnlyKeepRoute(followerSharedInbox, followerHost);
// Validate Moderation status
var followerModPolicy = _moderationRepository.GetModerationType(
ModerationEntityTypeEnum.Follower
);
if (followerModPolicy != ModerationTypeEnum.None)
{
var followerStatus = _moderationRepository.CheckStatus(
ModerationEntityTypeEnum.Follower,
$"@{followerUserName}@{followerHost}"
);
if (
followerModPolicy == ModerationTypeEnum.WhiteListing
&& followerStatus != ModeratedTypeEnum.WhiteListed
|| followerModPolicy == ModerationTypeEnum.BlackListing
&& followerStatus == ModeratedTypeEnum.BlackListed
)
return await SendRejectFollowAsync(activity, followerHost);
}
// Validate TwitterAccount status
var twitterAccountModPolicy = _moderationRepository.GetModerationType(
ModerationEntityTypeEnum.TwitterAccount
);
if (twitterAccountModPolicy != ModerationTypeEnum.None)
{
var twitterUserStatus = _moderationRepository.CheckStatus(
ModerationEntityTypeEnum.TwitterAccount,
twitterUser
);
if (
twitterAccountModPolicy == ModerationTypeEnum.WhiteListing
&& twitterUserStatus != ModeratedTypeEnum.WhiteListed
|| twitterAccountModPolicy == ModerationTypeEnum.BlackListing
&& twitterUserStatus == ModeratedTypeEnum.BlackListed
)
return await SendRejectFollowAsync(activity, followerHost);
}
// Validate follower count < MaxFollowsPerUser
if (_instanceSettings.MaxFollowsPerUser > 0)
{
var follower = await _followerDal.GetFollowerAsync(followerUserName, followerHost);
if (
follower != null
&& follower.Followings.Count + 1 > _instanceSettings.MaxFollowsPerUser
)
{
return await SendRejectFollowAsync(activity, followerHost);
}
}
// Validate User Protected
var user = _twitterUserService.GetUser(twitterUser);
if (!user.Protected)
{
// Execute
await _processFollowUser.ExecuteAsync(
followerUserName,
followerHost,
twitterUser,
followerInbox,
followerSharedInbox,
activity.actor
);
return await SendAcceptFollowAsync(activity, followerHost);
}
else
{
return await SendRejectFollowAsync(activity, followerHost);
}
}
private async Task<bool> SendAcceptFollowAsync(ActivityFollow activity, string followerHost)
{
var acceptFollow = new ActivityAcceptFollow()
{
context = "https://www.w3.org/ns/activitystreams",
id = $"{activity.apObject}#accepts/follows/{Guid.NewGuid()}",
type = "Accept",
actor = activity.apObject,
apObject = new ActivityFollow()
{
id = activity.id,
type = activity.type,
actor = activity.actor,
apObject = activity.apObject
}
};
var result = await _activityPubService.PostDataAsync(
acceptFollow,
followerHost,
activity.apObject
);
return result == HttpStatusCode.Accepted || result == HttpStatusCode.OK; //TODO: revamp this for better error handling
}
public async Task<bool> SendRejectFollowAsync(ActivityFollow activity, string followerHost)
{
var acceptFollow = new ActivityRejectFollow()
{
context = "https://www.w3.org/ns/activitystreams",
id = $"{activity.apObject}#rejects/follows/{Guid.NewGuid()}",
type = "Reject",
actor = activity.apObject,
apObject = new ActivityFollow()
{
id = activity.id,
type = activity.type,
actor = activity.actor,
apObject = activity.apObject
}
};
var result = await _activityPubService.PostDataAsync(
acceptFollow,
followerHost,
activity.apObject
);
return result == HttpStatusCode.Accepted || result == HttpStatusCode.OK; //TODO: revamp this for better error handling
}
private string OnlyKeepRoute(string inbox, string host)
{
if (string.IsNullOrWhiteSpace(inbox))
return null;
if (inbox.Contains(host))
inbox = inbox.Split(new[] { host }, StringSplitOptions.RemoveEmptyEntries).Last();
return inbox;
}
public async Task<bool> UndoFollowRequestedAsync(
string signature,
string method,
string path,
string queryString,
Dictionary<string, string> requestHeaders,
ActivityUndoFollow activity,
string body
)
{
// Validate
var sigValidation = await ValidateSignature(
activity.actor,
signature,
method,
path,
queryString,
requestHeaders,
body
);
if (!sigValidation.SignatureIsValidated)
return false;
// Save Follow in DB
var followerUserName = sigValidation.User.preferredUsername.ToLowerInvariant();
var followerHost = sigValidation.User.url
.Replace("https://", string.Empty)
.Split('/')
.First();
//var followerInbox = sigValidation.User.inbox;
var twitterUser = activity.apObject.apObject
.Split('/')
.Last()
.Replace("@", string.Empty);
await _processUndoFollowUser.ExecuteAsync(followerUserName, followerHost, twitterUser);
// Send Accept Activity
var acceptFollow = new ActivityAcceptUndoFollow()
{
context = "https://www.w3.org/ns/activitystreams",
id = $"{activity.apObject.apObject}#accepts/undofollows/{Guid.NewGuid()}",
type = "Accept",
actor = activity.apObject.apObject,
apObject = new ActivityUndoFollow()
{
id = activity.id,
type = activity.type,
actor = activity.actor,
apObject = activity.apObject
}
};
var result = await _activityPubService.PostDataAsync(
acceptFollow,
followerHost,
activity.apObject.apObject
);
return result == HttpStatusCode.Accepted || result == HttpStatusCode.OK; //TODO: revamp this for better error handling
}
private async Task<SignatureValidationResult> ValidateSignature(
string actor,
string rawSig,
string method,
string path,
string queryString,
Dictionary<string, string> requestHeaders,
string body
)
{
//Check Date Validity
var date = requestHeaders["date"];
var d = DateTime.Parse(date).ToUniversalTime();
var now = DateTime.UtcNow;
var delta = Math.Abs((d - now).TotalSeconds);
if (delta > 30)
return new SignatureValidationResult { SignatureIsValidated = false };
//Check Digest
var digest = requestHeaders["digest"];
var digestHash = digest
.Split(new[] { "SHA-256=" }, StringSplitOptions.RemoveEmptyEntries)
.LastOrDefault();
var calculatedDigestHash = _cryptoService.ComputeSha256Hash(body);
if (digestHash != calculatedDigestHash)
return new SignatureValidationResult { SignatureIsValidated = false };
//Check Signature
var signatures = rawSig.Split(',');
var signature_header = new Dictionary<string, string>();
foreach (var signature in signatures)
{
var m = HeaderRegexes.HeaderSignature.Match(signature);
signature_header.Add(m.Groups[1].ToString(), m.Groups[2].ToString());
}
var key_id = signature_header["keyId"];
var headers = signature_header["headers"];
var algorithm = signature_header["algorithm"];
var sig = Convert.FromBase64String(signature_header["signature"]);
// Retrieve User
var remoteUser = await _activityPubService.GetUser(actor);
// Prepare Key data
var toDecode = remoteUser.publicKey.publicKeyPem
.Trim()
.Remove(0, remoteUser.publicKey.publicKeyPem.IndexOf('\n'));
toDecode = toDecode.Remove(toDecode.LastIndexOf('\n')).Replace("\n", "");
var signKey = ASN1.ToRSA(Convert.FromBase64String(toDecode));
var toSign = new StringBuilder();
foreach (var headerKey in headers.Split(' '))
{
if (headerKey == "(request-target)")
toSign.Append($"(request-target): {method.ToLower()} {path}{queryString}\n");
else
toSign.Append($"{headerKey}: {string.Join(", ", requestHeaders[headerKey])}\n");
}
toSign.Remove(toSign.Length - 1, 1);
// Import key
var key = new RSACryptoServiceProvider();
var rsaKeyInfo = key.ExportParameters(false);
rsaKeyInfo.Modulus = Convert.FromBase64String(toDecode);
key.ImportParameters(rsaKeyInfo);
// Trust and Verify
var result = signKey.VerifyData(
Encoding.UTF8.GetBytes(toSign.ToString()),
sig,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1
);
return new SignatureValidationResult()
{
SignatureIsValidated = result,
User = remoteUser
};
}
}
public class SignatureValidationResult
{
public bool SignatureIsValidated { get; set; }
public Actor User { get; set; }
}
}