Кабинет: веб-установщик и управление пользователями в ядре
- установщик в самом кабинете: до настройки любая страница ведёт в /install; три шага — проверка сервера (каталог данных, https), связь с ядром (режим, адрес, ключ; связь проверяется до сохранения, в режиме ожидания страница ждёт подключения ядра), администратор кабинета (заводится в ядре через /api/lk/admin/bootstrap, только пока администраторов нет); настройки в data/lk.json 0600, окружение перекрывает файл; после установки установщик закрыт - настройки кабинета можно менять на ходу: Server за мьютексом, один mux на всё время - ключ модуля больше не кладётся в архив: его вводят в установщике; на странице настроек ключ показан целиком 10 минут после создания, потом маска - ядро: карточка «Пользователи кабинета» в настройках — список, заведение, смена пароля (гасит сессии), блокировка, права сотрудника, разрешение поручений, удаление; письма не отправляются - инструкция /admin/help/lk дополнена шагами установщика и разделом о подходящем хостинге - тесты: TestInstaller, TestNotConfigured, TestSetupPageLKUsersAndLink; установщик проверен вживую вторым экземпляром против боевого ядра Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+94
-65
@@ -17,6 +17,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -35,12 +36,15 @@ type Config struct {
|
||||
Agent bool
|
||||
}
|
||||
|
||||
// Server — кабинет.
|
||||
// Server — кабинет. Настройки живут за мьютексом: веб-установщик меняет их на ходу,
|
||||
// без перезапуска службы.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
bj *bjClient
|
||||
tpl *template.Template
|
||||
agent *agentQueue
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
bjc *bjClient
|
||||
agent *agentQueue
|
||||
dataDir string // где лежит lk.json (веб-установщик)
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
// New — кабинет с разобранными шаблонами.
|
||||
@@ -101,32 +105,70 @@ func New(cfg Config) (*Server, error) {
|
||||
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}
|
||||
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}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if s.cfg.BJKey == "" {
|
||||
func (s *Server) Configured() bool { return s.conf().configured() }
|
||||
|
||||
func (c Config) configured() bool {
|
||||
if c.BJKey == "" {
|
||||
return false
|
||||
}
|
||||
if s.cfg.Agent {
|
||||
if c.Agent {
|
||||
return true // адрес ядра не нужен: ядро подключается само
|
||||
}
|
||||
return s.cfg.BJURL != ""
|
||||
return c.BJURL != ""
|
||||
}
|
||||
|
||||
// InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и
|
||||
// токены сессий клиентов пойдут по сети в открытом виде.
|
||||
func (s *Server) InsecureCore() bool {
|
||||
if s.cfg.Agent || s.cfg.BJURL == "" || strings.HasPrefix(s.cfg.BJURL, "https://") {
|
||||
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(s.cfg.BJURL, "http://")
|
||||
rest := strings.TrimPrefix(c.BJURL, "http://")
|
||||
return !strings.HasPrefix(rest, "127.0.0.1") && !strings.HasPrefix(rest, "localhost")
|
||||
}
|
||||
|
||||
@@ -370,7 +412,7 @@ type securityCard struct {
|
||||
// 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)
|
||||
err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -381,7 +423,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
data["Title"] = s.cfg.Title
|
||||
data["Title"] = s.conf().Title
|
||||
if _, ok := data["Nav"]; !ok {
|
||||
data["Nav"] = ""
|
||||
}
|
||||
@@ -467,13 +509,11 @@ func session(r *http.Request) string {
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// Handler — все страницы кабинета. Без адреса bj-server или ключа — страница с подсказкой настройки.
|
||||
// Handler — все страницы кабинета. Пока кабинет не настроен, любая страница ведёт
|
||||
// в веб-установщик (/install), как у обычных веб-приложений после копирования файлов.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
if !s.Configured() {
|
||||
return harden(http.HandlerFunc(s.notConfigured))
|
||||
}
|
||||
bj := s.bj
|
||||
mux := http.NewServeMux()
|
||||
s.registerInstall(mux)
|
||||
|
||||
// Вход
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -491,7 +531,7 @@ func (s *Server) Handler() http.Handler {
|
||||
Contract string `json:"contract"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/login", "", map[string]string{
|
||||
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 {
|
||||
@@ -513,9 +553,9 @@ func (s *Server) Handler() http.Handler {
|
||||
// Выход гасит и клиентскую сессию, и сохранённую админскую: иначе после
|
||||
// «выхода» в браузере оставался действующий токен сотрудника.
|
||||
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
|
||||
_ = 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 != "" {
|
||||
_ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil)
|
||||
_ = s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil)
|
||||
}
|
||||
clearAll(w, r)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
@@ -541,7 +581,7 @@ func (s *Server) Handler() http.Handler {
|
||||
var cl struct {
|
||||
Claims []claimRow `json:"claims"`
|
||||
}
|
||||
_ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl)
|
||||
_ = s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl)
|
||||
active, _ := claimsView(cl.Claims)
|
||||
pos := pf.Positions
|
||||
if len(pos) > 5 {
|
||||
@@ -600,7 +640,7 @@ func (s *Server) Handler() http.Handler {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -625,7 +665,7 @@ func (s *Server) Handler() http.Handler {
|
||||
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 {
|
||||
if err := s.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
|
||||
}
|
||||
@@ -662,14 +702,14 @@ func (s *Server) Handler() http.Handler {
|
||||
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 {
|
||||
if err := s.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)
|
||||
_ = s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca)
|
||||
acts := ca.Actions
|
||||
if len(acts) > 8 {
|
||||
acts = acts[:8]
|
||||
@@ -690,7 +730,7 @@ func (s *Server) Handler() http.Handler {
|
||||
var res struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{
|
||||
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"),
|
||||
@@ -711,7 +751,7 @@ func (s *Server) Handler() http.Handler {
|
||||
Requests []documentRow `json:"requests"`
|
||||
Kinds []string `json:"kinds"`
|
||||
}
|
||||
if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -761,7 +801,7 @@ func (s *Server) Handler() http.Handler {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -790,7 +830,7 @@ func (s *Server) Handler() http.Handler {
|
||||
Message string `json:"message"`
|
||||
GUID string `json:"guid"`
|
||||
}
|
||||
err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/transfer", sess, map[string]any{
|
||||
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")),
|
||||
@@ -818,7 +858,7 @@ func (s *Server) Handler() http.Handler {
|
||||
var out struct {
|
||||
Claims []claimRow `json:"claims"`
|
||||
}
|
||||
if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -855,7 +895,7 @@ func (s *Server) Handler() http.Handler {
|
||||
if search != "" {
|
||||
path += "?search=" + url.QueryEscape(search)
|
||||
}
|
||||
if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -884,7 +924,7 @@ func (s *Server) Handler() http.Handler {
|
||||
Session string `json:"session"`
|
||||
AsClient string `json:"as_client"`
|
||||
}
|
||||
if err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/admin/impersonate", sess,
|
||||
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)
|
||||
@@ -902,7 +942,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику
|
||||
// токены чужих кабинетов копились бы и жили ещё 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)
|
||||
_ = 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", "")
|
||||
@@ -914,51 +954,40 @@ func (s *Server) Handler() http.Handler {
|
||||
// Проверка связи с ядром. Наружу отдаём только признак жизни: подробности
|
||||
// (внутренний адрес ядра, состояние базы) — администратору по ключу модуля.
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
full := secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey)
|
||||
cfg := s.conf()
|
||||
full := secureEqual(r.Header.Get("X-BJ-Key"), cfg.BJKey)
|
||||
var out map[string]any
|
||||
err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/ping", "", nil, &out)
|
||||
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(), s.cfg.BJURL, s.mode()
|
||||
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, s.cfg.BJURL, s.mode()
|
||||
resp["bj"], resp["bj_url"], resp["mode"] = out, cfg.BJURL, s.mode()
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
if s.agent != nil {
|
||||
s.registerAgent(mux)
|
||||
}
|
||||
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))
|
||||
return harden(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.Configured() && !strings.HasPrefix(r.URL.Path, "/install") && !strings.HasPrefix(r.URL.Path, "/assets/") && r.URL.Path != "/health" {
|
||||
http.Redirect(w, r, "/install", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
// mode — режим связи с ядром, для страницы состояния и инструкций администратору.
|
||||
func (s *Server) mode() string {
|
||||
if s.cfg.Agent {
|
||||
if s.conf().Agent {
|
||||
return "agent"
|
||||
}
|
||||
return "direct"
|
||||
|
||||
Reference in New Issue
Block a user