package quikexport // Служба bj-quikexport: свои настройки, раздел настроек фрагментом для bj-server и действия раздела. // // Протокол с bj-server (всё со служебным ключом, см. modkit): // - GET /section[?reveal_ro=1] — HTML раздела «Экспорт QUIK» для страницы «Настройка», // в заголовке X-BJ-Indicator — цвет индикатора меню (ok | warn | err | пусто); // - POST /admin/setup/quikexport[/keys|apply|import|service|dbinit] — действие; итог — 204 // с сообщением в X-BJ-Flash, bj-server показывает его как обычное сообщение страницы настроек; // - GET /admin/setup/quikexport/sql и /preview — файлы как есть. // // Лицензию проверяет bj-server и передаёт в X-BJ-Licensed: 1. import ( "bytes" "context" "embed" "encoding/json" "fmt" "html/template" "log" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/modkit" ) //go:embed web/section.html var sectionFS embed.FS // Config — где служба берёт настройки и ключ. type Config struct { StorePath string // собственные настройки модуля SetupPath string // setup.json bj: отсюда настройки переносятся при первом запуске (раздел quik_export) KeyPath string // служебный ключ модулей } // Server — служба модуля. type Server struct { cfg Config store *Store section *template.Template } // New — служба с разобранным шаблоном; при первом запуске переносит настройки из setup.json. func New(cfg Config) (*Server, error) { t, err := template.New("").Funcs(template.FuncMap{ "msk": func(t time.Time) string { if t.IsZero() { return "—" } return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") }, }).ParseFS(sectionFS, "web/section.html") if err != nil { return nil, err } st := &Store{path: cfg.StorePath} moved, err := st.migrate(cfg.SetupPath) if err != nil { return nil, fmt.Errorf("перенос настроек из %s: %w", cfg.SetupPath, err) } if moved { log.Printf("bj-quikexport: настройки перенесены из %s в %s", cfg.SetupPath, cfg.StorePath) } return &Server{cfg: cfg, store: st, section: t}, nil } // Handler — все маршруты службы; без служебного ключа отвечает 403. func (srv *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) mux.HandleFunc("/section", func(w http.ResponseWriter, r *http.Request) { v := quikExportView(srv.store, licensed(r), r.URL.Query().Get("reveal_ro") == "1") var buf bytes.Buffer if err := srv.section.ExecuteTemplate(&buf, "section", v); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-BJ-Indicator", indicator(v)) _, _ = w.Write(buf.Bytes()) }) mux.HandleFunc("/admin/setup/quikexport", srv.saveQuikExport) mux.HandleFunc("/admin/setup/quikexport/keys", srv.uploadQuikExportKeys) mux.HandleFunc("/admin/setup/quikexport/apply", srv.applyQuikExport) mux.HandleFunc("/admin/setup/quikexport/import", srv.importQuikExport) mux.HandleFunc("/admin/setup/quikexport/service", srv.serviceQuikExport) mux.HandleFunc("/admin/setup/quikexport/dbinit", srv.dbInitQuikExport) mux.HandleFunc("/admin/setup/quikexport/sql", srv.sqlQuikExport) mux.HandleFunc("/admin/setup/quikexport/preview", srv.previewQuikExport) return modkit.RequireKey(srv.cfg.KeyPath, mux) } func licensed(r *http.Request) bool { return r.Header.Get("X-BJ-Licensed") == "1" } // flash — итог действия для страницы настроек bj. func flash(w http.ResponseWriter, msg string) { w.Header().Set("X-BJ-Flash", url.PathEscape(msg)) w.WriteHeader(http.StatusNoContent) } // indicator — цвет индикатора раздела в меню настроек. func indicator(v QuikExportView) string { switch { case !v.Licensed: return "err" case v.S.Enabled && v.Service.Active == "active": return "ok" case v.S.Enabled: return "warn" } return "" } // ---- настройки ---- // Store — настройки модуля в собственном файле (bj-server перезаписывает setup.json целиком, // поэтому служба туда не пишет). type Store struct { path string mu sync.Mutex } // Get — настройки со значениями по умолчанию. func (st *Store) Get() QuikExportSettings { st.mu.Lock() defer st.mu.Unlock() var s QuikExportSettings if b, err := os.ReadFile(st.path); err == nil { _ = json.Unmarshal(b, &s) } return s.withDefaults() } // Save — атомарная запись (0600: в настройках пароли). func (st *Store) Save(s QuikExportSettings) error { st.mu.Lock() defer st.mu.Unlock() raw, err := json.MarshalIndent(s, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(st.path), 0o700); err != nil { return err } tmp := st.path + ".tmp" if err := os.WriteFile(tmp, raw, 0o600); err != nil { return err } return os.Rename(tmp, st.path) } // migrate — если своих настроек ещё нет, берёт раздел quik_export из setup.json bj. func (st *Store) migrate(setupPath string) (bool, error) { if _, err := os.Stat(st.path); err == nil || setupPath == "" { return false, nil } b, err := os.ReadFile(setupPath) if err != nil { return false, nil } var sj struct { QuikExport json.RawMessage `json:"quik_export"` } if json.Unmarshal(b, &sj) != nil || len(sj.QuikExport) == 0 || string(sj.QuikExport) == "null" { return false, nil } var s QuikExportSettings if err := json.Unmarshal(sj.QuikExport, &s); err != nil { return false, err } return true, st.Save(s) } // ---- root-воркер ---- const keymediaReqDir = "/var/lib/bj/keymedia-requests" // rootOp — запрос root-воркеру bj-keymedia-worker через файловую очередь и ожидание ответа. func rootOp(ctx context.Context, action string, fields map[string]string) (map[string]any, error) { id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano()) reqPath := filepath.Join(keymediaReqDir, id+".req") resPath := filepath.Join(keymediaReqDir, id+".res") defer os.Remove(resPath) req := map[string]string{"action": action} for k, v := range fields { req[k] = v } reqBody, _ := json.Marshal(req) // Пишем атомарно (tmp → rename), чтобы воркер не подхватил полупустой файл. tmp := reqPath + ".tmp" if err := os.WriteFile(tmp, reqBody, 0o660); err != nil { return nil, fmt.Errorf("запись запроса: %w", err) } if err := os.Rename(tmp, reqPath); err != nil { return nil, fmt.Errorf("публикация запроса: %w", err) } ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return nil, fmt.Errorf("таймаут ожидания root-воркера") case <-ticker.C: b, err := os.ReadFile(resPath) if err != nil { continue // ещё не готово } res := map[string]any{} // systemd/скрипты могут напечатать предупреждение перед JSON — берём с первой «{». if i := bytes.IndexByte(b, '{'); i > 0 { b = b[i:] } if jerr := json.Unmarshal(b, &res); jerr != nil { return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b))) } if res["ok"] != true { msg, _ := res["error"].(string) if msg == "" { msg = "ошибка" } return res, fmt.Errorf("воркер: %s", msg) } return res, nil } } }