3bb5e79272
- L2b: CSRF для изменяющих запросов админки — проверка Origin (иначе Referer) против Host на всех не-GET под /admin (sameOriginRequest в authMiddleware); за loopback-прокси учитывается X-Forwarded-Host. Второй слой поверх SameSite=Lax, без правки десятков форм настроек. - L3: /back-to-admin меняет состояние — только POST + csrfOK (форма уже с токеном, проверки в обработчике не было). - L6: EXEC процедуры DEPO по имени из настроек — через execProc с проверкой идентификатора (sqlident.go, см. коммит строчных событий). - L7: сессия просмотра (impersonate) снимает IsAdmin цели — сотрудник не действует от имени другого администратора; свои действия — из своей сессии. - L8: bootstrap администратора кабинета под замком lkBootstrapMu — два параллельных запроса не заведут двух админов (TOCTOU). - L10: allowlist перезапуска служб не пропускает пустое имя. - L11: ретранслятор — лимит тела проверяется по Content-Length до чтения и чтение ограничено RELAY_MAX_BODY+1. - M4: .htaccess дополнительно запрещает *.lock (код установки), если данные оказались в веб-корне; nginx-запрет /data уже в README. L4 (привязка сессии к сети по умолчанию выкл.) и L9 (косметическая cookie bj_lk_admin, арбитр — ядро) оставлены как есть, отмечены в отчёте. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
1318 lines
46 KiB
Go
1318 lines
46 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"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"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
|
||
// Relay — ретранслятор: страницы рисует ядро, здесь только передача конвертов.
|
||
Relay bool
|
||
// Local — кабинет собран внутрь ядра: API вызывается в том же процессе, ключ
|
||
// берётся функцией LocalKey (он меняется в настройках без перезапуска).
|
||
Local http.Handler
|
||
LocalKey func() string
|
||
}
|
||
|
||
// Server — кабинет. Настройки живут за мьютексом: веб-установщик меняет их на ходу,
|
||
// без перезапуска службы.
|
||
type Server struct {
|
||
mu sync.RWMutex
|
||
cfg Config
|
||
bjc *bjClient
|
||
agent *agentQueue
|
||
dataDir string // где лежит lk.json (веб-установщик)
|
||
tpl *template.Template
|
||
|
||
handlerOnce sync.Once
|
||
handler http.Handler
|
||
}
|
||
|
||
// 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": fmtMoney,
|
||
// доля координаты X в ширине холста — для подписей дат под графиком
|
||
"pctOf": func(x, w float64) float64 {
|
||
if w == 0 {
|
||
return 0
|
||
}
|
||
return x / w * 100
|
||
},
|
||
// Русское число: 1 договор, 2 договора, 5 договоров.
|
||
"plural": func(n int, one, few, many string) string {
|
||
w := many
|
||
switch m100 := n % 100; {
|
||
case m100 >= 11 && m100 <= 14:
|
||
case n%10 == 1:
|
||
w = one
|
||
case n%10 >= 2 && n%10 <= 4:
|
||
w = few
|
||
}
|
||
return strconv.Itoa(n) + " " + w
|
||
},
|
||
"fmtpct": func(f float64) string {
|
||
s := strconv.FormatFloat(f, 'f', 2, 64)
|
||
if f > 0 {
|
||
s = "+" + s
|
||
}
|
||
return strings.ReplaceAll(s, ".", ",") + " %"
|
||
},
|
||
"fmtdate": func(iso string) string { // 2026-09-18 → 18.09.2026
|
||
if t, err := time.Parse("2006-01-02", iso); err == nil {
|
||
return t.Format("02.01.2006")
|
||
}
|
||
return iso
|
||
},
|
||
"fmtpct1": func(f float64) string { // 84,1%
|
||
return strings.ReplaceAll(strconv.FormatFloat(f, 'f', 1, 64), ".", ",") + "%"
|
||
},
|
||
"fmtsigned": func(f float64) string { // +1 234,56 / −1 234,56
|
||
if f > 0 {
|
||
return "+" + fmtMoney(f)
|
||
}
|
||
return fmtMoney(f)
|
||
},
|
||
"fmtprice": func(f float64) string { // цена: крупные — 2 знака, мелкие — до 4
|
||
if f == 0 {
|
||
return "—"
|
||
}
|
||
digits := 4
|
||
if f >= 10 || f <= -10 {
|
||
digits = 2
|
||
}
|
||
str := strconv.FormatFloat(f, 'f', digits, 64)
|
||
if strings.Contains(str, ".") {
|
||
str = strings.TrimRight(strings.TrimRight(str, "0"), ".")
|
||
}
|
||
whole, frac := str, ""
|
||
if i := strings.IndexByte(str, '.'); i >= 0 {
|
||
whole, frac = str[:i], str[i+1:]
|
||
}
|
||
neg := strings.HasPrefix(whole, "-")
|
||
whole = strings.TrimPrefix(whole, "-")
|
||
var b strings.Builder
|
||
for i, c := range whole {
|
||
if i > 0 && (len(whole)-i)%3 == 0 {
|
||
b.WriteByte(' ')
|
||
}
|
||
b.WriteRune(c)
|
||
}
|
||
out := b.String()
|
||
if frac != "" {
|
||
out += "," + frac
|
||
}
|
||
if neg {
|
||
out = "−" + out
|
||
}
|
||
return out
|
||
},
|
||
"shortid": func(s string) string {
|
||
if len(s) > 8 {
|
||
return s[:8] + "…"
|
||
}
|
||
return s
|
||
},
|
||
}).Parse(pageTemplates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
srv := &Server{tpl: tpl}
|
||
srv.apply(cfg)
|
||
return srv, nil
|
||
}
|
||
|
||
// apply — применить настройки: пересобрать клиента к ядру и очередь режима ожидания.
|
||
func (s *Server) apply(cfg Config) {
|
||
cfg.BJURL = strings.TrimRight(strings.TrimSpace(cfg.BJURL), "/")
|
||
cfg.BJKey = strings.TrimSpace(cfg.BJKey)
|
||
if cfg.Title == "" {
|
||
cfg.Title = "Личный кабинет"
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.cfg = cfg
|
||
s.agent = nil
|
||
if cfg.Agent {
|
||
s.agent = newAgentQueue()
|
||
}
|
||
s.bjc = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: s.agent, local: cfg.Local, localKey: cfg.LocalKey}
|
||
}
|
||
|
||
// conf — текущие настройки (копия).
|
||
func (s *Server) conf() Config {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.cfg
|
||
}
|
||
|
||
// bj — клиент к ядру по текущим настройкам.
|
||
func (s *Server) bj() *bjClient {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.bjc
|
||
}
|
||
|
||
func (s *Server) agentQueue() *agentQueue {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.agent
|
||
}
|
||
|
||
// Configured — кабинету есть с чем работать: ключ модуля и, в прямом режиме, адрес ядра.
|
||
func (s *Server) Configured() bool { return s.conf().configured() }
|
||
|
||
func (c Config) configured() bool {
|
||
if c.Local != nil {
|
||
return true // внутри ядра настраивать нечего
|
||
}
|
||
if c.BJKey == "" {
|
||
return false
|
||
}
|
||
if c.Agent {
|
||
return true // адрес ядра не нужен: ядро подключается само
|
||
}
|
||
return c.BJURL != ""
|
||
}
|
||
|
||
// InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и
|
||
// токены сессий клиентов пойдут по сети в открытом виде.
|
||
func (s *Server) InsecureCore() bool { return s.conf().insecureCore() }
|
||
|
||
func (c Config) insecureCore() bool {
|
||
if c.Agent || c.BJURL == "" || strings.HasPrefix(c.BJURL, "https://") {
|
||
return false
|
||
}
|
||
rest := strings.TrimPrefix(c.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 // задан в режиме ожидания
|
||
local http.Handler // задан внутри ядра: API вызывается без сети
|
||
localKey func() string
|
||
}
|
||
|
||
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)
|
||
target := b.cfg.BJURL + path
|
||
if b.local != nil {
|
||
target = path
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, target, rdr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
key := b.cfg.BJKey
|
||
if b.localKey != nil {
|
||
key = b.localKey()
|
||
}
|
||
req.Header.Set("X-BJ-Key", key)
|
||
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")
|
||
}
|
||
var resp *http.Response
|
||
if b.local != nil {
|
||
// Внутри ядра: тот же обработчик API, только без сети. Граница проверок та же.
|
||
rec := &envelopeRecorder{status: http.StatusOK, hdr: http.Header{}}
|
||
req.RemoteAddr = "127.0.0.1:0"
|
||
b.local.ServeHTTP(rec, req)
|
||
resp = &http.Response{StatusCode: rec.status, Header: rec.hdr, Body: io.NopCloser(&rec.buf)}
|
||
} else {
|
||
resp, err = b.hc.Do(req)
|
||
if err != nil {
|
||
return errNoCore(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
|
||
}
|
||
|
||
// errNoCore / errCore — ошибки связи с ядром одним текстом для страниц.
|
||
func errNoCore(err error) error { return fmt.Errorf("нет связи с сервером: %w", err) }
|
||
func errCore(msg string) error { return fmt.Errorf("%s", msg) }
|
||
|
||
var errRequestTooLarge = fmt.Errorf("запрос слишком большой")
|
||
|
||
// 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"`
|
||
Email string `json:"email"`
|
||
Phone string `json:"phone"`
|
||
LastLogin string `json:"last_login"`
|
||
}
|
||
|
||
// operationRow — движение по счёту депо.
|
||
// lkPortfolio — портфель как у брокера (ответ /api/lk/portfolio2).
|
||
type lkPortfolio struct {
|
||
Source string `json:"source"`
|
||
AsOf string `json:"as_of"`
|
||
Total float64 `json:"total"`
|
||
Securities float64 `json:"securities_value"`
|
||
CashRUB float64 `json:"cash_rub"`
|
||
Cost float64 `json:"cost"`
|
||
PnL float64 `json:"pnl"`
|
||
PnLPct float64 `json:"pnl_pct"`
|
||
Priced int `json:"priced"`
|
||
Cash []lkCash `json:"cash"`
|
||
Positions []lkPosition `json:"positions"`
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
type lkCash struct {
|
||
Currency string `json:"currency"`
|
||
Amount float64 `json:"amount"`
|
||
}
|
||
|
||
type lkPosition struct {
|
||
ISIN string `json:"isin"`
|
||
Code string `json:"code"`
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Quantity float64 `json:"quantity"`
|
||
AvgPrice float64 `json:"avg_price"`
|
||
Price float64 `json:"price"`
|
||
InPct bool `json:"in_pct"`
|
||
Value float64 `json:"value"`
|
||
Cost float64 `json:"cost"`
|
||
PnL float64 `json:"pnl"`
|
||
PnLPct float64 `json:"pnl_pct"`
|
||
DayPct float64 `json:"day_pct"`
|
||
Share float64 `json:"share"`
|
||
Currency string `json:"currency"`
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
// lkUpcoming — ожидаемая выплата по бумаге портфеля.
|
||
type lkUpcoming struct {
|
||
Date string `json:"date"`
|
||
ISIN string `json:"isin"`
|
||
Security string `json:"security"`
|
||
Kind string `json:"kind"`
|
||
PerUnit float64 `json:"per_unit"`
|
||
Quantity float64 `json:"quantity"`
|
||
Total float64 `json:"total"`
|
||
Percent float64 `json:"percent"`
|
||
}
|
||
|
||
// lkPoint — точка истории стоимости портфеля (ответ /api/lk/history).
|
||
type lkPoint struct {
|
||
Day string `json:"day"`
|
||
Value float64 `json:"value"`
|
||
Cash float64 `json:"cash"`
|
||
Total float64 `json:"total"`
|
||
Positions int `json:"positions"`
|
||
}
|
||
|
||
// lkChart — готовый график для вставки в страницу: считаем его на сервере и
|
||
// отдаём координатами, потому что скриптов в кабинете нет (строгая CSP).
|
||
type lkChart struct {
|
||
Line, Area string // пути SVG
|
||
W, H float64 // размеры холста
|
||
Min, Max float64 // границы шкалы
|
||
First, Last float64 // стоимость в начале и в конце периода
|
||
Change float64 // изменение за период, ₽
|
||
ChangePct float64 // изменение за период, %
|
||
FromDay string // первая дата периода
|
||
ToDay string // последняя дата
|
||
Points int
|
||
GridY []float64 // горизонтальные линии (y в координатах холста)
|
||
GridLabel []string // подписи к ним
|
||
Marks []lkMark // подписи дат под осью
|
||
}
|
||
|
||
// lkMark — подпись даты на оси времени.
|
||
type lkMark struct {
|
||
X float64
|
||
Text string
|
||
}
|
||
|
||
// portfolioChart — линия стоимости портфеля по ежедневным срезам.
|
||
func portfolioChart(points []lkPoint) *lkChart {
|
||
if len(points) < 2 {
|
||
return nil
|
||
}
|
||
const w, h, pad = 980.0, 190.0, 10.0
|
||
lo, hi := points[0].Total, points[0].Total
|
||
for _, p := range points {
|
||
if p.Total < lo {
|
||
lo = p.Total
|
||
}
|
||
if p.Total > hi {
|
||
hi = p.Total
|
||
}
|
||
}
|
||
// Ось не от нуля: иначе движение на 1–2 % не видно. Запас сверху и снизу —
|
||
// чтобы линия не липла к краю.
|
||
span := hi - lo
|
||
if span <= 0 {
|
||
span = hi * 0.02
|
||
if span <= 0 {
|
||
span = 1
|
||
}
|
||
}
|
||
lo, hi = lo-span*0.15, hi+span*0.15
|
||
x := func(i int) float64 { return pad + (w-2*pad)*float64(i)/float64(len(points)-1) }
|
||
y := func(v float64) float64 { return pad + (h-2*pad)*(1-(v-lo)/(hi-lo)) }
|
||
var line, area strings.Builder
|
||
for i, p := range points {
|
||
cmd := "L"
|
||
if i == 0 {
|
||
cmd = "M"
|
||
}
|
||
fmt.Fprintf(&line, "%s%.1f %.1f ", cmd, x(i), y(p.Total))
|
||
}
|
||
area.WriteString(line.String())
|
||
fmt.Fprintf(&area, "L%.1f %.1f L%.1f %.1f Z", x(len(points)-1), h, x(0), h)
|
||
c := &lkChart{Line: strings.TrimSpace(line.String()), Area: area.String(), W: w, H: h,
|
||
Min: lo, Max: hi, First: points[0].Total, Last: points[len(points)-1].Total,
|
||
FromDay: ruDateFull(points[0].Day), ToDay: ruDateFull(points[len(points)-1].Day), Points: len(points)}
|
||
c.Change = c.Last - c.First
|
||
if c.First != 0 {
|
||
c.ChangePct = c.Change / c.First * 100
|
||
}
|
||
for _, frac := range []float64{0, 0.5, 1} {
|
||
v := lo + (hi-lo)*frac
|
||
c.GridY = append(c.GridY, y(v))
|
||
c.GridLabel = append(c.GridLabel, fmtMoney(v))
|
||
}
|
||
// Подписи дат: начало, середина, конец — больше на узкой карточке не влезает.
|
||
for _, i := range []int{0, len(points) / 2, len(points) - 1} {
|
||
c.Marks = append(c.Marks, lkMark{X: x(i), Text: ruDate(points[i].Day)})
|
||
}
|
||
return c
|
||
}
|
||
|
||
// ruDateFull — 2026-09-18 → 18.09.2026.
|
||
func ruDateFull(iso string) string {
|
||
if t, err := time.Parse("2006-01-02", iso); err == nil {
|
||
return t.Format("02.01.2006")
|
||
}
|
||
return iso
|
||
}
|
||
|
||
// ruDate — 2026-09-18 → 18.09.
|
||
func ruDate(iso string) string {
|
||
if t, err := time.Parse("2006-01-02", iso); err == nil {
|
||
return t.Format("02.01")
|
||
}
|
||
return iso
|
||
}
|
||
|
||
// lkSegment — доля вида активов в портфеле для полосы структуры.
|
||
type lkSegment struct {
|
||
Kind string
|
||
Value float64
|
||
Share float64
|
||
Color string
|
||
}
|
||
|
||
// portfolioStructure — структура портфеля по видам активов (бумаги по виду + деньги).
|
||
func portfolioStructure(p lkPortfolio) []lkSegment {
|
||
if p.Total <= 0 {
|
||
return nil
|
||
}
|
||
colors := map[string]string{"акция": "#e8a33d", "облигация": "#4f7cac", "фонд": "#5f9e7a", "пай": "#5f9e7a", "депозитарная расписка": "#b07aa1", "деньги": "#8e8879"}
|
||
byKind := map[string]float64{}
|
||
for _, pos := range p.Positions {
|
||
k := pos.Kind
|
||
if k == "" || k == "бумага" {
|
||
k = "прочее"
|
||
}
|
||
byKind[k] += pos.Value
|
||
}
|
||
if p.CashRUB > 0 {
|
||
byKind["деньги"] += p.CashRUB
|
||
}
|
||
plural := map[string]string{"акция": "акции", "облигация": "облигации", "фонд": "фонды", "пай": "паи", "депозитарная расписка": "расписки"}
|
||
var out []lkSegment
|
||
for k, v := range byKind {
|
||
if v <= 0 {
|
||
continue
|
||
}
|
||
c := colors[k]
|
||
if c == "" {
|
||
c = "#c9c3b4"
|
||
}
|
||
label := k
|
||
if pl, ok := plural[k]; ok {
|
||
label = pl
|
||
}
|
||
out = append(out, lkSegment{Kind: label, Value: v, Share: v / p.Total * 100, Color: c})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Value > out[j].Value })
|
||
return out
|
||
}
|
||
|
||
// lkOpRow — операция по брокерскому счёту (ответ /api/lk/ops).
|
||
type lkOpRow struct {
|
||
Date string `json:"date"`
|
||
Kind string `json:"kind"`
|
||
Side string `json:"side"`
|
||
Name string `json:"name"`
|
||
ISIN string `json:"isin"`
|
||
Quantity float64 `json:"quantity"`
|
||
Price float64 `json:"price"`
|
||
Amount float64 `json:"amount"`
|
||
Currency string `json:"currency"`
|
||
AmountRUB float64 `json:"amount_rub"`
|
||
}
|
||
|
||
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.conf().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 — все страницы кабинета. Пока кабинет не настроен, любая страница ведёт
|
||
// в веб-установщик (/install), как у обычных веб-приложений после копирования файлов.
|
||
func (s *Server) Handler() http.Handler {
|
||
s.handlerOnce.Do(func() { s.handler = s.buildHandler() })
|
||
return s.handler
|
||
}
|
||
|
||
func (s *Server) buildHandler() http.Handler {
|
||
mux := http.NewServeMux()
|
||
s.registerInstall(mux)
|
||
|
||
// Вход
|
||
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 := s.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) {
|
||
_ = s.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 != "" {
|
||
_ = s.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
|
||
}
|
||
// Сотрудник, не открывший кабинет клиента: его «табло» — список клиентов.
|
||
if c, err := r.Cookie("bj_lk_admin"); err == nil && c.Value == "1" {
|
||
if as, err := r.Cookie("bj_lk_as"); err != nil || as.Value == "" {
|
||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||
return
|
||
}
|
||
}
|
||
view := "online"
|
||
q := url.Values{}
|
||
today := time.Now().Format("2006-01-02")
|
||
date := r.URL.Query().Get("date")
|
||
if r.URL.Query().Get("view") == "archive" {
|
||
view = "archive"
|
||
if _, err := time.Parse("2006-01-02", date); err != nil || date > today {
|
||
date = today
|
||
}
|
||
q.Set("view", "archive")
|
||
q.Set("date", date)
|
||
}
|
||
var out struct {
|
||
Portfolio lkPortfolio `json:"portfolio"`
|
||
Contract string `json:"contract"`
|
||
FullName string `json:"full_name"`
|
||
Upcoming []lkUpcoming `json:"upcoming"`
|
||
}
|
||
path := "/api/lk/portfolio2"
|
||
if len(q) > 0 {
|
||
path += "?" + q.Encode()
|
||
}
|
||
data := map[string]any{"Nav": "b", "View": view, "Date": date, "Today": today}
|
||
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
|
||
data["Error"] = err.Error()
|
||
data["P"] = lkPortfolio{}
|
||
s.render(w, r, "portfolio", data)
|
||
return
|
||
}
|
||
data["P"], data["Contract"], data["FullName"] = out.Portfolio, out.Contract, out.FullName
|
||
data["Structure"], data["Upcoming"] = portfolioStructure(out.Portfolio), out.Upcoming
|
||
// Рубли и так видны в шапке и структуре — отдельный блок нужен, когда валют несколько.
|
||
showCash := len(out.Portfolio.Cash) > 1
|
||
if len(out.Portfolio.Cash) == 1 && out.Portfolio.Cash[0].Currency != "SUR" {
|
||
showCash = true
|
||
}
|
||
data["ShowCash"] = showCash
|
||
var upTotal float64
|
||
for _, u := range out.Upcoming {
|
||
upTotal += u.Total
|
||
}
|
||
data["UpcomingTotal"] = upTotal
|
||
// График стоимости — только для «Онлайн»: в архиве он был бы историей
|
||
// срезов, а не тем, что показано на экране.
|
||
if view != "archive" {
|
||
var hist struct {
|
||
Points []lkPoint `json:"points"`
|
||
On bool `json:"on"`
|
||
At string `json:"at"`
|
||
}
|
||
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/history?days=180", sess, nil, &hist); err == nil {
|
||
data["Chart"] = portfolioChart(hist.Points)
|
||
data["ChartPoints"] = len(hist.Points)
|
||
data["ChartOn"] = hist.On
|
||
}
|
||
}
|
||
s.render(w, r, "portfolio", data)
|
||
})
|
||
|
||
mux.HandleFunc("/portfolio", func(w http.ResponseWriter, r *http.Request) {
|
||
http.Redirect(w, r, "/", http.StatusMovedPermanently)
|
||
})
|
||
|
||
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 := s.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")
|
||
var out struct {
|
||
Rows []lkOpRow `json:"operations"`
|
||
From string `json:"from"`
|
||
To string `json:"to"`
|
||
Note string `json:"note"`
|
||
}
|
||
path := "/api/lk/ops?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
|
||
data := map[string]any{"Nav": "o", "From": from, "To": to}
|
||
if err := s.bj().call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
|
||
data["Error"] = err.Error()
|
||
s.render(w, r, "operations", data)
|
||
return
|
||
}
|
||
sort.SliceStable(out.Rows, func(i, j int) bool { // свежие сверху, независимо от порядка в ответе
|
||
di, _ := time.Parse("02.01.2006", out.Rows[i].Date)
|
||
dj, _ := time.Parse("02.01.2006", out.Rows[j].Date)
|
||
return di.After(dj)
|
||
})
|
||
var buy, sell, income float64
|
||
for _, o := range out.Rows {
|
||
switch o.Kind {
|
||
case "покупка", "ввод бумаг", "пополнение":
|
||
buy += o.AmountRUB
|
||
case "продажа", "погашение", "вывод бумаг", "вывод денег":
|
||
sell += o.AmountRUB
|
||
default:
|
||
if o.Side == "in" {
|
||
income += o.AmountRUB
|
||
}
|
||
}
|
||
}
|
||
data["Rows"], data["From"], data["To"], data["Note"] = out.Rows, out.From, out.To, out.Note
|
||
data["BuyRUB"], data["SellRUB"], data["IncomeRUB"] = buy, sell, income
|
||
s.render(w, r, "operations", data)
|
||
})
|
||
|
||
mux.HandleFunc("/income", func(w http.ResponseWriter, r *http.Request) {
|
||
http.Redirect(w, r, "/operations", http.StatusMovedPermanently)
|
||
})
|
||
|
||
mux.HandleFunc("/documents", func(w http.ResponseWriter, r *http.Request) {
|
||
sess := sessionW(w, r)
|
||
if sess == "" {
|
||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||
return
|
||
}
|
||
var out struct {
|
||
Ready []documentRow `json:"ready"`
|
||
}
|
||
if err := s.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})
|
||
})
|
||
|
||
// Профиль: договор, счета депо, доступ
|
||
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,
|
||
"Email": pf.Email, "Phone": pf.Phone, "LastLogin": pf.LastLogin,
|
||
})
|
||
})
|
||
|
||
// Поручение депозитарию (сейчас — перевод бумаг в другой депозитарий)
|
||
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 := s.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
|
||
}
|
||
// Изменяющее действие (поручение на списание бумаг) — только по своей форме
|
||
// с CSRF-токеном, как /login и /impersonate. Иначе чужая страница могла бы
|
||
// отправить поручение от имени вошедшего клиента.
|
||
if !csrfOK(r) {
|
||
setFlashCookie(w, r, "Форма устарела, откройте поручение заново.")
|
||
http.Redirect(w, r, "/order", http.StatusSeeOther)
|
||
return
|
||
}
|
||
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 := s.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 := s.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"`
|
||
Suggest []string `json:"suggest"`
|
||
}
|
||
search := strings.TrimSpace(r.URL.Query().Get("search"))
|
||
path := "/api/lk/admin/clients"
|
||
if search != "" {
|
||
path += "?search=" + url.QueryEscape(search)
|
||
}
|
||
if err := s.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,
|
||
"Suggest": out.Suggest, "Found": len(out.Clients) + len(out.Depo)})
|
||
})
|
||
|
||
// Администратор: войти в кабинет клиента
|
||
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 := s.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) {
|
||
// Меняет состояние (гасит сессию просмотра, переключает cookie) — только POST
|
||
// своей формы с CSRF, как и остальные изменяющие действия.
|
||
if r.Method != http.MethodPost || !csrfOK(r) {
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
return
|
||
}
|
||
// Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику
|
||
// токены чужих кабинетов копились бы и жили ещё 12 часов.
|
||
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
|
||
_ = s.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) {
|
||
cfg := s.conf()
|
||
full := secureEqual(r.Header.Get("X-BJ-Key"), cfg.BJKey)
|
||
var out map[string]any
|
||
err := s.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(), 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, cfg.BJURL, s.mode()
|
||
}
|
||
_ = json.NewEncoder(w).Encode(resp)
|
||
})
|
||
s.registerAgent(mux)
|
||
mux.Handle("/assets/", assetsHandler())
|
||
return harden(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
p := r.URL.Path
|
||
if !s.Configured() && !strings.HasPrefix(p, "/install") && !strings.HasPrefix(p, "/assets/") && p != "/health" {
|
||
http.Redirect(w, r, "/install", http.StatusSeeOther)
|
||
return
|
||
}
|
||
// Ретранслятор: всё, кроме своих служебных страниц, уезжает в ядро конвертом.
|
||
if s.conf().Relay && !strings.HasPrefix(p, "/install") && !strings.HasPrefix(p, "/agent/") && p != "/health" {
|
||
s.relay(w, r)
|
||
return
|
||
}
|
||
mux.ServeHTTP(w, r)
|
||
}))
|
||
}
|
||
|
||
// mode — режим связи с ядром, для страницы состояния и инструкций администратору.
|
||
func (s *Server) mode() string {
|
||
if s.conf().Agent {
|
||
return "agent"
|
||
}
|
||
return "direct"
|
||
}
|
||
|
||
// fmtMoney — 1 234 567,89 с разделителем разрядов; отрицательные — с минусом.
|
||
func fmtMoney(f float64) string {
|
||
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
|
||
}
|