Initial QMAX production app
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("admin/max")]
|
||||
public sealed class AdminMaxController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<QMaxOptions> options) : 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 MAX Admin</h1><p>Open this page with <code>?pairingCode=...</code>.</p>"), "text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
var snapshot = await WorkerGetAsync<MaxBrowserSnapshot>("snapshot", cancellationToken);
|
||||
var status = await WorkerGetAsync<MaxBridgeStatus>("status", cancellationToken);
|
||||
var screenshot = string.IsNullOrWhiteSpace(snapshot?.ScreenshotPngBase64)
|
||||
? "<p>No screenshot yet.</p>"
|
||||
: $"<img src=\"data:image/png;base64,{snapshot.ScreenshotPngBase64}\" />";
|
||||
var encodedPairingCode = Uri.EscapeDataString(pairingCode);
|
||||
var jsAuthorized = status?.IsAuthorized == true ? "true" : "false";
|
||||
var jsStage = JsonSerializer.Serialize(status?.LoginStage ?? "");
|
||||
|
||||
var html = $$"""
|
||||
<h1>QMAX MAX Admin</h1>
|
||||
<p><b>Status:</b> {{Html(status?.Status)}} | <b>Stage:</b> {{Html(status?.LoginStage)}} | <b>Authorized:</b> {{status?.IsAuthorized}} | <b>URL:</b> {{Html(snapshot?.Url)}}</p>
|
||||
<form method="post" action="/admin/max/login/start">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<button type="submit">Start phone login</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/max/login/code">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<input name="code" placeholder="MAX code" autocomplete="one-time-code" inputmode="numeric" />
|
||||
<button type="submit">Submit code</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/max/click">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<input name="x" placeholder="x" />
|
||||
<input name="y" placeholder="y" />
|
||||
<button type="submit">Click</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/max/type">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<input name="text" placeholder="text" />
|
||||
<button type="submit">Type</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/max/press">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<input name="key" placeholder="Enter" />
|
||||
<button type="submit">Press key</button>
|
||||
</form>
|
||||
<p class="links">
|
||||
<a href="/admin/max?pairingCode={{encodedPairingCode}}">Refresh</a>
|
||||
<a href="/admin/max/inspect/dom?pairingCode={{encodedPairingCode}}">DOM</a>
|
||||
<a href="/admin/max/inspect/storage?pairingCode={{encodedPairingCode}}">Storage</a>
|
||||
<a href="/admin/max/inspect/indexeddb-sample?pairingCode={{encodedPairingCode}}">IDB samples</a>
|
||||
<a href="/admin/max/inspect/network?pairingCode={{encodedPairingCode}}">Network</a>
|
||||
<a href="/admin/max/updates?pairingCode={{encodedPairingCode}}">Updates</a>
|
||||
</p>
|
||||
<p class="hint">Click directly on the screenshot to control the remote MAX browser. QMAX will forward the click and refresh the image.</p>
|
||||
<div class="screen">{{screenshot}}</div>
|
||||
<script>
|
||||
const qmaxPairingCode = "{{Html(pairingCode)}}";
|
||||
const qmaxIsAuthorized = {{jsAuthorized}};
|
||||
const qmaxStage = {{jsStage}};
|
||||
const qmaxScreen = document.querySelector(".screen img");
|
||||
if (qmaxScreen) {
|
||||
qmaxScreen.addEventListener("click", async (event) => {
|
||||
const rect = qmaxScreen.getBoundingClientRect();
|
||||
const x = Math.round((event.clientX - rect.left) * qmaxScreen.naturalWidth / rect.width);
|
||||
const y = Math.round((event.clientY - rect.top) * qmaxScreen.naturalHeight / rect.height);
|
||||
const body = new URLSearchParams({ pairingCode: qmaxPairingCode, x: String(x), y: String(y) });
|
||||
await fetch("/admin/max/click", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body
|
||||
});
|
||||
window.setTimeout(() => window.location.reload(), 1200);
|
||||
});
|
||||
}
|
||||
const qmaxIsEditing = () => {
|
||||
const active = document.activeElement;
|
||||
return active && ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName);
|
||||
};
|
||||
if (!qmaxIsAuthorized) {
|
||||
const intervalMs = qmaxStage === "CodeEntry" ? 3000 : 5000;
|
||||
window.setInterval(() => {
|
||||
if (!document.hidden && !qmaxIsEditing()) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
</script>
|
||||
""";
|
||||
|
||||
return Content(AdminShell(html), "text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
[HttpGet("inspect/{kind}")]
|
||||
public async Task<IActionResult> Inspect([FromRoute] string kind, [FromQuery] string pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var workerPath = kind.ToLowerInvariant() switch
|
||||
{
|
||||
"dom" => "inspect/dom",
|
||||
"storage" => "inspect/storage",
|
||||
"indexeddb-sample" => "inspect/indexeddb/sample",
|
||||
"network" => "inspect/network",
|
||||
_ => null
|
||||
};
|
||||
if (workerPath is null) return NotFound();
|
||||
|
||||
return Content(await WorkerGetRawAsync(workerPath, cancellationToken), "application/json", Encoding.UTF8);
|
||||
}
|
||||
|
||||
[HttpGet("updates")]
|
||||
public async Task<IActionResult> Updates([FromQuery] string pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
return Content(await WorkerGetRawAsync("updates", cancellationToken), "application/json", Encoding.UTF8);
|
||||
}
|
||||
|
||||
[HttpPost("login/start")]
|
||||
public async Task<IActionResult> StartLogin([FromForm] string pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("login/start", new { phoneNumber = _options.MaxPhoneNumber }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("login/code")]
|
||||
public async Task<IActionResult> Code([FromForm] string pairingCode, [FromForm] string code, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("login/code", new { code, waitMs = 60000 }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("click")]
|
||||
public async Task<IActionResult> Click([FromForm] string pairingCode, [FromForm] int x, [FromForm] int y, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("browser/click", new { x, y }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("type")]
|
||||
public async Task<IActionResult> Type([FromForm] string pairingCode, [FromForm] string text, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("browser/type", new { text }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("press")]
|
||||
public async Task<IActionResult> Press([FromForm] string pairingCode, [FromForm] string key, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("browser/press", new { key = string.IsNullOrWhiteSpace(key) ? "Enter" : key }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
private async Task<T?> WorkerGetAsync<T>(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
return await client.GetFromJsonAsync<T>(path, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string> WorkerGetRawAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
return await client.GetStringAsync(path, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task WorkerPostAsync(string path, object body, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
using var response = await client.PostAsJsonAsync(path, body, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
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 MAX Admin</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, sans-serif; background: #f3f7fa; color: #17212b; }
|
||||
main { max-width: 1320px; margin: 0 auto; padding: 16px; }
|
||||
form { display: inline-flex; gap: 8px; margin: 4px; align-items: center; }
|
||||
input { padding: 8px 10px; border: 1px solid #ccd6dd; border-radius: 6px; }
|
||||
button { padding: 8px 12px; border: 0; border-radius: 6px; color: white; background: #3390ec; }
|
||||
.links { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.hint { color: #5f7080; }
|
||||
.screen { margin-top: 16px; overflow: auto; background: #111; }
|
||||
img { max-width: 100%; display: block; cursor: crosshair; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>{{body}}</main></body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string Html(string? value)
|
||||
{
|
||||
return System.Net.WebUtility.HtmlEncode(value ?? "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Contracts;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/app-updates")]
|
||||
public sealed class AppUpdatesController(IOptions<QMaxOptions> options) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("android/latest")]
|
||||
public async Task<ActionResult<AppUpdateManifestDto>> Latest(CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestPath = Path.Combine(_options.ReleasesPath, "android", "latest.json");
|
||||
if (System.IO.File.Exists(manifestPath))
|
||||
{
|
||||
return PhysicalFile(manifestPath, "application/json");
|
||||
}
|
||||
|
||||
var androidReleases = new DirectoryInfo(Path.Combine(_options.ReleasesPath, "android"));
|
||||
if (!androidReleases.Exists)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var apk = androidReleases
|
||||
.GetFiles("*.apk")
|
||||
.OrderByDescending(x => x.LastWriteTimeUtc)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (apk is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var manifest = new AppUpdateManifestDto(
|
||||
"qmax",
|
||||
"QMAX",
|
||||
"0.1.0",
|
||||
1,
|
||||
"stable",
|
||||
"android",
|
||||
"apk",
|
||||
$"/api/app-updates/android/download/{Uri.EscapeDataString(apk.Name)}",
|
||||
apk.Length,
|
||||
await Sha256Async(apk.FullName, cancellationToken),
|
||||
"QMAX local release",
|
||||
apk.LastWriteTimeUtc);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("android/download/{fileName}")]
|
||||
public IActionResult Download(string fileName)
|
||||
{
|
||||
var safeName = Path.GetFileName(fileName);
|
||||
var path = Path.Combine(_options.ReleasesPath, "android", safeName);
|
||||
if (!System.IO.File.Exists(path) || !safeName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return PhysicalFile(path, "application/vnd.android.package-archive", safeName, enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
private static async Task<string> Sha256Async(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = System.IO.File.OpenRead(path);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Contracts;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public sealed class AuthController(
|
||||
QMaxDbContext db,
|
||||
ITokenService tokenService,
|
||||
IOptions<QMaxOptions> options) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("device/login")]
|
||||
public async Task<ActionResult<AuthResponse>> Login(DeviceLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var expected = _options.PairingCode;
|
||||
if (string.IsNullOrWhiteSpace(expected))
|
||||
{
|
||||
return Problem("QMax:PairingCode is not configured on the server.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!FixedTimeEquals(expected, request.PairingCode))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var user = await db.Users.FirstOrDefaultAsync(cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
DisplayName = "QMAX Owner",
|
||||
PhoneNumber = string.IsNullOrWhiteSpace(_options.MaxPhoneNumber) ? null : _options.MaxPhoneNumber
|
||||
};
|
||||
db.Users.Add(user);
|
||||
}
|
||||
|
||||
var refreshToken = tokenService.CreateRefreshToken();
|
||||
var session = new UserSession
|
||||
{
|
||||
User = user,
|
||||
DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) ? "Android" : request.DeviceName.Trim(),
|
||||
RefreshTokenHash = tokenService.HashRefreshToken(refreshToken)
|
||||
};
|
||||
db.UserSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return CreateAuthResponse(user, session, refreshToken);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<AuthResponse>> Refresh(RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var hash = tokenService.HashRefreshToken(request.RefreshToken);
|
||||
var session = await db.UserSessions
|
||||
.Include(x => x.User)
|
||||
.FirstOrDefaultAsync(x => x.RefreshTokenHash == hash, cancellationToken);
|
||||
|
||||
if (session?.User is null || session.RevokedAt is not null || session.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
session.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return CreateAuthResponse(session.User, session, request.RefreshToken);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionId = User.GetSessionId();
|
||||
if (sessionId is not null)
|
||||
{
|
||||
var session = await db.UserSessions.FindAsync([sessionId.Value], cancellationToken);
|
||||
if (session is not null)
|
||||
{
|
||||
session.RevokedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("sessions")]
|
||||
public async Task<ActionResult<IReadOnlyList<SessionDto>>> Sessions(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var currentSessionId = User.GetSessionId();
|
||||
var sessions = (await db.UserSessions
|
||||
.Where(x => x.UserId == userId && x.RevokedAt == null)
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.LastSeenAt)
|
||||
.Select(x => new SessionDto(x.Id, x.DeviceName, x.CreatedAt, x.LastSeenAt, x.ExpiresAt, x.Id == currentSessionId))
|
||||
.ToArray();
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
private AuthResponse CreateAuthResponse(User user, UserSession session, string refreshToken)
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddHours(8);
|
||||
var accessToken = tokenService.CreateAccessToken(user, session, expiresAt);
|
||||
var dto = new UserDto(user.Id, user.DisplayName, user.PhoneNumber, user.AvatarPath);
|
||||
return new AuthResponse(accessToken, refreshToken, expiresAt, dto);
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string expected, string actual)
|
||||
{
|
||||
var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expected);
|
||||
var actualBytes = System.Text.Encoding.UTF8.GetBytes(actual);
|
||||
return expectedBytes.Length == actualBytes.Length &&
|
||||
System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Contracts;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Hubs;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Infrastructure.Storage;
|
||||
using QMax.Api.Services;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/chats")]
|
||||
public sealed class ChatsController(
|
||||
QMaxDbContext db,
|
||||
ChatProjectionService projection,
|
||||
IMaxBridgeClient maxBridge,
|
||||
IAttachmentStorageService storage,
|
||||
IHubContext<QMaxHub> hubContext,
|
||||
MaxBridgeSyncService syncService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<ChatDto>>> GetChats(CancellationToken cancellationToken)
|
||||
{
|
||||
var chats = (await db.Chats.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.IsPinned)
|
||||
.ThenByDescending(x => x.UnreadCount > 0)
|
||||
.ThenByDescending(x => x.LastMessageAt ?? x.UpdatedAt)
|
||||
.Take(200)
|
||||
.ToArray();
|
||||
|
||||
return chats.Select(projection.ToDto).ToArray();
|
||||
}
|
||||
|
||||
[HttpPost("direct")]
|
||||
public async Task<ActionResult<ChatDto>> CreateDirect(CreateDirectChatRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats.FirstOrDefaultAsync(x => x.ExternalId == request.ExternalChatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
chat = new Chat
|
||||
{
|
||||
ExternalId = request.ExternalChatId,
|
||||
Title = request.Title,
|
||||
AvatarUrl = request.AvatarUrl,
|
||||
Kind = ChatKind.MaxDialog
|
||||
};
|
||||
db.Chats.Add(chat);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return projection.ToDto(chat);
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/messages")]
|
||||
public async Task<ActionResult<IReadOnlyList<MessageDto>>> GetMessages(Guid chatId, int take = 80, DateTimeOffset? before = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (before is null && !string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
await syncService.SyncChatHistoryAsync(chat.ExternalId, cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
if (before is null && chat.UnreadCount > 0)
|
||||
{
|
||||
chat.UnreadCount = 0;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
}
|
||||
|
||||
var query = db.Messages
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.Where(x => x.ChatId == chatId && x.DeletedAt == null);
|
||||
|
||||
if (before is not null)
|
||||
{
|
||||
query = query.Where(x => x.SentAt < before);
|
||||
}
|
||||
|
||||
var messages = (await query.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.SentAt)
|
||||
.Take(Math.Clamp(take, 1, 200))
|
||||
.ToArray();
|
||||
|
||||
return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray();
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/read")]
|
||||
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (chat.UnreadCount > 0)
|
||||
{
|
||||
chat.UnreadCount = 0;
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/search")]
|
||||
public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
|
||||
Guid chatId,
|
||||
[FromQuery(Name = "q")] string? query,
|
||||
int take = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var term = query?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(term))
|
||||
{
|
||||
return BadRequest("Search query is required.");
|
||||
}
|
||||
|
||||
var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId, cancellationToken);
|
||||
if (!chatExists)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var messages = await db.Messages
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.Where(x => x.ChatId == chatId && x.DeletedAt == null)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var results = messages
|
||||
.Where(x => MatchesSearch(x, term))
|
||||
.OrderByDescending(x => x.SentAt)
|
||||
.Take(Math.Clamp(take, 1, 200))
|
||||
.OrderBy(x => x.SentAt)
|
||||
.Select(projection.ToDto)
|
||||
.ToArray();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/avatar")]
|
||||
public async Task<IActionResult> DownloadAvatar(Guid chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
|
||||
if (chat is null || string.IsNullOrWhiteSpace(chat.AvatarUrl))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var remote = await maxBridge.DownloadMediaAsync(chat.AvatarUrl, cancellationToken);
|
||||
if (remote is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return File(remote.Stream, remote.ContentType);
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/presence")]
|
||||
public async Task<ActionResult<ChatPresenceDto>> GetPresence(Guid chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
return new ChatPresenceDto(false, null, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, cancellationToken);
|
||||
return new ChatPresenceDto(
|
||||
presence?.IsTyping == true,
|
||||
presence?.StatusText,
|
||||
presence?.UpdatedAt ?? DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/messages")]
|
||||
public async Task<ActionResult<MessageDto>> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats.FindAsync([chatId], cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var text = (request.Text ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return BadRequest("Message text is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
return BadRequest("Chat is not linked to MAX.");
|
||||
}
|
||||
|
||||
var result = await maxBridge.SendTextAsync(chat.ExternalId, text, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return MaxSendFailure(result, "text message");
|
||||
}
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
ChatId = chat.Id,
|
||||
ExternalId = result.ExternalMessageId,
|
||||
Direction = MessageDirection.Outgoing,
|
||||
Text = text,
|
||||
ReplyToMessageId = request.ReplyToMessageId,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
DeliveryState = MessageDeliveryState.Sent,
|
||||
SenderName = "You"
|
||||
};
|
||||
db.Messages.Add(message);
|
||||
chat.LastMessagePreview = text;
|
||||
chat.LastMessageAt = message.SentAt;
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpPatch("{chatId:guid}/messages/{messageId:guid}")]
|
||||
public async Task<ActionResult<MessageDto>> EditMessage(
|
||||
Guid chatId,
|
||||
Guid messageId,
|
||||
EditMessageRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var text = request.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return BadRequest("Message text is required.");
|
||||
}
|
||||
|
||||
var message = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (message.Direction != MessageDirection.Outgoing)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var maxFailure = await TryApplyMaxActionAsync(
|
||||
message,
|
||||
(externalChatId, externalMessageId) => maxBridge.EditMessageAsync(externalChatId, externalMessageId, message.Text, text, cancellationToken),
|
||||
"edit",
|
||||
cancellationToken);
|
||||
if (maxFailure is not null)
|
||||
{
|
||||
return maxFailure;
|
||||
}
|
||||
|
||||
message.Text = text;
|
||||
message.EditedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (message.Chat is not null)
|
||||
{
|
||||
await RecalculateChatPreviewAsync(message.Chat, cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpDelete("{chatId:guid}/messages/{messageId:guid}")]
|
||||
public async Task<IActionResult> DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var maxFailure = await TryApplyMaxActionAsync(
|
||||
message,
|
||||
(externalChatId, externalMessageId) => maxBridge.DeleteMessageAsync(externalChatId, externalMessageId, message.Text, cancellationToken),
|
||||
"delete",
|
||||
cancellationToken);
|
||||
if (maxFailure is not null)
|
||||
{
|
||||
return maxFailure;
|
||||
}
|
||||
|
||||
var deletedAt = DateTimeOffset.UtcNow;
|
||||
message.DeletedAt = deletedAt;
|
||||
message.EditedAt = message.EditedAt ?? deletedAt;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (message.Chat is not null)
|
||||
{
|
||||
await RecalculateChatPreviewAsync(message.Chat, cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")]
|
||||
public async Task<ActionResult<MessageDto>> SetReaction(
|
||||
Guid chatId,
|
||||
Guid messageId,
|
||||
SetReactionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var emoji = NormalizeReaction(request.Emoji);
|
||||
if (emoji is null)
|
||||
{
|
||||
return BadRequest("Reaction is required.");
|
||||
}
|
||||
|
||||
var message = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var maxFailure = await TryApplyMaxActionAsync(
|
||||
message,
|
||||
(externalChatId, externalMessageId) => maxBridge.SetReactionAsync(externalChatId, externalMessageId, message.Text, emoji, cancellationToken),
|
||||
"reaction",
|
||||
cancellationToken);
|
||||
if (maxFailure is not null)
|
||||
{
|
||||
return maxFailure;
|
||||
}
|
||||
|
||||
var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self");
|
||||
if (reaction is null)
|
||||
{
|
||||
reaction = new MessageReaction
|
||||
{
|
||||
MessageId = message.Id,
|
||||
Emoji = emoji,
|
||||
ActorKey = "self",
|
||||
ActorName = "You"
|
||||
};
|
||||
db.MessageReactions.Add(reaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
reaction.Emoji = emoji;
|
||||
reaction.ActorName = "You";
|
||||
reaction.CreatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message;
|
||||
var dto = projection.ToDto(updated);
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpDelete("{chatId:guid}/messages/{messageId:guid}/reaction")]
|
||||
public async Task<ActionResult<MessageDto>> ClearReaction(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self");
|
||||
if (reaction is not null)
|
||||
{
|
||||
var maxFailure = await TryApplyMaxActionAsync(
|
||||
message,
|
||||
(externalChatId, externalMessageId) => maxBridge.ClearReactionAsync(externalChatId, externalMessageId, message.Text, reaction.Emoji, cancellationToken),
|
||||
"reaction clear",
|
||||
cancellationToken);
|
||||
if (maxFailure is not null)
|
||||
{
|
||||
return maxFailure;
|
||||
}
|
||||
|
||||
message.Reactions.Remove(reaction);
|
||||
db.MessageReactions.Remove(reaction);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message;
|
||||
var dto = projection.ToDto(updated);
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")]
|
||||
public async Task<ActionResult<MessageDto>> ForwardMessage(
|
||||
Guid chatId,
|
||||
Guid messageId,
|
||||
ForwardMessageRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.Include(x => x.Attachments)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (source is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var target = await db.Chats.FindAsync([request.TargetChatId], cancellationToken);
|
||||
if (target is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target.ExternalId))
|
||||
{
|
||||
return BadRequest("Target chat is not linked to MAX.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.Text) && source.Attachments.Count == 0)
|
||||
{
|
||||
return BadRequest("Cannot forward an empty message.");
|
||||
}
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
ChatId = target.Id,
|
||||
Direction = MessageDirection.Outgoing,
|
||||
Text = source.Text,
|
||||
ForwardedFrom = ResolveForwardedFrom(source),
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
DeliveryState = MessageDeliveryState.Sending,
|
||||
SenderName = "You"
|
||||
};
|
||||
|
||||
var sortOrder = 0;
|
||||
foreach (var attachment in source.Attachments.OrderBy(x => x.SortOrder))
|
||||
{
|
||||
StoredAttachment stored;
|
||||
try
|
||||
{
|
||||
stored = await CopyForwardAttachmentAsync(attachment, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException error)
|
||||
{
|
||||
return Problem(error.Message, statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
message.Attachments.Add(new MessageAttachment
|
||||
{
|
||||
OriginalFileName = stored.OriginalFileName,
|
||||
StorageFileName = stored.StorageFileName,
|
||||
ContentType = stored.ContentType,
|
||||
FileSizeBytes = stored.FileSizeBytes,
|
||||
Sha256 = stored.Sha256,
|
||||
Kind = stored.Kind,
|
||||
SortOrder = sortOrder++
|
||||
});
|
||||
}
|
||||
|
||||
var result = await SendForwardedToMaxAsync(target.ExternalId, message, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return MaxSendFailure(result, "forward");
|
||||
}
|
||||
|
||||
message.ExternalId = result.ExternalMessageId;
|
||||
message.DeliveryState = MessageDeliveryState.Sent;
|
||||
db.Messages.Add(message);
|
||||
target.LastMessagePreview = ForwardPreview(message);
|
||||
target.LastMessageAt = message.SentAt;
|
||||
target.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/attachments")]
|
||||
[RequestSizeLimit(30L * 1024 * 1024)]
|
||||
public async Task<ActionResult<MessageDto>> UploadAttachment(
|
||||
Guid chatId,
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] string? caption,
|
||||
[FromForm] Guid? replyToMessageId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats.FindAsync([chatId], cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
return BadRequest("Chat is not linked to MAX.");
|
||||
}
|
||||
|
||||
var stored = await storage.SaveAsync(file, cancellationToken);
|
||||
var result = await maxBridge.SendAttachmentAsync(chat.ExternalId, storage.GetPath(stored.StorageFileName), caption ?? "", cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return MaxSendFailure(result, "attachment");
|
||||
}
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
ChatId = chat.Id,
|
||||
ExternalId = result.ExternalMessageId,
|
||||
Direction = MessageDirection.Outgoing,
|
||||
Text = caption,
|
||||
ReplyToMessageId = replyToMessageId,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
DeliveryState = MessageDeliveryState.Sent,
|
||||
SenderName = "You",
|
||||
Attachments =
|
||||
[
|
||||
new MessageAttachment
|
||||
{
|
||||
OriginalFileName = stored.OriginalFileName,
|
||||
StorageFileName = stored.StorageFileName,
|
||||
ContentType = stored.ContentType,
|
||||
FileSizeBytes = stored.FileSizeBytes,
|
||||
Sha256 = stored.Sha256,
|
||||
Kind = stored.Kind
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
db.Messages.Add(message);
|
||||
chat.LastMessagePreview = !string.IsNullOrWhiteSpace(caption)
|
||||
? caption
|
||||
: stored.Kind switch
|
||||
{
|
||||
AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
|
||||
AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442",
|
||||
_ => stored.OriginalFileName
|
||||
};
|
||||
chat.LastMessageAt = message.SentAt;
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")]
|
||||
public async Task<IActionResult> DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var attachment = await db.MessageAttachments
|
||||
.Include(x => x.Message)
|
||||
.FirstOrDefaultAsync(x => x.Id == attachmentId && x.Message!.ChatId == chatId, cancellationToken);
|
||||
|
||||
if (attachment is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var path = storage.GetPath(attachment.StorageFileName);
|
||||
if (!System.IO.File.Exists(path))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.RemoteUrl))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken);
|
||||
if (remote is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await using (remote.Stream)
|
||||
{
|
||||
var stored = await storage.SaveRemoteAsync(
|
||||
attachment.OriginalFileName,
|
||||
remote.ContentType,
|
||||
remote.Stream,
|
||||
remote.ContentLength ?? attachment.FileSizeBytes,
|
||||
attachment.Kind,
|
||||
cancellationToken);
|
||||
|
||||
attachment.OriginalFileName = stored.OriginalFileName;
|
||||
attachment.StorageFileName = stored.StorageFileName;
|
||||
attachment.ContentType = stored.ContentType;
|
||||
attachment.FileSizeBytes = stored.FileSizeBytes;
|
||||
attachment.Sha256 = stored.Sha256;
|
||||
attachment.Kind = stored.Kind;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
path = storage.GetPath(attachment.StorageFileName);
|
||||
}
|
||||
}
|
||||
|
||||
return PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
private ObjectResult MaxSendFailure(MaxSendResult result, string itemName)
|
||||
{
|
||||
return Problem(
|
||||
result.Error ?? $"MAX did not accept the {itemName}.",
|
||||
statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> TryApplyMaxActionAsync(
|
||||
Message message,
|
||||
Func<string, string, Task<MaxActionResult>> action,
|
||||
string actionName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var externalChatId = message.Chat?.ExternalId;
|
||||
var externalMessageId = message.ExternalId;
|
||||
if (string.IsNullOrWhiteSpace(externalChatId) || string.IsNullOrWhiteSpace(externalMessageId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = await action(externalChatId, externalMessageId);
|
||||
if (result.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Problem(
|
||||
result.Error ?? $"MAX {actionName} action failed.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
private static string? NormalizeReaction(string? value)
|
||||
{
|
||||
var emoji = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(emoji) || emoji.Length > 16 ? null : emoji;
|
||||
}
|
||||
|
||||
private async Task<Message?> LoadMessageForProjectionAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.Messages
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RecalculateChatPreviewAsync(Chat chat, CancellationToken cancellationToken)
|
||||
{
|
||||
var lastMessage = (await db.Messages
|
||||
.Include(x => x.Attachments)
|
||||
.Where(x => x.ChatId == chat.Id && x.DeletedAt == null)
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.SentAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
chat.LastMessageAt = lastMessage?.SentAt;
|
||||
chat.LastMessagePreview = lastMessage is null ? null : ForwardPreview(lastMessage);
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private async Task<StoredAttachment> CopyForwardAttachmentAsync(MessageAttachment attachment, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = storage.GetPath(attachment.StorageFileName);
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
await using var stream = System.IO.File.OpenRead(path);
|
||||
return await storage.SaveRemoteAsync(
|
||||
attachment.OriginalFileName,
|
||||
attachment.ContentType,
|
||||
stream,
|
||||
attachment.FileSizeBytes,
|
||||
attachment.Kind,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachment.RemoteUrl))
|
||||
{
|
||||
var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken);
|
||||
if (remote is not null)
|
||||
{
|
||||
await using (remote.Stream)
|
||||
{
|
||||
return await storage.SaveRemoteAsync(
|
||||
attachment.OriginalFileName,
|
||||
remote.ContentType,
|
||||
remote.Stream,
|
||||
remote.ContentLength ?? attachment.FileSizeBytes,
|
||||
attachment.Kind,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Cannot forward attachment {attachment.Id}: content is unavailable.");
|
||||
}
|
||||
|
||||
private async Task<MaxSendResult> SendForwardedToMaxAsync(string externalChatId, Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
var attachments = message.Attachments.OrderBy(x => x.SortOrder).ToArray();
|
||||
if (attachments.Length == 0)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(message.Text)
|
||||
? new MaxSendResult(false, null, "Forwarded message is empty.")
|
||||
: await maxBridge.SendTextAsync(externalChatId, message.Text, cancellationToken);
|
||||
}
|
||||
|
||||
MaxSendResult? lastResult = null;
|
||||
var errors = new List<string>();
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
var caption = attachment.SortOrder == 0 ? message.Text ?? "" : "";
|
||||
var result = await maxBridge.SendAttachmentAsync(externalChatId, storage.GetPath(attachment.StorageFileName), caption, cancellationToken);
|
||||
lastResult = result;
|
||||
if (!result.Success && !string.IsNullOrWhiteSpace(result.Error))
|
||||
{
|
||||
errors.Add(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? new MaxSendResult(true, lastResult?.ExternalMessageId, null)
|
||||
: new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct()));
|
||||
}
|
||||
|
||||
private static string? ResolveForwardedFrom(Message source)
|
||||
{
|
||||
return source.SenderName?.Trim().Length > 0
|
||||
? source.SenderName.Trim()
|
||||
: source.Chat?.Title;
|
||||
}
|
||||
|
||||
private static string ForwardPreview(Message message)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message.Text))
|
||||
{
|
||||
return message.Text;
|
||||
}
|
||||
|
||||
var first = message.Attachments.OrderBy(x => x.SortOrder).FirstOrDefault();
|
||||
return first?.Kind switch
|
||||
{
|
||||
AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
|
||||
AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442",
|
||||
_ => first?.OriginalFileName ?? "\u041f\u0435\u0440\u0435\u0441\u043b\u0430\u043d\u043d\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesSearch(Message message, string term)
|
||||
{
|
||||
return Contains(message.Text, term) ||
|
||||
Contains(message.SenderName, term) ||
|
||||
Contains(message.ForwardedFrom, term) ||
|
||||
message.Attachments.Any(x =>
|
||||
Contains(x.OriginalFileName, term) ||
|
||||
Contains(x.ContentType, term)) ||
|
||||
message.Reactions.Any(x => Contains(x.Emoji, term));
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string term)
|
||||
{
|
||||
return value?.Contains(term, StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Contracts;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Services;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/max")]
|
||||
public sealed class MaxController(
|
||||
IMaxBridgeClient maxBridge,
|
||||
MaxBridgeSyncService syncService,
|
||||
QMaxDbContext db,
|
||||
IOptions<QMaxOptions> options) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await maxBridge.GetStatusAsync(cancellationToken);
|
||||
await SaveStateAsync(status, cancellationToken);
|
||||
return ToDto(status);
|
||||
}
|
||||
|
||||
[HttpPost("login/start")]
|
||||
public async Task<ActionResult<MaxBridgeStatusDto>> BeginLogin(BeginMaxLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var phone = string.IsNullOrWhiteSpace(request.PhoneNumber) ? _options.MaxPhoneNumber : request.PhoneNumber;
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
{
|
||||
return BadRequest("Phone number is required.");
|
||||
}
|
||||
|
||||
var status = await maxBridge.BeginPhoneLoginAsync(phone, cancellationToken);
|
||||
await SaveStateAsync(status, cancellationToken);
|
||||
return ToDto(status);
|
||||
}
|
||||
|
||||
[HttpPost("login/code")]
|
||||
public async Task<ActionResult<MaxBridgeStatusDto>> SubmitCode(SubmitMaxCodeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await maxBridge.SubmitLoginCodeAsync(request.Code, cancellationToken);
|
||||
await SaveStateAsync(status, cancellationToken);
|
||||
return ToDto(status);
|
||||
}
|
||||
|
||||
[HttpGet("browser/snapshot")]
|
||||
public async Task<ActionResult<MaxBrowserSnapshotDto>> Snapshot(CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = await maxBridge.GetSnapshotAsync(cancellationToken);
|
||||
return new MaxBrowserSnapshotDto(snapshot.Url, snapshot.Title, snapshot.BodyText, snapshot.ScreenshotPngBase64, snapshot.CapturedAt);
|
||||
}
|
||||
|
||||
[HttpPost("sync")]
|
||||
public async Task<ActionResult<object>> Sync(CancellationToken cancellationToken)
|
||||
{
|
||||
var changed = await syncService.SyncOnceAsync(cancellationToken);
|
||||
return new { changed };
|
||||
}
|
||||
|
||||
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
||||
if (state is null)
|
||||
{
|
||||
state = new MaxAccountState { Id = 1 };
|
||||
db.MaxAccountStates.Add(state);
|
||||
}
|
||||
|
||||
state.PhoneNumber = _options.MaxPhoneNumber;
|
||||
state.Status = status.Status;
|
||||
state.IsAuthorized = status.IsAuthorized;
|
||||
state.LastUrl = status.Url;
|
||||
state.LastError = status.LastError;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
|
||||
{
|
||||
return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/push")]
|
||||
public sealed class PushController(QMaxDbContext db) : ControllerBase
|
||||
{
|
||||
public sealed record RegisterPushDeviceRequest(string FirebaseToken, string Platform);
|
||||
|
||||
[HttpPost("devices")]
|
||||
public async Task<IActionResult> Register(RegisterPushDeviceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.FirebaseToken))
|
||||
{
|
||||
return BadRequest("firebaseToken is required.");
|
||||
}
|
||||
|
||||
var userId = User.GetUserId();
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(x => x.FirebaseToken == request.FirebaseToken, cancellationToken);
|
||||
if (device is null)
|
||||
{
|
||||
device = new PushDevice { FirebaseToken = request.FirebaseToken };
|
||||
db.PushDevices.Add(device);
|
||||
}
|
||||
|
||||
device.UserId = userId;
|
||||
device.Platform = string.IsNullOrWhiteSpace(request.Platform) ? "android" : request.Platform;
|
||||
device.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("devices/{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken);
|
||||
if (device is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
db.PushDevices.Remove(device);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Data;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/status")]
|
||||
public sealed class StatusController(QMaxDbContext db) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
return new
|
||||
{
|
||||
name = "QMAX Bridge",
|
||||
status = "ok",
|
||||
serverTime = DateTimeOffset.UtcNow,
|
||||
chats = await db.Chats.CountAsync(cancellationToken),
|
||||
messages = await db.Messages.CountAsync(cancellationToken)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user