Compare commits

...

5 Commits

Author SHA1 Message Date
82288fa52c 🐛 Fix the permission group member uses the singular form as the name 2025-04-28 00:15:39 +08:00
e8c3219ef0 🗃️ Set up permission nodes db models 2025-04-28 00:13:18 +08:00
d343ac5fb8 🧱 Setup for the websocket 2025-04-27 23:56:57 +08:00
bd7e589681 Notification APIs 2025-04-27 23:52:41 +08:00
cb7179aa27 Notification service 2025-04-27 23:44:22 +08:00
20 changed files with 4147 additions and 9 deletions

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Permission;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@ -23,6 +24,8 @@ public class Account : ModelBase
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>(); [JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>(); [JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<PermissionGroupMember> GroupMemberships { get; set; } = new List<PermissionGroupMember>();
} }
public class Profile : ModelBase public class Profile : ModelBase

View File

@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class Notification : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Topic { get; set; } = null!;
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(2048)] public string? Subtitle { get; set; }
[MaxLength(4096)] public string? Content { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
public int Priority { get; set; } = 10;
public Instant? ViewedAt { get; set; }
public long AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}
public enum NotificationPushProvider
{
Apple,
Google
}
[Index(nameof(DeviceId), IsUnique = true)]
[Index(nameof(DeviceToken), IsUnique = true)]
public class NotificationPushSubscription : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string DeviceId { get; set; } = null!;
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public NotificationPushProvider Provider { get; set; }
public Instant? LastUsedAt { get; set; }
public long AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}

View File

@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Post;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/notifications")]
public class NotificationController(AppDatabase db, NotificationService nty) : ControllerBase
{
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Notification>>> ListNotifications([FromQuery] int offset = 0,
[FromQuery] int take = 20)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var totalCount = await db.Notifications
.Where(s => s.AccountId == currentUser.Id)
.CountAsync();
var notifications = await db.Notifications
.Where(s => s.AccountId == currentUser.Id)
.OrderByDescending(e => e.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(notifications);
}
public class PushNotificationSubscribeRequest
{
[MaxLength(4096)] public string DeviceId { get; set; } = null!;
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public NotificationPushProvider Provider { get; set; }
}
[HttpPut("subscription")]
[Authorize]
public async Task<ActionResult<NotificationPushSubscription>> SubscribeToPushNotification(
[FromBody] PushNotificationSubscribeRequest request
)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var result =
await nty.SubscribePushNotification(currentUser, request.Provider, request.DeviceId, request.DeviceToken);
return Ok(result);
}
}

View File

@ -0,0 +1,184 @@
using CorePush.Apple;
using CorePush.Firebase;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class NotificationService
{
private readonly AppDatabase _db;
private readonly ILogger<NotificationService> _logger;
private readonly FirebaseSender? _fcm;
private readonly ApnSender? _apns;
public NotificationService(
AppDatabase db,
IConfiguration cfg,
IHttpClientFactory clientFactory,
ILogger<NotificationService> logger
)
{
_db = db;
_logger = logger;
var cfgSection = cfg.GetSection("Notifications:Push");
// Set up the firebase push notification
var fcmConfig = cfgSection.GetValue<string>("Google");
if (fcmConfig != null)
_fcm = new FirebaseSender(File.ReadAllText(fcmConfig), clientFactory.CreateClient());
// Set up the apple push notification service
var apnsCert = cfgSection.GetValue<string>("Apple:PrivateKey");
if (apnsCert != null)
_apns = new ApnSender(new ApnSettings
{
P8PrivateKey = File.ReadAllText(apnsCert),
P8PrivateKeyId = cfgSection.GetValue<string>("Apple:PrivateKeyId"),
TeamId = cfgSection.GetValue<string>("Apple:TeamId"),
AppBundleIdentifier = cfgSection.GetValue<string>("Apple:BundleIdentifier"),
ServerType = cfgSection.GetValue<bool>("Production")
? ApnServerType.Production
: ApnServerType.Development
}, clientFactory.CreateClient());
}
// TODO remove all push notification with this device id when this device is logged out
public async Task<NotificationPushSubscription> SubscribePushNotification(
Account account,
NotificationPushProvider provider,
string deviceId,
string deviceToken
)
{
var existingSubscription = await _db.NotificationPushSubscriptions
.Where(s => s.AccountId == account.Id)
.Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
.FirstOrDefaultAsync();
if (existingSubscription != null)
{
// Reset these audit fields to renew the lifecycle of this device token
existingSubscription.CreatedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
existingSubscription.UpdatedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
_db.Update(existingSubscription);
await _db.SaveChangesAsync();
return existingSubscription;
}
var subscription = new NotificationPushSubscription
{
DeviceId = deviceId,
DeviceToken = deviceToken,
Provider = provider,
Account = account,
AccountId = account.Id,
};
_db.Add(subscription);
await _db.SaveChangesAsync();
return subscription;
}
public async Task<Notification> SendNotification(
Account account,
string? title = null,
string? subtitle = null,
string? content = null,
Dictionary<string, object>? meta = null,
bool isSilent = false
)
{
if (title is null && subtitle is null && content is null)
{
throw new ArgumentException("Unable to send notification that completely empty.");
}
var notification = new Notification
{
Title = title,
Subtitle = subtitle,
Content = content,
Meta = meta,
Account = account,
AccountId = account.Id,
};
_db.Add(notification);
await _db.SaveChangesAsync();
#pragma warning disable CS4014
if (!isSilent) DeliveryNotification(notification);
#pragma warning restore CS4014
return notification;
}
public async Task DeliveryNotification(Notification notification)
{
// TODO send websocket
// Pushing the notification
var subscribers = await _db.NotificationPushSubscriptions
.Where(s => s.AccountId == notification.AccountId)
.ToListAsync();
var tasks = new List<Task>();
foreach (var subscriber in subscribers)
{
tasks.Add(_PushSingleNotification(notification, subscriber));
}
await Task.WhenAll(tasks);
}
private async Task _PushSingleNotification(Notification notification, NotificationPushSubscription subscription)
{
switch (subscription.Provider)
{
case NotificationPushProvider.Google:
if (_fcm == null)
throw new InvalidOperationException("The firebase cloud messaging is not initialized.");
await _fcm.SendAsync(new
{
message = new
{
token = subscription.DeviceToken,
notification = new
{
title = notification.Title,
body = string.Join("\n", notification.Subtitle, notification.Content),
},
data = notification.Meta
}
});
break;
case NotificationPushProvider.Apple:
if (_apns == null)
throw new InvalidOperationException("The apple notification push service is not initialized.");
await _apns.SendAsync(new
{
apns = new
{
alert = new
{
title = notification.Title,
subtitle = notification.Subtitle,
content = notification.Content,
}
},
meta = notification.Meta,
},
deviceToken: subscription.DeviceToken,
apnsId: notification.Id.ToString(),
apnsPriority: notification.Priority,
apnPushType: ApnPushType.Alert
);
break;
default:
throw new InvalidOperationException($"Provider not supported: {subscription.Provider}");
}
}
}

View File

@ -19,6 +19,8 @@ public class AppDatabase(
IConfiguration configuration IConfiguration configuration
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<Permission.PermissionNode> PermissionNodes { get; set; } = null!;
public DbSet<Permission.PermissionGroup> PermissionGroups { get; set; } = null!;
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; }
@ -26,6 +28,8 @@ public class AppDatabase(
public DbSet<Account.Relationship> AccountRelationships { get; set; } public DbSet<Account.Relationship> AccountRelationships { get; set; }
public DbSet<Auth.Session> AuthSessions { get; set; } public DbSet<Auth.Session> AuthSessions { get; set; }
public DbSet<Auth.Challenge> AuthChallenges { get; set; } public DbSet<Auth.Challenge> AuthChallenges { get; set; }
public DbSet<Account.Notification> Notifications { get; set; }
public DbSet<Account.NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Storage.CloudFile> Files { get; set; } public DbSet<Storage.CloudFile> Files { 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; }
@ -54,6 +58,20 @@ public class AppDatabase(
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Permission.PermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.AccountId })
.HasName("permission_group_members");
modelBuilder.Entity<Permission.PermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Permission.PermissionGroupMember>()
.HasOne(pg => pg.Account)
.WithMany(a => a.GroupMemberships)
.HasForeignKey(pg => pg.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Account.Account>() modelBuilder.Entity<Account.Account>()
.HasOne(a => a.Profile) .HasOne(a => a.Profile)
.WithOne(p => p.Account) .WithOne(p => p.Account)

View File

@ -29,6 +29,7 @@ public class Challenge : ModelBase
[MaxLength(256)] public string? DeviceId { get; set; } [MaxLength(256)] public string? DeviceId { get; set; }
[MaxLength(1024)] public string? Nonce { get; set; } [MaxLength(1024)] public string? Nonce { get; set; }
public long AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Account.Account Account { get; set; } = null!;
public Challenge Normalize() public Challenge Normalize()

View File

@ -0,0 +1,50 @@
using System.Net.WebSockets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Connection;
[ApiController]
[Route("/ws")]
public class WebSocketController : ControllerBase
{
[Route("/ws")]
[Authorize]
public async Task TheGateway()
{
if (HttpContext.WebSockets.IsWebSocketRequest)
{
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
await _ConnectionEventLoop(webSocket);
}
else
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
}
}
private static async Task _ConnectionEventLoop(WebSocket webSocket)
{
// For now, it's echo
var buffer = new byte[1024 * 4];
var receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), CancellationToken.None);
while (!receiveResult.CloseStatus.HasValue)
{
await webSocket.SendAsync(
new ArraySegment<byte>(buffer, 0, receiveResult.Count),
receiveResult.MessageType,
receiveResult.EndOfMessage,
CancellationToken.None);
receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), CancellationToken.None);
}
await webSocket.CloseAsync(
receiveResult.CloseStatus.Value,
receiveResult.CloseStatusDescription,
CancellationToken.None);
}
}

View File

@ -13,6 +13,7 @@
<PackageReference Include="Blurhash.ImageSharp" Version="4.0.0" /> <PackageReference Include="Blurhash.ImageSharp" Version="4.0.0" />
<PackageReference Include="Casbin.NET" Version="2.12.0" /> <PackageReference Include="Casbin.NET" Version="2.12.0" />
<PackageReference Include="Casbin.NET.Adapter.EFCore" Version="2.5.0" /> <PackageReference Include="Casbin.NET.Adapter.EFCore" Version="2.5.0" />
<PackageReference Include="CorePush" Version="4.3.0" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="FFMpegCore" Version="5.2.0" /> <PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddNotification : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "picture_id",
table: "publishers",
type: "character varying(128)",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "background_id",
table: "publishers",
type: "character varying(128)",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "id",
table: "files",
type: "character varying(128)",
maxLength: 128,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AddColumn<Instant>(
name: "expired_at",
table: "files",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "picture_id",
table: "account_profiles",
type: "character varying(128)",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "background_id",
table: "account_profiles",
type: "character varying(128)",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.CreateTable(
name: "notification_push_subscriptions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
device_id = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
device_token = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
provider = table.Column<int>(type: "integer", nullable: false),
last_used_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
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_notification_push_subscriptions", x => x.id);
table.ForeignKey(
name: "fk_notification_push_subscriptions_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "notifications",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
title = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
subtitle = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
content = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
priority = table.Column<int>(type: "integer", nullable: false),
viewed_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
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_notifications", x => x.id);
table.ForeignKey(
name: "fk_notifications_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_notification_push_subscriptions_account_id",
table: "notification_push_subscriptions",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_notification_push_subscriptions_device_id",
table: "notification_push_subscriptions",
column: "device_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_notification_push_subscriptions_device_token",
table: "notification_push_subscriptions",
column: "device_token",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_notifications_account_id",
table: "notifications",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "notification_push_subscriptions");
migrationBuilder.DropTable(
name: "notifications");
migrationBuilder.DropColumn(
name: "expired_at",
table: "files");
migrationBuilder.AlterColumn<string>(
name: "picture_id",
table: "publishers",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "background_id",
table: "publishers",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "id",
table: "files",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldMaxLength: 128);
migrationBuilder.AlterColumn<string>(
name: "picture_id",
table: "account_profiles",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "background_id",
table: "account_profiles",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldNullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,137 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddPermission : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "topic",
table: "notifications",
type: "character varying(1024)",
maxLength: 1024,
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "permission_groups",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
key = table.Column<string>(type: "character varying(1024)", maxLength: 1024, 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_permission_groups", x => x.id);
});
migrationBuilder.CreateTable(
name: "permission_group_member",
columns: table => new
{
group_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<long>(type: "bigint", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
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_permission_group_member", x => new { x.group_id, x.account_id });
table.ForeignKey(
name: "fk_permission_group_member_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_permission_group_member_permission_groups_group_id",
column: x => x.group_id,
principalTable: "permission_groups",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "permission_nodes",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
actor = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
area = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
key = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
value = table.Column<object>(type: "jsonb", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
group_id = table.Column<Guid>(type: "uuid", nullable: true),
permission_group_id = table.Column<Guid>(type: "uuid", nullable: true),
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_permission_nodes", x => x.id);
table.ForeignKey(
name: "fk_permission_nodes_permission_groups_permission_group_id",
column: x => x.permission_group_id,
principalTable: "permission_groups",
principalColumn: "id");
table.ForeignKey(
name: "fk_permission_nodes_permission_nodes_group_id",
column: x => x.group_id,
principalTable: "permission_nodes",
principalColumn: "id");
});
migrationBuilder.CreateIndex(
name: "ix_permission_group_member_account_id",
table: "permission_group_member",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_permission_nodes_group_id",
table: "permission_nodes",
column: "group_id");
migrationBuilder.CreateIndex(
name: "ix_permission_nodes_key_area_actor",
table: "permission_nodes",
columns: new[] { "key", "area", "actor" });
migrationBuilder.CreateIndex(
name: "ix_permission_nodes_permission_group_id",
table: "permission_nodes",
column: "permission_group_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "permission_group_member");
migrationBuilder.DropTable(
name: "permission_nodes");
migrationBuilder.DropTable(
name: "permission_groups");
migrationBuilder.DropColumn(
name: "topic",
table: "notifications");
}
}
}

View File

@ -167,6 +167,131 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("account_contacts", (string)null); b.ToTable("account_contacts", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Account.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long>("AccountId")
.HasColumnType("bigint")
.HasColumnName("account_id");
b.Property<string>("Content")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("content");
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<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("Subtitle")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("subtitle");
b.Property<string>("Title")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("title");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("topic");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Instant?>("ViewedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("viewed_at");
b.HasKey("Id")
.HasName("pk_notifications");
b.HasIndex("AccountId")
.HasDatabaseName("ix_notifications_account_id");
b.ToTable("notifications", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Account.NotificationPushSubscription", 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>("DeviceId")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("device_id");
b.Property<string>("DeviceToken")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("device_token");
b.Property<Instant?>("LastUsedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_used_at");
b.Property<int>("Provider")
.HasColumnType("integer")
.HasColumnName("provider");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_notification_push_subscriptions");
b.HasIndex("AccountId")
.HasDatabaseName("ix_notification_push_subscriptions_account_id");
b.HasIndex("DeviceId")
.IsUnique()
.HasDatabaseName("ix_notification_push_subscriptions_device_id");
b.HasIndex("DeviceToken")
.IsUnique()
.HasDatabaseName("ix_notification_push_subscriptions_device_token");
b.ToTable("notification_push_subscriptions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -174,7 +299,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnName("id"); .HasColumnName("id");
b.Property<string>("BackgroundId") b.Property<string>("BackgroundId")
.HasColumnType("text") .HasColumnType("character varying(128)")
.HasColumnName("background_id"); .HasColumnName("background_id");
b.Property<string>("Bio") b.Property<string>("Bio")
@ -206,7 +331,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnName("middle_name"); .HasColumnName("middle_name");
b.Property<string>("PictureId") b.Property<string>("PictureId")
.HasColumnType("text") .HasColumnType("character varying(128)")
.HasColumnName("picture_id"); .HasColumnName("picture_id");
b.Property<Instant>("UpdatedAt") b.Property<Instant>("UpdatedAt")
@ -390,6 +515,149 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("auth_sessions", (string)null); b.ToTable("auth_sessions", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("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>("Key")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("key");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_permission_groups");
b.ToTable("permission_groups", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroupMember", b =>
{
b.Property<Guid>("GroupId")
.HasColumnType("uuid")
.HasColumnName("group_id");
b.Property<long>("AccountId")
.HasColumnType("bigint")
.HasColumnName("account_id");
b.Property<Instant?>("AffectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("affected_at");
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<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("GroupId", "AccountId")
.HasName("pk_permission_group_member");
b.HasIndex("AccountId")
.HasDatabaseName("ix_permission_group_member_account_id");
b.ToTable("permission_group_member", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("actor");
b.Property<Instant?>("AffectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("affected_at");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("area");
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<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Guid?>("GroupId")
.HasColumnType("uuid")
.HasColumnName("group_id");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("key");
b.Property<Guid?>("PermissionGroupId")
.HasColumnType("uuid")
.HasColumnName("permission_group_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<object>("Value")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_permission_nodes");
b.HasIndex("GroupId")
.HasDatabaseName("ix_permission_nodes_group_id");
b.HasIndex("PermissionGroupId")
.HasDatabaseName("ix_permission_nodes_permission_group_id");
b.HasIndex("Key", "Area", "Actor")
.HasDatabaseName("ix_permission_nodes_key_area_actor");
b.ToTable("permission_nodes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -692,7 +960,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnName("account_id"); .HasColumnName("account_id");
b.Property<string>("BackgroundId") b.Property<string>("BackgroundId")
.HasColumnType("text") .HasColumnType("character varying(128)")
.HasColumnName("background_id"); .HasColumnName("background_id");
b.Property<string>("Bio") b.Property<string>("Bio")
@ -721,7 +989,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnName("nick"); .HasColumnName("nick");
b.Property<string>("PictureId") b.Property<string>("PictureId")
.HasColumnType("text") .HasColumnType("character varying(128)")
.HasColumnName("picture_id"); .HasColumnName("picture_id");
b.Property<int>("PublisherType") b.Property<int>("PublisherType")
@ -793,7 +1061,8 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Storage.CloudFile", b => modelBuilder.Entity("DysonNetwork.Sphere.Storage.CloudFile", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("text") .HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("id"); .HasColumnName("id");
b.Property<long>("AccountId") b.Property<long>("AccountId")
@ -813,6 +1082,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
.HasColumnName("description"); .HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Dictionary<string, object>>("FileMeta") b.Property<Dictionary<string, object>>("FileMeta")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("file_meta"); .HasColumnName("file_meta");
@ -955,6 +1228,30 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Account"); b.Navigation("Account");
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Account.Notification", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifications_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Sphere.Account.NotificationPushSubscription", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notification_push_subscriptions_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Background") b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Background")
@ -1035,6 +1332,42 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Challenge"); b.Navigation("Challenge");
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroupMember", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany("GroupMemberships")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_permission_group_member_accounts_account_id");
b.HasOne("DysonNetwork.Sphere.Permission.PermissionGroup", "Group")
.WithMany("Members")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_permission_group_member_permission_groups_group_id");
b.Navigation("Account");
b.Navigation("Group");
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionNode", b =>
{
b.HasOne("DysonNetwork.Sphere.Permission.PermissionNode", "Group")
.WithMany()
.HasForeignKey("GroupId")
.HasConstraintName("fk_permission_nodes_permission_nodes_group_id");
b.HasOne("DysonNetwork.Sphere.Permission.PermissionGroup", null)
.WithMany("Nodes")
.HasForeignKey("PermissionGroupId")
.HasConstraintName("fk_permission_nodes_permission_groups_permission_group_id");
b.Navigation("Group");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Post.Post", "ForwardedPost") b.HasOne("DysonNetwork.Sphere.Post.Post", "ForwardedPost")
@ -1224,6 +1557,8 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Contacts"); b.Navigation("Contacts");
b.Navigation("GroupMemberships");
b.Navigation("IncomingRelationships"); b.Navigation("IncomingRelationships");
b.Navigation("OutgoingRelationships"); b.Navigation("OutgoingRelationships");
@ -1234,6 +1569,13 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Sessions"); b.Navigation("Sessions");
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
{
b.Navigation("Members");
b.Navigation("Nodes");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
{ {
b.Navigation("Attachments"); b.Navigation("Attachments");

View File

@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using NodaTime;
namespace DysonNetwork.Sphere.Permission;
/// The permission node model provides the infrastructure of permission control in Dyson Network.
/// It based on the ABAC permission model.
///
/// The value can be any type, boolean and number for most cases and stored in jsonb.
///
/// The area represents the region this permission affects. For example, the pub:&lt;publisherId&gt;
/// indicates it's a permission node for the publishers managing.
///
/// And the actor shows who owns the permission, in most cases, the user:&lt;userId&gt;
/// and when the permission node has a GroupId, the actor will be set to the group, but it won't work on checking
/// expect the member of that permission group inherent the permission from the group.
[Index(nameof(Key), nameof(Area), nameof(Actor))]
public class PermissionNode : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Actor { get; set; } = null!;
[MaxLength(1024)] public string Area { get; set; } = null!;
[MaxLength(1024)] public string Key { get; set; } = null!;
[Column(TypeName = "jsonb")] public object Value { get; set; } = null!;
public Instant? ExpiredAt { get; set; } = null;
public Instant? AffectedAt { get; set; } = null;
public Guid? GroupId { get; set; } = null;
[JsonIgnore] public PermissionNode? Group { get; set; } = null;
}
public class PermissionGroup : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Key { get; set; } = null!;
public ICollection<PermissionNode> Nodes { get; set; } = new List<PermissionNode>();
[JsonIgnore] public ICollection<PermissionGroupMember> Members { get; set; } = new List<PermissionGroupMember>();
}
public class PermissionGroupMember : ModelBase
{
public Guid GroupId { get; set; }
public long AccountId { get; set; }
public PermissionGroup Group { get; set; } = null!;
public Account.Account Account { get; set; } = null!;
public Instant? ExpiredAt { get; set; } = null;
public Instant? AffectedAt { get; set; } = null;
}

View File

@ -17,6 +17,7 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer)
var currentUser = currentUserValue as Account.Account; var currentUser = currentUserValue as Account.Account;
var totalCount = await db.Posts var totalCount = await db.Posts
.FilterWithVisibility(currentUser, isListing: true)
.CountAsync(); .CountAsync();
var posts = await db.Posts var posts = await db.Posts
.Include(e => e.Publisher) .Include(e => e.Publisher)
@ -27,6 +28,7 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer)
.Include(e => e.Attachments) .Include(e => e.Attachments)
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.Tags) .Include(e => e.Tags)
.Where(e => e.RepliedPostId == null)
.FilterWithVisibility(currentUser, isListing: true) .FilterWithVisibility(currentUser, isListing: true)
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt) .OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
.Skip(offset) .Skip(offset)
@ -76,6 +78,7 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer)
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)
.CountAsync(); .CountAsync();
var posts = await db.Posts var posts = await db.Posts
.Where(e => e.RepliedPostId == id) .Where(e => e.RepliedPostId == id)
@ -110,6 +113,8 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer)
[MaxLength(32)] public List<string>? Attachments { get; set; } [MaxLength(32)] public List<string>? Attachments { get; set; }
public Dictionary<string, object>? Meta { get; set; } public Dictionary<string, object>? Meta { get; set; }
public Instant? PublishedAt { get; set; } public Instant? PublishedAt { get; set; }
public long? RepliedPostId { get; set; }
public long? ForwardedPostId { get; set; }
} }
[HttpPost] [HttpPost]
@ -155,6 +160,22 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer)
Publisher = publisher, Publisher = publisher,
}; };
if (request.RepliedPostId is not null)
{
var repliedPost = await db.Posts.FindAsync(request.RepliedPostId.Value);
if (repliedPost is null) return BadRequest("Post replying to was not found.");
post.RepliedPost = repliedPost;
post.RepliedPostId = repliedPost.Id;
}
if (request.ForwardedPostId is not null)
{
var forwardedPost = await db.Posts.FindAsync(request.ForwardedPostId.Value);
if (forwardedPost is null) return BadRequest("Forwarded post was not found.");
post.ForwardedPost = forwardedPost;
post.ForwardedPostId = forwardedPost.Id;
}
try try
{ {
post = await ps.PostAsync( post = await ps.PostAsync(
@ -226,6 +247,7 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer)
var post = await db.Posts var post = await db.Posts
.Where(e => e.Id == id) .Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.Attachments) .Include(e => e.Attachments)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post is null) return NotFound(); if (post is null) return NotFound();

View File

@ -130,6 +130,7 @@ builder.Services.AddSwaggerGen(options =>
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddScoped<AccountService>(); builder.Services.AddScoped<AccountService>();
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>();

View File

@ -5,7 +5,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Storage; namespace DysonNetwork.Sphere.Storage;
public class RemoteStorageConfig public abstract 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;
@ -22,7 +22,7 @@ public class RemoteStorageConfig
public class CloudFile : ModelBase public class CloudFile : ModelBase
{ {
public string Id { get; set; } = Guid.NewGuid().ToString(); [MaxLength(128)] public string Id { get; set; } = Guid.NewGuid().ToString();
[MaxLength(1024)] public string Name { get; set; } = string.Empty; [MaxLength(1024)] public string Name { get; set; } = string.Empty;
[MaxLength(4096)] public string? Description { get; set; } [MaxLength(4096)] public string? Description { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? FileMeta { get; set; } = null!; [Column(TypeName = "jsonb")] public Dictionary<string, object>? FileMeta { get; set; } = null!;
@ -32,6 +32,7 @@ public class CloudFile : ModelBase
[MaxLength(256)] public string? Hash { get; set; } [MaxLength(256)] public string? Hash { get; set; }
public long Size { get; set; } public long Size { get; set; }
public Instant? UploadedAt { get; set; } public Instant? UploadedAt { get; set; }
public Instant? ExpiredAt { get; set; }
[MaxLength(128)] public string? UploadedTo { get; set; } [MaxLength(128)] public string? UploadedTo { get; set; }
// Metrics // Metrics

View File

@ -249,9 +249,12 @@ public class CloudFileUnusedRecyclingJob(AppDatabase db, FileService fs, ILogger
logger.LogInformation("Deleting unused cloud files..."); logger.LogInformation("Deleting unused cloud files...");
var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1); var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1);
var now = SystemClock.Instance.GetCurrentInstant();
var files = db.Files var files = db.Files
.Where(f => f.UsedCount == 0) .Where(f =>
.Where(f => f.CreatedAt < cutoff) (f.ExpiredAt == null && f.UsedCount == 0 && f.CreatedAt < cutoff) ||
(f.ExpiredAt != null && f.ExpiredAt >= now)
)
.ToList(); .ToList();
logger.LogInformation($"Deleting {files.Count} unused cloud files..."); logger.LogInformation($"Deleting {files.Count} unused cloud files...");

View File

@ -49,5 +49,17 @@
"Provider": "recaptcha", "Provider": "recaptcha",
"ApiKey": "6LfIzSArAAAAAN413MtycDcPlKa636knBSAhbzj-", "ApiKey": "6LfIzSArAAAAAN413MtycDcPlKa636knBSAhbzj-",
"ApiSecret": "" "ApiSecret": ""
},
"Notifications": {
"Push": {
"Production": true,
"Google": "path/to/config.json",
"Apple": {
"PrivateKey": "path/to/cert.p8",
"PrivateKeyId": "",
"TeamId": "",
"BundleIdentifier": ""
}
}
} }
} }

View File

@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSender_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003Fc5_003F2a1973a9_003FApnSender_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe49de78932194d52a02b07486c6d023a24600_003F2f_003F7ab1cc57_003FAuthenticationMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe49de78932194d52a02b07486c6d023a24600_003F2f_003F7ab1cc57_003FAuthenticationMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthorizationAppBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2ff26593f91746d7a53418a46dc419d1f200_003F4b_003F56550da2_003FAuthorizationAppBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthorizationAppBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2ff26593f91746d7a53418a46dc419d1f200_003F4b_003F56550da2_003FAuthorizationAppBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@ -22,6 +23,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F8c_003F9f6e3f4f_003FFileResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F8c_003F9f6e3f4f_003FFileResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AForwardedHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcfe5737f9bb84738979cbfedd11822a8ea00_003F50_003F9a335f87_003FForwardedHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AForwardedHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcfe5737f9bb84738979cbfedd11822a8ea00_003F50_003F9a335f87_003FForwardedHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AImageFile_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa932cb9090ed48088111ae919dcdd9021ba00_003F71_003F0a804432_003FImageFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AImageFile_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa932cb9090ed48088111ae919dcdd9021ba00_003F71_003F0a804432_003FImageFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIndexAttribute_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe38f14ac86274ebb9b366729231d1c1a8838_003F8b_003F2890293d_003FIndexAttribute_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIntentType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fbf_003Ffcb84131_003FIntentType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIntentType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fbf_003Ffcb84131_003FIntentType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIServiceCollectionQuartzConfigurator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1edbd6e24d7b430fabce72177269baa19200_003F67_003Faee36f5b_003FIServiceCollectionQuartzConfigurator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIServiceCollectionQuartzConfigurator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1edbd6e24d7b430fabce72177269baa19200_003F67_003Faee36f5b_003FIServiceCollectionQuartzConfigurator_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_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>