Шифрование, GIF, хранилище, админка

This commit is contained in:
Халимов Рустам
2026-03-16 14:49:31 +03:00
parent 336f9ea559
commit 6d018e41ea
44 changed files with 1876 additions and 162 deletions
@@ -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();
}
}
@@ -3,6 +3,11 @@ 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;
@@ -40,6 +45,20 @@ public static class DependencyInjection
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;
}
}
@@ -12,8 +12,12 @@
<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.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>
@@ -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,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,109 @@
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,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,25 @@
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; } = true;
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; } = true;
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();
}
@@ -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
}