package api import ( "encoding/json" "encoding/xml" "errors" "fmt" "log/slog" "net/http" "net/url" "os" "path/filepath" "strings" "dana/internal/domain" "dana/internal/storage" ) type website struct { dir string siteURL string services map[string]bool local bool renderer *pageRenderer } // WithWebsite serves build-time rendered React HTML through Go. API-only startup // is allowed before the first web build, so the generator can read the API. func (a *API) WithWebsite(dir, siteURL string) error { if dir == "" { return nil } abs, err := filepath.Abs(dir) if err != nil { return err } if _, err := os.Stat(filepath.Join(abs, "index.html")); errors.Is(err, os.ErrNotExist) { slog.Warn("web build not found; API-only startup", "dir", abs) return nil } else if err != nil { return err } w := &website{dir: abs, services: map[string]bool{}} var manifest struct { SiteURL string `json:"siteURL"` Services []string `json:"services"` } data, err := os.ReadFile(filepath.Join(abs, "seo-manifest.json")) if err == nil { if err := json.Unmarshal(data, &manifest); err != nil { return fmt.Errorf("SEO manifest: %w", err) } for _, service := range manifest.Services { w.services[service] = true } } else if !errors.Is(err, os.ErrNotExist) { return err } siteURL = strings.TrimRight(siteURL, "/") if siteURL == "" { siteURL = manifest.SiteURL } if siteURL == "" { siteURL = "http://127.0.0.1:8080" } if manifest.SiteURL != "" && manifest.SiteURL != siteURL { return fmt.Errorf("SITE_URL differs from the rendered canonical origin; rebuild web with SITE_URL=%s", siteURL) } u, err := url.Parse(siteURL) if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { return fmt.Errorf("SITE_URL must be an HTTP(S) origin") } w.siteURL = siteURL w.local = u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" a.website = w return nil } var rootFiles = map[string]bool{"/favicon.ico": true, "/apple-touch-icon.png": true} func canonicalPagePath(path string) string { if path == "/" { return path } return strings.TrimRight(path, "/") + "/" } func (a *API) websitePage(w http.ResponseWriter, r *http.Request) { site := a.website if site.local { w.Header().Set("X-Robots-Tag", "noindex, nofollow") } // Иконки сайта лежат в корне сборки (web/public): браузеры просят их без ссылок из HTML. if rootFiles[r.URL.Path] { file := filepath.Join(site.dir, strings.TrimPrefix(r.URL.Path, "/")) if stat, err := os.Stat(file); err == nil && stat.Mode().IsRegular() { w.Header().Set("Cache-Control", "public, max-age=604800") http.ServeFile(w, r, file) return } } if strings.HasPrefix(r.URL.Path, "/assets/") { file := filepath.Join(site.dir, filepath.FromSlash(strings.TrimPrefix(r.URL.Path, "/"))) rel, err := filepath.Rel(site.dir, file) if err != nil || strings.HasPrefix(rel, "..") { http.NotFound(w, r) return } if stat, err := os.Stat(file); err != nil || !stat.Mode().IsRegular() { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") http.ServeFile(w, r, file) return } switch strings.TrimRight(r.URL.Path, "/") { case "/company/docs", "/company/requisites": http.Redirect(w, r, "/legaldocs/", http.StatusMovedPermanently) return case "/company/history": http.Redirect(w, r, "/company/", http.StatusMovedPermanently) return } if r.URL.Path == "/privacy" || r.URL.Path == "/privacy/" || r.URL.Path == "/include/licenses_detail.php" { http.Redirect(w, r, "/legaldocs/privacy/", http.StatusMovedPermanently) return } pagePath := canonicalPagePath(r.URL.Path) if strings.HasPrefix(pagePath, "/services/") && pagePath != "/services/" { servicePath := strings.TrimSuffix(strings.TrimPrefix(pagePath, "/services/"), "/") svc, err := a.store.ServiceByPath(r.Context(), servicePath) if errors.Is(err, storage.ErrNotFound) { a.notFound(w, r, pagePath, strings.Split(strings.Trim(pagePath, "/"), "/")) return } if err != nil { handleErr(w, err) return } if !site.services[servicePath] && site.renderer == nil { w.Header().Set("X-Robots-Tag", "noindex") http.Error(w, "Страница ожидает сборки HTML. Выполните npm run build в web.", http.StatusServiceUnavailable) return } if redirectCanonical(w, r, pagePath) { return } if site.renderer != nil { a.serveSSR(w, r, pagePath, svc) return } file := filepath.Join(site.dir, "services", filepath.FromSlash(servicePath), "index.html") if _, err := os.Stat(file); err != nil { http.Error(w, "HTML страницы недоступен", http.StatusServiceUnavailable) return } w.Header().Set("Cache-Control", "no-cache") http.ServeFile(w, r, file) return } // Only implemented routes get the SPA shell. Unknown routes and unfinished // legacy sections return a real 404, even though React renders the design. known := map[string]bool{"/": true, "/services/": true, "/company/staff/": true, "/price/": true, "/checkup/": true, "/sales/": true, "/contacts/": true, "/legaldocs/": true, "/legaldocs/privacy/": true, "/legaldocs/consent/": true, "/legaldocs/cookie/": true} for _, p := range companyPaths { known[p] = true } parts := strings.Split(strings.Trim(pagePath, "/"), "/") if !known[pagePath] { valid, err := a.knownContentRoute(r, parts) if err != nil && !errors.Is(err, storage.ErrNotFound) { handleErr(w, err) return } if !valid { a.notFound(w, r, pagePath, parts) return } } if len(parts) == 4 && parts[0] == "company" && parts[1] == "staff" { doctor, err := a.store.DoctorBySlug(r.Context(), parts[3]) if err != nil { handleErr(w, err) return } canonical := doctorWebPath(*doctor) if redirectCanonical(w, r, canonical) { return } } // У вакансии нет своей страницы: старый адрес открывает её в общем списке. if len(parts) == 3 && parts[0] == "company" && parts[1] == "vacancy" { http.Redirect(w, r, "/company/vacancy/#"+url.PathEscape(parts[2]), http.StatusMovedPermanently) return } if len(parts) == 2 && parts[0] == "sales" { http.Redirect(w, r, "/sales/", http.StatusMovedPermanently) return } if redirectCanonical(w, r, pagePath) { return } if site.renderer != nil { a.serveSSR(w, r, pagePath, nil) return } w.Header().Set("Cache-Control", "no-cache") http.ServeFile(w, r, filepath.Join(site.dir, "index.html")) } func redirectCanonical(w http.ResponseWriter, r *http.Request, path string) bool { if r.URL.Path == path { return false } if r.URL.RawQuery != "" { path += "?" + r.URL.RawQuery } http.Redirect(w, r, path, http.StatusMovedPermanently) return true } // Разделы «Компании» с постоянными адресами старого сайта. var companyPaths = []string{"/company/", "/news/", "/articles/", "/reviews/", "/company/partners/", "/company/nadzor/", "/company/vacancy/", "/info/faq/", "/company/licenses/"} // postKinds: раздел сайта → вид публикации в базе. var postKinds = map[string]string{"news": "news", "articles": "article"} func (a *API) knownContentRoute(r *http.Request, p []string) (bool, error) { // «Пациенту» открывается, только когда клиника включила страницу в админке if len(p) == 1 && p[0] == "patients" { _, err := a.store.PageBySlug(r.Context(), "patients") return err == nil, err } if len(p) == 2 && postKinds[p[0]] != "" { _, err := a.store.PostBySlug(r.Context(), postKinds[p[0]], p[1]) return err == nil, err } if len(p) == 3 && p[0] == "company" && p[1] == "vacancy" { list, err := a.store.Vacancies(r.Context()) for _, v := range list { if v.Slug == p[2] { return true, err } } return false, err } if len(p) == 2 && p[0] == "checkup" { _, err := a.store.CheckupBySlug(r.Context(), p[1]) return err == nil, err } if len(p) == 2 && p[0] == "sales" { _, err := a.store.PostBySlug(r.Context(), "promo", p[1]) return err == nil, err } if len(p) == 2 && p[0] == "price" { tree, err := a.store.PriceList(r.Context(), "") if err != nil { return false, err } var contains func([]*domain.PriceSection) bool contains = func(list []*domain.PriceSection) bool { for _, s := range list { if (s.Slug == p[1] && len(s.Items) > 0) || contains(s.Children) { return true } } return false } return contains(tree), nil } if len(p) >= 3 && p[0] == "company" && p[1] == "staff" { if len(p) == 3 { list, err := a.store.Specialties(r.Context()) for _, s := range list { if s.Slug == p[2] { return true, err } } return false, err } if len(p) == 4 { doctor, err := a.store.DoctorBySlug(r.Context(), p[3]) if err != nil { return false, err } if len(doctor.Specialties) == 0 && p[2] == "vrachi" { return true, nil } for _, s := range doctor.Specialties { if s.Slug == p[2] { return true, nil } } return false, nil } } return false, nil } func (a *API) website404(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Robots-Tag", "noindex") w.Header().Set("Content-Type", "text/html; charset=utf-8") data, err := os.ReadFile(filepath.Join(a.website.dir, "index.html")) if err != nil { http.NotFound(w, r) return } w.WriteHeader(http.StatusNotFound) _, _ = w.Write(data) } func (a *API) robots(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") if a.website.local { _, _ = fmt.Fprint(w, "User-agent: *\nDisallow: /\n") return } // Query variants can be crawled to see their noindex response and canonical. _, _ = fmt.Fprintf(w, "User-agent: *\nDisallow: /admin\nDisallow: /api/\nSitemap: %s/sitemap.xml\n", a.website.siteURL) } func (a *API) sitemap(w http.ResponseWriter, r *http.Request) { tree, err := a.store.ServiceTree(r.Context()) if err != nil { handleErr(w, err) return } type location struct { URL string `xml:"loc"` } set := struct { XMLName xml.Name `xml:"urlset"` Namespace string `xml:"xmlns,attr"` URLs []location `xml:"url"` }{Namespace: "http://www.sitemaps.org/schemas/sitemap/0.9"} seen := map[string]bool{} addPath := func(path string) { if !seen[path] { seen[path] = true set.URLs = append(set.URLs, location{a.website.siteURL + path}) } } for _, p := range []string{"/", "/services/", "/company/staff/", "/price/", "/checkup/", "/sales/", "/contacts/"} { addPath(p) } for _, p := range companyPaths { addPath(p) } if _, err := a.store.PageBySlug(r.Context(), "patients"); err == nil { addPath("/patients/") } for _, section := range []string{"news", "articles"} { posts, err := a.store.Posts(r.Context(), postKinds[section], 100) if err != nil { handleErr(w, err) return } for _, p := range posts { addPath("/" + section + "/" + p.Slug + "/") } } var add func([]*domain.Service) add = func(list []*domain.Service) { for _, s := range list { if a.website.renderer != nil { addPath("/services/" + s.Path + "/") } else if a.website.services[s.Path] { if _, err := os.Stat(filepath.Join(a.website.dir, "services", filepath.FromSlash(s.Path), "index.html")); err == nil { addPath("/services/" + s.Path + "/") } } add(s.Children) } } add(tree) if a.website.renderer != nil { doctors, err := a.store.Doctors(r.Context(), storage.DoctorFilter{}) if err != nil { handleErr(w, err) return } for _, d := range doctors { addPath(doctorWebPath(d)) for _, s := range d.Specialties { addPath("/company/staff/" + s.Slug + "/") } } checkups, err := a.store.Checkups(r.Context(), "") if err != nil { handleErr(w, err) return } for _, c := range checkups { addPath("/checkup/" + c.Slug + "/") } prices, err := a.store.PriceList(r.Context(), "") if err != nil { handleErr(w, err) return } var addPrices func([]*domain.PriceSection) addPrices = func(list []*domain.PriceSection) { for _, s := range list { if len(s.Items) > 0 { addPath("/price/" + s.Slug + "/") } addPrices(s.Children) } } addPrices(prices) for _, slug := range []string{"legaldocs", "privacy", "consent", "cookie"} { _, err := a.store.PageBySlug(r.Context(), slug) if errors.Is(err, storage.ErrNotFound) { continue } if err != nil { handleErr(w, err) return } path := "/legaldocs/" if slug != "legaldocs" { path += slug + "/" } addPath(path) } } else { addPath("/legaldocs/") } w.Header().Set("Content-Type", "application/xml; charset=utf-8") _, _ = fmt.Fprint(w, xml.Header) _ = xml.NewEncoder(w).Encode(set) }