рабочий вариант, но скороть 10 МБит
build / backend (push) Has been cancelled
build / node-agent (push) Has been cancelled
build / worker (push) Has been cancelled

This commit is contained in:
2026-05-22 21:46:49 +03:00
parent 469fa0e860
commit 20d361a886
280 changed files with 954890 additions and 18524 deletions
@@ -0,0 +1,226 @@
package auth
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/example/remote-access-platform/backend/internal/platform/authority"
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
)
type loginHTMLPage struct {
Email string
Error string
}
type vpnDownloadHTMLPage struct {
ActorUserID string
AndroidAPK string
AndroidAPI string
BuildInfo string
Version string
VersionCode string
BuildType string
Size string
SHA256 string
PublishedAt string
}
func (m *Module) handleLoginHTMLPage(w http.ResponseWriter, r *http.Request) {
m.renderLoginHTML(w, loginHTMLPage{
Email: strings.TrimSpace(r.URL.Query().Get("email")),
Error: strings.TrimSpace(r.URL.Query().Get("error")),
})
}
func (m *Module) handleLoginHTML(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
m.renderLoginHTML(w, loginHTMLPage{Error: "Не удалось прочитать форму входа."})
return
}
email := strings.TrimSpace(r.FormValue("email"))
result, err := m.service.Login(r.Context(), LoginCommand{
Email: email,
Password: r.FormValue("password"),
DeviceFingerprint: "web-control-html5",
DeviceLabel: "HTML5 control panel",
TrustDevice: true,
})
if err != nil {
_, message := m.service.MapError(err)
m.renderLoginHTML(w, loginHTMLPage{Email: email, Error: message})
return
}
target := "/api/v1/auth/ui/vpn-download?actor_user_id=" + template.URLQueryEscaper(result.User.ID)
switch strings.TrimSpace(result.User.PlatformRole) {
case authority.PlatformRoleAdmin, authority.PlatformRoleRecoveryAdmin:
target = "/api/v1/ui/admin?actor_user_id=" + template.URLQueryEscaper(result.User.ID)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (m *Module) handleVPNDownloadHTML(w http.ResponseWriter, r *http.Request) {
page := vpnDownloadHTMLPage{
ActorUserID: strings.TrimSpace(r.URL.Query().Get("actor_user_id")),
AndroidAPK: "/downloads/rap-android-vpn-latest-debug.apk",
AndroidAPI: "/api/v1/downloads/rap-android-vpn-latest-debug.apk",
BuildInfo: "/downloads/rap-android-vpn-build.json",
}
page.loadAndroidBuildInfo()
page.applyAndroidDownloadPaths()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := vpnDownloadHTMLTemplate.Execute(w, page); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "render vpn download page")
}
}
func (m *Module) renderLoginHTML(w http.ResponseWriter, page loginHTMLPage) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := loginHTMLTemplate.Execute(w, page); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "render login page")
}
}
func (p *vpnDownloadHTMLPage) loadAndroidBuildInfo() {
var manifest struct {
Version struct {
Name string `json:"name"`
Code string `json:"code"`
} `json:"version"`
Build struct {
Type string `json:"type"`
} `json:"build"`
Published struct {
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"size_bytes"`
TimestampUTC string `json:"timestamp_utc"`
} `json:"published"`
}
releaseDir := strings.TrimSpace(os.Getenv("RAP_RELEASE_DIR"))
if releaseDir == "" {
releaseDir = "/tmp/rap-release"
}
data, err := os.ReadFile(filepath.Join(releaseDir, "rap-android-vpn-build.json"))
if err != nil || json.Unmarshal(data, &manifest) != nil {
return
}
p.Version = strings.TrimSpace(manifest.Version.Name)
p.VersionCode = strings.TrimSpace(manifest.Version.Code)
p.BuildType = strings.TrimSpace(manifest.Build.Type)
p.SHA256 = strings.TrimSpace(manifest.Published.SHA256)
p.PublishedAt = strings.TrimSpace(manifest.Published.TimestampUTC)
if manifest.Published.SizeBytes > 0 {
p.Size = fmt.Sprintf("%.1f MB", float64(manifest.Published.SizeBytes)/1024/1024)
}
}
func (p *vpnDownloadHTMLPage) applyAndroidDownloadPaths() {
version := strings.TrimSpace(p.Version)
buildType := strings.TrimSpace(p.BuildType)
if version == "" || buildType == "" {
return
}
fileName := "rap-android-vpn-" + version + "-" + buildType + ".apk"
p.AndroidAPK = "/downloads/releases/" + version + "/" + fileName
p.AndroidAPI = "/api/v1/downloads/releases/" + version + "/" + fileName
}
var loginHTMLTemplate = template.Must(template.New("login-html").Parse(`<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход в панель фермы</title>
<style>
:root{color-scheme:light;--bg:#eef2ec;--panel:#fffdf7;--ink:#102018;--muted:#66716a;--line:#dce2d8;--accent:#1f6b4c;--danger:#a9443b;--soft:#f6f4ea}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,Segoe UI,Arial,sans-serif;font-size:15px;line-height:1.45}
main{min-height:100vh;display:grid;place-items:center;padding:32px}
.shell{width:min(460px,100%)}
h1{margin:0 0 8px;font-size:26px;letter-spacing:0}.lead{margin:0 0 22px;color:var(--muted)}
form,.note{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:18px;box-shadow:0 14px 34px rgba(20,35,25,.08)}
label{display:block;margin:0 0 14px;font-weight:700;font-size:13px;color:#3d4a42}
input{width:100%;margin-top:7px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink);padding:12px 13px;font:inherit}
input:focus{outline:2px solid rgba(31,107,76,.22);border-color:var(--accent)}
button{width:100%;border:0;border-radius:6px;background:var(--accent);color:white;padding:12px 14px;font:700 15px Inter,Segoe UI,Arial,sans-serif;cursor:pointer}
.error{margin:0 0 14px;padding:10px 12px;border-radius:6px;background:#fff0ed;color:var(--danger);font-weight:700}
.note{margin-top:10px;background:var(--soft);color:var(--muted);font-size:13px;box-shadow:none}
</style>
</head>
<body>
<main>
<section class="shell" aria-labelledby="title">
<h1 id="title">Вход в панель фермы</h1>
<p class="lead">Панель открывает администрирование только для роли администратора. Обычный пользователь получит страницу установки VPN.</p>
<form method="post" action="/api/v1/auth/ui/login">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<label>Email или логин
<input name="email" value="{{.Email}}" autocomplete="username" required autofocus>
</label>
<label>Пароль
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">Войти</button>
</form>
<p class="note">Форма серверная HTML5: без клиентской авторизации и без тяжелой логики в браузере.</p>
</section>
</main>
</body>
</html>`))
var vpnDownloadHTMLTemplate = template.Must(template.New("vpn-download-html").Parse(`<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Установка VPN</title>
<style>
:root{color-scheme:light;--bg:#eef2ec;--panel:#fffdf7;--ink:#102018;--muted:#66716a;--line:#dce2d8;--accent:#1f6b4c;--soft:#f6f4ea}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,Segoe UI,Arial,sans-serif;font-size:15px;line-height:1.45}
main{min-height:100vh;display:grid;place-items:center;padding:32px}.shell{width:min(620px,100%)}
h1{margin:0 0 8px;font-size:26px;letter-spacing:0}.lead{margin:0 0 22px;color:var(--muted)}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:18px;box-shadow:0 14px 34px rgba(20,35,25,.08)}
.row{display:flex;gap:12px;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius:7px;padding:14px;margin-top:12px;background:white}
.title{font-weight:800}.meta{color:var(--muted);font-size:13px;margin-top:2px}
a.button{display:inline-flex;align-items:center;justify-content:center;min-width:130px;border-radius:6px;background:var(--accent);color:white;text-decoration:none;padding:11px 13px;font-weight:800}
a.secondary{background:white;color:var(--accent);border:1px solid var(--accent)}
a.link{color:var(--accent);font-weight:700}.note{margin-top:12px;background:var(--soft);border:1px solid var(--line);border-radius:7px;padding:12px;color:var(--muted);font-size:13px}
.facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:12px}
.fact{border:1px solid var(--line);border-radius:7px;background:white;padding:10px;min-width:0}
.fact b{display:block;font-size:12px;color:#3d4a42}.fact span{display:block;margin-top:3px;overflow-wrap:anywhere;font-weight:800}
.actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
@media(max-width:560px){.row{display:block}.actions{display:grid;margin-top:12px}a.button{width:100%}.facts{grid-template-columns:1fr}}
</style>
</head>
<body>
<main>
<section class="shell" aria-labelledby="title">
<h1 id="title">Установка VPN</h1>
<p class="lead">Для вашей роли доступен установщик Android VPN. Узел Android после установки работает с фермой по протоколу фермы.</p>
<div class="panel">
<div class="row">
<div><div class="title">Android VPN{{if .Version}} {{.Version}}{{end}}</div><div class="meta">актуальный APK установщика{{if .BuildType}} · {{.BuildType}}{{end}}</div></div>
<div class="actions">
<a class="button" href="{{.AndroidAPK}}" type="application/vnd.android.package-archive">Скачать APK</a>
<a class="button secondary" href="{{.AndroidAPI}}" type="application/vnd.android.package-archive">Резерв</a>
</div>
</div>
<div class="facts">
<div class="fact"><b>Версия</b><span>{{if .Version}}{{.Version}}{{else}}не указана{{end}}</span></div>
<div class="fact"><b>Код</b><span>{{if .VersionCode}}{{.VersionCode}}{{else}}не указан{{end}}</span></div>
<div class="fact"><b>Размер</b><span>{{if .Size}}{{.Size}}{{else}}не указан{{end}}</span></div>
<div class="fact"><b>SHA256</b><span>{{if .SHA256}}{{.SHA256}}{{else}}не указан{{end}}</span></div>
<div class="fact"><b>Публикация</b><span>{{if .PublishedAt}}{{.PublishedAt}}{{else}}не указана{{end}}</span></div>
<div class="fact"><b>Путь</b><span>{{.AndroidAPK}}</span></div>
</div>
<div class="note">Данные сборки: <a class="link" href="{{.BuildInfo}}">manifest JSON</a>{{if .ActorUserID}}. Пользователь: {{.ActorUserID}}{{end}}</div>
</div>
</section>
</main>
</body>
</html>`))