c32f399acc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
1168 lines
40 KiB
Go
1168 lines
40 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,
|
||
"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 { // цена: до 4 знаков, без хвостовых нулей, с пробелами разрядов
|
||
if f == 0 {
|
||
return "—"
|
||
}
|
||
s := strconv.FormatFloat(f, 'f', 4, 64)
|
||
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
|
||
whole, frac := s, ""
|
||
if i := strings.IndexByte(s, '.'); i >= 0 {
|
||
whole, frac = s[:i], s[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"`
|
||
}
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
var out []lkSegment
|
||
for k, v := range byKind {
|
||
if v <= 0 {
|
||
continue
|
||
}
|
||
c := colors[k]
|
||
if c == "" {
|
||
c = "#c9c3b4"
|
||
}
|
||
out = append(out, lkSegment{Kind: k, 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
|
||
var upTotal float64
|
||
for _, u := range out.Upcoming {
|
||
upTotal += u.Total
|
||
}
|
||
data["UpcomingTotal"] = upTotal
|
||
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
|
||
}
|
||
if r.Method == http.MethodPost {
|
||
_ = r.ParseForm()
|
||
var res struct {
|
||
Message string `json:"message"`
|
||
}
|
||
err := s.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 := 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, "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 := 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
|
||
}
|
||
_ = 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 := 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"`
|
||
}
|
||
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})
|
||
})
|
||
|
||
// Администратор: войти в кабинет клиента
|
||
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) {
|
||
// Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику
|
||
// токены чужих кабинетов копились бы и жили ещё 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
|
||
}
|