From 0dc1533b75aef55e87765a92b21a8af0e75517ba Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Fri, 26 Jan 2024 03:09:52 +0100 Subject: [PATCH] Improve NoteService.CreateNote --- .../Core/Extensions/StringExtensions.cs | 10 ++ .../ActivityStreams/Types/ASNote.cs | 4 +- Iceshrimp.Backend/Core/Helpers/MfmHelpers.cs | 98 +++++++++++++++++++ .../Core/Middleware/ErrorHandlerMiddleware.cs | 8 ++ .../Core/Services/NoteService.cs | 25 ++++- .../Core/Services/UserService.cs | 4 +- Iceshrimp.Backend/Iceshrimp.Backend.csproj | 1 + 7 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 Iceshrimp.Backend/Core/Helpers/MfmHelpers.cs diff --git a/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs b/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs index f5b3be50..f41a895c 100644 --- a/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs @@ -1,7 +1,17 @@ +using System.Globalization; + namespace Iceshrimp.Backend.Core.Extensions; public static class StringExtensions { public static string Truncate(this string target, int maxLength) { return target[..Math.Min(target.Length, maxLength)]; } + + public static string ToPunycode(this string target) { + return new IdnMapping().GetAscii(target); + } + + public static string FromPunycode(this string target) { + return new IdnMapping().GetUnicode(target); + } } \ 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 6c3c7ba5..d7a91ab5 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASNote.cs @@ -14,8 +14,8 @@ public class ASNote : ASObject { public string? Content { get; set; } [J("https://www.w3.org/ns/activitystreams#url")] - [JC(typeof(LDIdObjectConverter))] - public LDIdObject? Url { get; set; } + [JC(typeof(ASLinkConverter))] + public ASLink? Url { get; set; } [J("https://www.w3.org/ns/activitystreams#sensitive")] [JC(typeof(VC))] diff --git a/Iceshrimp.Backend/Core/Helpers/MfmHelpers.cs b/Iceshrimp.Backend/Core/Helpers/MfmHelpers.cs new file mode 100644 index 00000000..6f2287c1 --- /dev/null +++ b/Iceshrimp.Backend/Core/Helpers/MfmHelpers.cs @@ -0,0 +1,98 @@ +using System.Text; +using System.Text.RegularExpressions; +using AngleSharp.Dom; +using AngleSharp.Html.Parser; + +namespace Iceshrimp.Backend.Core.Helpers; + +public static class MfmHelpers { + public static async Task FromHtml(string? html) { + if (html == null) return ""; + + // Ensure compatibility with AP servers that send both
as well as newlines + var regex = new Regex(@"\r?\n", RegexOptions.IgnoreCase); + html = regex.Replace(html, "\n"); + + var dom = await new HtmlParser().ParseDocumentAsync(html); + if (dom.Body == null) return ""; + + var sb = new StringBuilder(); + dom.Body.ChildNodes.Select(ParseNode).ToList().ForEach(s => sb.Append(s)); + return sb.ToString().Trim(); + } + + private static string? ParseNode(INode node) { + if (node.NodeType is NodeType.Text) + return node.TextContent; + if (node.NodeType is NodeType.Comment or NodeType.Document) + return null; + + switch (node.NodeName) { + case "BR": { + return "\n"; + } + case "A": { + //TODO: implement parsing of links & mentions (automatically correct split domain mentions for the latter) + return null; + } + case "H1": { + return $"【{ParseChildren(node)}】\n"; + } + case "B": + case "STRONG": { + return $"**{ParseChildren(node)}**"; + } + case "SMALL": { + return $"{ParseChildren(node)}"; + } + case "S": + case "DEL": { + return $"~~{ParseChildren(node)}~~"; + } + case "I": + case "EM": { + return $"{ParseChildren(node)}"; + } + case "PRE": { + return node.ChildNodes is [{ NodeName: "CODE" }] + ? $"\n```\n{string.Join(null, node.ChildNodes[0].TextContent)}\n```\n" + : ParseChildren(node); + } + case "CODE": { + return $"`{ParseChildren(node)}`"; + } + case "BLOCKQUOTE": { + return node.TextContent.Length > 0 + ? $"\n> {string.Join("\n> ", node.TextContent.Split("\n"))}" + : null; + } + + case "P": + case "H2": + case "H3": + case "H4": + case "H5": + case "H6": { + return $"\n\n{ParseChildren(node)}"; + } + + case "DIV": + case "HEADER": + case "FOOTER": + case "ARTICLE": + case "LI": + case "DT": + case "DD": { + return $"\n{ParseChildren(node)}"; + } + + default: { + return ParseChildren(node); + } + } + } + + private static string ParseChildren(INode node) { + return string.Join(null, node.ChildNodes.Select(ParseNode)); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs index 4a61a96e..80fdce8b 100644 --- a/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs @@ -69,6 +69,14 @@ public class GracefulException(HttpStatusCode statusCode, string error, string m public GracefulException(string message, string? details = null) : this(HttpStatusCode.InternalServerError, HttpStatusCode.InternalServerError.ToString(), message, details) { } + + public static GracefulException UnprocessableEntity(string message, string? details = null) { + return new GracefulException(HttpStatusCode.UnprocessableEntity, message, details); + } + + public static GracefulException Forbidden(string message, string? details = null) { + return new GracefulException(HttpStatusCode.Forbidden, message, details); + } } public enum ExceptionVerbosity { diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index 1bb071a3..55119da9 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -3,6 +3,7 @@ using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Federation.ActivityPub; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Helpers; +using Iceshrimp.Backend.Core.Middleware; using Microsoft.EntityFrameworkCore; namespace Iceshrimp.Backend.Core.Services; @@ -19,16 +20,34 @@ public class NoteService(ILogger logger, DatabaseContext db, UserRe var user = await userResolver.Resolve(actor.Id); logger.LogDebug("Resolved user to {userId}", user.Id); + // Validate note + if (note.AttributedTo is not { Count: 1 } || note.AttributedTo[0].Id != user.Uri) + throw GracefulException.UnprocessableEntity("User.Uri doesn't match Note.AttributedTo"); + if (user.Uri == null) + throw GracefulException.UnprocessableEntity("User.Uri is null"); + if (new Uri(note.Id).IdnHost != new Uri(user.Uri).IdnHost) + throw GracefulException.UnprocessableEntity("User.Uri host doesn't match Note.Id host"); + if (!note.Id.StartsWith("https://")) + throw GracefulException.UnprocessableEntity("Note.Id schema is invalid"); + if (note.Url?.Link != null && !note.Url.Link.StartsWith("https://")) + throw GracefulException.UnprocessableEntity("Note.Url schema is invalid"); + if (note.PublishedAt is null or { Year: < 2007 } || note.PublishedAt > DateTime.Now + TimeSpan.FromDays(3)) + throw GracefulException.UnprocessableEntity("Note.PublishedAt is nonsensical"); + if (user.IsSuspended) + throw GracefulException.Forbidden("User is suspended"); + + //TODO: validate AP object type + //TODO: parse note visibility //TODO: resolve anything related to the note as well (reply thread, attachments, emoji, etc) var dbNote = new Note { Id = IdHelpers.GenerateSlowflakeId(), Uri = note.Id, - Url = note.Url?.Id, //FIXME: this doesn't seem to work yet - Text = note.MkContent ?? note.Content, //TODO: html-to-mfm + Url = note.Url?.Id, //FIXME: this doesn't seem to work yet + Text = note.MkContent ?? await MfmHelpers.FromHtml(note.Content), UserId = user.Id, CreatedAt = note.PublishedAt?.ToUniversalTime() ?? - throw new Exception("Missing or invalid PublishedAt field"), + throw GracefulException.UnprocessableEntity("Missing or invalid PublishedAt field"), UserHost = user.Host, Visibility = Note.NoteVisibility.Public //TODO: parse to & cc fields }; diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index a0734749..a9f13811 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -1,7 +1,9 @@ +using System.Globalization; using System.Net; using System.Security.Cryptography; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Federation.ActivityPub; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Middleware; @@ -16,7 +18,7 @@ public class UserService(ILogger logger, DatabaseContext db, APFetc var split = acct[5..].Split('@'); if (split.Length != 2) throw new GracefulException(HttpStatusCode.BadRequest, "Invalid query"); - return (split[0], split[1]); + return (split[0], split[1].ToPunycode()); } public Task GetUserFromQuery(string query) { diff --git a/Iceshrimp.Backend/Iceshrimp.Backend.csproj b/Iceshrimp.Backend/Iceshrimp.Backend.csproj index 4425041e..a93f2eab 100644 --- a/Iceshrimp.Backend/Iceshrimp.Backend.csproj +++ b/Iceshrimp.Backend/Iceshrimp.Backend.csproj @@ -14,6 +14,7 @@ +