Админка

This commit is contained in:
Халимов Рустам
2026-03-28 01:34:51 +03:00
parent ba5c5210d4
commit 9bae9752cc
22 changed files with 1590 additions and 958 deletions

View File

@@ -108,6 +108,12 @@ builder.Services.AddRouting(options =>
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{

View File

@@ -0,0 +1,33 @@
using Knot.Modules.Auth.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Commands;
public record BanUserCommand(Guid UserId) : ICommand<Result>;
internal sealed class BanUserCommandHandler : ICommandHandler<BanUserCommand, Result>
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork;
public BanUserCommandHandler(IUserRepository userRepository, IAuthUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Result>> Handle(BanUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null) return Result.Failure<Result>(Error.NotFound("User.NotFound", "User not found"));
user.Ban();
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(Result.Success());
}
}

View File

@@ -0,0 +1,33 @@
using Knot.Modules.Auth.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Commands;
public record UnbanUserCommand(Guid UserId) : ICommand<Result>;
internal sealed class UnbanUserCommandHandler : ICommandHandler<UnbanUserCommand, Result>
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork;
public UnbanUserCommandHandler(IUserRepository userRepository, IAuthUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Result>> Handle(UnbanUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null) return Result.Failure<Result>(Error.NotFound("User.NotFound", "User not found"));
user.Unban();
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(Result.Success());
}
}

View File

@@ -8,95 +8,106 @@ using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Stories.Domain;
namespace Knot.Modules.Admin.Application.Admin.Commands;
public record CleanRunCommand(IFileStorageService FileStorage, AuthDbContext IdentityDb) : ICommand<MessageResponse>;
public record CleanRunCommand() : ICommand<MessageResponse>;
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
private readonly IMongoCollection<Story> _stories;
private readonly IFileStorageService _fileStorage;
private readonly AuthDbContext _identityDb;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb, IFileStorageService fileStorage, AuthDbContext identityDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages");
_stories = mongoDb.GetCollection<Story>("stories");
_fileStorage = fileStorage;
_identityDb = identityDb;
}
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
try
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
var orphanMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted)
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted)
.ToList();
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
var activeStoryUrls = allStories
.Where(s => !string.IsNullOrEmpty(s.MediaUrl))
.Select(s => s.MediaUrl!);
foreach (var u in activeMessageUrls) validUrls.Add(u!);
foreach (var u in activeChatUrls) validUrls.Add(u);
foreach (var u in activeUserUrls) validUrls.Add(u);
foreach (var u in activeStoryUrls) validUrls.Add(u);
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
foreach (var file in allMinioFiles)
{
await request.FileStorage.DeleteFileAsync(file.FileId);
if (!validFileIds.Contains(file.FileId))
{
await _fileStorage.DeleteFileAsync(file.FileId);
}
}
}
if (orphanMessages.Any())
if (orphanMessages.Any())
{
var orphanIds = orphanMessages.Select(m => m.Id).ToList();
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));
}
catch (Exception ex)
{
var orphanIds = orphanMessages.Select(m => m.Id).ToList();
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
return Result.Failure<MessageResponse>(new Error("Cleanup.ProcessError", $"Cleanup failed: {ex.Message}"));
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));
}
}

View File

@@ -8,7 +8,6 @@ using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Application.Abstractions;

View File

@@ -8,7 +8,7 @@ using Knot.Modules.Stories.Application.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
public record TestKlipyConnectionCommand() : ICommand<bool>;
public record TestKlipyConnectionCommand(string ApiKey, string AppName) : ICommand<bool>;
internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler<TestKlipyConnectionCommand, bool>
{
@@ -23,13 +23,12 @@ internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler<TestKl
public async Task<Result<bool>> Handle(TestKlipyConnectionCommand request, CancellationToken ct)
{
var settings = await _settingsService.GetSettingsAsync(ct);
if (string.IsNullOrEmpty(settings.Klipy.ApiKey))
if (string.IsNullOrEmpty(request.ApiKey))
{
return Result.Failure<bool>(new Error("Klipy.KeyMissing", "API Key for Klipy is not configured."));
}
var isOk = await _klipyClient.TestConnectionAsync(settings.Klipy.ApiKey, ct);
var isOk = await _klipyClient.TestConnectionAsync(request.ApiKey, request.AppName, ct);
if (!isOk)
{
return Result.Failure<bool>(new Error("Klipy.AuthFailed", "Klipy API test request failed. Verify your API key."));

View File

@@ -11,5 +11,6 @@ public record AdminUserDetailsDto(
DateTime CreatedAt,
bool IsOnline,
DateTime LastOnlineAt,
bool IsBanned,
AdminUserStatsDto Stats
);

View File

@@ -10,5 +10,6 @@ public record AdminUserDto(
string? Avatar,
DateTime CreatedAt,
bool IsOnline,
DateTime LastOnlineAt
DateTime LastOnlineAt,
bool IsBanned
);

View File

@@ -8,98 +8,108 @@ using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Stories.Domain;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users;
namespace Knot.Modules.Admin.Application.Admin.Queries;
public record CleanDryRunQuery(IFileStorageService FileStorage, AuthDbContext IdentityDb) : IQuery<CleanupDryRunResultDto>;
public record CleanDryRunQuery() : IQuery<CleanupDryRunResultDto>;
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
private readonly IMongoCollection<Story> _stories;
private readonly IFileStorageService _fileStorage;
private readonly AuthDbContext _identityDb;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb, IFileStorageService fileStorage, AuthDbContext identityDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages");
_stories = mongoDb.GetCollection<Story>("stories");
_fileStorage = fileStorage;
_identityDb = identityDb;
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanedMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var orphanedMessagesCount = orphanedMessages.Count;
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
try
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
long safeBytes = 0;
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
var orphanedMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted)
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted)
.ToList();
var orphanedMessagesCount = orphanedMessages.Count;
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
var activeStoryUrls = allStories
.Where(s => !string.IsNullOrEmpty(s.MediaUrl))
.Select(s => s.MediaUrl!);
foreach (var u in activeMessageUrls) validUrls.Add(u!);
foreach (var u in activeChatUrls) validUrls.Add(u);
foreach (var u in activeUserUrls) validUrls.Add(u);
foreach (var u in activeStoryUrls) validUrls.Add(u);
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
long safeBytes = 0;
foreach (var file in allMinioFiles)
{
safeBytes += file.Size;
if (!validFileIds.Contains(file.FileId))
{
safeBytes += file.Size;
}
}
}
return Result.Success(new CleanupDryRunResultDto
return Result.Success(new CleanupDryRunResultDto
{
OrphanedMessagesCount = orphanedMessagesCount,
OrphanedMediaBytes = safeBytes
});
}
catch (Exception ex)
{
OrphanedMessagesCount = orphanedMessagesCount,
OrphanedMediaBytes = safeBytes
});
return Result.Failure<CleanupDryRunResultDto>(new Error("Cleanup.ProcessError", $"Failed to analyze junk data: {ex.Message}"));
}
}
}

View File

@@ -10,7 +10,6 @@ using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
@@ -37,32 +36,49 @@ internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQ
return Result.Failure<AdminUserDetailsDto>(AuthErrors.UserNotFound);
}
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
var userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
List<Message> userMessages;
try
{
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
}
catch (Exception)
{
userMessages = new List<Message>();
}
var messagesCount = userMessages.Count;
var allUserMedia = userMessages.OfType<MediaMessage>().SelectMany(m => m.Media).ToList();
var mediaMessages = userMessages.OfType<MediaMessage>().ToList();
var allUserMedia = mediaMessages
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.ToList();
var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video");
var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio");
var storageUsed = allUserMedia.Sum(m => m.Size ?? 0);
var userContents = userMessages.OfType<TextMessage>().Select(m => m.Content)
.Concat(userMessages.OfType<MediaMessage>().Where(m => m.Caption != null).Select(m => m.Caption))
var userContents = userMessages.OfType<TextMessage>()
.Select(m => m.Content)
.Concat(mediaMessages.Where(m => m.Caption != null).Select(m => m.Caption))
.Where(c => c != null)
.ToList();
var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http"));
var linksCount = userContents.Count(c => c != null && c.Contains("http", StringComparison.OrdinalIgnoreCase));
var isOnline = Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString());
var result = new AdminUserDetailsDto(
targetUser.Id,
targetUser.Username,
targetUser.DisplayName,
targetUser.Username ?? "unknown",
targetUser.DisplayName ?? "User",
targetUser.Bio,
targetUser.Avatar,
targetUser.CreatedAt,
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()),
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt,
isOnline,
isOnline ? DateTime.UtcNow : (targetUser.LastSeen ?? targetUser.CreatedAt),
targetUser.IsBanned,
new AdminUserStatsDto(
messagesCount,
mediaCount,

View File

@@ -38,7 +38,8 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery,
u.Avatar,
u.CreatedAt,
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()),
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt,
u.IsBanned
)).ToList();
return Result.Success(result);

View File

@@ -15,8 +15,12 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
using Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
namespace Knot.Host.Presentation.Endpoints;
public record KlipyTestDto(string ApiKey, string AppName);
public sealed class AdminEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
@@ -35,9 +39,9 @@ public sealed class AdminEndpoints : ICarterModule
return Results.Ok(result.Value);
});
group.MapPost("settings/test-klipy", async (ISender sender, CancellationToken ct) =>
group.MapPost("settings/test-klipy", async ([FromBody] KlipyTestDto dto, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new TestKlipyConnectionCommand(), ct);
var result = await sender.Send(new TestKlipyConnectionCommand(dto.ApiKey, dto.AppName), ct);
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description });
});
@@ -67,6 +71,24 @@ public sealed class AdminEndpoints : ICarterModule
return Results.Ok(result.Value);
});
group.MapPost("users/{userId:guid}/ban", async (Guid userId, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new BanUserCommand(userId), ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapPost("users/{userId:guid}/unban", async (Guid userId, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new UnbanUserCommand(userId), ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapDelete("users/{userId:guid}", async (Guid userId, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new DeleteUserCommand(userId), ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
{
var result = await sender.Send(new SearchUsersQuery(query), ct);
@@ -79,15 +101,25 @@ public sealed class AdminEndpoints : ICarterModule
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound("User not found");
});
group.MapGet("clean/dry-run", async ([FromServices] IFileStorageService fileStorage, [FromServices] AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
group.MapGet("clean/dry-run", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct);
var result = await sender.Send(new CleanDryRunQuery(), ct);
if (!result.IsSuccess)
{
Console.WriteLine($"[Admin] Cleanup Dry Run Error: {result.Error.Description}");
return Results.BadRequest(new { error = result.Error.Description });
}
return Results.Ok(result.Value);
});
group.MapPost("clean/run", async ([FromServices] IFileStorageService fileStorage, [FromServices] AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
group.MapPost("clean/run", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanRunCommand(fileStorage, identityDb), ct);
var result = await sender.Send(new CleanRunCommand(), ct);
if (!result.IsSuccess)
{
Console.WriteLine($"[Admin] Cleanup Run Error: {result.Error.Description}");
return Results.BadRequest(new { error = result.Error.Description });
}
return Results.Ok(result.Value);
});
}

View File

@@ -24,6 +24,10 @@ public sealed class User : AggregateRoot<Guid>
public bool IsOnline { get; private set; }
public DateTime? LastSeen { get; private set; }
public bool HideStatus { get; private set; }
public bool IsBanned { get; private set; }
public void Ban() => IsBanned = true;
public void Unban() => IsBanned = false;
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
: base(id)
@@ -34,6 +38,7 @@ public sealed class User : AggregateRoot<Guid>
Email = email;
Bio = bio;
CreatedAt = DateTime.UtcNow;
IsBanned = false;
}
/// <summary>

View File

@@ -0,0 +1,94 @@
// <auto-generated />
using System;
using Knot.Modules.Auth.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.Auth.Migrations
{
[DbContext(typeof(AuthDbContext))]
[Migration("20270327160000_AddUserBanField")]
partial class AddUserBanField
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddUserBanField : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBanned",
schema: "identity",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBanned",
schema: "identity",
table: "Users");
}
}
}

View File

@@ -60,6 +60,9 @@ namespace Knot.Modules.Auth.Migrations
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");

View File

@@ -36,6 +36,7 @@ public class MessagesConfig
public int ChatMessageLimit { get; set; } = 0;
public bool AllowMedia { get; set; } = true;
public int MaxMediaSizeBytes { get; set; } = 50 * 1024 * 1024;
public int MaxFileSize { get; set; } = 100 * 1024 * 1024;
public List<string> AllowedMediaTypes { get; set; } = new() { "image/jpeg", "image/png", "video/mp4", "image/gif" };
public bool AllowVoiceMessages { get; set; } = true;
public bool AllowForwarding { get; set; } = true;

View File

@@ -6,6 +6,6 @@ namespace Knot.Modules.Stories.Application.Abstractions;
public interface IKlipyClient
{
Task<bool> TestConnectionAsync(string apiKey, CancellationToken ct = default);
Task<List<string>> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default);
Task<bool> TestConnectionAsync(string apiKey, string appName, CancellationToken ct = default);
Task<List<string>> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default);
}

View File

@@ -2,8 +2,10 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Infrastructure.External;
using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
using MongoDB.Driver;
using System.Net.Http;
namespace Knot.Modules.Stories;
@@ -12,6 +14,7 @@ public static class DependencyInjection
public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<IStoryRepository, StoryRepository>();
services.AddHttpClient<IKlipyClient, KlipyClient>();
var connectionString = configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<Infrastructure.Database.StoriesDbContext>(options =>

View File

@@ -16,12 +16,13 @@ public sealed class KlipyClient : IKlipyClient
_httpClient = httpClient;
}
public async Task<bool> TestConnectionAsync(string apiKey, CancellationToken ct = default)
public async Task<bool> TestConnectionAsync(string apiKey, string appName, CancellationToken ct = default)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.klipy.co/v1/trending?limit=1");
request.Headers.Add("X-API-KEY", apiKey);
// According to docs: https://api.klipy.com/api/v1/{app_key}/gifs/trending
var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.klipy.com/api/v1/{appName}/gifs/trending?page=1&per_page=1&customer_id=knot_admin_test");
request.Headers.Add("X-KLIPY-API-KEY", apiKey);
using var response = await _httpClient.SendAsync(request, ct);
return response.IsSuccessStatusCode;
@@ -32,9 +33,9 @@ public sealed class KlipyClient : IKlipyClient
}
}
public async Task<List<string>> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default)
public async Task<List<string>> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default)
{
// В реальном проекте: десериализация ответа от Klipy API
// To be implemented as needed
return new List<string>();
}
}

View File

@@ -15,7 +15,9 @@ export class HttpClient {
const isFormData = fetchOptions.body instanceof FormData;
const computedHeaders: Record<string, string> = {
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
...(this.token
? { Authorization: this.token.startsWith('Basic ') ? this.token : `Bearer ${this.token}` }
: {}),
...(fetchOptions.headers as Record<string, string>),
};

File diff suppressed because it is too large Load Diff