227 lines
10 KiB
Go
227 lines
10 KiB
Go
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>`))
|