[backend/api] Add note refetch endpoint (ISH-352)
This commit is contained in:
parent
3316a391d5
commit
374d9d5ebf
7 changed files with 106 additions and 16 deletions
|
@ -38,7 +38,7 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/api/v1/apps")]
|
[HttpPost("/api/v1/apps")]
|
||||||
[EnableRateLimiting("strict")]
|
[EnableRateLimiting("auth")]
|
||||||
[ConsumesHybrid]
|
[ConsumesHybrid]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.RegisterAppResponse))]
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.RegisterAppResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))]
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))]
|
||||||
|
|
|
@ -42,7 +42,7 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[HideRequestDuration]
|
[HideRequestDuration]
|
||||||
[EnableRateLimiting("strict")]
|
[EnableRateLimiting("auth")]
|
||||||
[Consumes(MediaTypeNames.Application.Json)]
|
[Consumes(MediaTypeNames.Application.Json)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
|
||||||
|
@ -87,7 +87,7 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
[EnableRateLimiting("strict")]
|
[EnableRateLimiting("auth")]
|
||||||
[Consumes(MediaTypeNames.Application.Json)]
|
[Consumes(MediaTypeNames.Application.Json)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
|
||||||
|
@ -104,7 +104,7 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
|
||||||
[HttpPost("change-password")]
|
[HttpPost("change-password")]
|
||||||
[Authenticate]
|
[Authenticate]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[EnableRateLimiting("strict")]
|
[EnableRateLimiting("auth")]
|
||||||
[Consumes(MediaTypeNames.Application.Json)]
|
[Consumes(MediaTypeNames.Application.Json)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
|
||||||
|
|
|
@ -267,6 +267,76 @@ public class NoteController(
|
||||||
return Ok(new ValueResponse(res.success ? --count : count));
|
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<IActionResult> 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<string>();
|
||||||
|
|
||||||
|
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]
|
[HttpPost]
|
||||||
[Authenticate]
|
[Authenticate]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
|
|
@ -240,11 +240,17 @@ public static class ServiceExtensions
|
||||||
QueueLimit = 0
|
QueueLimit = 0
|
||||||
};
|
};
|
||||||
|
|
||||||
options.AddPolicy("sliding", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(),
|
options.AddPolicy("sliding", ctx =>
|
||||||
_ => sliding));
|
RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false),
|
||||||
|
_ => sliding));
|
||||||
|
|
||||||
options.AddPolicy("strict", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(),
|
options.AddPolicy("auth", ctx =>
|
||||||
_ => strict));
|
RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false),
|
||||||
|
_ => strict));
|
||||||
|
|
||||||
|
options.AddPolicy("strict", ctx =>
|
||||||
|
RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true),
|
||||||
|
_ => strict));
|
||||||
|
|
||||||
options.OnRejected = async (context, token) =>
|
options.OnRejected = async (context, token) =>
|
||||||
{
|
{
|
||||||
|
@ -316,7 +322,10 @@ public static class ServiceExtensions
|
||||||
|
|
||||||
public static class HttpContextExtensions
|
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.GetUser()?.Id ??
|
||||||
ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault() ??
|
ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault() ??
|
||||||
ctx.Connection.RemoteIpAddress?.ToString();
|
ctx.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
|
@ -410,7 +410,6 @@ public class NoteService(
|
||||||
var (mentionedUserIds, mentionedLocalUserIds, mentions, remoteMentions, splitDomainMapping) =
|
var (mentionedUserIds, mentionedLocalUserIds, mentions, remoteMentions, splitDomainMapping) =
|
||||||
resolvedMentions ?? await ResolveNoteMentionsAsync(text);
|
resolvedMentions ?? await ResolveNoteMentionsAsync(text);
|
||||||
|
|
||||||
|
|
||||||
List<MfmNode>? nodes = null;
|
List<MfmNode>? nodes = null;
|
||||||
if (text != null && string.IsNullOrWhiteSpace(text))
|
if (text != null && string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
|
@ -830,13 +829,13 @@ public class NoteService(
|
||||||
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage",
|
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage",
|
||||||
Justification = "Inspection doesn't understand IncludeCommonProperties()")]
|
Justification = "Inspection doesn't understand IncludeCommonProperties()")]
|
||||||
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "See above")]
|
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "See above")]
|
||||||
public async Task<Note?> ProcessNoteUpdateAsync(ASNote note, User actor)
|
public async Task<Note?> ProcessNoteUpdateAsync(ASNote note, User actor, User? user = null)
|
||||||
{
|
{
|
||||||
var dbNote = await db.Notes.IncludeCommonProperties()
|
var dbNote = await db.Notes.IncludeCommonProperties()
|
||||||
.Include(p => p.Poll)
|
.Include(p => p.Poll)
|
||||||
.FirstOrDefaultAsync(p => p.Uri == note.Id);
|
.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);
|
logger.LogDebug("Processing note update {id} for note {noteId}", note.Id, dbNote.Id);
|
||||||
|
|
||||||
|
@ -1027,7 +1026,7 @@ public class NoteService(
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Note?> ResolveNoteAsync(
|
public async Task<Note?> 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)
|
if (clearHistory)
|
||||||
|
@ -1051,7 +1050,7 @@ public class NoteService(
|
||||||
|
|
||||||
var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == uri);
|
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)
|
if (!fetchedNote?.VerifiedFetch ?? false)
|
||||||
fetchedNote = null;
|
fetchedNote = null;
|
||||||
|
@ -1082,7 +1081,7 @@ public class NoteService(
|
||||||
if (fetchedNote.Id != uri)
|
if (fetchedNote.Id != uri)
|
||||||
{
|
{
|
||||||
var res = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == fetchedNote.Id);
|
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);
|
var actor = await userResolver.ResolveAsync(attrTo.Id);
|
||||||
|
@ -1091,7 +1090,9 @@ public class NoteService(
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await ProcessNoteAsync(fetchedNote, actor, user);
|
return forceRefresh
|
||||||
|
? await ProcessNoteUpdateAsync(fetchedNote, actor, user)
|
||||||
|
: await ProcessNoteAsync(fetchedNote, actor, user);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
|
|
@ -55,4 +55,7 @@ internal class NoteControllerModel(ApiClient api)
|
||||||
|
|
||||||
public Task<NoteResponse> CreateNote(NoteCreateRequest request) =>
|
public Task<NoteResponse> CreateNote(NoteCreateRequest request) =>
|
||||||
api.Call<NoteResponse>(HttpMethod.Post, "/notes", data: request);
|
api.Call<NoteResponse>(HttpMethod.Post, "/notes", data: request);
|
||||||
|
|
||||||
|
public Task<NoteRefetchResponse?> RefetchNote(string id) =>
|
||||||
|
api.CallNullable<NoteRefetchResponse>(HttpMethod.Get, $"/notes/{id}/refetch");
|
||||||
}
|
}
|
7
Iceshrimp.Shared/Schemas/Web/NoteRefetchResponse.cs
Normal file
7
Iceshrimp.Shared/Schemas/Web/NoteRefetchResponse.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Iceshrimp.Shared.Schemas.Web;
|
||||||
|
|
||||||
|
public class NoteRefetchResponse
|
||||||
|
{
|
||||||
|
public required NoteResponse Note { get; set; }
|
||||||
|
public required List<string> Errors { get; set; }
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue