Админка

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
+6
View File
@@ -108,6 +108,12 @@ builder.Services.AddRouting(options =>
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddSignalR() builder.Services.AddSignalR()
.AddJsonProtocol(options => .AddJsonProtocol(options =>
{ {
@@ -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());
}
}
@@ -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());
}
}
@@ -8,28 +8,36 @@ using System.Threading.Tasks;
using MediatR; using MediatR;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; 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 MongoDB.Driver;
using Microsoft.EntityFrameworkCore; 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; 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> internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{ {
private readonly ChatsDbContext _chatsDbContext; private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages; 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; _chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages"); _messages = mongoDb.GetCollection<Message>("messages");
_stories = mongoDb.GetCollection<Story>("stories");
_fileStorage = fileStorage;
_identityDb = identityDb;
} }
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken) public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{
try
{ {
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
@@ -37,16 +45,18 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken); var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanMessages = allMessages var orphanMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId)) .Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted)
.ToList(); .ToList();
var keptMessages = allMessages var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId)) .Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted)
.ToList(); .ToList();
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList(); var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken); var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
var validUrls = new HashSet<string>(); var validUrls = new HashSet<string>();
@@ -64,18 +74,14 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
.Where(u => !string.IsNullOrEmpty(u.Avatar)) .Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!); .Select(u => u.Avatar!);
foreach (var u in activeMessageUrls) var activeStoryUrls = allStories
{ .Where(s => !string.IsNullOrEmpty(s.MediaUrl))
validUrls.Add(u!); .Select(s => s.MediaUrl!);
}
foreach (var u in activeChatUrls) foreach (var u in activeMessageUrls) validUrls.Add(u!);
{ foreach (var u in activeChatUrls) validUrls.Add(u);
validUrls.Add(u); foreach (var u in activeUserUrls) validUrls.Add(u);
} foreach (var u in activeStoryUrls) validUrls.Add(u);
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls var validFileIds = validUrls
.Where(u => u.Contains("/api/files/")) .Where(u => u.Contains("/api/files/"))
@@ -86,7 +92,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
{ {
if (!validFileIds.Contains(file.FileId)) if (!validFileIds.Contains(file.FileId))
{ {
await request.FileStorage.DeleteFileAsync(file.FileId); await _fileStorage.DeleteFileAsync(file.FileId);
} }
} }
@@ -99,4 +105,9 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
return Result.Success(new MessageResponse("Cleanup completed successfully")); return Result.Success(new MessageResponse("Cleanup completed successfully"));
} }
catch (Exception ex)
{
return Result.Failure<MessageResponse>(new Error("Cleanup.ProcessError", $"Cleanup failed: {ex.Message}"));
}
}
} }
@@ -8,7 +8,6 @@ using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Auth.DTOs; using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users; using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Auth.Infrastructure.Persistence; using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Application.Abstractions; using Knot.Modules.Auth.Application.Abstractions;
@@ -8,7 +8,7 @@ using Knot.Modules.Stories.Application.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Commands.TestKlipy; 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> 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) public async Task<Result<bool>> Handle(TestKlipyConnectionCommand request, CancellationToken ct)
{ {
var settings = await _settingsService.GetSettingsAsync(ct); if (string.IsNullOrEmpty(request.ApiKey))
if (string.IsNullOrEmpty(settings.Klipy.ApiKey))
{ {
return Result.Failure<bool>(new Error("Klipy.KeyMissing", "API Key for Klipy is not configured.")); 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) if (!isOk)
{ {
return Result.Failure<bool>(new Error("Klipy.AuthFailed", "Klipy API test request failed. Verify your API key.")); return Result.Failure<bool>(new Error("Klipy.AuthFailed", "Klipy API test request failed. Verify your API key."));
@@ -11,5 +11,6 @@ public record AdminUserDetailsDto(
DateTime CreatedAt, DateTime CreatedAt,
bool IsOnline, bool IsOnline,
DateTime LastOnlineAt, DateTime LastOnlineAt,
bool IsBanned,
AdminUserStatsDto Stats AdminUserStatsDto Stats
); );
@@ -10,5 +10,6 @@ public record AdminUserDto(
string? Avatar, string? Avatar,
DateTime CreatedAt, DateTime CreatedAt,
bool IsOnline, bool IsOnline,
DateTime LastOnlineAt DateTime LastOnlineAt,
bool IsBanned
); );
@@ -8,31 +8,38 @@ using System.Threading.Tasks;
using MediatR; using MediatR;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; 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 MongoDB.Driver;
using Microsoft.EntityFrameworkCore; 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.Auth.DTOs;
using Knot.Modules.Auth.Application.Users; using Knot.Modules.Auth.Application.Users;
namespace Knot.Modules.Admin.Application.Admin.Queries; 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> internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
{ {
private readonly ChatsDbContext _chatsDbContext; private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages; 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; _chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages"); _messages = mongoDb.GetCollection<Message>("messages");
_stories = mongoDb.GetCollection<Story>("stories");
_fileStorage = fileStorage;
_identityDb = identityDb;
} }
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken) public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
{
try
{ {
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
@@ -40,18 +47,20 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken); var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanedMessages = allMessages var orphanedMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId)) .Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted)
.ToList(); .ToList();
var keptMessages = allMessages var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId)) .Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted)
.ToList(); .ToList();
var orphanedMessagesCount = orphanedMessages.Count; var orphanedMessagesCount = orphanedMessages.Count;
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList(); var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken); var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
var validUrls = new HashSet<string>(); var validUrls = new HashSet<string>();
@@ -69,18 +78,14 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
.Where(u => !string.IsNullOrEmpty(u.Avatar)) .Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!); .Select(u => u.Avatar!);
foreach (var u in activeMessageUrls) var activeStoryUrls = allStories
{ .Where(s => !string.IsNullOrEmpty(s.MediaUrl))
validUrls.Add(u!); .Select(s => s.MediaUrl!);
}
foreach (var u in activeChatUrls) foreach (var u in activeMessageUrls) validUrls.Add(u!);
{ foreach (var u in activeChatUrls) validUrls.Add(u);
validUrls.Add(u); foreach (var u in activeUserUrls) validUrls.Add(u);
} foreach (var u in activeStoryUrls) validUrls.Add(u);
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls var validFileIds = validUrls
.Where(u => u.Contains("/api/files/")) .Where(u => u.Contains("/api/files/"))
@@ -102,4 +107,9 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
OrphanedMediaBytes = safeBytes OrphanedMediaBytes = safeBytes
}); });
} }
catch (Exception ex)
{
return Result.Failure<CleanupDryRunResultDto>(new Error("Cleanup.ProcessError", $"Failed to analyze junk data: {ex.Message}"));
}
}
} }
@@ -10,7 +10,6 @@ using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Auth.DTOs; using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users; using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using MongoDB.Driver; using MongoDB.Driver;
@@ -37,32 +36,49 @@ internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQ
return Result.Failure<AdminUserDetailsDto>(AuthErrors.UserNotFound); return Result.Failure<AdminUserDetailsDto>(AuthErrors.UserNotFound);
} }
List<Message> userMessages;
try
{
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId); var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
var userMessages = await _messages.Find(filter).ToListAsync(cancellationToken); userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
}
catch (Exception)
{
userMessages = new List<Message>();
}
var messagesCount = userMessages.Count; 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 mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video");
var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio"); var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio");
var storageUsed = allUserMedia.Sum(m => m.Size ?? 0); var storageUsed = allUserMedia.Sum(m => m.Size ?? 0);
var userContents = userMessages.OfType<TextMessage>().Select(m => m.Content) var userContents = userMessages.OfType<TextMessage>()
.Concat(userMessages.OfType<MediaMessage>().Where(m => m.Caption != null).Select(m => m.Caption)) .Select(m => m.Content)
.Concat(mediaMessages.Where(m => m.Caption != null).Select(m => m.Caption))
.Where(c => c != null)
.ToList(); .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( var result = new AdminUserDetailsDto(
targetUser.Id, targetUser.Id,
targetUser.Username, targetUser.Username ?? "unknown",
targetUser.DisplayName, targetUser.DisplayName ?? "User",
targetUser.Bio, targetUser.Bio,
targetUser.Avatar, targetUser.Avatar,
targetUser.CreatedAt, targetUser.CreatedAt,
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()), isOnline,
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt, isOnline ? DateTime.UtcNow : (targetUser.LastSeen ?? targetUser.CreatedAt),
targetUser.IsBanned,
new AdminUserStatsDto( new AdminUserStatsDto(
messagesCount, messagesCount,
mediaCount, mediaCount,
@@ -38,7 +38,8 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery,
u.Avatar, u.Avatar,
u.CreatedAt, u.CreatedAt,
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()), 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(); )).ToList();
return Result.Success(result); return Result.Success(result);
@@ -15,8 +15,12 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy; using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
using Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
namespace Knot.Host.Presentation.Endpoints; namespace Knot.Host.Presentation.Endpoints;
public record KlipyTestDto(string ApiKey, string AppName);
public sealed class AdminEndpoints : ICarterModule public sealed class AdminEndpoints : ICarterModule
{ {
public void AddRoutes(IEndpointRouteBuilder app) public void AddRoutes(IEndpointRouteBuilder app)
@@ -35,9 +39,9 @@ public sealed class AdminEndpoints : ICarterModule
return Results.Ok(result.Value); 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 }); 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); 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) => group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
{ {
var result = await sender.Send(new SearchUsersQuery(query), ct); 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"); 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); 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); return Results.Ok(result.Value);
}); });
} }
+5
View File
@@ -24,6 +24,10 @@ public sealed class User : AggregateRoot<Guid>
public bool IsOnline { get; private set; } public bool IsOnline { get; private set; }
public DateTime? LastSeen { get; private set; } public DateTime? LastSeen { get; private set; }
public bool HideStatus { 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) private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
: base(id) : base(id)
@@ -34,6 +38,7 @@ public sealed class User : AggregateRoot<Guid>
Email = email; Email = email;
Bio = bio; Bio = bio;
CreatedAt = DateTime.UtcNow; CreatedAt = DateTime.UtcNow;
IsBanned = false;
} }
/// <summary> /// <summary>
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -60,6 +60,9 @@ namespace Knot.Modules.Auth.Migrations
b.Property<bool>("IsExternal") b.Property<bool>("IsExternal")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsOnline") b.Property<bool>("IsOnline")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -36,6 +36,7 @@ public class MessagesConfig
public int ChatMessageLimit { get; set; } = 0; public int ChatMessageLimit { get; set; } = 0;
public bool AllowMedia { get; set; } = true; public bool AllowMedia { get; set; } = true;
public int MaxMediaSizeBytes { get; set; } = 50 * 1024 * 1024; 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 List<string> AllowedMediaTypes { get; set; } = new() { "image/jpeg", "image/png", "video/mp4", "image/gif" };
public bool AllowVoiceMessages { get; set; } = true; public bool AllowVoiceMessages { get; set; } = true;
public bool AllowForwarding { get; set; } = true; public bool AllowForwarding { get; set; } = true;
@@ -6,6 +6,6 @@ namespace Knot.Modules.Stories.Application.Abstractions;
public interface IKlipyClient public interface IKlipyClient
{ {
Task<bool> TestConnectionAsync(string apiKey, CancellationToken ct = default); Task<bool> TestConnectionAsync(string apiKey, string appName, CancellationToken ct = default);
Task<List<string>> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default); Task<List<string>> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default);
} }
@@ -2,8 +2,10 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Knot.Modules.Stories.Application.Abstractions; using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Infrastructure.External;
using Knot.Modules.Stories.Infrastructure.Persistence.Mongo; using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
using MongoDB.Driver; using MongoDB.Driver;
using System.Net.Http;
namespace Knot.Modules.Stories; namespace Knot.Modules.Stories;
@@ -12,6 +14,7 @@ public static class DependencyInjection
public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddScoped<IStoryRepository, StoryRepository>(); services.AddScoped<IStoryRepository, StoryRepository>();
services.AddHttpClient<IKlipyClient, KlipyClient>();
var connectionString = configuration.GetConnectionString("DefaultConnection"); var connectionString = configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<Infrastructure.Database.StoriesDbContext>(options => services.AddDbContext<Infrastructure.Database.StoriesDbContext>(options =>
@@ -16,12 +16,13 @@ public sealed class KlipyClient : IKlipyClient
_httpClient = httpClient; _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 try
{ {
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.klipy.co/v1/trending?limit=1"); // According to docs: https://api.klipy.com/api/v1/{app_key}/gifs/trending
request.Headers.Add("X-API-KEY", apiKey); 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); using var response = await _httpClient.SendAsync(request, ct);
return response.IsSuccessStatusCode; 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>(); return new List<string>();
} }
} }
@@ -15,7 +15,9 @@ export class HttpClient {
const isFormData = fetchOptions.body instanceof FormData; const isFormData = fetchOptions.body instanceof FormData;
const computedHeaders: Record<string, string> = { 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>), ...(fetchOptions.headers as Record<string, string>),
}; };
File diff suppressed because it is too large Load Diff