237 lines
11 KiB
C#
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 ?? "");
|
|
}
|
|
}
|