diff --git a/DOC/instruktsiya-dlya-osuschestvleniya-obmena-soobscheniyami-mezhdu-depozitariyami-pri-perevodah-tsennyh-bumag-fizicheskim-litsom-samomu-sebe.pdf b/DOC/instruktsiya-dlya-osuschestvleniya-obmena-soobscheniyami-mezhdu-depozitariyami-pri-perevodah-tsennyh-bumag-fizicheskim-litsom-samomu-sebe.pdf new file mode 100644 index 0000000..a7f2d05 Binary files /dev/null and b/DOC/instruktsiya-dlya-osuschestvleniya-obmena-soobscheniyami-mezhdu-depozitariyami-pri-perevodah-tsennyh-bumag-fizicheskim-litsom-samomu-sebe.pdf differ diff --git a/DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf b/DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf new file mode 100644 index 0000000..2b9b1ac Binary files /dev/null and b/DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf differ diff --git a/DOC/servis-most-m2m.pdf b/DOC/servis-most-m2m.pdf new file mode 100644 index 0000000..98c1040 Binary files /dev/null and b/DOC/servis-most-m2m.pdf differ diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 2bdbedb..5342346 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -21,7 +21,7 @@ var templatesFS embed.FS type admin struct { home, claims, claim, status, setup *template.Template help, helpDatabase, helpLK, helpCryptoPro, helpSystems *template.Template - wizard *template.Template + wizard, news *template.Template } // templateFuncs — функции, доступные внутри шаблонов. Главная задача — @@ -31,6 +31,7 @@ var templateFuncs = template.FuncMap{ "ru": russianText, "ruState": russianState, "ruOutcome": russianOutcome, + "now": time.Now, } // russianState переводит технический FSM-state в человекочитаемый @@ -126,10 +127,14 @@ func newAdmin() (*admin, error) { if err != nil { return nil, fmt.Errorf("parse admin_wizard: %w", err) } + news, err := parse("admin_news.html") + if err != nil { + return nil, fmt.Errorf("parse admin_news: %w", err) + } return &admin{ home: home, claims: claims, claim: claim, status: status, setup: setup, help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys, - wizard: wizard, + wizard: wizard, news: news, }, nil } @@ -151,6 +156,7 @@ type homeData struct { Failed int } Recent []ClaimView + News []NewsItem // top-3 активных или свежих новостей } // claimsData — данные журнала. @@ -175,7 +181,7 @@ type statusData struct { // RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin // со всеми загруженными шаблонами — вызывающий может прокинуть его в // registerSetup для добавления вкладки «Настройка». -func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) (*admin, error) { +func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts func() CheckOptions) (*admin, error) { a, err := newAdmin() if err != nil { return nil, err @@ -185,7 +191,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions p := strings.TrimPrefix(r.URL.Path, "/admin/") switch { case p == "" || p == "index" || p == "home": - a.renderHome(w, r, svc, getOpts()) + a.renderHome(w, r, svc, rc, getOpts()) case p == "claims": a.renderClaims(w, r, svc) case strings.HasPrefix(p, "claims/"): @@ -213,7 +219,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions return a, nil } -func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, opts CheckOptions) { +func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, rc *RuntimeConfig, opts CheckOptions) { ctx := r.Context() status := CheckAll(ctx, opts) recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10}) @@ -225,6 +231,7 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, page: nowPage("Дашборд", "home"), Status: status, Recent: recent.Items, + News: topNews(rc.Snapshot().News.Items, 3), } full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200}) if err == nil { @@ -279,3 +286,38 @@ func render(w http.ResponseWriter, t *template.Template, data any) { func nowPage(title, active string) page { return page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")} } + +// topNews отбирает максимум N новостей: сначала те, что активны прямо сейчас +// (по ValidFrom..ValidTo), потом просто свежие. Скрытые (Dismissed) — мимо. +func topNews(items []NewsItem, n int) []NewsItem { + now := time.Now() + var active, rest []NewsItem + for _, it := range items { + if it.Dismissed { + continue + } + isActive := !it.ValidFrom.IsZero() && !it.ValidTo.IsZero() && + now.After(it.ValidFrom) && now.Before(it.ValidTo) + // «Будущие» окна с ValidFrom в будущем тоже считаем актуальными + // (предупредить заранее). + isUpcoming := !it.ValidFrom.IsZero() && now.Before(it.ValidFrom) && + it.ValidFrom.Sub(now) < 7*24*time.Hour + if isActive || isUpcoming { + active = append(active, it) + } else { + rest = append(rest, it) + } + } + out := active + if len(out) < n { + need := n - len(out) + if need > len(rest) { + need = len(rest) + } + out = append(out, rest[:need]...) + } + if len(out) > n { + out = out[:n] + } + return out +} diff --git a/internal/lkgateway/cacerts.go b/internal/lkgateway/cacerts.go index 9696ab8..d2be0e4 100644 --- a/internal/lkgateway/cacerts.go +++ b/internal/lkgateway/cacerts.go @@ -32,9 +32,11 @@ var defaultNSDCAURLs = []string{ } // FetchCACertificates скачивает все URL из настроек, парсит .cer, и при -// успехе вызывает certmgr -inst -store mroot. Возвращает обновлённую -// CACertsSettings (для записи в runtime-конфиг) и сводку как строку. -func FetchCACertificates(ctx context.Context, s CACertsSettings) (CACertsSettings, string) { +// успехе вызывает certmgr -inst -store mroot. Если передан rc — на каждое +// фактическое изменение сертификата (новый или изменился SHA-256) +// публикуется новость в ленту через rc.AddNews. На сертификаты, +// истекающие в ближайшие 14 дней — отдельная новость-предупреждение. +func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConfig) (CACertsSettings, string) { if len(s.URLs) == 0 { return s, "Список URL пуст. Добавьте ссылки на .cer-файлы УЦ НРД в /admin/setup → «Сертификаты УЦ»." } @@ -90,12 +92,59 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings) (CACertsSetting } // Импорт через certmgr. + isNew := true + for _, old := range s.FetchedCerts { + if old.URL == u && old.Error == "" { + isNew = false + break + } + } if err := importCertToStore(ctx, der, store); err != nil { fc.Error = "certmgr: " + err.Error() fmt.Fprintf(&logBuf, "%s — certmgr упал: %s\n", u, err) + if rc != nil { + _ = rc.AddNews(NewsItem{ + ID: "ca-error-" + fc.SHA256[:12], + At: now, + Kind: "system", + Title: "Не удалось импортировать сертификат УЦ", + Body: "URL: " + u + "\nCN: " + fc.SubjectCN + "\nОшибка: " + err.Error(), + URL: u, + }) + } } else { fmt.Fprintf(&logBuf, "%s — импортирован в %s (CN=%s, sha256=%s...)\n", u, store, fc.SubjectCN, fc.SHA256[:12]) + if rc != nil { + kindTitle := "Обновлён сертификат УЦ" + if isNew { + kindTitle = "Установлен новый сертификат УЦ" + } + _ = rc.AddNews(NewsItem{ + ID: "ca-update-" + fc.SHA256[:12], + At: now, + Kind: "feature", + Title: kindTitle + ": " + fc.SubjectCN, + Body: fmt.Sprintf("Хранилище: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s", + store, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u), + URL: u, + ValidTo: fc.NotAfter, + }) + // Предупреждение если истекает в ближайшие 14 дней. + if !fc.NotAfter.IsZero() && time.Until(fc.NotAfter) < 14*24*time.Hour { + _ = rc.AddNews(NewsItem{ + ID: "ca-expiring-" + fc.SHA256[:12], + At: now, + Kind: "system", + Title: "⚠ Сертификат УЦ скоро истечёт: " + fc.SubjectCN, + Body: fmt.Sprintf("Срок действия — %s (через %d дней). Получите новую версию у УЦ и обновите URL в /admin/setup → «Сертификаты УЦ».", + fc.NotAfter.Format("02.01.2006"), + int(time.Until(fc.NotAfter)/(24*time.Hour))), + URL: u, + ValidTo: fc.NotAfter, + }) + } + } } newFetched = append(newFetched, fc) } @@ -192,7 +241,7 @@ func StartCACertsAutoUpdater(rc *RuntimeConfig) func() { for { s := rc.Snapshot().CACerts if s.AutoUpdate && len(s.URLs) > 0 { - updated, _ := FetchCACertificates(ctx, s) + updated, _ := FetchCACertificates(ctx, s, rc) if err := rc.UpdateCACerts(updated); err != nil { log.Printf("ca-certs auto-update: save failed: %v", err) } else { @@ -245,7 +294,7 @@ func (h *setupHandlers) fetchCACertsNow(w http.ResponseWriter, r *http.Request) ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() cur := h.rc.Snapshot().CACerts - updated, summary := FetchCACertificates(ctx, cur) + updated, summary := FetchCACertificates(ctx, cur, h.rc) if err := h.rc.UpdateCACerts(updated); err != nil { setupFlash(w, r, "Сертификаты УЦ: ошибка сохранения: "+err.Error()) return diff --git a/internal/lkgateway/news.go b/internal/lkgateway/news.go new file mode 100644 index 0000000..4b39b02 --- /dev/null +++ b/internal/lkgateway/news.go @@ -0,0 +1,369 @@ +package lkgateway + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// defaultDocSources — стартовый набор страниц НРД, которые doc-watcher +// будет проверять раз в сутки. Пользователь может добавить/удалить через UI. +var defaultDocSources = []DocSource{ + { + URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/", + Name: "Сервис MOEX МОСТ для M2M", + }, + { + URL: "https://www.nsd.ru/workflow/system/programs/", + Name: "ПО для участников ЭДО (ИШ, ФШ)", + }, + { + URL: "https://www.nsd.ru/workflow/system/programs/cryptoservice/", + Name: "Криптосервис", + }, +} + +// EnsureDocSources гарантирует что defaultDocSources прописаны в конфиге. +// Вызывается при старте bj-server. +func EnsureDocSources(rc *RuntimeConfig) { + s := rc.Snapshot().News + if len(s.DocSources) > 0 { + return + } + s.DocSources = append([]DocSource(nil), defaultDocSources...) + if err := rc.UpdateNews(s); err != nil { + log.Printf("news: не получилось сохранить default DocSources: %v", err) + } +} + +// pdfHrefRe — ищет в HTML href'ы, заканчивающиеся на .pdf (case-insensitive). +var pdfHrefRe = regexp.MustCompile(`(?i)href="([^"]+\.pdf)"`) + +// CheckDocSources обходит все DocSource из настроек, парсит HTML, ищет +// новые PDF и скачивает их в DOC/. На каждое нововведение эмитирует +// NewsItem типа "doc-update". Возвращает суммарную строку для лога. +func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string { + s := rc.Snapshot().News + if len(s.DocSources) == 0 { + s.DocSources = append([]DocSource(nil), defaultDocSources...) + } + var summary strings.Builder + now := time.Now() + + for i, src := range s.DocSources { + fmt.Fprintf(&summary, "→ %s\n", src.URL) + pdfs, err := fetchPDFLinks(ctx, src.URL) + if err != nil { + fmt.Fprintf(&summary, " ошибка: %v\n", err) + continue + } + if src.KnownPDFs == nil { + s.DocSources[i].KnownPDFs = map[string]string{} + } + known := s.DocSources[i].KnownPDFs + fmt.Fprintf(&summary, " найдено %d ссылок на PDF\n", len(pdfs)) + newlyAdded := 0 + for _, pdfURL := range pdfs { + hash, changed := checkPDF(ctx, pdfURL, known) + if !changed { + continue + } + known[pdfURL] = hash + newlyAdded++ + localPath, err := downloadPDFToDOC(ctx, pdfURL) + if err != nil { + fmt.Fprintf(&summary, " ✗ %s: %v\n", pdfURL, err) + continue + } + fmt.Fprintf(&summary, " ✓ %s → %s\n", pdfURL, localPath) + // Новость в ленту. + _ = rc.AddNews(NewsItem{ + ID: "doc-" + hash[:12], + At: now, + Kind: "doc-update", + Title: "Обновлена документация: " + filepath.Base(localPath), + Body: "Источник: " + src.Name + "\nURL: " + pdfURL + + "\nЛокально: " + localPath + "\nSHA-256: " + hash[:16] + "…", + URL: pdfURL, + }) + } + s.DocSources[i].LastChecked = now + if newlyAdded > 0 { + fmt.Fprintf(&summary, " добавлено новых: %d\n", newlyAdded) + } + } + + s.LastDocCheck = now + s.DocCheckResult = summary.String() + if err := rc.UpdateNews(s); err != nil { + log.Printf("news: save failed: %v", err) + } + return summary.String() +} + +// fetchPDFLinks качает HTML-страницу и извлекает все href'ы, заканчивающиеся +// на .pdf. Относительные URL разворачиваются в абсолютные. +func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) { + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pageURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "bj-server/1.0 (doc-watcher)") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, err + } + base, err := url.Parse(pageURL) + if err != nil { + return nil, err + } + matches := pdfHrefRe.FindAllStringSubmatch(string(body), -1) + seen := map[string]bool{} + var out []string + for _, m := range matches { + ref, err := url.Parse(m[1]) + if err != nil { + continue + } + abs := base.ResolveReference(ref).String() + // Игнорируем «системные» PDF (политика конфиденциальности и т.п.). + low := strings.ToLower(abs) + if strings.Contains(low, "personal_information") || + strings.Contains(low, "personal-information") || + strings.Contains(low, "razmeschenie-logotipa") { + continue + } + if seen[abs] { + continue + } + seen[abs] = true + out = append(out, abs) + } + return out, nil +} + +// checkPDF делает HEAD-запрос (или GET если HEAD не сработал) и сравнивает +// sha256 PDF с известным значением. Возвращает (новый_hash, изменился). +// HEAD у НРД редко возвращает Content-MD5/ETag — реальная проверка = +// скачать и посчитать sha256. +func checkPDF(ctx context.Context, pdfURL string, known map[string]string) (string, bool) { + reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil) + if err != nil { + return "", false + } + req.Header.Set("User-Agent", "bj-server/1.0 (doc-watcher)") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", false + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return "", false + } + h := sha256.New() + if _, err := io.Copy(h, io.LimitReader(resp.Body, 32<<20)); err != nil { + return "", false + } + hash := hex.EncodeToString(h.Sum(nil)) + if old, ok := known[pdfURL]; ok && old == hash { + return hash, false + } + return hash, true +} + +// downloadPDFToDOC скачивает PDF в DOC/. Если файл с таким именем уже +// есть — переименовывает старый в name.old-YYYYMMDD.pdf, чтобы оставить +// аудит. Возвращает путь до нового файла. +func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) { + u, err := url.Parse(pdfURL) + if err != nil { + return "", err + } + name := filepath.Base(u.Path) + if name == "" || !strings.HasSuffix(strings.ToLower(name), ".pdf") { + return "", errors.New("странное имя файла") + } + docDir := "DOC" + if _, err := os.Stat(docDir); err != nil { + return "", fmt.Errorf("DOC/ не доступен: %w", err) + } + dst := filepath.Join(docDir, name) + // Если файл уже есть — переименуем как backup. + if _, err := os.Stat(dst); err == nil { + old := filepath.Join(docDir, + strings.TrimSuffix(name, ".pdf")+ + "."+time.Now().Format("2006-01-02")+".pdf.bak") + _ = os.Rename(dst, old) + } + + reqCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "bj-server/1.0 (doc-watcher)") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + f, err := os.Create(dst) + if err != nil { + return "", err + } + defer f.Close() + if _, err := io.Copy(f, io.LimitReader(resp.Body, 64<<20)); err != nil { + return "", err + } + return dst, nil +} + +// StartDocWatcher запускает горутину, которая раз в сутки проверяет +// DocSources и эмитирует новости. Стартует через 60 сек после Run(). +func StartDocWatcher(rc *RuntimeConfig) func() { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + select { + case <-ctx.Done(): + return + case <-time.After(60 * time.Second): + } + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + summary := CheckDocSources(ctx, rc) + log.Printf("doc-watcher: проверка завершена\n%s", summary) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() + return cancel +} + +// addManualNews — POST /admin/news/add. +func (h *setupHandlers) addManualNews(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + title := strings.TrimSpace(r.FormValue("title")) + body := strings.TrimSpace(r.FormValue("body")) + kind := r.FormValue("kind") + if kind == "" { + kind = "manual" + } + if title == "" { + setupFlash(w, r, "Новости: укажите заголовок") + return + } + item := NewsItem{ + At: time.Now(), + Kind: kind, + Title: title, + Body: body, + } + if vf := r.FormValue("valid_from"); vf != "" { + if t, err := time.Parse("2006-01-02", vf); err == nil { + item.ValidFrom = t + } + } + if vt := r.FormValue("valid_to"); vt != "" { + if t, err := time.Parse("2006-01-02", vt); err == nil { + item.ValidTo = t.Add(24*time.Hour - time.Second) + } + } + if err := h.rc.AddNews(item); err != nil { + setupFlash(w, r, "Новости: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "Новость «"+title+"» добавлена в ленту") +} + +// dismissNews — POST /admin/news/dismiss?id=... +func (h *setupHandlers) dismissNews(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + id := r.FormValue("id") + if id == "" { + setupFlash(w, r, "Новости: id обязателен") + return + } + _ = h.rc.DismissNews(id) + setupFlash(w, r, "Новость скрыта") +} + +// checkDocsNow — POST /admin/news/check-docs. +func (h *setupHandlers) checkDocsNow(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + summary := CheckDocSources(ctx, h.rc) + if len(summary) > 600 { + summary = summary[:600] + "…" + } + setupFlash(w, r, "Проверка обновлений документации завершена. "+strings.TrimSpace(summary)) +} + +// SeedDefaultNews добавляет в ленту известные на момент запуска события +// (окно техработ TEST3 в мае 2026 и появление робота-автотестирования). +// Вызывается из server.go при старте — дедуп по ID гарантирован AddNews. +func SeedDefaultNews(rc *RuntimeConfig) { + defaults := []NewsItem{ + { + ID: "test3-maintenance-2026-05", + At: time.Date(2026, 5, 13, 0, 0, 0, 0, time.UTC), + Kind: "maintenance", + Title: "TEST3 недоступен 18.05.2026 — 22.05.2026 (техработы)", + Body: "НРД проводит техработы на тестовом контуре TEST3. На gost-t3.nsd.ru / rsa-t3.nsd.ru интеграционные прогоны в этот период не пойдут. При необходимости — переключитесь на GUEST (gost-gt.nsd.ru) или mock-режим. Источник: НРД письмо НРД-И-2026-8452 от 13.05.2026.", + URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/", + ValidFrom: time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC), + ValidTo: time.Date(2026, 5, 22, 23, 59, 59, 0, time.UTC), + }, + { + ID: "robot-autotest-2026-05-12", + At: time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC), + Kind: "feature", + Title: "Доступно автотестирование MOEX МОСТ с роботом на TEST3", + Body: "С 12.05.2026 клиенты, подключившиеся к автотестированию, могут гонять обмен сообщениями с роботом-контрагентом на TEST3. Не нужно ждать живого второго депозитария. Контакт: M2MOST@nsd.ru. Опубликованы новые инструкции: «Инструкция по тестированию с роботом» и «Инструкция для обмена при self-transfer» — обе в DOC/.", + URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/", + }, + } + for _, item := range defaults { + _ = rc.AddNews(item) + } +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 17e6ba4..448bc71 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -25,10 +25,43 @@ type Settings struct { NSD NSDSettings `json:"nsd"` LK LKSettings `json:"lk"` CACerts CACertsSettings `json:"ca_certs"` + News NewsSettings `json:"news"` LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } +// NewsSettings — лента новостей (события системы, окна техработ, обновления +// документации НРД). События добавляются вручную через UI или автоматически +// doc-watcher'ом и cron-задачами. Каждое событие может быть скрыто (Dismissed) +// оператором, но не удалено — лента служит «журналом» для аудита. +type NewsSettings struct { + Items []NewsItem `json:"items"` + DocSources []DocSource `json:"doc_sources"` // URL'ы для авто-проверки (NSD pages) + LastDocCheck time.Time `json:"last_doc_check"` + DocCheckResult string `json:"doc_check_result"` +} + +// NewsItem — одно событие в ленте. +type NewsItem struct { + ID string `json:"id"` // уникальный идентификатор для dismiss + At time.Time `json:"at"` + Kind string `json:"kind"` // "maintenance" | "feature" | "doc-update" | "manual" | "system" + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url,omitempty"` // ссылка на источник + ValidFrom time.Time `json:"valid_from,omitempty"` // для maintenance окон + ValidTo time.Time `json:"valid_to,omitempty"` + Dismissed bool `json:"dismissed"` +} + +// DocSource — страница НРД, которую doc-watcher периодически проверяет. +type DocSource struct { + URL string `json:"url"` + Name string `json:"name"` // человекочитаемое имя + LastChecked time.Time `json:"last_checked"` + KnownPDFs map[string]string `json:"known_pdfs"` // url → sha256 +} + // CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего // УЦ. Список редактируется пользователем; раз в сутки фоновая горутина // перекачивает каждый URL и переустанавливает сертификат, если он @@ -160,6 +193,55 @@ func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error { return r.save() } +// UpdateNews заменяет всю ленту новостей. +func (r *RuntimeConfig) UpdateNews(s NewsSettings) error { + r.mu.Lock() + r.data.News = s + r.data.UpdatedAt = time.Now() + r.mu.Unlock() + return r.save() +} + +// AddNews добавляет новость в начало ленты (newest first). Если в ленте уже +// есть новость с таким же ID — она обновляется (вместо дубликата). +func (r *RuntimeConfig) AddNews(item NewsItem) error { + r.mu.Lock() + if item.ID == "" { + item.ID = item.At.Format("20060102-150405") + "-" + item.Kind + } + if item.At.IsZero() { + item.At = time.Now() + } + // Дедуп по ID. + replaced := false + for i, ex := range r.data.News.Items { + if ex.ID == item.ID { + r.data.News.Items[i] = item + replaced = true + break + } + } + if !replaced { + r.data.News.Items = append([]NewsItem{item}, r.data.News.Items...) + } + r.data.UpdatedAt = time.Now() + r.mu.Unlock() + return r.save() +} + +// DismissNews помечает новость скрытой по ID (не удаляет — для аудита). +func (r *RuntimeConfig) DismissNews(id string) error { + r.mu.Lock() + for i := range r.data.News.Items { + if r.data.News.Items[i].ID == id { + r.data.News.Items[i].Dismissed = true + } + } + r.data.UpdatedAt = time.Now() + r.mu.Unlock() + return r.save() +} + func (r *RuntimeConfig) UpdateLK(s LKSettings) error { r.mu.Lock() r.data.LK = s diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 04ba901..e397002 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -105,7 +105,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { checkOpts = cfg.CheckOptions } - adminTpl, err := RegisterAdmin(mux, svc, checkOpts) + adminTpl, err := RegisterAdmin(mux, svc, rc, checkOpts) if err != nil { return nil, err } @@ -163,6 +163,14 @@ func (s *Server) Run(ctx context.Context) error { stopCACerts := StartCACertsAutoUpdater(s.rc) defer stopCACerts() + // Doc-watcher: раз в сутки проверяет сайт НРД на новые PDF и + // эмитирует новости в ленту. Дефолтные источники + дефолтные + // новости (окно техработ TEST3, появление робота) сеются один раз. + EnsureDocSources(s.rc) + SeedDefaultNews(s.rc) + stopDocWatcher := StartDocWatcher(s.rc) + defer stopDocWatcher() + errCh := make(chan error, 1) go func() { log.Printf("lk-gateway: listen %s", s.cfg.Addr) diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 8c8b4f5..097818b 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -77,10 +77,35 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service // Копирование контейнера КриптоПро с флешки в локальное хранилище. mux.HandleFunc("/admin/setup/crypto/copy-container", h.copyContainer) + // Новости / события системы. + mux.HandleFunc("/admin/news", h.renderNews) + mux.HandleFunc("/admin/news/add", h.addManualNews) + mux.HandleFunc("/admin/news/dismiss", h.dismissNews) + mux.HandleFunc("/admin/news/check-docs", h.checkDocsNow) + // Пошаговый мастер настройки для нетехнических пользователей. mux.HandleFunc("/admin/wizard", h.renderWizard) } +// renderNews — GET /admin/news. +func (h *setupHandlers) renderNews(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot() + data := struct { + page + Settings Settings + Flash string + }{ + page: nowPage("Новости", "news"), + Settings: s, + Flash: r.URL.Query().Get("flash"), + } + render(w, h.tpl.a.news, data) +} + // WizardData — данные для шаблона /admin/wizard. type WizardData struct { page diff --git a/internal/lkgateway/web/templates/admin_home.html b/internal/lkgateway/web/templates/admin_home.html index 61d00e2..ac20068 100644 --- a/internal/lkgateway/web/templates/admin_home.html +++ b/internal/lkgateway/web/templates/admin_home.html @@ -1,4 +1,25 @@ {{define "content"}} +{{/* Активные новости — сразу под навигацией. Показываем top-3: те у которых ValidFrom..ValidTo сейчас активны, иначе свежие. */}} +{{if .News}} +
{{.Flash}}
События системы, окна техработ НРД, обновления документации и сертификатов. Лента не очищается — служит журналом для аудита. Скрытые новости можно посмотреть, сняв галочку «Только активные».
+ + {{if .Settings.News.DocSources}} +| Имя | URL | PDF найдено | Последняя проверка |
|---|---|---|---|
| {{.Name}} | +{{.URL}} |
+ {{len .KnownPDFs}} | +{{if .LastChecked.IsZero}}—{{else}}{{.LastChecked.Format "02.01.2006 15:04"}}{{end}} | +
Пока ничего нет. Doc-watcher запустится через минуту после старта bj-server и заполнит ленту автоматически.
{{.Settings.News.DocCheckResult}}
+