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>
966 lines
34 KiB
Go
966 lines
34 KiB
Go
// 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"
|
||
}
|