
Instead of backfilling every note we come across that has a reply collection, only schedule a backfill job if someone wants to see the replies (on GET MastoAPI /context, or Iceshrimp API /descendants) Reply backfilling is also done on a ThreadIdOrId basis as opposed to the previous way of backfilling individual notes. This allows us finer grained control over the recursion and frees up the job queue, alongside allowing for easier implementation of context collection backfill in the future (by mapping each context collection to a thread) --- Currently, note threads are implicit based on the "threadId" column of a note, which can be null (where it's the same as the note's "id") This commit turns note threads into an actual entity, and as a part of that, makes "threadId" non-nullable (by specifically setting it to "id" for those cases) This is done to attach extra metadata to the entire thread, currently just the time of when it was last backfilled, but more may be added in the future (the context collection associated with this thread, for example) --- The data format for backfill jobs have backwards-incompatibly changed since the introduction of the feature. We can drop all old jobs without causing too much trouble as they will be re-scheduled on demand --- Signed-off-by: Laura Hausmann <laura@hausmann.dev>
574 lines
No EOL
19 KiB
C#
574 lines
No EOL
19 KiB
C#
using System.ComponentModel;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.Net;
|
|
using System.Net.Mime;
|
|
using AsyncKeyedLock;
|
|
using Iceshrimp.Backend.Controllers.Shared.Attributes;
|
|
using Iceshrimp.Backend.Controllers.Shared.Schemas;
|
|
using Iceshrimp.Backend.Controllers.Web.Renderers;
|
|
using Iceshrimp.Backend.Core.Database;
|
|
using Iceshrimp.Backend.Core.Database.Tables;
|
|
using Iceshrimp.Backend.Core.Extensions;
|
|
using Iceshrimp.Backend.Core.Helpers;
|
|
using Iceshrimp.Backend.Core.Middleware;
|
|
using Iceshrimp.Backend.Core.Services;
|
|
using Iceshrimp.Shared.Schemas.Web;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Iceshrimp.Backend.Controllers.Web;
|
|
|
|
[ApiController]
|
|
[EnableRateLimiting("sliding")]
|
|
[Route("/api/iceshrimp/notes")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
public class NoteController(
|
|
DatabaseContext db,
|
|
NoteService noteSvc,
|
|
NoteRenderer noteRenderer,
|
|
UserRenderer userRenderer,
|
|
CacheService cache,
|
|
BiteService biteSvc
|
|
) : ControllerBase
|
|
{
|
|
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
|
|
{
|
|
o.PoolSize = 100;
|
|
o.PoolInitialFill = 5;
|
|
});
|
|
|
|
[HttpGet("{id}")]
|
|
[Authenticate]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<NoteResponse> GetNote(string id)
|
|
{
|
|
var user = HttpContext.GetUser();
|
|
var note = await db.Notes.Where(p => p.Id == id)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FilterHidden(user, db, false, false)
|
|
.PrecomputeVisibilities(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
return await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user);
|
|
}
|
|
|
|
[HttpDelete("{id}")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task DeleteNote(string id)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
var note = await db.Notes.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
await noteSvc.DeleteNoteAsync(note);
|
|
}
|
|
|
|
[HttpGet("{id}/ascendants")]
|
|
[Authenticate]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<IEnumerable<NoteResponse>> GetNoteAscendants(
|
|
string id, [FromQuery] [DefaultValue(20)] [Range(1, 100)] int? limit
|
|
)
|
|
{
|
|
var user = HttpContext.GetUser();
|
|
|
|
var note = await db.Notes.Where(p => p.Id == id)
|
|
.EnsureVisibleFor(user)
|
|
.FilterHidden(user, db, false, false)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var hits = await db.NoteAncestors(note, limit ?? 20)
|
|
.Include(p => p.User.UserProfile)
|
|
.Include(p => p.Reply!.User.UserProfile)
|
|
.Include(p => p.Renote!.User.UserProfile)
|
|
.EnsureVisibleFor(user)
|
|
.FilterHidden(user, db)
|
|
.PrecomputeNoteContextVisibilities(user)
|
|
.ToListAsync();
|
|
|
|
var notes = hits.EnforceRenoteReplyVisibility();
|
|
var res = await noteRenderer.RenderMany(notes, user, Filter.FilterContext.Threads).ToListAsync();
|
|
|
|
// Strip redundant reply data
|
|
foreach (var item in res.Where(p => p.Reply != null && res.Any(i => i.Id == p.Reply.Id)))
|
|
item.Reply = null;
|
|
|
|
return res.OrderAncestors();
|
|
}
|
|
|
|
[HttpGet("{id}/descendants")]
|
|
[Authenticate]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<IEnumerable<NoteResponse>> GetNoteDescendants(
|
|
string id, [FromQuery] [DefaultValue(20)] [Range(1, 100)] int? depth
|
|
)
|
|
{
|
|
var user = HttpContext.GetUser();
|
|
|
|
var note = await db.Notes.Where(p => p.Id == id)
|
|
.EnsureVisibleFor(user)
|
|
.FilterHidden(user, db, false, false)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var hits = await db.NoteDescendants(note, depth ?? 20, 100)
|
|
.Include(p => p.User.UserProfile)
|
|
.Include(p => p.Reply!.User.UserProfile)
|
|
.Include(p => p.Renote!.User.UserProfile)
|
|
.EnsureVisibleFor(user)
|
|
.FilterHidden(user, db)
|
|
.PrecomputeNoteContextVisibilities(user)
|
|
.ToListAsync();
|
|
|
|
var notes = hits.EnforceRenoteReplyVisibility();
|
|
var res = await noteRenderer.RenderMany(notes, user, Filter.FilterContext.Threads).ToListAsync();
|
|
|
|
// Strip redundant reply data
|
|
foreach (var item in res.Where(p => p.Reply != null && res.Any(i => i.Id == p.Reply.Id)))
|
|
item.Reply = null;
|
|
|
|
if (user != null) await noteSvc.EnqueueBackfillTaskAsync(note);
|
|
|
|
return res.OrderDescendants();
|
|
}
|
|
|
|
[HttpGet("{id}/reactions/{name}")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[LinkPagination(20, 40)]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<IEnumerable<UserResponse>> GetNoteReactions(string id, string name)
|
|
{
|
|
var user = HttpContext.GetUser();
|
|
var note = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var users = await db.NoteReactions
|
|
.Where(p => p.Note == note && p.Reaction == $":{name.Trim(':')}:")
|
|
.Include(p => p.User.UserProfile)
|
|
.Select(p => p.User)
|
|
.ToListAsync();
|
|
|
|
return await userRenderer.RenderMany(users);
|
|
}
|
|
|
|
[HttpPost("{id}/bite")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
|
|
public async Task BiteNote(string id)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
if (user.Id == id)
|
|
throw GracefulException.BadRequest("You cannot bite your own note");
|
|
|
|
var target = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
await biteSvc.BiteAsync(user, target);
|
|
}
|
|
|
|
[HttpPost("{id}/like")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<ValueResponse> LikeNote(string id)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
var note = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var success = await noteSvc.LikeNoteAsync(note, user);
|
|
|
|
return new ValueResponse(success ? ++note.LikeCount : note.LikeCount);
|
|
}
|
|
|
|
[HttpPost("{id}/unlike")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<ValueResponse> UnlikeNote(string id)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
var note = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var success = await noteSvc.UnlikeNoteAsync(note, user);
|
|
|
|
return new ValueResponse(success ? --note.LikeCount : note.LikeCount);
|
|
}
|
|
|
|
[HttpGet("{id}/likes")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[RestPagination(20, 40)]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<PaginationWrapper<IEnumerable<UserResponse>>> GetNoteLikes(string id, PaginationQuery pq)
|
|
{
|
|
var user = HttpContext.GetUser();
|
|
var note = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var users = await db.NoteLikes
|
|
.Where(p => p.Note == note)
|
|
.Include(p => p.User.UserProfile)
|
|
.Paginate(pq, ControllerContext)
|
|
.Wrap(p => p.User)
|
|
.ToListAsync();
|
|
|
|
var res = await userRenderer.RenderMany(users.Select(p => p.Entity));
|
|
return HttpContext.CreatePaginationWrapper(pq, users, res);
|
|
}
|
|
|
|
[HttpPost("{id}/renote")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<ValueResponse> RenoteNote(string id, [FromQuery] NoteVisibility? visibility = null)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
var note = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var success = await noteSvc.RenoteNoteAsync(note, user, (Note.NoteVisibility?)visibility);
|
|
return new ValueResponse(success != null ? ++note.RenoteCount : note.RenoteCount);
|
|
}
|
|
|
|
[HttpPost("{id}/unrenote")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<ValueResponse> UnrenoteNote(string id)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
var note = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var count = await noteSvc.UnrenoteNoteAsync(note, user);
|
|
return new ValueResponse(note.RenoteCount - count);
|
|
}
|
|
|
|
[HttpGet("{id}/renotes")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[RestPagination(20, 40)]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<PaginationWrapper<IEnumerable<UserResponse>>> GetRenotes(string id, PaginationQuery pq)
|
|
{
|
|
var user = HttpContext.GetUser();
|
|
var note = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var users = await db.Notes
|
|
.Where(p => p.Renote == note && p.IsPureRenote)
|
|
.EnsureVisibleFor(user)
|
|
.Include(p => p.User.UserProfile)
|
|
.FilterHidden(user, db)
|
|
.Paginate(pq, ControllerContext)
|
|
.Wrap(p => p.User)
|
|
.ToListAsync();
|
|
|
|
var res = await userRenderer.RenderMany(users.Select(p => p.Entity));
|
|
return HttpContext.CreatePaginationWrapper(pq, users, res);
|
|
}
|
|
|
|
[HttpGet("{id}/quotes")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[RestPagination(20, 40)]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<PaginationWrapper<IEnumerable<NoteResponse>>> GetQuotes(string id, PaginationQuery pq)
|
|
{
|
|
var user = HttpContext.GetUser();
|
|
|
|
var note = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var renotes = await db.Notes
|
|
.Where(p => p.Renote == note && p.IsQuote)
|
|
.Include(p => p.User.UserProfile)
|
|
.EnsureVisibleFor(user)
|
|
.FilterHidden(user, db)
|
|
.Paginate(pq, ControllerContext)
|
|
.ToListAsync();
|
|
|
|
var res = await noteRenderer.RenderMany(renotes.EnforceRenoteReplyVisibility(), user,
|
|
Filter.FilterContext.Threads);
|
|
return HttpContext.CreatePaginationWrapper(pq, renotes, res);
|
|
}
|
|
|
|
[HttpPost("{id}/react/{name}")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<ValueResponse> ReactToNote(string id, string name)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
var note = await db.Notes
|
|
.Where(p => p.Id == id)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var res = await noteSvc.ReactToNoteAsync(note, user, name);
|
|
note.Reactions.TryGetValue(res.name, out var count);
|
|
return new ValueResponse(res.success ? ++count : count);
|
|
}
|
|
|
|
[HttpPost("{id}/unreact/{name}")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<ValueResponse> RemoveReactionFromNote(string id, string name)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
var note = await db.Notes.Where(p => p.Id == id)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var res = await noteSvc.RemoveReactionFromNoteAsync(note, user, name);
|
|
note.Reactions.TryGetValue(res.name, out var count);
|
|
return new ValueResponse(res.success ? --count : count);
|
|
}
|
|
|
|
[HttpPost("{id}/refetch")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[EnableRateLimiting("strict")]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<NoteRefetchResponse> 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");
|
|
|
|
return new NoteRefetchResponse
|
|
{
|
|
Note = await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user), Errors = errors
|
|
};
|
|
}
|
|
|
|
[HttpPost("{id}/mute")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[EnableRateLimiting("strict")]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task MuteNoteThread(string id)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
var target = await db.Notes.Where(p => p.Id == id)
|
|
.EnsureVisibleFor(user)
|
|
.Select(p => p.ThreadId)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
var mute = new NoteThreadMuting
|
|
{
|
|
Id = IdHelpers.GenerateSlowflakeId(),
|
|
CreatedAt = DateTime.UtcNow,
|
|
ThreadId = target,
|
|
UserId = user.Id
|
|
};
|
|
|
|
await db.NoteThreadMutings.Upsert(mute).On(p => new { p.UserId, p.ThreadId }).NoUpdate().RunAsync();
|
|
}
|
|
|
|
[HttpPost("{id}/unmute")]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[EnableRateLimiting("strict")]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task UnmuteNoteThread(string id)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
var target = await db.Notes.Where(p => p.Id == id)
|
|
.EnsureVisibleFor(user)
|
|
.Select(p => p.ThreadId)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.NotFound("Note not found");
|
|
|
|
await db.NoteThreadMutings.Where(p => p.User == user && p.ThreadId == target).ExecuteDeleteAsync();
|
|
}
|
|
|
|
[HttpPost]
|
|
[Authenticate]
|
|
[Authorize]
|
|
[Consumes(MediaTypeNames.Application.Json)]
|
|
[ProducesResults(HttpStatusCode.OK)]
|
|
[ProducesErrors(HttpStatusCode.NotFound)]
|
|
public async Task<NoteResponse> CreateNote(NoteCreateRequest request)
|
|
{
|
|
var user = HttpContext.GetUserOrFail();
|
|
|
|
if (request.IdempotencyKey != null)
|
|
{
|
|
var key = $"idempotency:{user.Id}:{request.IdempotencyKey}";
|
|
string hit;
|
|
using (await KeyedLocker.LockAsync(key))
|
|
{
|
|
hit = await cache.FetchAsync(key, TimeSpan.FromHours(24), () => $"_:{HttpContext.TraceIdentifier}");
|
|
}
|
|
|
|
if (hit != $"_:{HttpContext.TraceIdentifier}")
|
|
{
|
|
for (var i = 0; i <= 10; i++)
|
|
{
|
|
if (!hit.StartsWith('_')) break;
|
|
await Task.Delay(100);
|
|
hit = await cache.GetAsync<string>(key) ??
|
|
throw new Exception("Idempotency key status disappeared in for loop");
|
|
if (i >= 10)
|
|
throw GracefulException.RequestTimeout("Failed to resolve idempotency key note within 1000 ms");
|
|
}
|
|
|
|
return await GetNote(hit);
|
|
}
|
|
}
|
|
|
|
var reply = request.ReplyId != null
|
|
? await db.Notes.Where(p => p.Id == request.ReplyId)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.BadRequest("Reply target is nonexistent or inaccessible")
|
|
: null;
|
|
|
|
var renote = request.RenoteId != null
|
|
? await db.Notes.Where(p => p.Id == request.RenoteId)
|
|
.IncludeCommonProperties()
|
|
.EnsureVisibleFor(user)
|
|
.FirstOrDefaultAsync() ??
|
|
throw GracefulException.BadRequest("Renote target is nonexistent or inaccessible")
|
|
: null;
|
|
|
|
var attachments = request.MediaIds != null
|
|
? await db.DriveFiles.Where(p => request.MediaIds.Contains(p.Id)).ToListAsync()
|
|
: null;
|
|
|
|
var note = await noteSvc.CreateNoteAsync(new NoteService.NoteCreationData
|
|
{
|
|
User = user,
|
|
Visibility = (Note.NoteVisibility)request.Visibility,
|
|
Text = request.Text,
|
|
Cw = request.Cw,
|
|
Reply = reply,
|
|
Renote = renote,
|
|
Attachments = attachments
|
|
});
|
|
|
|
if (request.IdempotencyKey != null)
|
|
await cache.SetAsync($"idempotency:{user.Id}:{request.IdempotencyKey}", note.Id, TimeSpan.FromHours(24));
|
|
|
|
return await noteRenderer.RenderOne(note, user);
|
|
}
|
|
} |