using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using QMax.Api.Configuration; using QMax.Api.Data; using QMax.Api.Infrastructure.Max; using QMax.Api.Infrastructure.Storage; namespace QMax.Api.Controllers; [ApiController] [AllowAnonymous] [Route("admin")] public sealed class AdminController( QMaxDbContext db, IOptions options, IAttachmentStorageService storage, IMaxBridgeClient maxBridge) : ControllerBase { private readonly QMaxOptions _options = options.Value; [HttpGet] public async Task Index([FromQuery] string? pairingCode, CancellationToken cancellationToken) { if (!IsPairingCodeValid(pairingCode)) { return Content(AdminShell("

QMAX Admin

Open this page with ?pairingCode=....

"), "text/html", Encoding.UTF8); } var safePairingCode = pairingCode ?? ""; var status = await BuildStatusAsync(cancellationToken); var encodedPairingCode = Uri.EscapeDataString(safePairingCode); var latestChats = (await db.Chats.AsNoTracking().ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.LastMessageAt ?? x.UpdatedAt) .Take(20) .Select(x => new { x.Id, x.Title, x.Kind, x.UnreadCount, LastMessageAt = x.LastMessageAt ?? x.UpdatedAt, x.LastMessagePreview }) .ToArray(); var latestAttachments = (await db.MessageAttachments .AsNoTracking() .Include(x => x.Message) .ThenInclude(x => x!.Chat) .ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.CreatedAt) .Take(20) .Select(x => new { x.Id, ChatTitle = x.Message!.Chat!.Title, x.OriginalFileName, x.Kind, x.ContentType, x.FileSizeBytes, x.CreatedAt }) .ToArray(); var latestUsers = (await db.Users .AsNoTracking() .Include(x => x.Sessions) .ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.UpdatedAt) .Take(20) .Select(x => new { x.Id, x.DisplayName, x.PhoneNumber, x.CreatedAt, x.UpdatedAt, Sessions = x.Sessions.Count, ActiveSessions = x.Sessions.Count(session => session.RevokedAt == null && session.ExpiresAt > DateTimeOffset.UtcNow) }) .ToArray(); var rows = string.Join("", latestChats.Select(chat => $$""" {{Html(chat.Title)}} {{Html(chat.Kind.ToString())}} {{chat.UnreadCount}} {{Html(chat.LastMessageAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}} {{Html(chat.LastMessagePreview)}} """)); var attachmentRows = string.Join("", latestAttachments.Select(attachment => $$""" {{Html(attachment.OriginalFileName)}} {{Html(attachment.ChatTitle)}} {{Html(attachment.Kind.ToString())}} {{Html(attachment.ContentType)}} {{FormatBytes(attachment.FileSizeBytes)}} {{Html(attachment.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}} """)); var userRows = string.Join("", latestUsers.Select(user => $$""" {{Html(user.DisplayName)}} {{Html(user.PhoneNumber)}} {{user.ActiveSessions}} / {{user.Sessions}} {{Html(user.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}} {{Html(user.UpdatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}} {{user.Id}} """)); var html = $$"""

QMAX Admin

{{status.Users}}Users
{{status.Chats}}Chats
{{status.Messages}}Messages
{{status.Attachments}}Attachments
{{FormatBytes(status.AttachmentBytes)}}Media storage
{{Html(status.MaxStatus)}}MAX
{{status.PushDevices}}Push devices

Latest chats

{{rows}}
TitleKindUnreadUpdatedPreview

Users

{{userRows}}
NamePhoneSessionsCreatedUpdatedId

Latest attachments

{{attachmentRows}}
FileChatKindContent typeSizeCreated
"""; return Content(AdminShell(html), "text/html", Encoding.UTF8); } [HttpGet("api/status")] public async Task Status([FromQuery] string? pairingCode, CancellationToken cancellationToken) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); return Ok(await BuildStatusAsync(cancellationToken)); } [HttpGet("api/chats")] public async Task Chats([FromQuery] string? pairingCode, [FromQuery] string? q, [FromQuery] int take = 100, CancellationToken cancellationToken = default) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); var query = db.Chats.AsNoTracking(); if (!string.IsNullOrWhiteSpace(q)) { query = query.Where(x => x.Title.Contains(q) || (x.LastMessagePreview != null && x.LastMessagePreview.Contains(q))); } var chats = (await query .ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.LastMessageAt ?? x.UpdatedAt) .Take(Math.Clamp(take, 1, 500)) .Select(x => new { x.Id, x.ExternalId, x.Kind, x.Title, x.UnreadCount, x.IsPinned, x.IsMuted, x.IsArchived, x.LastMessageAt, x.LastMessagePreview, x.UpdatedAt }) .ToArray(); return Ok(chats); } [HttpGet("api/messages")] public async Task Messages([FromQuery] string? pairingCode, [FromQuery] Guid? chatId, [FromQuery] string? q, [FromQuery] int take = 100, CancellationToken cancellationToken = default) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); var query = db.Messages.AsNoTracking().Include(x => x.Chat).Include(x => x.Attachments).Where(x => x.DeletedAt == null); if (chatId is not null) { query = query.Where(x => x.ChatId == chatId); } if (!string.IsNullOrWhiteSpace(q)) { query = query.Where(x => x.Text != null && x.Text.Contains(q)); } var messages = (await query .ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.SentAt) .Take(Math.Clamp(take, 1, 500)) .Select(x => new { x.Id, x.ChatId, ChatTitle = x.Chat!.Title, x.Direction, x.SenderName, x.Text, x.SentAt, x.DeliveryState, x.Error, Attachments = x.Attachments.Count }) .ToArray(); return Ok(messages); } [HttpGet("api/users")] public async Task Users([FromQuery] string? pairingCode, [FromQuery] int take = 100, CancellationToken cancellationToken = default) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); var users = (await db.Users .AsNoTracking() .Include(x => x.Sessions) .ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.UpdatedAt) .Take(Math.Clamp(take, 1, 500)) .Select(x => new { x.Id, x.DisplayName, x.PhoneNumber, x.AvatarPath, x.CreatedAt, x.UpdatedAt, Sessions = x.Sessions .OrderByDescending(session => session.LastSeenAt) .Select(session => new { session.Id, session.DeviceName, session.CreatedAt, session.LastSeenAt, session.ExpiresAt, IsActive = session.RevokedAt == null && session.ExpiresAt > DateTimeOffset.UtcNow, session.RevokedAt }) .ToArray() }) .ToArray(); return Ok(users); } [HttpGet("api/attachments")] public async Task Attachments([FromQuery] string? pairingCode, [FromQuery] Guid? chatId, [FromQuery] int take = 100, CancellationToken cancellationToken = default) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); var query = db.MessageAttachments.AsNoTracking().Include(x => x.Message).ThenInclude(x => x!.Chat).AsQueryable(); if (chatId is not null) { query = query.Where(x => x.Message!.ChatId == chatId); } var attachments = (await query .ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.CreatedAt) .Take(Math.Clamp(take, 1, 500)) .Select(x => new { x.Id, x.MessageId, ChatId = x.Message!.ChatId, ChatTitle = x.Message.Chat!.Title, FileName = x.OriginalFileName, x.ContentType, x.FileSizeBytes, x.Kind, x.RemoteUrl, x.CreatedAt, ContentPath = $"/admin/api/attachments/{x.Id}/content" }) .ToArray(); return Ok(attachments); } [HttpGet("api/attachments/{id:guid}/content")] public async Task AttachmentContent(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); var attachment = await db.MessageAttachments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (attachment is null) { return NotFound(); } var path = storage.GetPath(attachment.StorageFileName); return System.IO.File.Exists(path) ? PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true) : NotFound(); } [HttpDelete("api/attachments/{id:guid}")] public async Task DeleteAttachment(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); var attachment = await db.MessageAttachments.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (attachment is null) { return NotFound(); } var path = storage.GetPath(attachment.StorageFileName); db.MessageAttachments.Remove(attachment); await db.SaveChangesAsync(cancellationToken); if (System.IO.File.Exists(path)) { System.IO.File.Delete(path); } return NoContent(); } [HttpDelete("api/users/{id:guid}")] public async Task DeleteUser(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); var user = await db.Users.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (user is null) { return NotFound(); } await db.UserSessions.Where(x => x.UserId == id).ExecuteDeleteAsync(cancellationToken); await db.PushDevices.Where(x => x.UserId == id).ExecuteDeleteAsync(cancellationToken); db.Users.Remove(user); await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpDelete("api/chats/{id:guid}")] public async Task DeleteChat(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); var chat = await db.Chats .Include(x => x.Messages) .ThenInclude(x => x.Attachments) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (chat is null) { return NotFound(); } var files = chat.Messages .SelectMany(x => x.Attachments) .Select(x => storage.GetPath(x.StorageFileName)) .Where(System.IO.File.Exists) .ToArray(); db.Chats.Remove(chat); await db.SaveChangesAsync(cancellationToken); foreach (var file in files) { System.IO.File.Delete(file); } return NoContent(); } private async Task BuildStatusAsync(CancellationToken cancellationToken) { var maxStatus = await maxBridge.GetStatusAsync(cancellationToken); return new AdminStatusDto( "QMAX Bridge", DateTimeOffset.UtcNow, await db.Users.CountAsync(cancellationToken), await db.Chats.CountAsync(cancellationToken), await db.Messages.CountAsync(cancellationToken), await db.MessageAttachments.CountAsync(cancellationToken), await db.MessageAttachments.Select(x => (long?)x.FileSizeBytes).SumAsync(cancellationToken) ?? 0, await db.PushDevices.CountAsync(cancellationToken), maxStatus.IsAuthorized, maxStatus.Status, maxStatus.LastError); } private bool IsPairingCodeValid(string? pairingCode) { return !string.IsNullOrWhiteSpace(_options.PairingCode) && string.Equals(pairingCode, _options.PairingCode, StringComparison.Ordinal); } private static string AdminShell(string body) { return $$""" QMAX Admin
{{body}}
"""; } private static string Html(string? value) { return System.Net.WebUtility.HtmlEncode(value ?? ""); } private static string FormatBytes(long bytes) { if (bytes < 1024) return $"{bytes} B"; var kb = bytes / 1024.0; if (kb < 1024) return $"{kb:0.0} KB"; var mb = kb / 1024.0; if (mb < 1024) return $"{mb:0.0} MB"; return $"{mb / 1024.0:0.0} GB"; } private sealed record AdminStatusDto( string Name, DateTimeOffset ServerTime, int Users, int Chats, int Messages, int Attachments, long AttachmentBytes, int PushDevices, bool MaxAuthorized, string MaxStatus, string? MaxLastError); }