482 lines
18 KiB
C#
482 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 async Task<IActionResult> 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<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);
|
|
}
|