Очистка удаленных
This commit is contained in:
@@ -157,9 +157,150 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("clean/dry-run")]
|
||||
public async Task<IActionResult> CleanDryRun(
|
||||
[FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage,
|
||||
[FromServices] Knot.Modules.Identity.Infrastructure.Persistence.IdentityDbContext identityDb,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var orphanedMessages = await _chatsDbContext.Messages
|
||||
.Include(m => m.Media)
|
||||
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var orphanedMessagesCount = orphanedMessages.Count;
|
||||
|
||||
// Fetch all current physical files from MinIO
|
||||
var allMinioFiles = (await fileStorage.ListFilesAsync()).ToList();
|
||||
|
||||
// Collect ALL active URLs that we must preserve
|
||||
var keptMessages = await _chatsDbContext.Messages
|
||||
.AsNoTracking()
|
||||
.Include(m => m.Media)
|
||||
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(ct);
|
||||
var allUsers = await identityDb.Users.AsNoTracking().ToListAsync(ct);
|
||||
|
||||
var validUrls = new HashSet<string>();
|
||||
|
||||
var activeMessageUrls = keptMessages
|
||||
.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) validUrls.Add(u!);
|
||||
foreach(var u in activeChatUrls) validUrls.Add(u);
|
||||
foreach(var u in activeUserUrls) 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)
|
||||
{
|
||||
if (!validFileIds.Contains(file.FileId))
|
||||
{
|
||||
safeBytes += file.Size;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new CleanupDryRunResultDto
|
||||
{
|
||||
OrphanedMessagesCount = orphanedMessagesCount,
|
||||
OrphanedMediaBytes = safeBytes
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("clean/run")]
|
||||
public async Task<IActionResult> CleanRun(
|
||||
[FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage,
|
||||
[FromServices] Knot.Modules.Identity.Infrastructure.Persistence.IdentityDbContext identityDb,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var orphanMessages = await _chatsDbContext.Messages
|
||||
.Include(m => m.Media)
|
||||
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Fetch all current physical files from MinIO
|
||||
var allMinioFiles = (await fileStorage.ListFilesAsync()).ToList();
|
||||
|
||||
// Collect ALL active URLs that we must preserve
|
||||
var keptMessages = await _chatsDbContext.Messages
|
||||
.AsNoTracking()
|
||||
.Include(m => m.Media)
|
||||
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(ct);
|
||||
var allUsers = await identityDb.Users.AsNoTracking().ToListAsync(ct);
|
||||
|
||||
var validUrls = new HashSet<string>();
|
||||
|
||||
var activeMessageUrls = keptMessages
|
||||
.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) validUrls.Add(u!);
|
||||
foreach(var u in activeChatUrls) validUrls.Add(u);
|
||||
foreach(var u in activeUserUrls) validUrls.Add(u);
|
||||
|
||||
var validFileIds = validUrls
|
||||
.Where(u => u.Contains("/api/files/"))
|
||||
.Select(u => u.Split('/').Last())
|
||||
.ToHashSet();
|
||||
|
||||
// Physically delete from Storage
|
||||
foreach (var file in allMinioFiles)
|
||||
{
|
||||
if (!validFileIds.Contains(file.FileId))
|
||||
{
|
||||
await fileStorage.DeleteFileAsync(file.FileId);
|
||||
}
|
||||
}
|
||||
|
||||
if(orphanMessages.Any()) {
|
||||
_chatsDbContext.Messages.RemoveRange(orphanMessages);
|
||||
await _chatsDbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return Ok(new { message = "Cleanup completed successfully" });
|
||||
}
|
||||
}
|
||||
|
||||
public class ResetPasswordDto
|
||||
{
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class CleanupDryRunResultDto
|
||||
{
|
||||
public int OrphanedMessagesCount { get; set; }
|
||||
public long OrphanedMediaBytes { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -29,18 +29,38 @@ public class KlipyController : ControllerBase
|
||||
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
|
||||
return BadRequest(new { error = "Klipy is disabled or not configured." });
|
||||
|
||||
if (_cache.TryGetValue("klipy_trending", out JsonElement cachedResult))
|
||||
var cacheKeyTrending = $"klipy_trending_{conf.KlipyApiKey}";
|
||||
|
||||
if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult))
|
||||
{
|
||||
return Ok(cachedResult);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? "anonymous" : conf.KlipyCustomerId;
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/trending?page=1&per_page=30&customer_id={conf.KlipyCustomerId ?? "anonymous"}";
|
||||
var result = await client.GetFromJsonAsync<JsonElement>(url);
|
||||
var url = $"https://api.klipy.co/api/v1/{conf.KlipyApiKey}/gifs/trending?page=1&per_page=30&customer_id={customerId}";
|
||||
|
||||
_cache.Set("klipy_trending", result, TimeSpan.FromMinutes(60)); // Cache trending for 60 minutes
|
||||
var response = await client.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// fallback to api.klipy.com if .co failed with 404 or something, though maybe not needed
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) {
|
||||
url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/trending?page=1&per_page=30&customer_id={customerId}";
|
||||
response = await client.GetAsync(url);
|
||||
errorContent = await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return StatusCode(500, new { error = "Failed to fetch from Klipy", details = errorContent, url });
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
_cache.Set(cacheKeyTrending, result, TimeSpan.FromMinutes(60)); // Cache trending for 60 minutes
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -59,7 +79,7 @@ public class KlipyController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
return BadRequest(new { error = "Query is empty." });
|
||||
|
||||
var cacheKey = $"klipy_search_{q.ToLowerInvariant()}";
|
||||
var cacheKey = $"klipy_search_{conf.KlipyApiKey}_{q.ToLowerInvariant()}";
|
||||
if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult))
|
||||
{
|
||||
return Ok(cachedResult);
|
||||
@@ -67,9 +87,26 @@ public class KlipyController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? "anonymous" : conf.KlipyCustomerId;
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/search?page=1&per_page=30&q={Uri.EscapeDataString(q)}&customer_id={conf.KlipyCustomerId ?? "anonymous"}";
|
||||
var result = await client.GetFromJsonAsync<JsonElement>(url);
|
||||
var url = $"https://api.klipy.co/api/v1/{conf.KlipyApiKey}/gifs/search?page=1&per_page=30&q={Uri.EscapeDataString(q)}&customer_id={customerId}";
|
||||
|
||||
var response = await client.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) {
|
||||
url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/search?page=1&per_page=30&q={Uri.EscapeDataString(q)}&customer_id={customerId}";
|
||||
response = await client.GetAsync(url);
|
||||
errorContent = await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return StatusCode(500, new { error = "Failed to fetch from Klipy", details = errorContent, url });
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(15)); // Cache searches for 15 minutes
|
||||
return Ok(result);
|
||||
|
||||
@@ -25,7 +25,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Маппинг стандартных переменных окружения в иерархию .NET
|
||||
var envMappings = new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Database"] = builder.Configuration["DATABASE_URL"],
|
||||
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"],
|
||||
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
|
||||
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
|
||||
["Jwt:Audience"] = builder.Configuration["JWT_AUDIENCE"],
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Database": "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "knot_super_secret_key_1234567890_knot",
|
||||
"Issuer": "Knot",
|
||||
"Audience": "KnotUsers",
|
||||
"ExpiryInMinutes": 1440
|
||||
}
|
||||
},
|
||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class DependencyInjection
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Настройка базы данных
|
||||
string connectionString = configuration.GetConnectionString("Database")!;
|
||||
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
|
||||
|
||||
services.AddDbContext<ChatsDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
@@ -63,8 +63,15 @@ public sealed class Message : AggregateRoot<Guid>
|
||||
{
|
||||
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType, DateTime.UtcNow, false);
|
||||
}
|
||||
|
||||
public static Message Import(Guid chatId, Guid senderId, string? content, string type, DateTime createdAt, Guid? replyToId = null, Guid? forwardedFromId = null)
|
||||
|
||||
public static Message Import(
|
||||
Guid chatId,
|
||||
Guid senderId,
|
||||
string? content,
|
||||
string type,
|
||||
DateTime createdAt,
|
||||
Guid? replyToId = null,
|
||||
Guid? forwardedFromId = null)
|
||||
{
|
||||
return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, null, forwardedFromId, null, null, null, createdAt, true);
|
||||
}
|
||||
@@ -105,7 +112,7 @@ public sealed class Message : AggregateRoot<Guid>
|
||||
{
|
||||
Content = null;
|
||||
IsDeleted = true;
|
||||
_media.Clear();
|
||||
// _media.Clear(); // DO NOT CLEAR! Data cleanup needs to know the URLs to delete from S3
|
||||
_reactions.Clear();
|
||||
}
|
||||
|
||||
@@ -133,9 +140,9 @@ public sealed class Reaction : Entity<Guid>
|
||||
Emoji = emoji;
|
||||
}
|
||||
|
||||
private Reaction() : base(Guid.Empty)
|
||||
{
|
||||
Emoji = string.Empty;
|
||||
private Reaction() : base(Guid.Empty)
|
||||
{
|
||||
Emoji = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +166,8 @@ public sealed class Media : Entity<Guid>
|
||||
Size = size;
|
||||
}
|
||||
|
||||
private Media() : base(Guid.Empty)
|
||||
{
|
||||
private Media() : base(Guid.Empty)
|
||||
{
|
||||
Type = string.Empty;
|
||||
Url = string.Empty;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class DependencyInjection
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Настройка базы данных
|
||||
string connectionString = configuration.GetConnectionString("Database")!;
|
||||
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
|
||||
|
||||
services.AddDbContext<IdentityDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
@@ -149,4 +149,38 @@ public class S3FileStorageService : IFileStorageService
|
||||
|
||||
return (msResult, contentType, fileName);
|
||||
}
|
||||
|
||||
public async Task DeleteFileAsync(string fileId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var removeArgs = new RemoveObjectArgs()
|
||||
.WithBucket(_bucketName)
|
||||
.WithObject(fileId);
|
||||
await _minioClient.RemoveObjectAsync(removeArgs).ConfigureAwait(false);
|
||||
}
|
||||
catch (MinioException e)
|
||||
{
|
||||
Console.WriteLine($"[Bucket] Error deleting file {fileId}: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<(string FileId, long Size)>> ListFilesAsync()
|
||||
{
|
||||
var result = new List<(string FileId, long Size)>();
|
||||
try
|
||||
{
|
||||
var listArgs = new ListObjectsArgs().WithBucket(_bucketName).WithRecursive(true);
|
||||
|
||||
await foreach (var item in _minioClient.ListObjectsEnumAsync(listArgs).ConfigureAwait(false))
|
||||
{
|
||||
result.Add((item.Key, (long)item.Size));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Bucket] Error listing files: {ex.Message}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,10 @@ public interface IFileStorageService
|
||||
|
||||
// Скачивает файл и возвращает его расшифрованный поток и тип содержимого.
|
||||
Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId);
|
||||
|
||||
// Удаляет файл из хранилища.
|
||||
Task DeleteFileAsync(string fileId);
|
||||
|
||||
// Получает список всех файлов в хранилище с их размерами.
|
||||
Task<System.Collections.Generic.IEnumerable<(string FileId, long Size)>> ListFilesAsync();
|
||||
}
|
||||
|
||||
@@ -362,10 +362,11 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
const availableTabs = tabsConfig.filter(tab => !loadedTabs.has(tab.key) || tab.count > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedTabs.size === tabsConfig.length && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
|
||||
const expected = chatId ? 5 : 1;
|
||||
if (loadedTabs.size === expected && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
|
||||
setActiveTab(availableTabs[0].key as any);
|
||||
}
|
||||
}, [loadedTabs, activeTab, tabsConfig.length]);
|
||||
}, [loadedTabs, activeTab, chatId, availableTabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -903,7 +904,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : loadedTabs.size === tabsConfig.length ? (
|
||||
) : loadedTabs.size === (chatId ? 5 : 1) ? (
|
||||
<div className="m-4 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<ImageIcon size={32} className="text-zinc-600 mb-3" />
|
||||
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
// Notification sound using Web Audio API — generates a pleasant chime
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext {
|
||||
function getAudioContext(): AudioContext | null {
|
||||
if (typeof window !== 'undefined' && navigator && 'userActivation' in navigator) {
|
||||
if (!(navigator as any).userActivation.hasBeenActive) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext();
|
||||
try {
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
audioContext = new AudioCtx();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
@@ -11,6 +24,7 @@ function getAudioContext(): AudioContext {
|
||||
export function playNotificationSound() {
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
|
||||
@@ -100,13 +100,24 @@ export async function extractWaveform(url: string, bars: number = 28): Promise<n
|
||||
const cached = waveformCache.get(url);
|
||||
if (cached) return cached;
|
||||
|
||||
let audioCtx: AudioContext | undefined;
|
||||
let audioCtx: any;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
||||
await audioCtx.close();
|
||||
const OfflineCtx = window.OfflineAudioContext || (window as any).webkitOfflineAudioContext;
|
||||
if (OfflineCtx) {
|
||||
audioCtx = new OfflineCtx(1, 1, 44100);
|
||||
} else {
|
||||
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
|
||||
// We only need the buffer, so decode it
|
||||
const audioBuffer = await audioCtx!.decodeAudioData(arrayBuffer);
|
||||
|
||||
// If it was a regular AudioContext, close it.
|
||||
if (audioCtx.close) {
|
||||
await audioCtx.close();
|
||||
}
|
||||
audioCtx = undefined;
|
||||
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
@@ -132,7 +143,7 @@ export async function extractWaveform(url: string, bars: number = 28): Promise<n
|
||||
return normalized;
|
||||
} catch {
|
||||
// Close leaked AudioContext if any
|
||||
if (audioCtx) audioCtx.close().catch(() => {});
|
||||
if (audioCtx && audioCtx.close) audioCtx.close().catch(() => {});
|
||||
// On error, return uniform bars
|
||||
return Array(bars).fill(0.5);
|
||||
}
|
||||
|
||||
@@ -108,8 +108,28 @@ const translations = {
|
||||
copied: 'Copied!',
|
||||
createUserSuccess: 'User created successfully',
|
||||
passwordResetSuccess: 'Password reset successfully',
|
||||
resetPasswordDesc: 'Generate a new strong password for this user and invalidate the old one.',
|
||||
usernamePlaceholder: 'Ex: johndoe',
|
||||
displayNamePlaceholder: 'Ex: John Doe',
|
||||
passwordPlaceholder: 'Strong password...',
|
||||
generate: 'Generate',
|
||||
cancel: 'Cancel',
|
||||
displayName: 'Display Name',
|
||||
createUserError: 'Error creating user',
|
||||
createUser: 'Create User',
|
||||
dataCleanup: 'Data Cleanup',
|
||||
dataCleanupDesc: 'Calculate and remove unreachable messages (e.g. from deleted groups) and media to free up disk space.',
|
||||
calcCleanupBtn: 'Calculate GC',
|
||||
runCleanupBtn: 'Run Clean',
|
||||
cleanMessages: 'Orphaned Messages',
|
||||
cleanMedia: 'Orphaned Media Size',
|
||||
cleanSuccess: 'Cleanup completed successfully!',
|
||||
testConnection: 'Test Connection',
|
||||
turnSuccess: 'TURN Connection Successful!',
|
||||
turnFailed: 'TURN Test Failed: ',
|
||||
klipySuccess: 'Klipy connection successful!',
|
||||
klipyFailed: 'Klipy Test Failed: ',
|
||||
apiRequired: 'API Key is required',
|
||||
},
|
||||
ru: {
|
||||
loginTitle: 'Вход администратора',
|
||||
@@ -173,8 +193,28 @@ const translations = {
|
||||
copied: 'Скопировано!',
|
||||
createUserSuccess: 'Пользователь успешно создан',
|
||||
passwordResetSuccess: 'Пароль успешно сброшен',
|
||||
resetPasswordDesc: 'Сгенерировать новый надежный пароль для этого пользователя и аннулировать старый.',
|
||||
usernamePlaceholder: 'Например: johndoe',
|
||||
displayNamePlaceholder: 'Например: Иван Иванов',
|
||||
passwordPlaceholder: 'Надежный пароль...',
|
||||
generate: 'Сгенерировать',
|
||||
cancel: 'Отмена',
|
||||
displayName: 'Имя профиля',
|
||||
createUserError: 'Ошибка при создании',
|
||||
createUser: 'Создать',
|
||||
dataCleanup: 'Очистка данных',
|
||||
dataCleanupDesc: 'Вычисление и удаление недостижимых сообщений (например, из удаленных групп) и файлов для освобождения места.',
|
||||
calcCleanupBtn: 'Оценить объем',
|
||||
runCleanupBtn: 'Очистить данные',
|
||||
cleanMessages: 'Сообщений к удалению',
|
||||
cleanMedia: 'Медиа к удалению',
|
||||
cleanSuccess: 'Очистка успешно завершена!',
|
||||
testConnection: 'Проверить соединение',
|
||||
turnSuccess: 'Успешное подключение к TURN!',
|
||||
turnFailed: 'Ошибка TURN: ',
|
||||
klipySuccess: 'Успешное подключение к Klipy!',
|
||||
klipyFailed: 'Ошибка Klipy: ',
|
||||
apiRequired: 'API ключ обязателен',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,18 +239,26 @@ export default function AdminPage() {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
interface CleanStats {
|
||||
orphanedMessagesCount: number;
|
||||
orphanedMediaBytes: number;
|
||||
}
|
||||
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [config, setConfig] = useState<Conf | null>(null);
|
||||
const [cleanStats, setCleanStats] = useState<CleanStats | null>(null);
|
||||
const [authHeader, setAuthHeader] = useState('');
|
||||
const [creds, setCreds] = useState({ user: '', pass: '' });
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
|
||||
// Users Tab
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<AppUser | null>(null);
|
||||
|
||||
const [isTestingTurn, setIsTestingTurn] = useState<boolean>(false);
|
||||
const [isTestingKlipy, setIsTestingKlipy] = useState<boolean>(false);
|
||||
|
||||
const [domainInput, setDomainInput] = useState('');
|
||||
|
||||
// Add User State
|
||||
@@ -278,6 +326,95 @@ export default function AdminPage() {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
|
||||
const handleTestTurn = async () => {
|
||||
if (!config || !config.turnHost) return;
|
||||
setIsTestingTurn(true);
|
||||
let resolved = false;
|
||||
|
||||
try {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{
|
||||
urls: `turn:${config.turnHost}:${config.turnPort}`,
|
||||
username: config.turnUser,
|
||||
credential: config.turnSecret
|
||||
}]
|
||||
});
|
||||
|
||||
pc.addTransceiver('audio');
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error("Timeout gathering relay candidates"));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && (e.candidate.type === 'relay' || e.candidate.type === 'srflx')) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
if (e.candidate === null) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Failed to gather candidates. Check credentials or network."));
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
showToast(t.turnSuccess, 'success');
|
||||
pc.close();
|
||||
} catch (err: any) {
|
||||
showToast(t.turnFailed + err.message, 'error');
|
||||
} finally {
|
||||
setIsTestingTurn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestKlipy = async () => {
|
||||
if (!config || !config.klipyApiKey) {
|
||||
showToast(t.apiRequired, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTestingKlipy(true);
|
||||
try {
|
||||
const customerId = config.klipyCustomerId || "anonymous";
|
||||
let url = `https://api.klipy.co/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`;
|
||||
|
||||
let res = await fetch(url);
|
||||
if (!res.ok && res.status === 404) {
|
||||
url = `https://api.klipy.com/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`;
|
||||
res = await fetch(url);
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (data && !data.error && (Array.isArray(data) || data.gifs || data.data)) {
|
||||
showToast(t.klipySuccess, 'success');
|
||||
} else {
|
||||
showToast(t.klipyFailed + (data?.error || "Invalid response format"), 'error');
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
showToast(t.klipyFailed + (err?.error || err?.message || res.statusText), 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast(t.klipyFailed + err.message, 'error');
|
||||
} finally {
|
||||
setIsTestingKlipy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
|
||||
@@ -330,6 +467,24 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCalcCleanup = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/clean/dry-run', { headers: { Authorization: authHeader } });
|
||||
if (res.ok) setCleanStats(await res.json());
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleRunCleanup = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/clean/run', { method: 'POST', headers: { Authorization: authHeader } });
|
||||
if (res.ok) {
|
||||
showToast(t.cleanSuccess, 'success');
|
||||
setCleanStats(null);
|
||||
fetchDashboard(authHeader);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const fetchUserDetails = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } });
|
||||
@@ -416,7 +571,7 @@ export default function AdminPage() {
|
||||
const freeSpace = stats ? stats.storageLimitBytes - stats.storageUsedBytes : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white font-sans flex overflow-hidden">
|
||||
<div className="h-[100dvh] w-full bg-black text-white font-sans flex overflow-hidden">
|
||||
<aside className="w-64 border-r border-white/10 bg-surface/50 p-6 flex flex-col gap-2 shrink-0 overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-accent flex items-center gap-2">
|
||||
@@ -482,6 +637,38 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DATA CLEANUP WIDGET */}
|
||||
<div className="bg-surface border border-white/10 rounded-2xl p-6 lg:col-span-2 relative overflow-hidden">
|
||||
<h3 className="text-gray-400 font-medium mb-2 flex items-center gap-2 relative z-10">
|
||||
<Trash2 className="w-5 h-5"/> {t.dataCleanup}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-500 mb-6 relative z-10 max-w-lg">{t.dataCleanupDesc}</div>
|
||||
|
||||
{!cleanStats ? (
|
||||
<button onClick={handleCalcCleanup} className="bg-accent/10 hover:bg-accent/20 text-accent font-semibold px-4 py-2 rounded-xl transition-colors shrink-0">
|
||||
{t.calcCleanupBtn}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center text-sm border-b border-white/10 pb-2">
|
||||
<span className="text-gray-400">{t.cleanMessages}:</span>
|
||||
<span className="text-white font-mono">{cleanStats.orphanedMessagesCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm border-b border-white/10 pb-2">
|
||||
<span className="text-gray-400">{t.cleanMedia}:</span>
|
||||
<span className="text-white font-mono">{formatBytes(cleanStats.orphanedMediaBytes)}</span>
|
||||
</div>
|
||||
<div className="flex justify-start gap-4 mt-2">
|
||||
<button onClick={handleRunCleanup} className="bg-red-500 hover:bg-red-600 text-white font-semibold px-6 py-2 rounded-xl transition-colors">
|
||||
{t.runCleanupBtn}
|
||||
</button>
|
||||
<button onClick={() => setCleanStats(null)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">{t.cancel}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -544,6 +731,12 @@ export default function AdminPage() {
|
||||
{t.turnSecret}
|
||||
<input type="password" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnSecret} onChange={e => setConfig({...config, turnSecret: e.target.value})} />
|
||||
</label>
|
||||
<div className="flex justify-start mt-2">
|
||||
<button onClick={handleTestTurn} disabled={isTestingTurn} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
|
||||
{isTestingTurn ? <Loader2 className="w-4 h-4 animate-spin" /> : <Activity className="w-4 h-4" />}
|
||||
{t.testConnection}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
@@ -566,11 +759,17 @@ export default function AdminPage() {
|
||||
Customer ID (for tracking/analytics)
|
||||
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.klipyCustomerId || ''} onChange={e => setConfig({...config, klipyCustomerId: e.target.value})} />
|
||||
</label>
|
||||
<div className="flex justify-start mt-2">
|
||||
<button onClick={handleTestKlipy} disabled={isTestingKlipy} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
|
||||
{isTestingKlipy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Activity className="w-4 h-4" />}
|
||||
{t.testConnection}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-8 self-end">
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
|
||||
{t.saveChanges}
|
||||
</button>
|
||||
@@ -635,7 +834,7 @@ export default function AdminPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-8 self-end">
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
|
||||
{t.saveChanges}
|
||||
</button>
|
||||
@@ -761,7 +960,7 @@ export default function AdminPage() {
|
||||
<div className="bg-red-400/5 border border-red-400/20 p-5 rounded-xl flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-white">{t.resetPassword}</h4>
|
||||
<p className="text-sm text-gray-400 mt-1">Generate a new strong password for this user and invalidate the old one.</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{t.resetPasswordDesc}</p>
|
||||
</div>
|
||||
<button onClick={() => handleResetPassword(selectedUser.id)} className="bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-6 py-2 rounded-xl transition-colors font-semibold whitespace-nowrap">
|
||||
{t.resetPassword}
|
||||
@@ -797,45 +996,45 @@ export default function AdminPage() {
|
||||
<h2 className="text-2xl font-bold text-white mb-2">{t.addUser}</h2>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
||||
Username
|
||||
{t.username}
|
||||
<input
|
||||
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
|
||||
value={newUser.username}
|
||||
onChange={e => setNewUser({...newUser, username: e.target.value.toLowerCase()})}
|
||||
placeholder="Ex: john_doe"
|
||||
placeholder={t.usernamePlaceholder}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
||||
Display Name
|
||||
{t.displayName}
|
||||
<input
|
||||
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
|
||||
value={newUser.displayName}
|
||||
onChange={e => setNewUser({...newUser, displayName: e.target.value})}
|
||||
placeholder="Ex: John Doe"
|
||||
placeholder={t.displayNamePlaceholder}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
||||
Password
|
||||
{t.password}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent flex-1 font-mono"
|
||||
value={newUser.password}
|
||||
onChange={e => setNewUser({...newUser, password: e.target.value})}
|
||||
placeholder="Strong password..."
|
||||
placeholder={t.passwordPlaceholder}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setNewUser({...newUser, password: generatePassword()})}
|
||||
className="bg-white/10 hover:bg-white/20 px-4 rounded-xl transition-colors shrink-0"
|
||||
>
|
||||
Generate
|
||||
{t.generate}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-4 pt-4 border-t border-white/10">
|
||||
<button onClick={() => setShowAddUserModal(false)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">Cancel</button>
|
||||
<button onClick={() => setShowAddUserModal(false)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">{t.cancel}</button>
|
||||
<button onClick={handleAddUser} disabled={!newUser.username || !newUser.displayName || !newUser.password} className="bg-accent text-black font-semibold px-6 py-2 rounded-xl hover:bg-accentLight transition-colors disabled:opacity-50">
|
||||
{t.createUser}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user