Reorganize root folder structure: Remove apps layer layer
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
64
backend/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.Designer.cs
generated
Normal file
64
backend/src/Shared/Knot.Shared.Infrastructure/Migrations/20260315204509_InitialSystem.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
37
backend/src/Shared/Knot.Shared.Infrastructure/UserContext.cs
Normal file
37
backend/src/Shared/Knot.Shared.Infrastructure/UserContext.cs
Normal 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;
|
||||
}
|
||||
33
backend/src/Shared/Knot.Shared.Kernel/AggregateRoot.cs
Normal file
33
backend/src/Shared/Knot.Shared.Kernel/AggregateRoot.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
15
backend/src/Shared/Knot.Shared.Kernel/Constants/Errors.cs
Normal file
15
backend/src/Shared/Knot.Shared.Kernel/Constants/Errors.cs
Normal 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 = "Пустой запрос недопустим.";
|
||||
}
|
||||
15
backend/src/Shared/Knot.Shared.Kernel/Constants/Klipy.cs
Normal file
15
backend/src/Shared/Knot.Shared.Kernel/Constants/Klipy.cs
Normal 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";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Knot.Shared.Kernel.Constants;
|
||||
|
||||
public enum Policies
|
||||
{
|
||||
AdminOnly
|
||||
}
|
||||
7
backend/src/Shared/Knot.Shared.Kernel/Constants/Roles.cs
Normal file
7
backend/src/Shared/Knot.Shared.Kernel/Constants/Roles.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Knot.Shared.Kernel.Constants;
|
||||
|
||||
public enum Roles
|
||||
{
|
||||
Admin,
|
||||
User
|
||||
}
|
||||
18
backend/src/Shared/Knot.Shared.Kernel/Constants/Routes.cs
Normal file
18
backend/src/Shared/Knot.Shared.Kernel/Constants/Routes.cs
Normal 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";
|
||||
}
|
||||
7
backend/src/Shared/Knot.Shared.Kernel/DomainErrors.cs
Normal file
7
backend/src/Shared/Knot.Shared.Kernel/DomainErrors.cs
Normal 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");
|
||||
}
|
||||
51
backend/src/Shared/Knot.Shared.Kernel/Entity.cs
Normal file
51
backend/src/Shared/Knot.Shared.Kernel/Entity.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
15
backend/src/Shared/Knot.Shared.Kernel/ICommand.cs
Normal file
15
backend/src/Shared/Knot.Shared.Kernel/ICommand.cs
Normal 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>
|
||||
{ }
|
||||
13
backend/src/Shared/Knot.Shared.Kernel/IQuery.cs
Normal file
13
backend/src/Shared/Knot.Shared.Kernel/IQuery.cs
Normal 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>
|
||||
{
|
||||
}
|
||||
12
backend/src/Shared/Knot.Shared.Kernel/IUnitOfWork.cs
Normal file
12
backend/src/Shared/Knot.Shared.Kernel/IUnitOfWork.cs
Normal 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);
|
||||
}
|
||||
7
backend/src/Shared/Knot.Shared.Kernel/IUserContext.cs
Normal file
7
backend/src/Shared/Knot.Shared.Kernel/IUserContext.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Knot.Shared.Kernel;
|
||||
|
||||
public interface IUserContext
|
||||
{
|
||||
Guid UserId { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
3
backend/src/Shared/Knot.Shared.Kernel/MessageResponse.cs
Normal file
3
backend/src/Shared/Knot.Shared.Kernel/MessageResponse.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Knot.Shared.Kernel;
|
||||
|
||||
public record MessageResponse(string Message);
|
||||
62
backend/src/Shared/Knot.Shared.Kernel/Result.cs
Normal file
62
backend/src/Shared/Knot.Shared.Kernel/Result.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
3
backend/src/Shared/Knot.Shared.Kernel/SuccessResponse.cs
Normal file
3
backend/src/Shared/Knot.Shared.Kernel/SuccessResponse.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Knot.Shared.Kernel;
|
||||
|
||||
public record SuccessResponse(bool Success);
|
||||
42
backend/src/Shared/Knot.Shared.Kernel/ValueObject.cs
Normal file
42
backend/src/Shared/Knot.Shared.Kernel/ValueObject.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user