package api import ( "encoding/json" "net/http" "net/url" "regexp" "strconv" "strings" "dana/internal/storage" ) // ---------- Страницы ---------- // Правовые страницы должны быть доступны всегда: на них ссылаются формы заявок // (согласие на обработку персональных данных) и баннер cookie. var requiredPages = map[string]bool{"legaldocs": true, "privacy": true, "consent": true, "cookie": true} type pageRequest struct { Title string `json:"title"` HTML string `json:"html"` Published bool `json:"published"` SEOTitle string `json:"seo_title"` SEODesc string `json:"seo_description"` SEOH1 string `json:"seo_h1"` } func (a *API) adminPages(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { list, err := a.store.AdminPages(r.Context()) if err != nil { handleErr(w, err) return } ok(w, list, map[string]any{"total": len(list)}) } func (a *API) adminUpdatePage(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") return } var req pageRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPostBody)).Decode(&req); err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос или слишком большой текст") return } current, err := a.store.AdminPage(r.Context(), id) if err != nil { handleErr(w, err) return } in := storage.PageInput{ Title: strings.TrimSpace(req.Title), Published: req.Published, SEOTitle: strings.TrimSpace(req.SEOTitle), SEODesc: strings.TrimSpace(req.SEODesc), SEOH1: strings.TrimSpace(req.SEOH1), } if in.Title == "" { fail(w, http.StatusUnprocessableEntity, "validation", "Укажите заголовок страницы") return } if requiredPages[current.Slug] && !in.Published { fail(w, http.StatusUnprocessableEntity, "validation", "Правовые документы нельзя скрыть: на них ссылаются формы заявок и баннер cookie") return } html := strings.TrimSpace(req.HTML) if requiredPages[current.Slug] && strings.TrimSpace(tagRE.ReplaceAllString(html, "")) == "" { fail(w, http.StatusUnprocessableEntity, "validation", "Текст правового документа не может быть пустым") return } // Блоки, которые админка не редактирует (например, фото на странице «О клинике»), // сохраняются как были; текстовые заменяются одним блоком из редактора. var blocks []json.RawMessage var old []json.RawMessage _ = json.Unmarshal(current.Blocks, &old) for _, b := range old { var head struct { Type string `json:"type"` HTML *string `json:"html"` } if json.Unmarshal(b, &head) == nil && head.HTML == nil && head.Type != "html" { blocks = append(blocks, b) } } if html != "" { b, _ := json.Marshal(map[string]string{"type": "html", "html": html}) blocks = append(blocks, b) } if blocks == nil { blocks = []json.RawMessage{} } in.Blocks, _ = json.Marshal(blocks) if err := a.store.AdminUpdatePage(r.Context(), id, in); err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "update", "page", id, clientIP(r)) ok(w, map[string]any{"id": id}, nil) } // ---------- Настройки ---------- // Все настройки публичны (/api/v1/settings), поэтому админка пишет только известные // ключи: пароли и ключи сервисов сюда попасть не должны, они живут в переменных окружения. type legalSettings struct { OrgName string `json:"org_name"` OrgShort string `json:"org_short"` INN string `json:"inn"` OGRN string `json:"ogrn"` Address string `json:"address"` License string `json:"license"` Email string `json:"email"` } type settingsRequest struct { Email string `json:"email"` MaxURL string `json:"max_url"` Legal legalSettings `json:"legal"` } var ( emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) onlyDigitsRE = regexp.MustCompile(`^\d+$`) ) func decodeSettings(w http.ResponseWriter, r *http.Request) (map[string]any, bool) { var req settingsRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req); err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") return nil, false } bad := func(msg string) (map[string]any, bool) { fail(w, http.StatusUnprocessableEntity, "validation", msg) return nil, false } trim := strings.TrimSpace req.Email, req.MaxURL = trim(req.Email), trim(req.MaxURL) l := &req.Legal l.OrgName, l.OrgShort, l.INN, l.OGRN = trim(l.OrgName), trim(l.OrgShort), trim(l.INN), trim(l.OGRN) l.Address, l.License, l.Email = trim(l.Address), trim(l.License), trim(l.Email) if req.Email != "" && !emailRE.MatchString(req.Email) { return bad("Проверьте адрес почты для сайта") } if req.MaxURL != "" { u, err := url.Parse(req.MaxURL) if err != nil || u.Scheme != "https" || !strings.Contains(u.Hostname(), ".") { return bad("Ссылка на MAX — адрес вида https://max.ru/…") } } // Реквизиты по закону должны быть на сайте медицинской организации — пустыми их не оставляем. if l.OrgName == "" || l.OrgShort == "" || l.Address == "" || l.License == "" { return bad("Заполните название организации, адрес и лицензию") } if !onlyDigitsRE.MatchString(l.INN) || (len(l.INN) != 10 && len(l.INN) != 12) { return bad("ИНН — 10 или 12 цифр") } if !onlyDigitsRE.MatchString(l.OGRN) || (len(l.OGRN) != 13 && len(l.OGRN) != 15) { return bad("ОГРН — 13 или 15 цифр") } if l.Email != "" && !emailRE.MatchString(l.Email) { return bad("Проверьте почту в реквизитах") } return map[string]any{"email": req.Email, "max_url": req.MaxURL, "legal": req.Legal}, true } func (a *API) adminSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { m, err := a.store.Settings(r.Context()) if err != nil { handleErr(w, err) return } ok(w, m, nil) } func (a *API) adminUpdateSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { values, valid := decodeSettings(w, r) if !valid { return } if err := a.store.AdminSetSettings(r.Context(), values); err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "update", "settings", 0, clientIP(r)) ok(w, values, nil) } // ---------- Филиалы ---------- type branchRequest struct { Slug string `json:"slug"` Name string `json:"name"` Address string `json:"address"` Phones []string `json:"phones"` WorkHours []string `json:"work_hours"` Lat *float64 `json:"lat"` Lon *float64 `json:"lon"` Sort int `json:"sort"` Published bool `json:"published"` } func cleanList(items []string) []string { out := []string{} for _, s := range items { if s = strings.TrimSpace(s); s != "" { out = append(out, s) } } return out } func decodeBranch(w http.ResponseWriter, r *http.Request) (storage.BranchInput, bool) { var req branchRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") return storage.BranchInput{}, false } in := storage.BranchInput{ Name: strings.TrimSpace(req.Name), Address: strings.TrimSpace(req.Address), Phones: cleanList(req.Phones), WorkHours: cleanList(req.WorkHours), Lat: req.Lat, Lon: req.Lon, Sort: req.Sort, Published: req.Published, } if in.Sort == 0 { in.Sort = 100 } bad := func(msg string) (storage.BranchInput, bool) { fail(w, http.StatusUnprocessableEntity, "validation", msg) return in, false } if in.Name == "" || in.Address == "" { return bad("Укажите название и адрес филиала") } slug, valid := resolveSlug(w, req.Slug, in.Name) if !valid { return in, false } in.Slug = slug for _, p := range in.Phones { // digitsRE (handlers_leads.go) совпадает с не-цифрами: после замены остаются только цифры if n := len(digitsRE.ReplaceAllString(p, "")); n < 10 || n > 12 { return bad("Телефон «" + p + "» — нужен номер целиком, например +7 928 962-00-94") } } // Первый телефон первого филиала стоит в шапке сайта — без него показать нечего. if in.Published && len(in.Phones) == 0 { return bad("Укажите хотя бы один телефон филиала") } if (in.Lat == nil) != (in.Lon == nil) { return bad("Укажите обе координаты или оставьте обе пустыми") } if in.Lat != nil && (*in.Lat < -90 || *in.Lat > 90 || *in.Lon < -180 || *in.Lon > 180) { return bad("Координаты вне допустимого диапазона: широта −90…90, долгота −180…180") } return in, true } func (a *API) adminBranches(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { list, err := a.store.AdminBranches(r.Context()) if err != nil { handleErr(w, err) return } ok(w, list, map[string]any{"total": len(list)}) } func (a *API) adminCreateBranch(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { in, valid := decodeBranch(w, r) if !valid { return } id, err := a.store.AdminCreateBranch(r.Context(), in) if err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "create", "branch", id, clientIP(r)) writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}}) } func (a *API) adminUpdateBranch(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный id") return } in, valid := decodeBranch(w, r) if !valid { return } if err := a.store.AdminUpdateBranch(r.Context(), id, in); err != nil { handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "update", "branch", id, clientIP(r)) ok(w, map[string]any{"id": id}, nil) } // ---------- Главный экран админки ---------- func (a *API) adminDashboard(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { d, err := a.store.Dashboard(r.Context(), 14) if err != nil { handleErr(w, err) return } ok(w, d, nil) }