12 Commits

Author SHA1 Message Date
8642737a07 Configurable post page 2025-12-12 00:10:57 +08:00
8181938aaf Managed mode page will render with layout 2025-12-11 22:25:40 +08:00
922afc2239 🐛 Fix realm query 2025-12-10 22:59:18 +08:00
a071bd2738 Publication site global config data structure 2025-12-10 19:33:00 +08:00
43945fc524 🐛 Fix discovery realms order incorrect 2025-12-07 14:28:41 +08:00
e477429a35 👔 Increase the chance of other type of activities show up
🗑️ Remove debug include in timeline
2025-12-06 21:12:08 +08:00
fe3a057185 👔 Discovery realms will show desc by member count 2025-12-06 21:10:08 +08:00
ad3c104c5c Proper trace for auth session 2025-12-04 00:38:44 +08:00
2020d625aa 🗃️ Add migration of add sticker pack icon 2025-12-04 00:27:09 +08:00
f471c5635d Post article thumbnail 2025-12-04 00:26:54 +08:00
eaeaa28c60 Sticker icon 2025-12-04 00:19:36 +08:00
ee5c7cb7ce 🐛 Fix get device API 2025-12-03 23:29:31 +08:00
36 changed files with 6355 additions and 979 deletions

View File

@@ -571,12 +571,12 @@ public class AccountCurrentController(
.Where(device => device.AccountId == currentUser.Id) .Where(device => device.AccountId == currentUser.Id)
.ToListAsync(); .ToListAsync();
var sessionDevices = devices.Select(SnAuthClientWithSessions.FromClient).ToList(); var sessionDevices = devices.ConvertAll(SnAuthClientWithSessions.FromClient).ToList();
var clientIds = sessionDevices.Select(x => x.Id).ToList(); var clientIds = sessionDevices.Select(x => x.Id).ToList();
var authSessions = await db.AuthSessions var authSessions = await db.AuthSessions
.Where(c => clientIds.Contains(c.Id)) .Where(c => c.ClientId != null && clientIds.Contains(c.ClientId.Value))
.GroupBy(c => c.Id) .GroupBy(c => c.ClientId!.Value)
.ToDictionaryAsync(c => c.Key, c => c.ToList()); .ToDictionaryAsync(c => c.Key, c => c.ToList());
foreach (var dev in sessionDevices) foreach (var dev in sessionDevices)
if (authSessions.TryGetValue(dev.Id, out var challenge)) if (authSessions.TryGetValue(dev.Id, out var challenge))

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@@ -14,7 +15,8 @@ public class AuthService(
IConfiguration config, IConfiguration config,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ICacheService cache ICacheService cache,
GeoIpService geo
) )
{ {
private HttpContext HttpContext => httpContextAccessor.HttpContext!; private HttpContext HttpContext => httpContextAccessor.HttpContext!;
@@ -49,7 +51,9 @@ public class AuthService(
.ToListAsync(); .ToListAsync();
var recentChallengeIds = 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 recentChallenges = await db.AuthChallenges.Where(c => recentChallengeIds.Contains(c.Id)).ToListAsync();
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(); var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
@@ -184,16 +188,23 @@ public class AuthService(
return totalRequiredSteps; return totalRequiredSteps;
} }
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time, public async Task<SnAuthSession> CreateSessionForOidcAsync(
Guid? customAppId = null, SnAuthSession? parentSession = null) 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 var session = new SnAuthSession
{ {
AccountId = account.Id, AccountId = account.Id,
CreatedAt = time, CreatedAt = time,
LastGrantedAt = time, LastGrantedAt = time,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), IpAddress = ipAddr,
UserAgent = HttpContext.Request.Headers.UserAgent, UserAgent = HttpContext.Request.Headers.UserAgent,
Location = geoLocation,
AppId = customAppId, AppId = customAppId,
ParentSessionId = parentSession?.Id, ParentSessionId = parentSession?.Id,
Type = customAppId is not null ? SessionType.OAuth : SessionType.Oidc, Type = customAppId is not null ? SessionType.OAuth : SessionType.Oidc,
@@ -212,7 +223,8 @@ public class AuthService(
ClientPlatform platform = ClientPlatform.Unidentified 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; if (device is not null) return device;
device = new SnAuthClient 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> /// <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) private async Task CollectSessionsToRevoke(Guid currentSessionId, HashSet<Guid> sessionsToRevoke)
{ {
if (sessionsToRevoke.Contains(currentSessionId)) if (!sessionsToRevoke.Add(currentSessionId))
{
return; // Already processed this session return; // Already processed this session
}
sessionsToRevoke.Add(currentSessionId);
// Find direct children // Find direct children
var childSessions = await db.AuthSessions var childSessions = await db.AuthSessions
@@ -425,6 +433,7 @@ public class AuthService(
AccountId = challenge.AccountId, AccountId = challenge.AccountId,
IpAddress = challenge.IpAddress, IpAddress = challenge.IpAddress,
UserAgent = challenge.UserAgent, UserAgent = challenge.UserAgent,
Location = challenge.Location,
Scopes = challenge.Scopes, Scopes = challenge.Scopes,
Audiences = challenge.Audiences, Audiences = challenge.Audiences,
ChallengeId = challenge.Id, ChallengeId = challenge.Id,
@@ -679,9 +688,16 @@ public class AuthService(
{ {
var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform); 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 now = SystemClock.Instance.GetCurrentInstant();
var session = new SnAuthSession var session = new SnAuthSession
{ {
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geoLocation,
AccountId = parentSession.AccountId, AccountId = parentSession.AccountId,
CreatedAt = now, CreatedAt = now,
LastGrantedAt = now, LastGrantedAt = now,

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -1053,6 +1053,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("last_granted_at"); .HasColumnName("last_granted_at");
b.Property<GeoPoint>("Location")
.HasColumnType("jsonb")
.HasColumnName("location");
b.Property<Guid?>("ParentSessionId") b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("parent_session_id"); .HasColumnName("parent_session_id");

View File

@@ -35,7 +35,8 @@ public class RealmServiceGrpc(
: realm.ToProtoValue(); : realm.ToProtoValue();
} }
public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request, ServerCallContext context) public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request,
ServerCallContext context)
{ {
var ids = request.Ids.Select(Guid.Parse).ToList(); var ids = request.Ids.Select(Guid.Parse).ToList();
var realms = await db.Realms.Where(r => ids.Contains(r.Id)).ToListAsync(); var realms = await db.Realms.Where(r => ids.Contains(r.Id)).ToListAsync();
@@ -67,19 +68,33 @@ public class RealmServiceGrpc(
return new GetUserRealmsResponse { RealmIds = { realms.Select(g => g.ToString()) } }; return new GetUserRealmsResponse { RealmIds = { realms.Select(g => g.ToString()) } };
} }
public override async Task<GetPublicRealmsResponse> GetPublicRealms(Empty request, ServerCallContext context) public override Task<GetPublicRealmsResponse> GetPublicRealms(
GetPublicRealmsRequest request,
ServerCallContext context
)
{ {
var realms = await db.Realms.Where(r => r.IsPublic).ToListAsync(); var realmsQueryable = db.Realms.Where(r => r.IsPublic).AsQueryable();
realmsQueryable = request.OrderBy switch
{
"random" => realmsQueryable.OrderBy(_ => EF.Functions.Random()),
"name" => realmsQueryable.OrderBy(r => r.Name),
"popularity" => realmsQueryable.OrderByDescending(r => r.Members.Count),
_ => realmsQueryable.OrderByDescending(r => r.CreatedAt)
};
var response = new GetPublicRealmsResponse(); var response = new GetPublicRealmsResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue())); response.Realms.AddRange(realmsQueryable.Select(r => r.ToProtoValue()));
return response; return Task.FromResult(response);
} }
public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request, ServerCallContext context) public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request,
ServerCallContext context)
{ {
var realms = await db.Realms var realms = await db.Realms
.Where(r => r.IsPublic) .Where(r => r.IsPublic)
.Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") || EF.Functions.Like(r.Name, $"{request.Query}%")) .Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") ||
EF.Functions.Like(r.Name, $"{request.Query}%"))
.Take(request.Limit) .Take(request.Limit)
.ToListAsync(); .ToListAsync();
var response = new GetPublicRealmsResponse(); var response = new GetPublicRealmsResponse();

View File

@@ -26,6 +26,7 @@ public class SnAuthSession : ModelBase
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = []; [Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = [];
[MaxLength(128)] public string? IpAddress { get; set; } [MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; } [MaxLength(512)] public string? UserAgent { get; set; }
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public SnAccount Account { get; set; } = null!; [JsonIgnore] public SnAccount Account { get; set; } = null!;

View File

@@ -38,7 +38,7 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
[NotMapped] [NotMapped]
public SnDeveloper Developer => Project.Developer; public SnDeveloper Developer => Project.Developer;
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id; [NotMapped] public string ResourceIdentifier => "developer.app:" + Id;
public Proto.CustomApp ToProto() public Proto.CustomApp ToProto()
{ {

View File

@@ -50,5 +50,5 @@ public class FilePool : ModelBase, IIdentifiedResource
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
public string ResourceIdentifier => $"file-pool/{Id}"; public string ResourceIdentifier => $"file.pool:{Id}";
} }

View File

@@ -20,6 +20,18 @@ public enum PublicationSiteMode
SelfManaged 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!;
}
public class SnPublicationSite : ModelBase public class SnPublicationSite : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -29,6 +41,7 @@ public class SnPublicationSite : ModelBase
public PublicationSiteMode Mode { get; set; } = PublicationSiteMode.FullyManaged; public PublicationSiteMode Mode { get; set; } = PublicationSiteMode.FullyManaged;
public List<SnPublicationPage> Pages { get; set; } = []; public List<SnPublicationPage> Pages { get; set; } = [];
[Column(TypeName = "jsonb")] public PublicationSiteConfig Config { get; set; } = new();
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
[NotMapped] public SnPublisher Publisher { get; set; } = null!; [NotMapped] public SnPublisher Publisher { get; set; } = null!;
@@ -46,7 +59,11 @@ public enum PublicationPageType
/// The redirect mode allows user to create a shortcut for their own link. /// The redirect mode allows user to create a shortcut for their own link.
/// such as example.solian.page/rickroll -- DyZ 301 -> youtube.com/... /// such as example.solian.page/rickroll -- DyZ 301 -> youtube.com/...
/// </summary> /// </summary>
Redirect Redirect,
/// <summary>
/// The Post Page type allows user render a list of posts based on the preconfigured filter.
/// </summary>
PostPage
} }
public class SnPublicationPage : ModelBase public class SnPublicationPage : ModelBase

View File

@@ -11,47 +11,33 @@ public class SnSticker : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] [MaxLength(128)] public string Slug { get; set; } = null!;
public string Slug { get; set; } = null!; [Column(TypeName = "jsonb")] public SnCloudFileReferenceObject Image { get; set; } = null!;
// Outdated fields, for backward compability
[MaxLength(32)]
public string? ImageId { get; set; }
[Column(TypeName = "jsonb")]
public SnCloudFileReferenceObject? Image { get; set; } = null!;
public Guid PackId { get; set; } public Guid PackId { get; set; }
[IgnoreMember] [JsonIgnore] public StickerPack Pack { get; set; } = null!;
[JsonIgnore] public string ResourceIdentifier => $"sticker:{Id}";
public StickerPack Pack { get; set; } = null!;
public string ResourceIdentifier => $"sticker/{Id}";
} }
[Index(nameof(Prefix), IsUnique = true)] [Index(nameof(Prefix), IsUnique = true)]
public class StickerPack : ModelBase public class StickerPack : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] [Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Icon { get; set; }
public string Name { get; set; } = null!; [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; } = []; public List<SnSticker> Stickers { get; set; } = [];
[IgnoreMember] [IgnoreMember] [JsonIgnore] public List<StickerPackOwnership> Ownerships { get; set; } = [];
[JsonIgnore]
public List<StickerPackOwnership> Ownerships { get; set; } = [];
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
public SnPublisher Publisher { get; set; } = null!; public SnPublisher Publisher { get; set; } = null!;
public string ResourceIdentifier => $"sticker.pack:{Id}";
} }
public class StickerPackOwnership : ModelBase public class StickerPackOwnership : ModelBase
@@ -62,6 +48,5 @@ public class StickerPackOwnership : ModelBase
public StickerPack Pack { get; set; } = null!; public StickerPack Pack { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[NotMapped] [NotMapped] public SnAccount Account { get; set; } = null!;
public SnAccount Account { get; set; } = null!;
} }

View File

@@ -46,7 +46,7 @@ service RealmService {
// Get realms for a user // Get realms for a user
rpc GetUserRealms(GetUserRealmsRequest) returns (GetUserRealmsResponse) {} rpc GetUserRealms(GetUserRealmsRequest) returns (GetUserRealmsResponse) {}
// Get public realms // Get public realms
rpc GetPublicRealms(google.protobuf.Empty) returns (GetPublicRealmsResponse) {} rpc GetPublicRealms(GetPublicRealmsRequest) returns (GetPublicRealmsResponse) {}
// Search public realms // Search public realms
rpc SearchRealms(SearchRealmsRequest) returns (GetPublicRealmsResponse) {} rpc SearchRealms(SearchRealmsRequest) returns (GetPublicRealmsResponse) {}
// Send invitation notification // Send invitation notification
@@ -84,6 +84,10 @@ message GetUserRealmsResponse {
repeated string realm_ids = 1; repeated string realm_ids = 1;
} }
message GetPublicRealmsRequest {
optional string order_by = 1;
}
message GetPublicRealmsResponse { message GetPublicRealmsResponse {
repeated Realm realms = 1; repeated Realm realms = 1;
} }

View File

@@ -27,9 +27,12 @@ public class RemoteRealmService(RealmService.RealmServiceClient realms)
return response.RealmIds.Select(Guid.Parse).ToList(); return response.RealmIds.Select(Guid.Parse).ToList();
} }
public async Task<List<SnRealm>> GetPublicRealms() public async Task<List<SnRealm>> GetPublicRealms(string orderBy = "date")
{ {
var response = await realms.GetPublicRealmsAsync(new Empty()); var response = await realms.GetPublicRealmsAsync(new GetPublicRealmsRequest
{
OrderBy = orderBy
});
return response.Realms.Select(SnRealm.FromProtoValue).ToList(); return response.Realms.Select(SnRealm.FromProtoValue).ToList();
} }

View File

@@ -8,24 +8,10 @@ public class DiscoveryService(RemoteRealmService remoteRealmService)
string? query, string? query,
int take = 10, int take = 10,
int offset = 0, int offset = 0,
bool randomizer = false bool random = false
) )
{ {
var allRealms = await remoteRealmService.GetPublicRealms(); var allRealms = await remoteRealmService.GetPublicRealms(random ? "random" : "popularity");
var communityRealms = allRealms.Where(r => r.IsCommunity); return allRealms.Where(r => r.IsCommunity).Skip(offset).Take(take).ToList();
if (!string.IsNullOrEmpty(query))
{
communityRealms = communityRealms.Where(r =>
r.Name.Contains(query, StringComparison.OrdinalIgnoreCase)
);
}
// 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;
return orderedRealms.Skip(offset).Take(take).ToList();
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

View File

@@ -1147,14 +1147,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<SnCloudFileReferenceObject>("Image") b.Property<SnCloudFileReferenceObject>("Image")
.IsRequired()
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("image"); .HasColumnName("image");
b.Property<string>("ImageId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("image_id");
b.Property<Guid>("PackId") b.Property<Guid>("PackId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("pack_id"); .HasColumnName("pack_id");
@@ -1202,6 +1198,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
.HasColumnName("description"); .HasColumnName("description");
b.Property<SnCloudFileReferenceObject>("Icon")
.HasColumnType("jsonb")
.HasColumnName("icon");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)

View 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();
}
}

View File

@@ -27,9 +27,6 @@ public class PostController(
PublisherService pub, PublisherService pub,
RemoteAccountService remoteAccountsHelper, RemoteAccountService remoteAccountsHelper,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
ActionLogService.ActionLogServiceClient als,
PaymentService.PaymentServiceClient payments,
PollService polls,
RemoteRealmService rs RemoteRealmService rs
) : ControllerBase ) : ControllerBase
{ {
@@ -43,27 +40,6 @@ public class PostController(
return Ok(posts); 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] [HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<SnPost>))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<SnPost>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -493,771 +469,4 @@ public class PostController(
return Ok(posts); 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();
}
} }

View File

@@ -15,7 +15,8 @@ public class StickerController(
AppDatabase db, AppDatabase db,
StickerService st, StickerService st,
Publisher.PublisherService ps, Publisher.PublisherService ps,
FileService.FileServiceClient files FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs
) : ControllerBase ) : ControllerBase
{ {
private async Task<IActionResult> _CheckStickerPackPermissions( private async Task<IActionResult> _CheckStickerPackPermissions(
@@ -114,6 +115,7 @@ public class StickerController(
public class StickerPackRequest public class StickerPackRequest
{ {
public string? IconId { get; set; }
[MaxLength(1024)] public string? Name { get; set; } [MaxLength(1024)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; } [MaxLength(4096)] public string? Description { get; set; }
[MaxLength(128)] public string? Prefix { get; set; } [MaxLength(128)] public string? Prefix { get; set; }
@@ -147,8 +149,28 @@ public class StickerController(
PublisherId = publisher.Id 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); db.StickerPacks.Add(pack);
await db.SaveChangesAsync(); 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); return Ok(pack);
} }
@@ -179,6 +201,32 @@ public class StickerController(
if (request.Prefix is not null) if (request.Prefix is not null)
pack.Prefix = request.Prefix; 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); db.StickerPacks.Update(pack);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(pack); return Ok(pack);
@@ -239,7 +287,11 @@ public class StickerController(
} }
[HttpGet("search")] [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 var queryable = db.Stickers
.Include(s => s.Pack) .Include(s => s.Pack)
@@ -300,7 +352,6 @@ public class StickerController(
var file = await files.GetFileAsync(new GetFileRequest { Id = request.ImageId }); var file = await files.GetFileAsync(new GetFileRequest { Id = request.ImageId });
if (file is null) if (file is null)
return BadRequest("Image not found"); return BadRequest("Image not found");
sticker.ImageId = request.ImageId;
sticker.Image = SnCloudFileReferenceObject.FromProtoValue(file); sticker.Image = SnCloudFileReferenceObject.FromProtoValue(file);
} }
@@ -367,7 +418,6 @@ public class StickerController(
var sticker = new SnSticker var sticker = new SnSticker
{ {
Slug = request.Slug, Slug = request.Slug,
ImageId = file.Id,
Image = SnCloudFileReferenceObject.FromProtoValue(file), Image = SnCloudFileReferenceObject.FromProtoValue(file),
Pack = pack Pack = pack
}; };

View File

@@ -12,6 +12,7 @@ public class StickerService(
) )
{ {
public const string StickerFileUsageIdentifier = "sticker"; public const string StickerFileUsageIdentifier = "sticker";
public const string StickerPackUsageIdentifier = "sticker.pack";
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
@@ -36,7 +37,8 @@ public class StickerService(
{ {
if (newImage is not null) if (newImage is not null)
{ {
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = sticker.ResourceIdentifier }); await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{ ResourceId = sticker.ResourceIdentifier });
sticker.Image = newImage; sticker.Image = newImage;
@@ -63,7 +65,8 @@ public class StickerService(
var stickerResourceId = $"sticker:{sticker.Id}"; var stickerResourceId = $"sticker:{sticker.Id}";
// Delete all file references for this sticker // 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); db.Stickers.Remove(sticker);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -82,11 +85,12 @@ public class StickerService(
// Delete all file references for each sticker in the pack // Delete all file references for each sticker in the pack
foreach (var stickerResourceId in stickers.Select(sticker => $"sticker:{sticker.Id}")) 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 // Delete any references for the pack itself
var packResourceId = $"stickerpack:{pack.Id}"; await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = packResourceId }); { ResourceId = pack.ResourceIdentifier });
db.Stickers.RemoveRange(stickers); db.Stickers.RemoveRange(stickers);
db.StickerPacks.Remove(pack); db.StickerPacks.Remove(pack);
@@ -119,7 +123,8 @@ public class StickerService(
{ {
var packPart = identifierParts[0]; var packPart = identifierParts[0];
var stickerPart = identifierParts[1]; 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 else
{ {

View File

@@ -25,8 +25,7 @@ public class ActivityController(TimelineService acts) : ControllerBase
public async Task<ActionResult<List<SnTimelineEvent>>> ListEvents( public async Task<ActionResult<List<SnTimelineEvent>>> ListEvents(
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromQuery] string? filter, [FromQuery] string? filter,
[FromQuery] int take = 20, [FromQuery] int take = 20
[FromQuery] string? debugInclude = null
) )
{ {
Instant? cursorTimestamp = null; 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); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
return currentUserValue is not Account currentUser return currentUserValue is not Account currentUser
? Ok(await acts.ListEventsForAnyone(take, cursorTimestamp, debugIncludeSet)) ? Ok(await acts.ListEventsForAnyone(take, cursorTimestamp))
: Ok( : Ok(await acts.ListEvents(take, cursorTimestamp, currentUser, filter));
await acts.ListEvents(take, cursorTimestamp, currentUser, filter, debugIncludeSet)
);
} }
} }

View File

@@ -32,14 +32,9 @@ public class TimelineService(
return performanceWeight / Math.Pow(normalizedTime + 1.0, 1.2); return performanceWeight / Math.Pow(normalizedTime + 1.0, 1.2);
} }
public async Task<List<SnTimelineEvent>> ListEventsForAnyone( public async Task<List<SnTimelineEvent>> ListEventsForAnyone(int take, Instant? cursor)
int take,
Instant? cursor,
HashSet<string>? debugInclude = null
)
{ {
var activities = new List<SnTimelineEvent>(); var activities = new List<SnTimelineEvent>();
debugInclude ??= new HashSet<string>();
// Get and process posts // Get and process posts
var publicRealms = await rs.GetPublicRealms(); var publicRealms = await rs.GetPublicRealms();
@@ -60,7 +55,7 @@ public class TimelineService(
// Randomly insert a discovery activity before some posts // Randomly insert a discovery activity before some posts
if (random.NextDouble() < 0.15) if (random.NextDouble() < 0.15)
{ {
var discovery = await MaybeGetDiscoveryActivity(debugInclude, cursor: cursor); var discovery = await MaybeGetDiscoveryActivity(cursor: cursor);
if (discovery != null) if (discovery != null)
interleaved.Add(discovery); interleaved.Add(discovery);
} }
@@ -80,12 +75,10 @@ public class TimelineService(
int take, int take,
Instant? cursor, Instant? cursor,
Account currentUser, Account currentUser,
string? filter = null, string? filter = null
HashSet<string>? debugInclude = null
) )
{ {
var activities = new List<SnTimelineEvent>(); var activities = new List<SnTimelineEvent>();
debugInclude ??= new HashSet<string>();
// Get user's friends and publishers // Get user's friends and publishers
var friendsResponse = await accounts.ListFriendsAsync( var friendsResponse = await accounts.ListFriendsAsync(
@@ -126,7 +119,7 @@ public class TimelineService(
{ {
if (random.NextDouble() < 0.15) if (random.NextDouble() < 0.15)
{ {
var discovery = await MaybeGetDiscoveryActivity(debugInclude, cursor: cursor); var discovery = await MaybeGetDiscoveryActivity(cursor: cursor);
if (discovery != null) if (discovery != null)
interleaved.Add(discovery); interleaved.Add(discovery);
} }
@@ -142,21 +135,16 @@ public class TimelineService(
return activities; return activities;
} }
private async Task<SnTimelineEvent?> MaybeGetDiscoveryActivity( private async Task<SnTimelineEvent?> MaybeGetDiscoveryActivity(Instant? cursor)
HashSet<string> debugInclude,
Instant? cursor
)
{ {
if (cursor != null)
return null;
var options = new List<Func<Task<SnTimelineEvent?>>>(); 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()); options.Add(() => GetRealmDiscoveryActivity());
if (debugInclude.Contains("publishers") || Random.Shared.NextDouble() < 0.2) if (Random.Shared.NextDouble() < 0.5)
options.Add(() => GetPublisherDiscoveryActivity()); options.Add(() => GetPublisherDiscoveryActivity());
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2) if (Random.Shared.NextDouble() < 0.5)
options.Add(() => GetArticleDiscoveryActivity()); options.Add(() => GetArticleDiscoveryActivity());
if (debugInclude.Contains("shuffledPosts") || Random.Shared.NextDouble() < 0.2) if (Random.Shared.NextDouble() < 0.5)
options.Add(() => GetShuffledPostsActivity()); options.Add(() => GetShuffledPostsActivity());
if (options.Count == 0) if (options.Count == 0)
return null; return null;

View File

@@ -0,0 +1,18 @@
namespace DysonNetwork.Zone.Customization;
// PostPage.Config -> filter
public class PostPageFilterConfig
{
public List<int> Types { get; set; }
public string? PubName { get; set; }
public string? OrderBy { get; set; }
public bool OrderDesc { get; set; } = true;
}
// PostPage.Config -> layout
public class PostPageLayoutConfig
{
public string? Title { get; set; }
public string? Description { get; set; }
public bool ShowPub { get; set; } = true;
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -1,6 +1,7 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using DysonNetwork.Zone; using DysonNetwork.Zone;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -82,6 +83,11 @@ namespace DysonNetwork.Zone.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("account_id"); .HasColumnName("account_id");
b.Property<PublicationSiteConfig>("Config")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("config");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");

View File

@@ -0,0 +1,8 @@
@model DynamicPage
@{
Layout = "_LayoutContained";
}
<div class="dynamic-content">
@Html.Raw(Model.Html)
</div>

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace DysonNetwork.Zone.Pages.Dynamic;
public class DynamicPage : PageModel
{
public string Html { get; set; } = "";
}

View File

@@ -2,10 +2,9 @@
@model DysonNetwork.Zone.Pages.PostsModel @model DysonNetwork.Zone.Pages.PostsModel
@{ @{
Layout = "_LayoutContained"; Layout = "_LayoutContained";
const string defaultAvatar = "https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp";
var pageTitle = "Posts"; var pageTitle = Model.LayoutConfig?.Title ?? "Posts";
var pageDescription = "A collection of posts."; var pageDescription = Model.LayoutConfig?.Description ?? "A collection of posts.";
string? ogImageUrl = null; string? ogImageUrl = null;
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}"; var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
var siteName = Model.Site?.Name ?? "Solar Network"; var siteName = Model.Site?.Name ?? "Solar Network";
@@ -48,7 +47,7 @@
<div class="container mx-auto px-8 py-8"> <div class="container mx-auto px-8 py-8">
<h1 class="text-3xl font-bold mb-8 px-5"> <h1 class="text-3xl font-bold mb-8 px-5">
<span class="mdi mdi-note-text-outline"></span> Posts <span class="mdi mdi-note-text-outline"></span> @pageTitle
</h1> </h1>
<div class="w-full grid grid-cols-3 gap-4"> <div class="w-full grid grid-cols-3 gap-4">
@@ -75,8 +74,8 @@
<div class="join"> <div class="join">
@{ @{
const int maxPagesToShow = 5; // e.g., 2 before, current, 2 after const int maxPagesToShow = 5; // e.g., 2 before, current, 2 after
var startPage = Math.Max(1, Model.CurrentPage - (maxPagesToShow / 2)); var startPage = Math.Max(1, Model.Index - (maxPagesToShow / 2));
var endPage = Math.Min(Model.TotalPages, Model.CurrentPage + (maxPagesToShow / 2)); var endPage = Math.Min(Model.TotalPages, Model.Index + (maxPagesToShow / 2));
// Adjust startPage and endPage to ensure exactly maxPagesToShow are shown if possible // Adjust startPage and endPage to ensure exactly maxPagesToShow are shown if possible
if (endPage - startPage + 1 < maxPagesToShow) if (endPage - startPage + 1 < maxPagesToShow)
@@ -92,15 +91,14 @@
} }
} }
<a asp-page="/Posts" <a href="/posts?index=@(Model.Index > 1 ? Model.Index - 1 : 1)"
asp-route-currentPage="@(Model.CurrentPage > 1 ? Model.CurrentPage - 1 : 1)" class="join-item btn @(Model.Index == 1 ? "btn-disabled" : "")">«</a>
class="join-item btn @(Model.CurrentPage == 1 ? "btn-disabled" : "")">«</a>
@if (startPage > 1) @if (startPage > 1)
{ {
<a asp-page="/Posts" <a asp-page="/Posts"
asp-route-currentPage="1" asp-route-currentPage="1"
class="join-item btn @(1 == Model.CurrentPage ? "btn-active" : "")"> class="join-item btn @(1 == Model.Index ? "btn-active" : "")">
1 1
</a> </a>
@if (startPage > 2) @if (startPage > 2)
@@ -111,11 +109,8 @@
@for (var idx = startPage; idx <= endPage; idx++) @for (var idx = startPage; idx <= endPage; idx++)
{ {
var pageIdx = idx; <a href="/posts?index=@(idx)" class="join-item btn @(idx == Model.Index ? "btn-active" : "")">
<a asp-page="/Posts" @idx
asp-route-currentPage="@pageIdx"
class="join-item btn @(pageIdx == Model.CurrentPage ? "btn-active" : "")">
@pageIdx
</a> </a>
} }
@@ -125,16 +120,14 @@
{ {
<span class="join-item btn btn-disabled">...</span> <span class="join-item btn btn-disabled">...</span>
} }
<a asp-page="/Posts" <a href="/posts?index=@(Model.TotalPages)"
asp-route-currentPage="@Model.TotalPages" class="join-item btn @(Model.TotalPages == Model.Index ? "btn-active" : "")">
class="join-item btn @(Model.TotalPages == Model.CurrentPage ? "btn-active" : "")">
@Model.TotalPages @Model.TotalPages
</a> </a>
} }
<a asp-page="/Posts" <a href="/posts?index=@(Model.Index < Model.TotalPages ? Model.Index + 1 : Model.TotalPages)"
asp-route-currentPage="@(Model.CurrentPage < Model.TotalPages ? Model.CurrentPage + 1 : Model.TotalPages)" class="join-item btn @(Model.Index == Model.TotalPages ? "btn-disabled" : "")">»</a>
class="join-item btn @(Model.CurrentPage == Model.TotalPages ? "btn-disabled" : "")">»</a>
</div> </div>
</div> </div>
} }

View File

@@ -1,6 +1,7 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Zone.Customization;
using DysonNetwork.Zone.Publication; using DysonNetwork.Zone.Publication;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
// Add this using statement // Add this using statement
@@ -15,34 +16,54 @@ public class PostsModel(
MarkdownConverter markdownConverter MarkdownConverter markdownConverter
) : PageModel ) : PageModel
{ {
[FromQuery] public bool ShowAll { get; set; } = false;
public SnPublicationSite? Site { get; set; } public SnPublicationSite? Site { get; set; }
public SnPublisher? Publisher { get; set; } public SnPublisher? Publisher { get; set; }
public List<SnPost> Posts { get; set; } = []; public List<SnPost> Posts { get; set; } = [];
public int TotalCount { get; set; } public int TotalCount { get; set; }
public int CurrentPage { get; set; } public int Index { get; set; }
public int PageSize { get; set; } = 10; public int PageSize { get; set; } = 10;
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public async Task OnGetAsync(int currentPage = 1) public PostPageFilterConfig? FilterConfig { get; set; }
{ public PostPageLayoutConfig? LayoutConfig { get; set; }
Site = HttpContext.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
CurrentPage = currentPage;
Publisher = await rps.GetPublisher(id: Site!.PublisherId.ToString()); public async Task OnGetAsync(int index = 1)
{
FilterConfig = HttpContext.Items["PostPage_FilterConfig"] as PostPageFilterConfig;
LayoutConfig = HttpContext.Items["PostPage_LayoutConfig"] as PostPageLayoutConfig;
Site = HttpContext.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
Index = index;
Publisher = FilterConfig?.PubName is not null
? await rps.GetPublisher(FilterConfig.PubName)
: await rps.GetPublisher(id: Site!.PublisherId.ToString());
var request = new ListPostsRequest var request = new ListPostsRequest
{ {
OrderBy = "date", OrderBy = FilterConfig?.OrderBy,
OrderDesc = true, OrderDesc = FilterConfig?.OrderDesc ?? true,
PageSize = PageSize, PageSize = PageSize,
PageToken = ((CurrentPage - 1) * PageSize).ToString(), PageToken = ((Index - 1) * PageSize).ToString(),
PublisherId = Site!.PublisherId.ToString() PublisherId = Publisher!.Id.ToString()
}; };
if (!ShowAll) request.Types_.Add(DysonNetwork.Shared.Proto.PostType.Article); if (FilterConfig?.Types is not null)
{
foreach (var type in FilterConfig.Types)
{
request.Types_.Add(type switch
{
0 => DysonNetwork.Shared.Proto.PostType.Moment,
1 => DysonNetwork.Shared.Proto.PostType.Article,
_ => DysonNetwork.Shared.Proto.PostType.Unspecified,
});
}
}
else
{
request.Types_.Add(DysonNetwork.Shared.Proto.PostType.Article);
}
var response = await postClient.ListPostsAsync(request); var response = await postClient.ListPostsAsync(request);

View File

@@ -1,5 +1,6 @@
@using DysonNetwork.Zone.Publication @using DysonNetwork.Zone.Publication
@using DysonNetwork.Shared.Models @using DysonNetwork.Shared.Models
@using Microsoft.IdentityModel.Tokens
@{ @{
Layout = "_Layout"; Layout = "_Layout";
var site = Context.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite; var site = Context.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
@@ -8,13 +9,26 @@
<div class="navbar backdrop-blur-md bg-white/1 shadow-xl px-5"> <div class="navbar backdrop-blur-md bg-white/1 shadow-xl px-5">
<div class="flex-1"> <div class="flex-1">
<a class="btn btn-ghost text-xl" asp-page="/Index">@siteDisplayName</a> <a class="btn btn-ghost text-xl" href="/">@siteDisplayName</a>
</div> </div>
<div class="flex-none"> <div class="flex-none">
@if (site?.Config.NavItems is null || site.Config.NavItems.IsNullOrEmpty())
{
@*Use preset navs*@
<ul class="menu menu-horizontal px-1"> <ul class="menu menu-horizontal px-1">
<li><a asp-page="/Posts">Posts</a></li> <li><a href="/posts">Posts</a></li>
<li><a asp-page="/About">About</a></li> <li><a href="/about">About</a></li>
</ul> </ul>
}
else
{
<ul class="menu menu-horizontal px-1">
@foreach (var item in site.Config.NavItems)
{
<li><a href="@item.Href">@item.Label</a></li>
}
</ul>
}
</div> </div>
</div> </div>
@@ -31,6 +45,11 @@
{ {
@await RenderSectionAsync("Head", required: false) @await RenderSectionAsync("Head", required: false)
@if (site?.Config.StyleOverride is not null)
{
<style>@(site.Config.StyleOverride)</style>
}
<style> <style>
.navbar { .navbar {
height: 64px; height: 64px;

View File

@@ -80,8 +80,7 @@
<div class="text-sm text-base-content/60"> <div class="text-sm text-base-content/60">
Posted on @Model.CreatedAt.ToDateTimeOffset().ToString("yyyy-MM-dd") Posted on @Model.CreatedAt.ToDateTimeOffset().ToString("yyyy-MM-dd")
</div> </div>
<a asp-page="/Posts/Details" asp-route-slug="@(Model.Slug ?? Model.Id.ToString())" <a href="/p/@(Model.Slug ?? Model.Id.ToString())"class="btn btn-sm btn-ghost btn-circle">
class="btn btn-sm btn-ghost btn-circle">
<span class="mdi mdi-arrow-right text-lg"></span> <span class="mdi mdi-arrow-right text-lg"></span>
</a> </a>
</div> </div>

View File

@@ -59,7 +59,8 @@ public class PublicationSiteController(
[HttpPost("{pubName}")] [HttpPost("{pubName}")]
[Authorize] [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) if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
return Unauthorized(); return Unauthorized();
@@ -75,6 +76,7 @@ public class PublicationSiteController(
Name = request.Name, Name = request.Name,
Description = request.Description, Description = request.Description,
PublisherId = publisher.Id, PublisherId = publisher.Id,
Config = request.Config ?? new PublicationSiteConfig(),
AccountId = accountId AccountId = accountId
}; };
@@ -96,7 +98,8 @@ public class PublicationSiteController(
[HttpPatch("{pubName}/{slug}")] [HttpPatch("{pubName}/{slug}")]
[Authorize] [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) if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
return Unauthorized(); return Unauthorized();
@@ -113,6 +116,7 @@ public class PublicationSiteController(
site.Slug = request.Slug; site.Slug = request.Slug;
site.Name = request.Name; site.Name = request.Name;
site.Description = request.Description ?? site.Description; site.Description = request.Description ?? site.Description;
site.Config = request.Config ?? site.Config;
try try
{ {
@@ -153,18 +157,10 @@ public class PublicationSiteController(
return NoContent(); 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")] [HttpGet("{pubName}/{siteSlug}/pages")]
[Authorize] [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); var site = await publicationService.GetSiteBySlug(siteSlug);
if (site == null) return NotFound(); if (site == null) return NotFound();
@@ -187,7 +183,8 @@ public class PublicationSiteController(
[HttpPost("{pubName}/{siteSlug}/pages")] [HttpPost("{pubName}/{siteSlug}/pages")]
[Authorize] [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) if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
return Unauthorized(); return Unauthorized();
@@ -280,6 +277,7 @@ public class PublicationSiteController(
[MaxLength(4096)] public string Slug { get; set; } = null!; [MaxLength(4096)] public string Slug { get; set; } = null!;
[MaxLength(4096)] public string Name { get; set; } = null!; [MaxLength(4096)] public string Name { get; set; } = null!;
[MaxLength(8192)] public string? Description { get; set; } [MaxLength(8192)] public string? Description { get; set; }
public PublicationSiteConfig? Config { get; set; }
} }
public class PublicationPageRequest public class PublicationPageRequest

View File

@@ -1,5 +1,15 @@
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Zone.Customization;
using DysonNetwork.Zone.Pages.Dynamic;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -9,7 +19,13 @@ public class PublicationSiteMiddleware(RequestDelegate next)
{ {
public const string SiteContextKey = "PubSite"; public const string SiteContextKey = "PubSite";
public async Task InvokeAsync(HttpContext context, AppDatabase db, PublicationSiteManager psm) public async Task InvokeAsync(
HttpContext context,
AppDatabase db,
PublicationSiteManager psm,
IRazorViewEngine viewEngine,
ITempDataProvider tempData
)
{ {
var siteNameValue = context.Request.Headers["X-SiteName"].ToString(); var siteNameValue = context.Request.Headers["X-SiteName"].ToString();
var currentPath = context.Request.Path.Value ?? ""; var currentPath = context.Request.Path.Value ?? "";
@@ -21,7 +37,8 @@ public class PublicationSiteMiddleware(RequestDelegate next)
} }
var site = await db.PublicationSites var site = await db.PublicationSites
.FirstOrDefaultAsync(s => EF.Functions.ILike(s.Slug, siteNameValue)); .FirstOrDefaultAsync(s => EF.Functions.ILike(s.Slug, siteNameValue)
);
if (site == null) if (site == null)
{ {
await next(context); await next(context);
@@ -38,13 +55,43 @@ public class PublicationSiteMiddleware(RequestDelegate next)
{ {
case PublicationPageType.HtmlPage case PublicationPageType.HtmlPage
when page.Config.TryGetValue("html", out var html) && html is JsonElement content: when page.Config.TryGetValue("html", out var html) && html is JsonElement content:
if (site.Mode == PublicationSiteMode.FullyManaged)
{
context.Items["PublicationHtmlContent"] = content.ToString();
var layoutedHtml = await RenderViewAsync(
context,
viewEngine,
tempData,
"/Pages/Dynamic/DynamicPage.cshtml",
new DynamicPage { Html = content.ToString() }
);
context.Response.ContentType = "text/html";
await context.Response.WriteAsync(layoutedHtml);
}
else
{
context.Response.ContentType = "text/html"; context.Response.ContentType = "text/html";
await context.Response.WriteAsync(content.ToString()); await context.Response.WriteAsync(content.ToString());
}
return; return;
case PublicationPageType.Redirect case PublicationPageType.Redirect
when page.Config.TryGetValue("target", out var tgt) && tgt is JsonElement redirectUrl: when page.Config.TryGetValue("target", out var tgt) && tgt is JsonElement redirectUrl:
context.Response.Redirect(redirectUrl.ToString()); context.Response.Redirect(redirectUrl.ToString());
return; return;
case PublicationPageType.PostPage:
PostPageFilterConfig? filterConfig = null;
if (page.Config.TryGetValue("filter", out var filter) && filter is JsonElement filterJson)
filterConfig = filterJson.Deserialize<PostPageFilterConfig>(GrpcTypeHelper.SerializerOptions);
PostPageLayoutConfig? layoutConfig = null;
if (page.Config.TryGetValue("layout", out var layout) && layout is JsonElement layoutJson)
layoutConfig = layoutJson.Deserialize<PostPageLayoutConfig>(GrpcTypeHelper.SerializerOptions);
context.Items["PostPage_LayoutConfig"] = layoutConfig;
context.Items["PostPage_FilterConfig"] = filterConfig;
context.Request.Path = "/Posts";
await next(context);
return;
default:
throw new ArgumentOutOfRangeException();
} }
} }
@@ -85,4 +132,51 @@ public class PublicationSiteMiddleware(RequestDelegate next)
await next(context); await next(context);
} }
private async Task<string> RenderViewAsync(
HttpContext context,
IRazorViewEngine engine,
ITempDataProvider tempDataProvider,
string viewPath,
object model)
{
var endpointFeature = context.Features.Get<IEndpointFeature>();
var endpoint = endpointFeature?.Endpoint;
var routeData = context.GetRouteData();
var actionContext = new ActionContext(
context,
routeData,
new ActionDescriptor()
);
await using var sw = new StringWriter();
var viewResult = engine.GetView(null, viewPath, true);
if (!viewResult.Success)
throw new FileNotFoundException($"View '{viewPath}' not found.");
var viewData = new ViewDataDictionary(
new EmptyModelMetadataProvider(),
new ModelStateDictionary())
{
Model = model
};
var tempData = new TempDataDictionary(context, tempDataProvider);
var viewContext = new ViewContext(
actionContext,
viewResult.View,
viewData,
tempData,
sw,
new HtmlHelperOptions()
);
await viewResult.View.RenderAsync(viewContext);
return sw.ToString();
}
} }

View File

@@ -122,6 +122,8 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOpenApiInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffcedb617b237dc31e998d31e01f101e8441948433938518c5f20cec1a845c1_003FOpenApiInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOpenApiInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffcedb617b237dc31e998d31e01f101e8441948433938518c5f20cec1a845c1_003FOpenApiInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APageActionDescriptor_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2be344df4d74430c8cfa6e1fd83191ea7b110_003Fc2_003F5bba515a_003FPageActionDescriptor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APageLoader_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2be344df4d74430c8cfa6e1fd83191ea7b110_003F9e_003Ff8e508b5_003FPageLoader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APageModel_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2be344df4d74430c8cfa6e1fd83191ea7b110_003Ff3_003Fd92c30ee_003FPageModel_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APageModel_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2be344df4d74430c8cfa6e1fd83191ea7b110_003Ff3_003Fd92c30ee_003FPageModel_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APathStringTransform_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003Fc5_003Fc4220f9f_003FPathStringTransform_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APathStringTransform_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003Fc5_003Fc4220f9f_003FPathStringTransform_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APathTransformExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003Fd9_003Faff65774_003FPathTransformExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APathTransformExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003Fd9_003Faff65774_003FPathTransformExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -155,6 +157,9 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASourceCustom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F45_003F5839ca6c_003FSourceCustom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASourceCustom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F45_003F5839ca6c_003FSourceCustom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F7a_003F870020d0_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F7a_003F870020d0_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodePagesExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe98808dec0c44c18de3f97b316370d478f08_003F20_003F1750deb2_003FStatusCodePagesExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodePagesMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe98808dec0c44c18de3f97b316370d478f08_003F0b_003Ff955e54b_003FStatusCodePagesMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodePagesOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe98808dec0c44c18de3f97b316370d478f08_003F01_003F859257a9_003FStatusCodePagesOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStreamConfig_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7140aa4493a2490fb306b1e68b5d533c98200_003Fbc_003Fccf922ff_003FStreamConfig_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStreamConfig_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7140aa4493a2490fb306b1e68b5d533c98200_003Fbc_003Fccf922ff_003FStreamConfig_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStructuredTransformer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003F5c_003F73acd7b4_003FStructuredTransformer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStructuredTransformer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003F5c_003F73acd7b4_003FStructuredTransformer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>