Compare commits

...

3 Commits

Author SHA1 Message Date
30db6ad9f1 🗃️ Realm database modeling 2025-05-02 00:08:21 +08:00
24f1a3a9e9 Truncated the post's body to prevent them from being too long 2025-05-01 23:49:17 +08:00
f7e926ad24 Listing post activities 2025-05-01 23:13:31 +08:00
12 changed files with 227 additions and 27 deletions

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace DysonNetwork.Sphere.Activity; namespace DysonNetwork.Sphere.Activity;
@ -6,6 +7,7 @@ public enum ActivityVisibility
{ {
Public, Public,
Friends, Friends,
Selected
} }
public class Activity : ModelBase public class Activity : ModelBase
@ -14,8 +16,11 @@ public class Activity : ModelBase
[MaxLength(1024)] public string Type { get; set; } = null!; [MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!; [MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
public ActivityVisibility Visibility { get; set; } = ActivityVisibility.Public; public ActivityVisibility Visibility { get; set; } = ActivityVisibility.Public;
public Dictionary<string, object> Meta = new(); [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta = new();
[Column(TypeName = "jsonb")] public ICollection<long> UsersVisible = new List<long>();
public long AccountId { get; set; } public long AccountId { get; set; }
public Account.Account Account { get; set; } = null!; public Account.Account Account { get; set; } = null!;
[NotMapped] public object? Data { get; set; }
} }

View File

@ -13,21 +13,22 @@ public class ActivityController(AppDatabase db, ActivityService act, Relationshi
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account; var currentUser = currentUserValue as Account.Account;
var userFriends = await rels.ListAccountFriends(currentUser!); var userFriends = currentUser is null ? null : await rels.ListAccountFriends(currentUser);
var totalCount = await db.Activities var totalCount = await db.Activities
.FilterWithVisibility(currentUser, userFriends) .FilterWithVisibility(currentUser, userFriends)
.CountAsync(); .CountAsync();
var posts = await db.Activities var activities = await db.Activities
.Include(e => e.Account) .Include(e => e.Account)
.FilterWithVisibility(currentUser, userFriends) .FilterWithVisibility(currentUser, userFriends)
.OrderByDescending(e => e.CreatedAt) .OrderByDescending(e => e.CreatedAt)
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
.ToListAsync(); .ToListAsync();
activities = await act.LoadActivityData(activities, currentUser, userFriends);
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();
return Ok(posts); return Ok(activities);
} }
} }

View File

@ -1,15 +1,60 @@
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Activity; namespace DysonNetwork.Sphere.Activity;
public class ActivityService(AppDatabase db) public class ActivityService(AppDatabase db)
{ {
public async Task<List<Activity>> LoadActivityData(List<Activity> input, Account.Account? currentUser,
List<long> userFriends)
{
if (input.Count == 0) return input;
var postsId = input
.Where(e => e.ResourceIdentifier.StartsWith("posts/"))
.Select(e => long.Parse(e.ResourceIdentifier.Split("/").Last()))
.Distinct()
.ToList();
if (postsId.Count > 0)
{
var posts = await db.Posts.Where(e => postsId.Contains(e.Id))
.Include(e => e.Publisher)
.Include(e => e.Publisher.Picture)
.Include(e => e.Publisher.Background)
.Include(e => e.ThreadedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Attachments)
.Include(e => e.Categories)
.Include(e => e.Tags)
.FilterWithVisibility(currentUser, userFriends)
.ToListAsync();
posts = PostService.TruncatePostContent(posts);
var postsDict = posts.ToDictionary(p => p.Id);
for (var idx = 0; idx < input.Count; idx++)
{
var resourceIdentifier = input[idx].ResourceIdentifier;
if (!resourceIdentifier.StartsWith("posts/")) continue;
var postId = long.Parse(resourceIdentifier.Split("/").Last());
if (postsDict.TryGetValue(postId, out var post) && input[idx].Data is null)
{
input[idx].Data = post;
}
}
}
return input;
}
public async Task<Activity> CreateActivity( public async Task<Activity> CreateActivity(
Account.Account user, Account.Account user,
string type, string type,
string identifier, string identifier,
ActivityVisibility visibility = ActivityVisibility.Public ActivityVisibility visibility = ActivityVisibility.Public,
List<long>? visibleUsers = null
) )
{ {
var activity = new Activity var activity = new Activity
@ -18,6 +63,7 @@ public class ActivityService(AppDatabase db)
ResourceIdentifier = identifier, ResourceIdentifier = identifier,
Visibility = visibility, Visibility = visibility,
AccountId = user.Id, AccountId = user.Id,
UsersVisible = visibleUsers ?? []
}; };
db.Activities.Add(activity); db.Activities.Add(activity);
@ -31,8 +77,26 @@ public class ActivityService(AppDatabase db)
if (post.Visibility is PostVisibility.Unlisted or PostVisibility.Private) return; if (post.Visibility is PostVisibility.Unlisted or PostVisibility.Private) return;
var identifier = $"posts/{post.Id}"; var identifier = $"posts/{post.Id}";
await CreateActivity(user, "posts.new", identifier, if (post.RepliedPostId is not null)
post.Visibility == PostVisibility.Friends ? ActivityVisibility.Friends : ActivityVisibility.Public); {
var ogPost = await db.Posts.Where(e => e.Id == post.RepliedPostId).Include(e => e.Publisher)
.FirstOrDefaultAsync();
if (ogPost == null) return;
await CreateActivity(
user,
"posts.new.replies",
identifier,
ActivityVisibility.Selected,
[ogPost.Publisher.AccountId!.Value]
);
}
await CreateActivity(
user,
"posts.new",
identifier,
post.Visibility == PostVisibility.Friends ? ActivityVisibility.Friends : ActivityVisibility.Public
);
} }
} }
@ -41,14 +105,13 @@ public static class ActivityQueryExtensions
public static IQueryable<Activity> FilterWithVisibility(this IQueryable<Activity> source, public static IQueryable<Activity> FilterWithVisibility(this IQueryable<Activity> source,
Account.Account? currentUser, List<long> userFriends) Account.Account? currentUser, List<long> userFriends)
{ {
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
if (currentUser is null) if (currentUser is null)
return source.Where(e => e.Visibility == ActivityVisibility.Public); return source.Where(e => e.Visibility == ActivityVisibility.Public);
return source return source
.Where(e => e.Visibility != ActivityVisibility.Friends || .Where(e => e.Visibility != ActivityVisibility.Friends ||
userFriends.Contains(e.AccountId) || userFriends.Contains(e.AccountId) ||
e.AccountId == currentUser.Id); e.AccountId == currentUser.Id)
.Where(e => e.Visibility != ActivityVisibility.Selected || e.UsersVisible.Contains(currentUser.Id));
} }
} }

View File

@ -20,11 +20,11 @@ public class AppDatabase(
IConfiguration configuration IConfiguration configuration
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<PermissionNode> PermissionNodes { get; set; } = null!; public DbSet<PermissionNode> PermissionNodes { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; } = null!; public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; } = null!; public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<Account.MagicSpell> MagicSpells { get; set; } = null!; public DbSet<Account.MagicSpell> MagicSpells { get; set; }
public DbSet<Account.Account> Accounts { get; set; } public DbSet<Account.Account> Accounts { get; set; }
public DbSet<Account.Profile> AccountProfiles { get; set; } public DbSet<Account.Profile> AccountProfiles { get; set; }
public DbSet<Account.AccountContact> AccountContacts { get; set; } public DbSet<Account.AccountContact> AccountContacts { get; set; }
@ -48,6 +48,9 @@ public class AppDatabase(
public DbSet<Post.PostCategory> PostCategories { get; set; } public DbSet<Post.PostCategory> PostCategories { get; set; }
public DbSet<Post.PostCollection> PostCollections { get; set; } public DbSet<Post.PostCollection> PostCollections { get; set; }
public DbSet<Realm.Realm> Realms { get; set; }
public DbSet<Realm.RealmMember> RealmMembers { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
var dataSourceBuilder = new NpgsqlDataSourceBuilder(configuration.GetConnectionString("App")); var dataSourceBuilder = new NpgsqlDataSourceBuilder(configuration.GetConnectionString("App"));
@ -154,19 +157,30 @@ public class AppDatabase(
.HasMany(p => p.Collections) .HasMany(p => p.Collections)
.WithMany(c => c.Posts) .WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_collection_links")); .UsingEntity(j => j.ToTable("post_collection_links"));
modelBuilder.Entity<Realm.RealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<Realm.RealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.PublisherMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
// Automatically apply soft-delete filter to all entities inheriting BaseModel // Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes()) foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{ {
if (typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
{ var method = typeof(AppDatabase)
var method = typeof(AppDatabase) .GetMethod(nameof(SetSoftDeleteFilter),
.GetMethod(nameof(SetSoftDeleteFilter), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! .MakeGenericMethod(entityType.ClrType);
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [modelBuilder]); method.Invoke(null, [modelBuilder]);
}
} }
} }

View File

@ -23,7 +23,7 @@ public class PermissionService(AppDatabase db)
.ToListAsync(); .ToListAsync();
var permission = await db.PermissionNodes var permission = await db.PermissionNodes
.Where(n => n.GroupId == null || groupsId.Contains(n.GroupId.Value)) .Where(n => n.GroupId == null || groupsId.Contains(n.GroupId.Value))
.Where(n => n.Key == key && (n.GroupId != null || n.Actor == actor) && n.Area == area) .Where(n => (n.Key == key || n.Key == "*") && (n.GroupId != null || n.Actor == actor) && n.Area == area)
.Where(n => n.ExpiredAt == null || n.ExpiredAt < now) .Where(n => n.ExpiredAt == null || n.ExpiredAt < now)
.Where(n => n.AffectedAt == null || n.AffectedAt >= now) .Where(n => n.AffectedAt == null || n.AffectedAt >= now)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();

View File

@ -51,7 +51,7 @@ public class Post : ModelBase
public Post? ForwardedPost { get; set; } public Post? ForwardedPost { get; set; }
public ICollection<CloudFile> Attachments { get; set; } = new List<CloudFile>(); public ICollection<CloudFile> Attachments { get; set; } = new List<CloudFile>();
public NpgsqlTsVector SearchVector { get; set; } [JsonIgnore] public NpgsqlTsVector SearchVector { get; set; }
public Publisher Publisher { get; set; } = null!; public Publisher Publisher { get; set; } = null!;
public ICollection<PostReaction> Reactions { get; set; } = new List<PostReaction>(); public ICollection<PostReaction> Reactions { get; set; } = new List<PostReaction>();
@ -59,7 +59,8 @@ public class Post : ModelBase
public ICollection<PostCategory> Categories { get; set; } = new List<PostCategory>(); public ICollection<PostCategory> Categories { get; set; } = new List<PostCategory>();
public ICollection<PostCollection> Collections { get; set; } = new List<PostCollection>(); public ICollection<PostCollection> Collections { get; set; } = new List<PostCollection>();
public bool Empty => Content == null && Attachments.Count == 0 && ForwardedPostId == null; [JsonIgnore] public bool Empty => Content == null && Attachments.Count == 0 && ForwardedPostId == null;
[NotMapped] public bool IsTruncated = false;
} }
public class PostTag : ModelBase public class PostTag : ModelBase

View File

@ -38,6 +38,7 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
.ToListAsync(); .ToListAsync();
posts = PostService.TruncatePostContent(posts);
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();
@ -101,6 +102,7 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
.ToListAsync(); .ToListAsync();
posts = PostService.TruncatePostContent(posts);
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();

View File

@ -1,3 +1,4 @@
using System.Text.Json;
using DysonNetwork.Sphere.Activity; using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -7,6 +8,64 @@ namespace DysonNetwork.Sphere.Post;
public class PostService(AppDatabase db, FileService fs, ActivityService act) public class PostService(AppDatabase db, FileService fs, ActivityService act)
{ {
public static List<Post> TruncatePostContent(List<Post> input)
{
// This truncate post content is designed for quill delta
const int maxLength = 256;
foreach (var item in input)
{
if (item.Content is not { RootElement: var rootElement }) continue;
if (rootElement.ValueKind != JsonValueKind.Array) continue;
var totalLength = 0;
var truncatedArrayElements = new List<JsonElement>();
foreach (var element in rootElement.EnumerateArray())
{
if (element is { ValueKind: JsonValueKind.Object } &&
element.TryGetProperty("insert", out var insertProperty))
{
if (insertProperty is { ValueKind: JsonValueKind.String })
{
var textContent = insertProperty.GetString()!;
if (totalLength + textContent.Length <= maxLength)
{
truncatedArrayElements.Add(element);
totalLength += textContent.Length;
}
else
{
var remainingLength = maxLength - totalLength;
if (remainingLength > 0)
{
using var truncatedElementDocument =
JsonDocument.Parse(
$@"{{ ""insert"": ""{textContent.Substring(0, remainingLength)}"" }}"
);
truncatedArrayElements.Add(truncatedElementDocument.RootElement.Clone());
totalLength = maxLength;
}
break;
}
}
else
truncatedArrayElements.Add(element);
}
else
truncatedArrayElements.Add(element);
if (totalLength >= maxLength)
break;
}
using var newDocument = JsonDocument.Parse(JsonSerializer.Serialize(truncatedArrayElements));
item.Content = newDocument;
}
return input;
}
public async Task<Post> PostAsync( public async Task<Post> PostAsync(
Account.Account user, Account.Account user,
Post post, Post post,

View File

@ -41,7 +41,7 @@ public enum PublisherMemberRole
Viewer = 25 Viewer = 25
} }
public class PublisherMember : ModelBase public class PublisherMember
{ {
public long PublisherId { get; set; } public long PublisherId { get; set; }
[JsonIgnore] public Publisher Publisher { get; set; } = null!; [JsonIgnore] public Publisher Publisher { get; set; } = null!;

View File

@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using NodaTime;
namespace DysonNetwork.Sphere.Realm;
public class Realm : ModelBase
{
public long Id { get; set; }
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
[MaxLength(4096)] public string Description { get; set; } = string.Empty;
public CloudFile? Picture { get; set; }
public CloudFile? Background { get; set; }
[JsonIgnore] public ICollection<RealmMember> Members { get; set; } = new List<RealmMember>();
public long AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; }
}
public enum RealmMemberRole
{
Owner = 100,
Moderator = 50,
Normal = 0
}
public class RealmMember : ModelBase
{
public long RealmId { get; set; }
[JsonIgnore] public Realm Realm { get; set; } = null!;
public long AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public RealmMemberRole Role { get; set; }
public Instant? JoinedAt { get; set; }
}

View File

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Realm;
[ApiController]
[Route("/realms")]
public class RealmController : ControllerBase
{
}

View File

@ -0,0 +1,6 @@
namespace DysonNetwork.Sphere.Realm;
public class RealmService(AppDatabase db)
{
}