Files
2026-07-03 22:39:48 +03:00

237 lines
11 KiB
C#

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 ?? "");
}
}