Compare commits
9 Commits
33abf12e41
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
a071bd2738
|
|||
|
43945fc524
|
|||
|
e477429a35
|
|||
|
fe3a057185
|
|||
|
ad3c104c5c
|
|||
|
2020d625aa
|
|||
|
f471c5635d
|
|||
|
eaeaa28c60
|
|||
|
ee5c7cb7ce
|
@@ -571,12 +571,12 @@ public class AccountCurrentController(
|
||||
.Where(device => device.AccountId == currentUser.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var sessionDevices = devices.Select(SnAuthClientWithSessions.FromClient).ToList();
|
||||
var sessionDevices = devices.ConvertAll(SnAuthClientWithSessions.FromClient).ToList();
|
||||
var clientIds = sessionDevices.Select(x => x.Id).ToList();
|
||||
|
||||
var authSessions = await db.AuthSessions
|
||||
.Where(c => clientIds.Contains(c.Id))
|
||||
.GroupBy(c => c.Id)
|
||||
.Where(c => c.ClientId != null && clientIds.Contains(c.ClientId.Value))
|
||||
.GroupBy(c => c.ClientId!.Value)
|
||||
.ToDictionaryAsync(c => c.Key, c => c.ToList());
|
||||
foreach (var dev in sessionDevices)
|
||||
if (authSessions.TryGetValue(dev.Id, out var challenge))
|
||||
@@ -956,4 +956,4 @@ public class AccountCurrentController(
|
||||
.ToListAsync();
|
||||
return Ok(records);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
@@ -14,7 +15,8 @@ public class AuthService(
|
||||
IConfiguration config,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ICacheService cache
|
||||
ICacheService cache,
|
||||
GeoIpService geo
|
||||
)
|
||||
{
|
||||
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
||||
@@ -49,7 +51,9 @@ public class AuthService(
|
||||
.ToListAsync();
|
||||
|
||||
var recentChallengeIds =
|
||||
recentSessions.Where(s => s.ChallengeId != null).Select(s => s.ChallengeId.Value).ToList();
|
||||
recentSessions
|
||||
.Where(s => s.ChallengeId != null)
|
||||
.Select(s => s.ChallengeId!.Value).ToList();
|
||||
var recentChallenges = await db.AuthChallenges.Where(c => recentChallengeIds.Contains(c.Id)).ToListAsync();
|
||||
|
||||
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
@@ -184,16 +188,23 @@ public class AuthService(
|
||||
return totalRequiredSteps;
|
||||
}
|
||||
|
||||
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time,
|
||||
Guid? customAppId = null, SnAuthSession? parentSession = null)
|
||||
public async Task<SnAuthSession> CreateSessionForOidcAsync(
|
||||
SnAccount account,
|
||||
Instant time,
|
||||
Guid? customAppId = null,
|
||||
SnAuthSession? parentSession = null
|
||||
)
|
||||
{
|
||||
var ipAddr = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var geoLocation = ipAddr is not null ? geo.GetPointFromIp(ipAddr) : null;
|
||||
var session = new SnAuthSession
|
||||
{
|
||||
AccountId = account.Id,
|
||||
CreatedAt = time,
|
||||
LastGrantedAt = time,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
IpAddress = ipAddr,
|
||||
UserAgent = HttpContext.Request.Headers.UserAgent,
|
||||
Location = geoLocation,
|
||||
AppId = customAppId,
|
||||
ParentSessionId = parentSession?.Id,
|
||||
Type = customAppId is not null ? SessionType.OAuth : SessionType.Oidc,
|
||||
@@ -212,7 +223,8 @@ public class AuthService(
|
||||
ClientPlatform platform = ClientPlatform.Unidentified
|
||||
)
|
||||
{
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
|
||||
var device = await db.AuthClients
|
||||
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
|
||||
if (device is not null) return device;
|
||||
device = new SnAuthClient
|
||||
{
|
||||
@@ -333,12 +345,8 @@ public class AuthService(
|
||||
/// <param name="sessionsToRevoke">A HashSet to store the IDs of all sessions to be revoked.</param>
|
||||
private async Task CollectSessionsToRevoke(Guid currentSessionId, HashSet<Guid> sessionsToRevoke)
|
||||
{
|
||||
if (sessionsToRevoke.Contains(currentSessionId))
|
||||
{
|
||||
if (!sessionsToRevoke.Add(currentSessionId))
|
||||
return; // Already processed this session
|
||||
}
|
||||
|
||||
sessionsToRevoke.Add(currentSessionId);
|
||||
|
||||
// Find direct children
|
||||
var childSessions = await db.AuthSessions
|
||||
@@ -425,6 +433,7 @@ public class AuthService(
|
||||
AccountId = challenge.AccountId,
|
||||
IpAddress = challenge.IpAddress,
|
||||
UserAgent = challenge.UserAgent,
|
||||
Location = challenge.Location,
|
||||
Scopes = challenge.Scopes,
|
||||
Audiences = challenge.Audiences,
|
||||
ChallengeId = challenge.Id,
|
||||
@@ -679,9 +688,16 @@ public class AuthService(
|
||||
{
|
||||
var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform);
|
||||
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||
var geoLocation = ipAddress is not null ? geo.GetPointFromIp(ipAddress) : null;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var session = new SnAuthSession
|
||||
{
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent,
|
||||
Location = geoLocation,
|
||||
AccountId = parentSession.AccountId,
|
||||
CreatedAt = now,
|
||||
LastGrantedAt = now,
|
||||
|
||||
2886
DysonNetwork.Pass/Migrations/20251203163459_AddLocationToSession.Designer.cs
generated
Normal file
2886
DysonNetwork.Pass/Migrations/20251203163459_AddLocationToSession.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLocationToSession : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<GeoPoint>(
|
||||
name: "location",
|
||||
table: "auth_sessions",
|
||||
type: "jsonb",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "location",
|
||||
table: "auth_sessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1053,6 +1053,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_granted_at");
|
||||
|
||||
b.Property<GeoPoint>("Location")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<Guid?>("ParentSessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("parent_session_id");
|
||||
|
||||
@@ -26,6 +26,7 @@ public class SnAuthSession : ModelBase
|
||||
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = [];
|
||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public SnAccount Account { get; set; } = null!;
|
||||
|
||||
@@ -38,7 +38,7 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
|
||||
[NotMapped]
|
||||
public SnDeveloper Developer => Project.Developer;
|
||||
|
||||
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
|
||||
[NotMapped] public string ResourceIdentifier => "developer.app:" + Id;
|
||||
|
||||
public Proto.CustomApp ToProto()
|
||||
{
|
||||
|
||||
@@ -50,5 +50,5 @@ public class FilePool : ModelBase, IIdentifiedResource
|
||||
|
||||
public Guid? AccountId { get; set; }
|
||||
|
||||
public string ResourceIdentifier => $"file-pool/{Id}";
|
||||
public string ResourceIdentifier => $"file.pool:{Id}";
|
||||
}
|
||||
|
||||
@@ -20,6 +20,19 @@ public enum PublicationSiteMode
|
||||
SelfManaged
|
||||
}
|
||||
|
||||
public class PublicationSiteConfig
|
||||
{
|
||||
public string? StyleOverride { get; set; }
|
||||
public List<PublicationSiteNavItem>? NavItems { get; set; } = [];
|
||||
}
|
||||
|
||||
public class PublicationSiteNavItem
|
||||
{
|
||||
[MaxLength(1024)] public string Label { get; set; } = null!;
|
||||
[MaxLength(8192)] public string Href { get; set; } = null!;
|
||||
Dictionary<string, object> Attributes { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SnPublicationSite : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -29,6 +42,7 @@ public class SnPublicationSite : ModelBase
|
||||
|
||||
public PublicationSiteMode Mode { get; set; } = PublicationSiteMode.FullyManaged;
|
||||
public List<SnPublicationPage> Pages { get; set; } = [];
|
||||
[Column(TypeName = "jsonb")] public PublicationSiteConfig Config { get; set; } = new();
|
||||
|
||||
public Guid PublisherId { get; set; }
|
||||
[NotMapped] public SnPublisher Publisher { get; set; } = null!;
|
||||
|
||||
@@ -11,47 +11,33 @@ public class SnSticker : ModelBase, IIdentifiedResource
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Slug { get; set; } = null!;
|
||||
|
||||
// Outdated fields, for backward compability
|
||||
[MaxLength(32)]
|
||||
public string? ImageId { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public SnCloudFileReferenceObject? Image { get; set; } = null!;
|
||||
[MaxLength(128)] public string Slug { get; set; } = null!;
|
||||
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject Image { get; set; } = null!;
|
||||
|
||||
public Guid PackId { get; set; }
|
||||
[IgnoreMember] [JsonIgnore] public StickerPack Pack { get; set; } = null!;
|
||||
|
||||
[JsonIgnore]
|
||||
public StickerPack Pack { get; set; } = null!;
|
||||
|
||||
public string ResourceIdentifier => $"sticker/{Id}";
|
||||
public string ResourceIdentifier => $"sticker:{Id}";
|
||||
}
|
||||
|
||||
[Index(nameof(Prefix), IsUnique = true)]
|
||||
public class StickerPack : ModelBase
|
||||
public class StickerPack : ModelBase, IIdentifiedResource
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string Name { get; set; } = null!;
|
||||
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Icon { get; set; }
|
||||
[MaxLength(1024)] public string Name { get; set; } = null!;
|
||||
[MaxLength(4096)] public string Description { get; set; } = string.Empty;
|
||||
[MaxLength(128)] public string Prefix { get; set; } = null!;
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Prefix { get; set; } = null!;
|
||||
|
||||
[IgnoreMember]
|
||||
public List<SnSticker> Stickers { get; set; } = [];
|
||||
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
public List<StickerPackOwnership> Ownerships { get; set; } = [];
|
||||
[IgnoreMember] [JsonIgnore] public List<StickerPackOwnership> Ownerships { get; set; } = [];
|
||||
|
||||
public Guid PublisherId { get; set; }
|
||||
public SnPublisher Publisher { get; set; } = null!;
|
||||
|
||||
public string ResourceIdentifier => $"sticker.pack:{Id}";
|
||||
}
|
||||
|
||||
public class StickerPackOwnership : ModelBase
|
||||
@@ -62,6 +48,5 @@ public class StickerPackOwnership : ModelBase
|
||||
public StickerPack Pack { get; set; } = null!;
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public SnAccount Account { get; set; } = null!;
|
||||
}
|
||||
[NotMapped] public SnAccount Account { get; set; } = null!;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public class DiscoveryService(RemoteRealmService remoteRealmService)
|
||||
// Since we don't have CreatedAt in the proto model, we'll just apply randomizer if requested
|
||||
var orderedRealms = randomizer
|
||||
? communityRealms.OrderBy(_ => Random.Shared.Next())
|
||||
: communityRealms;
|
||||
: communityRealms.OrderByDescending(q => q.Members.Count());
|
||||
|
||||
return orderedRealms.Skip(offset).Take(take).ToList();
|
||||
}
|
||||
|
||||
1940
DysonNetwork.Sphere/Migrations/20251203161208_AddStickerPackIcon.Designer.cs
generated
Normal file
1940
DysonNetwork.Sphere/Migrations/20251203161208_AddStickerPackIcon.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStickerPackIcon : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "image_id",
|
||||
table: "stickers");
|
||||
|
||||
migrationBuilder.AlterColumn<SnCloudFileReferenceObject>(
|
||||
name: "image",
|
||||
table: "stickers",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
oldClrType: typeof(SnCloudFileReferenceObject),
|
||||
oldType: "jsonb",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<SnCloudFileReferenceObject>(
|
||||
name: "icon",
|
||||
table: "sticker_packs",
|
||||
type: "jsonb",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "icon",
|
||||
table: "sticker_packs");
|
||||
|
||||
migrationBuilder.AlterColumn<SnCloudFileReferenceObject>(
|
||||
name: "image",
|
||||
table: "stickers",
|
||||
type: "jsonb",
|
||||
nullable: true,
|
||||
oldClrType: typeof(SnCloudFileReferenceObject),
|
||||
oldType: "jsonb");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "image_id",
|
||||
table: "stickers",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1147,14 +1147,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<SnCloudFileReferenceObject>("Image")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("image");
|
||||
|
||||
b.Property<string>("ImageId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("image_id");
|
||||
|
||||
b.Property<Guid>("PackId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("pack_id");
|
||||
@@ -1202,6 +1198,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<SnCloudFileReferenceObject>("Icon")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("icon");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
|
||||
831
DysonNetwork.Sphere/Post/PostActionController.cs
Normal file
831
DysonNetwork.Sphere/Post/PostActionController.cs
Normal file
@@ -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<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; }
|
||||
public Guid? FundId { get; set; }
|
||||
public string? ThumbnailId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AskPermission("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();
|
||||
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
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<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(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<string, object>();
|
||||
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<string> 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<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();
|
||||
|
||||
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<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");
|
||||
}
|
||||
|
||||
// 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<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 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<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 fund embeds
|
||||
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund");
|
||||
}
|
||||
|
||||
if (request.ThumbnailId is not null)
|
||||
{
|
||||
post.Meta ??= new Dictionary<string, object>();
|
||||
post.Meta["thumbnail"] = request.ThumbnailId;
|
||||
}
|
||||
else
|
||||
{
|
||||
post.Meta ??= new Dictionary<string, object>();
|
||||
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<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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -43,27 +40,6 @@ public class PostController(
|
||||
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)]
|
||||
@@ -493,771 +469,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<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; }
|
||||
public Guid? FundId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AskPermission("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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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<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(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<string> 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<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");
|
||||
}
|
||||
|
||||
// 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<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 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<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 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<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ public class StickerController(
|
||||
AppDatabase db,
|
||||
StickerService st,
|
||||
Publisher.PublisherService ps,
|
||||
FileService.FileServiceClient files
|
||||
FileService.FileServiceClient files,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs
|
||||
) : ControllerBase
|
||||
{
|
||||
private async Task<IActionResult> _CheckStickerPackPermissions(
|
||||
@@ -114,6 +115,7 @@ public class StickerController(
|
||||
|
||||
public class StickerPackRequest
|
||||
{
|
||||
public string? IconId { get; set; }
|
||||
[MaxLength(1024)] public string? Name { get; set; }
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
[MaxLength(128)] public string? Prefix { get; set; }
|
||||
@@ -147,8 +149,28 @@ public class StickerController(
|
||||
PublisherId = publisher.Id
|
||||
};
|
||||
|
||||
if (request.IconId is not null)
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = request.IconId });
|
||||
if (file is null)
|
||||
return BadRequest("Icon not found.");
|
||||
|
||||
pack.Icon = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
db.StickerPacks.Add(pack);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (pack.Icon is not null)
|
||||
{
|
||||
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
|
||||
{
|
||||
FileId = pack.Icon.Id,
|
||||
Usage = StickerService.StickerPackUsageIdentifier,
|
||||
ResourceId = pack.ResourceIdentifier
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(pack);
|
||||
}
|
||||
|
||||
@@ -179,6 +201,32 @@ public class StickerController(
|
||||
if (request.Prefix is not null)
|
||||
pack.Prefix = request.Prefix;
|
||||
|
||||
if (request.IconId is not null)
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = request.IconId });
|
||||
if (file is null)
|
||||
return BadRequest("Icon not found.");
|
||||
|
||||
if (file.Id != pack.Icon?.Id)
|
||||
{
|
||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||
{ ResourceId = pack.ResourceIdentifier, Usage = StickerService.StickerPackUsageIdentifier });
|
||||
|
||||
pack.Icon = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
|
||||
{
|
||||
FileId = pack.Icon.Id,
|
||||
Usage = StickerService.StickerPackUsageIdentifier,
|
||||
ResourceId = pack.ResourceIdentifier
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Still update the column in case user want to sync the changes of the file meta
|
||||
pack.Icon = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
}
|
||||
|
||||
db.StickerPacks.Update(pack);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(pack);
|
||||
@@ -239,7 +287,11 @@ public class StickerController(
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<List<SnSticker>>> SearchSticker([FromQuery] string query, [FromQuery] int take = 10, [FromQuery] int offset = 0)
|
||||
public async Task<ActionResult<List<SnSticker>>> SearchSticker(
|
||||
[FromQuery] string query,
|
||||
[FromQuery] int take = 10,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
var queryable = db.Stickers
|
||||
.Include(s => s.Pack)
|
||||
@@ -300,7 +352,6 @@ public class StickerController(
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = request.ImageId });
|
||||
if (file is null)
|
||||
return BadRequest("Image not found");
|
||||
sticker.ImageId = request.ImageId;
|
||||
sticker.Image = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
@@ -367,7 +418,6 @@ public class StickerController(
|
||||
var sticker = new SnSticker
|
||||
{
|
||||
Slug = request.Slug,
|
||||
ImageId = file.Id,
|
||||
Image = SnCloudFileReferenceObject.FromProtoValue(file),
|
||||
Pack = pack
|
||||
};
|
||||
@@ -437,4 +487,4 @@ public class StickerController(
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public class StickerService(
|
||||
)
|
||||
{
|
||||
public const string StickerFileUsageIdentifier = "sticker";
|
||||
public const string StickerPackUsageIdentifier = "sticker.pack";
|
||||
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
@@ -36,7 +37,8 @@ public class StickerService(
|
||||
{
|
||||
if (newImage is not null)
|
||||
{
|
||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = sticker.ResourceIdentifier });
|
||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||
{ ResourceId = sticker.ResourceIdentifier });
|
||||
|
||||
sticker.Image = newImage;
|
||||
|
||||
@@ -63,7 +65,8 @@ public class StickerService(
|
||||
var stickerResourceId = $"sticker:{sticker.Id}";
|
||||
|
||||
// Delete all file references for this sticker
|
||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = stickerResourceId });
|
||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||
{ ResourceId = stickerResourceId });
|
||||
|
||||
db.Stickers.Remove(sticker);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -82,11 +85,12 @@ public class StickerService(
|
||||
|
||||
// Delete all file references for each sticker in the pack
|
||||
foreach (var stickerResourceId in stickers.Select(sticker => $"sticker:{sticker.Id}"))
|
||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = stickerResourceId });
|
||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||
{ ResourceId = stickerResourceId });
|
||||
|
||||
// Delete any references for the pack itself
|
||||
var packResourceId = $"stickerpack:{pack.Id}";
|
||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = packResourceId });
|
||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||
{ ResourceId = pack.ResourceIdentifier });
|
||||
|
||||
db.Stickers.RemoveRange(stickers);
|
||||
db.StickerPacks.Remove(pack);
|
||||
@@ -119,7 +123,8 @@ public class StickerService(
|
||||
{
|
||||
var packPart = identifierParts[0];
|
||||
var stickerPart = identifierParts[1];
|
||||
query = query.Where(e => EF.Functions.ILike(e.Pack.Prefix, packPart) && EF.Functions.ILike(e.Slug, stickerPart));
|
||||
query = query.Where(e =>
|
||||
EF.Functions.ILike(e.Pack.Prefix, packPart) && EF.Functions.ILike(e.Slug, stickerPart));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -144,4 +149,4 @@ public class StickerService(
|
||||
await cache.RemoveAsync($"sticker:lookup:{sticker.Id}");
|
||||
await cache.RemoveAsync($"sticker:lookup:{sticker.Pack.Prefix}{sticker.Slug}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,7 @@ public class ActivityController(TimelineService acts) : ControllerBase
|
||||
public async Task<ActionResult<List<SnTimelineEvent>>> ListEvents(
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] string? filter,
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] string? debugInclude = null
|
||||
[FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
Instant? cursorTimestamp = null;
|
||||
@@ -42,13 +41,9 @@ public class ActivityController(TimelineService acts) : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>();
|
||||
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
return currentUserValue is not Account currentUser
|
||||
? Ok(await acts.ListEventsForAnyone(take, cursorTimestamp, debugIncludeSet))
|
||||
: Ok(
|
||||
await acts.ListEvents(take, cursorTimestamp, currentUser, filter, debugIncludeSet)
|
||||
);
|
||||
? Ok(await acts.ListEventsForAnyone(take, cursorTimestamp))
|
||||
: Ok(await acts.ListEvents(take, cursorTimestamp, currentUser, filter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,14 +32,9 @@ public class TimelineService(
|
||||
return performanceWeight / Math.Pow(normalizedTime + 1.0, 1.2);
|
||||
}
|
||||
|
||||
public async Task<List<SnTimelineEvent>> ListEventsForAnyone(
|
||||
int take,
|
||||
Instant? cursor,
|
||||
HashSet<string>? debugInclude = null
|
||||
)
|
||||
public async Task<List<SnTimelineEvent>> ListEventsForAnyone(int take, Instant? cursor)
|
||||
{
|
||||
var activities = new List<SnTimelineEvent>();
|
||||
debugInclude ??= new HashSet<string>();
|
||||
|
||||
// Get and process posts
|
||||
var publicRealms = await rs.GetPublicRealms();
|
||||
@@ -60,7 +55,7 @@ public class TimelineService(
|
||||
// Randomly insert a discovery activity before some posts
|
||||
if (random.NextDouble() < 0.15)
|
||||
{
|
||||
var discovery = await MaybeGetDiscoveryActivity(debugInclude, cursor: cursor);
|
||||
var discovery = await MaybeGetDiscoveryActivity(cursor: cursor);
|
||||
if (discovery != null)
|
||||
interleaved.Add(discovery);
|
||||
}
|
||||
@@ -80,12 +75,10 @@ public class TimelineService(
|
||||
int take,
|
||||
Instant? cursor,
|
||||
Account currentUser,
|
||||
string? filter = null,
|
||||
HashSet<string>? debugInclude = null
|
||||
string? filter = null
|
||||
)
|
||||
{
|
||||
var activities = new List<SnTimelineEvent>();
|
||||
debugInclude ??= new HashSet<string>();
|
||||
|
||||
// Get user's friends and publishers
|
||||
var friendsResponse = await accounts.ListFriendsAsync(
|
||||
@@ -126,7 +119,7 @@ public class TimelineService(
|
||||
{
|
||||
if (random.NextDouble() < 0.15)
|
||||
{
|
||||
var discovery = await MaybeGetDiscoveryActivity(debugInclude, cursor: cursor);
|
||||
var discovery = await MaybeGetDiscoveryActivity(cursor: cursor);
|
||||
if (discovery != null)
|
||||
interleaved.Add(discovery);
|
||||
}
|
||||
@@ -142,21 +135,16 @@ public class TimelineService(
|
||||
return activities;
|
||||
}
|
||||
|
||||
private async Task<SnTimelineEvent?> MaybeGetDiscoveryActivity(
|
||||
HashSet<string> debugInclude,
|
||||
Instant? cursor
|
||||
)
|
||||
private async Task<SnTimelineEvent?> MaybeGetDiscoveryActivity(Instant? cursor)
|
||||
{
|
||||
if (cursor != null)
|
||||
return null;
|
||||
var options = new List<Func<Task<SnTimelineEvent?>>>();
|
||||
if (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2)
|
||||
if (Random.Shared.NextDouble() < 0.5)
|
||||
options.Add(() => GetRealmDiscoveryActivity());
|
||||
if (debugInclude.Contains("publishers") || Random.Shared.NextDouble() < 0.2)
|
||||
if (Random.Shared.NextDouble() < 0.5)
|
||||
options.Add(() => GetPublisherDiscoveryActivity());
|
||||
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
|
||||
if (Random.Shared.NextDouble() < 0.5)
|
||||
options.Add(() => GetArticleDiscoveryActivity());
|
||||
if (debugInclude.Contains("shuffledPosts") || Random.Shared.NextDouble() < 0.2)
|
||||
if (Random.Shared.NextDouble() < 0.5)
|
||||
options.Add(() => GetShuffledPostsActivity());
|
||||
if (options.Count == 0)
|
||||
return null;
|
||||
|
||||
156
DysonNetwork.Zone/Migrations/20251210104942_AddSiteGlobalConfig.Designer.cs
generated
Normal file
156
DysonNetwork.Zone/Migrations/20251210104942_AddSiteGlobalConfig.Designer.cs
generated
Normal file
@@ -0,0 +1,156 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Zone;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Zone.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20251210104942_AddSiteGlobalConfig")]
|
||||
partial class AddSiteGlobalConfig
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublicationPage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Dictionary<string, object>>("Config")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<Guid>("SiteId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("site_id");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_publication_pages");
|
||||
|
||||
b.HasIndex("SiteId")
|
||||
.HasDatabaseName("ix_publication_pages_site_id");
|
||||
|
||||
b.ToTable("publication_pages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublicationSite", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<PublicationSiteConfig>("Config")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<int>("Mode")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("mode");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_publication_sites");
|
||||
|
||||
b.ToTable("publication_sites", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublicationPage", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnPublicationSite", "Site")
|
||||
.WithMany("Pages")
|
||||
.HasForeignKey("SiteId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_publication_pages_publication_sites_site_id");
|
||||
|
||||
b.Navigation("Site");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublicationSite", b =>
|
||||
{
|
||||
b.Navigation("Pages");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Zone.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSiteGlobalConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<PublicationSiteConfig>(
|
||||
name: "config",
|
||||
table: "publication_sites",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValue: new PublicationSiteConfig());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "config",
|
||||
table: "publication_sites");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Zone;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -82,6 +83,11 @@ namespace DysonNetwork.Zone.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<PublicationSiteConfig>("Config")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
@@ -59,7 +59,8 @@ public class PublicationSiteController(
|
||||
|
||||
[HttpPost("{pubName}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnPublicationSite>> CreateSite([FromRoute] string pubName, [FromBody] PublicationSiteRequest request)
|
||||
public async Task<ActionResult<SnPublicationSite>> CreateSite([FromRoute] string pubName,
|
||||
[FromBody] PublicationSiteRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
||||
return Unauthorized();
|
||||
@@ -75,6 +76,7 @@ public class PublicationSiteController(
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
PublisherId = publisher.Id,
|
||||
Config = request.Config ?? new PublicationSiteConfig(),
|
||||
AccountId = accountId
|
||||
};
|
||||
|
||||
@@ -96,7 +98,8 @@ public class PublicationSiteController(
|
||||
|
||||
[HttpPatch("{pubName}/{slug}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnPublicationSite>> UpdateSite([FromRoute] string pubName, string slug, [FromBody] PublicationSiteRequest request)
|
||||
public async Task<ActionResult<SnPublicationSite>> UpdateSite([FromRoute] string pubName, string slug,
|
||||
[FromBody] PublicationSiteRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
||||
return Unauthorized();
|
||||
@@ -113,6 +116,7 @@ public class PublicationSiteController(
|
||||
site.Slug = request.Slug;
|
||||
site.Name = request.Name;
|
||||
site.Description = request.Description ?? site.Description;
|
||||
site.Config = request.Config ?? site.Config;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -153,18 +157,10 @@ public class PublicationSiteController(
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("site/{slug}/page")]
|
||||
public async Task<ActionResult<SnPublicationPage>> RenderPage(string slug, [FromQuery] string path = "/")
|
||||
{
|
||||
var page = await publicationService.RenderPage(slug, path);
|
||||
if (page == null)
|
||||
return NotFound();
|
||||
return Ok(page);
|
||||
}
|
||||
|
||||
[HttpGet("{pubName}/{siteSlug}/pages")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnPublicationPage>>> ListPagesForSite([FromRoute] string pubName, [FromRoute] string siteSlug)
|
||||
public async Task<ActionResult<List<SnPublicationPage>>> ListPagesForSite([FromRoute] string pubName,
|
||||
[FromRoute] string siteSlug)
|
||||
{
|
||||
var site = await publicationService.GetSiteBySlug(siteSlug);
|
||||
if (site == null) return NotFound();
|
||||
@@ -187,7 +183,8 @@ public class PublicationSiteController(
|
||||
|
||||
[HttpPost("{pubName}/{siteSlug}/pages")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnPublicationPage>> CreatePage([FromRoute] string pubName, [FromRoute] string siteSlug, [FromBody] PublicationPageRequest request)
|
||||
public async Task<ActionResult<SnPublicationPage>> CreatePage([FromRoute] string pubName,
|
||||
[FromRoute] string siteSlug, [FromBody] PublicationPageRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
||||
return Unauthorized();
|
||||
@@ -280,6 +277,7 @@ public class PublicationSiteController(
|
||||
[MaxLength(4096)] public string Slug { get; set; } = null!;
|
||||
[MaxLength(4096)] public string Name { get; set; } = null!;
|
||||
[MaxLength(8192)] public string? Description { get; set; }
|
||||
public PublicationSiteConfig? Config { get; set; }
|
||||
}
|
||||
|
||||
public class PublicationPageRequest
|
||||
@@ -288,4 +286,4 @@ public class PublicationSiteController(
|
||||
[MaxLength(8192)] public string? Path { get; set; }
|
||||
public Dictionary<string, object?>? Config { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user