diff --git a/apps/server-net/src/Host/Controllers/AdminController.cs b/apps/server-net/src/Host/Controllers/AdminController.cs index 3fa2a88..dad95c2 100644 --- a/apps/server-net/src/Host/Controllers/AdminController.cs +++ b/apps/server-net/src/Host/Controllers/AdminController.cs @@ -157,9 +157,150 @@ public class AdminController : ControllerBase } }); } + + [HttpGet("clean/dry-run")] + public async Task 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(); + + 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 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(); + + 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; } +} + + diff --git a/apps/server-net/src/Host/Controllers/KlipyController.cs b/apps/server-net/src/Host/Controllers/KlipyController.cs index 4d73aec..da069af 100644 --- a/apps/server-net/src/Host/Controllers/KlipyController.cs +++ b/apps/server-net/src/Host/Controllers/KlipyController.cs @@ -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(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(); + + _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(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(); _cache.Set(cacheKey, result, TimeSpan.FromMinutes(15)); // Cache searches for 15 minutes return Ok(result); diff --git a/apps/server-net/src/Host/Program.cs b/apps/server-net/src/Host/Program.cs index 1488300..8b6a967 100644 --- a/apps/server-net/src/Host/Program.cs +++ b/apps/server-net/src/Host/Program.cs @@ -25,7 +25,7 @@ var builder = WebApplication.CreateBuilder(args); // Маппинг стандартных переменных окружения в иерархию .NET var envMappings = new Dictionary { - ["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"], diff --git a/apps/server-net/src/Host/appsettings.json b/apps/server-net/src/Host/appsettings.json index 13e7ac7..9f436c0 100644 --- a/apps/server-net/src/Host/appsettings.json +++ b/apps/server-net/src/Host/appsettings.json @@ -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" } diff --git a/apps/server-net/src/Modules/Chats/DependencyInjection.cs b/apps/server-net/src/Modules/Chats/DependencyInjection.cs index 42c3ae6..0df4398 100644 --- a/apps/server-net/src/Modules/Chats/DependencyInjection.cs +++ b/apps/server-net/src/Modules/Chats/DependencyInjection.cs @@ -19,7 +19,7 @@ public static class DependencyInjection IConfiguration configuration) { // Настройка базы данных - string connectionString = configuration.GetConnectionString("Database")!; + string connectionString = configuration.GetConnectionString("DefaultConnection")!; services.AddDbContext(options => options.UseNpgsql(connectionString)); diff --git a/apps/server-net/src/Modules/Chats/Domain/Message.cs b/apps/server-net/src/Modules/Chats/Domain/Message.cs index 72bc67f..ad62c34 100644 --- a/apps/server-net/src/Modules/Chats/Domain/Message.cs +++ b/apps/server-net/src/Modules/Chats/Domain/Message.cs @@ -63,8 +63,15 @@ public sealed class Message : AggregateRoot { 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 { 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 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 Size = size; } - private Media() : base(Guid.Empty) - { + private Media() : base(Guid.Empty) + { Type = string.Empty; Url = string.Empty; } diff --git a/apps/server-net/src/Modules/Identity/DependencyInjection.cs b/apps/server-net/src/Modules/Identity/DependencyInjection.cs index eef4fd6..57065f3 100644 --- a/apps/server-net/src/Modules/Identity/DependencyInjection.cs +++ b/apps/server-net/src/Modules/Identity/DependencyInjection.cs @@ -19,7 +19,7 @@ public static class DependencyInjection IConfiguration configuration) { // Настройка базы данных - string connectionString = configuration.GetConnectionString("Database")!; + string connectionString = configuration.GetConnectionString("DefaultConnection")!; services.AddDbContext(options => options.UseNpgsql(connectionString)); diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs index 6a07548..6c1e952 100644 --- a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs @@ -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> 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; + } } diff --git a/apps/server-net/src/Shared/Knot.Shared.Kernel/Storage/IFileStorageService.cs b/apps/server-net/src/Shared/Knot.Shared.Kernel/Storage/IFileStorageService.cs index 5b7afd6..a898f4a 100644 --- a/apps/server-net/src/Shared/Knot.Shared.Kernel/Storage/IFileStorageService.cs +++ b/apps/server-net/src/Shared/Knot.Shared.Kernel/Storage/IFileStorageService.cs @@ -10,4 +10,10 @@ public interface IFileStorageService // Скачивает файл и возвращает его расшифрованный поток и тип содержимого. Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId); + + // Удаляет файл из хранилища. + Task DeleteFileAsync(string fileId); + + // Получает список всех файлов в хранилище с их размерами. + Task> ListFilesAsync(); } diff --git a/apps/web/src/components/UserProfile.tsx b/apps/web/src/components/UserProfile.tsx index b6f3f43..d09a9a2 100644 --- a/apps/web/src/components/UserProfile.tsx +++ b/apps/web/src/components/UserProfile.tsx @@ -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 )} - ) : loadedTabs.size === tabsConfig.length ? ( + ) : loadedTabs.size === (chatId ? 5 : 1) ? (

{(t('sharedPhotos' as any) || 'Нет вложений') as string}

diff --git a/apps/web/src/lib/sounds.ts b/apps/web/src/lib/sounds.ts index 9ecc740..8b02ca9 100644 --- a/apps/web/src/lib/sounds.ts +++ b/apps/web/src/lib/sounds.ts @@ -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(); } diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 4cefc26..7a4c0f9 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -100,13 +100,24 @@ export async function extractWaveform(url: string, bars: number = 28): Promise {}); + if (audioCtx && audioCtx.close) audioCtx.close().catch(() => {}); // On error, return uniform bars return Array(bars).fill(0.5); } diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index 83d590a..f6e27a1 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -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(null); const [config, setConfig] = useState(null); + const [cleanStats, setCleanStats] = useState(null); const [authHeader, setAuthHeader] = useState(''); const [creds, setCreds] = useState({ user: '', pass: '' }); const [authenticated, setAuthenticated] = useState(false); - // Users Tab const [users, setUsers] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [isSearching, setIsSearching] = useState(false); const [selectedUser, setSelectedUser] = useState(null); + const [isTestingTurn, setIsTestingTurn] = useState(false); + const [isTestingKlipy, setIsTestingKlipy] = useState(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((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 ( -
+
+ + {/* DATA CLEANUP WIDGET */} +
+

+ {t.dataCleanup} +

+
{t.dataCleanupDesc}
+ + {!cleanStats ? ( + + ) : ( +
+
+ {t.cleanMessages}: + {cleanStats.orphanedMessagesCount} +
+
+ {t.cleanMedia}: + {formatBytes(cleanStats.orphanedMediaBytes)} +
+
+ + +
+
+ )} +
+
)} @@ -544,6 +731,12 @@ export default function AdminPage() { {t.turnSecret} setConfig({...config, turnSecret: e.target.value})} /> +
+ +
)} @@ -566,11 +759,17 @@ export default function AdminPage() { Customer ID (for tracking/analytics) setConfig({...config, klipyCustomerId: e.target.value})} /> +
+ +
)} -
+
@@ -635,7 +834,7 @@ export default function AdminPage() { )}
-
+
@@ -761,7 +960,7 @@ export default function AdminPage() {

{t.resetPassword}

-

Generate a new strong password for this user and invalidate the old one.

+

{t.resetPasswordDesc}

- +