From 16f46dbe433cbb6b42c3a4577557363a2a8ad919 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Tue, 13 Feb 2024 00:06:17 +0100 Subject: [PATCH] [backend/federation] Handle note attachments (ISH-48) --- .../ActivityStreams/Types/ASAttachment.cs | 70 +++++++++++++++++++ .../ActivityStreams/Types/ASNote.cs | 4 ++ .../Core/Services/DriveService.cs | 35 ++++++++-- .../Core/Services/NoteService.cs | 22 +++++- 4 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASAttachment.cs diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASAttachment.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASAttachment.cs new file mode 100644 index 00000000..ea7cd01b --- /dev/null +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASAttachment.cs @@ -0,0 +1,70 @@ +using Iceshrimp.Backend.Core.Configuration; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using J = Newtonsoft.Json.JsonPropertyAttribute; +using JC = Newtonsoft.Json.JsonConverterAttribute; + +namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; + +public class ASAttachment : ASObjectBase { + [J("@type")] + [JC(typeof(StringListSingleConverter))] + public string? Type { get; set; } +} + +public class ASDocument : ASAttachment { + [J("https://www.w3.org/ns/activitystreams#url")] + [JC(typeof(ASObjectBaseConverter))] + public ASObjectBase? Url { get; set; } + + [J("https://www.w3.org/ns/activitystreams#mediaType")] + [JC(typeof(ValueObjectConverter))] + public string? MediaType { get; set; } + + [J("https://www.w3.org/ns/activitystreams#sensitive")] + [JC(typeof(ValueObjectConverter))] + public bool? Sensitive { get; set; } +} + +public sealed class ASAttachmentConverter : JsonConverter { + public override bool CanWrite => false; + + public override bool CanConvert(Type objectType) { + return true; + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, + JsonSerializer serializer) { + if (reader.TokenType == JsonToken.StartObject) { + var obj = JObject.Load(reader); + return HandleObject(obj); + } + + if (reader.TokenType == JsonToken.StartArray) { + var array = JArray.Load(reader); + var result = new List(); + foreach (var token in array) { + if (token is not JObject obj) return null; + var item = HandleObject(obj); + if (item == null) return null; + result.Add(item); + } + + return result; + } + + return null; + } + + private static ASAttachment? HandleObject(JToken obj) { + var attachment = obj.ToObject(); + + return attachment?.Type == $"{Constants.ActivityStreamsNs}#Document" + ? obj.ToObject() + : attachment; + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs index a8f9e25b..5c59fddf 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs @@ -47,6 +47,10 @@ public class ASNote : ASObject { [J("https://www.w3.org/ns/activitystreams#tag")] [JC(typeof(ASTagConverter))] public List? Tags { get; set; } + + [J("https://www.w3.org/ns/activitystreams#attachment")] + [JC(typeof(ASAttachmentConverter))] + public List? Attachments { get; set; } public Note.NoteVisibility GetVisibility(ASActor actor) { if (To.Any(p => p.Id == "https://www.w3.org/ns/activitystreams#Public")) diff --git a/Iceshrimp.Backend/Core/Services/DriveService.cs b/Iceshrimp.Backend/Core/Services/DriveService.cs index e670b841..1c4e7676 100644 --- a/Iceshrimp.Backend/Core/Services/DriveService.cs +++ b/Iceshrimp.Backend/Core/Services/DriveService.cs @@ -17,18 +17,24 @@ public class DriveService( IOptionsSnapshot storageConfig, IOptions instanceConfig, HttpClient httpClient, - QueueService queueSvc + QueueService queueSvc, + ILogger logger ) { public async Task StoreFile(string? uri, User user, bool sensitive) { if (uri == null) return null; + logger.LogDebug("Storing file {uri} for user {userId}", uri, user.Id); + try { // Do we already have the file? var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Uri == uri); if (file != null) { // If the user matches, return the existing file - if (file.UserId == user.Id) + if (file.UserId == user.Id) { + logger.LogDebug("File {uri} is already registered for user, returning existing file {id}", + uri, file.Id); return file; + } // Otherwise, clone the file var req = new DriveFileCreationRequest { @@ -38,7 +44,14 @@ public class DriveService( MimeType = null! // Not needed in .Clone }; - return file.Clone(user, req); + var clonedFile = file.Clone(user, req); + + logger.LogDebug("File {uri} is already registered for different user, returning clone of existing file {id}, stored as {cloneId}", + uri, file.Id, clonedFile.Id); + + await db.AddAsync(clonedFile); + await db.SaveChangesAsync(); + return clonedFile; } var res = await httpClient.GetAsync(uri); @@ -52,8 +65,8 @@ public class DriveService( return await StoreFile(await res.Content.ReadAsStreamAsync(), user, request); } - catch { - //TODO: log error + catch (Exception e) { + logger.LogError("Failed to insert file {uri}: {error}", uri, e.Message); return null; } } @@ -61,12 +74,20 @@ public class DriveService( public async Task StoreFile(Stream data, User user, DriveFileCreationRequest request) { var buf = new BufferedStream(data); var digest = await DigestHelpers.Sha256DigestAsync(buf); - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Sha256 == digest); + logger.LogDebug("Storing file {digest} for user {userId}", digest, user.Id); + var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Sha256 == digest); if (file is { IsLink: false }) { - if (file.UserId == user.Id) + if (file.UserId == user.Id) { + logger.LogDebug("File {digest} is already registered for user, returning existing file {id}", + digest, file.Id); return file; + } var clonedFile = file.Clone(user, request); + + logger.LogDebug("File {digest} is already registered for different user, returning clone of existing file {id}, stored as {cloneId}", + digest, file.Id, clonedFile.Id); + await db.AddAsync(clonedFile); await db.SaveChangesAsync(); return clonedFile; diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index 9fe8869b..0f94f32c 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -33,7 +33,8 @@ public class NoteService( NoteRenderer noteRenderer, UserRenderer userRenderer, MentionsResolver mentionsResolver, - MfmConverter mfmConverter + MfmConverter mfmConverter, + DriveService driveSvc ) { private readonly List _resolverHistory = []; private int _recursionLimit = 100; @@ -187,6 +188,13 @@ public class NoteService( dbNote.Text = mentionsResolver.ResolveMentions(dbNote.Text, dbNote.UserHost, mentions, splitDomainMapping); } + var sensitive = (note.Sensitive ?? false) || dbNote.Cw != null; + var files = await ProcessAttachmentsAsync(note.Attachments, user, sensitive); + if (files.Count != 0) { + dbNote.FileIds = files.Select(p => p.Id).ToList(); + dbNote.AttachedFileTypes = files.Select(p => p.Type).ToList(); + } + user.NotesCount++; await db.Notes.AddAsync(dbNote); await db.SaveChangesAsync(); @@ -262,6 +270,18 @@ public class NoteService( return (userIds, mentions, remoteMentions, splitDomainMapping); } + private async Task> ProcessAttachmentsAsync( + List? attachments, User user, bool sensitive + ) { + if (attachments is not { Count: > 0 }) return []; + var result = await attachments + .OfType() + .Select(p => driveSvc.StoreFile(p.Url?.Id, user, p.Sensitive ?? sensitive)) + .AwaitAllNoConcurrencyAsync(); + + return result.Where(p => p != null).Cast().ToList(); + } + public async Task ResolveNoteAsync(string uri) { //TODO: is this enough to prevent DoS attacks? if (_recursionLimit-- <= 0)