Reorganize root folder structure: Remove apps layer layer

This commit is contained in:
Халимов Рустам
2026-03-19 22:22:45 +03:00
parent fb252f9d87
commit 11ebbc853b
296 changed files with 139 additions and 139 deletions

View File

@@ -0,0 +1,81 @@
using System.Text.Json;
using Knot.Shared.Infrastructure.Persistence;
using Knot.Shared.Infrastructure.Persistence.Entities;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Knot.Shared.Infrastructure.Configuration;
public class SettingsService : ISettingsService
{
private readonly IServiceProvider _serviceProvider;
private readonly IEncryptionService _encryptionService;
private SystemSettingsDto _current;
public SettingsService(IServiceProvider serviceProvider, IEncryptionService encryptionService)
{
_serviceProvider = serviceProvider;
_encryptionService = encryptionService;
_current = new SystemSettingsDto(); // Default
}
public SystemSettingsDto Current => _current;
public async Task<SystemSettingsDto> GetSettingsAsync(CancellationToken cancellationToken = default)
{
using var scope = _serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SystemDbContext>();
var setting = await db.Settings.FirstOrDefaultAsync(s => s.Key == "Global", cancellationToken);
if (setting == null)
{
return new SystemSettingsDto();
}
try
{
var json = _encryptionService.DecryptMessage(setting.EncryptedValue);
var dto = JsonSerializer.Deserialize<SystemSettingsDto>(json);
if (dto != null)
{
_current = dto;
return dto;
}
}
catch
{
// If decryption fails or JSON is invalid, return default
}
return new SystemSettingsDto();
}
public async Task UpdateSettingsAsync(SystemSettingsDto settings, CancellationToken cancellationToken = default)
{
using var scope = _serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SystemDbContext>();
var json = JsonSerializer.Serialize(settings);
var encrypted = _encryptionService.EncryptMessage(json);
var setting = await db.Settings.FirstOrDefaultAsync(s => s.Key == "Global", cancellationToken);
if (setting == null)
{
db.Settings.Add(new SystemSetting { Key = "Global", EncryptedValue = encrypted });
}
else
{
setting.EncryptedValue = encrypted;
}
await db.SaveChangesAsync(cancellationToken);
_current = settings; // Update in-memory reference
}
public void Initialize()
{
_current = GetSettingsAsync().GetAwaiter().GetResult();
}
}

View File

@@ -0,0 +1,64 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
using Knot.Shared.Kernel.Storage;
using Knot.Shared.Infrastructure.Persistence;
using Knot.Shared.Infrastructure.Configuration;
using Knot.Shared.Infrastructure.Statistics;
using Knot.Shared.Kernel.Configuration;
using Microsoft.EntityFrameworkCore;
using Minio;
using System;
namespace Knot.Shared.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddSharedInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpContextAccessor();
services.AddScoped<IUserContext, UserContext>();
// Настройка шифрования
var masterKey = configuration["KNOT_MASTER_ENCRYPTION_KEY"]
?? throw new ArgumentNullException("KNOT_MASTER_ENCRYPTION_KEY is missing in env.");
services.AddSingleton<IEncryptionService>(new AesEncryptionService(masterKey));
// Настройка MinIO (S3)
var s3Endpoint = configuration["S3_ENDPOINT"] ?? "minio:9000";
var s3AccessKey = configuration["S3_ACCESS_KEY"] ?? "admin";
var s3SecretKey = configuration["S3_SECRET_KEY"] ?? "KnotSuperSecretMinioPassword";
var s3Bucket = configuration["S3_BUCKET"] ?? "knot-uploads";
services.AddMinio(configureClient => configureClient
.WithEndpoint(s3Endpoint)
.WithCredentials(s3AccessKey, s3SecretKey)
.WithSSL(false)
.Build());
services.AddScoped<IFileStorageService>(provider =>
{
var minioClient = provider.GetRequiredService<IMinioClient>();
var encService = provider.GetRequiredService<IEncryptionService>();
return new S3FileStorageService(minioClient, encService, s3Bucket);
});
// Настройка System Database
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? configuration["DATABASE_URL"]
?? throw new ArgumentNullException("Database connection string not found.");
services.AddDbContext<SystemDbContext>(options =>
options.UseNpgsql(connectionString));
services.AddMemoryCache();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddScoped<Knot.Shared.Kernel.Services.IStatisticsService, StatisticsService>();
services.AddHostedService<StatisticsWorker>();
return services;
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,62 @@
using System.Net.Http.Headers;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
namespace Knot.Shared.Infrastructure.Middleware;
public class AdminAuthMiddleware
{
private readonly RequestDelegate _next;
public AdminAuthMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, IConfiguration configuration)
{
if (context.Request.Path.StartsWithSegments("/api/admin"))
{
var expectedUser = configuration["KNOT_ADMIN_USER"];
var expectedPass = configuration["KNOT_ADMIN_PASSWORD"];
if (string.IsNullOrEmpty(expectedUser) || string.IsNullOrEmpty(expectedPass))
{
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Admin credentials not configured on server.");
return;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
context.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"Admin Area\"");
context.Response.StatusCode = 401;
return;
}
try
{
var authHeader = AuthenticationHeaderValue.Parse(context.Request.Headers["Authorization"]);
var credentialBytes = Convert.FromBase64String(authHeader.Parameter ?? string.Empty);
var credentials = Encoding.UTF8.GetString(credentialBytes).Split(':', 2);
var username = credentials[0];
var password = credentials[1];
if (username != expectedUser || password != expectedPass)
{
context.Response.StatusCode = 403;
return;
}
}
catch
{
context.Response.StatusCode = 401;
return;
}
}
await _next(context);
}
}

View File

@@ -0,0 +1,59 @@
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Knot.Shared.Infrastructure.Middleware;
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception has occurred.");
await HandleExceptionAsync(context, ex);
}
}
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var (statusCode, message) = exception switch
{
UnauthorizedAccessException => (HttpStatusCode.Unauthorized, "Unauthorized access."),
KeyNotFoundException => (HttpStatusCode.NotFound, "Resource not found."),
InvalidOperationException => (HttpStatusCode.BadRequest, exception.Message),
_ => (HttpStatusCode.InternalServerError, "An internal server error occurred.")
};
context.Response.StatusCode = (int)statusCode;
var response = new
{
message = message,
error = message,
detail = exception.Message,
status = context.Response.StatusCode
};
await context.Response.WriteAsync(JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
}

View File

@@ -0,0 +1,64 @@
// <auto-generated />
using System;
using Knot.Shared.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Shared.Infrastructure.Migrations
{
[DbContext(typeof(SystemDbContext))]
[Migration("20260315204509_InitialSystem")]
partial class InitialSystem
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("system")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.DailyStat", b =>
{
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<long>("ActiveUsers")
.HasColumnType("bigint");
b.Property<long>("TotalFilesSize")
.HasColumnType("bigint");
b.Property<long>("TotalMessages")
.HasColumnType("bigint");
b.HasKey("Date");
b.ToTable("DailyStats", "system");
});
modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.SystemSetting", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("EncryptedValue")
.IsRequired()
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("Settings", "system");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Shared.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialSystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "system");
migrationBuilder.CreateTable(
name: "DailyStats",
schema: "system",
columns: table => new
{
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
TotalMessages = table.Column<long>(type: "bigint", nullable: false),
TotalFilesSize = table.Column<long>(type: "bigint", nullable: false),
ActiveUsers = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DailyStats", x => x.Date);
});
migrationBuilder.CreateTable(
name: "Settings",
schema: "system",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
EncryptedValue = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Settings", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DailyStats",
schema: "system");
migrationBuilder.DropTable(
name: "Settings",
schema: "system");
}
}
}

View File

@@ -0,0 +1,61 @@
// <auto-generated />
using System;
using Knot.Shared.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Shared.Infrastructure.Migrations
{
[DbContext(typeof(SystemDbContext))]
partial class SystemDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("system")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.DailyStat", b =>
{
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<long>("ActiveUsers")
.HasColumnType("bigint");
b.Property<long>("TotalFilesSize")
.HasColumnType("bigint");
b.Property<long>("TotalMessages")
.HasColumnType("bigint");
b.HasKey("Date");
b.ToTable("DailyStats", "system");
});
modelBuilder.Entity("Knot.Shared.Infrastructure.Persistence.Entities.SystemSetting", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("EncryptedValue")
.IsRequired()
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("Settings", "system");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,11 @@
using System;
namespace Knot.Shared.Infrastructure.Persistence.Entities;
public class DailyStat
{
public DateTime Date { get; set; }
public long TotalMessages { get; set; }
public long TotalFilesSize { get; set; }
public long ActiveUsers { get; set; }
}

View File

@@ -0,0 +1,9 @@
using System;
namespace Knot.Shared.Infrastructure.Persistence.Entities;
public class SystemSetting
{
public string Key { get; set; } = string.Empty;
public string EncryptedValue { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,29 @@
using Knot.Shared.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
namespace Knot.Shared.Infrastructure.Persistence;
public class SystemDbContext : DbContext
{
public SystemDbContext(DbContextOptions<SystemDbContext> options) : base(options) { }
public DbSet<SystemSetting> Settings => Set<SystemSetting>();
public DbSet<DailyStat> DailyStats => Set<DailyStat>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("system");
modelBuilder.Entity<SystemSetting>(builder =>
{
builder.ToTable("Settings");
builder.HasKey(s => s.Key);
});
modelBuilder.Entity<DailyStat>(builder =>
{
builder.ToTable("DailyStats");
builder.HasKey(s => s.Date);
});
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using System.IO;
namespace Knot.Shared.Infrastructure.Persistence;
public class SystemDbContextFactory : IDesignTimeDbContextFactory<SystemDbContext>
{
public SystemDbContext CreateDbContext(string[] args)
{
var basePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "Host");
if (!Directory.Exists(basePath))
{
basePath = Directory.GetCurrentDirectory(); // fallback
}
var configuration = new ConfigurationBuilder()
.SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
var builder = new DbContextOptionsBuilder<SystemDbContext>();
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? configuration["DATABASE_URL"]
?? "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass";
builder.UseNpgsql(connectionString);
return new SystemDbContext(builder.Options);
}
}

View File

@@ -0,0 +1,117 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace Knot.Shared.Kernel.Security;
public class AesEncryptionService : IEncryptionService
{
private readonly byte[] _key;
public AesEncryptionService(string masterKeyBase64)
{
// Try decoding as base64 or just using UTF8 string
if (masterKeyBase64.Length == 32)
{
_key = Encoding.UTF8.GetBytes(masterKeyBase64);
}
else
{
try
{
_key = Convert.FromBase64String(masterKeyBase64);
}
catch
{
_key = Encoding.UTF8.GetBytes(masterKeyBase64.PadRight(32, '0')[..32]);
}
}
if (_key.Length != 32)
{
throw new ArgumentException("Мастер-ключ должен быть ровно 32 байта для AES-256.");
}
}
// Сообщения: AES-256-GCM
public string EncryptMessage(string plainText)
{
if (string.IsNullOrEmpty(plainText))
{
return plainText;
}
var nonce = new byte[12];
RandomNumberGenerator.Fill(nonce);
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = new byte[plainBytes.Length];
var tag = new byte[16];
using var aesGcm = new AesGcm(_key, 16);
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
// формат: nonce:tag:cipherText
return $"{Convert.ToBase64String(nonce)}:{Convert.ToBase64String(tag)}:{Convert.ToBase64String(cipherBytes)}";
}
public string DecryptMessage(string cipherText)
{
if (string.IsNullOrEmpty(cipherText))
{
return cipherText;
}
var parts = cipherText.Split(':');
if (parts.Length != 3)
{
return cipherText;
}
try
{
var nonce = Convert.FromBase64String(parts[0]);
var tag = Convert.FromBase64String(parts[1]);
var cipherBytes = Convert.FromBase64String(parts[2]);
var plainBytes = new byte[cipherBytes.Length];
using var aesGcm = new AesGcm(_key, 16);
aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes);
return Encoding.UTF8.GetString(plainBytes);
}
catch
{
return cipherText; // Возвращаем оригинал при ошибке расшифровки (например, старые не зашифрованные сообщения)
}
}
// Потоковые файлы: AES CBC через CryptoStream
// (Примечание: Для GCM через CryptoStream нет поддержки "из коробки" в .NET при использовании Aes.Create().
// Требуется CBC+HMAC/CTR или механизм AEAD блоками. Здесь предоставляется стандартный AES-CBC для соответствия требованиям CryptoStream).
public Stream CreateEncryptionStream(Stream unencryptedOutputStream, out byte[] iv)
{
var aes = Aes.Create();
aes.KeySize = 256;
aes.Key = _key;
aes.Mode = CipherMode.CBC;
aes.GenerateIV();
iv = aes.IV;
var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
return new CryptoStream(unencryptedOutputStream, encryptor, CryptoStreamMode.Write);
}
public Stream CreateDecryptionStream(Stream encryptedInputStream, byte[] iv)
{
var aes = Aes.Create();
aes.KeySize = 256;
aes.Key = _key;
aes.Mode = CipherMode.CBC;
aes.IV = iv;
var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
return new CryptoStream(encryptedInputStream, decryptor, CryptoStreamMode.Read);
}
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel.Services;
using Knot.Shared.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace Knot.Shared.Infrastructure.Statistics;
public class StatisticsService : IStatisticsService
{
private readonly SystemDbContext _db;
private readonly IMemoryCache _cache;
private const string CacheKey = "dashboard_stats";
public StatisticsService(SystemDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public async Task<DashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default)
{
// 10 second caching to allow fast UI updates while still preventing DB overload
if (_cache.TryGetValue(CacheKey, out DashboardStatsDto cached))
{
return cached!;
}
var latestStat = await _db.DailyStats
.OrderByDescending(s => s.Date)
.FirstOrDefaultAsync(cancellationToken);
var history = await _db.DailyStats
.OrderByDescending(s => s.Date)
.Take(30)
.Select(s => new ActivityStatDto
{
Date = s.Date,
Messages = s.TotalMessages,
FilesSize = s.TotalFilesSize
})
.ToListAsync(cancellationToken);
var totalUsers = await _db.Database.GetDbConnection().CreateCommand().QueryTotalUsersAsync(); // Faked for simplicity without direct Identity reference
long totalDiskSpace = 5L * 1024 * 1024 * 1024 * 1024; // Fallback
long freeSpace = 0;
try
{
var drive = new System.IO.DriveInfo("/");
if (drive.IsReady)
{
totalDiskSpace = drive.TotalSize;
freeSpace = drive.AvailableFreeSpace;
}
else
{
drive = new System.IO.DriveInfo(System.IO.Directory.GetCurrentDirectory());
totalDiskSpace = drive.TotalSize;
freeSpace = drive.AvailableFreeSpace;
}
}
catch { }
var usedSpace = latestStat?.TotalFilesSize ?? 0;
long onlineUsersCount = _cache.TryGetValue("Global_OnlineUsersCount", out int count) ? count : 0;
long offlineUsersCount = totalUsers - onlineUsersCount;
if (offlineUsersCount < 0)
{
offlineUsersCount = 0;
}
var stats = new DashboardStatsDto
{
StorageUsedBytes = usedSpace,
StorageLimitBytes = freeSpace > 0 ? usedSpace + freeSpace : totalDiskSpace,
OnlineUsers = onlineUsersCount,
OfflineUsers = offlineUsersCount,
TotalUsers = totalUsers,
ActivityTimeline = history
};
_cache.Set(CacheKey, stats, TimeSpan.FromSeconds(10));
return stats;
}
}
// Temporary internal extensions for cross-module Db fetching during demonstration
internal static class SqlExtensions
{
public static async Task<long> QueryTotalUsersAsync(this System.Data.IDbCommand cmd)
{
try
{
cmd.CommandText = "SELECT COUNT(*) FROM identity.\"Users\"";
if (cmd.Connection?.State != System.Data.ConnectionState.Open)
{
await ((System.Data.Common.DbConnection)cmd.Connection!).OpenAsync();
}
var result = await ((System.Data.Common.DbCommand)cmd).ExecuteScalarAsync();
return Convert.ToInt64(result);
}
catch
{
return 0; // Fallback
}
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Infrastructure.Persistence;
using Knot.Shared.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Knot.Shared.Infrastructure.Statistics;
public class StatisticsWorker : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
public StatisticsWorker(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SystemDbContext>();
// Simplified gathering for daily stats
var today = DateTime.UtcNow.Date;
var stat = await db.DailyStats.FirstOrDefaultAsync(s => s.Date == today, stoppingToken);
if (stat == null)
{
stat = new DailyStat { Date = today };
db.DailyStats.Add(stat);
}
var conn = db.Database.GetDbConnection();
var cmd = conn.CreateCommand();
stat.ActiveUsers = await cmd.QueryTotalUsersAsync();
long dbSize = 0;
long filesSize = 0;
try
{
// 1. Database size itself
cmd.CommandText = "SELECT pg_database_size(current_database());";
var dbSizeResult = await cmd.ExecuteScalarAsync();
dbSize = dbSizeResult != DBNull.Value ? Convert.ToInt64(dbSizeResult) : 0;
// 2. Sum of all uploaded files (which live in MinIO, but we track size in MessageMedia)
cmd.CommandText = "SELECT SUM(\"Size\") FROM chats.\"MessageMedia\";";
var mediaSizeResult = await cmd.ExecuteScalarAsync();
filesSize = mediaSizeResult != DBNull.Value ? Convert.ToInt64(mediaSizeResult) : 0;
}
catch { }
stat.TotalFilesSize = dbSize + filesSize; // Database size + MinIO files size
await db.SaveChangesAsync(stoppingToken);
}
catch
{
// Background service swallows errors to not crash the app
}
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
}

View File

@@ -0,0 +1,197 @@
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Tasks;
using Knot.Shared.Kernel.Security;
namespace Knot.Shared.Kernel.Storage;
public class S3FileStorageService : IFileStorageService
{
private readonly IMinioClient _minioClient;
private readonly IEncryptionService _encryptionService;
private readonly string _bucketName;
public S3FileStorageService(IMinioClient minioClient, IEncryptionService encryptionService, string bucketName)
{
_minioClient = minioClient;
_encryptionService = encryptionService;
_bucketName = bucketName;
}
private async Task EnsureBucketExistsAsync()
{
try
{
var bktExistArgs = new BucketExistsArgs().WithBucket(_bucketName);
bool found = await _minioClient.BucketExistsAsync(bktExistArgs).ConfigureAwait(false);
if (!found)
{
var mkBktArgs = new MakeBucketArgs().WithBucket(_bucketName);
await _minioClient.MakeBucketAsync(mkBktArgs).ConfigureAwait(false);
}
}
catch (MinioException e)
{
Console.WriteLine($"[Bucket] Minio Error: {e.Message}");
}
}
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
{
await EnsureBucketExistsAsync();
// 1. Вычисляем SHA-256 для Content-Addressable Storage (CAS) - дедупликация
string fileHash;
using (var sha256 = SHA256.Create())
using (var msHash = new MemoryStream())
{
var startPos = fileStream.Position;
await fileStream.CopyToAsync(msHash);
var hashBytes = sha256.ComputeHash(msHash.ToArray());
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
// Возвращаем указатель потока в начало для последующего чтения
fileStream.Position = startPos;
}
var ext = Path.GetExtension(fileName);
var fileId = $"{fileHash}{ext}";
// 2. Шифруем файл "на лету" во временный файл
// Для больших файлов мы используем временный файл, чтобы не перегружать оперативную память (RAM)
var tempFilePath = Path.GetTempFileName();
byte[] ivParams;
try
{
using (var tempFs = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write))
using (var cryptoStream = _encryptionService.CreateEncryptionStream(tempFs, out ivParams))
{
await fileStream.CopyToAsync(cryptoStream);
}
// 3. Загружаем зашифрованный файл в MinIO
using var fileToUpload = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read);
var metadata = new System.Collections.Generic.Dictionary<string, string>
{
{ "ContentType", contentType },
{ "OriginalFileName", fileName },
{ "IV", Convert.ToBase64String(ivParams) },
{ "KeyVersion", "1" },
{ "EncryptionAlgorithm", "AES-256-CBC" } // Как реализовано в потоке AES CBC
};
var putObjectArgs = new PutObjectArgs()
.WithBucket(_bucketName)
.WithObject(fileId)
.WithStreamData(fileToUpload)
.WithObjectSize(fileToUpload.Length)
.WithContentType("application/octet-stream")
.WithHeaders(metadata);
await _minioClient.PutObjectAsync(putObjectArgs).ConfigureAwait(false);
return fileId;
}
finally
{
if (File.Exists(tempFilePath))
{
File.Delete(tempFilePath);
}
}
}
public async Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId)
{
// Для скачивания, мы транслируем из Minio во временный файл, расшифровываем и возвращаем.
var tempFilePath = Path.GetTempFileName();
string contentType = "application/octet-stream";
string fileName = fileId;
string ivBase64 = string.Empty;
var statArgs = new StatObjectArgs().WithBucket(_bucketName).WithObject(fileId);
var stat = await _minioClient.StatObjectAsync(statArgs).ConfigureAwait(false);
if (stat.MetaData.ContainsKey("Contenttype"))
{
contentType = stat.MetaData["Contenttype"];
}
if (stat.MetaData.ContainsKey("Originalfilename"))
{
fileName = stat.MetaData["Originalfilename"];
}
if (stat.MetaData.ContainsKey("Iv"))
{
ivBase64 = stat.MetaData["Iv"];
}
// Загружаем во временный файл (так как CryptoStream требует правильного чтения/записи)
var getObjArgs = new GetObjectArgs()
.WithBucket(_bucketName)
.WithObject(fileId)
.WithCallbackStream((stream) =>
{
using var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write);
stream.CopyTo(fs);
});
await _minioClient.GetObjectAsync(getObjArgs).ConfigureAwait(false);
// Создаем поток дешифрования над байтами временного файла, оборачиваем в MemoryStream, чтобы он автоматически закрывался
// В реальном масштабируемом продакшене лучше возвращать FileStream напрямую, обернутый в crypto stream.
var msResult = new MemoryStream();
using (var fsRead = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read))
{
var iv = string.IsNullOrEmpty(ivBase64) ? new byte[16] : Convert.FromBase64String(ivBase64);
using var cryptoStream = _encryptionService.CreateDecryptionStream(fsRead, iv);
await cryptoStream.CopyToAsync(msResult);
}
File.Delete(tempFilePath);
msResult.Position = 0;
return (msResult, contentType, fileName);
}
public async Task DeleteFileAsync(string fileId)
{
try
{
var removeArgs = new RemoveObjectArgs()
.WithBucket(_bucketName)
.WithObject(fileId);
await _minioClient.RemoveObjectAsync(removeArgs).ConfigureAwait(false);
}
catch (MinioException e)
{
Console.WriteLine($"[Bucket] Error deleting file {fileId}: {e.Message}");
}
}
public async Task<IEnumerable<(string FileId, long Size)>> ListFilesAsync()
{
var result = new List<(string FileId, long Size)>();
try
{
var listArgs = new ListObjectsArgs().WithBucket(_bucketName).WithRecursive(true);
await foreach (var item in _minioClient.ListObjectsEnumAsync(listArgs).ConfigureAwait(false))
{
result.Add((item.Key, (long)item.Size));
}
}
catch (Exception ex)
{
Console.WriteLine($"[Bucket] Error listing files: {ex.Message}");
}
return result;
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
using Knot.Shared.Kernel;
using System.IdentityModel.Tokens.Jwt;
namespace Knot.Shared.Infrastructure;
public sealed class UserContext : IUserContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
public UserContext(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Guid UserId
{
get
{
var userIdClaim = _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier)
?? _httpContextAccessor.HttpContext?.User?.FindFirstValue("sub")
?? _httpContextAccessor.HttpContext?.User?.FindFirstValue(JwtRegisteredClaimNames.Sub); // Modified
if (userIdClaim is null || !Guid.TryParse(userIdClaim, out Guid userId))
{
// Вместо исключения возвращаем пустой Guid или обрабатываем иначе,
// но в защищенных эндпоинтах это не должно случаться
return Guid.Empty; // Modified
}
return userId;
}
}
public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
}

View File

@@ -0,0 +1,33 @@
using MediatR;
namespace Knot.Shared.Kernel;
/// <summary>
/// Маркерный интерфейс для корней агрегатов (Aggregate Roots).
/// </summary>
public interface IAggregateRoot { }
/// <summary>
/// Интерфейс для доменных событий.
/// </summary>
public interface IDomainEvent : INotification { }
/// <summary>
/// Базовый класс для сущностей, которые являются корнями агрегатов.
/// </summary>
public abstract class AggregateRoot<TId> : Entity<TId>, IAggregateRoot
where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = new();
protected AggregateRoot(TId id) : base(id) { }
public IReadOnlyCollection<IDomainEvent> GetDomainEvents() => _domainEvents.AsReadOnly();
public void ClearDomainEvents() => _domainEvents.Clear();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
}

View File

@@ -0,0 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Shared.Kernel.Configuration;
public interface ISettingsService
{
Task<SystemSettingsDto> GetSettingsAsync(CancellationToken cancellationToken = default);
Task UpdateSettingsAsync(SystemSettingsDto settings, CancellationToken cancellationToken = default);
SystemSettingsDto Current { get; }
}

View File

@@ -0,0 +1,28 @@
namespace Knot.Shared.Kernel.Configuration;
public class SystemSettingsDto
{
// Storage Limits
public double MaxStorageQuotaTb { get; set; } = 5.0;
public int MaxGroupMembers { get; set; } = 500;
public int MaxFileSizeMb { get; set; } = 100;
// Calls
public bool EnableCalls { get; set; } = false;
public string TurnHost { get; set; } = string.Empty;
public int TurnPort { get; set; } = 3478;
public string TurnUser { get; set; } = string.Empty;
public string TurnSecret { get; set; } = string.Empty;
// Klipy
public bool EnableKlipy { get; set; } = false;
public string KlipyApiKey { get; set; } = string.Empty;
public string KlipyCustomerId { get; set; } = string.Empty;
// Confederation
public bool EnableConfederation { get; set; } = false;
public List<string> AllowedDomains { get; set; } = new();
// Auth
public bool EnableRegistration { get; set; } = true;
}

View File

@@ -0,0 +1,15 @@
namespace Knot.Shared.Kernel.Constants;
public static class Errors
{
public const string Unauthorized = "Не авторизован.";
public const string Forbidden = "Доступ запрещен.";
public const string NotFound = "Сущность не найдена.";
public const string ValidationError = "Ошибка валидации.";
public const string InternalServerError = "Внутренняя ошибка сервера.";
public const string DisabledByAdmin = "Модуль отключен администратором.";
public const string KlipyNotConfigured = "Ключ Klipy не настроен или модуль отключен.";
public const string KlipyApiError = "Ошибка запроса к Klipy API.";
public const string KlipySearchError = "Ошибка запроса к Klipy API при поиске.";
public const string InvalidQuery = "Пустой запрос недопустим.";
}

View File

@@ -0,0 +1,15 @@
namespace Knot.Shared.Kernel.Constants;
public static class Klipy
{
public const string ApiUrlCo = "https://api.klipy.co/api/v1/{0}/{1}?page=1&per_page=30&{2}&customer_id={3}";
public const string ApiUrlCom = "https://api.klipy.com/api/v1/{0}/{1}?page=1&per_page=30&{2}&customer_id={3}";
public const string ResourceTrending = "gifs/trending";
public const string ResourceSearch = "gifs/search";
public const int TrendingCacheMinutes = 60;
public const int SearchCacheMinutes = 15;
public const string DefaultCustomerId = "anonymous";
}

View File

@@ -0,0 +1,6 @@
namespace Knot.Shared.Kernel.Constants;
public enum Policies
{
AdminOnly
}

View File

@@ -0,0 +1,7 @@
namespace Knot.Shared.Kernel.Constants;
public enum Roles
{
Admin,
User
}

View File

@@ -0,0 +1,18 @@
namespace Knot.Shared.Kernel.Constants;
public static class Routes
{
public const string ApiAdmin = "/api/admin";
public const string ApiAuth = "/api/auth";
public const string ApiChats = "/api/chats";
public const string ApiConfig = "/api/config";
public const string ApiFederation = "/api/federation";
public const string ApiFiles = "/api/files";
public const string ApiFriends = "/api/friends";
public const string ApiKlipy = "/api/klipy";
public const string ApiMessages = "/api/messages";
public const string ApiStories = "/api/stories";
public const string ApiTelegramImport = "/api/telegram/import";
public const string ApiUsers = "/api/users";
public const string ApiWebRtc = "/api/webrtc";
}

View File

@@ -0,0 +1,7 @@
namespace Knot.Shared.Kernel;
public static class DomainErrors
{
public static readonly Error InvalidPassword = new Error("Admin.InvalidPassword", "Пароль не должен быть пустым.");
public static readonly Error RequestInvalid = new Error("Request.Invalid", "Invalid federation request");
}

View File

@@ -0,0 +1,51 @@
namespace Knot.Shared.Kernel;
/// <summary>
/// Базовый класс для всех сущностей в системе.
/// </summary>
/// <typeparam name="TId">Тип идентификатора сущности.</typeparam>
public abstract class Entity<TId> : IEquatable<Entity<TId>>
where TId : notnull
{
public TId Id { get; protected set; }
protected Entity(TId id)
{
Id = id;
}
public override bool Equals(object? obj)
{
return obj is Entity<TId> entity && Equals(entity);
}
public bool Equals(Entity<TId>? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return Id.Equals(other.Id);
}
public static bool operator ==(Entity<TId>? left, Entity<TId>? right)
{
return Equals(left, right);
}
public static bool operator !=(Entity<TId>? left, Entity<TId>? right)
{
return !Equals(left, right);
}
public override int GetHashCode()
{
return Id.GetHashCode();
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
namespace Knot.Shared.Kernel;
public interface ICommand : IRequest<Result> { }
public interface ICommand<TResponse> : IRequest<Result<TResponse>> { }
public interface ICommandHandler<in TCommand> : IRequestHandler<TCommand, Result>
where TCommand : ICommand
{ }
public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
where TCommand : ICommand<TResponse>
{ }

View File

@@ -0,0 +1,13 @@
using MediatR;
namespace Knot.Shared.Kernel;
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
{
}
public interface IQueryHandler<TQuery, TResponse>
: IRequestHandler<TQuery, Result<TResponse>>
where TQuery : IQuery<TResponse>
{
}

View File

@@ -0,0 +1,12 @@
namespace Knot.Shared.Kernel;
/// <summary>
/// Интерфейс для паттерна Unit of Work, обеспечивающий атомарность операций в рамках одной транзакции БД.
/// </summary>
public interface IUnitOfWork
{
/// <summary>
/// Сохраняет все изменения, сделанные в контексте, в базу данных.
/// </summary>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,7 @@
namespace Knot.Shared.Kernel;
public interface IUserContext
{
Guid UserId { get; }
bool IsAuthenticated { get; }
}

View File

@@ -0,0 +1,10 @@
namespace Knot.Shared.Kernel;
public record UserInfo(Guid Id, string Username, string DisplayName, string? Avatar);
public interface IUserDisplayNameProvider
{
Task<string> GetDisplayNameAsync(Guid userId, CancellationToken ct = default);
Task<UserInfo?> GetUserInfoAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyDictionary<Guid, UserInfo>> GetUsersInfoAsync(IEnumerable<Guid> userIds, CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,3 @@
namespace Knot.Shared.Kernel;
public record MessageResponse(string Message);

View File

@@ -0,0 +1,62 @@
namespace Knot.Shared.Kernel;
/// <summary>
/// Представляет ошибку в доменной логике.
/// </summary>
public sealed record Error(string Code, string Description)
{
public static readonly Error None = new(string.Empty, string.Empty);
}
/// <summary>
/// Общая обертка для результата операции. Позволяет избегать использования исключений для управления потоком.
/// </summary>
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
{
throw new InvalidOperationException();
}
if (!isSuccess && error == Error.None)
{
throw new InvalidOperationException();
}
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
public static Result<TValue> Success<TValue>(TValue value) => Result<TValue>.Success(value);
public static Result<TValue> Failure<TValue>(Error error) => Result<TValue>.Failure(error);
}
/// <summary>
/// Результ операции, содержащий значение.
/// </summary>
public class Result<TValue> : Result
{
private readonly TValue? _value;
protected internal Result(TValue? value, bool isSuccess, Error error)
: base(isSuccess, error)
{
_value = value;
}
public TValue Value => IsSuccess
? _value!
: throw new InvalidOperationException("Нельзя получить значение ошибочного результата.");
public static Result<TValue> Success(TValue value) => new(value, true, Error.None);
public new static Result<TValue> Failure(Error error) => new(default, false, error);
}

View File

@@ -0,0 +1,16 @@
using System.IO;
namespace Knot.Shared.Kernel.Security;
public interface IEncryptionService
{
// Шифрует текст (например, сообщения) для хранения в БД
string EncryptMessage(string plainText);
string DecryptMessage(string cipherText);
// Создает поток шифрования для потоковой передачи файлов "на лету"
Stream CreateEncryptionStream(Stream unencryptedOutputStream, out byte[] iv);
// Создает поток дешифрования для чтения файлов "на лету"
Stream CreateDecryptionStream(Stream encryptedInputStream, byte[] iv);
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Shared.Kernel.Services;
public interface IStatisticsService
{
Task<DashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default);
}
public class DashboardStatsDto
{
public long StorageUsedBytes { get; set; }
public long StorageLimitBytes { get; set; }
public long OnlineUsers { get; set; }
public long OfflineUsers { get; set; }
public long TotalUsers { get; set; }
public List<ActivityStatDto> ActivityTimeline { get; set; } = new();
public List<TopUserDto> TopUsersByMessages { get; set; } = new();
public List<TopUserDto> TopUsersByStorage { get; set; } = new();
}
public class ActivityStatDto
{
public DateTime Date { get; set; }
public long Messages { get; set; }
public long FilesSize { get; set; }
}
public class TopUserDto
{
public Guid UserId { get; set; }
public string Username { get; set; } = string.Empty;
public long Value { get; set; } // messages count or bytes
}

View File

@@ -0,0 +1,19 @@
using System.IO;
using System.Threading.Tasks;
namespace Knot.Shared.Kernel.Storage;
public interface IFileStorageService
{
// Загружает поток файла и возвращает его уникальный идентификатор (SHA256 хеш или GUID).
Task<string> UploadFileAsync(Stream stream, string fileName, string contentType);
// Скачивает файл и возвращает его расшифрованный поток и тип содержимого.
Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId);
// Удаляет файл из хранилища.
Task DeleteFileAsync(string fileId);
// Получает список всех файлов в хранилище с их размерами.
Task<System.Collections.Generic.IEnumerable<(string FileId, long Size)>> ListFilesAsync();
}

View File

@@ -0,0 +1,3 @@
namespace Knot.Shared.Kernel;
public record SuccessResponse(bool Success);

View File

@@ -0,0 +1,42 @@
namespace Knot.Shared.Kernel;
/// <summary>
/// Базовый класс для объектов-значений (Value Objects).
/// Объекты-значения сравниваются по их свойствам, а не по идентичности.
/// </summary>
public abstract class ValueObject : IEquatable<ValueObject>
{
protected abstract IEnumerable<object> GetEqualityComponents();
public override bool Equals(object? obj)
{
return obj is ValueObject valueObject && Equals(valueObject);
}
public bool Equals(ValueObject? other)
{
if (other is null)
{
return false;
}
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x?.GetHashCode() ?? 0)
.Aggregate((x, y) => x ^ y);
}
public static bool operator ==(ValueObject? left, ValueObject? right)
{
return Equals(left, right);
}
public static bool operator !=(ValueObject? left, ValueObject? right)
{
return !Equals(left, right);
}
}