diff --git a/Iceshrimp.Backend/Controllers/Mastodon/AuthController.cs b/Iceshrimp.Backend/Controllers/Mastodon/AuthController.cs index f5631b34..1652c3c9 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/AuthController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/AuthController.cs @@ -38,7 +38,7 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa } [HttpPost("/api/v1/apps")] - [EnableRateLimiting("strict")] + [EnableRateLimiting("auth")] [ConsumesHybrid] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.RegisterAppResponse))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] diff --git a/Iceshrimp.Backend/Controllers/Web/AuthController.cs b/Iceshrimp.Backend/Controllers/Web/AuthController.cs index 0dae3df2..2024afbc 100644 --- a/Iceshrimp.Backend/Controllers/Web/AuthController.cs +++ b/Iceshrimp.Backend/Controllers/Web/AuthController.cs @@ -42,7 +42,7 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere [HttpPost("login")] [HideRequestDuration] - [EnableRateLimiting("strict")] + [EnableRateLimiting("auth")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] @@ -87,7 +87,7 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere } [HttpPost("register")] - [EnableRateLimiting("strict")] + [EnableRateLimiting("auth")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] @@ -104,7 +104,7 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere [HttpPost("change-password")] [Authenticate] [Authorize] - [EnableRateLimiting("strict")] + [EnableRateLimiting("auth")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] diff --git a/Iceshrimp.Backend/Controllers/Web/NoteController.cs b/Iceshrimp.Backend/Controllers/Web/NoteController.cs index d59ddbf2..218d8dd3 100644 --- a/Iceshrimp.Backend/Controllers/Web/NoteController.cs +++ b/Iceshrimp.Backend/Controllers/Web/NoteController.cs @@ -267,6 +267,76 @@ public class NoteController( return Ok(new ValueResponse(res.success ? --count : count)); } + [HttpPost("{id}/refetch")] + [Authenticate] + [Authorize] + [EnableRateLimiting("strict")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteRefetchResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] + public async Task RefetchNote(string id) + { + var user = HttpContext.GetUserOrFail(); + var note = await db.Notes.Where(p => p.Id == id && p.User.Host != null && p.Uri != null) + .IncludeCommonProperties() + .EnsureVisibleFor(user) + .FilterHidden(user, db, false, false) + .FirstOrDefaultAsync() ?? + throw GracefulException.NotFound("Note not found"); + + if (note.Uri == null) + throw new Exception("note.Uri must not be null at this point"); + + var errors = new List(); + + try + { + await noteSvc.ResolveNoteAsync(note.Uri, null, user, clearHistory: true, forceRefresh: true); + } + catch (Exception e) + { + errors.Add($"Failed to refetch note: {e.Message}"); + } + + if (note.ReplyUri != null) + { + try + { + await noteSvc.ResolveNoteAsync(note.ReplyUri, null, user, clearHistory: true, forceRefresh: true); + } + catch (Exception e) + { + errors.Add($"Failed to fetch reply target: {e.Message}"); + } + } + + if (note.RenoteUri != null) + { + try + { + await noteSvc.ResolveNoteAsync(note.RenoteUri, null, user, clearHistory: true, forceRefresh: true); + } + catch (Exception e) + { + errors.Add($"Failed to fetch renote target: {e.Message}"); + } + } + + db.ChangeTracker.Clear(); + note = await db.Notes.Where(p => p.Id == id && p.User.Host != null && p.Uri != null) + .IncludeCommonProperties() + .EnsureVisibleFor(user) + .FilterHidden(user, db, false, false) + .PrecomputeVisibilities(user) + .FirstOrDefaultAsync() ?? + throw new Exception("Note disappeared during refetch"); + + var res = new NoteRefetchResponse + { + Note = await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user), Errors = errors + }; + return Ok(res); + } + [HttpPost] [Authenticate] [Authorize] diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs index 220f8054..f5fb3845 100644 --- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs @@ -240,11 +240,17 @@ public static class ServiceExtensions QueueLimit = 0 }; - options.AddPolicy("sliding", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(), - _ => sliding)); + options.AddPolicy("sliding", ctx => + RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false), + _ => sliding)); - options.AddPolicy("strict", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(), - _ => strict)); + options.AddPolicy("auth", ctx => + RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false), + _ => strict)); + + options.AddPolicy("strict", ctx => + RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true), + _ => strict)); options.OnRejected = async (context, token) => { @@ -316,7 +322,10 @@ public static class ServiceExtensions public static class HttpContextExtensions { - public static string? GetRateLimitPartition(this HttpContext ctx) => + public static string GetRateLimitPartition(this HttpContext ctx, bool includeRoute) => + (includeRoute ? ctx.Request.Path.ToString() + "#" : "") + (GetRateLimitPartitionInternal(ctx) ?? ""); + + private static string? GetRateLimitPartitionInternal(this HttpContext ctx) => ctx.GetUser()?.Id ?? ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? ctx.Connection.RemoteIpAddress?.ToString(); diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index 2718da8c..12bb0be9 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -410,7 +410,6 @@ public class NoteService( var (mentionedUserIds, mentionedLocalUserIds, mentions, remoteMentions, splitDomainMapping) = resolvedMentions ?? await ResolveNoteMentionsAsync(text); - List? nodes = null; if (text != null && string.IsNullOrWhiteSpace(text)) { @@ -830,13 +829,13 @@ public class NoteService( [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Inspection doesn't understand IncludeCommonProperties()")] [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "See above")] - public async Task ProcessNoteUpdateAsync(ASNote note, User actor) + public async Task ProcessNoteUpdateAsync(ASNote note, User actor, User? user = null) { var dbNote = await db.Notes.IncludeCommonProperties() .Include(p => p.Poll) .FirstOrDefaultAsync(p => p.Uri == note.Id); - if (dbNote == null) return await ProcessNoteAsync(note, actor); + if (dbNote == null) return await ProcessNoteAsync(note, actor, user); logger.LogDebug("Processing note update {id} for note {noteId}", note.Id, dbNote.Id); @@ -1027,7 +1026,7 @@ public class NoteService( } public async Task ResolveNoteAsync( - string uri, ASNote? fetchedNote = null, User? user = null, bool clearHistory = false + string uri, ASNote? fetchedNote = null, User? user = null, bool clearHistory = false, bool forceRefresh = false ) { if (clearHistory) @@ -1051,7 +1050,7 @@ public class NoteService( var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == uri); - if (note != null) return note; + if (note != null && !forceRefresh) return note; if (!fetchedNote?.VerifiedFetch ?? false) fetchedNote = null; @@ -1082,7 +1081,7 @@ public class NoteService( if (fetchedNote.Id != uri) { var res = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == fetchedNote.Id); - if (res != null) return res; + if (res != null && !forceRefresh) return res; } var actor = await userResolver.ResolveAsync(attrTo.Id); @@ -1091,7 +1090,9 @@ public class NoteService( { try { - return await ProcessNoteAsync(fetchedNote, actor, user); + return forceRefresh + ? await ProcessNoteUpdateAsync(fetchedNote, actor, user) + : await ProcessNoteAsync(fetchedNote, actor, user); } catch (Exception e) { diff --git a/Iceshrimp.Frontend/Core/ControllerModels/NoteControllerModel.cs b/Iceshrimp.Frontend/Core/ControllerModels/NoteControllerModel.cs index 07327a1f..3a3dad97 100644 --- a/Iceshrimp.Frontend/Core/ControllerModels/NoteControllerModel.cs +++ b/Iceshrimp.Frontend/Core/ControllerModels/NoteControllerModel.cs @@ -55,4 +55,7 @@ internal class NoteControllerModel(ApiClient api) public Task CreateNote(NoteCreateRequest request) => api.Call(HttpMethod.Post, "/notes", data: request); + + public Task RefetchNote(string id) => + api.CallNullable(HttpMethod.Get, $"/notes/{id}/refetch"); } \ No newline at end of file diff --git a/Iceshrimp.Shared/Schemas/Web/NoteRefetchResponse.cs b/Iceshrimp.Shared/Schemas/Web/NoteRefetchResponse.cs new file mode 100644 index 00000000..d5284745 --- /dev/null +++ b/Iceshrimp.Shared/Schemas/Web/NoteRefetchResponse.cs @@ -0,0 +1,7 @@ +namespace Iceshrimp.Shared.Schemas.Web; + +public class NoteRefetchResponse +{ + public required NoteResponse Note { get; set; } + public required List Errors { get; set; } +} \ No newline at end of file