Compare commits
3 Commits
42b5129aa4
...
b1543f5b08
Author | SHA1 | Date | |
---|---|---|---|
b1543f5b08 | |||
0f9e865c0b | |||
bf64afd849 |
@ -10,6 +10,8 @@ public class AccountService(AppDatabase db, PermissionService pm, IMemoryCache c
|
|||||||
{
|
{
|
||||||
public async Task PurgeAccountCache(Account account)
|
public async Task PurgeAccountCache(Account account)
|
||||||
{
|
{
|
||||||
|
cache.Remove($"dyn_user_friends_{account.Id}");
|
||||||
|
|
||||||
var sessions = await db.AuthSessions.Where(e => e.Account.Id == account.Id).Select(e => e.Id)
|
var sessions = await db.AuthSessions.Where(e => e.Account.Id == account.Id).Select(e => e.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
foreach (var session in sessions)
|
foreach (var session in sessions)
|
||||||
@ -31,144 +33,4 @@ public class AccountService(AppDatabase db, PermissionService pm, IMemoryCache c
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HasExistingRelationship(Account userA, Account userB)
|
|
||||||
{
|
|
||||||
var count = await db.AccountRelationships
|
|
||||||
.Where(r => (r.AccountId == userA.Id && r.AccountId == userB.Id) ||
|
|
||||||
(r.AccountId == userB.Id && r.AccountId == userA.Id))
|
|
||||||
.CountAsync();
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Relationship?> GetRelationship(
|
|
||||||
Account account,
|
|
||||||
Account related,
|
|
||||||
RelationshipStatus? status,
|
|
||||||
bool ignoreExpired = false
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
|
||||||
var queries = db.AccountRelationships
|
|
||||||
.Where(r => r.AccountId == account.Id && r.AccountId == related.Id);
|
|
||||||
if (ignoreExpired) queries = queries.Where(r => r.ExpiredAt > now);
|
|
||||||
if (status is not null) queries = queries.Where(r => r.Status == status);
|
|
||||||
var relationship = await queries.FirstOrDefaultAsync();
|
|
||||||
return relationship;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status)
|
|
||||||
{
|
|
||||||
if (status == RelationshipStatus.Pending)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Cannot create relationship with pending status, use SendFriendRequest instead.");
|
|
||||||
if (await HasExistingRelationship(sender, target))
|
|
||||||
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
|
||||||
|
|
||||||
var relationship = new Relationship
|
|
||||||
{
|
|
||||||
Account = sender,
|
|
||||||
AccountId = sender.Id,
|
|
||||||
Related = target,
|
|
||||||
RelatedId = target.Id,
|
|
||||||
Status = status
|
|
||||||
};
|
|
||||||
|
|
||||||
db.AccountRelationships.Add(relationship);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
await ApplyRelationshipPermissions(relationship);
|
|
||||||
|
|
||||||
return relationship;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
|
|
||||||
{
|
|
||||||
if (await HasExistingRelationship(sender, target))
|
|
||||||
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
|
||||||
|
|
||||||
var relationship = new Relationship
|
|
||||||
{
|
|
||||||
Account = sender,
|
|
||||||
AccountId = sender.Id,
|
|
||||||
Related = target,
|
|
||||||
RelatedId = target.Id,
|
|
||||||
Status = RelationshipStatus.Pending,
|
|
||||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7))
|
|
||||||
};
|
|
||||||
|
|
||||||
db.AccountRelationships.Add(relationship);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return relationship;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Relationship> AcceptFriendRelationship(
|
|
||||||
Relationship relationship,
|
|
||||||
RelationshipStatus status = RelationshipStatus.Friends
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (relationship.Status == RelationshipStatus.Pending)
|
|
||||||
throw new ArgumentException("Cannot accept friend request by setting the new status to pending.");
|
|
||||||
|
|
||||||
// Whatever the receiver decides to apply which status to the relationship,
|
|
||||||
// the sender should always see the user as a friend since the sender ask for it
|
|
||||||
relationship.Status = RelationshipStatus.Friends;
|
|
||||||
relationship.ExpiredAt = null;
|
|
||||||
db.Update(relationship);
|
|
||||||
|
|
||||||
var relationshipBackward = new Relationship
|
|
||||||
{
|
|
||||||
Account = relationship.Related,
|
|
||||||
AccountId = relationship.RelatedId,
|
|
||||||
Related = relationship.Account,
|
|
||||||
RelatedId = relationship.AccountId,
|
|
||||||
Status = status
|
|
||||||
};
|
|
||||||
db.AccountRelationships.Add(relationshipBackward);
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
await Task.WhenAll(
|
|
||||||
ApplyRelationshipPermissions(relationship),
|
|
||||||
ApplyRelationshipPermissions(relationshipBackward)
|
|
||||||
);
|
|
||||||
|
|
||||||
return relationshipBackward;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Relationship> UpdateRelationship(Account account, Account related, RelationshipStatus status)
|
|
||||||
{
|
|
||||||
var relationship = await GetRelationship(account, related, status);
|
|
||||||
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
|
||||||
if (relationship.Status == status) return relationship;
|
|
||||||
relationship.Status = status;
|
|
||||||
db.Update(relationship);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
await ApplyRelationshipPermissions(relationship);
|
|
||||||
return relationship;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ApplyRelationshipPermissions(Relationship relationship)
|
|
||||||
{
|
|
||||||
// Apply the relationship permissions to casbin enforcer
|
|
||||||
// domain: the user
|
|
||||||
// status is friends: all permissions are allowed by default, expect specially specified
|
|
||||||
// status is blocked: all permissions are disallowed by default, expect specially specified
|
|
||||||
// others: use the default permissions by design
|
|
||||||
|
|
||||||
var domain = $"user:{relationship.AccountId.ToString()}";
|
|
||||||
var target = $"user:{relationship.RelatedId.ToString()}";
|
|
||||||
|
|
||||||
await pm.RemovePermissionNode(target, domain, "*");
|
|
||||||
|
|
||||||
bool? value = relationship.Status switch
|
|
||||||
{
|
|
||||||
RelationshipStatus.Friends => true,
|
|
||||||
RelationshipStatus.Blocked => false,
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
if (value is null) return;
|
|
||||||
|
|
||||||
await pm.AddPermissionNode(target, domain, "*", value);
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -22,7 +22,6 @@ public class MagicSpellService(AppDatabase db, EmailService email, ILogger<Magic
|
|||||||
Type = type,
|
Type = type,
|
||||||
ExpiresAt = expiredAt,
|
ExpiresAt = expiredAt,
|
||||||
AffectedAt = affectedAt,
|
AffectedAt = affectedAt,
|
||||||
Account = account,
|
|
||||||
AccountId = account.Id,
|
AccountId = account.Id,
|
||||||
Meta = meta
|
Meta = meta
|
||||||
};
|
};
|
||||||
|
@ -42,7 +42,7 @@ public class NotificationService
|
|||||||
: ApnServerType.Development
|
: ApnServerType.Development
|
||||||
}, clientFactory.CreateClient());
|
}, clientFactory.CreateClient());
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO remove all push notification with this device id when this device is logged out
|
// TODO remove all push notification with this device id when this device is logged out
|
||||||
|
|
||||||
public async Task<NotificationPushSubscription> SubscribePushNotification(
|
public async Task<NotificationPushSubscription> SubscribePushNotification(
|
||||||
@ -101,16 +101,13 @@ public class NotificationService
|
|||||||
Subtitle = subtitle,
|
Subtitle = subtitle,
|
||||||
Content = content,
|
Content = content,
|
||||||
Meta = meta,
|
Meta = meta,
|
||||||
Account = account,
|
|
||||||
AccountId = account.Id,
|
AccountId = account.Id,
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Add(notification);
|
_db.Add(notification);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
#pragma warning disable CS4014
|
if (!isSilent) _ = DeliveryNotification(notification).ConfigureAwait(false);
|
||||||
if (!isSilent) DeliveryNotification(notification);
|
|
||||||
#pragma warning restore CS4014
|
|
||||||
|
|
||||||
return notification;
|
return notification;
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ namespace DysonNetwork.Sphere.Account;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/relationships")]
|
[Route("/relationships")]
|
||||||
public class RelationshipController(AppDatabase db, AccountService accounts) : ControllerBase
|
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@ -48,7 +48,7 @@ public class RelationshipController(AppDatabase db, AccountService accounts) : C
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var relationship = await accounts.CreateRelationship(
|
var relationship = await rels.CreateRelationship(
|
||||||
currentUser, relatedUser, request.Status
|
currentUser, relatedUser, request.Status
|
||||||
);
|
);
|
||||||
return relationship;
|
return relationship;
|
||||||
|
165
DysonNetwork.Sphere/Account/RelationshipService.cs
Normal file
165
DysonNetwork.Sphere/Account/RelationshipService.cs
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
using DysonNetwork.Sphere.Permission;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Account;
|
||||||
|
|
||||||
|
public class RelationshipService(AppDatabase db, PermissionService pm, IMemoryCache cache)
|
||||||
|
{
|
||||||
|
public async Task<bool> HasExistingRelationship(Account userA, Account userB)
|
||||||
|
{
|
||||||
|
var count = await db.AccountRelationships
|
||||||
|
.Where(r => (r.AccountId == userA.Id && r.AccountId == userB.Id) ||
|
||||||
|
(r.AccountId == userB.Id && r.AccountId == userA.Id))
|
||||||
|
.CountAsync();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship?> GetRelationship(
|
||||||
|
Account account,
|
||||||
|
Account related,
|
||||||
|
RelationshipStatus? status,
|
||||||
|
bool ignoreExpired = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
|
var queries = db.AccountRelationships
|
||||||
|
.Where(r => r.AccountId == account.Id && r.AccountId == related.Id);
|
||||||
|
if (ignoreExpired) queries = queries.Where(r => r.ExpiredAt > now);
|
||||||
|
if (status is not null) queries = queries.Where(r => r.Status == status);
|
||||||
|
var relationship = await queries.FirstOrDefaultAsync();
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status)
|
||||||
|
{
|
||||||
|
if (status == RelationshipStatus.Pending)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Cannot create relationship with pending status, use SendFriendRequest instead.");
|
||||||
|
if (await HasExistingRelationship(sender, target))
|
||||||
|
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
||||||
|
|
||||||
|
var relationship = new Relationship
|
||||||
|
{
|
||||||
|
AccountId = sender.Id,
|
||||||
|
RelatedId = target.Id,
|
||||||
|
Status = status
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AccountRelationships.Add(relationship);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await ApplyRelationshipPermissions(relationship);
|
||||||
|
|
||||||
|
cache.Remove($"dyn_user_friends_{relationship.AccountId}");
|
||||||
|
cache.Remove($"dyn_user_friends_{relationship.RelatedId}");
|
||||||
|
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
|
||||||
|
{
|
||||||
|
if (await HasExistingRelationship(sender, target))
|
||||||
|
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
||||||
|
|
||||||
|
var relationship = new Relationship
|
||||||
|
{
|
||||||
|
AccountId = sender.Id,
|
||||||
|
RelatedId = target.Id,
|
||||||
|
Status = RelationshipStatus.Pending,
|
||||||
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7))
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AccountRelationships.Add(relationship);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> AcceptFriendRelationship(
|
||||||
|
Relationship relationship,
|
||||||
|
RelationshipStatus status = RelationshipStatus.Friends
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (relationship.Status == RelationshipStatus.Pending)
|
||||||
|
throw new ArgumentException("Cannot accept friend request by setting the new status to pending.");
|
||||||
|
|
||||||
|
// Whatever the receiver decides to apply which status to the relationship,
|
||||||
|
// the sender should always see the user as a friend since the sender ask for it
|
||||||
|
relationship.Status = RelationshipStatus.Friends;
|
||||||
|
relationship.ExpiredAt = null;
|
||||||
|
db.Update(relationship);
|
||||||
|
|
||||||
|
var relationshipBackward = new Relationship
|
||||||
|
{
|
||||||
|
AccountId = relationship.RelatedId,
|
||||||
|
RelatedId = relationship.AccountId,
|
||||||
|
Status = status
|
||||||
|
};
|
||||||
|
db.AccountRelationships.Add(relationshipBackward);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await Task.WhenAll(
|
||||||
|
ApplyRelationshipPermissions(relationship),
|
||||||
|
ApplyRelationshipPermissions(relationshipBackward)
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.Remove($"dyn_user_friends_{relationship.AccountId}");
|
||||||
|
cache.Remove($"dyn_user_friends_{relationship.RelatedId}");
|
||||||
|
|
||||||
|
return relationshipBackward;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> UpdateRelationship(Account account, Account related, RelationshipStatus status)
|
||||||
|
{
|
||||||
|
var relationship = await GetRelationship(account, related, status);
|
||||||
|
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||||
|
if (relationship.Status == status) return relationship;
|
||||||
|
relationship.Status = status;
|
||||||
|
db.Update(relationship);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await ApplyRelationshipPermissions(relationship);
|
||||||
|
cache.Remove($"dyn_user_friends_{related.Id}");
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<long>> ListAccountFriends(Account account)
|
||||||
|
{
|
||||||
|
if (!cache.TryGetValue($"dyn_user_friends_{account.Id}", out List<long>? friends))
|
||||||
|
{
|
||||||
|
friends = await db.AccountRelationships
|
||||||
|
.Where(r => r.RelatedId == account.Id)
|
||||||
|
.Where(r => r.Status == RelationshipStatus.Friends)
|
||||||
|
.Select(r => r.AccountId)
|
||||||
|
.ToListAsync();
|
||||||
|
cache.Set($"dyn_user_friends_{account.Id}", friends, TimeSpan.FromHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return friends ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyRelationshipPermissions(Relationship relationship)
|
||||||
|
{
|
||||||
|
// Apply the relationship permissions to casbin enforcer
|
||||||
|
// domain: the user
|
||||||
|
// status is friends: all permissions are allowed by default, expect specially specified
|
||||||
|
// status is blocked: all permissions are disallowed by default, expect specially specified
|
||||||
|
// others: use the default permissions by design
|
||||||
|
|
||||||
|
var domain = $"user:{relationship.AccountId.ToString()}";
|
||||||
|
var target = $"user:{relationship.RelatedId.ToString()}";
|
||||||
|
|
||||||
|
await pm.RemovePermissionNode(target, domain, "*");
|
||||||
|
|
||||||
|
bool? value = relationship.Status switch
|
||||||
|
{
|
||||||
|
RelationshipStatus.Friends => true,
|
||||||
|
RelationshipStatus.Blocked => false,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (value is null) return;
|
||||||
|
|
||||||
|
await pm.AddPermissionNode(target, domain, "*", value);
|
||||||
|
}
|
||||||
|
}
|
21
DysonNetwork.Sphere/Activity/Activity.cs
Normal file
21
DysonNetwork.Sphere/Activity/Activity.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Activity;
|
||||||
|
|
||||||
|
public enum ActivityVisibility
|
||||||
|
{
|
||||||
|
Public,
|
||||||
|
Friends,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Activity : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
[MaxLength(1024)] public string Type { get; set; } = null!;
|
||||||
|
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
|
||||||
|
public ActivityVisibility Visibility { get; set; } = ActivityVisibility.Public;
|
||||||
|
public Dictionary<string, object> Meta = new();
|
||||||
|
|
||||||
|
public long AccountId { get; set; }
|
||||||
|
public Account.Account Account { get; set; } = null!;
|
||||||
|
}
|
33
DysonNetwork.Sphere/Activity/ActivityController.cs
Normal file
33
DysonNetwork.Sphere/Activity/ActivityController.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using DysonNetwork.Sphere.Account;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Activity;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/activities")]
|
||||||
|
public class ActivityController(AppDatabase db, ActivityService act, RelationshipService rels) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<Activity>>> ListActivities([FromQuery] int offset, [FromQuery] int take = 20)
|
||||||
|
{
|
||||||
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
|
var currentUser = currentUserValue as Account.Account;
|
||||||
|
var userFriends = await rels.ListAccountFriends(currentUser!);
|
||||||
|
|
||||||
|
var totalCount = await db.Activities
|
||||||
|
.FilterWithVisibility(currentUser, userFriends)
|
||||||
|
.CountAsync();
|
||||||
|
var posts = await db.Activities
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.FilterWithVisibility(currentUser, userFriends)
|
||||||
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
|
||||||
|
return Ok(posts);
|
||||||
|
}
|
||||||
|
}
|
54
DysonNetwork.Sphere/Activity/ActivityService.cs
Normal file
54
DysonNetwork.Sphere/Activity/ActivityService.cs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
using DysonNetwork.Sphere.Post;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Activity;
|
||||||
|
|
||||||
|
public class ActivityService(AppDatabase db)
|
||||||
|
{
|
||||||
|
public async Task<Activity> CreateActivity(
|
||||||
|
Account.Account user,
|
||||||
|
string type,
|
||||||
|
string identifier,
|
||||||
|
ActivityVisibility visibility = ActivityVisibility.Public
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var activity = new Activity
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
ResourceIdentifier = identifier,
|
||||||
|
Visibility = visibility,
|
||||||
|
AccountId = user.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Activities.Add(activity);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CreateNewPostActivity(Account.Account user, Post.Post post)
|
||||||
|
{
|
||||||
|
if (post.Visibility is PostVisibility.Unlisted or PostVisibility.Private) return;
|
||||||
|
|
||||||
|
var identifier = $"posts/{post.Id}";
|
||||||
|
await CreateActivity(user, "posts.new", identifier,
|
||||||
|
post.Visibility == PostVisibility.Friends ? ActivityVisibility.Friends : ActivityVisibility.Public);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ActivityQueryExtensions
|
||||||
|
{
|
||||||
|
public static IQueryable<Activity> FilterWithVisibility(this IQueryable<Activity> source,
|
||||||
|
Account.Account? currentUser, List<long> userFriends)
|
||||||
|
{
|
||||||
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
|
|
||||||
|
if (currentUser is null)
|
||||||
|
return source.Where(e => e.Visibility == ActivityVisibility.Public);
|
||||||
|
|
||||||
|
return source
|
||||||
|
.Where(e => e.Visibility != ActivityVisibility.Friends ||
|
||||||
|
userFriends.Contains(e.AccountId) ||
|
||||||
|
e.AccountId == currentUser.Id);
|
||||||
|
}
|
||||||
|
}
|
@ -38,6 +38,8 @@ public class AppDatabase(
|
|||||||
|
|
||||||
public DbSet<Storage.CloudFile> Files { get; set; }
|
public DbSet<Storage.CloudFile> Files { get; set; }
|
||||||
|
|
||||||
|
public DbSet<Activity.Activity> Activities { get; set; }
|
||||||
|
|
||||||
public DbSet<Post.Publisher> Publishers { get; set; }
|
public DbSet<Post.Publisher> Publishers { get; set; }
|
||||||
public DbSet<Post.PublisherMember> PublisherMembers { get; set; }
|
public DbSet<Post.PublisherMember> PublisherMembers { get; set; }
|
||||||
public DbSet<Post.Post> Posts { get; set; }
|
public DbSet<Post.Post> Posts { get; set; }
|
||||||
@ -70,7 +72,8 @@ public class AppDatabase(
|
|||||||
Nodes =
|
Nodes =
|
||||||
{
|
{
|
||||||
PermissionService.NewPermissionNode("group:default", "global", "posts.create", true),
|
PermissionService.NewPermissionNode("group:default", "global", "posts.create", true),
|
||||||
PermissionService.NewPermissionNode("group:default", "global", "publishers.create", true)
|
PermissionService.NewPermissionNode("group:default", "global", "publishers.create", true),
|
||||||
|
PermissionService.NewPermissionNode("group:default", "global", "files.create", true)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
@ -84,9 +87,9 @@ public class AppDatabase(
|
|||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity<Permission.PermissionGroupMember>()
|
modelBuilder.Entity<PermissionGroupMember>()
|
||||||
.HasKey(pg => new { pg.GroupId, pg.Actor });
|
.HasKey(pg => new { pg.GroupId, pg.Actor });
|
||||||
modelBuilder.Entity<Permission.PermissionGroupMember>()
|
modelBuilder.Entity<PermissionGroupMember>()
|
||||||
.HasOne(pg => pg.Group)
|
.HasOne(pg => pg.Group)
|
||||||
.WithMany(g => g.Members)
|
.WithMany(g => g.Members)
|
||||||
.HasForeignKey(pg => pg.GroupId)
|
.HasForeignKey(pg => pg.GroupId)
|
||||||
@ -121,6 +124,10 @@ public class AppDatabase(
|
|||||||
.HasForeignKey(pm => pm.AccountId)
|
.HasForeignKey(pm => pm.AccountId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Post.Post>()
|
||||||
|
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
|
||||||
|
.HasIndex(p => p.SearchVector)
|
||||||
|
.HasMethod("GIN");
|
||||||
modelBuilder.Entity<Post.Post>()
|
modelBuilder.Entity<Post.Post>()
|
||||||
.HasOne(p => p.ThreadedPost)
|
.HasOne(p => p.ThreadedPost)
|
||||||
.WithOne()
|
.WithOne()
|
||||||
|
@ -50,7 +50,6 @@ public class AuthController(
|
|||||||
|
|
||||||
var challenge = new Challenge
|
var challenge = new Challenge
|
||||||
{
|
{
|
||||||
Account = account,
|
|
||||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
||||||
StepTotal = 1,
|
StepTotal = 1,
|
||||||
Platform = request.Platform,
|
Platform = request.Platform,
|
||||||
@ -59,6 +58,7 @@ public class AuthController(
|
|||||||
IpAddress = ipAddress,
|
IpAddress = ipAddress,
|
||||||
UserAgent = userAgent,
|
UserAgent = userAgent,
|
||||||
DeviceId = request.DeviceId,
|
DeviceId = request.DeviceId,
|
||||||
|
AccountId = account.Id
|
||||||
}.Normalize();
|
}.Normalize();
|
||||||
|
|
||||||
await db.AuthChallenges.AddAsync(challenge);
|
await db.AuthChallenges.AddAsync(challenge);
|
||||||
@ -123,13 +123,19 @@ public class AuthController(
|
|||||||
{
|
{
|
||||||
challenge.StepRemain--;
|
challenge.StepRemain--;
|
||||||
challenge.BlacklistFactors.Add(factor.Id);
|
challenge.BlacklistFactors.Add(factor.Id);
|
||||||
|
db.Update(challenge);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid password.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
challenge.FailedAttempts++;
|
challenge.FailedAttempts++;
|
||||||
|
db.Update(challenge);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return BadRequest();
|
return BadRequest("Invalid password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
@ -8,13 +8,8 @@ namespace DysonNetwork.Sphere.Connection;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/ws")]
|
[Route("/ws")]
|
||||||
public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerBase
|
public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
private static readonly ConcurrentDictionary<
|
|
||||||
(long AccountId, string DeviceId),
|
|
||||||
(WebSocket Socket, CancellationTokenSource Cts)
|
|
||||||
> ActiveConnections = new();
|
|
||||||
|
|
||||||
[Route("/ws")]
|
[Route("/ws")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[SwaggerIgnore]
|
[SwaggerIgnore]
|
||||||
@ -42,7 +37,7 @@ public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerB
|
|||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
var connectionKey = (accountId, deviceId);
|
var connectionKey = (accountId, deviceId);
|
||||||
|
|
||||||
if (!ActiveConnections.TryAdd(connectionKey, (webSocket, cts)))
|
if (!ws.TryAdd(connectionKey, webSocket, cts))
|
||||||
{
|
{
|
||||||
await webSocket.CloseAsync(
|
await webSocket.CloseAsync(
|
||||||
WebSocketCloseStatus.InternalServerError,
|
WebSocketCloseStatus.InternalServerError,
|
||||||
@ -57,7 +52,7 @@ public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerB
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _ConnectionEventLoop(webSocket, connectionKey, cts.Token);
|
await _ConnectionEventLoop(connectionKey, webSocket, cts.Token);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -65,16 +60,15 @@ public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerB
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
ActiveConnections.TryRemove(connectionKey, out _);
|
ws.Disconnect(connectionKey);
|
||||||
cts.Dispose();
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
$"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
|
$"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task _ConnectionEventLoop(
|
private async Task _ConnectionEventLoop(
|
||||||
WebSocket webSocket,
|
|
||||||
(long AccountId, string DeviceId) connectionKey,
|
(long AccountId, string DeviceId) connectionKey,
|
||||||
|
WebSocket webSocket,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@ -93,41 +87,17 @@ public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerB
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await webSocket.CloseAsync(
|
// TODO handle values
|
||||||
receiveResult.CloseStatus.Value,
|
|
||||||
receiveResult.CloseStatusDescription,
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Connection was canceled, close it gracefully
|
|
||||||
if (
|
if (
|
||||||
webSocket.State != WebSocketState.Closed
|
webSocket.State != WebSocketState.Closed
|
||||||
&& webSocket.State != WebSocketState.Aborted
|
&& webSocket.State != WebSocketState.Aborted
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await webSocket.CloseAsync(
|
ws.Disconnect(connectionKey);
|
||||||
WebSocketCloseStatus.NormalClosure,
|
|
||||||
"Connection closed by server",
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method will be used later to send messages to specific connections
|
|
||||||
public static async Task SendMessageAsync(long accountId, string deviceId, string message)
|
|
||||||
{
|
|
||||||
if (ActiveConnections.TryGetValue((accountId, deviceId), out var connection))
|
|
||||||
{
|
|
||||||
var buffer = System.Text.Encoding.UTF8.GetBytes(message);
|
|
||||||
await connection.Socket.SendAsync(
|
|
||||||
new ArraySegment<byte>(buffer, 0, buffer.Length),
|
|
||||||
WebSocketMessageType.Text,
|
|
||||||
true,
|
|
||||||
connection.Cts.Token
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
35
DysonNetwork.Sphere/Connection/WebSocketService.cs
Normal file
35
DysonNetwork.Sphere/Connection/WebSocketService.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Connection;
|
||||||
|
|
||||||
|
public class WebSocketService
|
||||||
|
{
|
||||||
|
public static readonly ConcurrentDictionary<
|
||||||
|
(long AccountId, string DeviceId),
|
||||||
|
(WebSocket Socket, CancellationTokenSource Cts)
|
||||||
|
> ActiveConnections = new();
|
||||||
|
|
||||||
|
public bool TryAdd(
|
||||||
|
(long AccountId, string DeviceId) key,
|
||||||
|
WebSocket socket,
|
||||||
|
CancellationTokenSource cts
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (ActiveConnections.TryGetValue(key, out _))
|
||||||
|
Disconnect(key, "Just connected somewhere else with the same identifier."); // Disconnect the previous one using the same identifier
|
||||||
|
return ActiveConnections.TryAdd(key, (socket, cts));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Disconnect((long AccountId, string DeviceId) key, string? reason = null)
|
||||||
|
{
|
||||||
|
if (!ActiveConnections.TryGetValue(key, out var data)) return;
|
||||||
|
data.Socket.CloseAsync(
|
||||||
|
WebSocketCloseStatus.NormalClosure,
|
||||||
|
reason ?? "Server just decided to disconnect.",
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
data.Cts.Cancel();
|
||||||
|
ActiveConnections.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
}
|
@ -9,13 +9,14 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using NpgsqlTypes;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Migrations
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(AppDatabase))]
|
[DbContext(typeof(AppDatabase))]
|
||||||
[Migration("20250430163514_InitialMigration")]
|
[Migration("20250501080049_InitialMigration")]
|
||||||
partial class InitialMigration
|
partial class InitialMigration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@ -456,6 +457,54 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.ToTable("account_relationships", (string)null);
|
b.ToTable("account_relationships", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Activity.Activity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<long>("AccountId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
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>("ResourceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("resource_identifier");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("visibility");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_activities");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId")
|
||||||
|
.HasDatabaseName("ix_activities_account_id");
|
||||||
|
|
||||||
|
b.ToTable("activities", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -742,8 +791,8 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Content")
|
b.Property<JsonDocument>("Content")
|
||||||
.HasColumnType("text")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("content");
|
.HasColumnName("content");
|
||||||
|
|
||||||
b.Property<Instant>("CreatedAt")
|
b.Property<Instant>("CreatedAt")
|
||||||
@ -792,6 +841,14 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasColumnName("replied_post_id");
|
.HasColumnName("replied_post_id");
|
||||||
|
|
||||||
|
b.Property<NpgsqlTsVector>("SearchVector")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("tsvector")
|
||||||
|
.HasColumnName("search_vector")
|
||||||
|
.HasAnnotation("Npgsql:TsVectorConfig", "simple")
|
||||||
|
.HasAnnotation("Npgsql:TsVectorProperties", new[] { "Title", "Description", "Content" });
|
||||||
|
|
||||||
b.Property<long?>("ThreadedPostId")
|
b.Property<long?>("ThreadedPostId")
|
||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasColumnName("threaded_post_id");
|
.HasColumnName("threaded_post_id");
|
||||||
@ -837,6 +894,11 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.HasIndex("RepliedPostId")
|
b.HasIndex("RepliedPostId")
|
||||||
.HasDatabaseName("ix_posts_replied_post_id");
|
.HasDatabaseName("ix_posts_replied_post_id");
|
||||||
|
|
||||||
|
b.HasIndex("SearchVector")
|
||||||
|
.HasDatabaseName("ix_posts_search_vector");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
|
||||||
|
|
||||||
b.HasIndex("ThreadedPostId")
|
b.HasIndex("ThreadedPostId")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_posts_threaded_post_id");
|
.HasDatabaseName("ix_posts_threaded_post_id");
|
||||||
@ -1165,6 +1227,10 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("file_meta");
|
.HasColumnName("file_meta");
|
||||||
|
|
||||||
|
b.Property<bool>("HasCompression")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("has_compression");
|
||||||
|
|
||||||
b.Property<string>("Hash")
|
b.Property<string>("Hash")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
@ -1384,6 +1450,18 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.Navigation("Related");
|
b.Navigation("Related");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Activity.Activity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_activities_accounts_account_id");
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
@ -4,6 +4,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using NpgsqlTypes;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@ -162,6 +163,30 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "activities",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
resource_identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||||
|
visibility = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
account_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_activities", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_activities_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "auth_challenges",
|
name: "auth_challenges",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@ -395,6 +420,7 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
uploaded_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
uploaded_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
uploaded_to = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
uploaded_to = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
has_compression = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
used_count = table.Column<int>(type: "integer", nullable: false),
|
used_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
account_id = table.Column<long>(type: "bigint", nullable: false),
|
account_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
post_id = table.Column<long>(type: "bigint", nullable: true),
|
post_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
@ -487,7 +513,7 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
edited_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
edited_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
published_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
published_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
visibility = table.Column<int>(type: "integer", nullable: false),
|
visibility = table.Column<int>(type: "integer", nullable: false),
|
||||||
content = table.Column<string>(type: "text", nullable: true),
|
content = table.Column<JsonDocument>(type: "jsonb", nullable: true),
|
||||||
type = table.Column<int>(type: "integer", nullable: false),
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
||||||
views_unique = table.Column<int>(type: "integer", nullable: false),
|
views_unique = table.Column<int>(type: "integer", nullable: false),
|
||||||
@ -497,6 +523,9 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
threaded_post_id = table.Column<long>(type: "bigint", nullable: true),
|
threaded_post_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
replied_post_id = table.Column<long>(type: "bigint", nullable: true),
|
replied_post_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
forwarded_post_id = table.Column<long>(type: "bigint", nullable: true),
|
forwarded_post_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
search_vector = table.Column<NpgsqlTsVector>(type: "tsvector", nullable: false)
|
||||||
|
.Annotation("Npgsql:TsVectorConfig", "simple")
|
||||||
|
.Annotation("Npgsql:TsVectorProperties", new[] { "title", "description", "content" }),
|
||||||
publisher_id = table.Column<long>(type: "bigint", nullable: false),
|
publisher_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
@ -693,6 +722,11 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
column: "name",
|
column: "name",
|
||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_activities_account_id",
|
||||||
|
table: "activities",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_auth_challenges_account_id",
|
name: "ix_auth_challenges_account_id",
|
||||||
table: "auth_challenges",
|
table: "auth_challenges",
|
||||||
@ -806,6 +840,12 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
table: "posts",
|
table: "posts",
|
||||||
column: "replied_post_id");
|
column: "replied_post_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_posts_search_vector",
|
||||||
|
table: "posts",
|
||||||
|
column: "search_vector")
|
||||||
|
.Annotation("Npgsql:IndexMethod", "GIN");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_posts_threaded_post_id",
|
name: "ix_posts_threaded_post_id",
|
||||||
table: "posts",
|
table: "posts",
|
||||||
@ -891,6 +931,9 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "account_relationships");
|
name: "account_relationships");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "activities");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "auth_sessions");
|
name: "auth_sessions");
|
||||||
|
|
@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
|||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using NpgsqlTypes;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@ -453,6 +454,54 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.ToTable("account_relationships", (string)null);
|
b.ToTable("account_relationships", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Activity.Activity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<long>("AccountId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
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>("ResourceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("resource_identifier");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("visibility");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_activities");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId")
|
||||||
|
.HasDatabaseName("ix_activities_account_id");
|
||||||
|
|
||||||
|
b.ToTable("activities", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -739,8 +788,8 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Content")
|
b.Property<JsonDocument>("Content")
|
||||||
.HasColumnType("text")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("content");
|
.HasColumnName("content");
|
||||||
|
|
||||||
b.Property<Instant>("CreatedAt")
|
b.Property<Instant>("CreatedAt")
|
||||||
@ -789,6 +838,14 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasColumnName("replied_post_id");
|
.HasColumnName("replied_post_id");
|
||||||
|
|
||||||
|
b.Property<NpgsqlTsVector>("SearchVector")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("tsvector")
|
||||||
|
.HasColumnName("search_vector")
|
||||||
|
.HasAnnotation("Npgsql:TsVectorConfig", "simple")
|
||||||
|
.HasAnnotation("Npgsql:TsVectorProperties", new[] { "Title", "Description", "Content" });
|
||||||
|
|
||||||
b.Property<long?>("ThreadedPostId")
|
b.Property<long?>("ThreadedPostId")
|
||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasColumnName("threaded_post_id");
|
.HasColumnName("threaded_post_id");
|
||||||
@ -834,6 +891,11 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.HasIndex("RepliedPostId")
|
b.HasIndex("RepliedPostId")
|
||||||
.HasDatabaseName("ix_posts_replied_post_id");
|
.HasDatabaseName("ix_posts_replied_post_id");
|
||||||
|
|
||||||
|
b.HasIndex("SearchVector")
|
||||||
|
.HasDatabaseName("ix_posts_search_vector");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
|
||||||
|
|
||||||
b.HasIndex("ThreadedPostId")
|
b.HasIndex("ThreadedPostId")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_posts_threaded_post_id");
|
.HasDatabaseName("ix_posts_threaded_post_id");
|
||||||
@ -1162,6 +1224,10 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("file_meta");
|
.HasColumnName("file_meta");
|
||||||
|
|
||||||
|
b.Property<bool>("HasCompression")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("has_compression");
|
||||||
|
|
||||||
b.Property<string>("Hash")
|
b.Property<string>("Hash")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
@ -1381,6 +1447,18 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.Navigation("Related");
|
b.Navigation("Related");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Activity.Activity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_activities_accounts_account_id");
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Sphere.Storage;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using NpgsqlTypes;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Post;
|
namespace DysonNetwork.Sphere.Post;
|
||||||
|
|
||||||
@ -31,8 +33,7 @@ public class Post : ModelBase
|
|||||||
public Instant? PublishedAt { get; set; }
|
public Instant? PublishedAt { get; set; }
|
||||||
public PostVisibility Visibility { get; set; } = PostVisibility.Public;
|
public PostVisibility Visibility { get; set; } = PostVisibility.Public;
|
||||||
|
|
||||||
// ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength
|
[Column(TypeName = "jsonb")] public JsonDocument? Content { get; set; }
|
||||||
public string? Content { get; set; }
|
|
||||||
|
|
||||||
public PostType Type { get; set; }
|
public PostType Type { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
|
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
|
||||||
@ -49,6 +50,8 @@ public class Post : ModelBase
|
|||||||
public long? ForwardedPostId { get; set; }
|
public long? ForwardedPostId { get; set; }
|
||||||
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; }
|
||||||
|
|
||||||
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>();
|
||||||
@ -56,7 +59,7 @@ 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?.Trim() is { Length: 0 } && Attachments.Count == 0 && ForwardedPostId == null;
|
public bool Empty => Content == null && Attachments.Count == 0 && ForwardedPostId == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PostTag : ModelBase
|
public class PostTag : ModelBase
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json;
|
||||||
using Casbin;
|
using Casbin;
|
||||||
|
using DysonNetwork.Sphere.Account;
|
||||||
using DysonNetwork.Sphere.Permission;
|
using DysonNetwork.Sphere.Permission;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@ -9,16 +11,17 @@ namespace DysonNetwork.Sphere.Post;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/posts")]
|
[Route("/posts")]
|
||||||
public class PostController(AppDatabase db, PostService ps) : ControllerBase
|
public class PostController(AppDatabase db, PostService ps, RelationshipService rels) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<List<Post>>> ListPosts([FromQuery] int offset = 0, [FromQuery] int take = 20)
|
public async Task<ActionResult<List<Post>>> ListPosts([FromQuery] int offset = 0, [FromQuery] int take = 20)
|
||||||
{
|
{
|
||||||
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 = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
|
||||||
|
|
||||||
var totalCount = await db.Posts
|
var totalCount = await db.Posts
|
||||||
.FilterWithVisibility(currentUser, isListing: true)
|
.FilterWithVisibility(currentUser, userFriends, isListing: true)
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
var posts = await db.Posts
|
var posts = await db.Posts
|
||||||
.Include(e => e.Publisher)
|
.Include(e => e.Publisher)
|
||||||
@ -30,7 +33,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
|
|||||||
.Include(e => e.Categories)
|
.Include(e => e.Categories)
|
||||||
.Include(e => e.Tags)
|
.Include(e => e.Tags)
|
||||||
.Where(e => e.RepliedPostId == null)
|
.Where(e => e.RepliedPostId == null)
|
||||||
.FilterWithVisibility(currentUser, isListing: true)
|
.FilterWithVisibility(currentUser, userFriends, isListing: true)
|
||||||
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
|
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
@ -46,6 +49,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
|
|||||||
{
|
{
|
||||||
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 = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
|
||||||
|
|
||||||
var post = await db.Posts
|
var post = await db.Posts
|
||||||
.Where(e => e.Id == id)
|
.Where(e => e.Id == id)
|
||||||
@ -58,7 +62,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
|
|||||||
.Include(e => e.Tags)
|
.Include(e => e.Tags)
|
||||||
.Include(e => e.Categories)
|
.Include(e => e.Categories)
|
||||||
.Include(e => e.Attachments)
|
.Include(e => e.Attachments)
|
||||||
.FilterWithVisibility(currentUser)
|
.FilterWithVisibility(currentUser, userFriends)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (post is null) return NotFound();
|
if (post is null) return NotFound();
|
||||||
|
|
||||||
@ -71,6 +75,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
|
|||||||
{
|
{
|
||||||
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 = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
|
||||||
|
|
||||||
var post = await db.Posts
|
var post = await db.Posts
|
||||||
.Where(e => e.Id == id)
|
.Where(e => e.Id == id)
|
||||||
@ -79,7 +84,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
|
|||||||
|
|
||||||
var totalCount = await db.Posts
|
var totalCount = await db.Posts
|
||||||
.Where(e => e.RepliedPostId == post.Id)
|
.Where(e => e.RepliedPostId == post.Id)
|
||||||
.FilterWithVisibility(currentUser, isListing: true)
|
.FilterWithVisibility(currentUser, userFriends, isListing: true)
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
var posts = await db.Posts
|
var posts = await db.Posts
|
||||||
.Where(e => e.RepliedPostId == id)
|
.Where(e => e.RepliedPostId == id)
|
||||||
@ -91,7 +96,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
|
|||||||
.Include(e => e.Attachments)
|
.Include(e => e.Attachments)
|
||||||
.Include(e => e.Categories)
|
.Include(e => e.Categories)
|
||||||
.Include(e => e.Tags)
|
.Include(e => e.Tags)
|
||||||
.FilterWithVisibility(currentUser, isListing: true)
|
.FilterWithVisibility(currentUser, userFriends, isListing: true)
|
||||||
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
|
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
@ -106,7 +111,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
|
|||||||
{
|
{
|
||||||
[MaxLength(1024)] public string? Title { get; set; }
|
[MaxLength(1024)] public string? Title { get; set; }
|
||||||
[MaxLength(4096)] public string? Description { get; set; }
|
[MaxLength(4096)] public string? Description { get; set; }
|
||||||
public string? Content { get; set; }
|
public JsonDocument? Content { get; set; }
|
||||||
public PostVisibility? Visibility { get; set; }
|
public PostVisibility? Visibility { get; set; }
|
||||||
public PostType? Type { get; set; }
|
public PostType? Type { get; set; }
|
||||||
[MaxLength(16)] public List<string>? Tags { get; set; }
|
[MaxLength(16)] public List<string>? Tags { get; set; }
|
||||||
@ -179,6 +184,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
post = await ps.PostAsync(
|
post = await ps.PostAsync(
|
||||||
|
currentUser,
|
||||||
post,
|
post,
|
||||||
attachments: request.Attachments,
|
attachments: request.Attachments,
|
||||||
tags: request.Tags,
|
tags: request.Tags,
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
|
using DysonNetwork.Sphere.Activity;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Sphere.Storage;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Post;
|
namespace DysonNetwork.Sphere.Post;
|
||||||
|
|
||||||
public class PostService(AppDatabase db, FileService fs)
|
public class PostService(AppDatabase db, FileService fs, ActivityService act)
|
||||||
{
|
{
|
||||||
public async Task<Post> PostAsync(
|
public async Task<Post> PostAsync(
|
||||||
|
Account.Account user,
|
||||||
Post post,
|
Post post,
|
||||||
List<string>? attachments = null,
|
List<string>? attachments = null,
|
||||||
List<string>? tags = null,
|
List<string>? tags = null,
|
||||||
@ -66,6 +68,8 @@ public class PostService(AppDatabase db, FileService fs)
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await fs.MarkUsageRangeAsync(post.Attachments, 1);
|
await fs.MarkUsageRangeAsync(post.Attachments, 1);
|
||||||
|
|
||||||
|
await act.CreateNewPostActivity(user, post);
|
||||||
|
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,7 +157,7 @@ public class PostService(AppDatabase db, FileService fs)
|
|||||||
public static class PostQueryExtensions
|
public static class PostQueryExtensions
|
||||||
{
|
{
|
||||||
public static IQueryable<Post> FilterWithVisibility(this IQueryable<Post> source, Account.Account? currentUser,
|
public static IQueryable<Post> FilterWithVisibility(this IQueryable<Post> source, Account.Account? currentUser,
|
||||||
bool isListing = false)
|
List<long> userFriends, bool isListing = false)
|
||||||
{
|
{
|
||||||
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
|
|
||||||
@ -172,6 +176,9 @@ public static class PostQueryExtensions
|
|||||||
|
|
||||||
return source
|
return source
|
||||||
.Where(e => e.PublishedAt != null && now >= e.PublishedAt && e.Publisher.AccountId == currentUser.Id)
|
.Where(e => e.PublishedAt != null && now >= e.PublishedAt && e.Publisher.AccountId == currentUser.Id)
|
||||||
.Where(e => e.Visibility != PostVisibility.Private || e.Publisher.AccountId == currentUser.Id);
|
.Where(e => e.Visibility != PostVisibility.Private || e.Publisher.AccountId == currentUser.Id)
|
||||||
|
.Where(e => e.Visibility != PostVisibility.Friends ||
|
||||||
|
(e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) ||
|
||||||
|
e.Publisher.AccountId == currentUser.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -102,9 +102,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
|||||||
|
|
||||||
var newMember = new PublisherMember
|
var newMember = new PublisherMember
|
||||||
{
|
{
|
||||||
Account = relatedUser,
|
|
||||||
AccountId = relatedUser.Id,
|
AccountId = relatedUser.Id,
|
||||||
Publisher = publisher,
|
|
||||||
PublisherId = publisher.Id,
|
PublisherId = publisher.Id,
|
||||||
Role = request.Role,
|
Role = request.Role,
|
||||||
};
|
};
|
||||||
|
@ -22,13 +22,12 @@ public class PublisherService(AppDatabase db, FileService fs)
|
|||||||
Bio = bio ?? account.Profile.Bio,
|
Bio = bio ?? account.Profile.Bio,
|
||||||
Picture = picture ?? account.Profile.Picture,
|
Picture = picture ?? account.Profile.Picture,
|
||||||
Background = background ?? account.Profile.Background,
|
Background = background ?? account.Profile.Background,
|
||||||
Account = account,
|
AccountId = account.Id,
|
||||||
Members = new List<PublisherMember>
|
Members = new List<PublisherMember>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
AccountId = account.Id,
|
AccountId = account.Id,
|
||||||
Account = account,
|
|
||||||
Role = PublisherMemberRole.Owner,
|
Role = PublisherMemberRole.Owner,
|
||||||
JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
|
JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,9 @@ using Casbin;
|
|||||||
using Casbin.Persist.Adapter.EFCore;
|
using Casbin.Persist.Adapter.EFCore;
|
||||||
using DysonNetwork.Sphere;
|
using DysonNetwork.Sphere;
|
||||||
using DysonNetwork.Sphere.Account;
|
using DysonNetwork.Sphere.Account;
|
||||||
|
using DysonNetwork.Sphere.Activity;
|
||||||
using DysonNetwork.Sphere.Auth;
|
using DysonNetwork.Sphere.Auth;
|
||||||
|
using DysonNetwork.Sphere.Connection;
|
||||||
using DysonNetwork.Sphere.Permission;
|
using DysonNetwork.Sphere.Permission;
|
||||||
using DysonNetwork.Sphere.Post;
|
using DysonNetwork.Sphere.Post;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Sphere.Storage;
|
||||||
@ -114,14 +116,17 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<WebSocketService>();
|
||||||
builder.Services.AddScoped<EmailService>();
|
builder.Services.AddScoped<EmailService>();
|
||||||
builder.Services.AddScoped<PermissionService>();
|
builder.Services.AddScoped<PermissionService>();
|
||||||
builder.Services.AddScoped<AccountService>();
|
builder.Services.AddScoped<AccountService>();
|
||||||
|
builder.Services.AddScoped<RelationshipService>();
|
||||||
builder.Services.AddScoped<MagicSpellService>();
|
builder.Services.AddScoped<MagicSpellService>();
|
||||||
builder.Services.AddScoped<NotificationService>();
|
builder.Services.AddScoped<NotificationService>();
|
||||||
builder.Services.AddScoped<AuthService>();
|
builder.Services.AddScoped<AuthService>();
|
||||||
builder.Services.AddScoped<FileService>();
|
builder.Services.AddScoped<FileService>();
|
||||||
builder.Services.AddScoped<PublisherService>();
|
builder.Services.AddScoped<PublisherService>();
|
||||||
|
builder.Services.AddScoped<ActivityService>();
|
||||||
builder.Services.AddScoped<PostService>();
|
builder.Services.AddScoped<PostService>();
|
||||||
|
|
||||||
// Timed task
|
// Timed task
|
||||||
@ -187,7 +192,7 @@ app.MapRazorPages().RequireRateLimiting("fixed");
|
|||||||
var tusDiskStore = new tusdotnet.Stores.TusDiskStore(
|
var tusDiskStore = new tusdotnet.Stores.TusDiskStore(
|
||||||
builder.Configuration.GetSection("Tus").GetValue<string>("StorePath")!
|
builder.Configuration.GetSection("Tus").GetValue<string>("StorePath")!
|
||||||
);
|
);
|
||||||
app.MapTus("/files/tus", (_) => Task.FromResult<DefaultTusConfiguration>(new()
|
app.MapTus("/files/tus", _ => Task.FromResult<DefaultTusConfiguration>(new()
|
||||||
{
|
{
|
||||||
Store = tusDiskStore,
|
Store = tusDiskStore,
|
||||||
Events = new Events
|
Events = new Events
|
||||||
@ -234,28 +239,12 @@ app.MapTus("/files/tus", (_) => Task.FromResult<DefaultTusConfiguration>(new()
|
|||||||
|
|
||||||
var fileService = eventContext.HttpContext.RequestServices.GetRequiredService<FileService>();
|
var fileService = eventContext.HttpContext.RequestServices.GetRequiredService<FileService>();
|
||||||
|
|
||||||
var info = await fileService.AnalyzeFileAsync(user, file.Id, fileStream, fileName, contentType);
|
var info = await fileService.ProcessNewFileAsync(user, file.Id, fileStream, fileName, contentType);
|
||||||
|
|
||||||
var jsonOptions = httpContext.RequestServices.GetRequiredService<IOptions<JsonOptions>>().Value
|
var jsonOptions = httpContext.RequestServices.GetRequiredService<IOptions<JsonOptions>>().Value
|
||||||
.JsonSerializerOptions;
|
.JsonSerializerOptions;
|
||||||
var infoJson = JsonSerializer.Serialize(info, jsonOptions);
|
var infoJson = JsonSerializer.Serialize(info, jsonOptions);
|
||||||
eventContext.HttpContext.Response.Headers.Append("X-FileInfo", infoJson);
|
eventContext.HttpContext.Response.Headers.Append("X-FileInfo", infoJson);
|
||||||
|
|
||||||
#pragma warning disable CS4014
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
using var scope = eventContext.HttpContext.RequestServices
|
|
||||||
.GetRequiredService<IServiceScopeFactory>()
|
|
||||||
.CreateScope();
|
|
||||||
// Keep the service didn't be disposed
|
|
||||||
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
|
|
||||||
// Keep the file stream opened
|
|
||||||
var fileData = await tusDiskStore.GetFileAsync(file.Id, CancellationToken.None);
|
|
||||||
var newStream = await fileData.GetContentAsync(CancellationToken.None);
|
|
||||||
await fs.UploadFileToRemoteAsync(info, newStream, null);
|
|
||||||
await tusDiskStore.DeleteFileAsync(file.Id, CancellationToken.None);
|
|
||||||
});
|
|
||||||
#pragma warning restore CS4014
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
@ -5,7 +5,7 @@ using NodaTime;
|
|||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage;
|
namespace DysonNetwork.Sphere.Storage;
|
||||||
|
|
||||||
public abstract class RemoteStorageConfig
|
public class RemoteStorageConfig
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = string.Empty;
|
public string Id { get; set; } = string.Empty;
|
||||||
public string Label { get; set; } = string.Empty;
|
public string Label { get; set; } = string.Empty;
|
||||||
@ -34,12 +34,14 @@ public class CloudFile : ModelBase
|
|||||||
public Instant? UploadedAt { get; set; }
|
public Instant? UploadedAt { get; set; }
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
[MaxLength(128)] public string? UploadedTo { get; set; }
|
[MaxLength(128)] public string? UploadedTo { get; set; }
|
||||||
|
public bool HasCompression { get; set; }= false;
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
// When this used count keep zero, it means it's not used by anybody, so it can be recycled
|
// When this used count keep zero, it means it's not used by anybody, so it can be recycled
|
||||||
public int UsedCount { get; set; } = 0;
|
public int UsedCount { get; set; } = 0;
|
||||||
|
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||||
|
public long AccountId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CloudFileSensitiveMark
|
public enum CloudFileSensitiveMark
|
||||||
|
@ -4,6 +4,7 @@ using System.Security.Cryptography;
|
|||||||
using Blurhash.ImageSharp;
|
using Blurhash.ImageSharp;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Minio;
|
using Minio;
|
||||||
|
using Minio.DataModel;
|
||||||
using Minio.DataModel.Args;
|
using Minio.DataModel.Args;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
@ -13,11 +14,13 @@ using ExifTag = SixLabors.ImageSharp.Metadata.Profiles.Exif.ExifTag;
|
|||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage;
|
namespace DysonNetwork.Sphere.Storage;
|
||||||
|
|
||||||
public class FileService(AppDatabase db, IConfiguration configuration)
|
public class FileService(AppDatabase db, IConfiguration configuration, ILogger<FileService> logger, IServiceScopeFactory scopeFactory)
|
||||||
{
|
{
|
||||||
|
private static readonly string TempFilePrefix = "dyn-cloudfile";
|
||||||
|
|
||||||
// The analysis file method no longer will remove the GPS EXIF data
|
// The analysis file method no longer will remove the GPS EXIF data
|
||||||
// It should be handled on the client side, and for some specific cases it should be keep
|
// It should be handled on the client side, and for some specific cases it should be keep
|
||||||
public async Task<CloudFile> AnalyzeFileAsync(
|
public async Task<CloudFile> ProcessNewFileAsync(
|
||||||
Account.Account account,
|
Account.Account account,
|
||||||
string fileId,
|
string fileId,
|
||||||
Stream stream,
|
Stream stream,
|
||||||
@ -25,6 +28,10 @@ public class FileService(AppDatabase db, IConfiguration configuration)
|
|||||||
string? contentType
|
string? contentType
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
// If this variable present a value means the processor modified the uploaded file
|
||||||
|
// Upload this file to the remote instead
|
||||||
|
var modifiedResult = new List<(string filePath, string suffix)>();
|
||||||
|
|
||||||
var fileSize = stream.Length;
|
var fileSize = stream.Length;
|
||||||
var hash = await HashFileAsync(stream, fileSize: fileSize);
|
var hash = await HashFileAsync(stream, fileSize: fileSize);
|
||||||
contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName);
|
contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName);
|
||||||
@ -39,7 +46,7 @@ public class FileService(AppDatabase db, IConfiguration configuration)
|
|||||||
MimeType = contentType,
|
MimeType = contentType,
|
||||||
Size = fileSize,
|
Size = fileSize,
|
||||||
Hash = hash,
|
Hash = hash,
|
||||||
Account = account,
|
AccountId = account.Id
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (contentType.Split('/')[0])
|
switch (contentType.Split('/')[0])
|
||||||
@ -105,6 +112,79 @@ public class FileService(AppDatabase db, IConfiguration configuration)
|
|||||||
|
|
||||||
db.Files.Add(file);
|
db.Files.Add(file);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var nfs = scope.ServiceProvider.GetRequiredService<FileService>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.LogInformation("Processed file {fileId}, now trying optimizing if possible...", fileId);
|
||||||
|
|
||||||
|
if (contentType.Split('/')[0] == "image")
|
||||||
|
{
|
||||||
|
file.MimeType = "image/webp";
|
||||||
|
|
||||||
|
List<Task> tasks = [];
|
||||||
|
|
||||||
|
var ogFilePath = Path.Join(configuration.GetValue<string>("Tus:StorePath"), file.Id);
|
||||||
|
using var imageSharp = await Image.LoadAsync<Rgba32>(ogFilePath);
|
||||||
|
var imagePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
|
||||||
|
tasks.Add(imageSharp.SaveAsWebpAsync(imagePath));
|
||||||
|
modifiedResult.Add((imagePath, string.Empty));
|
||||||
|
|
||||||
|
if (imageSharp.Size.Width * imageSharp.Size.Height >= 1024 * 1024)
|
||||||
|
{
|
||||||
|
var compressedClone = imageSharp.Clone();
|
||||||
|
compressedClone.Mutate(i => i.Resize(new ResizeOptions
|
||||||
|
{
|
||||||
|
Mode = ResizeMode.Max,
|
||||||
|
Size = new Size(1024, 1024),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
var imageCompressedPath =
|
||||||
|
Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}-compressed");
|
||||||
|
tasks.Add(compressedClone.SaveAsWebpAsync(imageCompressedPath));
|
||||||
|
modifiedResult.Add((imageCompressedPath, ".compressed"));
|
||||||
|
file.HasCompression = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Optimized file {fileId}, now uploading...", fileId);
|
||||||
|
|
||||||
|
if (modifiedResult.Count > 0)
|
||||||
|
{
|
||||||
|
List<Task<CloudFile>> tasks = [];
|
||||||
|
tasks.AddRange(modifiedResult.Select(result =>
|
||||||
|
nfs.UploadFileToRemoteAsync(file, result.filePath, null, result.suffix, true)));
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
file = await tasks.First();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
file = await nfs.UploadFileToRemoteAsync(file, stream, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Uploaded file {fileId} done!", fileId);
|
||||||
|
|
||||||
|
var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
await scopedDb.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(setter => setter
|
||||||
|
.SetProperty(f => f.UploadedAt, file.UploadedAt)
|
||||||
|
.SetProperty(f => f.UploadedTo, file.UploadedTo)
|
||||||
|
.SetProperty(f => f.MimeType, file.MimeType)
|
||||||
|
.SetProperty(f => f.HasCompression, file.HasCompression)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception err)
|
||||||
|
{
|
||||||
|
logger.LogError(err, "Failed to process {fileId}", fileId);
|
||||||
|
}
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +221,17 @@ public class FileService(AppDatabase db, IConfiguration configuration)
|
|||||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CloudFile> UploadFileToRemoteAsync(CloudFile file, Stream stream, string? targetRemote)
|
public async Task<CloudFile> UploadFileToRemoteAsync(CloudFile file, string filePath, string? targetRemote,
|
||||||
|
string? suffix = null, bool selfDestruct = false)
|
||||||
|
{
|
||||||
|
var fileStream = File.OpenRead(filePath);
|
||||||
|
var result = await UploadFileToRemoteAsync(file, fileStream, targetRemote, suffix);
|
||||||
|
if (selfDestruct) File.Delete(filePath);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CloudFile> UploadFileToRemoteAsync(CloudFile file, Stream stream, string? targetRemote,
|
||||||
|
string? suffix = null)
|
||||||
{
|
{
|
||||||
if (file.UploadedAt.HasValue) return file;
|
if (file.UploadedAt.HasValue) return file;
|
||||||
|
|
||||||
@ -159,17 +249,13 @@ public class FileService(AppDatabase db, IConfiguration configuration)
|
|||||||
|
|
||||||
await client.PutObjectAsync(new PutObjectArgs()
|
await client.PutObjectAsync(new PutObjectArgs()
|
||||||
.WithBucket(bucket)
|
.WithBucket(bucket)
|
||||||
.WithObject(file.Id)
|
.WithObject(string.IsNullOrWhiteSpace(suffix) ? file.Id : file.Id + suffix)
|
||||||
.WithStreamData(stream)
|
.WithStreamData(stream)
|
||||||
.WithObjectSize(stream.Length)
|
.WithObjectSize(stream.Length)
|
||||||
.WithContentType(contentType)
|
.WithContentType(contentType)
|
||||||
);
|
);
|
||||||
|
|
||||||
file.UploadedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
file.UploadedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(setter => setter
|
|
||||||
.SetProperty(f => f.UploadedAt, file.UploadedAt)
|
|
||||||
.SetProperty(f => f.UploadedTo, file.UploadedTo)
|
|
||||||
);
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,6 +30,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5703920a18f94462b4354fab05326e6519a200_003F35_003F8536fc49_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5703920a18f94462b4354fab05326e6519a200_003F35_003F8536fc49_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKnownResamplers_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003Fb3_003Fcdb3e080_003FKnownResamplers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMailboxAddress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8e03e47c46b7469f97abc40667cbcf9b133000_003Fa6_003F83324248_003FMailboxAddress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMailboxAddress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8e03e47c46b7469f97abc40667cbcf9b133000_003Fa6_003F83324248_003FMailboxAddress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMediaAnalysis_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fd7_003F5c138865_003FMediaAnalysis_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMediaAnalysis_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fd7_003F5c138865_003FMediaAnalysis_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMicrosoftDependencyInjectionJobFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1edbd6e24d7b430fabce72177269baa19200_003Fa8_003F91b091de_003FMicrosoftDependencyInjectionJobFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMicrosoftDependencyInjectionJobFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1edbd6e24d7b430fabce72177269baa19200_003Fa8_003F91b091de_003FMicrosoftDependencyInjectionJobFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
@ -40,6 +41,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fd3_003F7b05b2bd_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fd3_003F7b05b2bd_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APresignedGetObjectArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F0df26a9d89e29319e9efcaea0a8489db9e97bc1aedcca3f7e360cc50f8f4ea_003FPresignedGetObjectArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APresignedGetObjectArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F0df26a9d89e29319e9efcaea0a8489db9e97bc1aedcca3f7e360cc50f8f4ea_003FPresignedGetObjectArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F42d8f09d6a294d00a6f49efc989927492fe00_003F4e_003F26d1ee34_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F42d8f09d6a294d00a6f49efc989927492fe00_003F4e_003F26d1ee34_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResizeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003F48_003F0209e410_003FResizeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASecuritySchemeType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F29898ce74e3763a786ac1bd9a6db2152e1af75769440b1e53b9cbdf1dda1bd99_003FSecuritySchemeType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASecuritySchemeType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F29898ce74e3763a786ac1bd9a6db2152e1af75769440b1e53b9cbdf1dda1bd99_003FSecuritySchemeType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceCollectionContainerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc0e30e11d8f5456cb7a11b21ebee6c5a35c00_003F60_003F78b485f5_003FServiceCollectionContainerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceCollectionContainerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc0e30e11d8f5456cb7a11b21ebee6c5a35c00_003F60_003F78b485f5_003FServiceCollectionContainerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASetPropertyCalls_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F458b5f22476b4599b87176214d5e4026c2327b148f4d3f885ee92362b4dac3_003FSetPropertyCalls_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASetPropertyCalls_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F458b5f22476b4599b87176214d5e4026c2327b148f4d3f885ee92362b4dac3_003FSetPropertyCalls_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user