Files
Bridge-and-Join-s/modules/lk/lk.go
T
zuevav 2a7442c935 Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные
- корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT),
  заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы»
- дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками,
  установщиком и инструкцией; ключ доступа на странице показан замаскированным

Связь без публичного адреса у ядра
- режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему,
  выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения,
  тот же ключ модуля в обе стороны, состояние канала в настройках
- сквозной тест: настоящий кабинет + настоящий цикл ядра

Защита (по итогам аудита)
- вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина,
  пароль от 10 символов
- сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных;
  карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный
  срок 24 ч; привязка к браузеру, к сети — по настройке
- вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение
- кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict,
  выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера,
  ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей
- шрифты вшиты в программу: страница не обращается к сторонним сайтам
- экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand
- исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash)

Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания),
живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 23:47:10 +03:00

966 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"sort"
"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 // название кабинета в шапке
// Agent — режим ожидания: у ядра нет адреса, доступного кабинету, поэтому
// не кабинет ходит в ядро, а ядро само подключается сюда за запросами.
Agent bool
}
// Server — кабинет.
type Server struct {
cfg Config
bj *bjClient
tpl *template.Template
agent *agentQueue
}
// 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)
},
"fmtmoney": func(f float64) string { // 1 234 567,89 — с разделителем разрядов
neg := f < 0
if neg {
f = -f
}
whole := int64(f)
cents := int64((f-float64(whole))*100 + 0.5)
if cents >= 100 {
whole, cents = whole+1, 0
}
d := strconv.FormatInt(whole, 10)
var b strings.Builder
for i, c := range d {
if i > 0 && (len(d)-i)%3 == 0 {
b.WriteByte(' ')
}
b.WriteRune(c)
}
out := b.String()
if cents > 0 {
out += "," + fmt.Sprintf("%02d", cents)
}
if neg {
out = "−" + out
}
return out
},
"fmtpct": func(f float64) string {
s := strconv.FormatFloat(f, 'f', 2, 64)
if f > 0 {
s = "+" + s
}
return strings.ReplaceAll(s, ".", ",") + " %"
},
"shortid": func(s string) string {
if len(s) > 8 {
return s[:8] + "…"
}
return s
},
}).Parse(pageTemplates)
if err != nil {
return nil, err
}
srv := &Server{cfg: cfg, tpl: tpl}
if cfg.Agent {
srv.agent = newAgentQueue()
}
srv.bj = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: srv.agent}
return srv, nil
}
// Configured — кабинету есть с чем работать: ключ модуля и, в прямом режиме, адрес ядра.
func (s *Server) Configured() bool {
if s.cfg.BJKey == "" {
return false
}
if s.cfg.Agent {
return true // адрес ядра не нужен: ядро подключается само
}
return s.cfg.BJURL != ""
}
// InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и
// токены сессий клиентов пойдут по сети в открытом виде.
func (s *Server) InsecureCore() bool {
if s.cfg.Agent || s.cfg.BJURL == "" || strings.HasPrefix(s.cfg.BJURL, "https://") {
return false
}
rest := strings.TrimPrefix(s.cfg.BJURL, "http://")
return !strings.HasPrefix(rest, "127.0.0.1") && !strings.HasPrefix(rest, "localhost")
}
// ---- клиент к bj-server ----
type bjClient struct {
cfg Config
hc *http.Client
agent *agentQueue // задан в режиме ожидания
}
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
var raw []byte
if body != nil {
raw, _ = json.Marshal(body)
}
if b.agent != nil {
return b.callAgent(ctx, method, path, session, raw, out)
}
rdr := bytes.NewReader(raw)
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 v, ok := ctx.Value(visitorKey{}).(visitor); ok {
// Ядро привязывает сессию к браузеру и сети клиента, а вход считает по адресу:
// кабинет — единственный, кто их знает, поэтому передаёт вместе с ключом.
req.Header.Set("X-BJ-Client-IP", v.ip)
req.Header.Set("X-BJ-Client-Agent", v.agent)
}
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
}
// callAgent — тот же запрос, но через очередь: его заберёт ядро, когда подключится.
func (b *bjClient) callAgent(ctx context.Context, method, path, session string, raw []byte, out any) error {
res, err := b.agent.push(ctx, method, path, raw, session, 40*time.Second)
if err != nil {
return err
}
if res.Status >= 400 {
var e struct{ Error string }
_ = json.Unmarshal(res.Body, &e)
if e.Error == "" {
e.Error = fmt.Sprintf("ошибка сервера (%d)", res.Status)
}
return fmt.Errorf("%s", e.Error)
}
if out != nil && len(res.Body) > 0 {
return json.Unmarshal(res.Body, out)
}
return nil
}
// visitor — кто пришёл в кабинет: адрес и браузер. Передаём ядру, чтобы сессия
// была привязана к устройству, а перебор пароля считался по адресу посетителя.
type visitor struct{ ip, agent string }
type visitorKey struct{}
// reqCtx — контекст запроса вместе с данными посетителя.
func reqCtx(r *http.Request) context.Context {
return context.WithValue(r.Context(), visitorKey{}, visitor{ip: clientIP(r), agent: r.UserAgent()})
}
// ---- модели ответов ----
type position struct {
ISIN string `json:"isin"`
SecurityCode string `json:"security_code"`
SecurityName string `json:"security_name"`
AccountID string `json:"account_id"`
Quantity float64 `json:"quantity"`
Blocked float64 `json:"blocked"`
Available float64 `json:"available"`
UpdatedAt string `json:"updated_at"`
Transferable bool `json:"transferable"`
Price float64 `json:"price"`
Value float64 `json:"value"`
ChangePct float64 `json:"change_pct"`
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"`
}
// portfolioView — сводка реестра для доски: сколько выпусков всего, свободных и с ограничением.
func portfolioView(pos []position) (total, free, blocked int, accounts []string) {
seen := map[string]bool{}
for _, p := range pos {
total++
switch {
case p.Blocked > 0 || !p.Transferable:
blocked++
default:
free++
}
if p.AccountID != "" && !seen[p.AccountID] {
seen[p.AccountID] = true
accounts = append(accounts, p.AccountID)
}
}
sort.Strings(accounts)
return
}
// claimsView — сколько поручений в работе и сколько завершено.
func claimsView(rows []claimRow) (active, done int) {
for _, c := range rows {
switch c.Status {
case "done":
done++
case "rejected", "declined", "error":
default:
active++
}
}
return
}
// portfolioResp — ответ /api/lk/portfolio.
type portfolioResp struct {
Contract string `json:"contract"`
FullName string `json:"full_name"`
Positions []position `json:"positions"`
Source string `json:"source"`
ReadOnly bool `json:"read_only"`
TotalValue float64 `json:"total_value"`
Priced int `json:"priced"`
}
// operationRow — движение по счёту депо.
type operationRow struct {
Date string `json:"date"`
DoneAt string `json:"done_at"`
Kind string `json:"kind"`
Number string `json:"number"`
Account string `json:"account"`
ISIN string `json:"isin"`
Security string `json:"security"`
Quantity float64 `json:"quantity"`
State string `json:"state"`
Note string `json:"note"`
}
// incomeRow — выплата дохода.
type incomeRow struct {
Date string `json:"date"`
Kind string `json:"kind"`
ISIN string `json:"isin"`
Security string `json:"security"`
Quantity float64 `json:"quantity"`
Accrued float64 `json:"accrued"`
Tax float64 `json:"tax"`
Paid float64 `json:"paid"`
State string `json:"state"`
Account string `json:"account"`
}
// corpAction — корпоративное действие по бумаге клиента.
type corpAction struct {
FixDate string `json:"fix_date"`
OperDate string `json:"oper_date"`
Kind string `json:"kind"`
ISIN string `json:"isin"`
Security string `json:"security"`
Note string `json:"note"`
}
// documentRow — документ депозитария или заявка клиента.
type documentRow struct {
ID int64 `json:"id"`
Date string `json:"date"`
Title string `json:"title"`
Period string `json:"period"`
Account string `json:"account"`
State string `json:"state"`
Note string `json:"note"`
Ordered bool `json:"ordered"`
Kind string `json:"kind"`
}
// securityCard — карточка бумаги из ядра.
type securityCard struct {
ISIN string `json:"isin"`
Name string `json:"name"`
Kind string `json:"kind"`
Issuer string `json:"issuer"`
RegNumber string `json:"reg_number"`
CFI string `json:"cfi"`
Face float64 `json:"face"`
Maturity string `json:"maturity"`
Accepted bool `json:"accepted"`
QualOnly bool `json:"qual_only"`
QualNote string `json:"qual_note"`
Price float64 `json:"price"`
PriceIn string `json:"price_in"`
Board string `json:"board"`
ChangePct float64 `json:"change_pct"`
Position *position `json:"position"`
Payments []struct {
Date string `json:"date"`
Kind string `json:"kind"`
Amount float64 `json:"amount"`
Percent float64 `json:"percent"`
FixDate string `json:"fix_date"`
Paid bool `json:"paid"`
} `json:"payments"`
Note string `json:"note"`
}
// portfolio — реестр клиента из ядра.
func (s *Server) portfolio(r *http.Request, sess string) (portfolioResp, error) {
var out portfolioResp
err := s.bj.call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out)
return out, err
}
// ---- страницы ----
// 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
}
data["CSRF"] = csrfToken(w, r)
if _, ok := data["Flash"]; !ok {
if f := takeFlash(w, r); f != "" {
data["Flash"] = f
}
}
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)
}
}
// setFlag — служебная кука кабинета. HttpOnly обязателен: в bj_lk_admin_sess лежит
// живой токен сотрудника, и скрипт на странице не должен уметь его прочитать.
func setFlag(w http.ResponseWriter, r *http.Request, name, val string) {
age := cookieTTL
if val == "" {
age = -1
}
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age,
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode})
}
func setSession(w http.ResponseWriter, r *http.Request, val string) {
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: val, Path: "/",
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode, MaxAge: cookieTTL})
}
// flash — сообщение посетителю до следующей страницы. Раньше текст ехал в адресе
// (?m=…), то есть попадал в журнал nginx и в историю браузера вместе с ФИО и
// текстами ошибок; теперь он живёт одну страницу в куке.
func setFlashCookie(w http.ResponseWriter, r *http.Request, msg string) {
http.SetCookie(w, &http.Cookie{Name: "bj_lk_flash", Value: url.QueryEscape(msg), Path: "/", MaxAge: 60,
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode})
}
func takeFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie("bj_lk_flash")
if err != nil || c.Value == "" {
return ""
}
http.SetCookie(w, &http.Cookie{Name: "bj_lk_flash", Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode})
msg, _ := url.QueryUnescape(c.Value)
return msg
}
// clearAll — погасить все куки кабинета (выход, сбой сессии).
func clearAll(w http.ResponseWriter, r *http.Request) {
for _, n := range []string{"bj_lk", "bj_lk_admin", "bj_lk_as", "bj_lk_admin_sess", "bj_lk_flash", csrfCookie} {
http.SetCookie(w, &http.Cookie{Name: n, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode})
}
}
// 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, r, 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 harden(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", nil)
return
}
if !csrfOK(r) {
s.render(w, r, "login", map[string]any{"Error": "Форма устарела, попробуйте войти ещё раз."})
return
}
var out struct {
Session string `json:"session"`
FullName string `json:"full_name"`
Contract string `json:"contract"`
IsAdmin bool `json:"is_admin"`
}
err := bj.call(reqCtx(r), 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{"Error": err.Error()})
return
}
setSession(w, r, out.Session)
if out.IsAdmin {
setFlag(w, r, "bj_lk_admin", "1")
http.Redirect(w, r, "/clients", http.StatusSeeOther)
return
}
setFlag(w, r, "bj_lk_admin", "")
http.Redirect(w, r, "/", http.StatusSeeOther)
})
// Выход гасит и клиентскую сессию, и сохранённую админскую: иначе после
// «выхода» в браузере оставался действующий токен сотрудника.
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
_ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
_ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil)
}
clearAll(w, r)
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
}
pf, err := s.portfolio(r, sess)
if err != nil {
s.render(w, r, "board", map[string]any{"Nav": "b", "Error": err.Error()})
return
}
total, free, blocked, _ := portfolioView(pf.Positions)
var cl struct {
Claims []claimRow `json:"claims"`
}
_ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl)
active, _ := claimsView(cl.Claims)
pos := pf.Positions
if len(pos) > 5 {
pos = pos[:5]
}
claims := cl.Claims
if len(claims) > 5 {
claims = claims[:5]
}
s.render(w, r, "board", map[string]any{
"Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName,
"Positions": pos, "Claims": claims,
"Total": total, "Free": free, "Blocked": blocked, "Active": active,
"Value": pf.TotalValue, "Priced": pf.Priced,
})
})
// Реестр бумаг
mux.HandleFunc("/portfolio", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
pf, err := s.portfolio(r, sess)
if err != nil {
s.render(w, r, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
return
}
total, free, blocked, _ := portfolioView(pf.Positions)
updated := ""
if len(pf.Positions) > 0 {
updated = pf.Positions[0].UpdatedAt
}
s.render(w, r, "portfolio", map[string]any{
"Nav": "p", "Contract": pf.Contract, "FullName": pf.FullName,
"Positions": pf.Positions,
"Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated,
"Total": total, "Free": free, "Blocked": blocked,
"Value": pf.TotalValue, "Priced": pf.Priced,
})
})
// Карточка бумаги
mux.HandleFunc("/security", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
isin := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("isin")))
if isin == "" {
http.Redirect(w, r, "/portfolio", http.StatusSeeOther)
return
}
var out struct {
Security securityCard `json:"security"`
}
if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil {
s.render(w, r, "security", map[string]any{"Nav": "p", "Error": err.Error(), "ISIN": isin})
return
}
s.render(w, r, "security", map[string]any{"Nav": "p", "S": out.Security, "ISIN": isin})
})
// Операции по счетам депо
mux.HandleFunc("/operations", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
if from == "" {
from = time.Now().AddDate(-1, 0, 0).Format("2006-01-02")
}
if to == "" {
to = time.Now().Format("2006-01-02")
}
var out struct {
Operations []operationRow `json:"operations"`
}
path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to})
return
}
var in, outq int
for _, o := range out.Operations {
if o.Quantity < 0 {
outq++
} else {
in++
}
}
s.render(w, r, "operations", map[string]any{"Nav": "o", "Operations": out.Operations,
"From": from, "To": to, "Total": len(out.Operations), "In": in, "Out": outq})
})
// Доходы и налоги
mux.HandleFunc("/income", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
if from == "" {
from = time.Now().Format("2006") + "-01-01"
}
if to == "" {
to = time.Now().Format("2006-01-02")
}
var out struct {
Income []incomeRow `json:"income"`
Accrued float64 `json:"accrued"`
Paid float64 `json:"paid"`
Tax float64 `json:"tax"`
}
path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to})
return
}
var ca struct {
Actions []corpAction `json:"actions"`
}
_ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca)
acts := ca.Actions
if len(acts) > 8 {
acts = acts[:8]
}
s.render(w, r, "income", map[string]any{"Nav": "i", "Income": out.Income, "From": from, "To": to,
"Accrued": out.Accrued, "Paid": out.Paid, "Tax": out.Tax, "Actions": acts})
})
// Документы: готовые выписки, заявки клиента и заказ нового документа
mux.HandleFunc("/documents", 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.MethodPost {
_ = r.ParseForm()
var res struct {
Message string `json:"message"`
}
err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{
"kind": r.FormValue("kind"),
"from": r.FormValue("from"),
"to": r.FormValue("to"),
"account": r.FormValue("account"),
"comment": strings.TrimSpace(r.FormValue("comment")),
}, &res)
if err != nil {
setFlashCookie(w, r, err.Error())
http.Redirect(w, r, "/documents", http.StatusSeeOther)
return
}
setFlashCookie(w, r, res.Message)
http.Redirect(w, r, "/documents", http.StatusSeeOther)
return
}
var out struct {
Ready []documentRow `json:"ready"`
Requests []documentRow `json:"requests"`
Kinds []string `json:"kinds"`
}
if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil {
s.render(w, r, "documents", map[string]any{"Nav": "d", "Error": err.Error()})
return
}
s.render(w, r, "documents", map[string]any{"Nav": "d", "Ready": out.Ready, "Requests": out.Requests,
"Kinds": out.Kinds,
"From": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), "To": time.Now().Format("2006-01-02")})
})
// Профиль: договор, счета депо, доступ
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
pf, err := s.portfolio(r, sess)
if err != nil {
s.render(w, r, "profile", map[string]any{"Nav": "u", "Error": err.Error()})
return
}
_, _, _, accounts := portfolioView(pf.Positions)
login := ""
if c, err := r.Cookie("bj_lk_as"); err == nil {
login = c.Value
}
if login == "" {
login = pf.Contract
}
s.render(w, r, "profile", map[string]any{
"Nav": "u", "Contract": pf.Contract, "FullName": pf.FullName,
"AccountList": accounts, "Accounts": len(accounts), "ReadOnly": pf.ReadOnly, "Login": login,
})
})
// Поручение депозитарию (сейчас — перевод бумаг в другой депозитарий)
mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/order", http.StatusMovedPermanently)
})
mux.HandleFunc("/order", 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(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")})
return
}
s.render(w, r, "transfer", map[string]any{"Nav": "c", "Positions": out.Positions, "FullName": out.FullName,
"PickISIN": r.URL.Query().Get("isin"), "Today": time.Now().Format("02.01.2006")})
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": "c", "Error": "Укажите количество хотя бы по одной бумаге", "Today": time.Now().Format("02.01.2006")})
return
}
var res struct {
Message string `json:"message"`
GUID string `json:"guid"`
}
err := bj.call(reqCtx(r), 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": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")})
return
}
setFlashCookie(w, r, res.Message)
http.Redirect(w, r, "/orders", http.StatusSeeOther)
})
// Поручения — журнал всех видов
mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/orders", http.StatusMovedPermanently)
})
mux.HandleFunc("/orders", 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(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil {
s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
return
}
active, done := claimsView(out.Claims)
s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims,
"Total": len(out.Claims), "Active": active, "Done": done})
})
// Администратор: список клиентов
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(reqCtx(r), 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})
})
// Администратор: войти в кабинет клиента
mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Только POST с формой кабинета: иначе достаточно было заманить сотрудника
// на страницу с картинкой /impersonate?contract=… , чтобы его сессия молча
// переключилась в кабинет чужого клиента.
if r.Method != http.MethodPost || !csrfOK(r) {
setFlashCookie(w, r, "Форма устарела, откройте список клиентов заново.")
http.Redirect(w, r, "/clients", http.StatusSeeOther)
return
}
login := r.FormValue("login")
contract := r.FormValue("contract")
var out struct {
Session string `json:"session"`
AsClient string `json:"as_client"`
}
if err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/admin/impersonate", sess,
map[string]string{"login": login, "contract": contract}, &out); err != nil {
setFlashCookie(w, r, err.Error())
http.Redirect(w, r, "/clients", http.StatusSeeOther)
return
}
// сохраняем админскую сессию, чтобы можно было вернуться
setFlag(w, r, "bj_lk_admin_sess", sess)
setSession(w, r, out.Session)
setFlag(w, r, "bj_lk_as", out.AsClient)
http.Redirect(w, r, "/", http.StatusSeeOther)
})
// Администратор: вернуться из кабинета клиента
mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) {
// Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику
// токены чужих кабинетов копились бы и жили ещё 12 часов.
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
_ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
setSession(w, r, c.Value)
}
setFlag(w, r, "bj_lk_as", "")
setFlag(w, r, "bj_lk_admin_sess", "")
http.Redirect(w, r, "/clients", http.StatusSeeOther)
})
// Проверка связи с bj-server (для админа при установке)
// Проверка связи с ядром. Наружу отдаём только признак жизни: подробности
// (внутренний адрес ядра, состояние базы) — администратору по ключу модуля.
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
full := secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey)
var out map[string]any
err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/ping", "", nil, &out)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err != nil {
w.WriteHeader(http.StatusBadGateway)
resp := map[string]any{"ok": false}
if full {
resp["error"], resp["bj_url"], resp["mode"] = err.Error(), s.cfg.BJURL, s.mode()
}
_ = json.NewEncoder(w).Encode(resp)
return
}
resp := map[string]any{"ok": true}
if full {
resp["bj"], resp["bj_url"], resp["mode"] = out, s.cfg.BJURL, s.mode()
}
_ = json.NewEncoder(w).Encode(resp)
})
if s.agent != nil {
s.registerAgent(mux)
}
mux.Handle("/assets/", assetsHandler())
return harden(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))
}
// mode — режим связи с ядром, для страницы состояния и инструкций администратору.
func (s *Server) mode() string {
if s.cfg.Agent {
return "agent"
}
return "direct"
}