From 249c344df8c6166bb0310dc95dfd75783e52c431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Wed, 1 Apr 2026 17:42:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B0=D0=B9=D0=BB=D1=8B,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/Host/Program.cs | 83 +++--- .../Presentation/Endpoints/AdminEndpoints.cs | 2 +- .../Application/DTOs/MediaDto.cs | 2 +- .../Persistence/ChatsDbContextFactory.cs | 20 +- .../Endpoints/FederationEndpoints.cs | 2 +- .../Klipy/Queries/GetTrendingGifsQuery.cs | 2 +- .../Klipy/Queries/SearchGifsQuery.cs | 2 +- .../Presentation/Endpoints/KlipyEndpoints.cs | 6 +- .../Modules/Relations/DependencyInjection.cs | 2 +- .../Persistence/RelationsDbContext.cs | 21 +- .../Persistence/RelationsDbContextFactory.cs | 33 +++ .../20260401112051_AddFriendships.Designer.cs | 114 ++++++++ .../20260401112051_AddFriendships.cs | 97 +++++++ .../RelationsDbContextModelSnapshot.cs | 111 ++++++++ .../Presentation/Endpoints/FilesEndpoints.cs | 68 +++-- .../Modules/Stories/DependencyInjection.cs | 2 + .../Database/StoriesDbContextFactory.cs | 33 +++ .../Infrastructure/Database/UserRepository.cs | 42 +++ .../Stories/Knot.Modules.Stories.csproj | 1 + .../20260401112239_InitialStories.Designer.cs | 54 ++++ .../20260401112239_InitialStories.cs | 48 ++++ .../StoriesDbContextModelSnapshot.cs | 51 ++++ .../presentation/components/MessageBubble.tsx | 125 ++++----- .../presentation/components/MessageInput.tsx | 254 ++++++------------ 24 files changed, 847 insertions(+), 328 deletions(-) create mode 100644 backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContextFactory.cs create mode 100644 backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.Designer.cs create mode 100644 backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.cs create mode 100644 backend/src/Modules/Relations/Migrations/RelationsDbContextModelSnapshot.cs create mode 100644 backend/src/Modules/Stories/Infrastructure/Database/StoriesDbContextFactory.cs create mode 100644 backend/src/Modules/Stories/Infrastructure/Database/UserRepository.cs create mode 100644 backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.Designer.cs create mode 100644 backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.cs create mode 100644 backend/src/Modules/Stories/Migrations/StoriesDbContextModelSnapshot.cs diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index b6dc5ca..9188727 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -1,39 +1,41 @@ -using Knot.Modules.Messaging; -using Knot.Shared.Infrastructure; -using Knot.Modules.Auth; -using Knot.Modules.Auth.Presentation.Endpoints; -using Knot.Modules.Profiles; -using Knot.Modules.Profiles.Presentation.Endpoints; -using Knot.Modules.Settings; -using Knot.Modules.Settings.Presentation.Endpoints; -using Knot.Modules.Admin; -using Knot.Modules.Conversations; -using Knot.Modules.Conversations.Infrastructure.SignalR; -using Knot.Modules.Conversations.Presentation.Endpoints; -using Knot.Modules.Auth.Infrastructure.Persistence; -using Knot.Modules.Conversations.Infrastructure.Persistence; -using Knot.Modules.Storage; -using Knot.Modules.Stories; -using Knot.Modules.Stories.Presentation.Endpoints; -using Knot.Modules.Klipy; -using Knot.Modules.Federation; -using Knot.Modules.WebRtc; -using Knot.Modules.WebRtc.Presentation.Endpoints; -using Knot.Modules.TelegramImport; -using Knot.Modules.TelegramImport.Presentation.Endpoints; -using Knot.Modules.Relations; -using Knot.Modules.Relations.Presentation.Endpoints; -using Host.Endpoints; -using Knot.Host.Presentation.Endpoints; -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.IdentityModel.Tokens; -using System.Text; using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using Knot.Modules.Admin; +using Knot.Modules.Admin.Presentation.Endpoints; +using Knot.Modules.Auth; +using Knot.Modules.Auth.Infrastructure.Persistence; +using Knot.Modules.Auth.Presentation.Endpoints; +using Knot.Modules.Conversations; +using Knot.Modules.Conversations.Infrastructure.Persistence; +using Knot.Modules.Conversations.Infrastructure.SignalR; +using Knot.Modules.Conversations.Presentation.Endpoints; +using Knot.Modules.Federation; +using Knot.Modules.Federation.Presentation.Endpoints; +using Knot.Modules.Klipy; +using Knot.Modules.Klipy.Presentation.Endpoints; +using Knot.Modules.Messaging; +using Knot.Modules.Profiles; +using Knot.Modules.Profiles.Presentation.Endpoints; +using Knot.Modules.Relations; +using Knot.Modules.Relations.Presentation.Endpoints; +using Knot.Modules.Settings; +using Knot.Modules.Settings.Presentation.Endpoints; +using Knot.Modules.Storage; +using Knot.Modules.Storage.Presentation.Endpoints; +using Knot.Modules.Stories; +using Knot.Modules.Stories.Presentation.Endpoints; +using Knot.Modules.TelegramImport; +using Knot.Modules.TelegramImport.Presentation.Endpoints; +using Knot.Modules.WebRtc; +using Knot.Modules.WebRtc.Presentation.Endpoints; +using Knot.Shared.Infrastructure; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.SignalR; -using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; @@ -72,6 +74,7 @@ builder.Services.AddSettingsModule(builder.Configuration); builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddConversationsModule(builder.Configuration); builder.Services.AddProfilesModule(builder.Configuration); +builder.Services.AddRelationsModule(builder.Configuration); builder.Services.AddStorageModule(builder.Configuration); builder.Services.AddStoriesModule(builder.Configuration); builder.Services.AddKlipyModule(); @@ -86,7 +89,8 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( typeof(Knot.Modules.Messaging.DependencyInjection).Assembly, typeof(Knot.Modules.Conversations.DependencyInjection).Assembly, typeof(Knot.Modules.Stories.DependencyInjection).Assembly, - typeof(Knot.Modules.Klipy.DependencyInjection).Assembly + typeof(Knot.Modules.Klipy.DependencyInjection).Assembly, + typeof(Knot.Modules.Relations.DependencyInjection).Assembly )); // Настройка CORS @@ -96,9 +100,9 @@ builder.Services.AddCors(options => { var originsFromConfig = builder.Configuration["Cors:Origins"]; var domain = builder.Configuration["DOMAIN"]; - - var origins = !string.IsNullOrEmpty(originsFromConfig) - ? originsFromConfig.Split(',') + + var origins = !string.IsNullOrEmpty(originsFromConfig) + ? originsFromConfig.Split(',') : (!string.IsNullOrEmpty(domain) ? new[] { $"https://{domain}" } : new[] { "*" }); var corsBuilder = policy.WithOrigins(origins) @@ -120,7 +124,7 @@ builder.Services.AddCors(options => builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // Настройка маршрутизации -builder.Services.AddRouting(options => +builder.Services.AddRouting(options => { options.LowercaseUrls = true; options.LowercaseQueryStrings = true; @@ -184,7 +188,7 @@ using (var scope = app.Services.CreateScope()) { var identityDb = scope.ServiceProvider.GetRequiredService(); await identityDb.Database.MigrateAsync(); - + var chatsDb = scope.ServiceProvider.GetRequiredService(); await chatsDb.Database.MigrateAsync(); @@ -194,6 +198,9 @@ using (var scope = app.Services.CreateScope()) var storiesDb = scope.ServiceProvider.GetRequiredService(); await storiesDb.Database.MigrateAsync(); + var relationsDb = scope.ServiceProvider.GetRequiredService(); + await relationsDb.Database.MigrateAsync(); + // Set Encryption Service for MongoDB serializers var encryptionService = scope.ServiceProvider.GetRequiredService(); Knot.Modules.Messaging.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService; diff --git a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs index 86fc2b0..18c6eb0 100644 --- a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs +++ b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs @@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -namespace Knot.Host.Presentation.Endpoints; +namespace Knot.Modules.Admin.Presentation.Endpoints; public record KlipyTestDto( [property: JsonPropertyName("apiKey")] string ApiKey, diff --git a/backend/src/Modules/Conversations/Application/DTOs/MediaDto.cs b/backend/src/Modules/Conversations/Application/DTOs/MediaDto.cs index c6dc45f..23da2a0 100644 --- a/backend/src/Modules/Conversations/Application/DTOs/MediaDto.cs +++ b/backend/src/Modules/Conversations/Application/DTOs/MediaDto.cs @@ -5,7 +5,7 @@ namespace Knot.Modules.Conversations.Application.DTOs; public record MediaDto( Guid Id, string Type, - string Url, + string? Url, string? Filename, long? Size ); diff --git a/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatsDbContextFactory.cs b/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatsDbContextFactory.cs index 9ae127f..88a5a4b 100644 --- a/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatsDbContextFactory.cs +++ b/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatsDbContextFactory.cs @@ -1,7 +1,9 @@ +using System.IO; using Knot.Modules.Conversations.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; namespace Knot.Modules.Conversations.Infrastructure; @@ -10,8 +12,24 @@ public class ChatsDbContextFactory : IDesignTimeDbContextFactory { public ChatsDbContext CreateDbContext(string[] args) { + var basePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "Host"); + if (!Directory.Exists(basePath)) + { + basePath = Directory.GetCurrentDirectory(); + } + + var configuration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseNpgsql("Host=localhost;Database=knot_db;Username=knot;Password=knot_pass"); + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"; + + optionsBuilder.UseNpgsql(connectionString); return new ChatsDbContext(optionsBuilder.Options); } } \ No newline at end of file diff --git a/backend/src/Modules/Federation/Presentation/Endpoints/FederationEndpoints.cs b/backend/src/Modules/Federation/Presentation/Endpoints/FederationEndpoints.cs index 52f4420..be780d5 100644 --- a/backend/src/Modules/Federation/Presentation/Endpoints/FederationEndpoints.cs +++ b/backend/src/Modules/Federation/Presentation/Endpoints/FederationEndpoints.cs @@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -namespace Host.Endpoints; +namespace Knot.Modules.Federation.Presentation.Endpoints; /// /// Эндпоинты федерации для межсерверного взаимодействия. diff --git a/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs index ea66a5d..792c520 100644 --- a/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs +++ b/backend/src/Modules/Klipy/Application/Klipy/Queries/GetTrendingGifsQuery.cs @@ -11,7 +11,7 @@ using System.Threading; using System.Threading.Tasks; using MediatR; -namespace Host.Application.Klipy.Queries; +namespace Knot.Modules.Klipy.Application.Klipy.Queries; public record GetTrendingGifsQuery : IQuery; diff --git a/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs b/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs index 942b53e..f244f2e 100644 --- a/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs +++ b/backend/src/Modules/Klipy/Application/Klipy/Queries/SearchGifsQuery.cs @@ -11,7 +11,7 @@ using System.Threading; using System.Threading.Tasks; using MediatR; -namespace Host.Application.Klipy.Queries; +namespace Knot.Modules.Klipy.Application.Klipy.Queries; public record SearchGifsQuery(string Query) : IQuery; diff --git a/backend/src/Modules/Klipy/Presentation/Endpoints/KlipyEndpoints.cs b/backend/src/Modules/Klipy/Presentation/Endpoints/KlipyEndpoints.cs index 93fed50..9e413b8 100644 --- a/backend/src/Modules/Klipy/Presentation/Endpoints/KlipyEndpoints.cs +++ b/backend/src/Modules/Klipy/Presentation/Endpoints/KlipyEndpoints.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Routing; using System; using System.Threading.Tasks; -namespace Host.Endpoints; +namespace Knot.Modules.Klipy.Presentation.Endpoints; /// /// Регистрация эндпоинтов для сервиса Klipy. @@ -20,13 +20,13 @@ public static class KlipyEndpoints group.MapGet("/trending", async (ISender sender, CancellationToken ct) => { - var result = await sender.Send(new Host.Application.Klipy.Queries.GetTrendingGifsQuery(), ct); + var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetTrendingGifsQuery(), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description }); }); group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) => { - var result = await sender.Send(new Host.Application.Klipy.Queries.SearchGifsQuery(q), ct); + var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.SearchGifsQuery(q), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description }); }); } diff --git a/backend/src/Modules/Relations/DependencyInjection.cs b/backend/src/Modules/Relations/DependencyInjection.cs index 915efbd..86feb2b 100644 --- a/backend/src/Modules/Relations/DependencyInjection.cs +++ b/backend/src/Modules/Relations/DependencyInjection.cs @@ -15,7 +15,7 @@ public static class DependencyInjection services.AddDbContext(options => options.UseNpgsql(connectionString)); - services.AddScoped(); + services.AddScoped(); services.AddMediatR(config => config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); diff --git a/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs b/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs index dc329b2..1b7196d 100644 --- a/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs +++ b/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs @@ -1,12 +1,12 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Relations.Domain; +using Knot.Shared.Kernel; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; -using Knot.Modules.Relations.Domain; -using Knot.Shared.Kernel; -using System.Linq; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; namespace Knot.Modules.Relations.Infrastructure.Persistence; @@ -22,6 +22,7 @@ public sealed class RelationsDbContext : DbContext public DbSet Contacts => Set(); public DbSet UserReplicas => Set(); + public DbSet Friendships => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -40,6 +41,14 @@ public sealed class RelationsDbContext : DbContext builder.ToTable("UserReplicas"); builder.HasKey(r => r.Id); }); + + modelBuilder.Entity(builder => + { + builder.ToTable("Friendships"); + builder.HasKey(f => f.Id); + builder.Property(f => f.Status).HasConversion(); + builder.HasIndex(f => new { f.UserId, f.FriendId }).IsUnique(); + }); } public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContextFactory.cs b/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContextFactory.cs new file mode 100644 index 0000000..de22aa4 --- /dev/null +++ b/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContextFactory.cs @@ -0,0 +1,33 @@ +using System.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace Knot.Modules.Relations.Infrastructure.Persistence; + +public class RelationsDbContextFactory : IDesignTimeDbContextFactory +{ + public RelationsDbContext CreateDbContext(string[] args) + { + var basePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "Host"); + if (!Directory.Exists(basePath)) + { + basePath = Directory.GetCurrentDirectory(); + } + + var configuration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"; + + optionsBuilder.UseNpgsql(connectionString); + + return new RelationsDbContext(optionsBuilder.Options, null!); + } +} diff --git a/backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.Designer.cs b/backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.Designer.cs new file mode 100644 index 0000000..90820fd --- /dev/null +++ b/backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.Designer.cs @@ -0,0 +1,114 @@ +// +using System; +using Knot.Modules.Relations.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.Modules.Relations.Migrations +{ + [DbContext(typeof(RelationsDbContext))] + [Migration("20260401112051_AddFriendships")] + partial class AddFriendships + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("relations") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Knot.Modules.Relations.Domain.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ContactId") + .IsUnique(); + + b.ToTable("Contacts", "relations"); + }); + + modelBuilder.Entity("Knot.Modules.Relations.Domain.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FriendId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FriendId") + .IsUnique(); + + b.ToTable("Friendships", "relations"); + }); + + modelBuilder.Entity("Knot.Modules.Relations.Domain.UserReplica", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .IsRequired() + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Domain") + .HasColumnType("text"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("UserReplicas", "relations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.cs b/backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.cs new file mode 100644 index 0000000..9b5c0f6 --- /dev/null +++ b/backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.cs @@ -0,0 +1,97 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Knot.Modules.Relations.Migrations +{ + /// + public partial class AddFriendships : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "relations"); + + migrationBuilder.CreateTable( + name: "Contacts", + schema: "relations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ContactId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Contacts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Friendships", + schema: "relations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + FriendId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Friendships", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UserReplicas", + schema: "relations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Username = table.Column(type: "text", nullable: false), + DisplayName = table.Column(type: "text", nullable: false), + Avatar = table.Column(type: "text", nullable: false), + IsExternal = table.Column(type: "boolean", nullable: false), + Domain = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserReplicas", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Contacts_UserId_ContactId", + schema: "relations", + table: "Contacts", + columns: new[] { "UserId", "ContactId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_UserId_FriendId", + schema: "relations", + table: "Friendships", + columns: new[] { "UserId", "FriendId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Contacts", + schema: "relations"); + + migrationBuilder.DropTable( + name: "Friendships", + schema: "relations"); + + migrationBuilder.DropTable( + name: "UserReplicas", + schema: "relations"); + } + } +} diff --git a/backend/src/Modules/Relations/Migrations/RelationsDbContextModelSnapshot.cs b/backend/src/Modules/Relations/Migrations/RelationsDbContextModelSnapshot.cs new file mode 100644 index 0000000..03aa5dc --- /dev/null +++ b/backend/src/Modules/Relations/Migrations/RelationsDbContextModelSnapshot.cs @@ -0,0 +1,111 @@ +// +using System; +using Knot.Modules.Relations.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Knot.Modules.Relations.Migrations +{ + [DbContext(typeof(RelationsDbContext))] + partial class RelationsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("relations") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Knot.Modules.Relations.Domain.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ContactId") + .IsUnique(); + + b.ToTable("Contacts", "relations"); + }); + + modelBuilder.Entity("Knot.Modules.Relations.Domain.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FriendId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FriendId") + .IsUnique(); + + b.ToTable("Friendships", "relations"); + }); + + modelBuilder.Entity("Knot.Modules.Relations.Domain.UserReplica", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .IsRequired() + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Domain") + .HasColumnType("text"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("UserReplicas", "relations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Storage/Presentation/Endpoints/FilesEndpoints.cs b/backend/src/Modules/Storage/Presentation/Endpoints/FilesEndpoints.cs index db44cf0..e792775 100644 --- a/backend/src/Modules/Storage/Presentation/Endpoints/FilesEndpoints.cs +++ b/backend/src/Modules/Storage/Presentation/Endpoints/FilesEndpoints.cs @@ -9,17 +9,43 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Caching.Memory; -namespace Knot.Host.Presentation.Endpoints; +namespace Knot.Modules.Storage.Presentation.Endpoints; public static class FilesEndpoints { public static void MapFilesEndpoints(this WebApplication app) { - var group = app.MapGroup("api/files"); - - group.MapGet("{id}", async (string id, [FromQuery] bool download, [FromServices] IFileStorageService fileStorage, [FromServices] IMemoryCache cache, CancellationToken ct) => + // Federation remote endpoint - most specific, must be first + app.MapGet("/api/files/remote/{domain}/{id}", async (string domain, string id, HttpClient httpClient, ISettingsService settingsService, CancellationToken ct) => + { + var settings = await settingsService.GetSettingsAsync(ct); + if (!settings.Federation.Enabled) return Results.Forbid(); + + var targetUrl = $"{domain.TrimEnd('/')}/api/federation/v1/proxy/{id}"; + + var request = new HttpRequestMessage(HttpMethod.Get, targetUrl); + request.Headers.Add("X-Knot-Domain", settings.System.DomainUrl); + + using (var rsa = RSA.Create()) + { + rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _); + var dataToSign = Encoding.UTF8.GetBytes(id + settings.System.DomainUrl); + var signature = Convert.ToBase64String(rsa.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + request.Headers.Add("X-Knot-Signature", signature); + } + + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + if (!response.IsSuccessStatusCode) return Results.NotFound(); + + var remoteStream = await response.Content.ReadAsStreamAsync(ct); + var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream"; + + return Results.Stream(remoteStream, contentType, enableRangeProcessing: true); + }); + + // Main file download endpoint - without group prefix to avoid routing issues + app.MapGet("/api/files/{id}", async ([FromRoute] string id, [FromServices] IFileStorageService fileStorage, [FromServices] IMemoryCache cache, HttpContext httpContext, [FromQuery] bool download = false, CancellationToken ct = default) => { - // (Текущая логика локальных файлов остается без изменений) try { var cacheKey = $"file_{id}"; @@ -50,39 +76,9 @@ public static class FilesEndpoints } catch (Exception ex) { - return Results.NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message }); + return Results.NotFound(new { error = "Файл не найден", message = ex.Message }); } }); - - // Новый эндпоинт для удаленных (федеративных) файлов - group.MapGet("/remote/{domain}/{id}", async (string domain, string id, HttpClient httpClient, ISettingsService settingsService, CancellationToken ct) => - { - var settings = await settingsService.GetSettingsAsync(ct); - if (!settings.Federation.Enabled) return Results.Forbid(); - - // Формируем подписанный запрос к удаленному серверу - var targetUrl = $"{domain.TrimEnd('/')}/api/federation/v1/proxy/{id}"; - - var request = new HttpRequestMessage(HttpMethod.Get, targetUrl); - request.Headers.Add("X-Knot-Domain", settings.System.DomainUrl); - - // Генерируем подпись запроса своим приватным ключом - using (var rsa = RSA.Create()) - { - rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _); - var dataToSign = Encoding.UTF8.GetBytes(id + settings.System.DomainUrl); - var signature = Convert.ToBase64String(rsa.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); - request.Headers.Add("X-Knot-Signature", signature); - } - - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); - if (!response.IsSuccessStatusCode) return Results.NotFound(); - - var remoteStream = await response.Content.ReadAsStreamAsync(ct); - var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream"; - - return Results.Stream(remoteStream, contentType, enableRangeProcessing: true); - }); } } diff --git a/backend/src/Modules/Stories/DependencyInjection.cs b/backend/src/Modules/Stories/DependencyInjection.cs index 646c207..78ceffa 100644 --- a/backend/src/Modules/Stories/DependencyInjection.cs +++ b/backend/src/Modules/Stories/DependencyInjection.cs @@ -1,6 +1,7 @@ using System.Net.Http; using Knot.Contracts.Stories.Infrastructure.Persistence; using Knot.Modules.Stories.Application.Abstractions; +using Knot.Modules.Stories.Infrastructure.Database; using Knot.Modules.Stories.Infrastructure.Persistence.Mongo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -14,6 +15,7 @@ public static class DependencyInjection public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration) { services.AddScoped(); + services.AddScoped(); services.AddScoped(); var connectionString = configuration.GetConnectionString("DefaultConnection"); diff --git a/backend/src/Modules/Stories/Infrastructure/Database/StoriesDbContextFactory.cs b/backend/src/Modules/Stories/Infrastructure/Database/StoriesDbContextFactory.cs new file mode 100644 index 0000000..440f884 --- /dev/null +++ b/backend/src/Modules/Stories/Infrastructure/Database/StoriesDbContextFactory.cs @@ -0,0 +1,33 @@ +using System.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace Knot.Modules.Stories.Infrastructure.Database; + +public class StoriesDbContextFactory : IDesignTimeDbContextFactory +{ + public StoriesDbContext CreateDbContext(string[] args) + { + var basePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "Host"); + if (!Directory.Exists(basePath)) + { + basePath = Directory.GetCurrentDirectory(); + } + + var configuration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"; + + optionsBuilder.UseNpgsql(connectionString); + + return new StoriesDbContext(optionsBuilder.Options); + } +} diff --git a/backend/src/Modules/Stories/Infrastructure/Database/UserRepository.cs b/backend/src/Modules/Stories/Infrastructure/Database/UserRepository.cs new file mode 100644 index 0000000..b8319a7 --- /dev/null +++ b/backend/src/Modules/Stories/Infrastructure/Database/UserRepository.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Contracts.Profiles.Domain; +using Knot.Contracts.Relations.Domain; +using Knot.Modules.Stories.Application.Abstractions; + +namespace Knot.Modules.Stories.Infrastructure.Database; + +internal sealed class UserRepository : IUserRepository +{ + private readonly IProfileRepository _profileRepository; + + public UserRepository(IProfileRepository profileRepository) + { + _profileRepository = profileRepository; + } + + public async Task ExistsAsync(Guid id, CancellationToken cancellationToken = default) + { + var profile = await _profileRepository.GetAsync(id, cancellationToken); + return profile != null; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var profile = await _profileRepository.GetAsync(id, cancellationToken); + if (profile == null) + { + return null; + } + + return new UserReplica( + profile.UserId, + profile.Username ?? string.Empty, + profile.DisplayName ?? string.Empty, + profile.Avatar ?? string.Empty, + false, + null + ); + } +} diff --git a/backend/src/Modules/Stories/Knot.Modules.Stories.csproj b/backend/src/Modules/Stories/Knot.Modules.Stories.csproj index 2778c4e..d369966 100644 --- a/backend/src/Modules/Stories/Knot.Modules.Stories.csproj +++ b/backend/src/Modules/Stories/Knot.Modules.Stories.csproj @@ -7,6 +7,7 @@ + diff --git a/backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.Designer.cs b/backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.Designer.cs new file mode 100644 index 0000000..e3a9b60 --- /dev/null +++ b/backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.Designer.cs @@ -0,0 +1,54 @@ +// +using System; +using Knot.Modules.Stories.Infrastructure.Database; +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.Modules.Stories.Migrations +{ + [DbContext(typeof(StoriesDbContext))] + [Migration("20260401112239_InitialStories")] + partial class InitialStories + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("stories") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Knot.Modules.Stories.Domain.StoryViewer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ViewedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("StoryId", "UserId") + .IsUnique(); + + b.ToTable("StoryViewers", "stories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.cs b/backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.cs new file mode 100644 index 0000000..8bc4d8d --- /dev/null +++ b/backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Knot.Modules.Stories.Migrations +{ + /// + public partial class InitialStories : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "stories"); + + migrationBuilder.CreateTable( + name: "StoryViewers", + schema: "stories", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + StoryId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ViewedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StoryViewers", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_StoryViewers_StoryId_UserId", + schema: "stories", + table: "StoryViewers", + columns: new[] { "StoryId", "UserId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StoryViewers", + schema: "stories"); + } + } +} diff --git a/backend/src/Modules/Stories/Migrations/StoriesDbContextModelSnapshot.cs b/backend/src/Modules/Stories/Migrations/StoriesDbContextModelSnapshot.cs new file mode 100644 index 0000000..62101df --- /dev/null +++ b/backend/src/Modules/Stories/Migrations/StoriesDbContextModelSnapshot.cs @@ -0,0 +1,51 @@ +// +using System; +using Knot.Modules.Stories.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Knot.Modules.Stories.Migrations +{ + [DbContext(typeof(StoriesDbContext))] + partial class StoriesDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("stories") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Knot.Modules.Stories.Domain.StoryViewer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ViewedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("StoryId", "UserId") + .IsUnique(); + + b.ToTable("StoryViewers", "stories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx index 816b87d..9c3b30f 100644 --- a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -430,16 +430,16 @@ function MessageBubble({ !needsFrame ? 'p-0 shadow-none border-none bg-transparent' : isMine - ? 'bubble-sent px-4 py-2.5 hover:bg-primary/[0.12] rounded-2xl rounded-br-[4px]' - : 'bubble-received px-4 py-2.5 hover:bg-surface-container-highest/80 rounded-2xl rounded-bl-[4px]' + ? 'bubble-sent px-4 py-3 hover:brightness-110' + : 'bubble-received px-4 py-3 hover:brightness-110' }`} > {/* Reply */} - {message.replyTo && ( + {message.replyTo && (
{ e.stopPropagation(); @@ -451,12 +451,12 @@ function MessageBubble({ } }} > -

+

{message.replyTo.sender?.displayName || message.replyTo.sender?.userName || message.replyTo.sender?.username || ''}

{message.replyTo.isDeleted ? ( -

{t('messageDeleted')}

+

{t('messageDeleted')}

) : ( <> {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => { @@ -466,13 +466,13 @@ function MessageBubble({
{m.type === 'image' ? ( isMp4 ? ( -
); })()} -

+

{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => { const m = message.replyTo.media[0]; if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF'; @@ -522,7 +522,7 @@ function MessageBubble({ )}

)} -

+

{message.quote}

@@ -633,29 +633,27 @@ function MessageBubble({ ); })()} - {/* Голосовое */} + {/* Голосовое - Optimized Kinetic Layout */} {hasVoice && ( -
+
); })()} + + {Object.keys(reactionGroups).length > 0 && ( +
+ {Object.entries(reactionGroups).map(([emoji, data]) => ( + + ))} +
+ )}
{isMine && ( @@ -877,7 +862,7 @@ function MessageBubble({ initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} - className="fixed z-[9999] w-52 rounded-[1.25rem] glass-strong shadow-2xl py-1.5 overflow-hidden border border-white/10" + className="fixed z-[9999] w-52 rounded-[1.25rem] bg-[#1a1a1a]/95 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] py-1.5 overflow-hidden border border-white/10" style={{ left: contextPos.x, top: contextPos.y }} onClick={(e) => e.stopPropagation()} onContextMenu={(e) => { diff --git a/client-web/src/modules/chats/presentation/components/MessageInput.tsx b/client-web/src/modules/chats/presentation/components/MessageInput.tsx index a7d1e62..1a8df44 100644 --- a/client-web/src/modules/chats/presentation/components/MessageInput.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageInput.tsx @@ -130,8 +130,10 @@ export default function MessageInput({ chatId }: MessageInputProps) { useEffect(() => { const el = inputRef.current; if (el) { - el.style.height = 'auto'; - el.style.height = Math.min(el.scrollHeight, 150) + 'px'; + el.style.height = '22px'; // Reset/Min height + if (text) { + el.style.height = Math.min(el.scrollHeight, 150) + 'px'; + } } }, [text]); @@ -616,12 +618,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
-

+

{editingMessage ? t('editing') : `${t('replyTo')} ${replyTo?.sender?.displayName || replyTo?.sender?.userName || replyTo?.sender?.username || ''}`}

-
+
{replyTo?.quote ? `«${replyTo.quote}»` : (editingMessage || replyTo)?.content || t('media') || 'Медиа'}
@@ -739,14 +741,14 @@ export default function MessageInput({ chatId }: MessageInputProps) {
) : ( -
- {/* Attach */} -
+
+ {/* Attach - Outside */} +
{showAttachMenu && ( @@ -780,129 +782,73 @@ export default function MessageInput({ chatId }: MessageInputProps) { )} - - - + +
- {/* Input */} -
- {/* @Mention popup */} - - {mentionQuery !== null && filteredMembers.length > 0 && ( - - {filteredMembers.map((m, i) => ( - + +
+