963 lines
		
	
	
		
			38 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			963 lines
		
	
	
		
			38 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| 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.WebReader;
 | |
| using Microsoft.AspNetCore.Authorization;
 | |
| using Microsoft.AspNetCore.Mvc;
 | |
| using Microsoft.EntityFrameworkCore;
 | |
| using NodaTime;
 | |
| using Swashbuckle.AspNetCore.Annotations;
 | |
| using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole;
 | |
| using PublisherService = DysonNetwork.Sphere.Publisher.PublisherService;
 | |
| 
 | |
| namespace DysonNetwork.Sphere.Post;
 | |
| 
 | |
| [ApiController]
 | |
| [Route("/api/posts")]
 | |
| public class PostController(
 | |
|     AppDatabase db,
 | |
|     PostService ps,
 | |
|     PublisherService pub,
 | |
|     RemoteAccountService remoteAccountsHelper,
 | |
|     AccountService.AccountServiceClient accounts,
 | |
|     ActionLogService.ActionLogServiceClient als,
 | |
|     PaymentService.PaymentServiceClient payments,
 | |
|     PollService polls,
 | |
|     RemoteRealmService rs
 | |
| )
 | |
|     : ControllerBase
 | |
| {
 | |
|     [HttpGet("featured")]
 | |
|     public async Task<ActionResult<List<SnPost>>> ListFeaturedPosts()
 | |
|     {
 | |
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
 | |
|         var currentUser = currentUserValue as Account;
 | |
| 
 | |
|         var posts = await ps.ListFeaturedPostsAsync(currentUser);
 | |
|         return Ok(posts);
 | |
|     }
 | |
| 
 | |
|     /// <summary>
 | |
|     /// Retrieves a paginated list of posts with optional filtering and sorting.
 | |
|     /// </summary>
 | |
|     /// <param name="includeReplies">Whether to include reply posts in the results. If false, only root posts are returned.</param>
 | |
|     /// <param name="offset">The number of posts to skip for pagination.</param>
 | |
|     /// <param name="take">The maximum number of posts to return (default: 20).</param>
 | |
|     /// <param name="pubName">Filter posts by publisher name.</param>
 | |
|     /// <param name="realmName">Filter posts by realm slug.</param>
 | |
|     /// <param name="type">Filter posts by post type (as integer).</param>
 | |
|     /// <param name="categories">Filter posts by category slugs.</param>
 | |
|     /// <param name="tags">Filter posts by tag slugs.</param>
 | |
|     /// <param name="queryTerm">Search term to filter posts by title, description, or content.</param>
 | |
|     /// <param name="queryVector">If true, uses vector search with the query term. If false, performs a simple ILIKE search.</param>
 | |
|     /// <param name="onlyMedia">If true, only returns posts that have attachments.</param>
 | |
|     /// <param name="shuffle">If true, returns posts in random order. If false, orders by published/created date (newest first).</param>
 | |
|     /// <param name="pinned">If true, returns posts that pinned. If false, returns posts that are not pinned. If null, returns all posts.</param>
 | |
|     /// <returns>
 | |
|     /// Returns an ActionResult containing a list of Post objects that match the specified criteria.
 | |
|     /// Includes an X-Total header with the total count of matching posts before pagination.
 | |
|     /// </returns>
 | |
|     /// <response code="200">Returns the list of posts matching the criteria.</response>
 | |
|     [HttpGet]
 | |
|     [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<SnPost>))]
 | |
|     [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | |
|     [SwaggerOperation(
 | |
|         Summary = "Retrieves a paginated list of posts",
 | |
|         Description =
 | |
|             "Gets posts with various filtering and sorting options. Supports pagination and advanced search capabilities.",
 | |
|         OperationId = "ListPosts",
 | |
|         Tags = ["Posts"]
 | |
|     )]
 | |
|     [SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved the list of posts", typeof(List<SnPost>))]
 | |
|     [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request parameters")]
 | |
|     public async Task<ActionResult<List<SnPost>>> ListPosts(
 | |
|         [FromQuery] int offset = 0,
 | |
|         [FromQuery] int take = 20,
 | |
|         [FromQuery(Name = "pub")] string? pubName = null,
 | |
|         [FromQuery(Name = "realm")] string? realmName = null,
 | |
|         [FromQuery(Name = "type")] int? type = null,
 | |
|         [FromQuery(Name = "categories")] List<string>? categories = null,
 | |
|         [FromQuery(Name = "tags")] List<string>? tags = null,
 | |
|         [FromQuery(Name = "query")] string? queryTerm = null,
 | |
|         [FromQuery(Name = "vector")] bool queryVector = false,
 | |
|         [FromQuery(Name = "media")] bool onlyMedia = false,
 | |
|         [FromQuery(Name = "shuffle")] bool shuffle = false,
 | |
|         [FromQuery(Name = "replies")] bool? includeReplies = null,
 | |
|         [FromQuery(Name = "pinned")] bool? pinned = null
 | |
|     )
 | |
|     {
 | |
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
 | |
|         var currentUser = currentUserValue as Account;
 | |
| 
 | |
|         List<Guid> userFriends = [];
 | |
|         if (currentUser != null)
 | |
|         {
 | |
|             var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
 | |
|             { AccountId = currentUser.Id });
 | |
|             userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
 | |
|         }
 | |
| 
 | |
|         var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
 | |
|         var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(accountId);
 | |
|         var userRealms = currentUser is null ? new List<Guid>() : await rs.GetUserRealms(accountId);
 | |
|         var publicRealms = await rs.GetPublicRealms();
 | |
|         var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
 | |
|         var visibleRealmIds = userRealms.Concat(publicRealmIds).Distinct().ToList();
 | |
| 
 | |
|         var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
 | |
|         var realm = realmName == null ? null : (await rs.GetRealmBySlug(realmName));
 | |
| 
 | |
|         var query = db.Posts
 | |
|             .Include(e => e.Categories)
 | |
|             .Include(e => e.Tags)
 | |
|             .Include(e => e.RepliedPost)
 | |
|             .Include(e => e.ForwardedPost)
 | |
|             .Include(e => e.FeaturedRecords)
 | |
|             .AsQueryable();
 | |
|         if (publisher != null)
 | |
|             query = query.Where(p => p.PublisherId == publisher.Id);
 | |
|         if (realm != null)
 | |
|             query = query.Where(p => p.RealmId == realm.Id);
 | |
|         if (type != null)
 | |
|             query = query.Where(p => p.Type == (Shared.Models.PostType)type);
 | |
|         if (categories is { Count: > 0 })
 | |
|             query = query.Where(p => p.Categories.Any(c => categories.Contains(c.Slug)));
 | |
|         if (tags is { Count: > 0 })
 | |
|             query = query.Where(p => p.Tags.Any(c => tags.Contains(c.Slug)));
 | |
|         if (onlyMedia)
 | |
|             query = query.Where(e => e.Attachments.Count > 0);
 | |
| 
 | |
|         if (realm == null)
 | |
|             query = query.Where(p => p.RealmId == null || visibleRealmIds.Contains(p.RealmId.Value));
 | |
| 
 | |
|         switch (pinned)
 | |
|         {
 | |
|             case true when realm != null:
 | |
|                 query = query.Where(p => p.PinMode == Shared.Models.PostPinMode.RealmPage);
 | |
|                 break;
 | |
|             case true when publisher != null:
 | |
|                 query = query.Where(p => p.PinMode == Shared.Models.PostPinMode.PublisherPage);
 | |
|                 break;
 | |
|             case true:
 | |
|                 return BadRequest(
 | |
|                     "You need pass extra realm or publisher params in order to filter with pinned posts.");
 | |
|             case false:
 | |
|                 query = query.Where(p => p.PinMode == null);
 | |
|                 break;
 | |
|         }
 | |
| 
 | |
|         query = includeReplies switch
 | |
|         {
 | |
|             false => query.Where(e => e.RepliedPostId == null),
 | |
|             true => query.Where(e => e.RepliedPostId != null),
 | |
|             _ => query
 | |
|         };
 | |
| 
 | |
|         if (!string.IsNullOrWhiteSpace(queryTerm))
 | |
|         {
 | |
|             if (queryVector)
 | |
|                 query = query.Where(p => p.SearchVector.Matches(EF.Functions.ToTsQuery(queryTerm)));
 | |
|             else
 | |
|                 query = query.Where(p =>
 | |
|                     (p.Title != null && EF.Functions.ILike(p.Title, $"%{queryTerm}%")) ||
 | |
|                     (p.Description != null && EF.Functions.ILike(p.Description, $"%{queryTerm}%")) ||
 | |
|                     (p.Content != null && EF.Functions.ILike(p.Content, $"%{queryTerm}%"))
 | |
|                 );
 | |
|         }
 | |
| 
 | |
|         query = query
 | |
|             .FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true);
 | |
| 
 | |
|         var totalCount = await query
 | |
|             .CountAsync();
 | |
| 
 | |
|         query = shuffle
 | |
|             ? query.OrderBy(e => EF.Functions.Random())
 | |
|             : query.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt);
 | |
| 
 | |
|         var posts = await query
 | |
|             .Skip(offset)
 | |
|             .Take(take)
 | |
|             .ToListAsync();
 | |
|         posts = await ps.LoadPostInfo(posts, currentUser, true);
 | |
| 
 | |
|         // Load realm data for posts that have realm
 | |
|         await LoadPostsRealmsAsync(posts, rs);
 | |
| 
 | |
|         Response.Headers["X-Total"] = totalCount.ToString();
 | |
| 
 | |
|         return Ok(posts);
 | |
|     }
 | |
| 
 | |
|     private static async Task LoadPostsRealmsAsync(List<SnPost> posts, RemoteRealmService rs)
 | |
|     {
 | |
|         var postRealmIds = posts.Where(p => p.RealmId != null).Select(p => p.RealmId.Value).Distinct().ToList();
 | |
|         if (!postRealmIds.Any()) return;
 | |
| 
 | |
|         var realms = await rs.GetRealmBatch(postRealmIds.Select(id => id.ToString()).ToList());
 | |
|         var realmDict = realms.GroupBy(r => r.Id).ToDictionary(g => g.Key, g => g.FirstOrDefault());
 | |
| 
 | |
|         foreach (var post in posts.Where(p => p.RealmId != null))
 | |
|         {
 | |
|             if (post.RealmId != null && realmDict.TryGetValue(post.RealmId.Value, out var realm))
 | |
|             {
 | |
|                 post.Realm = realm;
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     [HttpGet("{publisherName}/{slug}")]
 | |
|     public async Task<ActionResult<SnPost>> GetPost(string publisherName, string slug)
 | |
|     {
 | |
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
 | |
|         var currentUser = currentUserValue as Account;
 | |
|         List<Guid> userFriends = [];
 | |
|         if (currentUser != null)
 | |
|         {
 | |
|             var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
 | |
|             { AccountId = currentUser.Id });
 | |
|             userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
 | |
|         }
 | |
| 
 | |
|         var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
 | |
| 
 | |
|         var post = await db.Posts
 | |
|             .Include(e => e.Publisher)
 | |
|             .Where(e => e.Slug == slug && e.Publisher.Name == publisherName)
 | |
|             .Include(e => e.Tags)
 | |
|             .Include(e => e.Categories)
 | |
|             .Include(e => e.RepliedPost)
 | |
|             .Include(e => e.ForwardedPost)
 | |
|             .Include(e => e.FeaturedRecords)
 | |
|             .FilterWithVisibility(currentUser, userFriends, userPublishers)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (post is null) return NotFound();
 | |
|         post = await ps.LoadPostInfo(post, currentUser);
 | |
|         if (post.RealmId != null)
 | |
|             post.Realm = await rs.GetRealm(post.RealmId.Value.ToString());
 | |
| 
 | |
|         return Ok(post);
 | |
|     }
 | |
| 
 | |
|     [HttpGet("{id:guid}")]
 | |
|     public async Task<ActionResult<SnPost>> GetPost(Guid id)
 | |
|     {
 | |
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
 | |
|         var currentUser = currentUserValue as Account;
 | |
|         List<Guid> userFriends = [];
 | |
|         if (currentUser != null)
 | |
|         {
 | |
|             var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
 | |
|             { AccountId = currentUser.Id });
 | |
|             userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
 | |
|         }
 | |
| 
 | |
|         var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
 | |
| 
 | |
|         var post = await db.Posts
 | |
|             .Where(e => e.Id == id)
 | |
|             .Include(e => e.Publisher)
 | |
|             .Include(e => e.Tags)
 | |
|             .Include(e => e.Categories)
 | |
|             .Include(e => e.RepliedPost)
 | |
|             .Include(e => e.ForwardedPost)
 | |
|             .Include(e => e.FeaturedRecords)
 | |
|             .FilterWithVisibility(currentUser, userFriends, userPublishers)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (post is null) return NotFound();
 | |
|         post = await ps.LoadPostInfo(post, currentUser);
 | |
|         if (post.RealmId != null)
 | |
|         {
 | |
|             post.Realm = await rs.GetRealm(post.RealmId.Value.ToString());
 | |
|         }
 | |
| 
 | |
|         return Ok(post);
 | |
|     }
 | |
| 
 | |
|     [HttpGet("{id:guid}/reactions")]
 | |
|     public async Task<ActionResult<List<SnPostReaction>>> GetReactions(
 | |
|         Guid id,
 | |
|         [FromQuery] string? symbol = null,
 | |
|         [FromQuery] int offset = 0,
 | |
|         [FromQuery] int take = 20
 | |
|     )
 | |
|     {
 | |
|         var query = db.PostReactions
 | |
|             .Where(e => e.PostId == id);
 | |
|         if (symbol is not null) query = query.Where(e => e.Symbol == symbol);
 | |
| 
 | |
|         var totalCount = await query.CountAsync();
 | |
|         Response.Headers.Append("X-Total", totalCount.ToString());
 | |
| 
 | |
|         var reactions = await query
 | |
|             .OrderBy(r => r.Symbol)
 | |
|             .ThenByDescending(r => r.CreatedAt)
 | |
|             .Take(take)
 | |
|             .Skip(offset)
 | |
|             .ToListAsync();
 | |
| 
 | |
|         var accountsProto = await remoteAccountsHelper.GetAccountBatch(reactions.Select(r => r.AccountId).ToList());
 | |
|         var accounts = accountsProto.ToDictionary(a => Guid.Parse(a.Id), a => SnAccount.FromProtoValue(a));
 | |
| 
 | |
|         foreach (var reaction in reactions)
 | |
|             if (accounts.TryGetValue(reaction.AccountId, out var account))
 | |
|                 reaction.Account = account;
 | |
| 
 | |
|         return Ok(reactions);
 | |
|     }
 | |
| 
 | |
|     [HttpGet("{id:guid}/replies/featured")]
 | |
|     public async Task<ActionResult<SnPost>> GetFeaturedReply(Guid id)
 | |
|     {
 | |
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
 | |
|         var currentUser = currentUserValue as Account;
 | |
|         List<Guid> userFriends = [];
 | |
|         if (currentUser != null)
 | |
|         {
 | |
|             var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
 | |
|             { AccountId = currentUser.Id });
 | |
|             userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
 | |
|         }
 | |
| 
 | |
|         var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
 | |
| 
 | |
|         var now = SystemClock.Instance.GetCurrentInstant();
 | |
|         var post = await db.Posts
 | |
|             .Where(e => e.RepliedPostId == id)
 | |
|             .OrderByDescending(p =>
 | |
|                 p.Upvotes * 2 -
 | |
|                 p.Downvotes +
 | |
|                 ((p.CreatedAt - now).TotalMinutes < 60 ? 5 : 0)
 | |
|             )
 | |
|             .FilterWithVisibility(currentUser, userFriends, userPublishers)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (post is null) return NotFound();
 | |
|         post = await ps.LoadPostInfo(post, currentUser, true);
 | |
| 
 | |
|         return Ok(post);
 | |
|     }
 | |
| 
 | |
|     [HttpGet("{id:guid}/replies/pinned")]
 | |
|     public async Task<ActionResult<List<SnPost>>> ListPinnedReplies(Guid id)
 | |
|     {
 | |
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
 | |
|         var currentUser = currentUserValue as Account;
 | |
|         List<Guid> userFriends = [];
 | |
|         if (currentUser != null)
 | |
|         {
 | |
|             var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
 | |
|             { AccountId = currentUser.Id });
 | |
|             userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
 | |
|         }
 | |
| 
 | |
|         var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
 | |
| 
 | |
|         var now = SystemClock.Instance.GetCurrentInstant();
 | |
|         var posts = await db.Posts
 | |
|             .Where(e => e.RepliedPostId == id && e.PinMode == Shared.Models.PostPinMode.ReplyPage)
 | |
|             .OrderByDescending(p => p.CreatedAt)
 | |
|             .FilterWithVisibility(currentUser, userFriends, userPublishers)
 | |
|             .ToListAsync();
 | |
|         posts = await ps.LoadPostInfo(posts, currentUser);
 | |
| 
 | |
|         return Ok(posts);
 | |
|     }
 | |
| 
 | |
|     [HttpGet("{id:guid}/replies")]
 | |
|     public async Task<ActionResult<List<SnPost>>> ListReplies(Guid id, [FromQuery] int offset = 0,
 | |
|         [FromQuery] int take = 20)
 | |
|     {
 | |
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
 | |
|         var currentUser = currentUserValue as Account;
 | |
| 
 | |
|         List<Guid> userFriends = [];
 | |
|         if (currentUser != null)
 | |
|         {
 | |
|             var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
 | |
|             { AccountId = currentUser.Id });
 | |
|             userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
 | |
|         }
 | |
| 
 | |
|         var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
 | |
| 
 | |
|         var parent = await db.Posts
 | |
|             .Where(e => e.Id == id)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (parent is null) return NotFound();
 | |
| 
 | |
|         var totalCount = await db.Posts
 | |
|             .Where(e => e.RepliedPostId == parent.Id)
 | |
|             .FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true)
 | |
|             .CountAsync();
 | |
|         var posts = await db.Posts
 | |
|             .Where(e => e.RepliedPostId == id)
 | |
|             .Include(e => e.ForwardedPost)
 | |
|             .Include(e => e.Categories)
 | |
|             .Include(e => e.Tags)
 | |
|             .Include(e => e.FeaturedRecords)
 | |
|             .FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true)
 | |
|             .OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
 | |
|             .Skip(offset)
 | |
|             .Take(take)
 | |
|             .ToListAsync();
 | |
|         posts = await ps.LoadPostInfo(posts, currentUser, true);
 | |
| 
 | |
|         var postsId = posts.Select(e => e.Id).ToList();
 | |
|         var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
 | |
|         foreach (var post in posts)
 | |
|             post.ReactionsCount =
 | |
|                 reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary<string, int>();
 | |
| 
 | |
|         Response.Headers["X-Total"] = totalCount.ToString();
 | |
| 
 | |
|         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<string>? Tags { get; set; }
 | |
|         [MaxLength(8)] public List<string>? Categories { get; set; }
 | |
|         [MaxLength(32)] public List<string>? Attachments { get; set; }
 | |
|         public Dictionary<string, object>? 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; }
 | |
|     }
 | |
| 
 | |
|     [HttpPost]
 | |
|     [RequiredPermission("global", "posts.create")]
 | |
|     public async Task<ActionResult<SnPost>> 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);
 | |
| 
 | |
|         Shared.Models.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<int> { 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<string, object>();
 | |
|                 if (!post.Meta.TryGetValue("embeds", out var existingEmbeds) ||
 | |
|                     existingEmbeds is not List<EmbeddableBase>)
 | |
|                     post.Meta["embeds"] = new List<Dictionary<string, object>>();
 | |
|                 var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
 | |
|                 embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
 | |
|                 post.Meta["embeds"] = embeds;
 | |
|             }
 | |
|             catch (Exception ex)
 | |
|             {
 | |
|                 return BadRequest(ex.Message);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         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<string> ReactionsAllowedDefault =
 | |
|         ["thumb_up", "thumb_down", "just_okay", "cry", "confuse", "clap", "laugh", "angry", "party", "pray", "heart"];
 | |
| 
 | |
|     [HttpPost("{id:guid}/reactions")]
 | |
|     [Authorize]
 | |
|     [RequiredPermission("global", "posts.react")]
 | |
|     public async Task<ActionResult<SnPostReaction>> 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<ActionResult<SnPostAward>> 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<ActionResult<PostAwardResponse>> 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<string, object?>
 | |
|             {
 | |
|                 ["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<ActionResult<SnPost>> 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<int> { 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<ActionResult<SnPost>> 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<int> { 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<ActionResult<SnPost>> 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<string, object>();
 | |
|                 if (!post.Meta.TryGetValue("embeds", out var existingEmbeds) ||
 | |
|                     existingEmbeds is not List<EmbeddableBase>)
 | |
|                     post.Meta["embeds"] = new List<Dictionary<string, object>>();
 | |
|                 var embeds = (List<Dictionary<string, object>>)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<string, object>();
 | |
|             if (!post.Meta.TryGetValue("embeds", out var existingEmbeds) ||
 | |
|                 existingEmbeds is not List<EmbeddableBase>)
 | |
|                 post.Meta["embeds"] = new List<Dictionary<string, object>>();
 | |
|             var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
 | |
|             // Remove all old poll embeds
 | |
|             embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll");
 | |
|         }
 | |
| 
 | |
|         // 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<int> { 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<ActionResult<SnPost>> 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();
 | |
|     }
 | |
| }
 |