From f471c5635d8416ab5b14dca198884fa5aedb8170 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 4 Dec 2025 00:26:54 +0800 Subject: [PATCH] :sparkles: Post article thumbnail --- .../Post/PostActionController.cs | 831 ++++++++++++++++++ DysonNetwork.Sphere/Post/PostController.cs | 771 ---------------- 2 files changed, 831 insertions(+), 771 deletions(-) create mode 100644 DysonNetwork.Sphere/Post/PostActionController.cs diff --git a/DysonNetwork.Sphere/Post/PostActionController.cs b/DysonNetwork.Sphere/Post/PostActionController.cs new file mode 100644 index 0000000..82a6bc1 --- /dev/null +++ b/DysonNetwork.Sphere/Post/PostActionController.cs @@ -0,0 +1,831 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using DysonNetwork.Shared.Auth; +using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Proto; +using DysonNetwork.Shared.Registry; +using DysonNetwork.Sphere.Poll; +using DysonNetwork.Sphere.Wallet; +using DysonNetwork.Sphere.WebReader; +using Grpc.Core; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Swashbuckle.AspNetCore.Annotations; +using PostType = DysonNetwork.Shared.Models.PostType; +using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole; +using PublisherService = DysonNetwork.Sphere.Publisher.PublisherService; + +namespace DysonNetwork.Sphere.Post; + +[ApiController] +[Route("/api/posts")] +public class PostActionController( + AppDatabase db, + PostService ps, + PublisherService pub, + AccountService.AccountServiceClient accounts, + ActionLogService.ActionLogServiceClient als, + PaymentService.PaymentServiceClient payments, + PollService polls, + RemoteRealmService rs +) : ControllerBase +{ + public class PostRequest + { + [MaxLength(1024)] public string? Title { get; set; } + + [MaxLength(4096)] public string? Description { get; set; } + + [MaxLength(1024)] public string? Slug { get; set; } + public string? Content { get; set; } + + public Shared.Models.PostVisibility? Visibility { get; set; } = + Shared.Models.PostVisibility.Public; + + public Shared.Models.PostType? Type { get; set; } + public Shared.Models.PostEmbedView? EmbedView { get; set; } + + [MaxLength(16)] public List? Tags { get; set; } + [MaxLength(8)] public List? Categories { get; set; } + [MaxLength(32)] public List? Attachments { get; set; } + + public Dictionary? Meta { get; set; } + public Instant? PublishedAt { get; set; } + public Guid? RepliedPostId { get; set; } + public Guid? ForwardedPostId { get; set; } + public Guid? RealmId { get; set; } + + public Guid? PollId { get; set; } + public Guid? FundId { get; set; } + public string? ThumbnailId { get; set; } + } + + [HttpPost] + [AskPermission("posts.create")] + public async Task> CreatePost( + [FromBody] PostRequest request, + [FromQuery(Name = "pub")] string? pubName + ) + { + request.Content = TextSanitizer.Sanitize(request.Content); + if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 }) + return BadRequest("Content is required."); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + if (!string.IsNullOrWhiteSpace(request.ThumbnailId) && request.Type != PostType.Article) + return BadRequest("Thumbnail only supported in article."); + if (!string.IsNullOrWhiteSpace(request.ThumbnailId) && + !(request.Attachments?.Contains(request.ThumbnailId) ?? false)) + return BadRequest("Thumbnail must be presented in attachment list."); + + var accountId = Guid.Parse(currentUser.Id); + + SnPublisher? publisher; + if (pubName is null) + { + // Use the first personal publisher + publisher = await db.Publishers.FirstOrDefaultAsync(e => + e.AccountId == accountId && e.Type == Shared.Models.PublisherType.Individual + ); + } + else + { + publisher = await pub.GetPublisherByName(pubName); + if (publisher is null) + return BadRequest("Publisher was not found."); + if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor)) + return StatusCode(403, "You need at least be an editor to post as this publisher."); + } + + if (publisher is null) + return BadRequest("Publisher was not found."); + + var post = new SnPost + { + Title = request.Title, + Description = request.Description, + Slug = request.Slug, + Content = request.Content, + Visibility = request.Visibility ?? Shared.Models.PostVisibility.Public, + PublishedAt = request.PublishedAt, + Type = request.Type ?? Shared.Models.PostType.Moment, + Meta = request.Meta, + EmbedView = request.EmbedView, + Publisher = publisher, + }; + + if (request.RepliedPostId is not null) + { + var repliedPost = await db + .Posts.Where(p => p.Id == request.RepliedPostId.Value) + .Include(p => p.Publisher) + .FirstOrDefaultAsync(); + if (repliedPost is null) + return BadRequest("Post replying to was not found."); + post.RepliedPost = repliedPost; + post.RepliedPostId = repliedPost.Id; + } + + if (request.ForwardedPostId is not null) + { + var forwardedPost = await db + .Posts.Where(p => p.Id == request.ForwardedPostId.Value) + .Include(p => p.Publisher) + .FirstOrDefaultAsync(); + if (forwardedPost is null) + return BadRequest("Forwarded post was not found."); + post.ForwardedPost = forwardedPost; + post.ForwardedPostId = forwardedPost.Id; + } + + if (request.RealmId is not null) + { + var realm = await rs.GetRealm(request.RealmId.Value.ToString()); + if ( + !await rs.IsMemberWithRole( + realm.Id, + accountId, + new List { RealmMemberRole.Normal } + ) + ) + return StatusCode(403, "You are not a member of this realm."); + post.RealmId = realm.Id; + } + + if (request.PollId.HasValue) + { + try + { + var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); + post.Meta ??= new Dictionary(); + if ( + !post.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); + post.Meta["embeds"] = embeds; + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + if (request.FundId.HasValue) + { + try + { + var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest + { + FundId = request.FundId.Value.ToString() + }); + + // Check if the fund was created by the current user + if (fundResponse.CreatorAccountId != currentUser.Id) + return BadRequest("You can only share funds that you created."); + + var fundEmbed = new FundEmbed { Id = request.FundId.Value }; + post.Meta ??= new Dictionary(); + if ( + !post.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + embeds.Add(EmbeddableBase.ToDictionary(fundEmbed)); + post.Meta["embeds"] = embeds; + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("The specified fund does not exist."); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) + { + return BadRequest("Invalid fund ID."); + } + } + + if (request.ThumbnailId is not null) + { + post.Meta ??= new Dictionary(); + post.Meta["thumbnail"] = request.ThumbnailId; + } + + try + { + post = await ps.PostAsync( + post, + attachments: request.Attachments, + tags: request.Tags, + categories: request.Categories + ); + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + + _ = als.CreateActionLogAsync( + new CreateActionLogRequest + { + Action = ActionLogType.PostCreate, + Meta = + { + { + "post_id", + Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) + }, + }, + AccountId = currentUser.Id, + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + } + ); + + post.Publisher = publisher; + + return post; + } + + public class PostReactionRequest + { + [MaxLength(256)] public string Symbol { get; set; } = null!; + public Shared.Models.PostReactionAttitude Attitude { get; set; } + } + + public static readonly List ReactionsAllowedDefault = + [ + "thumb_up", + "thumb_down", + "just_okay", + "cry", + "confuse", + "clap", + "laugh", + "angry", + "party", + "pray", + "heart", + ]; + + [HttpPost("{id:guid}/reactions")] + [Authorize] + [AskPermission("posts.react")] + public async Task> ReactPost( + Guid id, + [FromBody] PostReactionRequest request + ) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var friendsResponse = await accounts.ListFriendsAsync( + new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() } + ); + var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); + var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); + + if (!ReactionsAllowedDefault.Contains(request.Symbol)) + if (currentUser.PerkSubscription is null) + return BadRequest("You need subscription to send custom reactions"); + + var post = await db + .Posts.Where(e => e.Id == id) + .Include(e => e.Publisher) + .FilterWithVisibility(currentUser, userFriends, userPublishers) + .FirstOrDefaultAsync(); + if (post is null) + return NotFound(); + + var accountId = Guid.Parse(currentUser.Id); + var isSelfReact = + post.Publisher.AccountId is not null && post.Publisher.AccountId == accountId; + + var isExistingReaction = await db.PostReactions.AnyAsync(r => + r.PostId == post.Id && r.Symbol == request.Symbol && r.AccountId == accountId + ); + var reaction = new SnPostReaction + { + Symbol = request.Symbol, + Attitude = request.Attitude, + PostId = post.Id, + AccountId = accountId, + }; + var isRemoving = await ps.ModifyPostVotes( + post, + reaction, + currentUser, + isExistingReaction, + isSelfReact + ); + + if (isRemoving) + return NoContent(); + + _ = als.CreateActionLogAsync( + new CreateActionLogRequest + { + Action = ActionLogType.PostReact, + Meta = + { + { + "post_id", + Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) + }, + { "reaction", Google.Protobuf.WellKnownTypes.Value.ForString(request.Symbol) }, + }, + AccountId = currentUser.Id.ToString(), + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + } + ); + + return Ok(reaction); + } + + public class PostAwardRequest + { + public decimal Amount { get; set; } + public Shared.Models.PostReactionAttitude Attitude { get; set; } + + [MaxLength(4096)] public string? Message { get; set; } + } + + [HttpGet("{id:guid}/awards")] + public async Task> GetPostAwards( + Guid id, + [FromQuery] int offset = 0, + [FromQuery] int take = 20 + ) + { + var queryable = db.PostAwards.Where(a => a.PostId == id).AsQueryable(); + + var totalCount = await queryable.CountAsync(); + Response.Headers.Append("X-Total", totalCount.ToString()); + + var awards = await queryable.Take(take).Skip(offset).ToListAsync(); + + return Ok(awards); + } + + public class PostAwardResponse + { + public Guid OrderId { get; set; } + } + + [HttpPost("{id:guid}/awards")] + [Authorize] + public async Task> AwardPost( + Guid id, + [FromBody] PostAwardRequest request + ) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + if (request.Attitude == Shared.Models.PostReactionAttitude.Neutral) + return BadRequest("You cannot create a neutral post award"); + + var friendsResponse = await accounts.ListFriendsAsync( + new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() } + ); + var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); + var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); + + var post = await db + .Posts.Where(e => e.Id == id) + .Include(e => e.Publisher) + .FilterWithVisibility(currentUser, userFriends, userPublishers) + .FirstOrDefaultAsync(); + if (post is null) + return NotFound(); + + var accountId = Guid.Parse(currentUser.Id); + + var orderRemark = string.IsNullOrWhiteSpace(post.Title) + ? "from @" + post.Publisher.Name + : post.Title; + var order = await payments.CreateOrderAsync( + new CreateOrderRequest + { + ProductIdentifier = "posts.award", + Currency = "points", // NSP - Source Points + Remarks = $"Award post {orderRemark}", + Amount = request.Amount.ToString(CultureInfo.InvariantCulture), + Meta = GrpcTypeHelper.ConvertObjectToByteString( + new Dictionary + { + ["account_id"] = accountId, + ["post_id"] = post.Id, + ["amount"] = request.Amount.ToString(CultureInfo.InvariantCulture), + ["message"] = request.Message, + ["attitude"] = request.Attitude, + } + ), + } + ); + + return Ok(new PostAwardResponse() { OrderId = Guid.Parse(order.Id) }); + } + + public class PostPinRequest + { + [Required] public Shared.Models.PostPinMode Mode { get; set; } + } + + [HttpPost("{id:guid}/pin")] + [Authorize] + public async Task> PinPost(Guid id, [FromBody] PostPinRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var post = await db + .Posts.Where(e => e.Id == id) + .Include(e => e.Publisher) + .Include(e => e.RepliedPost) + .FirstOrDefaultAsync(); + if (post is null) + return NotFound(); + + var accountId = Guid.Parse(currentUser.Id); + if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor)) + return StatusCode(403, "You are not an editor of this publisher"); + + if (request.Mode == Shared.Models.PostPinMode.RealmPage && post.RealmId != null) + { + if ( + !await rs.IsMemberWithRole( + post.RealmId.Value, + accountId, + new List { RealmMemberRole.Moderator } + ) + ) + return StatusCode(403, "You are not a moderator of this realm"); + } + + try + { + await ps.PinPostAsync(post, currentUser, request.Mode); + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + + _ = als.CreateActionLogAsync( + new CreateActionLogRequest + { + Action = ActionLogType.PostPin, + Meta = + { + { + "post_id", + Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) + }, + { + "mode", + Google.Protobuf.WellKnownTypes.Value.ForString(request.Mode.ToString()) + }, + }, + AccountId = currentUser.Id.ToString(), + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + } + ); + + return Ok(post); + } + + [HttpDelete("{id:guid}/pin")] + [Authorize] + public async Task> UnpinPost(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var post = await db + .Posts.Where(e => e.Id == id) + .Include(e => e.Publisher) + .Include(e => e.RepliedPost) + .FirstOrDefaultAsync(); + if (post is null) + return NotFound(); + + var accountId = Guid.Parse(currentUser.Id); + if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor)) + return StatusCode(403, "You are not an editor of this publisher"); + + if (post is { PinMode: Shared.Models.PostPinMode.RealmPage, RealmId: not null }) + { + if ( + !await rs.IsMemberWithRole( + post.RealmId.Value, + accountId, + new List { RealmMemberRole.Moderator } + ) + ) + return StatusCode(403, "You are not a moderator of this realm"); + } + + try + { + await ps.UnpinPostAsync(post, currentUser); + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + + _ = als.CreateActionLogAsync( + new CreateActionLogRequest + { + Action = ActionLogType.PostUnpin, + Meta = + { + { + "post_id", + Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) + }, + }, + AccountId = currentUser.Id.ToString(), + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + } + ); + + return Ok(post); + } + + [HttpPatch("{id:guid}")] + public async Task> UpdatePost( + Guid id, + [FromBody] PostRequest request, + [FromQuery(Name = "pub")] string? pubName + ) + { + request.Content = TextSanitizer.Sanitize(request.Content); + if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 }) + return BadRequest("Content is required."); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + if (!string.IsNullOrWhiteSpace(request.ThumbnailId) && request.Type != PostType.Article) + return BadRequest("Thumbnail only supported in article."); + if (!string.IsNullOrWhiteSpace(request.ThumbnailId) && + !(request.Attachments?.Contains(request.ThumbnailId) ?? false)) + return BadRequest("Thumbnail must be presented in attachment list."); + + var post = await db + .Posts.Where(e => e.Id == id) + .Include(e => e.Publisher) + .Include(e => e.Categories) + .Include(e => e.Tags) + .Include(e => e.FeaturedRecords) + .FirstOrDefaultAsync(); + if (post is null) + return NotFound(); + + var accountId = Guid.Parse(currentUser.Id); + if (!await pub.IsMemberWithRole(post.Publisher.Id, accountId, PublisherMemberRole.Editor)) + return StatusCode(403, "You need at least be an editor to edit this publisher's post."); + + if (pubName is not null) + { + var publisher = await pub.GetPublisherByName(pubName); + if (publisher is null) + return NotFound(); + if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor)) + return StatusCode( + 403, + "You need at least be an editor to transfer this post to this publisher." + ); + post.PublisherId = publisher.Id; + post.Publisher = publisher; + } + + if (request.Title is not null) + post.Title = request.Title; + if (request.Description is not null) + post.Description = request.Description; + if (request.Slug is not null) + post.Slug = request.Slug; + if (request.Content is not null) + post.Content = request.Content; + if (request.Visibility is not null) + post.Visibility = request.Visibility.Value; + if (request.Type is not null) + post.Type = request.Type.Value; + if (request.Meta is not null) + post.Meta = request.Meta; + + // The same, this field can be null, so update it anyway. + post.EmbedView = request.EmbedView; + + // All the fields are updated when the request contains the specific fields + // But the Poll can be null, so it will be updated whatever it included in requests or not + if (request.PollId.HasValue) + { + try + { + var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); + post.Meta ??= new Dictionary(); + if ( + !post.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + // Remove all old poll embeds + embeds.RemoveAll(e => + e.TryGetValue("type", out var type) && type.ToString() == "poll" + ); + embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); + post.Meta["embeds"] = embeds; + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + else + { + post.Meta ??= new Dictionary(); + if ( + !post.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + // Remove all old poll embeds + embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll"); + } + + // Handle fund embeds + if (request.FundId.HasValue) + { + try + { + var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest + { + FundId = request.FundId.Value.ToString() + }); + + // Check if the fund was created by the current user + if (fundResponse.CreatorAccountId != currentUser.Id) + return BadRequest("You can only share funds that you created."); + + var fundEmbed = new FundEmbed { Id = request.FundId.Value }; + post.Meta ??= new Dictionary(); + if ( + !post.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + // Remove all old fund embeds + embeds.RemoveAll(e => + e.TryGetValue("type", out var type) && type.ToString() == "fund" + ); + embeds.Add(EmbeddableBase.ToDictionary(fundEmbed)); + post.Meta["embeds"] = embeds; + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("The specified fund does not exist."); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) + { + return BadRequest("Invalid fund ID."); + } + } + else + { + post.Meta ??= new Dictionary(); + if ( + !post.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + // Remove all old fund embeds + embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund"); + } + + if (request.ThumbnailId is not null) + { + post.Meta ??= new Dictionary(); + post.Meta["thumbnail"] = request.ThumbnailId; + } + else + { + post.Meta ??= new Dictionary(); + post.Meta.Remove("thumbnail"); + } + + // The realm is the same as well as the poll + if (request.RealmId is not null) + { + var realm = await rs.GetRealm(request.RealmId.Value.ToString()); + if ( + !await rs.IsMemberWithRole( + realm.Id, + accountId, + new List { RealmMemberRole.Normal } + ) + ) + return StatusCode(403, "You are not a member of this realm."); + post.RealmId = realm.Id; + } + else + { + post.RealmId = null; + } + + try + { + post = await ps.UpdatePostAsync( + post, + attachments: request.Attachments, + tags: request.Tags, + categories: request.Categories, + publishedAt: request.PublishedAt + ); + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + + _ = als.CreateActionLogAsync( + new CreateActionLogRequest + { + Action = ActionLogType.PostUpdate, + Meta = + { + { + "post_id", + Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) + }, + }, + AccountId = currentUser.Id.ToString(), + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + } + ); + + return Ok(post); + } + + [HttpDelete("{id:guid}")] + public async Task> DeletePost(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var post = await db + .Posts.Where(e => e.Id == id) + .Include(e => e.Publisher) + .FirstOrDefaultAsync(); + if (post is null) + return NotFound(); + + if ( + !await pub.IsMemberWithRole( + post.Publisher.Id, + Guid.Parse(currentUser.Id), + PublisherMemberRole.Editor + ) + ) + return StatusCode( + 403, + "You need at least be an editor to delete the publisher's post." + ); + + await ps.DeletePostAsync(post); + + _ = als.CreateActionLogAsync( + new CreateActionLogRequest + { + Action = ActionLogType.PostDelete, + Meta = + { + { + "post_id", + Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) + }, + }, + AccountId = currentUser.Id.ToString(), + UserAgent = Request.Headers.UserAgent, + IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + } + ); + + return NoContent(); + } +} diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs index 04f09ba..cbb58c2 100644 --- a/DysonNetwork.Sphere/Post/PostController.cs +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -27,9 +27,6 @@ public class PostController( PublisherService pub, RemoteAccountService remoteAccountsHelper, AccountService.AccountServiceClient accounts, - ActionLogService.ActionLogServiceClient als, - PaymentService.PaymentServiceClient payments, - PollService polls, RemoteRealmService rs ) : ControllerBase { @@ -55,7 +52,6 @@ public class PostController( /// Filter posts by category slugs. /// Filter posts by tag slugs. /// Search term to filter posts by title, description, or content. - /// If true, uses vector search with the query term. If false, performs a simple ILIKE search. /// If true, only returns posts that have attachments. /// If true, returns posts in random order. If false, orders by published/created date (newest first). /// If true, returns posts that pinned. If false, returns posts that are not pinned. If null, returns all posts. @@ -493,771 +489,4 @@ public class PostController( return Ok(posts); } - - public class PostRequest - { - [MaxLength(1024)] public string? Title { get; set; } - - [MaxLength(4096)] public string? Description { get; set; } - - [MaxLength(1024)] public string? Slug { get; set; } - public string? Content { get; set; } - - public Shared.Models.PostVisibility? Visibility { get; set; } = - Shared.Models.PostVisibility.Public; - - public Shared.Models.PostType? Type { get; set; } - public Shared.Models.PostEmbedView? EmbedView { get; set; } - - [MaxLength(16)] public List? Tags { get; set; } - - [MaxLength(8)] public List? Categories { get; set; } - - [MaxLength(32)] public List? Attachments { get; set; } - public Dictionary? Meta { get; set; } - public Instant? PublishedAt { get; set; } - public Guid? RepliedPostId { get; set; } - public Guid? ForwardedPostId { get; set; } - public Guid? RealmId { get; set; } - - public Guid? PollId { get; set; } - public Guid? FundId { get; set; } - } - - [HttpPost] - [AskPermission("posts.create")] - public async Task> CreatePost( - [FromBody] PostRequest request, - [FromQuery(Name = "pub")] string? pubName - ) - { - request.Content = TextSanitizer.Sanitize(request.Content); - if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 }) - return BadRequest("Content is required."); - if (HttpContext.Items["CurrentUser"] is not Account currentUser) - return Unauthorized(); - - var accountId = Guid.Parse(currentUser.Id); - - SnPublisher? publisher; - if (pubName is null) - { - // Use the first personal publisher - publisher = await db.Publishers.FirstOrDefaultAsync(e => - e.AccountId == accountId && e.Type == Shared.Models.PublisherType.Individual - ); - } - else - { - publisher = await pub.GetPublisherByName(pubName); - if (publisher is null) - return BadRequest("Publisher was not found."); - if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor)) - return StatusCode(403, "You need at least be an editor to post as this publisher."); - } - - if (publisher is null) - return BadRequest("Publisher was not found."); - - var post = new SnPost - { - Title = request.Title, - Description = request.Description, - Slug = request.Slug, - Content = request.Content, - Visibility = request.Visibility ?? Shared.Models.PostVisibility.Public, - PublishedAt = request.PublishedAt, - Type = request.Type ?? Shared.Models.PostType.Moment, - Meta = request.Meta, - EmbedView = request.EmbedView, - Publisher = publisher, - }; - - if (request.RepliedPostId is not null) - { - var repliedPost = await db - .Posts.Where(p => p.Id == request.RepliedPostId.Value) - .Include(p => p.Publisher) - .FirstOrDefaultAsync(); - if (repliedPost is null) - return BadRequest("Post replying to was not found."); - post.RepliedPost = repliedPost; - post.RepliedPostId = repliedPost.Id; - } - - if (request.ForwardedPostId is not null) - { - var forwardedPost = await db - .Posts.Where(p => p.Id == request.ForwardedPostId.Value) - .Include(p => p.Publisher) - .FirstOrDefaultAsync(); - if (forwardedPost is null) - return BadRequest("Forwarded post was not found."); - post.ForwardedPost = forwardedPost; - post.ForwardedPostId = forwardedPost.Id; - } - - if (request.RealmId is not null) - { - var realm = await rs.GetRealm(request.RealmId.Value.ToString()); - if ( - !await rs.IsMemberWithRole( - realm.Id, - accountId, - new List { RealmMemberRole.Normal } - ) - ) - return StatusCode(403, "You are not a member of this realm."); - post.RealmId = realm.Id; - } - - if (request.PollId.HasValue) - { - try - { - var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); - post.Meta ??= new Dictionary(); - if ( - !post.Meta.TryGetValue("embeds", out var existingEmbeds) - || existingEmbeds is not List - ) - post.Meta["embeds"] = new List>(); - var embeds = (List>)post.Meta["embeds"]; - embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); - post.Meta["embeds"] = embeds; - } - catch (Exception ex) - { - return BadRequest(ex.Message); - } - } - - if (request.FundId.HasValue) - { - try - { - var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest - { - FundId = request.FundId.Value.ToString() - }); - - // Check if the fund was created by the current user - if (fundResponse.CreatorAccountId != currentUser.Id) - return BadRequest("You can only share funds that you created."); - - var fundEmbed = new FundEmbed { Id = request.FundId.Value }; - post.Meta ??= new Dictionary(); - if ( - !post.Meta.TryGetValue("embeds", out var existingEmbeds) - || existingEmbeds is not List - ) - post.Meta["embeds"] = new List>(); - var embeds = (List>)post.Meta["embeds"]; - embeds.Add(EmbeddableBase.ToDictionary(fundEmbed)); - post.Meta["embeds"] = embeds; - } - catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) - { - return BadRequest("The specified fund does not exist."); - } - catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) - { - return BadRequest("Invalid fund ID."); - } - } - - try - { - post = await ps.PostAsync( - post, - attachments: request.Attachments, - tags: request.Tags, - categories: request.Categories - ); - } - catch (InvalidOperationException err) - { - return BadRequest(err.Message); - } - - _ = als.CreateActionLogAsync( - new CreateActionLogRequest - { - Action = ActionLogType.PostCreate, - Meta = - { - { - "post_id", - Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) - }, - }, - AccountId = currentUser.Id.ToString(), - UserAgent = Request.Headers.UserAgent, - IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), - } - ); - - post.Publisher = publisher; - - return post; - } - - public class PostReactionRequest - { - [MaxLength(256)] public string Symbol { get; set; } = null!; - public Shared.Models.PostReactionAttitude Attitude { get; set; } - } - - public static readonly List ReactionsAllowedDefault = - [ - "thumb_up", - "thumb_down", - "just_okay", - "cry", - "confuse", - "clap", - "laugh", - "angry", - "party", - "pray", - "heart", - ]; - - [HttpPost("{id:guid}/reactions")] - [Authorize] - [AskPermission("posts.react")] - public async Task> ReactPost( - Guid id, - [FromBody] PostReactionRequest request - ) - { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) - return Unauthorized(); - - var friendsResponse = await accounts.ListFriendsAsync( - new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() } - ); - var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); - var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); - - if (!ReactionsAllowedDefault.Contains(request.Symbol)) - if (currentUser.PerkSubscription is null) - return BadRequest("You need subscription to send custom reactions"); - - var post = await db - .Posts.Where(e => e.Id == id) - .Include(e => e.Publisher) - .FilterWithVisibility(currentUser, userFriends, userPublishers) - .FirstOrDefaultAsync(); - if (post is null) - return NotFound(); - - var accountId = Guid.Parse(currentUser.Id); - var isSelfReact = - post.Publisher.AccountId is not null && post.Publisher.AccountId == accountId; - - var isExistingReaction = await db.PostReactions.AnyAsync(r => - r.PostId == post.Id && r.Symbol == request.Symbol && r.AccountId == accountId - ); - var reaction = new SnPostReaction - { - Symbol = request.Symbol, - Attitude = request.Attitude, - PostId = post.Id, - AccountId = accountId, - }; - var isRemoving = await ps.ModifyPostVotes( - post, - reaction, - currentUser, - isExistingReaction, - isSelfReact - ); - - if (isRemoving) - return NoContent(); - - _ = als.CreateActionLogAsync( - new CreateActionLogRequest - { - Action = ActionLogType.PostReact, - Meta = - { - { - "post_id", - Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) - }, - { "reaction", Google.Protobuf.WellKnownTypes.Value.ForString(request.Symbol) }, - }, - AccountId = currentUser.Id.ToString(), - UserAgent = Request.Headers.UserAgent, - IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), - } - ); - - return Ok(reaction); - } - - public class PostAwardRequest - { - public decimal Amount { get; set; } - public Shared.Models.PostReactionAttitude Attitude { get; set; } - - [MaxLength(4096)] public string? Message { get; set; } - } - - [HttpGet("{id:guid}/awards")] - public async Task> GetPostAwards( - Guid id, - [FromQuery] int offset = 0, - [FromQuery] int take = 20 - ) - { - var queryable = db.PostAwards.Where(a => a.PostId == id).AsQueryable(); - - var totalCount = await queryable.CountAsync(); - Response.Headers.Append("X-Total", totalCount.ToString()); - - var awards = await queryable.Take(take).Skip(offset).ToListAsync(); - - return Ok(awards); - } - - public class PostAwardResponse - { - public Guid OrderId { get; set; } - } - - [HttpPost("{id:guid}/awards")] - [Authorize] - public async Task> AwardPost( - Guid id, - [FromBody] PostAwardRequest request - ) - { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) - return Unauthorized(); - if (request.Attitude == Shared.Models.PostReactionAttitude.Neutral) - return BadRequest("You cannot create a neutral post award"); - - var friendsResponse = await accounts.ListFriendsAsync( - new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() } - ); - var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); - var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); - - var post = await db - .Posts.Where(e => e.Id == id) - .Include(e => e.Publisher) - .FilterWithVisibility(currentUser, userFriends, userPublishers) - .FirstOrDefaultAsync(); - if (post is null) - return NotFound(); - - var accountId = Guid.Parse(currentUser.Id); - - var orderRemark = string.IsNullOrWhiteSpace(post.Title) - ? "from @" + post.Publisher.Name - : post.Title; - var order = await payments.CreateOrderAsync( - new CreateOrderRequest - { - ProductIdentifier = "posts.award", - Currency = "points", // NSP - Source Points - Remarks = $"Award post {orderRemark}", - Amount = request.Amount.ToString(CultureInfo.InvariantCulture), - Meta = GrpcTypeHelper.ConvertObjectToByteString( - new Dictionary - { - ["account_id"] = accountId, - ["post_id"] = post.Id, - ["amount"] = request.Amount.ToString(CultureInfo.InvariantCulture), - ["message"] = request.Message, - ["attitude"] = request.Attitude, - } - ), - } - ); - - return Ok(new PostAwardResponse() { OrderId = Guid.Parse(order.Id) }); - } - - public class PostPinRequest - { - [Required] public Shared.Models.PostPinMode Mode { get; set; } - } - - [HttpPost("{id:guid}/pin")] - [Authorize] - public async Task> PinPost(Guid id, [FromBody] PostPinRequest request) - { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) - return Unauthorized(); - - var post = await db - .Posts.Where(e => e.Id == id) - .Include(e => e.Publisher) - .Include(e => e.RepliedPost) - .FirstOrDefaultAsync(); - if (post is null) - return NotFound(); - - var accountId = Guid.Parse(currentUser.Id); - if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor)) - return StatusCode(403, "You are not an editor of this publisher"); - - if (request.Mode == Shared.Models.PostPinMode.RealmPage && post.RealmId != null) - { - if ( - !await rs.IsMemberWithRole( - post.RealmId.Value, - accountId, - new List { RealmMemberRole.Moderator } - ) - ) - return StatusCode(403, "You are not a moderator of this realm"); - } - - try - { - await ps.PinPostAsync(post, currentUser, request.Mode); - } - catch (InvalidOperationException err) - { - return BadRequest(err.Message); - } - - _ = als.CreateActionLogAsync( - new CreateActionLogRequest - { - Action = ActionLogType.PostPin, - Meta = - { - { - "post_id", - Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) - }, - { - "mode", - Google.Protobuf.WellKnownTypes.Value.ForString(request.Mode.ToString()) - }, - }, - AccountId = currentUser.Id.ToString(), - UserAgent = Request.Headers.UserAgent, - IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), - } - ); - - return Ok(post); - } - - [HttpDelete("{id:guid}/pin")] - [Authorize] - public async Task> UnpinPost(Guid id) - { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) - return Unauthorized(); - - var post = await db - .Posts.Where(e => e.Id == id) - .Include(e => e.Publisher) - .Include(e => e.RepliedPost) - .FirstOrDefaultAsync(); - if (post is null) - return NotFound(); - - var accountId = Guid.Parse(currentUser.Id); - if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor)) - return StatusCode(403, "You are not an editor of this publisher"); - - if (post is { PinMode: Shared.Models.PostPinMode.RealmPage, RealmId: not null }) - { - if ( - !await rs.IsMemberWithRole( - post.RealmId.Value, - accountId, - new List { RealmMemberRole.Moderator } - ) - ) - return StatusCode(403, "You are not a moderator of this realm"); - } - - try - { - await ps.UnpinPostAsync(post, currentUser); - } - catch (InvalidOperationException err) - { - return BadRequest(err.Message); - } - - _ = als.CreateActionLogAsync( - new CreateActionLogRequest - { - Action = ActionLogType.PostUnpin, - Meta = - { - { - "post_id", - Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) - }, - }, - AccountId = currentUser.Id.ToString(), - UserAgent = Request.Headers.UserAgent, - IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), - } - ); - - return Ok(post); - } - - [HttpPatch("{id:guid}")] - public async Task> UpdatePost( - Guid id, - [FromBody] PostRequest request, - [FromQuery(Name = "pub")] string? pubName - ) - { - request.Content = TextSanitizer.Sanitize(request.Content); - if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 }) - return BadRequest("Content is required."); - if (HttpContext.Items["CurrentUser"] is not Account currentUser) - return Unauthorized(); - - var post = await db - .Posts.Where(e => e.Id == id) - .Include(e => e.Publisher) - .Include(e => e.Categories) - .Include(e => e.Tags) - .Include(e => e.FeaturedRecords) - .FirstOrDefaultAsync(); - if (post is null) - return NotFound(); - - var accountId = Guid.Parse(currentUser.Id); - if (!await pub.IsMemberWithRole(post.Publisher.Id, accountId, PublisherMemberRole.Editor)) - return StatusCode(403, "You need at least be an editor to edit this publisher's post."); - - if (pubName is not null) - { - var publisher = await pub.GetPublisherByName(pubName); - if (publisher is null) - return NotFound(); - if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor)) - return StatusCode( - 403, - "You need at least be an editor to transfer this post to this publisher." - ); - post.PublisherId = publisher.Id; - post.Publisher = publisher; - } - - if (request.Title is not null) - post.Title = request.Title; - if (request.Description is not null) - post.Description = request.Description; - if (request.Slug is not null) - post.Slug = request.Slug; - if (request.Content is not null) - post.Content = request.Content; - if (request.Visibility is not null) - post.Visibility = request.Visibility.Value; - if (request.Type is not null) - post.Type = request.Type.Value; - if (request.Meta is not null) - post.Meta = request.Meta; - - // The same, this field can be null, so update it anyway. - post.EmbedView = request.EmbedView; - - // All the fields are updated when the request contains the specific fields - // But the Poll can be null, so it will be updated whatever it included in requests or not - if (request.PollId.HasValue) - { - try - { - var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); - post.Meta ??= new Dictionary(); - if ( - !post.Meta.TryGetValue("embeds", out var existingEmbeds) - || existingEmbeds is not List - ) - post.Meta["embeds"] = new List>(); - var embeds = (List>)post.Meta["embeds"]; - // Remove all old poll embeds - embeds.RemoveAll(e => - e.TryGetValue("type", out var type) && type.ToString() == "poll" - ); - embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); - post.Meta["embeds"] = embeds; - } - catch (Exception ex) - { - return BadRequest(ex.Message); - } - } - else - { - post.Meta ??= new Dictionary(); - if ( - !post.Meta.TryGetValue("embeds", out var existingEmbeds) - || existingEmbeds is not List - ) - post.Meta["embeds"] = new List>(); - var embeds = (List>)post.Meta["embeds"]; - // Remove all old poll embeds - embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll"); - } - - // Handle fund embeds - if (request.FundId.HasValue) - { - try - { - var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest - { - FundId = request.FundId.Value.ToString() - }); - - // Check if the fund was created by the current user - if (fundResponse.CreatorAccountId != currentUser.Id) - return BadRequest("You can only share funds that you created."); - - var fundEmbed = new FundEmbed { Id = request.FundId.Value }; - post.Meta ??= new Dictionary(); - if ( - !post.Meta.TryGetValue("embeds", out var existingEmbeds) - || existingEmbeds is not List - ) - post.Meta["embeds"] = new List>(); - var embeds = (List>)post.Meta["embeds"]; - // Remove all old fund embeds - embeds.RemoveAll(e => - e.TryGetValue("type", out var type) && type.ToString() == "fund" - ); - embeds.Add(EmbeddableBase.ToDictionary(fundEmbed)); - post.Meta["embeds"] = embeds; - } - catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) - { - return BadRequest("The specified fund does not exist."); - } - catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) - { - return BadRequest("Invalid fund ID."); - } - } - else - { - post.Meta ??= new Dictionary(); - if ( - !post.Meta.TryGetValue("embeds", out var existingEmbeds) - || existingEmbeds is not List - ) - post.Meta["embeds"] = new List>(); - var embeds = (List>)post.Meta["embeds"]; - // Remove all old fund embeds - embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund"); - } - - // The realm is the same as well as the poll - if (request.RealmId is not null) - { - var realm = await rs.GetRealm(request.RealmId.Value.ToString()); - if ( - !await rs.IsMemberWithRole( - realm.Id, - accountId, - new List { RealmMemberRole.Normal } - ) - ) - return StatusCode(403, "You are not a member of this realm."); - post.RealmId = realm.Id; - } - else - { - post.RealmId = null; - } - - try - { - post = await ps.UpdatePostAsync( - post, - attachments: request.Attachments, - tags: request.Tags, - categories: request.Categories, - publishedAt: request.PublishedAt - ); - } - catch (InvalidOperationException err) - { - return BadRequest(err.Message); - } - - _ = als.CreateActionLogAsync( - new CreateActionLogRequest - { - Action = ActionLogType.PostUpdate, - Meta = - { - { - "post_id", - Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) - }, - }, - AccountId = currentUser.Id.ToString(), - UserAgent = Request.Headers.UserAgent, - IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), - } - ); - - return Ok(post); - } - - [HttpDelete("{id:guid}")] - public async Task> DeletePost(Guid id) - { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) - return Unauthorized(); - - var post = await db - .Posts.Where(e => e.Id == id) - .Include(e => e.Publisher) - .FirstOrDefaultAsync(); - if (post is null) - return NotFound(); - - if ( - !await pub.IsMemberWithRole( - post.Publisher.Id, - Guid.Parse(currentUser.Id), - PublisherMemberRole.Editor - ) - ) - return StatusCode( - 403, - "You need at least be an editor to delete the publisher's post." - ); - - await ps.DeletePostAsync(post); - - _ = als.CreateActionLogAsync( - new CreateActionLogRequest - { - Action = ActionLogType.PostDelete, - Meta = - { - { - "post_id", - Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) - }, - }, - AccountId = currentUser.Id.ToString(), - UserAgent = Request.Headers.UserAgent, - IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), - } - ); - - return NoContent(); - } }