diff --git a/cmd/bj-lk/main.go b/cmd/bj-lk/main.go index da8664f..948f20b 100644 --- a/cmd/bj-lk/main.go +++ b/cmd/bj-lk/main.go @@ -1,10 +1,10 @@ -// Команда bj-lk — Личный кабинет клиента (отдельный модуль). +// Команда bj-lk — Личный кабинет клиента (modules/lk), отдельный модуль, обновляется независимо. // // Ставится на любой веб-сервер клиента. Сам к базам данных НЕ подключается — // работает только с bj-server по HTTP API с ключом доступа. Клиент входит по // логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру. // -// Настройка — через переменные окружения или флаги: +// Настройка — через переменные окружения (служба: /etc/bj/lk.env) или флаги: // // BJ_URL адрес bj-server (напр. https://bj.example.ru) // BJ_KEY ключ доступа модуля (выдаётся в админке bj-server) @@ -13,435 +13,45 @@ package main import ( - "bytes" - "context" - "encoding/json" "flag" "fmt" - "html/template" "log" "net/http" - "net/url" "os" - "strconv" - "strings" "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk" ) -// cookieTTL — срок жизни кук кабинета. Должен быть не меньше срока сессии на -// bj-server (12ч), иначе браузер забудет токен раньше, чем тот протухнет, и -// клиент увидит «сессия истекла». Кука продлевается при каждом запросе. -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"` -} +// version — версия модуля (go build -ldflags "-X main.version=…"). +var version = "0.1.0" func main() { - cfg := loadConfig() - if cfg.BJURL == "" || cfg.BJKey == "" { - log.Fatal("не заданы BJ_URL и BJ_KEY — укажите адрес bj-server и ключ доступа (см. инструкцию)") + cfg := lk.Config{BJURL: os.Getenv("BJ_URL"), BJKey: os.Getenv("BJ_KEY"), Title: os.Getenv("LK_TITLE")} + listen := os.Getenv("LISTEN") + 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}} - tpl := template.Must(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)) - - 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) - } + if listen == "" { + listen = ":8081" } - setFlag := func(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}) + srv, err := lk.New(cfg) + if err != nil { + log.Fatalf("bj-lk: %v", err) } - // sessionW — как session, но заодно продлевает куку, чтобы у активного - // клиента кабинет не сбрасывался на середине работы. - 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 + if !srv.Configured() { + log.Printf("bj-lk: не заданы BJ_URL и BJ_KEY — кабинет показывает страницу настройки (см. /etc/bj/lk.env)") } - session := func(r *http.Request) string { - curReq = r - c, err := r.Cookie("bj_lk") - 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.Printf("bj-lk %s (Личный кабинет) слушает %s, bj-server: %s", version, listen, cfg.BJURL) + hs := &http.Server{Addr: listen, Handler: srv.Handler(), ReadHeaderTimeout: 5 * time.Second} + if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } - _ = context.Background } diff --git a/deploy/embed.go b/deploy/embed.go index fe5941c..46163cc 100644 --- a/deploy/embed.go +++ b/deploy/embed.go @@ -6,5 +6,5 @@ import "embed" // 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 diff --git a/deploy/systemd/bj-lk.service b/deploy/systemd/bj-lk.service new file mode 100644 index 0000000..8ae1f29 --- /dev/null +++ b/deploy/systemd/bj-lk.service @@ -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 diff --git a/modules/lk/lk.go b/modules/lk/lk.go new file mode 100644 index 0000000..28cab86 --- /dev/null +++ b/modules/lk/lk.go @@ -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, `
%s
`, + template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg)) +} diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go new file mode 100644 index 0000000..daa96c6 --- /dev/null +++ b/modules/lk/lk_test.go @@ -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()) + } + } +} diff --git a/cmd/bj-lk/templates.go b/modules/lk/templates.go similarity index 99% rename from cmd/bj-lk/templates.go rename to modules/lk/templates.go index 00e33e2..1a598cf 100644 --- a/cmd/bj-lk/templates.go +++ b/modules/lk/templates.go @@ -1,4 +1,4 @@ -package main +package lk // pageTemplates — встроенные страницы Личного кабинета (без внешних зависимостей, // чтобы модуль был одним файлом и ставился на любой веб-сервер). diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 21fc7ad..a48a18d 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -6,7 +6,7 @@ # канал stable (по умолчанию) | beta | … # опубликовать 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 # (по умолчанию /opt/bj/crypto-service.jar). Ключ подписи: RELEASE_KEY (~/keys/release-signing.priv). 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" 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" +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 INSTALLER_VERSION > "$DIST/bj-installer.version" ver COMPONENT_INSTALL_VERSION > "$DIST/bj-component-install.version"