package api import ( "encoding/json" "errors" "log/slog" "net/http" "reflect" "strings" "time" "dana/internal/bitrix" "dana/internal/megafon" "dana/internal/notify" "dana/internal/onec" "dana/internal/prodoctorov" "dana/internal/sberhealth" "dana/internal/storage" ) type API struct { store *storage.Storage mediaDir string notifier notify.Notifier website *website pbx *megafon.Client pbxClinics map[string]*megafon.Client // АТС других клиник (clinics.go) onec *onec.Client onecHook string onecEvery time.Duration onecLongPoll bool bitrix *bitrix.Client smscLogin string smscPassword string smscCallbackToken string prodoctorov *prodoctorov.Client sberhealth *sberhealth.Client limiter *rateLimiter confirmKick chan struct{} // будит подтверждение записей (booking_confirm.go) } // WithBitrix включает разовую выгрузку номеров из Битрикс24 для «Отчёта по каналам». // Без вебхука кнопка в отчёте просто не предлагается. func (a *API) WithBitrix(c *bitrix.Client) { a.bitrix = c } func (a *API) WithSMSC(login, password string) { a.smscLogin = login; a.smscPassword = password } // WithSMSCCallbackToken включает приём обратных вызовов SMSC: без токена в адресе они отклоняются. func (a *API) WithSMSCCallbackToken(t string) { a.smscCallbackToken = strings.TrimSpace(t) } func New(store *storage.Storage, mediaDir string) *API { return &API{store: store, mediaDir: mediaDir, limiter: newRateLimiter(), confirmKick: make(chan struct{}, 1)} } func (a *API) Routes() http.Handler { mux := http.NewServeMux() if a.website != nil { mux.HandleFunc("GET /robots.txt", a.robots) mux.HandleFunc("GET /sitemap.xml", a.sitemap) mux.HandleFunc("GET /", a.websitePage) } mux.HandleFunc("GET /api/v1/health", a.health) mux.HandleFunc("GET /api/v1/services", a.services) mux.HandleFunc("GET /api/v1/services/{path...}", a.service) mux.HandleFunc("GET /api/v1/specialties", a.specialties) mux.HandleFunc("GET /api/v1/doctors", a.doctors) mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor) mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots) mux.HandleFunc("GET /api/v1/prices", a.prices) mux.HandleFunc("GET /api/v1/price-sections", a.priceSections) mux.HandleFunc("GET /api/v1/checkups", a.checkups) mux.HandleFunc("GET /api/v1/checkups/{slug}", a.checkup) mux.HandleFunc("GET /api/v1/news", a.posts("news")) mux.HandleFunc("GET /api/v1/news/{slug}", a.post("news")) mux.HandleFunc("GET /api/v1/articles", a.posts("article")) mux.HandleFunc("GET /api/v1/articles/{slug}", a.post("article")) mux.HandleFunc("GET /api/v1/promos", a.posts("promo")) mux.HandleFunc("GET /api/v1/promos/{slug}", a.post("promo")) mux.HandleFunc("GET /api/v1/reviews", a.reviews) mux.HandleFunc("GET /api/v1/faq", a.faq) mux.HandleFunc("GET /api/v1/documents", a.documents) mux.HandleFunc("GET /api/v1/partners", a.partners) mux.HandleFunc("GET /api/v1/vacancies", a.vacancies) mux.HandleFunc("GET /api/v1/branches", a.branches) mux.HandleFunc("GET /api/v1/gallery", a.gallery) mux.HandleFunc("GET /api/v1/settings", a.settings) mux.HandleFunc("GET /api/v1/pages/{slug}", a.page) mux.HandleFunc("GET /api/v1/banners", a.banners) mux.HandleFunc("GET /api/v1/search", a.search) // Формы и заявки с сайта. mux.HandleFunc("GET /api/v1/forms", a.forms) mux.HandleFunc("POST /api/v1/leads", a.createLead) mux.HandleFunc("POST /api/v1/appointments", a.createAppointment) mux.HandleFunc("POST /api/v1/auth/wait-call/init", a.waitCallInit) mux.HandleFunc("GET /api/v1/auth/wait-call/status", a.waitCallStatus) mux.HandleFunc("POST /api/v1/auth/smsc-callback", a.smscCallback) mux.HandleFunc("GET /api/v1/auth/smsc-callback", a.smscCallback) // --- админка --- mux.HandleFunc("POST /api/v1/admin/login", a.adminLogin) mux.HandleFunc("POST /api/v1/admin/logout", a.adminLogout) mux.HandleFunc("GET /api/v1/admin/me", a.requireAuth(a.adminMe)) mux.HandleFunc("GET /api/v1/admin/services", a.requireAuth(a.adminServices)) mux.HandleFunc("POST /api/v1/admin/services", a.requireAuth(a.adminCreateService)) mux.HandleFunc("PUT /api/v1/admin/services/{id}", a.requireAuth(a.adminUpdateService)) mux.HandleFunc("POST /api/v1/onec/events", a.onecEvents) mux.HandleFunc("GET /api/v1/admin/onec", a.requireAuth(a.adminOnec)) mux.HandleFunc("POST /api/v1/admin/onec/sync", a.requireAuth(a.adminOnecSync)) mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/link", a.requireAuth(a.adminOnecLink)) mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/booking", a.requireAuth(a.adminOnecBooking)) mux.HandleFunc("GET /api/v1/admin/onec/grid", a.requireAuth(a.adminOnecGrid)) mux.HandleFunc("GET /api/v1/admin/onec/appointments", a.requireAuth(a.adminOnecAppointments)) mux.HandleFunc("GET /api/v1/admin/booking-settings", a.requireAuth(a.adminBookingSettings)) mux.HandleFunc("PUT /api/v1/admin/booking-settings", a.requireAuth(a.adminUpdateBookingSettings)) mux.HandleFunc("GET /api/v1/admin/leads", a.requireAuth(a.adminLeads)) mux.HandleFunc("PUT /api/v1/admin/leads/{id}", a.requireAuth(a.adminUpdateLead)) mux.HandleFunc("GET /api/v1/admin/reviews", a.requireAuth(a.adminReviews)) mux.HandleFunc("POST /api/v1/admin/reviews", a.requireAuth(a.adminCreateReview)) mux.HandleFunc("PUT /api/v1/admin/reviews/{id}", a.requireAuth(a.adminUpdateReview)) mux.HandleFunc("DELETE /api/v1/admin/reviews/{id}", a.requireAuth(a.adminDeleteReview)) mux.HandleFunc("GET /api/v1/admin/banners", a.requireAuth(a.adminBanners)) mux.HandleFunc("POST /api/v1/admin/banners", a.requireAuth(a.adminCreateBanner)) mux.HandleFunc("PUT /api/v1/admin/banners/{id}", a.requireAuth(a.adminUpdateBanner)) mux.HandleFunc("DELETE /api/v1/admin/banners/{id}", a.requireAuth(a.adminDeleteBanner)) mux.HandleFunc("POST /api/v1/admin/media", a.requireAuth(a.adminUploadMedia)) mux.HandleFunc("GET /api/v1/admin/doctors", a.requireAuth(a.adminDoctors)) mux.HandleFunc("GET /api/v1/admin/doctors-quality", a.requireAuth(a.adminDoctorQuality)) mux.HandleFunc("POST /api/v1/admin/doctors", a.requireAuth(a.adminCreateDoctor)) mux.HandleFunc("PUT /api/v1/admin/doctors/{id}", a.requireAuth(a.adminUpdateDoctor)) mux.HandleFunc("GET /api/v1/admin/specialties", a.requireAuth(a.adminSpecialties)) mux.HandleFunc("POST /api/v1/admin/specialties", a.requireAuth(a.adminCreateSpecialty)) mux.HandleFunc("PUT /api/v1/admin/specialties/{id}", a.requireAuth(a.adminUpdateSpecialty)) mux.HandleFunc("DELETE /api/v1/admin/specialties/{id}", a.requireAuth(a.adminDeleteSpecialty)) mux.HandleFunc("PUT /api/v1/admin/doctors/{id}/links", a.requireAuth(a.adminSetDoctorLinks)) mux.HandleFunc("GET /api/v1/admin/doctors/{id}/bio", a.requireAuth(a.adminBioEntries)) mux.HandleFunc("POST /api/v1/admin/bio-entries", a.requireAuth(a.adminCreateBioEntry)) mux.HandleFunc("PUT /api/v1/admin/bio-entries/{id}", a.requireAuth(a.adminUpdateBioEntry)) mux.HandleFunc("DELETE /api/v1/admin/bio-entries/{id}", a.requireAuth(a.adminDeleteBioEntry)) mux.HandleFunc("GET /api/v1/admin/price-sections", a.requireAuth(a.adminPriceSections)) mux.HandleFunc("GET /api/v1/admin/price-items", a.requireAuth(a.adminPriceItems)) mux.HandleFunc("POST /api/v1/admin/price-items", a.requireAuth(a.adminCreatePriceItem)) mux.HandleFunc("PUT /api/v1/admin/price-items/{id}", a.requireAuth(a.adminUpdatePriceItem)) mux.HandleFunc("DELETE /api/v1/admin/price-items/{id}", a.requireAuth(a.adminDeletePriceItem)) mux.HandleFunc("GET /api/v1/admin/promos", a.requireAuth(a.adminPromos)) mux.HandleFunc("POST /api/v1/admin/promos", a.requireAuth(a.adminCreatePromo)) mux.HandleFunc("PUT /api/v1/admin/promos/{id}", a.requireAuth(a.adminUpdatePromo)) mux.HandleFunc("DELETE /api/v1/admin/promos/{id}", a.requireAuth(a.adminDeletePromo)) mux.HandleFunc("GET /api/v1/admin/checkups", a.requireAuth(a.adminCheckups)) mux.HandleFunc("POST /api/v1/admin/checkups", a.requireAuth(a.adminCreateCheckup)) mux.HandleFunc("PUT /api/v1/admin/checkups/{id}", a.requireAuth(a.adminUpdateCheckup)) // Новости (/admin/news) и статьи (/admin/articles) — одинаковые обработчики с разным видом записи. for section, kind := range postKinds { base := "/api/v1/admin/" + section mux.HandleFunc("GET "+base, a.requireAuth(a.adminPosts(kind))) mux.HandleFunc("POST "+base, a.requireAuth(a.adminCreatePost(kind))) mux.HandleFunc("PUT "+base+"/{id}", a.requireAuth(a.adminUpdatePost(kind))) mux.HandleFunc("DELETE "+base+"/{id}", a.requireAuth(a.adminDeletePost(kind))) } mux.HandleFunc("GET /api/v1/admin/vacancies", a.requireAuth(a.adminVacancies)) mux.HandleFunc("POST /api/v1/admin/vacancies", a.requireAuth(a.adminCreateVacancy)) mux.HandleFunc("PUT /api/v1/admin/vacancies/{id}", a.requireAuth(a.adminUpdateVacancy)) mux.HandleFunc("DELETE /api/v1/admin/vacancies/{id}", a.requireAuth(a.adminDeleteVacancy)) mux.HandleFunc("GET /api/v1/admin/documents", a.requireAuth(a.adminDocuments)) mux.HandleFunc("POST /api/v1/admin/documents", a.requireAuth(a.adminCreateDocument)) mux.HandleFunc("PUT /api/v1/admin/documents/{id}", a.requireAuth(a.adminUpdateDocument)) mux.HandleFunc("DELETE /api/v1/admin/documents/{id}", a.requireAuth(a.adminDeleteDocument)) mux.HandleFunc("GET /api/v1/admin/partners", a.requireAuth(a.adminPartners)) mux.HandleFunc("POST /api/v1/admin/partners", a.requireAuth(a.adminCreatePartner)) mux.HandleFunc("PUT /api/v1/admin/partners/{id}", a.requireAuth(a.adminUpdatePartner)) mux.HandleFunc("DELETE /api/v1/admin/partners/{id}", a.requireAuth(a.adminDeletePartner)) mux.HandleFunc("GET /api/v1/admin/dashboard", a.requireAuth(a.adminDashboard)) mux.HandleFunc("GET /api/v1/admin/stats", a.requireAuth(a.adminStats)) mux.HandleFunc("GET /api/v1/admin/clinics", a.requireAuth(a.adminClinics)) mux.HandleFunc("GET /api/v1/admin/share-link", a.requireAuth(a.adminShareLink)) mux.HandleFunc("POST /api/v1/admin/share-link", a.requireAuth(a.adminCreateShareLink)) mux.HandleFunc("DELETE /api/v1/admin/share-link", a.requireAuth(a.adminDeleteShareLink)) mux.HandleFunc("GET /api/v1/share/weekly-reports", a.shareWeeklyReports) mux.HandleFunc("GET /api/v1/admin/pbx/status", a.requireAuth(a.adminPBXStatus)) mux.HandleFunc("POST /api/v1/admin/pbx/check", a.requireAuth(a.adminPBXCheck)) mux.HandleFunc("POST /api/v1/admin/pbx/sync", a.requireAuth(a.adminPBXSync)) mux.HandleFunc("GET /api/v1/admin/pbx/lines", a.requireAuth(a.adminPBXLines)) mux.HandleFunc("PUT /api/v1/admin/pbx/lines", a.requireAuth(a.adminSetPBXLine)) mux.HandleFunc("GET /api/v1/admin/pbx/report", a.requireAuth(a.adminPBXReport)) mux.HandleFunc("GET /api/v1/admin/reports", a.requireAuth(a.adminReports)) mux.HandleFunc("POST /api/v1/admin/reports", a.requireAuth(a.adminCreateReport)) mux.HandleFunc("POST /api/v1/admin/reports/period", a.requireAuth(a.adminReportPeriod)) mux.HandleFunc("GET /api/v1/admin/reports/{id}", a.requireAuth(a.adminReport)) mux.HandleFunc("POST /api/v1/admin/reports/{id}/cancel", a.requireAuth(a.adminCancelReport)) mux.HandleFunc("DELETE /api/v1/admin/reports/{id}", a.requireAuth(a.adminDeleteReport)) mux.HandleFunc("GET /api/v1/admin/reports/{id}/download", a.requireAuth(a.adminDownloadReport)) mux.HandleFunc("GET /api/v1/admin/weekly-reports", a.requireAuth(a.adminWeeklyReports)) mux.HandleFunc("DELETE /api/v1/admin/weekly-reports/{id}", a.requireAuth(a.adminDeleteWeeklyReport)) mux.HandleFunc("PUT /api/v1/admin/weekly-reports/{id}/budget", a.requireAuth(a.adminSetWeeklyBudget)) mux.HandleFunc("GET /api/v1/admin/redirects", a.requireAuth(a.adminRedirects)) mux.HandleFunc("POST /api/v1/admin/redirects", a.requireAuth(a.adminCreateRedirect)) mux.HandleFunc("DELETE /api/v1/admin/redirects/{id}", a.requireAuth(a.adminDeleteRedirect)) mux.HandleFunc("DELETE /api/v1/admin/not-found", a.requireAuth(a.adminDeleteNotFound)) mux.HandleFunc("GET /api/v1/admin/pages", a.requireAuth(a.adminPages)) mux.HandleFunc("PUT /api/v1/admin/pages/{id}", a.requireAuth(a.adminUpdatePage)) mux.HandleFunc("GET /api/v1/admin/settings", a.requireAuth(a.adminSettings)) mux.HandleFunc("PUT /api/v1/admin/settings", a.requireAuth(a.adminUpdateSettings)) mux.HandleFunc("GET /api/v1/admin/branches", a.requireAuth(a.adminBranches)) mux.HandleFunc("POST /api/v1/admin/branches", a.requireAuth(a.adminCreateBranch)) mux.HandleFunc("PUT /api/v1/admin/branches/{id}", a.requireAuth(a.adminUpdateBranch)) mux.HandleFunc("GET /api/v1/admin/faq", a.requireAuth(a.adminFAQ)) mux.HandleFunc("POST /api/v1/admin/faq", a.requireAuth(a.adminCreateFAQ)) mux.HandleFunc("PUT /api/v1/admin/faq/{id}", a.requireAuth(a.adminUpdateFAQ)) mux.HandleFunc("DELETE /api/v1/admin/faq/{id}", a.requireAuth(a.adminDeleteFAQ)) // Интерфейс админки (встроен в бинарник). mux.Handle("GET /admin/", http.StripPrefix("/admin/", http.FileServer(http.FS(adminFS())))) mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/", http.StatusFound) }) // Раздача загруженных картинок: /media/... -> файлы из mediaDir. if a.mediaDir != "" { mux.Handle("GET /media/", mediaHandler(a.mediaDir)) } return logging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() // Заголовки безопасности на все ответы. CSP только frame-ancestors — // защита от кликджекинга без риска сломать SPA (скрипты/стили не трогаем). h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "SAMEORIGIN") // Безопасные директивы, не ломающие SPA: запрет плагинов, чужого , // отправки форм на сторонние адреса и встраивания сайта в чужие фреймы. // Полный script-src/style-src добавляется отдельно (сначала в Report-Only). h.Set("Content-Security-Policy", "frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'") h.Set("Referrer-Policy", "strict-origin-when-cross-origin") h.Set("Strict-Transport-Security", "max-age=31536000") // браузер учитывает только по HTTPS if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/admin" || strings.HasPrefix(r.URL.Path, "/admin/") { h.Set("X-Robots-Tag", "noindex, nofollow") } mux.ServeHTTP(w, r) })) } type envelope struct { Data any `json:"data"` Meta map[string]any `json:"meta,omitempty"` } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(payload); err != nil { slog.Error("encode response", "err", err) } } func ok(w http.ResponseWriter, data any, meta map[string]any) { writeJSON(w, http.StatusOK, envelope{Data: emptyIfNil(data), Meta: meta}) } // emptyIfNil превращает nil-срез в пустой: Go кодирует его как null, и фронт // падал бы на пустой выборке (поиск без результатов, врачи без отзывов). func emptyIfNil(data any) any { v := reflect.ValueOf(data) if v.Kind() == reflect.Slice && v.IsNil() { return reflect.MakeSlice(v.Type(), 0, 0).Interface() } return data } type apiError struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } func fail(w http.ResponseWriter, status int, code, message string) { var e apiError e.Error.Code = code e.Error.Message = message writeJSON(w, status, e) } // handleErr переводит ошибки хранилища в ответ API. func handleErr(w http.ResponseWriter, err error) { if errors.Is(err, storage.ErrNotFound) { fail(w, http.StatusNotFound, "not_found", "Не найдено") return } if errors.Is(err, storage.ErrBadReference) { fail(w, http.StatusUnprocessableEntity, "bad_reference", "Связанная запись не найдена, обновите страницу") return } if errors.Is(err, storage.ErrConflict) { fail(w, http.StatusConflict, "conflict", "Такой адрес уже занят другой записью") return } slog.Error("request failed", "err", err) fail(w, http.StatusInternalServerError, "internal", "Внутренняя ошибка") } func logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slog.Debug("request", "method", r.Method, "path", r.URL.Path) next.ServeHTTP(w, r) }) }