Initial QMAX production app

This commit is contained in:
sevenhill
2026-07-03 22:39:48 +03:00
commit 9fcc659438
119 changed files with 17959 additions and 0 deletions
@@ -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)
};
}
}