diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1f269b3..90d7890 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -12,6 +12,8 @@ env: BACKUPS: /var/backups/privapub.thepra.dev LOCAL_URL: http://127.0.0.1:6970 PUBLIC_URL: https://privapub.thepra.dev + MONGO_URI: mongodb://127.0.0.1:27022 + MONGO_DB: PrivaPub jobs: site: @@ -55,6 +57,13 @@ jobs: echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV" ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true + - name: Dump the database + run: | + DUMP="$BACKUPS/mongo-$(date +%Y%m%d-%H%M%S).archive.gz" + mongodump --quiet --uri "$MONGO_URI" --db "$MONGO_DB" --gzip --archive="$DUMP" + echo "::notice::database dumped to $DUMP ($(du -h "$DUMP" | cut -f1))" + ls -1t "$BACKUPS"/mongo-*.archive.gz 2>/dev/null | tail -n +8 | xargs -r rm -f || true + - name: Stop, sync, start run: | sudo systemctl stop "$UNIT" @@ -88,4 +97,11 @@ jobs: [ "$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; } + code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html") + [ "$code" = "404" ] || { echo "::error::swagger answered $code in production"; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' --data '{"junk":' "$PUBLIC_URL/human-centipede") + [ "$code" = "400" ] || { echo "::error::a junk inbox POST answered $code"; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \ + --data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede") + [ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; } echo "::notice::serving $served" diff --git a/PrivaPub/Controllers/ClientToServer/RootUserController.cs b/PrivaPub/Controllers/ClientToServer/RootUserController.cs index 496eef2..f990f97 100644 --- a/PrivaPub/Controllers/ClientToServer/RootUserController.cs +++ b/PrivaPub/Controllers/ClientToServer/RootUserController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; @@ -11,6 +12,7 @@ using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User.Avatar; using PrivaPub.Extensions; using PrivaPub.Federation.Actors; +using PrivaPub.Infrastructure; using PrivaPub.Models; using PrivaPub.Models.User; using PrivaPub.Resources; @@ -56,7 +58,7 @@ namespace PrivaPub.Controllers.ClientToServer #region User endpoints - [HttpPost, Route("/clientapi/user/signup"), Authorize(Policy = Policies.IsUser), AllowAnonymous] + [HttpPost, Route("/clientapi/user/signup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous] public async Task SignUp(LoginForm signUpForm) { if (User.Identity?.IsAuthenticated ?? false) return Redirect("/"); @@ -80,7 +82,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/login"), Authorize(Policy = Policies.IsUser), AllowAnonymous] + [HttpPost, Route("/clientapi/user/login"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous] public async Task Login(LoginForm loginForm) { if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions"); @@ -106,7 +108,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/invitation/signup"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/invitation/signup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token) { var result = new WebResult(); @@ -156,7 +158,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/invitation/login"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/invitation/login"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task InvitationLogin(InvitationLoginForm loginForm, CancellationToken token) { var result = new WebResult(); @@ -339,7 +341,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/recover/password"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/recover/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task RecoverPassword(PasswordRecoveryForm passwordRecoveryForm) { var result = new WebResult(); @@ -363,7 +365,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/recover/valid"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/recover/valid"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task IsValidRecoveryCode( [FromBody, Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), @@ -386,7 +388,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/recover/update/password"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/recover/update/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task ChangePassword(NewPasswordForm newPasswordForm) { var result = new WebResult(); diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index c23f67a..a46708c 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using MongoDB.Entities; @@ -11,6 +12,7 @@ using PostEntity = PrivaPub.Models.Post.Post; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Inbox; +using PrivaPub.Infrastructure; namespace PrivaPub.Federation.Controllers { @@ -113,7 +115,7 @@ namespace PrivaPub.Federation.Controllers return Activity(note); } - [HttpPost, Route("{actor}/mouth")] + [HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)] public async Task Inbox(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); @@ -122,11 +124,11 @@ namespace PrivaPub.Federation.Controllers return Answer(await _inbox.Receive(Request, local, token)); } - [HttpPost, Route("{actor}/human-centipede")] + [HttpPost, Route("{actor}/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)] public async Task ActorSharedInbox(string actor, CancellationToken token) => Answer(await _inbox.Receive(Request, default, token)); - [HttpPost, Route("/human-centipede")] + [HttpPost, Route("/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)] public async Task SharedInbox(CancellationToken token) => Answer(await _inbox.Receive(Request, default, token)); diff --git a/PrivaPub/Infrastructure/RateLimiting.cs b/PrivaPub/Infrastructure/RateLimiting.cs new file mode 100644 index 0000000..b81476f --- /dev/null +++ b/PrivaPub/Infrastructure/RateLimiting.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.RateLimiting; + +using PrivaPub.Federation.Objects; +using PrivaPub.Federation.Signing; + +using System.Threading.RateLimiting; + +namespace PrivaPub.Infrastructure +{ + public static class RateLimiting + { + public const string Accounts = "accounts"; + public const string Inbox = "inbox"; + + public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service) => + service.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); + options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter( + SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress, + _ => new TokenBucketRateLimiterOptions + { + TokenLimit = 300, + TokensPerPeriod = 50, + ReplenishmentPeriod = TimeSpan.FromSeconds(10), + QueueLimit = 0 + })); + }); + + static string SenderOrigin(HttpRequest request) + { + var signature = request.Headers["Signature"].ToString(); + return string.IsNullOrEmpty(signature) ? default : Origin.Of(HttpSignatures.Parse(signature)?.KeyId); + } + } +} diff --git a/PrivaPub/Models/Post/PostBoost.cs b/PrivaPub/Models/Post/PostBoost.cs deleted file mode 100644 index 8865876..0000000 --- a/PrivaPub/Models/Post/PostBoost.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace PrivaPub.Models.Post -{ - public class PostBoost - { - public string PostId { get; set; } - public string GroupUserId { get; set; } - - public bool IsFederatedCopy { get; set; } - - public DateTime CreationDate { get; set; } - } -} diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index 83aef11..3d8e646 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -11,6 +11,7 @@ using Serilog; using PrivaPub.Data; using PrivaPub.Extensions; +using PrivaPub.Infrastructure; using PrivaPub.Infrastructure.Cli; using PrivaPub.Infrastructure.Data; using PrivaPub.Infrastructure.Http; @@ -52,6 +53,7 @@ try .PrivaPubServicesConfiguration() .PrivaPubFederationConfiguration(builder.Configuration) .PrivaPubCORSConfiguration() + .PrivaPubRateLimiting() .PrivaPubMiddlewareConfiguration(); } catch (Exception ex) @@ -122,6 +124,7 @@ try app.UseRequestLocalization(await localizationService.Get()); app.UseRouting(); + app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/PrivaPub/Services/ActivityPubClient.cs b/PrivaPub/Services/ActivityPubClient.cs deleted file mode 100644 index 393277b..0000000 --- a/PrivaPub/Services/ActivityPubClient.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace PrivaPub.Services -{ - public class ActivityPubClient : HttpClient - { - //readonly HttpClient client; - - //public ActivityPubClient(HttpClient client) - //{ - // this.client = client; - //} - } -}