Files
QMAX/server/QMax.Api/Controllers/AdminController.cs
T
2026-07-06 22:46:55 +03:00

460 lines
18 KiB
C#

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<QMaxOptions> options,
IAttachmentStorageService storage,
IMaxBridgeClient maxBridge) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[HttpGet]
public async Task<IActionResult> Index([FromQuery] string? pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode))
{
return Content(AdminShell("<h1>QMAX Admin</h1><p>Open this page with <code>?pairingCode=...</code>.</p>"), "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 => $$"""
<tr>
<td>{{Html(chat.Title)}}</td>
<td>{{Html(chat.Kind.ToString())}}</td>
<td>{{chat.UnreadCount}}</td>
<td>{{Html(chat.LastMessageAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
<td>{{Html(chat.LastMessagePreview)}}</td>
</tr>
"""));
var attachmentRows = string.Join("", latestAttachments.Select(attachment => $$"""
<tr>
<td><a href="/admin/api/attachments/{{attachment.Id}}/content?pairingCode={{encodedPairingCode}}">{{Html(attachment.OriginalFileName)}}</a></td>
<td>{{Html(attachment.ChatTitle)}}</td>
<td>{{Html(attachment.Kind.ToString())}}</td>
<td>{{Html(attachment.ContentType)}}</td>
<td>{{FormatBytes(attachment.FileSizeBytes)}}</td>
<td>{{Html(attachment.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
</tr>
"""));
var userRows = string.Join("", latestUsers.Select(user => $$"""
<tr>
<td>{{Html(user.DisplayName)}}</td>
<td>{{Html(user.PhoneNumber)}}</td>
<td>{{user.ActiveSessions}} / {{user.Sessions}}</td>
<td>{{Html(user.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
<td>{{Html(user.UpdatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
<td><code>{{user.Id}}</code></td>
</tr>
"""));
var html = $$"""
<h1>QMAX Admin</h1>
<nav>
<a href="/admin?pairingCode={{encodedPairingCode}}">Dashboard</a>
<a href="/admin/max?pairingCode={{encodedPairingCode}}">MAX Browser</a>
<a href="/admin/api/status?pairingCode={{encodedPairingCode}}">Status JSON</a>
<a href="/admin/api/chats?pairingCode={{encodedPairingCode}}">Chats JSON</a>
<a href="/admin/api/messages?pairingCode={{encodedPairingCode}}">Messages JSON</a>
<a href="/admin/api/attachments?pairingCode={{encodedPairingCode}}">Attachments JSON</a>
<a href="/admin/api/users?pairingCode={{encodedPairingCode}}">Users JSON</a>
</nav>
<section class="metrics">
<div><b>{{status.Users}}</b><span>Users</span></div>
<div><b>{{status.Chats}}</b><span>Chats</span></div>
<div><b>{{status.Messages}}</b><span>Messages</span></div>
<div><b>{{status.Attachments}}</b><span>Attachments</span></div>
<div><b>{{FormatBytes(status.AttachmentBytes)}}</b><span>Media storage</span></div>
<div><b>{{Html(status.MaxStatus)}}</b><span>MAX</span></div>
<div><b>{{status.PushDevices}}</b><span>Push devices</span></div>
</section>
<section>
<h2>Latest chats</h2>
<table>
<thead><tr><th>Title</th><th>Kind</th><th>Unread</th><th>Updated</th><th>Preview</th></tr></thead>
<tbody>{{rows}}</tbody>
</table>
</section>
<section>
<h2>Users</h2>
<table>
<thead><tr><th>Name</th><th>Phone</th><th>Sessions</th><th>Created</th><th>Updated</th><th>Id</th></tr></thead>
<tbody>{{userRows}}</tbody>
</table>
</section>
<section>
<h2>Latest attachments</h2>
<table>
<thead><tr><th>File</th><th>Chat</th><th>Kind</th><th>Content type</th><th>Size</th><th>Created</th></tr></thead>
<tbody>{{attachmentRows}}</tbody>
</table>
</section>
""";
return Content(AdminShell(html), "text/html", Encoding.UTF8);
}
[HttpGet("api/status")]
public async Task<IActionResult> Status([FromQuery] string? pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
return Ok(await BuildStatusAsync(cancellationToken));
}
[HttpGet("api/chats")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 IActionResult DeleteChat(Guid id, [FromQuery] string? pairingCode)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client.");
}
private async Task<AdminStatusDto> 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 $$"""
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QMAX Admin</title>
<style>
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f3f7fa; color: #17212b; }
main { max-width: 1320px; margin: 0 auto; padding: 18px; }
h1 { margin: 0 0 14px; font-size: 28px; }
h2 { margin-top: 26px; font-size: 18px; }
nav { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
nav a { color: #2578c4; text-decoration: none; font-weight: 600; }
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 16px 0; }
.metrics div { background: white; border: 1px solid #dde7ee; border-radius: 8px; padding: 12px; }
.metrics b { display: block; font-size: 24px; }
.metrics span { color: #6c7883; font-size: 13px; }
table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #dde7ee; border-radius: 8px; overflow: hidden; }
th, td { padding: 9px 10px; border-bottom: 1px solid #edf2f5; text-align: left; vertical-align: top; }
th { color: #6c7883; font-size: 12px; text-transform: uppercase; letter-spacing: .02em; }
tr:last-child td { border-bottom: 0; }
code { background: #e7eef3; padding: 2px 5px; border-radius: 5px; }
</style>
</head>
<body><main>{{body}}</main></body>
</html>
""";
}
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);
}