Кабинет: веб-установщик и управление пользователями в ядре
- установщик в самом кабинете: до настройки любая страница ведёт в /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:
+8
-1
@@ -49,12 +49,19 @@ func main() {
|
|||||||
if listen == "" {
|
if listen == "" {
|
||||||
listen = ":8081"
|
listen = ":8081"
|
||||||
}
|
}
|
||||||
|
// Настройки из файла веб-установщика (data/lk.json); окружение и флаги их перекрывают.
|
||||||
|
dataDir := lk.DefaultDataDir()
|
||||||
|
cfg, err := lk.LoadConfigFile(dataDir, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("bj-lk: %v", err)
|
||||||
|
}
|
||||||
srv, err := lk.New(cfg)
|
srv, err := lk.New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("bj-lk: %v", err)
|
log.Fatalf("bj-lk: %v", err)
|
||||||
}
|
}
|
||||||
|
srv.SetDataDir(dataDir)
|
||||||
if !srv.Configured() {
|
if !srv.Configured() {
|
||||||
log.Printf("bj-lk: не заданы BJ_KEY и адрес ядра — кабинет показывает страницу настройки (см. /etc/bj-lk/lk.env)")
|
log.Printf("bj-lk: кабинет не настроен — откройте его адрес в браузере, запустится установщик (или задайте BJ_KEY и адрес ядра в окружении)")
|
||||||
}
|
}
|
||||||
if srv.InsecureCore() {
|
if srv.InsecureCore() {
|
||||||
// Не отказываем в запуске (бывают закрытые сети со своим шифрованием),
|
// Не отказываем в запуске (бывают закрытые сети со своим шифрованием),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package lkgateway
|
|||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||||
sess := newLKSessions()
|
sess := newLKSessions()
|
||||||
sess.bindNetwork = func() bool { return rc.Snapshot().LK.BindNetwork }
|
sess.bindNetwork = func() bool { return rc.Snapshot().LK.BindNetwork }
|
||||||
|
globalLKSessions = sess
|
||||||
|
|
||||||
// проверка ключа модуля
|
// проверка ключа модуля
|
||||||
auth := func(w http.ResponseWriter, r *http.Request) bool {
|
auth := func(w http.ResponseWriter, r *http.Request) bool {
|
||||||
@@ -117,6 +119,40 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Первый администратор кабинета из веб-установщика. Только по ключу модуля и только
|
||||||
|
// пока администраторов нет: потом учётные записи заводит администратор в настройках.
|
||||||
|
mux.HandleFunc("/api/lk/admin/bootstrap", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
lkJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "нужен POST"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
has, err := lkHasAdmin(r.Context(), rc)
|
||||||
|
if err != nil {
|
||||||
|
lkJSON(w, http.StatusBadGateway, map[string]string{"error": "база кабинета недоступна: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
lkJSON(w, http.StatusConflict, map[string]string{"error": "администратор кабинета уже создан — войдите под ним или пропустите шаг"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct{ Login, Password, FullName, Email string }
|
||||||
|
if json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&in) != nil {
|
||||||
|
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := LKUser{Login: strings.TrimSpace(in.Login), Contract: "ADMIN", FullName: strings.TrimSpace(in.FullName),
|
||||||
|
Email: strings.TrimSpace(in.Email), Active: true, IsAdmin: true}
|
||||||
|
if err := lkCreateUser(r.Context(), rc, u, in.Password); err != nil {
|
||||||
|
lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raiseSystemAlert(rc, "Кабинет: создан администратор", fmt.Sprintf("Из установщика кабинета создан администратор «%s».", u.Login))
|
||||||
|
lkJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "администратор создан"})
|
||||||
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/api/lk/logout", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/lk/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !auth(w, r) {
|
if !auth(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ func lkNewAPIKey() (string, error) {
|
|||||||
return "lk-" + hex.EncodeToString(b), nil
|
return "lk-" + hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lkKeyRevealUntil — до какого момента ключ на странице настроек показывается целиком:
|
||||||
|
// сразу после создания администратору нужно переписать его в установщик кабинета.
|
||||||
|
var lkKeyRevealUntil time.Time
|
||||||
|
|
||||||
|
// lkKeyForPage — ключ для страницы настроек: целиком сразу после создания, потом маска.
|
||||||
|
func lkKeyForPage(key string) string {
|
||||||
|
if key != "" && time.Now().Before(lkKeyRevealUntil) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return maskSecret(key)
|
||||||
|
}
|
||||||
|
|
||||||
// maskSecret — показать ключ так, чтобы его нельзя было списать со скриншота,
|
// maskSecret — показать ключ так, чтобы его нельзя было списать со скриншота,
|
||||||
// но можно было сверить с тем, что стоит у кабинета.
|
// но можно было сверить с тем, что стоит у кабинета.
|
||||||
func maskSecret(v string) string {
|
func maskSecret(v string) string {
|
||||||
@@ -84,18 +96,26 @@ func lkDistFiles(s Settings, coreURL string) ([]struct {
|
|||||||
// ядро подключается к кабинету само по имени этого сервера.
|
// ядро подключается к кабинету само по имени этого сервера.
|
||||||
mode, urlLine = "agent", "# BJ_URL не нужен: ядро подключается к кабинету само"
|
mode, urlLine = "agent", "# BJ_URL не нужен: ядро подключается к кабинету само"
|
||||||
}
|
}
|
||||||
env := fmt.Sprintf(`# Настройки кабинета клиента (bj-lk). Файл читает служба, права 0600.
|
// Ключ в архив НЕ кладём: его вводят в веб-установщике кабинета. Так архив можно
|
||||||
|
// хранить и пересылать, а секрет живёт только в двух местах — в ядре и в файле
|
||||||
|
// настроек кабинета с правами 0600.
|
||||||
|
env := fmt.Sprintf(`# Настройки кабинета клиента (bj-lk) для установки службой. Файл читает служба, права 0600.
|
||||||
|
# Проще — ничего здесь не менять: после запуска откройте адрес кабинета в браузере,
|
||||||
|
# запустится установщик и сам спросит способ связи и ключ.
|
||||||
# Режим связи с ядром: direct — кабинет обращается к ядру; agent — ядро подключается сюда.
|
# Режим связи с ядром: direct — кабинет обращается к ядру; agent — ядро подключается сюда.
|
||||||
BJ_MODE=%s
|
BJ_MODE=%s
|
||||||
# Адрес ядра bj, к которому обращается кабинет (только для режима direct):
|
# Адрес ядра bj, к которому обращается кабинет (только для режима direct):
|
||||||
%s
|
%s
|
||||||
# Ключ доступа модуля — тот же, что в настройках ядра, раздел «Личный кабинет».
|
# Ключ доступа модуля — из настроек ядра, раздел «Личный кабинет». Оставьте пустым,
|
||||||
BJ_KEY=%s
|
# чтобы ввести его в установщике; заполните — если ставите без браузера.
|
||||||
|
BJ_KEY=
|
||||||
# Адрес и порт, на которых кабинет слушает (за ним обычно стоит nginx с TLS):
|
# Адрес и порт, на которых кабинет слушает (за ним обычно стоит nginx с TLS):
|
||||||
LISTEN=127.0.0.1:8099
|
LISTEN=127.0.0.1:8099
|
||||||
# Заголовок кабинета:
|
# Заголовок кабинета:
|
||||||
LK_TITLE=%s
|
LK_TITLE=%s
|
||||||
`, mode, urlLine, s.LK.APIKey, title)
|
# Каталог данных (файл настроек установщика):
|
||||||
|
LK_DATA=/var/lib/bj-lk
|
||||||
|
`, mode, urlLine, title)
|
||||||
|
|
||||||
unit := `[Unit]
|
unit := `[Unit]
|
||||||
Description=Личный кабинет клиента депозитария (bj-lk)
|
Description=Личный кабинет клиента депозитария (bj-lk)
|
||||||
@@ -116,7 +136,7 @@ NoNewPrivileges=true
|
|||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ReadWritePaths=/var/log/bj-lk
|
ReadWritePaths=/var/log/bj-lk /var/lib/bj-lk
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -129,6 +149,7 @@ DIR=$(cd "$(dirname "$0")" && pwd)
|
|||||||
id bjlk >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin bjlk
|
id bjlk >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin bjlk
|
||||||
install -d -m 755 /opt/bj-lk
|
install -d -m 755 /opt/bj-lk
|
||||||
install -d -m 750 -o bjlk -g bjlk /var/log/bj-lk
|
install -d -m 750 -o bjlk -g bjlk /var/log/bj-lk
|
||||||
|
install -d -m 700 -o bjlk -g bjlk /var/lib/bj-lk
|
||||||
install -d -m 750 /etc/bj-lk
|
install -d -m 750 /etc/bj-lk
|
||||||
install -m 755 "$DIR/bj-lk" /opt/bj-lk/bj-lk
|
install -m 755 "$DIR/bj-lk" /opt/bj-lk/bj-lk
|
||||||
if [ -f /etc/bj-lk/lk.env ]; then
|
if [ -f /etc/bj-lk/lk.env ]; then
|
||||||
@@ -142,7 +163,8 @@ systemctl enable --now bj-lk
|
|||||||
sleep 2
|
sleep 2
|
||||||
systemctl --no-pager --full status bj-lk | head -n 12
|
systemctl --no-pager --full status bj-lk | head -n 12
|
||||||
echo
|
echo
|
||||||
echo "Проверка: curl -s http://127.0.0.1:8099/health"
|
echo "Дальше откройте адрес кабинета в браузере — запустится установщик."
|
||||||
|
echo "Проверка службы: curl -s http://127.0.0.1:8099/health"
|
||||||
`
|
`
|
||||||
|
|
||||||
readme := fmt.Sprintf(`Личный кабинет клиента депозитария (bj-lk)
|
readme := fmt.Sprintf(`Личный кабинет клиента депозитария (bj-lk)
|
||||||
@@ -160,10 +182,19 @@ echo "Проверка: curl -s http://127.0.0.1:8099/health"
|
|||||||
install.sh — установка одной командой
|
install.sh — установка одной командой
|
||||||
README.txt — этот файл
|
README.txt — этот файл
|
||||||
|
|
||||||
Установка:
|
Установка — как у обычного веб-приложения:
|
||||||
1. Распакуйте архив на сервере кабинета.
|
1. Распакуйте архив на сервере кабинета.
|
||||||
2. sudo ./install.sh
|
2. sudo ./install.sh — заводит службу; либо просто запустите ./bj-lk в своём
|
||||||
3. Проверьте: curl -s http://127.0.0.1:8099/health — должно вернуться "ok":true.
|
каталоге, если хостинг позволяет держать программу запущенной.
|
||||||
|
3. Откройте адрес кабинета в браузере. Запустится установщик: проверит сервер,
|
||||||
|
спросит способ связи с ядром и ключ доступа, проверит связь и предложит
|
||||||
|
завести администратора кабинета. Настройки он сохранит в файл lk.json
|
||||||
|
(каталог LK_DATA, права 0600). Ключа в этом архиве нет — берите его из
|
||||||
|
настроек ядра, раздел «Личный кабинет».
|
||||||
|
4. Проверка службы: curl -s http://127.0.0.1:8099/health — "ok":true.
|
||||||
|
|
||||||
|
Установка без браузера: заполните BJ_KEY (и BJ_URL для режима direct) в lk.env и
|
||||||
|
перезапустите службу — окружение перекрывает файл установщика.
|
||||||
|
|
||||||
Настройки (файл /etc/bj-lk/lk.env):
|
Настройки (файл /etc/bj-lk/lk.env):
|
||||||
BJ_URL — адрес ядра bj: %s
|
BJ_URL — адрес ядра bj: %s
|
||||||
@@ -200,14 +231,13 @@ echo "Проверка: curl -s http://127.0.0.1:8099/health"
|
|||||||
• наружу открыт только 443 сервера кабинета;
|
• наружу открыт только 443 сервера кабинета;
|
||||||
• в режиме direct кабинет обращается к ядру на %s — разрешите этот доступ
|
• в режиме direct кабинет обращается к ядру на %s — разрешите этот доступ
|
||||||
в межсетевом экране; в режиме agent входящий доступ к ядру не нужен вовсе;
|
в межсетевом экране; в режиме agent входящий доступ к ядру не нужен вовсе;
|
||||||
• ключ доступа хранится только в /etc/bj-lk/lk.env (права 0600, владелец root);
|
• ключ доступа хранится только в файле настроек кабинета (lk.json или lk.env,
|
||||||
• ключ можно сменить в настройках ядра, после чего заменить его в lk.env и
|
права 0600); в этом архиве его нет;
|
||||||
перезапустить службу;
|
• ключ можно сменить в настройках ядра, после чего удалить lk.json и пройти
|
||||||
|
установщик заново (или вписать новый ключ в lk.env) и перезапустить службу;
|
||||||
• кабинет не хранит данные клиентов: ни базы, ни файлов — всё берётся у ядра
|
• кабинет не хранит данные клиентов: ни базы, ни файлов — всё берётся у ядра
|
||||||
на время запроса. Шрифты и оформление вшиты в программу, сторонние сайты
|
на время запроса. Шрифты и оформление вшиты в программу, сторонние сайты
|
||||||
страница не запрашивает;
|
страница не запрашивает;
|
||||||
• ВАЖНО: в этом архиве лежит боевой ключ доступа. После установки удалите
|
|
||||||
архив с рабочего компьютера и не пересылайте его почтой или в мессенджере.
|
|
||||||
|
|
||||||
Обновления. Кабинет обновляется отдельно от ядра: новую версию скачивайте на
|
Обновления. Кабинет обновляется отдельно от ядра: новую версию скачивайте на
|
||||||
странице настроек ядра, раздел «Личный кабинет», и ставьте тем же install.sh —
|
странице настроек ядра, раздел «Личный кабинет», и ставьте тем же install.sh —
|
||||||
@@ -290,7 +320,8 @@ func (h *setupHandlers) lkSaveDist(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.APIKey = key
|
s.APIKey = key
|
||||||
msg = "Создан новый ключ доступа кабинета. Скачайте дистрибутив заново или впишите ключ в /etc/bj-lk/lk.env и перезапустите службу кабинета."
|
lkKeyRevealUntil = time.Now().Add(10 * time.Minute) // показать целиком один раз, чтобы переписать в установщик
|
||||||
|
msg = "Создан новый ключ доступа кабинета. Он показан целиком ниже в течение 10 минут — введите его в установщике кабинета (или в lk.env) и перезапустите службу кабинета, если она уже работала."
|
||||||
case "clearkey":
|
case "clearkey":
|
||||||
s.APIKey = ""
|
s.APIKey = ""
|
||||||
msg = "Ключ удалён: кабинет больше не сможет обращаться к ядру."
|
msg = "Ключ удалён: кабинет больше не сможет обращаться к ядру."
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ func TestLKDistFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
env := got["bj-lk/lk.env"]
|
env := got["bj-lk/lk.env"]
|
||||||
if !strings.Contains(env, "BJ_URL=https://bj.example.ru") || !strings.Contains(env, "BJ_KEY=lk-testkey") {
|
if !strings.Contains(env, "BJ_URL=https://bj.example.ru") || !strings.Contains(env, "BJ_KEY=\n") {
|
||||||
t.Errorf("настройки кабинета: %s", env)
|
t.Errorf("настройки кабинета: %s", env)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(env, "lk-testkey") || strings.Contains(got["bj-lk/README.txt"], "lk-testkey") {
|
||||||
|
t.Error("ключ доступа попал в архив — он должен вводиться в установщике")
|
||||||
|
}
|
||||||
if !strings.Contains(env, "Депозитарий «Пример» · личный кабинет") {
|
if !strings.Contains(env, "Депозитарий «Пример» · личный кабинет") {
|
||||||
t.Errorf("заголовок кабинета не подставлен: %s", env)
|
t.Errorf("заголовок кабинета не подставлен: %s", env)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
// lkusers_admin.go — учётные записи кабинета клиента: управление из настроек ядра и
|
||||||
|
// первый администратор из веб-установщика кабинета.
|
||||||
|
//
|
||||||
|
// Пользователи кабинета живут только здесь, в lk.users: кабинет на хостинге своей
|
||||||
|
// базы не имеет. Здесь заводят клиентов и сотрудников, меняют пароли, блокируют.
|
||||||
|
// Письма никому не отправляются: почта хранится, но рассылка выключена — данные
|
||||||
|
// пока тестовые, и случайное письмо реальному человеку недопустимо.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// globalLKSessions — сессии кабинета: нужны, чтобы гасить их при смене пароля и блокировке.
|
||||||
|
var globalLKSessions *lkSessions
|
||||||
|
|
||||||
|
// lkHasAdmin — есть ли хотя бы один администратор кабинета.
|
||||||
|
func lkHasAdmin(ctx context.Context, rc *RuntimeConfig) (bool, error) {
|
||||||
|
db, err := lkDB(rc)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(cctx, `SELECT count(*) FROM lk.users WHERE is_admin AND active`).Scan(&n); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lkUserSetFlags — включить/выключить учётную запись, права сотрудника, подачу поручений.
|
||||||
|
func lkUserSetFlags(ctx context.Context, rc *RuntimeConfig, id int64, column string, value bool) error {
|
||||||
|
switch column {
|
||||||
|
case "active", "is_admin", "can_transfer":
|
||||||
|
default:
|
||||||
|
return errors.New("неизвестный признак")
|
||||||
|
}
|
||||||
|
db, err := lkDB(rc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
res, err := db.ExecContext(cctx, `UPDATE lk.users SET `+column+`=$1 WHERE id=$2`, value, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return errors.New("учётная запись не найдена")
|
||||||
|
}
|
||||||
|
if globalLKSessions != nil {
|
||||||
|
globalLKSessions.forget(id)
|
||||||
|
if !value && column == "active" {
|
||||||
|
globalLKSessions.dropUser(id) // заблокированного выбрасываем сразу
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lkUserUpdate — контакты и договор.
|
||||||
|
func lkUserUpdate(ctx context.Context, rc *RuntimeConfig, id int64, contract, fullName, email, phone string) error {
|
||||||
|
if strings.TrimSpace(contract) == "" {
|
||||||
|
return errors.New("номер договора обязателен")
|
||||||
|
}
|
||||||
|
db, err := lkDB(rc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, err = db.ExecContext(cctx, `UPDATE lk.users SET contract_number=$1, full_name=$2, email=$3, phone=$4 WHERE id=$5`,
|
||||||
|
strings.TrimSpace(contract), strings.TrimSpace(fullName), strings.TrimSpace(email), strings.TrimSpace(phone), id)
|
||||||
|
if err == nil && globalLKSessions != nil {
|
||||||
|
globalLKSessions.forget(id)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// lkUserDelete — удалить учётную запись. Поручения и журнал остаются: они привязаны к договору.
|
||||||
|
func lkUserDelete(ctx context.Context, rc *RuntimeConfig, id int64) error {
|
||||||
|
db, err := lkDB(rc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := db.ExecContext(cctx, `DELETE FROM lk.users WHERE id=$1`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if globalLKSessions != nil {
|
||||||
|
globalLKSessions.dropUser(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lkUserByLogin — учётная запись по логину (для смены пароля из настроек).
|
||||||
|
func lkUserIDByLogin(ctx context.Context, rc *RuntimeConfig, login string) (int64, error) {
|
||||||
|
db, err := lkDB(rc)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var id int64
|
||||||
|
if err := db.QueryRowContext(cctx, `SELECT id FROM lk.users WHERE login=$1`, login).Scan(&id); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, errors.New("учётная запись не найдена")
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lkUsersSave — POST /admin/setup/lk/users: заведение, пароль, признаки, удаление.
|
||||||
|
func (h *setupHandlers) lkUsersSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||||
|
var err error
|
||||||
|
msg := "Пользователи кабинета: сохранено"
|
||||||
|
switch op := r.FormValue("op"); op {
|
||||||
|
case "create":
|
||||||
|
u := LKUser{
|
||||||
|
Login: strings.TrimSpace(r.FormValue("login")), Contract: strings.TrimSpace(r.FormValue("contract")),
|
||||||
|
FullName: strings.TrimSpace(r.FormValue("full_name")), Email: strings.TrimSpace(r.FormValue("email")),
|
||||||
|
Phone: strings.TrimSpace(r.FormValue("phone")), Active: true,
|
||||||
|
IsAdmin: r.FormValue("is_admin") == "1", CanTransfer: r.FormValue("can_transfer") == "1",
|
||||||
|
}
|
||||||
|
if u.IsAdmin && u.Contract == "" {
|
||||||
|
u.Contract = "ADMIN" // у сотрудника своего договора нет
|
||||||
|
}
|
||||||
|
err = lkCreateUser(ctx, h.rc, u, r.FormValue("password"))
|
||||||
|
msg = fmt.Sprintf("Учётная запись «%s» создана. Пароль сообщите пользователю лично: письма кабинет не отправляет.", u.Login)
|
||||||
|
case "password":
|
||||||
|
login := strings.TrimSpace(r.FormValue("login"))
|
||||||
|
if err = lkSetPassword(ctx, h.rc, login, r.FormValue("password")); err == nil {
|
||||||
|
if uid, e := lkUserIDByLogin(ctx, h.rc, login); e == nil && globalLKSessions != nil {
|
||||||
|
globalLKSessions.dropUser(uid) // старые сессии после смены пароля не живут
|
||||||
|
}
|
||||||
|
msg = fmt.Sprintf("Пароль «%s» изменён, его сессии завершены.", login)
|
||||||
|
}
|
||||||
|
case "update":
|
||||||
|
err = lkUserUpdate(ctx, h.rc, id, r.FormValue("contract"), r.FormValue("full_name"), r.FormValue("email"), r.FormValue("phone"))
|
||||||
|
case "block", "unblock":
|
||||||
|
err = lkUserSetFlags(ctx, h.rc, id, "active", op == "unblock")
|
||||||
|
msg = map[bool]string{true: "Учётная запись разблокирована", false: "Учётная запись заблокирована, сессии завершены"}[op == "unblock"]
|
||||||
|
case "admin_on", "admin_off":
|
||||||
|
err = lkUserSetFlags(ctx, h.rc, id, "is_admin", op == "admin_on")
|
||||||
|
case "transfer_on", "transfer_off":
|
||||||
|
err = lkUserSetFlags(ctx, h.rc, id, "can_transfer", op == "transfer_on")
|
||||||
|
case "delete":
|
||||||
|
err = lkUserDelete(ctx, h.rc, id)
|
||||||
|
msg = "Учётная запись удалена"
|
||||||
|
default:
|
||||||
|
err = errors.New("неизвестное действие")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
setupFlash(w, r, "Пользователи кабинета: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setupFlash(w, r, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lkUsersForSetup — список для страницы настроек; ошибка базы — строкой, чтобы страница открылась.
|
||||||
|
func lkUsersForSetup(rc *RuntimeConfig) ([]LKUser, string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
list, err := lkListUsers(ctx, rc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err.Error()
|
||||||
|
}
|
||||||
|
return list, ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Страница настроек: пользователи кабинета, режим связи, замаскированный ключ.
|
||||||
|
func TestSetupPageLKUsersAndLink(t *testing.T) {
|
||||||
|
a, err := newAdmin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var s Settings
|
||||||
|
s.LK.APIKey = "lk-0123456789abcdef0123456789abcdef"
|
||||||
|
s.LK.AgentMode, s.LK.CabinetURL = true, "https://lk.example.ru"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
render(rec, a.setup, SetupData{
|
||||||
|
page: nowPage("Настройка", "setup"), Settings: s,
|
||||||
|
LKKeyMask: maskSecret(s.LK.APIKey), LKAgent: lkAgentStatus(s),
|
||||||
|
LKUsers: []LKUser{
|
||||||
|
{ID: 1, Login: "manager", Contract: "ADMIN", FullName: "Сотрудник", Active: true, IsAdmin: true},
|
||||||
|
{ID: 2, Login: "br859", Contract: "БР-859", Active: false},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{"Пользователи кабинета", "manager", "БР-859", "заблокирована", "Снять права сотрудника",
|
||||||
|
"Разблокировать", "Письма не отправляются", "Ядро подключается к кабинету", "https://lk.example.ru", "ядро не дозвонилось"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("в странице нет %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, s.LK.APIKey) {
|
||||||
|
t.Error("ключ кабинета показан целиком без создания")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "lk-0123") {
|
||||||
|
t.Error("маска ключа не показана")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
|||||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||||
mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist)
|
mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist)
|
||||||
mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload)
|
mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload)
|
||||||
|
mux.HandleFunc("/admin/setup/lk/users", h.lkUsersSave)
|
||||||
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
|
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
|
||||||
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
|
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
|
||||||
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
|
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
|
||||||
@@ -311,10 +312,12 @@ type SetupData struct {
|
|||||||
NotifyGroups []struct{ Key, Title string }
|
NotifyGroups []struct{ Key, Title string }
|
||||||
NotifyKinds []struct{ Key, Title string }
|
NotifyKinds []struct{ Key, Title string }
|
||||||
DepoJob DepoJobView
|
DepoJob DepoJobView
|
||||||
DepoTables DepoTablesView // трансферные таблицы FTV_M2M* в базе DEPO
|
DepoTables DepoTablesView // трансферные таблицы FTV_M2M* в базе DEPO
|
||||||
QuikExport QuikExportView // модуль «Экспорт биржевой информации»
|
QuikExport QuikExportView // модуль «Экспорт биржевой информации»
|
||||||
LKAgent LKAgentStatus // канал кабинета клиента (режим ожидания)
|
LKAgent LKAgentStatus // канал кабинета клиента (режим ожидания)
|
||||||
LKKeyMask string // ключ кабинета в маскированном виде
|
LKKeyMask string // ключ кабинета в маскированном виде
|
||||||
|
LKUsers []LKUser // учётные записи кабинета
|
||||||
|
LKUsersError string
|
||||||
Versions SystemVersions // версии компонентов bj и внешних систем
|
Versions SystemVersions // версии компонентов bj и внешних систем
|
||||||
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
|
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
|
||||||
Features []LicenseFeatureView // признаки лицензии для карточки
|
Features []LicenseFeatureView // признаки лицензии для карточки
|
||||||
@@ -380,13 +383,14 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
|
|||||||
DepoTables: depoTablesView(s.Fancy),
|
DepoTables: depoTablesView(s.Fancy),
|
||||||
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
|
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
|
||||||
LKAgent: lkAgentStatus(s),
|
LKAgent: lkAgentStatus(s),
|
||||||
LKKeyMask: maskSecret(s.LK.APIKey),
|
LKKeyMask: lkKeyForPage(s.LK.APIKey),
|
||||||
Versions: systemVersions(h.rc),
|
Versions: systemVersions(h.rc),
|
||||||
Modules: modulesAllowed(h.rc),
|
Modules: modulesAllowed(h.rc),
|
||||||
Features: licenseFeatureViews(h.rc),
|
Features: licenseFeatureViews(h.rc),
|
||||||
Users: usersView(w),
|
Users: usersView(w),
|
||||||
Flash: flash,
|
Flash: flash,
|
||||||
}
|
}
|
||||||
|
data.LKUsers, data.LKUsersError = lkUsersForSetup(h.rc)
|
||||||
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
|
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
|
||||||
data.Error = errVal
|
data.Error = errVal
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,29 @@
|
|||||||
<p class="muted">Речь о нашем модуле <code>bj-lk</code> — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.</p>
|
<p class="muted">Речь о нашем модуле <code>bj-lk</code> — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.</p>
|
||||||
|
|
||||||
<h3>Шаг 1. Ключ и архив</h3>
|
<h3>Шаг 1. Ключ и архив</h3>
|
||||||
<p>Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек, установщик и README. <b>Внутри архива боевой ключ</b>: после установки удалите архив с рабочего компьютера, не пересылайте его почтой.</p>
|
<p>Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа (сразу после создания он показан целиком 10 минут — перепишите его для установщика) и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек службы, установщик и README. Ключа в архиве нет.</p>
|
||||||
|
|
||||||
<h3>Шаг 2. Установка на сервере кабинета</h3>
|
<h3>Шаг 2. Файлы на сервер и запуск</h3>
|
||||||
<pre>tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
|
<pre>tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
|
||||||
cd bj-lk
|
cd bj-lk
|
||||||
sudo ./install.sh
|
sudo ./install.sh # на своём сервере: заводит службу bj-lk
|
||||||
curl -s http://127.0.0.1:8099/health # {"ok":true}</pre>
|
# или на хостинге, где можно держать программу запущенной:
|
||||||
<p>Установщик заводит системного пользователя <code>bjlk</code>, кладёт программу в <code>/opt/bj-lk</code>, настройки — в <code>/etc/bj-lk/lk.env</code> (права 0600) и включает службу <code>bj-lk</code>. Сам кабинет слушает только <code>127.0.0.1:8099</code>.</p>
|
LISTEN=127.0.0.1:8099 ./bj-lk</pre>
|
||||||
|
<p><code>install.sh</code> заводит системного пользователя <code>bjlk</code>, кладёт программу в <code>/opt/bj-lk</code>, каталог данных — <code>/var/lib/bj-lk</code>, и включает службу. Сам кабинет слушает только <code>127.0.0.1:8099</code>.</p>
|
||||||
|
|
||||||
<h3>Шаг 3. Публикация наружу</h3>
|
<h3>Шаг 3. Установщик в браузере</h3>
|
||||||
|
<p>Откройте адрес кабинета — до настройки любая страница ведёт в установщик, как у обычных веб-приложений. Он делает три шага:</p>
|
||||||
|
<ol>
|
||||||
|
<li><b>Проверка сервера</b>: каталог данных доступен для записи, соединение защищено (https), адрес кабинета.</li>
|
||||||
|
<li><b>Связь с ядром</b>: способ связи (кабинет обращается к ядру / ядро подключается к кабинету), адрес ядра и ключ доступа. В прямом режиме связь проверяется до сохранения; в режиме ожидания страница ждёт, пока ядро подключится.</li>
|
||||||
|
<li><b>Администратор кабинета</b>: логин и пароль сотрудника, который будет видеть список клиентов. Учётная запись заводится в ядре; если администратор уже есть, шаг пропускается.</li>
|
||||||
|
</ol>
|
||||||
|
<p>Настройки сохраняются в <code>lk.json</code> (каталог данных, права 0600), после чего установщик закрывается. Чтобы пройти его заново — удалите этот файл. Установка без браузера: заполните <code>lk.env</code> — окружение перекрывает файл установщика.</p>
|
||||||
|
|
||||||
|
<h3>Шаг 4. Публикация наружу</h3>
|
||||||
<p>Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.</p>
|
<p>Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.</p>
|
||||||
|
|
||||||
<h3>Шаг 4. Связь с ядром — два способа</h3>
|
<h3>Связь с ядром — два способа</h3>
|
||||||
<p><b>Кабинет обращается к ядру</b> (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.</p>
|
<p><b>Кабинет обращается к ядру</b> (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.</p>
|
||||||
<p><b>Ядро подключается к кабинету.</b> Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.</p>
|
<p><b>Ядро подключается к кабинету.</b> Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.</p>
|
||||||
<p>Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит <code>BJ_MODE=agent</code>, а <code>BJ_URL</code> не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.</p>
|
<p>Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит <code>BJ_MODE=agent</code>, а <code>BJ_URL</code> не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.</p>
|
||||||
@@ -31,7 +41,11 @@ curl -s http://127.0.0.1:8099/health # {"ok":true}</pre>
|
|||||||
<li>Страница не обращается к сторонним сайтам — шрифты и оформление вшиты в программу.</li>
|
<li>Страница не обращается к сторонним сайтам — шрифты и оформление вшиты в программу.</li>
|
||||||
<li>Вход сотрудника в кабинет клиента записывается в журнал доступа (<code>lk.audit</code>) и поднимает оповещение.</li>
|
<li>Вход сотрудника в кабинет клиента записывается в журнал доступа (<code>lk.audit</code>) и поднимает оповещение.</li>
|
||||||
<li>Обновляется кабинет отдельно от ядра — через сервер обновлений, по лицензии модуля «Личный кабинет клиента».</li>
|
<li>Обновляется кабинет отдельно от ядра — через сервер обновлений, по лицензии модуля «Личный кабинет клиента».</li>
|
||||||
|
<li>Учётные записи кабинета (клиенты и сотрудники) заводятся здесь же, в карточке «Пользователи кабинета». Письма не отправляются — пароль сообщается лично.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>Какой хостинг подходит</h3>
|
||||||
|
<p>Кабинет — одна программа (Linux, x86-64), которая должна быть постоянно запущена и слушать порт. Подходят: свой сервер или VPS, хостинг с поддержкой собственных приложений (Node/Go/Python через SSH или панель). Обычный PHP-хостинг «папка с файлами» программу запустить не сможет — там нужен либо тариф с приложениями, либо VPS у того же хостера.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -605,7 +605,7 @@ function hbBind(inp,box,list,onPick){
|
|||||||
|
|
||||||
<div class="card" id="sec-lk-dist">
|
<div class="card" id="sec-lk-dist">
|
||||||
<h2><span class="dot {{if .Settings.LK.APIKey}}ok{{else}}warn{{end}}"></span>Кабинет клиента на своём сервере</h2>
|
<h2><span class="dot {{if .Settings.LK.APIKey}}ok{{else}}warn{{end}}"></span>Кабинет клиента на своём сервере</h2>
|
||||||
<p class="muted">Кабинет (<code>bj-lk</code>) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: <b>1)</b> создайте ключ доступа, <b>2)</b> выберите способ связи, <b>3)</b> скачайте архив и распакуйте его на сервере кабинета, <b>4)</b> выполните там <code>sudo ./install.sh</code>, <b>5)</b> опубликуйте кабинет через nginx с сертификатом (пример в README архива).</p>
|
<p class="muted">Кабинет (<code>bj-lk</code>) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: <b>1)</b> создайте ключ доступа, <b>2)</b> выберите способ связи, <b>3)</b> скачайте архив и распакуйте его на сервере кабинета, <b>4)</b> запустите программу (<code>sudo ./install.sh</code> заводит службу), <b>5)</b> откройте адрес кабинета в браузере — установщик проверит сервер, спросит ключ, проверит связь и заведёт администратора, <b>6)</b> опубликуйте кабинет через nginx с сертификатом (пример в README архива).</p>
|
||||||
|
|
||||||
<form method="post" action="/admin/setup/lk/dist" style="display:grid;gap:14px;max-width:780px;margin-top:12px">
|
<form method="post" action="/admin/setup/lk/dist" style="display:grid;gap:14px;max-width:780px;margin-top:12px">
|
||||||
<input type="hidden" name="section" value="sec-lk-dist"><input type="hidden" name="core_url_set" value="1"><input type="hidden" name="mode_set" value="1">
|
<input type="hidden" name="section" value="sec-lk-dist"><input type="hidden" name="core_url_set" value="1"><input type="hidden" name="mode_set" value="1">
|
||||||
@@ -617,7 +617,7 @@ function hbBind(inp,box,list,onPick){
|
|||||||
<button type="submit" name="op" value="newkey" class="btn btn-secondary">{{if .Settings.LK.APIKey}}Сменить ключ{{else}}Создать ключ{{end}}</button>
|
<button type="submit" name="op" value="newkey" class="btn btn-secondary">{{if .Settings.LK.APIKey}}Сменить ключ{{else}}Создать ключ{{end}}</button>
|
||||||
{{if .Settings.LK.APIKey}}<button type="submit" name="op" value="clearkey" class="btn btn-secondary" onclick="return confirm('Удалить ключ? Кабинет перестанет получать данные, пока не впишете новый.')">Удалить</button>{{end}}
|
{{if .Settings.LK.APIKey}}<button type="submit" name="op" value="clearkey" class="btn btn-secondary" onclick="return confirm('Удалить ключ? Кабинет перестанет получать данные, пока не впишете новый.')">Удалить</button>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<span class="muted" style="font-size:12px">Ключ показан не полностью — целиком он попадает только в архив, в файл <code>/etc/bj-lk/lk.env</code> на сервере кабинета. При смене ключа скачайте архив заново или впишите новый ключ в этот файл и перезапустите службу кабинета.</span>
|
<span class="muted" style="font-size:12px">Ключ показан не полностью. В архив он не попадает: его вводят в установщике кабинета (или вписывают в <code>lk.env</code> при установке без браузера). Целиком ключ виден один раз — сразу после создания.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -658,7 +658,56 @@ function hbBind(inp,box,list,onPick){
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<p class="muted" style="font-size:12px;margin-top:10px">В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек с правами 0600, установщик и README. Внутри архива лежит боевой ключ — после установки удалите архив с рабочего компьютера. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
|
<p class="muted" style="font-size:12px;margin-top:10px">В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек службы, установщик и README. Ключа в архиве нет — его спрашивает веб-установщик. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="sec-lk-users">
|
||||||
|
<h2><span class="dot {{if .LKUsers}}ok{{else}}warn{{end}}"></span>Пользователи кабинета</h2>
|
||||||
|
<p class="muted">Учётные записи кабинета живут здесь, в базе ядра: кабинет на хостинге своей базы не имеет. Клиент привязан к номеру договора депозитария — по нему кабинет показывает портфель. Сотрудник («администратор кабинета») видит список клиентов и открывает их кабинеты на просмотр. <b>Письма не отправляются</b>: почта только хранится, пароль сообщите лично.</p>
|
||||||
|
{{if .LKUsersError}}<div class="alert alert-warn">База кабинета недоступна: {{.LKUsersError}}</div>{{end}}
|
||||||
|
{{if .LKUsers}}
|
||||||
|
<div style="overflow-x:auto"><table style="margin-top:10px">
|
||||||
|
<tr><th>Логин</th><th>Договор</th><th>Имя</th><th>Почта</th><th>Роль</th><th>Поручения</th><th>Состояние</th><th></th></tr>
|
||||||
|
{{range .LKUsers}}<tr>
|
||||||
|
<td><code>{{.Login}}</code></td><td>{{.Contract}}</td><td>{{.FullName}}</td><td>{{.Email}}</td>
|
||||||
|
<td>{{if .IsAdmin}}сотрудник{{else}}клиент{{end}}</td>
|
||||||
|
<td>{{if .CanTransfer}}разрешены{{else}}<span class="muted">просмотр</span>{{end}}</td>
|
||||||
|
<td>{{if .Active}}<span class="dot ok"></span>активна{{else}}<span class="dot err"></span>заблокирована{{end}}</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<form method="post" action="/admin/setup/lk/users" style="display:inline"><input type="hidden" name="section" value="sec-lk-users"><input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
{{if .Active}}<button class="btn btn-secondary btn-sm" name="op" value="block">Заблокировать</button>{{else}}<button class="btn btn-secondary btn-sm" name="op" value="unblock">Разблокировать</button>{{end}}
|
||||||
|
{{if .IsAdmin}}<button class="btn btn-secondary btn-sm" name="op" value="admin_off">Снять права сотрудника</button>{{else}}<button class="btn btn-secondary btn-sm" name="op" value="admin_on">Сделать сотрудником</button>
|
||||||
|
{{if .CanTransfer}}<button class="btn btn-secondary btn-sm" name="op" value="transfer_off">Запретить поручения</button>{{else}}<button class="btn btn-secondary btn-sm" name="op" value="transfer_on">Разрешить поручения</button>{{end}}{{end}}
|
||||||
|
<button class="btn btn-secondary btn-sm" name="op" value="delete" onclick="return confirm('Удалить учётную запись {{.Login}}? Поручения и журнал останутся.')">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</table></div>
|
||||||
|
{{else}}{{if not .LKUsersError}}<p class="muted">Учётных записей пока нет. Первого администратора можно завести из установщика кабинета или здесь.</p>{{end}}{{end}}
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;margin-top:14px">
|
||||||
|
<form method="post" action="/admin/setup/lk/users" style="display:grid;gap:8px">
|
||||||
|
<input type="hidden" name="section" value="sec-lk-users"><input type="hidden" name="op" value="create">
|
||||||
|
<b>Новая учётная запись</b>
|
||||||
|
<input type="text" name="login" placeholder="Логин (латиницей или номер договора)" required autocomplete="off">
|
||||||
|
<input type="text" name="contract" placeholder="Номер договора в депозитарии, например БР-859" autocomplete="off">
|
||||||
|
<input type="text" name="full_name" placeholder="ФИО" autocomplete="off">
|
||||||
|
<input type="email" name="email" placeholder="Электронная почта (не рассылается)" autocomplete="off">
|
||||||
|
<input type="text" name="phone" placeholder="Телефон" autocomplete="off">
|
||||||
|
<input type="password" name="password" placeholder="Пароль: от 10 символов, буквы и цифра" required autocomplete="new-password">
|
||||||
|
<label style="font-weight:400"><input type="checkbox" name="is_admin" value="1"> сотрудник депозитария (видит клиентов, договор не нужен)</label>
|
||||||
|
<label style="font-weight:400"><input type="checkbox" name="can_transfer" value="1"> разрешить подачу поручений (по умолчанию — только просмотр)</label>
|
||||||
|
<div><button type="submit" class="btn btn-ok">Создать</button></div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/setup/lk/users" style="display:grid;gap:8px;align-content:start">
|
||||||
|
<input type="hidden" name="section" value="sec-lk-users"><input type="hidden" name="op" value="password">
|
||||||
|
<b>Сменить пароль</b>
|
||||||
|
<input type="text" name="login" placeholder="Логин" required autocomplete="off">
|
||||||
|
<input type="password" name="password" placeholder="Новый пароль" required autocomplete="new-password">
|
||||||
|
<span class="muted" style="font-size:12px">Все сессии пользователя будут завершены.</span>
|
||||||
|
<div><button type="submit" class="btn btn-secondary">Сменить</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
+20
-3
@@ -19,6 +19,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +51,15 @@ type agentQueue struct {
|
|||||||
waiting map[string]*agentTask
|
waiting map[string]*agentTask
|
||||||
signal chan struct{}
|
signal chan struct{}
|
||||||
seq uint64
|
seq uint64
|
||||||
|
// lastFetch — когда ядро в последний раз забирало запросы: по этому установщик
|
||||||
|
// понимает, что ядро дозвонилось до кабинета.
|
||||||
|
lastFetch atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// coreSeen — ядро подключалось за последнюю минуту.
|
||||||
|
func (q *agentQueue) coreSeen() bool {
|
||||||
|
t := q.lastFetch.Load()
|
||||||
|
return t > 0 && time.Since(time.Unix(t, 0)) < time.Minute
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAgentQueue() *agentQueue {
|
func newAgentQueue() *agentQueue {
|
||||||
@@ -142,8 +152,13 @@ func idFromSeq(n uint64) string {
|
|||||||
|
|
||||||
// registerAgent — точки, куда подключается ядро (только в режиме ожидания).
|
// registerAgent — точки, куда подключается ядро (только в режиме ожидания).
|
||||||
func (s *Server) registerAgent(mux *http.ServeMux) {
|
func (s *Server) registerAgent(mux *http.ServeMux) {
|
||||||
|
// В прямом режиме этих точек как бы нет: очередь не создана — отвечаем 404.
|
||||||
keyOK := func(w http.ResponseWriter, r *http.Request) bool {
|
keyOK := func(w http.ResponseWriter, r *http.Request) bool {
|
||||||
if !secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey) {
|
if s.agentQueue() == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !secureEqual(r.Header.Get("X-BJ-Key"), s.conf().BJKey) {
|
||||||
http.Error(w, "неверный ключ модуля", http.StatusForbidden)
|
http.Error(w, "неверный ключ модуля", http.StatusForbidden)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -155,7 +170,9 @@ func (s *Server) registerAgent(mux *http.ServeMux) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
wait := 25 * time.Second
|
wait := 25 * time.Second
|
||||||
t := s.agent.next(r.Context(), wait)
|
q := s.agentQueue()
|
||||||
|
q.lastFetch.Store(time.Now().Unix())
|
||||||
|
t := q.next(r.Context(), wait)
|
||||||
if t == nil {
|
if t == nil {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
@@ -182,7 +199,7 @@ func (s *Server) registerAgent(mux *http.ServeMux) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, _ := base64.StdEncoding.DecodeString(in.Body)
|
body, _ := base64.StdEncoding.DecodeString(in.Body)
|
||||||
if !s.agent.reply(in.ID, agentResult{Status: in.Status, Body: body}) {
|
if !s.agentQueue().reply(in.ID, agentResult{Status: in.Status, Body: body}) {
|
||||||
w.WriteHeader(http.StatusGone) // запрос уже не ждут: клиент ушёл или истёк срок
|
w.WriteHeader(http.StatusGone) // запрос уже не ждут: клиент ушёл или истёк срок
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package lk
|
||||||
|
|
||||||
|
// install.go — веб-установщик кабинета.
|
||||||
|
//
|
||||||
|
// Порядок как у обычных веб-приложений: администратор копирует файлы на сервер,
|
||||||
|
// открывает адрес кабинета — и попадает в установщик. Тот проверяет окружение,
|
||||||
|
// спрашивает, как связаться с ядром, проверяет связь, помогает завести первого
|
||||||
|
// администратора кабинета и сохраняет настройки в файл рядом с программой.
|
||||||
|
// Служебные переменные окружения (BJ_URL, BJ_KEY…) при этом остаются рабочим способом
|
||||||
|
// настройки для тех, кто ставит кабинет руками или скриптом: они перекрывают файл.
|
||||||
|
//
|
||||||
|
// Файл настроек: <каталог данных>/lk.json, права 0600. Каталог данных — LK_DATA, иначе
|
||||||
|
// подкаталог data рядом с исполняемым файлом.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configFileName — имя файла настроек в каталоге данных.
|
||||||
|
const configFileName = "lk.json"
|
||||||
|
|
||||||
|
// fileConfig — что лежит в lk.json. Ключ — секрет, поэтому файл 0600.
|
||||||
|
type fileConfig struct {
|
||||||
|
Mode string `json:"mode"` // direct | agent
|
||||||
|
BJURL string `json:"bj_url,omitempty"`
|
||||||
|
BJKey string `json:"bj_key"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultDataDir — каталог данных по умолчанию: data рядом с программой.
|
||||||
|
func DefaultDataDir() string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("LK_DATA")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "data"
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(exe), "data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfigFile — настройки из файла; непустые значения из cfg (окружение, флаги)
|
||||||
|
// имеют приоритет. Отсутствие файла — не ошибка: значит, установщик ещё не проходили.
|
||||||
|
func LoadConfigFile(dataDir string, cfg Config) (Config, error) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dataDir, configFileName))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
var f fileConfig
|
||||||
|
if err := json.Unmarshal(raw, &f); err != nil {
|
||||||
|
return cfg, fmt.Errorf("файл настроек %s повреждён: %v", configFileName, err)
|
||||||
|
}
|
||||||
|
if cfg.BJKey == "" {
|
||||||
|
cfg.BJKey = f.BJKey
|
||||||
|
}
|
||||||
|
if cfg.BJURL == "" {
|
||||||
|
cfg.BJURL = f.BJURL
|
||||||
|
}
|
||||||
|
if cfg.Title == "" {
|
||||||
|
cfg.Title = f.Title
|
||||||
|
}
|
||||||
|
if !cfg.Agent && os.Getenv("BJ_MODE") == "" {
|
||||||
|
cfg.Agent = f.Mode == "agent"
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveConfigFile — записать настройки; каталог создаётся при необходимости.
|
||||||
|
func saveConfigFile(dataDir string, cfg Config) error {
|
||||||
|
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("каталог данных %s: %v", dataDir, err)
|
||||||
|
}
|
||||||
|
f := fileConfig{Mode: "direct", BJURL: cfg.BJURL, BJKey: cfg.BJKey, Title: cfg.Title}
|
||||||
|
if cfg.Agent {
|
||||||
|
f.Mode, f.BJURL = "agent", ""
|
||||||
|
}
|
||||||
|
raw, _ := json.MarshalIndent(f, "", " ")
|
||||||
|
tmp := filepath.Join(dataDir, configFileName+".tmp")
|
||||||
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("запись настроек: %v", err)
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, filepath.Join(dataDir, configFileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDataDir — где хранить настройки (вызывается из main до Handler).
|
||||||
|
func (s *Server) SetDataDir(dir string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.dataDir = dir
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dataDirPath() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if s.dataDir == "" {
|
||||||
|
return DefaultDataDir()
|
||||||
|
}
|
||||||
|
return s.dataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// installCheck — одна строка проверки окружения.
|
||||||
|
type installCheck struct {
|
||||||
|
Name, Note string
|
||||||
|
OK bool
|
||||||
|
Warn bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// installChecks — что проверяем перед установкой.
|
||||||
|
func (s *Server) installChecks(r *http.Request) []installCheck {
|
||||||
|
var out []installCheck
|
||||||
|
dir := s.dataDirPath()
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
out = append(out, installCheck{Name: "Каталог данных", Note: fmt.Sprintf("%s: нет прав на создание (%v). Дайте программе право писать в свой каталог.", dir, err)})
|
||||||
|
} else if f, err := os.CreateTemp(dir, ".probe-*"); err != nil {
|
||||||
|
out = append(out, installCheck{Name: "Каталог данных", Note: fmt.Sprintf("%s: нельзя записать файл (%v)", dir, err)})
|
||||||
|
} else {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(f.Name())
|
||||||
|
out = append(out, installCheck{Name: "Каталог данных", Note: dir + " — доступен для записи", OK: true})
|
||||||
|
}
|
||||||
|
if httpsRequest(r) {
|
||||||
|
out = append(out, installCheck{Name: "Защищённое соединение", Note: "кабинет открыт по https", OK: true})
|
||||||
|
} else if localRequest(r) {
|
||||||
|
out = append(out, installCheck{Name: "Защищённое соединение", Note: "открыто с этого же сервера — для проверки достаточно; клиентам кабинет публикуйте только по https", OK: true, Warn: true})
|
||||||
|
} else {
|
||||||
|
out = append(out, installCheck{Name: "Защищённое соединение", Note: "кабинет открыт по http без TLS. Так публиковать нельзя: пароли клиентов пойдут по сети открытым текстом. Включите сертификат на хостинге и откройте установщик по https.", Warn: true})
|
||||||
|
}
|
||||||
|
out = append(out, installCheck{Name: "Адрес кабинета", Note: r.Host, OK: true})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// installData — данные для страницы установщика.
|
||||||
|
type installData struct {
|
||||||
|
Step int
|
||||||
|
Checks []installCheck
|
||||||
|
Cfg Config
|
||||||
|
Error string
|
||||||
|
Notice string
|
||||||
|
Host string
|
||||||
|
Waiting bool // режим ожидания: ждём подключения ядра
|
||||||
|
AdminOK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerInstall — страницы установщика.
|
||||||
|
func (s *Server) registerInstall(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/install", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Уже настроено: повторный вход в установщик — только удалением файла настроек.
|
||||||
|
// Исключение — шаг «администратор», он идёт сразу после сохранения связи.
|
||||||
|
if s.Configured() && !(r.Method == http.MethodPost && r.FormValue("step") == "admin") {
|
||||||
|
s.render(w, r, "installed", map[string]any{"File": filepath.Join(s.dataDirPath(), configFileName)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := installData{Step: 1, Checks: s.installChecks(r), Cfg: s.conf(), Host: r.Host}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !csrfOK(r) {
|
||||||
|
d.Error = "Форма устарела, обновите страницу."
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.FormValue("step") {
|
||||||
|
case "connect":
|
||||||
|
s.installConnect(w, r, d)
|
||||||
|
case "admin":
|
||||||
|
s.installAdmin(w, r, d)
|
||||||
|
default:
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Состояние связи для страницы ожидания ядра (опрашивается страницей по meta refresh).
|
||||||
|
mux.HandleFunc("/install/wait", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
d := installData{Step: 2, Cfg: s.conf(), Host: r.Host, Waiting: true}
|
||||||
|
if q := s.agentQueue(); q != nil && q.coreSeen() {
|
||||||
|
d.Step, d.Waiting = 3, false
|
||||||
|
d.Notice = "Ядро подключилось к кабинету. Осталось завести администратора."
|
||||||
|
}
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, d installData) {
|
||||||
|
s.render(w, r, "install", map[string]any{"D": d})
|
||||||
|
}
|
||||||
|
|
||||||
|
// installConnect — шаг «связь с ядром»: сохранить настройки и проверить связь.
|
||||||
|
func (s *Server) installConnect(w http.ResponseWriter, r *http.Request, d installData) {
|
||||||
|
cfg := d.Cfg
|
||||||
|
cfg.Agent = r.FormValue("mode") == "agent"
|
||||||
|
cfg.BJURL = strings.TrimRight(strings.TrimSpace(r.FormValue("bj_url")), "/")
|
||||||
|
cfg.BJKey = strings.TrimSpace(r.FormValue("bj_key"))
|
||||||
|
cfg.Title = strings.TrimSpace(r.FormValue("title"))
|
||||||
|
d.Cfg = cfg
|
||||||
|
switch {
|
||||||
|
case cfg.BJKey == "":
|
||||||
|
d.Error = "Укажите ключ доступа модуля — он создаётся в настройках ядра, раздел «Личный кабинет»."
|
||||||
|
case !cfg.Agent && cfg.BJURL == "":
|
||||||
|
d.Error = "Укажите адрес ядра или выберите режим «ядро подключается к кабинету»."
|
||||||
|
case !cfg.Agent && !strings.HasPrefix(cfg.BJURL, "https://") && cfg.insecureCore():
|
||||||
|
d.Error = "Адрес ядра должен начинаться с https:// — по этому каналу идут ключ и сессии клиентов."
|
||||||
|
}
|
||||||
|
if d.Error != "" {
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !cfg.Agent {
|
||||||
|
// Прямой режим: проверяем связь до сохранения, чтобы не записать нерабочие настройки.
|
||||||
|
probe := &bjClient{cfg: cfg, hc: &http.Client{Timeout: 15 * time.Second}}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var out map[string]any
|
||||||
|
if err := probe.call(ctx, http.MethodGet, "/api/lk/ping", "", nil, &out); err != nil {
|
||||||
|
d.Error = "Связь с ядром не установлена: " + err.Error()
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := saveConfigFile(s.dataDirPath(), cfg); err != nil {
|
||||||
|
d.Error = err.Error()
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.apply(cfg)
|
||||||
|
if cfg.Agent {
|
||||||
|
// Проверить связь можно только когда ядро само подключится: показываем ожидание.
|
||||||
|
http.Redirect(w, r, "/install/wait", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Step, d.Notice = 3, "Связь с ядром установлена, настройки сохранены. Осталось завести администратора кабинета."
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// installAdmin — шаг «администратор кабинета»: учётная запись заводится в ядре.
|
||||||
|
func (s *Server) installAdmin(w http.ResponseWriter, r *http.Request, d installData) {
|
||||||
|
d.Step = 3
|
||||||
|
login := strings.TrimSpace(r.FormValue("login"))
|
||||||
|
pass, pass2 := r.FormValue("password"), r.FormValue("password2")
|
||||||
|
switch {
|
||||||
|
case r.FormValue("skip") != "":
|
||||||
|
s.installDone(w, r)
|
||||||
|
return
|
||||||
|
case login == "" || pass == "":
|
||||||
|
d.Error = "Укажите логин и пароль администратора."
|
||||||
|
case pass != pass2:
|
||||||
|
d.Error = "Пароли не совпадают."
|
||||||
|
}
|
||||||
|
if d.Error != "" {
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out struct{ Message string }
|
||||||
|
err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/admin/bootstrap", "", map[string]string{
|
||||||
|
"login": login, "password": pass, "full_name": strings.TrimSpace(r.FormValue("full_name")),
|
||||||
|
"email": strings.TrimSpace(r.FormValue("email")),
|
||||||
|
}, &out)
|
||||||
|
if err != nil {
|
||||||
|
d.Error = err.Error()
|
||||||
|
s.renderInstall(w, r, d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.installDone(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) installDone(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.render(w, r, "install_done", map[string]any{"File": filepath.Join(s.dataDirPath(), configFileName)})
|
||||||
|
}
|
||||||
+94
-65
@@ -17,6 +17,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,12 +36,15 @@ type Config struct {
|
|||||||
Agent bool
|
Agent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server — кабинет.
|
// Server — кабинет. Настройки живут за мьютексом: веб-установщик меняет их на ходу,
|
||||||
|
// без перезапуска службы.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg Config
|
mu sync.RWMutex
|
||||||
bj *bjClient
|
cfg Config
|
||||||
tpl *template.Template
|
bjc *bjClient
|
||||||
agent *agentQueue
|
agent *agentQueue
|
||||||
|
dataDir string // где лежит lk.json (веб-установщик)
|
||||||
|
tpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// New — кабинет с разобранными шаблонами.
|
// New — кабинет с разобранными шаблонами.
|
||||||
@@ -101,32 +105,70 @@ func New(cfg Config) (*Server, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
srv := &Server{cfg: cfg, tpl: tpl}
|
srv := &Server{tpl: tpl}
|
||||||
if cfg.Agent {
|
srv.apply(cfg)
|
||||||
srv.agent = newAgentQueue()
|
|
||||||
}
|
|
||||||
srv.bj = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: srv.agent}
|
|
||||||
return srv, nil
|
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 — кабинету есть с чем работать: ключ модуля и, в прямом режиме, адрес ядра.
|
// Configured — кабинету есть с чем работать: ключ модуля и, в прямом режиме, адрес ядра.
|
||||||
func (s *Server) Configured() bool {
|
func (s *Server) Configured() bool { return s.conf().configured() }
|
||||||
if s.cfg.BJKey == "" {
|
|
||||||
|
func (c Config) configured() bool {
|
||||||
|
if c.BJKey == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if s.cfg.Agent {
|
if c.Agent {
|
||||||
return true // адрес ядра не нужен: ядро подключается само
|
return true // адрес ядра не нужен: ядро подключается само
|
||||||
}
|
}
|
||||||
return s.cfg.BJURL != ""
|
return c.BJURL != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и
|
// InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и
|
||||||
// токены сессий клиентов пойдут по сети в открытом виде.
|
// токены сессий клиентов пойдут по сети в открытом виде.
|
||||||
func (s *Server) InsecureCore() bool {
|
func (s *Server) InsecureCore() bool { return s.conf().insecureCore() }
|
||||||
if s.cfg.Agent || s.cfg.BJURL == "" || strings.HasPrefix(s.cfg.BJURL, "https://") {
|
|
||||||
|
func (c Config) insecureCore() bool {
|
||||||
|
if c.Agent || c.BJURL == "" || strings.HasPrefix(c.BJURL, "https://") {
|
||||||
return false
|
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")
|
return !strings.HasPrefix(rest, "127.0.0.1") && !strings.HasPrefix(rest, "localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +412,7 @@ type securityCard struct {
|
|||||||
// portfolio — реестр клиента из ядра.
|
// portfolio — реестр клиента из ядра.
|
||||||
func (s *Server) portfolio(r *http.Request, sess string) (portfolioResp, error) {
|
func (s *Server) portfolio(r *http.Request, sess string) (portfolioResp, error) {
|
||||||
var out portfolioResp
|
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
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +423,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
data = map[string]any{}
|
data = map[string]any{}
|
||||||
}
|
}
|
||||||
data["Title"] = s.cfg.Title
|
data["Title"] = s.conf().Title
|
||||||
if _, ok := data["Nav"]; !ok {
|
if _, ok := data["Nav"]; !ok {
|
||||||
data["Nav"] = ""
|
data["Nav"] = ""
|
||||||
}
|
}
|
||||||
@@ -467,13 +509,11 @@ func session(r *http.Request) string {
|
|||||||
return c.Value
|
return c.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler — все страницы кабинета. Без адреса bj-server или ключа — страница с подсказкой настройки.
|
// Handler — все страницы кабинета. Пока кабинет не настроен, любая страница ведёт
|
||||||
|
// в веб-установщик (/install), как у обычных веб-приложений после копирования файлов.
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
if !s.Configured() {
|
|
||||||
return harden(http.HandlerFunc(s.notConfigured))
|
|
||||||
}
|
|
||||||
bj := s.bj
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
s.registerInstall(mux)
|
||||||
|
|
||||||
// Вход
|
// Вход
|
||||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -491,7 +531,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
Contract string `json:"contract"`
|
Contract string `json:"contract"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
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"),
|
"login": r.FormValue("login"), "password": r.FormValue("password"),
|
||||||
}, &out)
|
}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -513,9 +553,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// Выход гасит и клиентскую сессию, и сохранённую админскую: иначе после
|
// Выход гасит и клиентскую сессию, и сохранённую админскую: иначе после
|
||||||
// «выхода» в браузере оставался действующий токен сотрудника.
|
// «выхода» в браузере оставался действующий токен сотрудника.
|
||||||
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
|
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 != "" {
|
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)
|
clearAll(w, r)
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
@@ -541,7 +581,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
var cl struct {
|
var cl struct {
|
||||||
Claims []claimRow `json:"claims"`
|
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)
|
active, _ := claimsView(cl.Claims)
|
||||||
pos := pf.Positions
|
pos := pf.Positions
|
||||||
if len(pos) > 5 {
|
if len(pos) > 5 {
|
||||||
@@ -600,7 +640,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
var out struct {
|
var out struct {
|
||||||
Security securityCard `json:"security"`
|
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})
|
s.render(w, r, "security", map[string]any{"Nav": "p", "Error": err.Error(), "ISIN": isin})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -625,7 +665,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
Operations []operationRow `json:"operations"`
|
Operations []operationRow `json:"operations"`
|
||||||
}
|
}
|
||||||
path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
|
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})
|
s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -662,14 +702,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
Tax float64 `json:"tax"`
|
Tax float64 `json:"tax"`
|
||||||
}
|
}
|
||||||
path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
|
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})
|
s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var ca struct {
|
var ca struct {
|
||||||
Actions []corpAction `json:"actions"`
|
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
|
acts := ca.Actions
|
||||||
if len(acts) > 8 {
|
if len(acts) > 8 {
|
||||||
acts = acts[:8]
|
acts = acts[:8]
|
||||||
@@ -690,7 +730,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
var res struct {
|
var res struct {
|
||||||
Message string `json:"message"`
|
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"),
|
"kind": r.FormValue("kind"),
|
||||||
"from": r.FormValue("from"),
|
"from": r.FormValue("from"),
|
||||||
"to": r.FormValue("to"),
|
"to": r.FormValue("to"),
|
||||||
@@ -711,7 +751,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
Requests []documentRow `json:"requests"`
|
Requests []documentRow `json:"requests"`
|
||||||
Kinds []string `json:"kinds"`
|
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()})
|
s.render(w, r, "documents", map[string]any{"Nav": "d", "Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -761,7 +801,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
Positions []position `json:"positions"`
|
Positions []position `json:"positions"`
|
||||||
FullName string `json:"full_name"`
|
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")})
|
s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -790,7 +830,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
GUID string `json:"guid"`
|
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_inn": strings.TrimSpace(r.FormValue("receiving_inn")),
|
||||||
"receiving_name": strings.TrimSpace(r.FormValue("receiving_name")),
|
"receiving_name": strings.TrimSpace(r.FormValue("receiving_name")),
|
||||||
"account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")),
|
"account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")),
|
||||||
@@ -818,7 +858,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
var out struct {
|
var out struct {
|
||||||
Claims []claimRow `json:"claims"`
|
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()})
|
s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -855,7 +895,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
if search != "" {
|
if search != "" {
|
||||||
path += "?search=" + url.QueryEscape(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()})
|
s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -884,7 +924,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
Session string `json:"session"`
|
Session string `json:"session"`
|
||||||
AsClient string `json:"as_client"`
|
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 {
|
map[string]string{"login": login, "contract": contract}, &out); err != nil {
|
||||||
setFlashCookie(w, r, err.Error())
|
setFlashCookie(w, r, err.Error())
|
||||||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||||||
@@ -902,7 +942,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику
|
// Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику
|
||||||
// токены чужих кабинетов копились бы и жили ещё 12 часов.
|
// токены чужих кабинетов копились бы и жили ещё 12 часов.
|
||||||
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
|
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)
|
setSession(w, r, c.Value)
|
||||||
}
|
}
|
||||||
setFlag(w, r, "bj_lk_as", "")
|
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) {
|
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
|
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")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
resp := map[string]any{"ok": false}
|
resp := map[string]any{"ok": false}
|
||||||
if full {
|
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)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp := map[string]any{"ok": true}
|
resp := map[string]any{"ok": true}
|
||||||
if full {
|
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)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
})
|
})
|
||||||
if s.agent != nil {
|
s.registerAgent(mux)
|
||||||
s.registerAgent(mux)
|
|
||||||
}
|
|
||||||
mux.Handle("/assets/", assetsHandler())
|
mux.Handle("/assets/", assetsHandler())
|
||||||
return harden(mux)
|
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)
|
||||||
// notConfigured — кабинет запущен без адреса bj-server или ключа: подсказка вместо падения службы.
|
return
|
||||||
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."
|
mux.ServeHTTP(w, r)
|
||||||
if r.URL.Path == "/health" {
|
}))
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": msg})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head>`+
|
|
||||||
`<body style="font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px;line-height:1.5"><h1>%s</h1><p>%s</p></body></html>`,
|
|
||||||
template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mode — режим связи с ядром, для страницы состояния и инструкций администратору.
|
// mode — режим связи с ядром, для страницы состояния и инструкций администратору.
|
||||||
func (s *Server) mode() string {
|
func (s *Server) mode() string {
|
||||||
if s.cfg.Agent {
|
if s.conf().Agent {
|
||||||
return "agent"
|
return "agent"
|
||||||
}
|
}
|
||||||
return "direct"
|
return "direct"
|
||||||
|
|||||||
+98
-5
@@ -7,6 +7,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -81,6 +83,19 @@ func fakeBJ(t *testing.T) *httptest.Server {
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/api/lk/admin/bootstrap", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !keyOK(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct{ Login, Password string }
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||||
|
if in.Login == "" || len(in.Password) < 10 {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "пароль минимум 10 символов"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "message": "администратор создан"})
|
||||||
|
})
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
return srv
|
return srv
|
||||||
@@ -155,20 +170,94 @@ func TestAdminFlagPerRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кабинет без настроек ведёт в веб-установщик, а /health честно отвечает, что связи нет.
|
||||||
func TestNotConfigured(t *testing.T) {
|
func TestNotConfigured(t *testing.T) {
|
||||||
s, err := New(Config{BJURL: "http://127.0.0.1:8080"})
|
s, err := New(Config{BJURL: "http://127.0.0.1:8080"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
s.SetDataDir(t.TempDir())
|
||||||
if s.Configured() {
|
if s.Configured() {
|
||||||
t.Fatal("без ключа кабинет не настроен")
|
t.Fatal("без ключа кабинет не настроен")
|
||||||
}
|
}
|
||||||
for _, p := range []string{"/", "/health"} {
|
h := s.Handler()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/install" {
|
||||||
|
t.Fatalf("/: код %d, Location %q — ожидали установщик", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Проверить связь и сохранить") {
|
||||||
|
t.Fatalf("/install: код %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||||
|
if rec.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("/health без настроек: код %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Веб-установщик целиком: проверка сервера → связь с ядром → администратор → файл настроек.
|
||||||
|
func TestInstaller(t *testing.T) {
|
||||||
|
bj := fakeBJ(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
s, err := New(Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.SetDataDir(dir)
|
||||||
|
h := s.Handler()
|
||||||
|
csrf := csrfFromPage(t, h)
|
||||||
|
post := func(form url.Values) *httptest.ResponseRecorder {
|
||||||
|
form.Set("csrf", csrf.Value)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(csrf)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil))
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "BJ_KEY") {
|
return rec
|
||||||
t.Fatalf("%s: код %d: %s", p, rec.Code, rec.Body.String())
|
}
|
||||||
}
|
// неверный ключ — настройки не сохраняются
|
||||||
|
rec := post(url.Values{"step": {"connect"}, "mode": {"direct"}, "bj_url": {bj.URL}, "bj_key": {"чужой"}})
|
||||||
|
if !strings.Contains(rec.Body.String(), "Связь с ядром не установлена") || s.Configured() {
|
||||||
|
t.Fatalf("неверный ключ принят: %d", rec.Code)
|
||||||
|
}
|
||||||
|
// верный ключ — сохранено, шаг администратора
|
||||||
|
rec = post(url.Values{"step": {"connect"}, "mode": {"direct"}, "bj_url": {bj.URL}, "bj_key": {"lk-key"}, "title": {"Тест"}})
|
||||||
|
if !strings.Contains(rec.Body.String(), "Администратор кабинета") || !s.Configured() {
|
||||||
|
t.Fatalf("связь не сохранена: %d %s", rec.Code, rec.Body.String()[:200])
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, configFileName))
|
||||||
|
if err != nil || !strings.Contains(string(raw), `"bj_key": "lk-key"`) || !strings.Contains(string(raw), `"mode": "direct"`) {
|
||||||
|
t.Fatalf("файл настроек: %v %s", err, raw)
|
||||||
|
}
|
||||||
|
if st, _ := os.Stat(filepath.Join(dir, configFileName)); st.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("права файла настроек %o", st.Mode().Perm())
|
||||||
|
}
|
||||||
|
// администратор: пароли не совпали → ошибка; совпали → ядро получило bootstrap
|
||||||
|
rec = post(url.Values{"step": {"admin"}, "login": {"admin"}, "password": {"Надёжный-пароль-1"}, "password2": {"другой"}})
|
||||||
|
if !strings.Contains(rec.Body.String(), "Пароли не совпадают") {
|
||||||
|
t.Fatal("несовпадение паролей пропущено")
|
||||||
|
}
|
||||||
|
rec = post(url.Values{"step": {"admin"}, "login": {"admin"}, "password": {"Надёжный-пароль-1"}, "password2": {"Надёжный-пароль-1"}})
|
||||||
|
if !strings.Contains(rec.Body.String(), "Кабинет установлен") {
|
||||||
|
t.Fatalf("завершение: %d %s", rec.Code, rec.Body.String()[:200])
|
||||||
|
}
|
||||||
|
// установщик закрыт, настройки поднимаются из файла при новом запуске
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
|
||||||
|
if !strings.Contains(rec.Body.String(), "уже настроен") {
|
||||||
|
t.Fatal("установщик остался открытым после установки")
|
||||||
|
}
|
||||||
|
cfg, err := LoadConfigFile(dir, Config{})
|
||||||
|
if err != nil || cfg.BJKey != "lk-key" || cfg.BJURL != bj.URL || cfg.Agent || cfg.Title != "Тест" {
|
||||||
|
t.Fatalf("настройки из файла: %+v %v", cfg, err)
|
||||||
|
}
|
||||||
|
// окружение перекрывает файл
|
||||||
|
cfg, _ = LoadConfigFile(dir, Config{BJKey: "из-окружения"})
|
||||||
|
if cfg.BJKey != "из-окружения" {
|
||||||
|
t.Fatal("окружение должно перекрывать файл")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +343,10 @@ func csrfFromPage(t *testing.T, h http.Handler) *http.Cookie {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||||
|
if loc := rec.Header().Get("Location"); rec.Code == http.StatusSeeOther && loc != "" {
|
||||||
|
rec = httptest.NewRecorder() // кабинет не настроен: токен выдаёт установщик
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, loc, nil))
|
||||||
|
}
|
||||||
for _, c := range rec.Result().Cookies() {
|
for _, c := range rec.Result().Cookies() {
|
||||||
if c.Name == csrfCookie {
|
if c.Name == csrfCookie {
|
||||||
return c
|
return c
|
||||||
|
|||||||
@@ -303,6 +303,88 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
|||||||
{{else}}<p class="empty">Поручений пока нет.</p>{{end}}
|
{{else}}<p class="empty">Поручений пока нет.</p>{{end}}
|
||||||
</main></body></html>{{end}}
|
</main></body></html>{{end}}
|
||||||
|
|
||||||
|
{{define "install"}}{{template "head" .}}{{$d := .D}}
|
||||||
|
<div class="login">
|
||||||
|
<section class="side">
|
||||||
|
<span class="brand">{{template "mark" .}}<span class="lbl" style="font-size:12px;letter-spacing:.14em;color:#f3f0e7">Установка кабинета</span></span>
|
||||||
|
<div>
|
||||||
|
<h1>Три шага — и кабинет работает</h1>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;margin-top:22px;font-size:14.5px">
|
||||||
|
<span style="display:flex;gap:12px"><span class="n" style="color:{{if ge $d.Step 1}}#e8a33d{{else}}#8e8879{{end}}">01</span><span>Проверка сервера{{if gt $d.Step 1}} — готово{{end}}</span></span>
|
||||||
|
<span style="display:flex;gap:12px"><span class="n" style="color:{{if ge $d.Step 2}}#e8a33d{{else}}#8e8879{{end}}">02</span><span>Связь с ядром депозитария{{if gt $d.Step 2}} — готово{{end}}</span></span>
|
||||||
|
<span style="display:flex;gap:12px"><span class="n" style="color:{{if ge $d.Step 3}}#e8a33d{{else}}#8e8879{{end}}">03</span><span>Администратор кабинета</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mut" style="color:#8e8879;margin:0">Кабинет ничего не хранит у себя: пользователи, пароли и данные клиентов живут в ядре. Здесь сохраняется только способ связи с ним.</p>
|
||||||
|
</section>
|
||||||
|
<section class="form">
|
||||||
|
<form method="post" action="/install" style="max-width:520px"><input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
|
{{if $d.Error}}<div class="msg err">{{$d.Error}}</div>{{end}}
|
||||||
|
{{if $d.Notice}}<div class="msg ok">{{$d.Notice}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if lt $d.Step 3}}
|
||||||
|
<h1 style="margin:0">Сервер и связь с ядром</h1>
|
||||||
|
<div style="display:grid;gap:8px">
|
||||||
|
{{range $d.Checks}}<div style="display:flex;gap:10px;align-items:flex-start;font-size:14px"><span style="color:{{if .OK}}{{if .Warn}}#e8a33d{{else}}#3f8f5a{{end}}{{else}}#c0392b{{end}};font-weight:700">{{if .OK}}✓{{else}}✗{{end}}</span><span><b>{{.Name}}.</b> {{.Note}}</span></div>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.Waiting}}
|
||||||
|
<meta http-equiv="refresh" content="5">
|
||||||
|
<div class="msg ok">Настройки сохранены. Ждём, когда ядро подключится к кабинету по адресу <b>{{$d.Host}}</b>… Страница обновляется сама.</div>
|
||||||
|
<p class="mut" style="margin:0">В настройках ядра (раздел «Личный кабинет») выберите «Ядро подключается к кабинету» и укажите этот адрес. Ключ должен совпадать.</p>
|
||||||
|
<input type="hidden" name="step" value="wait">
|
||||||
|
{{else}}
|
||||||
|
<input type="hidden" name="step" value="connect">
|
||||||
|
<div>
|
||||||
|
<label>Заголовок кабинета</label>
|
||||||
|
<input name="title" value="{{$d.Cfg.Title}}" placeholder="Депозитарий · личный кабинет">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Как связаться с ядром</label>
|
||||||
|
<label style="display:flex;gap:8px;align-items:flex-start;text-transform:none;letter-spacing:0;font-family:inherit;font-size:14px;color:var(--ink);margin-top:6px"><input type="radio" name="mode" value="direct" {{if not $d.Cfg.Agent}}checked{{end}}> <span><b>Кабинет обращается к ядру</b> — у ядра есть адрес, доступный с этого сервера.</span></label>
|
||||||
|
<label style="display:flex;gap:8px;align-items:flex-start;text-transform:none;letter-spacing:0;font-family:inherit;font-size:14px;color:var(--ink);margin-top:6px"><input type="radio" name="mode" value="agent" {{if $d.Cfg.Agent}}checked{{end}}> <span><b>Ядро подключается к кабинету</b> — у ядра нет адреса в интернете; оно само дозвонится сюда.</span></label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bj_url">Адрес ядра <span style="text-transform:none;letter-spacing:0">(только для первого способа)</span></label>
|
||||||
|
<input id="bj_url" name="bj_url" value="{{$d.Cfg.BJURL}}" placeholder="https://bj.депозитарий.ru" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bj_key">Ключ доступа модуля</label>
|
||||||
|
<input id="bj_key" name="bj_key" type="password" value="{{$d.Cfg.BJKey}}" autocomplete="off" placeholder="lk-…">
|
||||||
|
<p class="mut" style="margin:6px 0 0">Создаётся в настройках ядра: «Личный кабинет» → «Создать ключ».</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn main" type="submit">Проверить связь и сохранить</button>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<h1 style="margin:0">Администратор кабинета</h1>
|
||||||
|
<p class="mut" style="margin:0">Сотрудник депозитария, который входит в кабинет, видит список клиентов и может открыть кабинет клиента на просмотр. Учётная запись заводится в ядре. Если администратор уже есть — шаг можно пропустить.</p>
|
||||||
|
<input type="hidden" name="step" value="admin">
|
||||||
|
<div><label for="login">Логин</label><input id="login" name="login" autocomplete="off"></div>
|
||||||
|
<div><label for="full_name">Имя сотрудника</label><input id="full_name" name="full_name" autocomplete="off"></div>
|
||||||
|
<div><label for="email">Электронная почта <span style="text-transform:none;letter-spacing:0">(письма пока не отправляются)</span></label><input id="email" name="email" type="email" autocomplete="off"></div>
|
||||||
|
<div><label for="password">Пароль <span style="text-transform:none;letter-spacing:0">(от 10 символов, буквы и цифра)</span></label><input id="password" name="password" type="password" autocomplete="new-password"></div>
|
||||||
|
<div><label for="password2">Пароль ещё раз</label><input id="password2" name="password2" type="password" autocomplete="new-password"></div>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||||
|
<button class="btn main" type="submit">Создать и завершить</button>
|
||||||
|
<button class="btn" type="submit" name="skip" value="1">Пропустить</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body></html>{{end}}
|
||||||
|
|
||||||
|
{{define "install_done"}}{{template "head" .}}
|
||||||
|
<div class="login"><section class="side"><span class="brand">{{template "mark" .}}<span class="lbl" style="font-size:12px;letter-spacing:.14em;color:#f3f0e7">Установка кабинета</span></span><div><h1>Кабинет установлен</h1></div><p class="mut" style="color:#8e8879;margin:0">Настройки — в файле <span class="n">{{.File}}</span>. Чтобы пройти установку заново, удалите его.</p></section>
|
||||||
|
<section class="form"><form action="/login" method="get" style="max-width:420px"><h1 style="margin:0">Готово</h1><p class="mut" style="margin:0">Клиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Личный кабинет».</p><button class="btn main" type="submit">Перейти ко входу</button></form></section></div>
|
||||||
|
</body></html>{{end}}
|
||||||
|
|
||||||
|
{{define "installed"}}{{template "head" .}}
|
||||||
|
<div class="login"><section class="side"><span class="brand">{{template "mark" .}}<span class="lbl" style="font-size:12px;letter-spacing:.14em;color:#f3f0e7">Установка кабинета</span></span><div><h1>Кабинет уже настроен</h1></div></section>
|
||||||
|
<section class="form"><form action="/login" method="get" style="max-width:420px"><p class="mut" style="margin:0">Установщик закрыт: настройки сохранены в <span class="n">{{.File}}</span>. Чтобы изменить их, отредактируйте файл или удалите его и откройте эту страницу снова.</p><button class="btn main" type="submit">Перейти ко входу</button></form></section></div>
|
||||||
|
</body></html>{{end}}
|
||||||
|
|
||||||
{{define "profile"}}{{template "head" .}}{{template "nav" .}}
|
{{define "profile"}}{{template "head" .}}{{template "nav" .}}
|
||||||
<section class="board"><div class="board-row">
|
<section class="board"><div class="board-row">
|
||||||
<div><span class="lbl" style="color:var(--dim)">Профиль</span>
|
<div><span class="lbl" style="color:var(--dim)">Профиль</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user