From aa6988399d9391bc66b5978bb3fe932b68d2e42d Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Fri, 2 Feb 2024 02:35:03 +0100 Subject: [PATCH] [backend/masto-client] Add schemas and renderers for user statuses --- .../Mastodon/MastodonAuthController.cs | 16 ++-- .../Mastodon/MastodonUserController.cs | 1 + .../Mastodon/Renderers/NoteRenderer.cs | 45 +++++++++++ .../Mastodon/Renderers/UserRenderer.cs | 6 +- .../Schemas/{Auth.cs => AuthSchemas.cs} | 2 +- .../Schemas/{ => Entities}/Account.cs | 13 +--- .../Mastodon/Schemas/Entities/Status.cs | 75 +++++++++++++++++++ .../Core/Extensions/ServiceExtensions.cs | 3 +- 8 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs rename Iceshrimp.Backend/Controllers/Mastodon/Schemas/{Auth.cs => AuthSchemas.cs} (98%) rename Iceshrimp.Backend/Controllers/Mastodon/Schemas/{ => Entities}/Account.cs (75%) create mode 100644 Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Status.cs diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MastodonAuthController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MastodonAuthController.cs index 53cf48f0..098d4e9a 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MastodonAuthController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MastodonAuthController.cs @@ -18,13 +18,13 @@ public class MastodonAuthController(DatabaseContext db) : Controller { [HttpGet("/api/v1/apps/verify_credentials")] [AuthenticateOauth] [Produces("application/json")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Auth.VerifyAppCredentialsResponse))] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.VerifyAppCredentialsResponse))] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] public IActionResult VerifyAppCredentials() { var token = HttpContext.GetOauthToken(); if (token == null) throw GracefulException.Unauthorized("The access token is invalid"); - var res = new Auth.VerifyAppCredentialsResponse { + var res = new AuthSchemas.VerifyAppCredentialsResponse { App = token.App, VapidKey = null //FIXME }; @@ -36,9 +36,9 @@ public class MastodonAuthController(DatabaseContext db) : Controller { [EnableRateLimiting("strict")] [ConsumesHybrid] [Produces("application/json")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Auth.RegisterAppResponse))] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.RegisterAppResponse))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] - public async Task RegisterApp([FromHybrid] Auth.RegisterAppRequest request) { + public async Task RegisterApp([FromHybrid] AuthSchemas.RegisterAppRequest request) { if (request.RedirectUris.Count == 0) throw GracefulException.BadRequest("Invalid redirect_uris parameter"); @@ -71,7 +71,7 @@ public class MastodonAuthController(DatabaseContext db) : Controller { await db.AddAsync(app); await db.SaveChangesAsync(); - var res = new Auth.RegisterAppResponse { + var res = new AuthSchemas.RegisterAppResponse { App = app, VapidKey = null //FIXME }; @@ -83,9 +83,9 @@ public class MastodonAuthController(DatabaseContext db) : Controller { [HttpPost("/oauth/token")] [ConsumesHybrid] [Produces("application/json")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Auth.OauthTokenResponse))] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.OauthTokenResponse))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] - public async Task GetOauthToken([FromHybrid] Auth.OauthTokenRequest request) { + public async Task GetOauthToken([FromHybrid] AuthSchemas.OauthTokenRequest request) { //TODO: app-level access (grant_type = "client_credentials") if (request.GrantType != "authorization_code") throw GracefulException.BadRequest("Invalid grant_type"); @@ -108,7 +108,7 @@ public class MastodonAuthController(DatabaseContext db) : Controller { token.Active = true; await db.SaveChangesAsync(); - var res = new Auth.OauthTokenResponse { + var res = new AuthSchemas.OauthTokenResponse { CreatedAt = token.CreatedAt, Scopes = token.Scopes, AccessToken = token.Token diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MastodonUserController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MastodonUserController.cs index 89bc6222..24fe3149 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MastodonUserController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MastodonUserController.cs @@ -1,5 +1,6 @@ using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; +using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Core.Middleware; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs new file mode 100644 index 00000000..2f5b1860 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs @@ -0,0 +1,45 @@ +using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Core.Configuration; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.MfmSharp.Conversion; +using Microsoft.Extensions.Options; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers; + +public class NoteRenderer(IOptions config, UserRenderer userRenderer) { + public async Task RenderAsync(Note note, int recurse = 2) { + var uri = note.Uri ?? $"https://{config.Value.WebDomain}/notes/@{note.Id}"; + var renote = note.Renote != null && recurse > 0 ? await RenderAsync(note.Renote, --recurse) : null; + var text = note.Text; //TODO: append quote uri + var content = text != null ? await MfmConverter.ToHtmlAsync(text) : null; + + var res = new Status { + Id = note.Id, + Uri = uri, + Url = note.Url ?? uri, + Account = await userRenderer.RenderAsync(note.User), //TODO: batch this + ReplyId = note.ReplyId, + ReplyUserId = note.ReplyUserId, + Renote = renote, //TODO: check if it's a pure renote + Quote = renote, //TODO: see above + ContentType = "text/x.misskeymarkdown", + CreatedAt = note.CreatedAt.ToString("O")[..^5], + EditedAt = note.UpdatedAt?.ToString("O")[..^5], + RepliesCount = note.RepliesCount, + RenoteCount = note.RenoteCount, + FavoriteCount = 0, //FIXME + IsRenoted = false, //FIXME + IsFavorited = false, //FIXME + IsBookmarked = false, //FIXME + IsMuted = null, //FIXME + IsSensitive = note.Cw != null, + ContentWarning = note.Cw ?? "", + Visibility = Status.EncodeVisibility(note.Visibility), + Content = content, + Text = text, + IsPinned = false //FIXME + }; + + return res; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs index 62b7f4de..b6056cf2 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs @@ -1,4 +1,4 @@ -using Iceshrimp.Backend.Controllers.Mastodon.Schemas; +using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; @@ -15,7 +15,7 @@ public class UserRenderer(IOptions config, DatabaseConte var acct = user.Username; if (user.Host != null) acct += $"@{user.Host}"; - + //TODO: respect ffVisibility for follower/following count var res = new Account { @@ -37,7 +37,7 @@ public class UserRenderer(IOptions config, DatabaseConte HeaderStaticUrl = user.BannerUrl ?? _transparent, //TODO MovedToAccount = null, //TODO IsBot = user.IsBot, - IsDiscoverable = user.IsExplorable, + IsDiscoverable = user.IsExplorable }; return res; diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Auth.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/AuthSchemas.cs similarity index 98% rename from Iceshrimp.Backend/Controllers/Mastodon/Schemas/Auth.cs rename to Iceshrimp.Backend/Controllers/Mastodon/Schemas/AuthSchemas.cs index 4d1caf72..eebdd816 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Auth.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/AuthSchemas.cs @@ -7,7 +7,7 @@ using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas; -public abstract class Auth { +public abstract class AuthSchemas { public class VerifyAppCredentialsResponse { public required OauthApp App; diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Account.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Account.cs similarity index 75% rename from Iceshrimp.Backend/Controllers/Mastodon/Schemas/Account.cs rename to Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Account.cs index 52fa7d22..3a1c9b24 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Account.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Account.cs @@ -1,11 +1,6 @@ -using Iceshrimp.Backend.Core.Database.Tables; -using Iceshrimp.Backend.Core.Helpers; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; -using JR = System.Text.Json.Serialization.JsonRequiredAttribute; -using JC = System.Text.Json.Serialization.JsonConverterAttribute; -using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute; -namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas; +namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; public class Account { [J("id")] public required string Id { get; set; } @@ -28,7 +23,7 @@ public class Account { [J("bot")] public required bool IsBot { get; set; } [J("discoverable")] public required bool IsDiscoverable { get; set; } - [J("source")] public string? Source => null; //FIXME - [J("fields")] public IEnumerable Fields => []; //FIXME - [J("emojis")] public IEnumerable Emoji => []; //FIXME + [J("source")] public object? Source => null; //FIXME + [J("fields")] public object[] Fields => []; //FIXME + [J("emojis")] public object[] Emoji => []; //FIXME } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Status.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Status.cs new file mode 100644 index 00000000..303de583 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Status.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Middleware; +using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; +using JI = System.Text.Json.Serialization.JsonIgnoreAttribute; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; + +public class Status { + [J("text")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? Text; + + [J("id")] public required string Id { get; set; } + [J("uri")] public required string Uri { get; set; } + [J("url")] public required string Url { get; set; } + [J("account")] public required Account Account { get; set; } + [J("in_reply_to_id")] public required string? ReplyId { get; set; } + [J("in_reply_to_account_id")] public required string? ReplyUserId { get; set; } + [J("reblog")] public required Status? Renote { get; set; } + [J("quote")] public required Status? Quote { get; set; } + [J("content_type")] public required string ContentType { get; set; } + [J("created_at")] public required string CreatedAt { get; set; } + [J("edited_at")] public required string? EditedAt { get; set; } + [J("replies_count")] public required long RepliesCount { get; set; } + [J("reblogs_count")] public required long RenoteCount { get; set; } + [J("favourites_count")] public required long FavoriteCount { get; set; } + [J("reblogged")] public required bool? IsRenoted { get; set; } + [J("favourited")] public required bool? IsFavorited { get; set; } + [J("bookmarked")] public required bool? IsBookmarked { get; set; } + [J("muted")] public required bool? IsMuted { get; set; } + [J("sensitive")] public required bool IsSensitive { get; set; } + [J("spoiler_text")] public required string ContentWarning { get; set; } + [J("visibility")] public required string Visibility { get; set; } + + [J("content")] + [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? Content { get; set; } + + [J("pinned")] + [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required bool? IsPinned { get; set; } + + [J("emojis")] public object[] Emojis => []; //FIXME + [J("media_attachments")] public object[] Media => []; //FIXME + [J("mentions")] public object[] Mentions => []; //FIXME + [J("tags")] public object[] Tags => []; //FIXME + [J("reactions")] public object[] Reactions => []; //FIXME + [J("filtered")] public object[] Filtered => []; //FIXME + [J("card")] public object? Card => null; //FIXME + [J("poll")] public object? Poll => null; //FIXME + [J("application")] public object? Application => null; //FIXME + + [J("language")] public string? Language => null; //FIXME + + public static string EncodeVisibility(Note.NoteVisibility visibility) { + return visibility switch { + Note.NoteVisibility.Public => "public", + Note.NoteVisibility.Home => "unlisted", + Note.NoteVisibility.Followers => "private", + Note.NoteVisibility.Specified => "direct", + Note.NoteVisibility.Hidden => throw new GracefulException("Cannot encode hidden visibility"), //FIXME + _ => throw new GracefulException($"Unknown visibility: {visibility}") + }; + } + + public static Note.NoteVisibility DecodeVisibility(string visibility) { + return visibility switch { + "public" => Note.NoteVisibility.Public, + "unlisted" => Note.NoteVisibility.Home, + "private" => Note.NoteVisibility.Followers, + "direct" => Note.NoteVisibility.Specified, + _ => throw GracefulException.BadRequest($"Unknown visibility: {visibility}") + }; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs index bbeaae34..90f95159 100644 --- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs @@ -33,9 +33,10 @@ public static class ServiceExtensions { services.AddScoped(); services.AddScoped(); services.AddScoped(); - + //TODO: make this prettier services.AddScoped(); + services.AddScoped(); // Singleton = instantiated once across application lifetime services.AddSingleton();