diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml index b1fc70d..d28cb08 100644 --- a/.github/workflows/dotnet-core.yml +++ b/.github/workflows/dotnet-core.yml @@ -24,5 +24,5 @@ jobs: run: dotnet build --configuration Release --no-restore working-directory: ${{env.working-directory}} - name: Test - run: dotnet test --no-restore --verbosity quiet + run: dotnet test --no-restore --verbosity minimal working-directory: ${{env.working-directory}} diff --git a/VARIABLES.md b/VARIABLES.md index c00da5e..32176f9 100644 --- a/VARIABLES.md +++ b/VARIABLES.md @@ -2,6 +2,40 @@ You can configure some of BirdsiteLIVE's settings via environment variables (those are optionnals): +## Blacklisting & Whitelisting + +### Fediverse users and instances + +Here are the supported patterns to describe Fediverse users and/or instances: + +* `@user@instance.ext` to describe a Fediverse user +* `instance.ext` to describe an instance under a domain name +* `*.instance.ext` to describe instances from all subdomains of a domain name (this doesn't include the instance.ext, if you want both you need to add both) + +You can whitelist or blacklist fediverses users by settings the followings variables with the above patterns separated by `;`: + +* `Moderation:FollowersWhiteListing` Fediverse Whitelisting +* `Moderation:FollowersBlackListing` Fediverse Blacklisting + +If the whitelisting is set, only given patterns can follow twitter accounts on the instance. +If blacklisted, the given patterns can't follow twitter accounts on the instance. +If both whitelisting and blacklisting are set, only the whitelisting will be active. + +### Twitter users + +Here is the supported pattern to describe Twitter users: + +* `twitter_handle` to describe a Twitter user + +You can whitelist or blacklist twitter users by settings the followings variables with the above pattern separated by `;`: + +* `Moderation:TwitterAccountsWhiteListing` Twitter Whitelisting +* `Moderation:TwitterAccountsBlackListing` Twitter Blacklisting + +If the whitelisting is set, only given patterns can be followed on the instance. +If blacklisted, the given patterns can't be followed on the instance. +If both whitelisting and blacklisting are set, only the whitelisting will be active. + ## Logging * `Logging:Type` (default: none) set the type of the logging and monitoring system, currently the only type supported is `insights` for *Azure Application Insights* (PR welcome to support other types) @@ -11,4 +45,45 @@ You can configure some of BirdsiteLIVE's settings via environment variables (tho * `Instance:Name` (default: BirdsiteLIVE) the name of the instance * `Instance:ResolveMentionsInProfiles` (default: true) to enable or disable mentions parsing in profile's description. Resolving it will consume more User's API calls since newly discovered account can also contain references to others accounts as well. On a big instance it is recommended to disable it. -* `Instance:PublishReplies` (default: false) to enable or disable replies publishing. \ No newline at end of file +* `Instance:PublishReplies` (default: false) to enable or disable replies publishing. + +# Docker Compose full example + +In order to illustrate above variables, here is an example of an updated `docker-compose.yml` file: + +```diff +version: "3" + +networks: + [...] + +services: + server: + image: nicolasconstant/birdsitelive:latest + [...] + environment: + - Instance:Domain=domain.name + - Instance:AdminEmail=name@domain.ext + - Db:Type=postgres + - Db:Host=db + - Db:Name=birdsitelive + - Db:User=birdsitelive + - Db:Password=birdsitelive + - Twitter:ConsumerKey=twitter.api.key + - Twitter:ConsumerSecret=twitter.api.key ++ - Moderation:FollowersWhiteListing=@me@my-instance.ca;friend-instance.com;*.friend-instance.com ++ - Moderation:TwitterAccountsBlackListing=douchebag;jerk_88;theRealIdiot ++ - Instance:Name=MyTwitterRelay ++ - Instance:ResolveMentionsInProfiles=false ++ - Instance:PublishReplies=true + networks: + [...] + + db: + image: postgres:9.6 + [...] +``` + +## Apply the modifications + +After the modification of the `docker-compose.yml` file, you will need to run `docker-compose up -d` to apply the changes. diff --git a/src/BirdsiteLive.ActivityPub/ApDeserializer.cs b/src/BirdsiteLive.ActivityPub/ApDeserializer.cs index e577fbb..17dadbe 100644 --- a/src/BirdsiteLive.ActivityPub/ApDeserializer.cs +++ b/src/BirdsiteLive.ActivityPub/ApDeserializer.cs @@ -41,7 +41,6 @@ namespace BirdsiteLive.ActivityPub } }; return acceptFollow; - break; } break; } diff --git a/src/BirdsiteLive.Common/Settings/ModerationSettings.cs b/src/BirdsiteLive.Common/Settings/ModerationSettings.cs new file mode 100644 index 0000000..ad9fd54 --- /dev/null +++ b/src/BirdsiteLive.Common/Settings/ModerationSettings.cs @@ -0,0 +1,10 @@ +namespace BirdsiteLive.Common.Settings +{ + public class ModerationSettings + { + public string FollowersWhiteListing { get; set; } + public string FollowersBlackListing { get; set; } + public string TwitterAccountsWhiteListing { get; set; } + public string TwitterAccountsBlackListing { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs index ac657e4..c6618d9 100644 --- a/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs +++ b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs @@ -5,7 +5,7 @@ namespace BirdsiteLive.Domain.BusinessUseCases { public interface IProcessFollowUser { - Task ExecuteAsync(string followerUsername, string followerDomain, string twitterUsername, string followerInbox, string sharedInbox); + Task ExecuteAsync(string followerUsername, string followerDomain, string twitterUsername, string followerInbox, string sharedInbox, string followerActorId); } public class ProcessFollowUser : IProcessFollowUser @@ -21,13 +21,13 @@ namespace BirdsiteLive.Domain.BusinessUseCases } #endregion - public async Task ExecuteAsync(string followerUsername, string followerDomain, string twitterUsername, string followerInbox, string sharedInbox) + public async Task ExecuteAsync(string followerUsername, string followerDomain, string twitterUsername, string followerInbox, string sharedInbox, string followerActorId) { // Get Follower and Twitter Users var follower = await _followerDal.GetFollowerAsync(followerUsername, followerDomain); if (follower == null) { - await _followerDal.CreateFollowerAsync(followerUsername, followerDomain, followerInbox, sharedInbox); + await _followerDal.CreateFollowerAsync(followerUsername, followerDomain, followerInbox, sharedInbox, followerActorId); follower = await _followerDal.GetFollowerAsync(followerUsername, followerDomain); } diff --git a/src/BirdsiteLive.Domain/Repository/ModerationRepository.cs b/src/BirdsiteLive.Domain/Repository/ModerationRepository.cs new file mode 100644 index 0000000..f3e23f5 --- /dev/null +++ b/src/BirdsiteLive.Domain/Repository/ModerationRepository.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using BirdsiteLive.Common.Settings; +using BirdsiteLive.Domain.Tools; + +namespace BirdsiteLive.Domain.Repository +{ + public interface IModerationRepository + { + ModerationTypeEnum GetModerationType(ModerationEntityTypeEnum type); + ModeratedTypeEnum CheckStatus(ModerationEntityTypeEnum type, string entity); + } + + public class ModerationRepository : IModerationRepository + { + private readonly Regex[] _followersWhiteListing; + private readonly Regex[] _followersBlackListing; + private readonly Regex[] _twitterAccountsWhiteListing; + private readonly Regex[] _twitterAccountsBlackListing; + + private readonly Dictionary _modMode = + new Dictionary(); + + #region Ctor + public ModerationRepository(ModerationSettings settings) + { + var parsedFollowersWhiteListing = ModerationParser.Parse(settings.FollowersWhiteListing); + var parsedFollowersBlackListing = ModerationParser.Parse(settings.FollowersBlackListing); + var parsedTwitterAccountsWhiteListing = ModerationParser.Parse(settings.TwitterAccountsWhiteListing); + var parsedTwitterAccountsBlackListing = ModerationParser.Parse(settings.TwitterAccountsBlackListing); + + _followersWhiteListing = parsedFollowersWhiteListing + .Select(x => ModerationRegexParser.Parse(ModerationEntityTypeEnum.Follower, x)) + .ToArray(); + _followersBlackListing = parsedFollowersBlackListing + .Select(x => ModerationRegexParser.Parse(ModerationEntityTypeEnum.Follower, x)) + .ToArray(); + _twitterAccountsWhiteListing = parsedTwitterAccountsWhiteListing + .Select(x => ModerationRegexParser.Parse(ModerationEntityTypeEnum.TwitterAccount, x)) + .ToArray(); + _twitterAccountsBlackListing = parsedTwitterAccountsBlackListing + .Select(x => ModerationRegexParser.Parse(ModerationEntityTypeEnum.TwitterAccount, x)) + .ToArray(); + + // Set Follower moderation politic + if (_followersWhiteListing.Any()) + _modMode.Add(ModerationEntityTypeEnum.Follower, ModerationTypeEnum.WhiteListing); + else if (_followersBlackListing.Any()) + _modMode.Add(ModerationEntityTypeEnum.Follower, ModerationTypeEnum.BlackListing); + else + _modMode.Add(ModerationEntityTypeEnum.Follower, ModerationTypeEnum.None); + + // Set Twitter account moderation politic + if (_twitterAccountsWhiteListing.Any()) + _modMode.Add(ModerationEntityTypeEnum.TwitterAccount, ModerationTypeEnum.WhiteListing); + else if (_twitterAccountsBlackListing.Any()) + _modMode.Add(ModerationEntityTypeEnum.TwitterAccount, ModerationTypeEnum.BlackListing); + else + _modMode.Add(ModerationEntityTypeEnum.TwitterAccount, ModerationTypeEnum.None); + } + #endregion + + public ModerationTypeEnum GetModerationType(ModerationEntityTypeEnum type) + { + return _modMode[type]; + } + + public ModeratedTypeEnum CheckStatus(ModerationEntityTypeEnum type, string entity) + { + if (_modMode[type] == ModerationTypeEnum.None) return ModeratedTypeEnum.None; + + switch (type) + { + case ModerationEntityTypeEnum.Follower: + return ProcessFollower(entity); + case ModerationEntityTypeEnum.TwitterAccount: + return ProcessTwitterAccount(entity); + } + + throw new NotImplementedException($"Type {type} is not supported"); + } + + private ModeratedTypeEnum ProcessFollower(string entity) + { + var politic = _modMode[ModerationEntityTypeEnum.Follower]; + + switch (politic) + { + case ModerationTypeEnum.None: + return ModeratedTypeEnum.None; + case ModerationTypeEnum.BlackListing: + if (_followersBlackListing.Any(x => x.IsMatch(entity))) + return ModeratedTypeEnum.BlackListed; + return ModeratedTypeEnum.None; + case ModerationTypeEnum.WhiteListing: + if (_followersWhiteListing.Any(x => x.IsMatch(entity))) + return ModeratedTypeEnum.WhiteListed; + return ModeratedTypeEnum.None; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private ModeratedTypeEnum ProcessTwitterAccount(string entity) + { + var politic = _modMode[ModerationEntityTypeEnum.TwitterAccount]; + + switch (politic) + { + case ModerationTypeEnum.None: + return ModeratedTypeEnum.None; + case ModerationTypeEnum.BlackListing: + if (_twitterAccountsBlackListing.Any(x => x.IsMatch(entity))) + return ModeratedTypeEnum.BlackListed; + return ModeratedTypeEnum.None; + case ModerationTypeEnum.WhiteListing: + if (_twitterAccountsWhiteListing.Any(x => x.IsMatch(entity))) + return ModeratedTypeEnum.WhiteListed; + return ModeratedTypeEnum.None; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + public enum ModerationEntityTypeEnum + { + Unknown = 0, + Follower = 1, + TwitterAccount = 2 + } + + public enum ModerationTypeEnum + { + None = 0, + BlackListing = 1, + WhiteListing = 2 + } + + public enum ModeratedTypeEnum + { + None = 0, + BlackListed = 1, + WhiteListed = 2 + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/StatusService.cs b/src/BirdsiteLive.Domain/StatusService.cs index 4983de1..e25d52b 100644 --- a/src/BirdsiteLive.Domain/StatusService.cs +++ b/src/BirdsiteLive.Domain/StatusService.cs @@ -41,7 +41,6 @@ namespace BirdsiteLive.Domain var noteUrl = UrlFactory.GetNoteUrl(_instanceSettings.Domain, username, tweet.Id.ToString()); var to = $"{actorUrl}/followers"; - var apPublic = "https://www.w3.org/ns/activitystreams#Public"; var extractedTags = _statusExtractor.Extract(tweet.MessageContent); _statisticsHandler.ExtractedStatus(extractedTags.tags.Count(x => x.type == "Mention")); @@ -70,11 +69,9 @@ namespace BirdsiteLive.Domain attributedTo = actorUrl, inReplyTo = inReplyTo, - //to = new [] {to}, - //cc = new [] { apPublic }, to = new[] { to }, - //cc = new[] { apPublic }, + //cc = new[] { "https://www.w3.org/ns/activitystreams#Public" }, cc = new string[0], sensitive = false, diff --git a/src/BirdsiteLive.Domain/Tools/ModerationParser.cs b/src/BirdsiteLive.Domain/Tools/ModerationParser.cs new file mode 100644 index 0000000..a338b20 --- /dev/null +++ b/src/BirdsiteLive.Domain/Tools/ModerationParser.cs @@ -0,0 +1,23 @@ +using System; +using System.Linq; + +namespace BirdsiteLive.Domain.Tools +{ + public class ModerationParser + { + public static string[] Parse(string entry) + { + if (string.IsNullOrWhiteSpace(entry)) return new string[0]; + + var separationChar = '|'; + if (entry.Contains(";")) separationChar = ';'; + else if (entry.Contains(",")) separationChar = ','; + + var splitEntries = entry + .Split(new[] {separationChar}, StringSplitOptions.RemoveEmptyEntries) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.ToLowerInvariant().Trim()); + return splitEntries.ToArray(); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/Tools/ModerationRegexParser.cs b/src/BirdsiteLive.Domain/Tools/ModerationRegexParser.cs new file mode 100644 index 0000000..6f4df11 --- /dev/null +++ b/src/BirdsiteLive.Domain/Tools/ModerationRegexParser.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.RegularExpressions; +using BirdsiteLive.Domain.Repository; +using Org.BouncyCastle.Pkcs; + +namespace BirdsiteLive.Domain.Tools +{ + public class ModerationRegexParser + { + public static Regex Parse(ModerationEntityTypeEnum type, string data) + { + data = data.ToLowerInvariant().Trim(); + + if (type == ModerationEntityTypeEnum.Follower) + { + if (data.StartsWith("@")) + return new Regex($@"^{data}$"); + + if (data.StartsWith("*")) + data = data.Replace("*", "(.+)"); + + return new Regex($@"^@(.+)@{data}$"); + } + + return new Regex($@"^{data}$"); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/UserService.cs b/src/BirdsiteLive.Domain/UserService.cs index a72f017..24c8287 100644 --- a/src/BirdsiteLive.Domain/UserService.cs +++ b/src/BirdsiteLive.Domain/UserService.cs @@ -11,6 +11,7 @@ using BirdsiteLive.Common.Regexes; using BirdsiteLive.Common.Settings; using BirdsiteLive.Cryptography; using BirdsiteLive.Domain.BusinessUseCases; +using BirdsiteLive.Domain.Repository; using BirdsiteLive.Domain.Statistics; using BirdsiteLive.Domain.Tools; using BirdsiteLive.Twitter; @@ -25,6 +26,8 @@ namespace BirdsiteLive.Domain Actor GetUser(TwitterUser twitterUser); Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity, string body); Task UndoFollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityUndoFollow activity, string body); + + Task SendRejectFollowAsync(ActivityFollow activity, string followerHost); } public class UserService : IUserService @@ -40,8 +43,10 @@ namespace BirdsiteLive.Domain private readonly ITwitterUserService _twitterUserService; + private readonly IModerationRepository _moderationRepository; + #region Ctor - public UserService(InstanceSettings instanceSettings, ICryptoService cryptoService, IActivityPubService activityPubService, IProcessFollowUser processFollowUser, IProcessUndoFollowUser processUndoFollowUser, IStatusExtractor statusExtractor, IExtractionStatisticsHandler statisticsHandler, ITwitterUserService twitterUserService) + public UserService(InstanceSettings instanceSettings, ICryptoService cryptoService, IActivityPubService activityPubService, IProcessFollowUser processFollowUser, IProcessUndoFollowUser processUndoFollowUser, IStatusExtractor statusExtractor, IExtractionStatisticsHandler statisticsHandler, ITwitterUserService twitterUserService, IModerationRepository moderationRepository) { _instanceSettings = instanceSettings; _cryptoService = cryptoService; @@ -51,6 +56,7 @@ namespace BirdsiteLive.Domain _statusExtractor = statusExtractor; _statisticsHandler = statisticsHandler; _twitterUserService = twitterUserService; + _moderationRepository = moderationRepository; } #endregion @@ -119,62 +125,94 @@ namespace BirdsiteLive.Domain 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(); + // 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); + 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 User Protected var user = _twitterUserService.GetUser(twitterUser); if (!user.Protected) { // Execute - await _processFollowUser.ExecuteAsync(followerUserName, followerHost, twitterUser, followerInbox, followerSharedInbox); + await _processFollowUser.ExecuteAsync(followerUserName, followerHost, twitterUser, followerInbox, followerSharedInbox, activity.actor); - // Send Accept Activity - 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 + return await SendAcceptFollowAsync(activity, followerHost); } else { - // Send Reject Activity - 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 + return await SendRejectFollowAsync(activity, followerHost); } } + + private async Task 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 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) { diff --git a/src/BirdsiteLive.Moderation/Actions/RejectAllFollowingsAction.cs b/src/BirdsiteLive.Moderation/Actions/RejectAllFollowingsAction.cs new file mode 100644 index 0000000..9a1b1bc --- /dev/null +++ b/src/BirdsiteLive.Moderation/Actions/RejectAllFollowingsAction.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using BirdsiteLive.ActivityPub; +using BirdsiteLive.ActivityPub.Converters; +using BirdsiteLive.Common.Settings; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Domain; + +namespace BirdsiteLive.Moderation.Actions +{ + public interface IRejectAllFollowingsAction + { + Task ProcessAsync(Follower follower); + } + + public class RejectAllFollowingsAction : IRejectAllFollowingsAction + { + private readonly ITwitterUserDal _twitterUserDal; + private readonly IUserService _userService; + private readonly InstanceSettings _instanceSettings; + + #region Ctor + public RejectAllFollowingsAction(ITwitterUserDal twitterUserDal, IUserService userService, InstanceSettings instanceSettings) + { + _twitterUserDal = twitterUserDal; + _userService = userService; + _instanceSettings = instanceSettings; + } + #endregion + + public async Task ProcessAsync(Follower follower) + { + foreach (var following in follower.Followings) + { + try + { + var f = await _twitterUserDal.GetTwitterUserAsync(following); + var activityFollowing = new ActivityFollow + { + type = "Follow", + actor = follower.ActorId, + apObject = UrlFactory.GetActorUrl(_instanceSettings.Domain, f.Acct) + }; + + await _userService.SendRejectFollowAsync(activityFollowing, follower.Host); + } + catch (Exception) { } + } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Moderation/Actions/RejectFollowingAction.cs b/src/BirdsiteLive.Moderation/Actions/RejectFollowingAction.cs new file mode 100644 index 0000000..c5963eb --- /dev/null +++ b/src/BirdsiteLive.Moderation/Actions/RejectFollowingAction.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; +using BirdsiteLive.ActivityPub; +using BirdsiteLive.ActivityPub.Converters; +using BirdsiteLive.Common.Settings; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Domain; + +namespace BirdsiteLive.Moderation.Actions +{ + public interface IRejectFollowingAction + { + Task ProcessAsync(Follower follower, SyncTwitterUser twitterUser); + } + + public class RejectFollowingAction : IRejectFollowingAction + { + private readonly IUserService _userService; + private readonly InstanceSettings _instanceSettings; + + #region Ctor + public RejectFollowingAction(IUserService userService, InstanceSettings instanceSettings) + { + _userService = userService; + _instanceSettings = instanceSettings; + } + #endregion + + public async Task ProcessAsync(Follower follower, SyncTwitterUser twitterUser) + { + try + { + var activityFollowing = new ActivityFollow + { + type = "Follow", + actor = follower.ActorId, + apObject = UrlFactory.GetActorUrl(_instanceSettings.Domain, twitterUser.Acct) + }; + await _userService.SendRejectFollowAsync(activityFollowing, follower.Host); + } + catch (Exception) { } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Moderation/Actions/RemoveFollowerAction.cs b/src/BirdsiteLive.Moderation/Actions/RemoveFollowerAction.cs new file mode 100644 index 0000000..8ab3132 --- /dev/null +++ b/src/BirdsiteLive.Moderation/Actions/RemoveFollowerAction.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using BirdsiteLive.ActivityPub; +using BirdsiteLive.ActivityPub.Converters; +using BirdsiteLive.Common.Settings; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Domain; + +namespace BirdsiteLive.Moderation.Actions +{ + public interface IRemoveFollowerAction + { + Task ProcessAsync(Follower follower); + } + + public class RemoveFollowerAction : IRemoveFollowerAction + { + private readonly IFollowersDal _followersDal; + private readonly ITwitterUserDal _twitterUserDal; + private readonly IRejectAllFollowingsAction _rejectAllFollowingsAction; + + #region Ctor + public RemoveFollowerAction(IFollowersDal followersDal, ITwitterUserDal twitterUserDal, IRejectAllFollowingsAction rejectAllFollowingsAction) + { + _followersDal = followersDal; + _twitterUserDal = twitterUserDal; + _rejectAllFollowingsAction = rejectAllFollowingsAction; + } + #endregion + + public async Task ProcessAsync(Follower follower) + { + // Perform undo following to user instance + await _rejectAllFollowingsAction.ProcessAsync(follower); + + // Remove twitter users if no more followers + var followings = follower.Followings; + foreach (var following in followings) + { + var followers = await _followersDal.GetFollowersAsync(following); + if (followers.Length == 1 && followers.First().Id == follower.Id) + await _twitterUserDal.DeleteTwitterUserAsync(following); + } + + // Remove follower from DB + await _followersDal.DeleteFollowerAsync(follower.Id); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Moderation/Actions/RemoveTwitterAccountAction.cs b/src/BirdsiteLive.Moderation/Actions/RemoveTwitterAccountAction.cs new file mode 100644 index 0000000..714cca6 --- /dev/null +++ b/src/BirdsiteLive.Moderation/Actions/RemoveTwitterAccountAction.cs @@ -0,0 +1,57 @@ +using System.Linq; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; + +namespace BirdsiteLive.Moderation.Actions +{ + public interface IRemoveTwitterAccountAction + { + Task ProcessAsync(SyncTwitterUser twitterUser); + } + + public class RemoveTwitterAccountAction : IRemoveTwitterAccountAction + { + private readonly IFollowersDal _followersDal; + private readonly ITwitterUserDal _twitterUserDal; + private readonly IRejectFollowingAction _rejectFollowingAction; + + #region Ctor + public RemoveTwitterAccountAction(IFollowersDal followersDal, ITwitterUserDal twitterUserDal, IRejectFollowingAction rejectFollowingAction) + { + _followersDal = followersDal; + _twitterUserDal = twitterUserDal; + _rejectFollowingAction = rejectFollowingAction; + } + #endregion + + public async Task ProcessAsync(SyncTwitterUser twitterUser) + { + // Check Followers + var twitterUserId = twitterUser.Id; + var followers = await _followersDal.GetFollowersAsync(twitterUserId); + + // Remove all Followers + foreach (var follower in followers) + { + // Perform undo following to user instance + await _rejectFollowingAction.ProcessAsync(follower, twitterUser); + + // Remove following from DB + if (follower.Followings.Contains(twitterUserId)) + follower.Followings.Remove(twitterUserId); + + if (follower.FollowingsSyncStatus.ContainsKey(twitterUserId)) + follower.FollowingsSyncStatus.Remove(twitterUserId); + + if (follower.Followings.Any()) + await _followersDal.UpdateFollowerAsync(follower); + else + await _followersDal.DeleteFollowerAsync(follower.Id); + } + + // Remove twitter user + await _twitterUserDal.DeleteTwitterUserAsync(twitterUserId); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Moderation/BirdsiteLive.Moderation.csproj b/src/BirdsiteLive.Moderation/BirdsiteLive.Moderation.csproj new file mode 100644 index 0000000..b7bbeea --- /dev/null +++ b/src/BirdsiteLive.Moderation/BirdsiteLive.Moderation.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + + + + + + + + + + + + diff --git a/src/BirdsiteLive.Moderation/ModerationPipeline.cs b/src/BirdsiteLive.Moderation/ModerationPipeline.cs new file mode 100644 index 0000000..bc92a17 --- /dev/null +++ b/src/BirdsiteLive.Moderation/ModerationPipeline.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading.Tasks; +using BirdsiteLive.Domain.Repository; +using BirdsiteLive.Moderation.Processors; +using Microsoft.Extensions.Logging; + +namespace BirdsiteLive.Moderation +{ + public interface IModerationPipeline + { + Task ApplyModerationSettingsAsync(); + } + + public class ModerationPipeline : IModerationPipeline + { + private readonly IModerationRepository _moderationRepository; + private readonly IFollowerModerationProcessor _followerModerationProcessor; + private readonly ITwitterAccountModerationProcessor _twitterAccountModerationProcessor; + + private readonly ILogger _logger; + + #region Ctor + public ModerationPipeline(IModerationRepository moderationRepository, IFollowerModerationProcessor followerModerationProcessor, ITwitterAccountModerationProcessor twitterAccountModerationProcessor, ILogger logger) + { + _moderationRepository = moderationRepository; + _followerModerationProcessor = followerModerationProcessor; + _twitterAccountModerationProcessor = twitterAccountModerationProcessor; + _logger = logger; + } + #endregion + + public async Task ApplyModerationSettingsAsync() + { + try + { + await CheckFollowerModerationPolicyAsync(); + await CheckTwitterAccountModerationPolicyAsync(); + } + catch (Exception e) + { + _logger.LogCritical(e, "ModerationPipeline execution failed."); + } + } + + private async Task CheckFollowerModerationPolicyAsync() + { + var followerPolicy = _moderationRepository.GetModerationType(ModerationEntityTypeEnum.Follower); + if (followerPolicy == ModerationTypeEnum.None) return; + + await _followerModerationProcessor.ProcessAsync(followerPolicy); + } + + private async Task CheckTwitterAccountModerationPolicyAsync() + { + var twitterAccountPolicy = _moderationRepository.GetModerationType(ModerationEntityTypeEnum.TwitterAccount); + if (twitterAccountPolicy == ModerationTypeEnum.None) return; + + await _twitterAccountModerationProcessor.ProcessAsync(twitterAccountPolicy); + } + } +} diff --git a/src/BirdsiteLive.Moderation/Processors/FollowerModerationProcessor.cs b/src/BirdsiteLive.Moderation/Processors/FollowerModerationProcessor.cs new file mode 100644 index 0000000..99d72f6 --- /dev/null +++ b/src/BirdsiteLive.Moderation/Processors/FollowerModerationProcessor.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Domain.Repository; +using BirdsiteLive.Moderation.Actions; + +namespace BirdsiteLive.Moderation.Processors +{ + public interface IFollowerModerationProcessor + { + Task ProcessAsync(ModerationTypeEnum type); + } + + public class FollowerModerationProcessor : IFollowerModerationProcessor + { + private readonly IFollowersDal _followersDal; + private readonly IModerationRepository _moderationRepository; + private readonly IRemoveFollowerAction _removeFollowerAction; + + #region Ctor + public FollowerModerationProcessor(IFollowersDal followersDal, IModerationRepository moderationRepository, IRemoveFollowerAction removeFollowerAction) + { + _followersDal = followersDal; + _moderationRepository = moderationRepository; + _removeFollowerAction = removeFollowerAction; + } + #endregion + + public async Task ProcessAsync(ModerationTypeEnum type) + { + if (type == ModerationTypeEnum.None) return; + + var followers = await _followersDal.GetAllFollowersAsync(); + + foreach (var follower in followers) + { + var followerHandle = $"@{follower.Acct.Trim()}@{follower.Host.Trim()}".ToLowerInvariant(); + var status = _moderationRepository.CheckStatus(ModerationEntityTypeEnum.Follower, followerHandle); + + if (type == ModerationTypeEnum.WhiteListing && status != ModeratedTypeEnum.WhiteListed || + type == ModerationTypeEnum.BlackListing && status == ModeratedTypeEnum.BlackListed) + await _removeFollowerAction.ProcessAsync(follower); + } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Moderation/Processors/TwitterAccountModerationProcessor.cs b/src/BirdsiteLive.Moderation/Processors/TwitterAccountModerationProcessor.cs new file mode 100644 index 0000000..91e3931 --- /dev/null +++ b/src/BirdsiteLive.Moderation/Processors/TwitterAccountModerationProcessor.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Domain.Repository; +using BirdsiteLive.Moderation.Actions; + +namespace BirdsiteLive.Moderation.Processors +{ + public interface ITwitterAccountModerationProcessor + { + Task ProcessAsync(ModerationTypeEnum type); + } + + public class TwitterAccountModerationProcessor : ITwitterAccountModerationProcessor + { + private readonly ITwitterUserDal _twitterUserDal; + private readonly IModerationRepository _moderationRepository; + private readonly IRemoveTwitterAccountAction _removeTwitterAccountAction; + + #region Ctor + public TwitterAccountModerationProcessor(ITwitterUserDal twitterUserDal, IModerationRepository moderationRepository, IRemoveTwitterAccountAction removeTwitterAccountAction) + { + _twitterUserDal = twitterUserDal; + _moderationRepository = moderationRepository; + _removeTwitterAccountAction = removeTwitterAccountAction; + } + #endregion + + public async Task ProcessAsync(ModerationTypeEnum type) + { + if (type == ModerationTypeEnum.None) return; + + var twitterUsers = await _twitterUserDal.GetAllTwitterUsersAsync(); + + foreach (var user in twitterUsers) + { + var userHandle = user.Acct.ToLowerInvariant().Trim(); + var status = _moderationRepository.CheckStatus(ModerationEntityTypeEnum.TwitterAccount, userHandle); + + if (type == ModerationTypeEnum.WhiteListing && status != ModeratedTypeEnum.WhiteListed || + type == ModerationTypeEnum.BlackListing && status == ModeratedTypeEnum.BlackListed) + await _removeTwitterAccountAction.ProcessAsync(user); + } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.sln b/src/BirdsiteLive.sln index 1e9d65b..737b8b1 100644 --- a/src/BirdsiteLive.sln +++ b/src/BirdsiteLive.sln @@ -41,6 +41,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.Pipeline.Tests EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.DAL.Tests", "Tests\BirdsiteLive.DAL.Tests\BirdsiteLive.DAL.Tests.csproj", "{5A1E3EB5-6CBB-470D-8A0D-10F8C18353D5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.Moderation", "BirdsiteLive.Moderation\BirdsiteLive.Moderation.csproj", "{4BE541AC-8A93-4FA3-98AC-956CC2D5B748}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BirdsiteLive.Moderation.Tests", "Tests\BirdsiteLive.Moderation.Tests\BirdsiteLive.Moderation.Tests.csproj", "{0A311BF3-4FD9-4303-940A-A3778890561C}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.Common.Tests", "Tests\BirdsiteLive.Common.Tests\BirdsiteLive.Common.Tests.csproj", "{C69F7582-6050-44DC-BAAB-7C8F0BDA525C}" EndProject Global @@ -109,6 +113,14 @@ Global {5A1E3EB5-6CBB-470D-8A0D-10F8C18353D5}.Debug|Any CPU.Build.0 = Debug|Any CPU {5A1E3EB5-6CBB-470D-8A0D-10F8C18353D5}.Release|Any CPU.ActiveCfg = Release|Any CPU {5A1E3EB5-6CBB-470D-8A0D-10F8C18353D5}.Release|Any CPU.Build.0 = Release|Any CPU + {4BE541AC-8A93-4FA3-98AC-956CC2D5B748}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BE541AC-8A93-4FA3-98AC-956CC2D5B748}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BE541AC-8A93-4FA3-98AC-956CC2D5B748}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BE541AC-8A93-4FA3-98AC-956CC2D5B748}.Release|Any CPU.Build.0 = Release|Any CPU + {0A311BF3-4FD9-4303-940A-A3778890561C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A311BF3-4FD9-4303-940A-A3778890561C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A311BF3-4FD9-4303-940A-A3778890561C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A311BF3-4FD9-4303-940A-A3778890561C}.Release|Any CPU.Build.0 = Release|Any CPU {C69F7582-6050-44DC-BAAB-7C8F0BDA525C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C69F7582-6050-44DC-BAAB-7C8F0BDA525C}.Debug|Any CPU.Build.0 = Debug|Any CPU {C69F7582-6050-44DC-BAAB-7C8F0BDA525C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -132,6 +144,8 @@ Global {F544D745-89A8-4DEA-B61C-A7E6C53C1D63} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} {BF51CA81-5A7A-46F8-B4FB-861C6BE59298} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} {5A1E3EB5-6CBB-470D-8A0D-10F8C18353D5} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} + {4BE541AC-8A93-4FA3-98AC-956CC2D5B748} = {DA3C160C-4811-4E26-A5AD-42B81FAF2D7C} + {0A311BF3-4FD9-4303-940A-A3778890561C} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} {C69F7582-6050-44DC-BAAB-7C8F0BDA525C} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/src/BirdsiteLive/BirdsiteLive.csproj b/src/BirdsiteLive/BirdsiteLive.csproj index 038392d..e57b4a4 100644 --- a/src/BirdsiteLive/BirdsiteLive.csproj +++ b/src/BirdsiteLive/BirdsiteLive.csproj @@ -4,7 +4,7 @@ netcoreapp3.1 d21486de-a812-47eb-a419-05682bb68856 Linux - 0.14.5 + 0.15.0 @@ -18,6 +18,7 @@ + diff --git a/src/BirdsiteLive/Component/NodeInfoViewComponent.cs b/src/BirdsiteLive/Component/NodeInfoViewComponent.cs new file mode 100644 index 0000000..deb10a9 --- /dev/null +++ b/src/BirdsiteLive/Component/NodeInfoViewComponent.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Domain.Repository; +using BirdsiteLive.Services; +using BirdsiteLive.Statistics.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace BirdsiteLive.Component +{ + public class NodeInfoViewComponent : ViewComponent + { + private readonly IModerationRepository _moderationRepository; + private readonly ICachedStatisticsService _cachedStatisticsService; + + #region Ctor + public NodeInfoViewComponent(IModerationRepository moderationRepository, ICachedStatisticsService cachedStatisticsService) + { + _moderationRepository = moderationRepository; + _cachedStatisticsService = cachedStatisticsService; + } + #endregion + + public async Task InvokeAsync() + { + var followerPolicy = _moderationRepository.GetModerationType(ModerationEntityTypeEnum.Follower); + var twitterAccountPolicy = _moderationRepository.GetModerationType(ModerationEntityTypeEnum.TwitterAccount); + + var statistics = await _cachedStatisticsService.GetStatisticsAsync(); + + var viewModel = new NodeInfoViewModel + { + BlacklistingEnabled = followerPolicy == ModerationTypeEnum.BlackListing || + twitterAccountPolicy == ModerationTypeEnum.BlackListing, + WhitelistingEnabled = followerPolicy == ModerationTypeEnum.WhiteListing || + twitterAccountPolicy == ModerationTypeEnum.WhiteListing, + InstanceSaturation = statistics.Saturation + }; + + //viewModel = new NodeInfoViewModel + //{ + // BlacklistingEnabled = false, + // WhitelistingEnabled = false, + // InstanceSaturation = 175 + //}; + return View(viewModel); + } + } + + public class NodeInfoViewModel + { + public bool BlacklistingEnabled { get; set; } + public bool WhitelistingEnabled { get; set; } + public int InstanceSaturation { get; set; } + } +} diff --git a/src/BirdsiteLive/Controllers/AboutController.cs b/src/BirdsiteLive/Controllers/AboutController.cs new file mode 100644 index 0000000..e64a147 --- /dev/null +++ b/src/BirdsiteLive/Controllers/AboutController.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BirdsiteLive.Domain.Repository; +using BirdsiteLive.Services; + +namespace BirdsiteLive.Controllers +{ + public class AboutController : Controller + { + private readonly IModerationRepository _moderationRepository; + private readonly ICachedStatisticsService _cachedStatisticsService; + + #region Ctor + public AboutController(IModerationRepository moderationRepository, ICachedStatisticsService cachedStatisticsService) + { + _moderationRepository = moderationRepository; + _cachedStatisticsService = cachedStatisticsService; + } + #endregion + + public async Task Index() + { + var stats = await _cachedStatisticsService.GetStatisticsAsync(); + return View(stats); + } + + public IActionResult Blacklisting() + { + var status = GetModerationStatus(); + return View("Blacklisting", status); + } + + public IActionResult Whitelisting() + { + var status = GetModerationStatus(); + return View("Whitelisting", status); + } + + private ModerationStatus GetModerationStatus() + { + var status = new ModerationStatus + { + Followers = _moderationRepository.GetModerationType(ModerationEntityTypeEnum.Follower), + TwitterAccounts = _moderationRepository.GetModerationType(ModerationEntityTypeEnum.TwitterAccount) + }; + return status; + } + } + + public class ModerationStatus + { + public ModerationTypeEnum Followers { get; set; } + public ModerationTypeEnum TwitterAccounts { get; set; } + } +} diff --git a/src/BirdsiteLive/Controllers/DebugingController.cs b/src/BirdsiteLive/Controllers/DebugingController.cs index 12ac90e..ff8f0b4 100644 --- a/src/BirdsiteLive/Controllers/DebugingController.cs +++ b/src/BirdsiteLive/Controllers/DebugingController.cs @@ -19,13 +19,15 @@ namespace BirdsiteLive.Controllers private readonly InstanceSettings _instanceSettings; private readonly ICryptoService _cryptoService; private readonly IActivityPubService _activityPubService; + private readonly IUserService _userService; #region Ctor - public DebugingController(InstanceSettings instanceSettings, ICryptoService cryptoService, IActivityPubService activityPubService) + public DebugingController(InstanceSettings instanceSettings, ICryptoService cryptoService, IActivityPubService activityPubService, IUserService userService) { _instanceSettings = instanceSettings; _cryptoService = cryptoService; _activityPubService = activityPubService; + _userService = userService; } #endregion @@ -67,7 +69,6 @@ namespace BirdsiteLive.Controllers var noteUrl = $"https://{_instanceSettings.Domain}/@{username}/{noteGuid}"; var to = $"{actor}/followers"; - var apPublic = "https://www.w3.org/ns/activitystreams#Public"; var now = DateTime.UtcNow; var nowString = now.ToString("s") + "Z"; @@ -80,7 +81,7 @@ namespace BirdsiteLive.Controllers actor = actor, published = nowString, to = new []{ to }, - //cc = new [] { apPublic }, + //cc = new [] { "https://www.w3.org/ns/activitystreams#Public" }, apObject = new Note() { id = noteId, @@ -90,7 +91,7 @@ namespace BirdsiteLive.Controllers url = noteUrl, attributedTo = actor, to = new[] { to }, - //cc = new [] { apPublic }, + //cc = new [] { "https://www.w3.org/ns/activitystreams#Public" }, sensitive = false, content = "

Woooot

", attachment = new Attachment[0], @@ -102,6 +103,20 @@ namespace BirdsiteLive.Controllers return View("Index"); } + + [HttpPost] + public async Task PostRejectFollow() + { + var activityFollow = new ActivityFollow + { + type = "Follow", + actor = "https://mastodon.technology/users/testtest", + apObject = $"https://{_instanceSettings.Domain}/users/afp" + }; + + await _userService.SendRejectFollowAsync(activityFollow, "mastodon.technology"); + return View("Index"); + } } public static class HtmlHelperExtensions diff --git a/src/BirdsiteLive/Controllers/UsersController.cs b/src/BirdsiteLive/Controllers/UsersController.cs index 0fbefcd..a06b1e9 100644 --- a/src/BirdsiteLive/Controllers/UsersController.cs +++ b/src/BirdsiteLive/Controllers/UsersController.cs @@ -159,13 +159,11 @@ namespace BirdsiteLive.Controllers return Accepted(); } } - - return Accepted(); } [Route("/users/{id}/followers")] [HttpGet] - public async Task Followers(string id) + public IActionResult Followers(string id) { var r = Request.Headers["Accept"].First(); if (!r.Contains("application/activity+json")) return NotFound(); diff --git a/src/BirdsiteLive/Controllers/WellKnownController.cs b/src/BirdsiteLive/Controllers/WellKnownController.cs index 4151ab0..501f783 100644 --- a/src/BirdsiteLive/Controllers/WellKnownController.cs +++ b/src/BirdsiteLive/Controllers/WellKnownController.cs @@ -7,6 +7,7 @@ using BirdsiteLive.ActivityPub.Converters; using BirdsiteLive.Common.Regexes; using BirdsiteLive.Common.Settings; using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Domain.Repository; using BirdsiteLive.Models; using BirdsiteLive.Models.WellKnownModels; using BirdsiteLive.Twitter; @@ -18,15 +19,17 @@ namespace BirdsiteLive.Controllers [ApiController] public class WellKnownController : ControllerBase { + private readonly IModerationRepository _moderationRepository; private readonly ITwitterUserService _twitterUserService; private readonly ITwitterUserDal _twitterUserDal; private readonly InstanceSettings _settings; #region Ctor - public WellKnownController(InstanceSettings settings, ITwitterUserService twitterUserService, ITwitterUserDal twitterUserDal) + public WellKnownController(InstanceSettings settings, ITwitterUserService twitterUserService, ITwitterUserDal twitterUserDal, IModerationRepository moderationRepository) { _twitterUserService = twitterUserService; _twitterUserDal = twitterUserDal; + _moderationRepository = moderationRepository; _settings = settings; } #endregion @@ -58,6 +61,7 @@ namespace BirdsiteLive.Controllers { var version = System.Reflection.Assembly.GetEntryAssembly().GetName().Version.ToString(3); var twitterUsersCount = await _twitterUserDal.GetTwitterUsersCountAsync(); + var isOpenRegistration = _moderationRepository.GetModerationType(ModerationEntityTypeEnum.Follower) != ModerationTypeEnum.WhiteListing; if (id == "2.0") { @@ -81,7 +85,7 @@ namespace BirdsiteLive.Controllers { "activitypub" }, - openRegistrations = false, + openRegistrations = isOpenRegistration, services = new Models.WellKnownModels.Services() { inbound = new object[0], @@ -117,7 +121,7 @@ namespace BirdsiteLive.Controllers { "activitypub" }, - openRegistrations = false, + openRegistrations = isOpenRegistration, services = new Models.WellKnownModels.Services() { inbound = new object[0], diff --git a/src/BirdsiteLive/Services/CachedStatisticsService.cs b/src/BirdsiteLive/Services/CachedStatisticsService.cs new file mode 100644 index 0000000..9337797 --- /dev/null +++ b/src/BirdsiteLive/Services/CachedStatisticsService.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using BirdsiteLive.Common.Settings; +using BirdsiteLive.DAL.Contracts; + +namespace BirdsiteLive.Services +{ + public interface ICachedStatisticsService + { + Task GetStatisticsAsync(); + } + + public class CachedStatisticsService : ICachedStatisticsService + { + private readonly ITwitterUserDal _twitterUserDal; + + private static CachedStatistics _cachedStatistics; + private readonly InstanceSettings _instanceSettings; + + #region Ctor + public CachedStatisticsService(ITwitterUserDal twitterUserDal, InstanceSettings instanceSettings) + { + _twitterUserDal = twitterUserDal; + _instanceSettings = instanceSettings; + } + #endregion + + public async Task GetStatisticsAsync() + { + if (_cachedStatistics == null || + (DateTime.UtcNow - _cachedStatistics.RefreshedTime).TotalMinutes > 15) + { + var twitterUserMax = _instanceSettings.MaxUsersCapacity; + var twitterUserCount = await _twitterUserDal.GetTwitterUsersCountAsync(); + var saturation = (int)((double)twitterUserCount / twitterUserMax * 100); + + _cachedStatistics = new CachedStatistics + { + RefreshedTime = DateTime.UtcNow, + Saturation = saturation + }; + } + + return _cachedStatistics; + } + } + + public class CachedStatistics + { + public DateTime RefreshedTime { get; set; } + public int Saturation { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Services/FederationService.cs b/src/BirdsiteLive/Services/FederationService.cs index 26583f9..0b0faed 100644 --- a/src/BirdsiteLive/Services/FederationService.cs +++ b/src/BirdsiteLive/Services/FederationService.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using BirdsiteLive.DAL; using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Moderation; using BirdsiteLive.Pipeline; using Microsoft.Extensions.Hosting; @@ -12,13 +13,15 @@ namespace BirdsiteLive.Services public class FederationService : BackgroundService { private readonly IDatabaseInitializer _databaseInitializer; + private readonly IModerationPipeline _moderationPipeline; private readonly IStatusPublicationPipeline _statusPublicationPipeline; private readonly IHostApplicationLifetime _applicationLifetime; #region Ctor - public FederationService(IDatabaseInitializer databaseInitializer, IStatusPublicationPipeline statusPublicationPipeline, IHostApplicationLifetime applicationLifetime) + public FederationService(IDatabaseInitializer databaseInitializer, IModerationPipeline moderationPipeline, IStatusPublicationPipeline statusPublicationPipeline, IHostApplicationLifetime applicationLifetime) { _databaseInitializer = databaseInitializer; + _moderationPipeline = moderationPipeline; _statusPublicationPipeline = statusPublicationPipeline; _applicationLifetime = applicationLifetime; } @@ -29,6 +32,7 @@ namespace BirdsiteLive.Services try { await _databaseInitializer.InitAndMigrateDbAsync(); + await _moderationPipeline.ApplyModerationSettingsAsync(); await _statusPublicationPipeline.ExecuteAsync(stoppingToken); } finally diff --git a/src/BirdsiteLive/Startup.cs b/src/BirdsiteLive/Startup.cs index ab50932..16c7ee4 100644 --- a/src/BirdsiteLive/Startup.cs +++ b/src/BirdsiteLive/Startup.cs @@ -66,7 +66,10 @@ namespace BirdsiteLive var logsSettings = Configuration.GetSection("Logging").Get(); services.For().Use(x => logsSettings); - + + var moderationSettings = Configuration.GetSection("Moderation").Get(); + services.For().Use(x => moderationSettings); + if (string.Equals(dbSettings.Type, DbTypes.Postgres, StringComparison.OrdinalIgnoreCase)) { var connString = $"Host={dbSettings.Host};Username={dbSettings.User};Password={dbSettings.Password};Database={dbSettings.Name}"; @@ -96,6 +99,7 @@ namespace BirdsiteLive _.Assembly("BirdsiteLive.Domain"); _.Assembly("BirdsiteLive.DAL"); _.Assembly("BirdsiteLive.DAL.Postgres"); + _.Assembly("BirdsiteLive.Moderation"); _.Assembly("BirdsiteLive.Pipeline"); _.TheCallingAssembly(); diff --git a/src/BirdsiteLive/Views/About/Blacklisting.cshtml b/src/BirdsiteLive/Views/About/Blacklisting.cshtml new file mode 100644 index 0000000..148b0aa --- /dev/null +++ b/src/BirdsiteLive/Views/About/Blacklisting.cshtml @@ -0,0 +1,27 @@ +@using BirdsiteLive.Domain.Repository +@model BirdsiteLive.Controllers.ModerationStatus +@{ + ViewData["Title"] = "Blacklisting"; +} + +
+

Blacklisting

+ + @if (Model.Followers == ModerationTypeEnum.BlackListing) + { +


This node is blacklisting some instances and/or Fediverse users.

+ } + + @if (Model.TwitterAccounts == ModerationTypeEnum.BlackListing) + { +


This node is blacklisting some twitter users.

+ } + + @if (Model.Followers != ModerationTypeEnum.BlackListing && Model.TwitterAccounts != ModerationTypeEnum.BlackListing) + { +


This node is not using blacklisting.

+ } + + @*

FAQ

+

TODO

*@ +
\ No newline at end of file diff --git a/src/BirdsiteLive/Views/About/Index.cshtml b/src/BirdsiteLive/Views/About/Index.cshtml new file mode 100644 index 0000000..1f16b09 --- /dev/null +++ b/src/BirdsiteLive/Views/About/Index.cshtml @@ -0,0 +1,30 @@ +@model BirdsiteLive.Services.CachedStatistics +@{ + ViewData["Title"] = "About"; +} + +
+

Node Saturation

+ +

+
+ This node usage is at @Model.Saturation%
+
+

+ +

FAQ

+

Why is there a limit on the node?

+ +

BirdsiteLIVE rely on the Twitter API to provide high quality content. This API has limitations and therefore limits node capacity.

+ +

What happen when the node is saturated?

+ +

+ When the saturation rate goes above 100% the node will no longer update all accounts every 15 minutes and instead will reduce the pooling rate to stay under the API limits, the more saturated a node is the less efficient it will be.
+ The software doesn't scale, and it's by design. +

+ +

How can I reduce the node's saturation?

+ +

If you're not on your own node, be reasonable and don't follow too much accounts. And if you can, host your own node. BirdsiteLIVE doesn't require a lot of resources to work and therefore is really cheap to self-host.

+
\ No newline at end of file diff --git a/src/BirdsiteLive/Views/About/Whitelisting.cshtml b/src/BirdsiteLive/Views/About/Whitelisting.cshtml new file mode 100644 index 0000000..bdd00bc --- /dev/null +++ b/src/BirdsiteLive/Views/About/Whitelisting.cshtml @@ -0,0 +1,27 @@ +@using BirdsiteLive.Domain.Repository +@model BirdsiteLive.Controllers.ModerationStatus +@{ + ViewData["Title"] = "Whitelisting"; +} + +
+

Whitelisting

+ + @if (Model.Followers == ModerationTypeEnum.WhiteListing) + { +


This node is whitelisting some instances and/or Fediverse users.

+ } + + @if (Model.TwitterAccounts == ModerationTypeEnum.WhiteListing) + { +


This node is whitelisting some twitter users.

+ } + + @if (Model.Followers != ModerationTypeEnum.WhiteListing && Model.TwitterAccounts != ModerationTypeEnum.WhiteListing) + { +


This node is not using whitelisting.

+ } + + @*

FAQ

+

TODO

*@ +
\ No newline at end of file diff --git a/src/BirdsiteLive/Views/Debuging/Index.cshtml b/src/BirdsiteLive/Views/Debuging/Index.cshtml index cb56c56..5bcde75 100644 --- a/src/BirdsiteLive/Views/Debuging/Index.cshtml +++ b/src/BirdsiteLive/Views/Debuging/Index.cshtml @@ -16,4 +16,11 @@ + + + +
+ + +
\ No newline at end of file diff --git a/src/BirdsiteLive/Views/Shared/Components/NodeInfo/Default.cshtml b/src/BirdsiteLive/Views/Shared/Components/NodeInfo/Default.cshtml new file mode 100644 index 0000000..4e0f093 --- /dev/null +++ b/src/BirdsiteLive/Views/Shared/Components/NodeInfo/Default.cshtml @@ -0,0 +1,22 @@ +@model BirdsiteLive.Component.NodeInfoViewModel + +
+ @if (ViewData.Model.WhitelistingEnabled) + { + Whitelisting Enabled + } + @if (ViewData.Model.BlacklistingEnabled) + { + Blacklisting Enabled + } + +
+ +
+
75 && ViewData.Model.InstanceSaturation < 100) ? "bg-danger ":"") + @((ViewData.Model.InstanceSaturation > 100) ? "bg-saturation-danger ":"")" style="width: @ViewData.Model.InstanceSaturation%">@ViewData.Model.InstanceSaturation%
+
+
+
diff --git a/src/BirdsiteLive/Views/Shared/_Layout.cshtml b/src/BirdsiteLive/Views/Shared/_Layout.cshtml index f9f571a..7f089ad 100644 --- a/src/BirdsiteLive/Views/Shared/_Layout.cshtml +++ b/src/BirdsiteLive/Views/Shared/_Layout.cshtml @@ -9,6 +9,7 @@ +
@@ -39,6 +40,11 @@