Личный кабинет — модуль modules/lk: сборка в релизе, unit в комплекте, ключ вне unit-файла
Код bj-lk перенесён в modules/lk (cmd/bj-lk — запуск, флаг -version); релиз собирает bj-lk со своей версией (LK_VERSION), unit bj-lk.service встроен в комплект установщика и берёт адрес bj-server и ключ доступа из /etc/bj/lk.env, а не из unit-файла. Исправлено: признаки «администратор» и «кабинет клиента» читались из общей переменной последнего запроса (curReq) — при одновременной работе признак мог достаться чужому запросу. Теперь из кук своего запроса. Без BJ_URL/BJ_KEY кабинет показывает подсказку настройки вместо падения службы. Тесты: вход, портфель, health, флаг админа по запросу, без настройки. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+27
-417
@@ -1,10 +1,10 @@
|
|||||||
// Команда bj-lk — Личный кабинет клиента (отдельный модуль).
|
// Команда bj-lk — Личный кабинет клиента (modules/lk), отдельный модуль, обновляется независимо.
|
||||||
//
|
//
|
||||||
// Ставится на любой веб-сервер клиента. Сам к базам данных НЕ подключается —
|
// Ставится на любой веб-сервер клиента. Сам к базам данных НЕ подключается —
|
||||||
// работает только с bj-server по HTTP API с ключом доступа. Клиент входит по
|
// работает только с bj-server по HTTP API с ключом доступа. Клиент входит по
|
||||||
// логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру.
|
// логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру.
|
||||||
//
|
//
|
||||||
// Настройка — через переменные окружения или флаги:
|
// Настройка — через переменные окружения (служба: /etc/bj/lk.env) или флаги:
|
||||||
//
|
//
|
||||||
// BJ_URL адрес bj-server (напр. https://bj.example.ru)
|
// BJ_URL адрес bj-server (напр. https://bj.example.ru)
|
||||||
// BJ_KEY ключ доступа модуля (выдаётся в админке bj-server)
|
// BJ_KEY ключ доступа модуля (выдаётся в админке bj-server)
|
||||||
@@ -13,435 +13,45 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cookieTTL — срок жизни кук кабинета. Должен быть не меньше срока сессии на
|
// version — версия модуля (go build -ldflags "-X main.version=…").
|
||||||
// bj-server (12ч), иначе браузер забудет токен раньше, чем тот протухнет, и
|
var version = "0.1.0"
|
||||||
// клиент увидит «сессия истекла». Кука продлевается при каждом запросе.
|
|
||||||
const cookieTTL = 12 * 3600
|
|
||||||
|
|
||||||
type config struct {
|
|
||||||
BJURL string
|
|
||||||
BJKey string
|
|
||||||
Listen string
|
|
||||||
Title string
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadConfig() config {
|
|
||||||
c := config{
|
|
||||||
BJURL: os.Getenv("BJ_URL"),
|
|
||||||
BJKey: os.Getenv("BJ_KEY"),
|
|
||||||
Listen: os.Getenv("LISTEN"),
|
|
||||||
Title: os.Getenv("LK_TITLE"),
|
|
||||||
}
|
|
||||||
flag.StringVar(&c.BJURL, "bj-url", c.BJURL, "адрес bj-server (https://...)")
|
|
||||||
flag.StringVar(&c.BJKey, "bj-key", c.BJKey, "ключ доступа модуля")
|
|
||||||
flag.StringVar(&c.Listen, "listen", c.Listen, "адрес прослушивания (:8081)")
|
|
||||||
flag.StringVar(&c.Title, "title", c.Title, "заголовок кабинета")
|
|
||||||
flag.Parse()
|
|
||||||
if c.Listen == "" {
|
|
||||||
c.Listen = ":8081"
|
|
||||||
}
|
|
||||||
if c.Title == "" {
|
|
||||||
c.Title = "Личный кабинет"
|
|
||||||
}
|
|
||||||
c.BJURL = strings.TrimRight(c.BJURL, "/")
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- клиент к bj-server ----
|
|
||||||
|
|
||||||
type bjClient struct {
|
|
||||||
cfg config
|
|
||||||
hc *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
|
|
||||||
var rdr *bytes.Reader
|
|
||||||
if body != nil {
|
|
||||||
raw, _ := json.Marshal(body)
|
|
||||||
rdr = bytes.NewReader(raw)
|
|
||||||
} else {
|
|
||||||
rdr = bytes.NewReader(nil)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("X-BJ-Key", b.cfg.BJKey)
|
|
||||||
if session != "" {
|
|
||||||
req.Header.Set("X-BJ-Session", session)
|
|
||||||
}
|
|
||||||
if body != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
resp, err := b.hc.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("нет связи с сервером: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
var e struct{ Error string }
|
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
|
||||||
if e.Error == "" {
|
|
||||||
e.Error = fmt.Sprintf("ошибка сервера (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%s", e.Error)
|
|
||||||
}
|
|
||||||
if out != nil {
|
|
||||||
return json.NewDecoder(resp.Body).Decode(out)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- модели ответов ----
|
|
||||||
|
|
||||||
type position struct {
|
|
||||||
ISIN string `json:"isin"`
|
|
||||||
SecurityCode string `json:"security_code"`
|
|
||||||
SecurityName string `json:"security_name"`
|
|
||||||
Quantity float64 `json:"quantity"`
|
|
||||||
Blocked float64 `json:"blocked"`
|
|
||||||
Available float64 `json:"available"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
Transferable bool `json:"transferable"`
|
|
||||||
Note string `json:"note"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type claimRow struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
GUID string `json:"guid"`
|
|
||||||
StatusRu string `json:"status_ru"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
Securit int `json:"securities"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := loadConfig()
|
cfg := lk.Config{BJURL: os.Getenv("BJ_URL"), BJKey: os.Getenv("BJ_KEY"), Title: os.Getenv("LK_TITLE")}
|
||||||
if cfg.BJURL == "" || cfg.BJKey == "" {
|
listen := os.Getenv("LISTEN")
|
||||||
log.Fatal("не заданы BJ_URL и BJ_KEY — укажите адрес bj-server и ключ доступа (см. инструкцию)")
|
flag.StringVar(&cfg.BJURL, "bj-url", cfg.BJURL, "адрес bj-server (https://...)")
|
||||||
|
flag.StringVar(&cfg.BJKey, "bj-key", cfg.BJKey, "ключ доступа модуля")
|
||||||
|
flag.StringVar(&listen, "listen", listen, "адрес прослушивания (:8081)")
|
||||||
|
flag.StringVar(&cfg.Title, "title", cfg.Title, "заголовок кабинета")
|
||||||
|
showVersion := flag.Bool("version", false, "показать версию")
|
||||||
|
flag.Parse()
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println("bj-lk", version)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
bj := &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}}
|
if listen == "" {
|
||||||
tpl := template.Must(template.New("t").Funcs(template.FuncMap{
|
listen = ":8081"
|
||||||
"fmtnum": func(f float64) string {
|
|
||||||
if f == float64(int64(f)) {
|
|
||||||
return strconv.FormatInt(int64(f), 10)
|
|
||||||
}
|
|
||||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
|
||||||
},
|
|
||||||
"shortid": func(s string) string {
|
|
||||||
if len(s) > 8 {
|
|
||||||
return s[:8] + "…"
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
},
|
|
||||||
}).Parse(pageTemplates))
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
// признаки режима (админ / просмотр кабинета клиента) — из кук
|
|
||||||
var curReq *http.Request
|
|
||||||
render := func(w http.ResponseWriter, name string, data map[string]any) {
|
|
||||||
if data == nil {
|
|
||||||
data = map[string]any{}
|
|
||||||
}
|
|
||||||
data["Title"] = cfg.Title
|
|
||||||
if _, ok := data["Nav"]; !ok {
|
|
||||||
data["Nav"] = ""
|
|
||||||
}
|
|
||||||
if curReq != nil {
|
|
||||||
if _, ok := data["IsAdmin"]; !ok {
|
|
||||||
if c, err := curReq.Cookie("bj_lk_admin"); err == nil && c.Value == "1" {
|
|
||||||
data["IsAdmin"] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c, err := curReq.Cookie("bj_lk_as"); err == nil && c.Value != "" {
|
|
||||||
data["AsClient"] = c.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := tpl.ExecuteTemplate(w, name, data); err != nil {
|
|
||||||
log.Printf("шаблон %s: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setFlag := func(w http.ResponseWriter, name, val string) {
|
srv, err := lk.New(cfg)
|
||||||
age := cookieTTL
|
if err != nil {
|
||||||
if val == "" {
|
log.Fatalf("bj-lk: %v", err)
|
||||||
age = -1
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode})
|
|
||||||
}
|
}
|
||||||
// sessionW — как session, но заодно продлевает куку, чтобы у активного
|
if !srv.Configured() {
|
||||||
// клиента кабинет не сбрасывался на середине работы.
|
log.Printf("bj-lk: не заданы BJ_URL и BJ_KEY — кабинет показывает страницу настройки (см. /etc/bj/lk.env)")
|
||||||
sessionW := func(w http.ResponseWriter, r *http.Request) string {
|
|
||||||
curReq = r
|
|
||||||
c, err := r.Cookie("bj_lk")
|
|
||||||
if err != nil || c.Value == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/",
|
|
||||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
|
||||||
return c.Value
|
|
||||||
}
|
}
|
||||||
session := func(r *http.Request) string {
|
log.Printf("bj-lk %s (Личный кабинет) слушает %s, bj-server: %s", version, listen, cfg.BJURL)
|
||||||
curReq = r
|
hs := &http.Server{Addr: listen, Handler: srv.Handler(), ReadHeaderTimeout: 5 * time.Second}
|
||||||
c, err := r.Cookie("bj_lk")
|
if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вход
|
|
||||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
render(w, "login", map[string]any{"Flash": r.URL.Query().Get("e")})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = r.ParseForm()
|
|
||||||
var out struct {
|
|
||||||
Session string `json:"session"`
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
Contract string `json:"contract"`
|
|
||||||
IsAdmin bool `json:"is_admin"`
|
|
||||||
}
|
|
||||||
err := bj.call(r.Context(), http.MethodPost, "/api/lk/login", "", map[string]string{
|
|
||||||
"login": r.FormValue("login"), "password": r.FormValue("password"),
|
|
||||||
}, &out)
|
|
||||||
if err != nil {
|
|
||||||
render(w, "login", map[string]any{"Flash": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/",
|
|
||||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
|
||||||
if out.IsAdmin {
|
|
||||||
setFlag(w, "bj_lk_admin", "1")
|
|
||||||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setFlag(w, "bj_lk_admin", "")
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1})
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Портфель
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s := sessionW(w, r)
|
|
||||||
if s == "" {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out struct {
|
|
||||||
Contract string `json:"contract"`
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
Positions []position `json:"positions"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
ReadOnly bool `json:"read_only"`
|
|
||||||
}
|
|
||||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil {
|
|
||||||
render(w, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
render(w, "portfolio", map[string]any{
|
|
||||||
"Nav": "p", "Contract": out.Contract, "FullName": out.FullName,
|
|
||||||
"Positions": out.Positions, "Flash": r.URL.Query().Get("m"),
|
|
||||||
"Source": out.Source, "ReadOnly": out.ReadOnly,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Заявка на перевод
|
|
||||||
mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s := sessionW(w, r)
|
|
||||||
if s == "" {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
var out struct {
|
|
||||||
Positions []position `json:"positions"`
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
}
|
|
||||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil {
|
|
||||||
render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
render(w, "transfer", map[string]any{"Nav": "t", "Positions": out.Positions, "FullName": out.FullName})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = r.ParseForm()
|
|
||||||
var secs []map[string]any
|
|
||||||
for _, isin := range r.Form["isin"] {
|
|
||||||
q := strings.TrimSpace(r.FormValue("qty_" + isin))
|
|
||||||
if q == "" || q == "0" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f, err := strconv.ParseFloat(strings.ReplaceAll(q, ",", "."), 64)
|
|
||||||
if err != nil || f <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
secs = append(secs, map[string]any{"isin": isin, "quantity": f})
|
|
||||||
}
|
|
||||||
if len(secs) == 0 {
|
|
||||||
render(w, "transfer", map[string]any{"Nav": "t", "Error": "Укажите количество хотя бы по одной бумаге"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var res struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
GUID string `json:"guid"`
|
|
||||||
}
|
|
||||||
err := bj.call(r.Context(), http.MethodPost, "/api/lk/transfer", s, map[string]any{
|
|
||||||
"receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")),
|
|
||||||
"receiving_name": strings.TrimSpace(r.FormValue("receiving_name")),
|
|
||||||
"account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")),
|
|
||||||
"comment": strings.TrimSpace(r.FormValue("comment")),
|
|
||||||
"securities": secs,
|
|
||||||
}, &res)
|
|
||||||
if err != nil {
|
|
||||||
render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/claims?m="+res.Message, http.StatusSeeOther)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Мои заявки
|
|
||||||
mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s := sessionW(w, r)
|
|
||||||
if s == "" {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out struct {
|
|
||||||
Claims []claimRow `json:"claims"`
|
|
||||||
}
|
|
||||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/claims", s, nil, &out); err != nil {
|
|
||||||
render(w, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
render(w, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m")})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Администратор: список клиентов
|
|
||||||
mux.HandleFunc("/clients", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s := sessionW(w, r)
|
|
||||||
if s == "" {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out struct {
|
|
||||||
Clients []struct {
|
|
||||||
Login string `json:"login"`
|
|
||||||
Contract string `json:"contract_number"`
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
IsAdmin bool `json:"is_admin"`
|
|
||||||
} `json:"clients"`
|
|
||||||
Depo []struct {
|
|
||||||
Contract string `json:"contract"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Securities int `json:"securities"`
|
|
||||||
} `json:"depo"`
|
|
||||||
DepoError string `json:"depo_error"`
|
|
||||||
}
|
|
||||||
search := strings.TrimSpace(r.URL.Query().Get("search"))
|
|
||||||
path := "/api/lk/admin/clients"
|
|
||||||
if search != "" {
|
|
||||||
path += "?search=" + url.QueryEscape(search)
|
|
||||||
}
|
|
||||||
if err := bj.call(r.Context(), http.MethodGet, path, s, nil, &out); err != nil {
|
|
||||||
render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients,
|
|
||||||
"Depo": out.Depo, "DepoError": out.DepoError, "Search": search,
|
|
||||||
"Flash": r.URL.Query().Get("m")})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Администратор: войти в кабинет клиента
|
|
||||||
mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s := sessionW(w, r)
|
|
||||||
if s == "" {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
login := r.FormValue("login")
|
|
||||||
if login == "" {
|
|
||||||
login = r.URL.Query().Get("login")
|
|
||||||
}
|
|
||||||
contract := r.FormValue("contract")
|
|
||||||
if contract == "" {
|
|
||||||
contract = r.URL.Query().Get("contract")
|
|
||||||
}
|
|
||||||
var out struct {
|
|
||||||
Session string `json:"session"`
|
|
||||||
AsClient string `json:"as_client"`
|
|
||||||
}
|
|
||||||
if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", s,
|
|
||||||
map[string]string{"login": login, "contract": contract}, &out); err != nil {
|
|
||||||
http.Redirect(w, r, "/clients?m="+err.Error(), http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// сохраняем админскую сессию, чтобы можно было вернуться
|
|
||||||
if c, err := r.Cookie("bj_lk"); err == nil {
|
|
||||||
setFlag(w, "bj_lk_admin_sess", c.Value)
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/",
|
|
||||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
|
||||||
setFlag(w, "bj_lk_as", out.AsClient)
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Администратор: вернуться из кабинета клиента
|
|
||||||
mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/",
|
|
||||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
|
||||||
}
|
|
||||||
setFlag(w, "bj_lk_as", "")
|
|
||||||
setFlag(w, "bj_lk_admin_sess", "")
|
|
||||||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Проверка связи с bj-server (для админа при установке)
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var out map[string]any
|
|
||||||
err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out)
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": cfg.BJURL})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": cfg.BJURL})
|
|
||||||
})
|
|
||||||
|
|
||||||
log.Printf("bj-lk (Личный кабинет) слушает %s, bj-server: %s", cfg.Listen, cfg.BJURL)
|
|
||||||
srv := &http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
_ = context.Background
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,5 +6,5 @@ import "embed"
|
|||||||
|
|
||||||
// Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system).
|
// Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system).
|
||||||
//
|
//
|
||||||
//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service
|
//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service systemd/bj-lk.service
|
||||||
var Files embed.FS
|
var Files embed.FS
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Bridge&Join: Личный кабинет клиента (bj-lk)
|
||||||
|
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||||
|
After=network-online.target bj-server.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=bj
|
||||||
|
Group=bj
|
||||||
|
# Значения по умолчанию. Адрес bj-server, ключ доступа модуля (BJ_KEY), порт (LISTEN) и заголовок
|
||||||
|
# (LK_TITLE) задаются в /etc/bj/lk.env (root, 0600), чтобы ключ не лежал в unit-файле.
|
||||||
|
Environment=BJ_URL=http://127.0.0.1:8080
|
||||||
|
Environment=LISTEN=:8099
|
||||||
|
EnvironmentFile=-/etc/bj/lk.env
|
||||||
|
ExecStart=/opt/bj/bj-lk
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:/var/log/bj/bj-lk.log
|
||||||
|
StandardError=append:/var/log/bj/bj-lk.log
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/log/bj
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
// Package lk — модуль «Личный кабинет клиента» (служба bj-lk, cmd/bj-lk).
|
||||||
|
//
|
||||||
|
// Ставится на любой веб-сервер клиента и обновляется независимо от ядра. Сам к базам данных НЕ
|
||||||
|
// подключается — работает только с bj-server по HTTP API с ключом доступа (X-BJ-Key). Клиент
|
||||||
|
// входит по логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру.
|
||||||
|
package lk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cookieTTL — срок жизни кук кабинета. Должен быть не меньше срока сессии на
|
||||||
|
// bj-server (12ч), иначе браузер забудет токен раньше, чем тот протухнет, и
|
||||||
|
// клиент увидит «сессия истекла». Кука продлевается при каждом запросе.
|
||||||
|
const cookieTTL = 12 * 3600
|
||||||
|
|
||||||
|
// Config — подключение к bj-server и оформление.
|
||||||
|
type Config struct {
|
||||||
|
BJURL string // адрес bj-server (https://...)
|
||||||
|
BJKey string // ключ доступа модуля (выдаётся в настройках bj-server)
|
||||||
|
Title string // название кабинета в шапке
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server — кабинет.
|
||||||
|
type Server struct {
|
||||||
|
cfg Config
|
||||||
|
bj *bjClient
|
||||||
|
tpl *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// New — кабинет с разобранными шаблонами.
|
||||||
|
func New(cfg Config) (*Server, error) {
|
||||||
|
cfg.BJURL = strings.TrimRight(strings.TrimSpace(cfg.BJURL), "/")
|
||||||
|
cfg.BJKey = strings.TrimSpace(cfg.BJKey)
|
||||||
|
if cfg.Title == "" {
|
||||||
|
cfg.Title = "Личный кабинет"
|
||||||
|
}
|
||||||
|
tpl, err := template.New("t").Funcs(template.FuncMap{
|
||||||
|
"fmtnum": func(f float64) string {
|
||||||
|
if f == float64(int64(f)) {
|
||||||
|
return strconv.FormatInt(int64(f), 10)
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||||
|
},
|
||||||
|
"shortid": func(s string) string {
|
||||||
|
if len(s) > 8 {
|
||||||
|
return s[:8] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
},
|
||||||
|
}).Parse(pageTemplates)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Server{cfg: cfg, bj: &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}}, tpl: tpl}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured — заданы адрес bj-server и ключ доступа.
|
||||||
|
func (s *Server) Configured() bool { return s.cfg.BJURL != "" && s.cfg.BJKey != "" }
|
||||||
|
|
||||||
|
// ---- клиент к bj-server ----
|
||||||
|
|
||||||
|
type bjClient struct {
|
||||||
|
cfg Config
|
||||||
|
hc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
|
||||||
|
var rdr *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
rdr = bytes.NewReader(raw)
|
||||||
|
} else {
|
||||||
|
rdr = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-BJ-Key", b.cfg.BJKey)
|
||||||
|
if session != "" {
|
||||||
|
req.Header.Set("X-BJ-Session", session)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := b.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("нет связи с сервером: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
var e struct{ Error string }
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||||
|
if e.Error == "" {
|
||||||
|
e.Error = fmt.Sprintf("ошибка сервера (%d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", e.Error)
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- модели ответов ----
|
||||||
|
|
||||||
|
type position struct {
|
||||||
|
ISIN string `json:"isin"`
|
||||||
|
SecurityCode string `json:"security_code"`
|
||||||
|
SecurityName string `json:"security_name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Blocked float64 `json:"blocked"`
|
||||||
|
Available float64 `json:"available"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Transferable bool `json:"transferable"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type claimRow struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
StatusRu string `json:"status_ru"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Securit int `json:"securities"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- страницы ----
|
||||||
|
|
||||||
|
// render — страница; признаки режима (админ / просмотр кабинета клиента) — из кук этого запроса.
|
||||||
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
|
||||||
|
if data == nil {
|
||||||
|
data = map[string]any{}
|
||||||
|
}
|
||||||
|
data["Title"] = s.cfg.Title
|
||||||
|
if _, ok := data["Nav"]; !ok {
|
||||||
|
data["Nav"] = ""
|
||||||
|
}
|
||||||
|
if _, ok := data["IsAdmin"]; !ok {
|
||||||
|
if c, err := r.Cookie("bj_lk_admin"); err == nil && c.Value == "1" {
|
||||||
|
data["IsAdmin"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c, err := r.Cookie("bj_lk_as"); err == nil && c.Value != "" {
|
||||||
|
data["AsClient"] = c.Value
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := s.tpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
log.Printf("шаблон %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFlag(w http.ResponseWriter, name, val string) {
|
||||||
|
age := cookieTTL
|
||||||
|
if val == "" {
|
||||||
|
age = -1
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSession(w http.ResponseWriter, val string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: val, Path: "/",
|
||||||
|
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionW — как session, но заодно продлевает куку, чтобы у активного
|
||||||
|
// клиента кабинет не сбрасывался на середине работы.
|
||||||
|
func sessionW(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
c, err := r.Cookie("bj_lk")
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
setSession(w, c.Value)
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(r *http.Request) string {
|
||||||
|
c, err := r.Cookie("bj_lk")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler — все страницы кабинета. Без адреса bj-server или ключа — страница с подсказкой настройки.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
if !s.Configured() {
|
||||||
|
return http.HandlerFunc(s.notConfigured)
|
||||||
|
}
|
||||||
|
bj := s.bj
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Вход
|
||||||
|
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
s.render(w, r, "login", map[string]any{"Flash": r.URL.Query().Get("e")})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.ParseForm()
|
||||||
|
var out struct {
|
||||||
|
Session string `json:"session"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Contract string `json:"contract"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
}
|
||||||
|
err := bj.call(r.Context(), http.MethodPost, "/api/lk/login", "", map[string]string{
|
||||||
|
"login": r.FormValue("login"), "password": r.FormValue("password"),
|
||||||
|
}, &out)
|
||||||
|
if err != nil {
|
||||||
|
s.render(w, r, "login", map[string]any{"Flash": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSession(w, out.Session)
|
||||||
|
if out.IsAdmin {
|
||||||
|
setFlag(w, "bj_lk_admin", "1")
|
||||||
|
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFlag(w, "bj_lk_admin", "")
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Портфель
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := sessionW(w, r)
|
||||||
|
if sess == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Contract string `json:"contract"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Positions []position `json:"positions"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
}
|
||||||
|
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
|
||||||
|
s.render(w, r, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, r, "portfolio", map[string]any{
|
||||||
|
"Nav": "p", "Contract": out.Contract, "FullName": out.FullName,
|
||||||
|
"Positions": out.Positions, "Flash": r.URL.Query().Get("m"),
|
||||||
|
"Source": out.Source, "ReadOnly": out.ReadOnly,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Заявка на перевод
|
||||||
|
mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionW(w, r)
|
||||||
|
if sess == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
var out struct {
|
||||||
|
Positions []position `json:"positions"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
}
|
||||||
|
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
|
||||||
|
s.render(w, r, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, r, "transfer", map[string]any{"Nav": "t", "Positions": out.Positions, "FullName": out.FullName})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.ParseForm()
|
||||||
|
var secs []map[string]any
|
||||||
|
for _, isin := range r.Form["isin"] {
|
||||||
|
q := strings.TrimSpace(r.FormValue("qty_" + isin))
|
||||||
|
if q == "" || q == "0" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(strings.ReplaceAll(q, ",", "."), 64)
|
||||||
|
if err != nil || f <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
secs = append(secs, map[string]any{"isin": isin, "quantity": f})
|
||||||
|
}
|
||||||
|
if len(secs) == 0 {
|
||||||
|
s.render(w, r, "transfer", map[string]any{"Nav": "t", "Error": "Укажите количество хотя бы по одной бумаге"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
}
|
||||||
|
err := bj.call(r.Context(), http.MethodPost, "/api/lk/transfer", sess, map[string]any{
|
||||||
|
"receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")),
|
||||||
|
"receiving_name": strings.TrimSpace(r.FormValue("receiving_name")),
|
||||||
|
"account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")),
|
||||||
|
"comment": strings.TrimSpace(r.FormValue("comment")),
|
||||||
|
"securities": secs,
|
||||||
|
}, &res)
|
||||||
|
if err != nil {
|
||||||
|
s.render(w, r, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/claims?m="+url.QueryEscape(res.Message), http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Мои заявки
|
||||||
|
mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionW(w, r)
|
||||||
|
if sess == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Claims []claimRow `json:"claims"`
|
||||||
|
}
|
||||||
|
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil {
|
||||||
|
s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m")})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Администратор: список клиентов
|
||||||
|
mux.HandleFunc("/clients", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionW(w, r)
|
||||||
|
if sess == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Clients []struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Contract string `json:"contract_number"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
} `json:"clients"`
|
||||||
|
Depo []struct {
|
||||||
|
Contract string `json:"contract"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Securities int `json:"securities"`
|
||||||
|
} `json:"depo"`
|
||||||
|
DepoError string `json:"depo_error"`
|
||||||
|
}
|
||||||
|
search := strings.TrimSpace(r.URL.Query().Get("search"))
|
||||||
|
path := "/api/lk/admin/clients"
|
||||||
|
if search != "" {
|
||||||
|
path += "?search=" + url.QueryEscape(search)
|
||||||
|
}
|
||||||
|
if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil {
|
||||||
|
s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients,
|
||||||
|
"Depo": out.Depo, "DepoError": out.DepoError, "Search": search,
|
||||||
|
"Flash": r.URL.Query().Get("m")})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Администратор: войти в кабинет клиента
|
||||||
|
mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionW(w, r)
|
||||||
|
if sess == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
login := r.FormValue("login")
|
||||||
|
if login == "" {
|
||||||
|
login = r.URL.Query().Get("login")
|
||||||
|
}
|
||||||
|
contract := r.FormValue("contract")
|
||||||
|
if contract == "" {
|
||||||
|
contract = r.URL.Query().Get("contract")
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Session string `json:"session"`
|
||||||
|
AsClient string `json:"as_client"`
|
||||||
|
}
|
||||||
|
if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", sess,
|
||||||
|
map[string]string{"login": login, "contract": contract}, &out); err != nil {
|
||||||
|
http.Redirect(w, r, "/clients?m="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// сохраняем админскую сессию, чтобы можно было вернуться
|
||||||
|
setFlag(w, "bj_lk_admin_sess", sess)
|
||||||
|
setSession(w, out.Session)
|
||||||
|
setFlag(w, "bj_lk_as", out.AsClient)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Администратор: вернуться из кабинета клиента
|
||||||
|
mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
|
||||||
|
setSession(w, c.Value)
|
||||||
|
}
|
||||||
|
setFlag(w, "bj_lk_as", "")
|
||||||
|
setFlag(w, "bj_lk_admin_sess", "")
|
||||||
|
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Проверка связи с bj-server (для админа при установке)
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var out map[string]any
|
||||||
|
err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out)
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": s.cfg.BJURL})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": s.cfg.BJURL})
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// notConfigured — кабинет запущен без адреса bj-server или ключа: подсказка вместо падения службы.
|
||||||
|
func (s *Server) notConfigured(w http.ResponseWriter, r *http.Request) {
|
||||||
|
const msg = "Личный кабинет не настроен: укажите адрес bj-server (BJ_URL) и ключ доступа модуля (BJ_KEY) в /etc/bj/lk.env, затем выполните systemctl restart bj-lk. Ключ задаётся в настройках bj-server."
|
||||||
|
if r.URL.Path == "/health" {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
fmt.Fprintf(w, `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head>`+
|
||||||
|
`<body style="font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px;line-height:1.5"><h1>%s</h1><p>%s</p></body></html>`,
|
||||||
|
template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg))
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package lk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeBJ — API bj-server для кабинета: проверка ключа, вход и портфель.
|
||||||
|
func fakeBJ(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
keyOK := func(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if r.Header.Get("X-BJ-Key") != "lk-key" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "неверный ключ модуля"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
mux.HandleFunc("/api/lk/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if keyOK(w, r) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"session": "sess-1", "full_name": "Мостовиков Тест", "is_admin": false})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/lk/portfolio", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "positions": []any{}})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if keyOK(w, r) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginPortfolioHealth(t *testing.T) {
|
||||||
|
bj := fakeBJ(t)
|
||||||
|
s, err := New(Config{BJURL: bj.URL + "/", BJKey: "lk-key", Title: "Кабинет · тест"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := s.Handler()
|
||||||
|
|
||||||
|
form := url.Values{"login": {"client"}, "password": {"secret"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||||
|
t.Fatalf("вход: код %d, Location %q", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
var sess *http.Cookie
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == "bj_lk" {
|
||||||
|
sess = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sess == nil || sess.Value != "sess-1" || !sess.HttpOnly {
|
||||||
|
t.Fatalf("кука сессии: %+v", sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(sess)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
body := rec.Body.String()
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(body, "Мостовиков Тест") || !strings.Contains(body, "Кабинет · тест") {
|
||||||
|
t.Fatalf("портфель: код %d: %s", rec.Code, body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `href="/clients"`) {
|
||||||
|
t.Error("клиенту без признака администратора показан раздел «Клиенты»")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"ok":true`) {
|
||||||
|
t.Fatalf("health: код %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Признак администратора берётся из кук своего запроса, а не предыдущего (прежде был общий curReq).
|
||||||
|
func TestAdminFlagPerRequest(t *testing.T) {
|
||||||
|
bj := fakeBJ(t)
|
||||||
|
s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := s.Handler()
|
||||||
|
admin := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||||
|
admin.AddCookie(&http.Cookie{Name: "bj_lk_admin", Value: "1"})
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), admin)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if strings.Contains(rec.Body.String(), `href="/clients"`) {
|
||||||
|
t.Fatal("признак администратора перешёл из чужого запроса")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotConfigured(t *testing.T) {
|
||||||
|
s, err := New(Config{BJURL: "http://127.0.0.1:8080"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Configured() {
|
||||||
|
t.Fatal("без ключа кабинет не настроен")
|
||||||
|
}
|
||||||
|
for _, p := range []string{"/", "/health"} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil))
|
||||||
|
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "BJ_KEY") {
|
||||||
|
t.Fatalf("%s: код %d: %s", p, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package lk
|
||||||
|
|
||||||
// pageTemplates — встроенные страницы Личного кабинета (без внешних зависимостей,
|
// pageTemplates — встроенные страницы Личного кабинета (без внешних зависимостей,
|
||||||
// чтобы модуль был одним файлом и ставился на любой веб-сервер).
|
// чтобы модуль был одним файлом и ставился на любой веб-сервер).
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
# канал stable (по умолчанию) | beta | …
|
# канал stable (по умолчанию) | beta | …
|
||||||
# опубликовать publish — сразу загрузить на сервер (нужен репозиторий lic_Bridge-and-Join-s рядом)
|
# опубликовать publish — сразу загрузить на сервер (нужен репозиторий lic_Bridge-and-Join-s рядом)
|
||||||
#
|
#
|
||||||
# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, NPR_VERSION, QUIKEXPORT_VERSION,
|
# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, NPR_VERSION, QUIKEXPORT_VERSION, LK_VERSION,
|
||||||
# CRYPTO_VERSION (по умолчанию — версия релиза). Криптосервис берётся из CRYPTO_JAR
|
# CRYPTO_VERSION (по умолчанию — версия релиза). Криптосервис берётся из CRYPTO_JAR
|
||||||
# (по умолчанию /opt/bj/crypto-service.jar). Ключ подписи: RELEASE_KEY (~/keys/release-signing.priv).
|
# (по умолчанию /opt/bj/crypto-service.jar). Ключ подписи: RELEASE_KEY (~/keys/release-signing.priv).
|
||||||
set -eu
|
set -eu
|
||||||
@@ -28,6 +28,8 @@ CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver NPR_VERSI
|
|||||||
ver NPR_VERSION > "$DIST/bj-npr.version"
|
ver NPR_VERSION > "$DIST/bj-npr.version"
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver QUIKEXPORT_VERSION)" -o "$DIST/bj-quikexport" ./cmd/bj-quikexport
|
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver QUIKEXPORT_VERSION)" -o "$DIST/bj-quikexport" ./cmd/bj-quikexport
|
||||||
ver QUIKEXPORT_VERSION > "$DIST/bj-quikexport.version"
|
ver QUIKEXPORT_VERSION > "$DIST/bj-quikexport.version"
|
||||||
|
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver LK_VERSION)" -o "$DIST/bj-lk" ./cmd/bj-lk
|
||||||
|
ver LK_VERSION > "$DIST/bj-lk.version"
|
||||||
ver BJ_SERVER_VERSION > "$DIST/bj-server.version"
|
ver BJ_SERVER_VERSION > "$DIST/bj-server.version"
|
||||||
ver INSTALLER_VERSION > "$DIST/bj-installer.version"
|
ver INSTALLER_VERSION > "$DIST/bj-installer.version"
|
||||||
ver COMPONENT_INSTALL_VERSION > "$DIST/bj-component-install.version"
|
ver COMPONENT_INSTALL_VERSION > "$DIST/bj-component-install.version"
|
||||||
|
|||||||
Reference in New Issue
Block a user