package api import ( "bytes" "context" "crypto/sha256" "encoding/json" "errors" "fmt" "log/slog" "net/http" "os" "os/exec" "path/filepath" "strings" "sync" "time" "dana/internal/domain" "dana/internal/storage" ) type renderCacheEntry struct { hash [32]byte html []byte } type pageRenderer struct { script, node, bundle string slots chan struct{} mu sync.Mutex cache map[string]renderCacheEntry cacheBytes int } // WithSSR enables current-data HTML rendering without a new frontend framework. // Setting SSR_RENDERER=off explicitly selects the build-time snapshot mode. func (a *API) WithSSR(script, node string) error { if script == "" || script == "off" || a.website == nil { return nil } script, err := filepath.Abs(script) if err != nil { return err } if _, err := os.Stat(script); err != nil { return fmt.Errorf("SSR renderer: %w", err) } bundle := filepath.Join(filepath.Dir(filepath.Dir(script)), ".ssr", "entry-server.js") if _, err := os.Stat(bundle); err != nil { return fmt.Errorf("SSR bundle is missing; run npm run build: %w", err) } binary, err := exec.LookPath(node) if err != nil { return fmt.Errorf("Node.js is required for live SSR (or set SSR_RENDERER=off): %w", err) } a.website.renderer = &pageRenderer{script: script, node: binary, bundle: bundle, slots: make(chan struct{}, 2), cache: map[string]renderCacheEntry{}} slog.Info("current-data HTML rendering enabled") return nil } type publicRenderRequest struct { URL string `json:"url"` SiteURL string `json:"siteURL"` Template string `json:"template"` Common map[string]any `json:"common"` Initial map[string]any `json:"initial"` // Source code timestamps are also part of the cache fingerprint. RendererVersion int64 `json:"rendererVersion"` BundleVersion int64 `json:"bundleVersion"` } func doctorWebPath(d domain.Doctor) string { section := "vrachi" if len(d.Specialties) > 0 { section = d.Specialties[0].Slug } return "/company/staff/" + section + "/" + d.Slug + "/" } func (a *API) prepareSSR(r *http.Request, path string, svc *domain.Service) (*publicRenderRequest, error) { ctx := r.Context() branches, err := a.store.Branches(ctx) if err != nil { return nil, err } settings, err := a.store.Settings(ctx) if err != nil { return nil, err } specialties, err := a.store.Specialties(ctx) if err != nil { return nil, err } doctors, err := a.store.Doctors(ctx, storage.DoctorFilter{}) if err != nil { return nil, err } template, err := os.ReadFile(filepath.Join(a.website.dir, "index.html")) if err != nil { return nil, err } input := &publicRenderRequest{URL: path, SiteURL: a.website.siteURL, Template: string(template), Common: map[string]any{"branches": emptyIfNil(branches), "settings": settings}, Initial: map[string]any{}} if r.URL.RawQuery != "" { input.URL += "?" + r.URL.RawQuery } set := func(key string, data any) { input.Initial[key] = envelope{Data: emptyIfNil(data)} } set("specialties", specialties) set("doctors", doctors) parts := strings.Split(strings.Trim(path, "/"), "/") switch { case svc != nil: set("service:"+svc.Path, svc) prices, err := a.store.PriceList(ctx, "") if err != nil { return nil, err } set("prices", prices) if i := strings.LastIndex(svc.Path, "/"); i > 0 { parent, err := a.store.ServiceByPath(ctx, svc.Path[:i]) if err != nil { return nil, err } set("service:"+parent.Path, parent) } case path == "/" || path == "/services/": tree, err := a.store.ServiceTree(ctx) if err != nil { return nil, err } set("services", tree) if path == "/" { banners, err := a.store.Banners(ctx, "home_hero") if err != nil { return nil, err } set("home:banners", banners) } case parts[0] == "company" && len(parts) >= 2 && parts[1] == "staff": if len(parts) == 4 { doctor, err := a.store.DoctorBySlug(ctx, parts[3]) if err != nil { return nil, err } set("doctor:"+doctor.Slug, doctor) reviews, err := a.store.Reviews(ctx, doctor.Slug, 50) if err != nil { return nil, err } set("reviews:"+doctor.Slug, reviews) } else { section := "" if len(parts) == 3 { section = parts[2] } q := r.URL.Query().Get("q") list, err := a.store.Doctors(ctx, storage.DoctorFilter{Specialty: section, Query: q}) if err != nil { return nil, err } set("doctor-list:"+section+":"+q, list) } case parts[0] == "price": services, err := a.store.ServiceTree(ctx) if err != nil { return nil, err } set("services", services) tree, err := a.store.PriceList(ctx, "") if err != nil { return nil, err } set("prices", tree) case postKinds[parts[0]] != "": section := parts[0] if len(parts) == 2 { post, err := a.store.PostBySlug(ctx, postKinds[section], parts[1]) if err != nil { return nil, err } set(section+":"+post.Slug, post) } else { if err := a.setSectionPage(ctx, set, section); err != nil { return nil, err } posts, err := a.store.Posts(ctx, postKinds[section], 100) if err != nil { return nil, err } set(section, posts) } case path == "/company/" || path == "/company/partners/" || path == "/company/nadzor/" || path == "/company/vacancy/" || path == "/info/faq/" || path == "/reviews/" || path == "/company/licenses/": if err := a.prepareCompanySSR(ctx, path, set); err != nil { return nil, err } case path == "/patients/": if err := a.setSectionPage(ctx, set, "patients"); err != nil { return nil, err } case parts[0] == "legaldocs": slug := "legaldocs" if len(parts) == 2 { slug = parts[1] } page, err := a.store.PageBySlug(ctx, slug) if err != nil { return nil, err } set("page:"+slug, page) case parts[0] == "checkup" || path == "/sales/": if parts[0] == "checkup" && len(parts) == 2 { checkup, err := a.store.CheckupBySlug(ctx, parts[1]) if err != nil { return nil, err } set("checkup:"+checkup.Slug, checkup) } else { list, err := a.store.Checkups(ctx, "") if err != nil { return nil, err } set("checkups", list) if path == "/sales/" { promos, err := a.store.Posts(ctx, "promo", 20) if err != nil { return nil, err } set("promos", promos) } } } if stat, err := os.Stat(a.website.renderer.script); err == nil { input.RendererVersion = stat.ModTime().UnixNano() } if stat, err := os.Stat(a.website.renderer.bundle); err == nil { input.BundleVersion = stat.ModTime().UnixNano() } return input, nil } // setSectionPage кладёт в данные страницы заголовок, SEO и вводный текст раздела из pages. func (a *API) setSectionPage(ctx context.Context, set func(string, any), slug string) error { page, err := a.store.PageBySlug(ctx, slug) if errors.Is(err, storage.ErrNotFound) { return nil // раздел работает и без записи в pages — со своим заголовком } if err != nil { return err } set("page:"+slug, page) return nil } func (a *API) prepareCompanySSR(ctx context.Context, path string, set func(string, any)) error { var ( slug string key string data any err error ) switch path { case "/company/": slug, key = "about", "services" data, err = a.store.ServiceTree(ctx) case "/company/partners/": slug, key = "partners", "partners" data, err = a.store.Partners(ctx) case "/company/licenses/": slug, key = "licenses", "documents:" data, err = a.store.Documents(ctx, "") case "/company/nadzor/": slug, key = "nadzor", "documents:supervisor" data, err = a.store.Documents(ctx, "supervisor") case "/company/vacancy/": slug, key = "vacancy", "vacancies" data, err = a.store.Vacancies(ctx) case "/info/faq/": slug, key = "faq", "faq" data, err = a.store.FAQ(ctx, "") case "/reviews/": slug, key = "reviews", "reviews:all" data, err = a.store.Reviews(ctx, "", 100) } if err != nil { return err } set(key, data) return a.setSectionPage(ctx, set, slug) } type cappedBuffer struct { bytes.Buffer limit int overflow bool } func (b *cappedBuffer) Write(p []byte) (int, error) { n := len(p) remaining := b.limit - b.Len() if len(p) > remaining { b.overflow = true p = p[:remaining] } _, _ = b.Buffer.Write(p) return n, nil } func (p *pageRenderer) render(ctx context.Context, key string, request []byte) ([]byte, bool, error) { hash := sha256.Sum256(request) p.mu.Lock() cached, exists := p.cache[key] p.mu.Unlock() if exists && cached.hash == hash { return cached.html, true, nil } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() select { case p.slots <- struct{}{}: defer func() { <-p.slots }() case <-ctx.Done(): return nil, false, ctx.Err() } // Another request for the same version might have completed while waiting. p.mu.Lock() cached, exists = p.cache[key] p.mu.Unlock() if exists && cached.hash == hash { return cached.html, true, nil } command := exec.CommandContext(ctx, p.node, p.script) command.Dir = filepath.Dir(p.script) command.Stdin = bytes.NewReader(request) out := &cappedBuffer{limit: 4 << 20} stderr := &cappedBuffer{limit: 8 << 10} command.Stdout, command.Stderr = out, stderr if err := command.Run(); err != nil { return nil, false, fmt.Errorf("HTML renderer: %w: %s", err, stderr.String()) } if out.overflow || !bytes.Contains(out.Bytes(), []byte("
")) || !bytes.Contains(out.Bytes(), []byte("page-data")) { return nil, false, errors.New("HTML renderer returned incomplete or oversized output") } html := out.Bytes() // Query variants are rendered correctly but never displace canonical cache entries. if !strings.Contains(key, "?") && len(html) <= 1<<20 { p.mu.Lock() if previous, ok := p.cache[key]; ok { p.cacheBytes -= len(previous.html) delete(p.cache, key) } for len(p.cache) >= 64 || p.cacheBytes+len(html) > 16<<20 { for oldKey, old := range p.cache { p.cacheBytes -= len(old.html) delete(p.cache, oldKey) break } } p.cache[key] = renderCacheEntry{hash: hash, html: html} p.cacheBytes += len(html) p.mu.Unlock() } return html, false, nil } func (a *API) serveSSR(w http.ResponseWriter, r *http.Request, path string, svc *domain.Service) { input, err := a.prepareSSR(r, path, svc) if errors.Is(err, storage.ErrNotFound) { a.website404(w, r) return } if err != nil { handleErr(w, err) return } data, err := json.Marshal(input) if err != nil { handleErr(w, err) return } html, hit, err := a.website.renderer.render(r.Context(), input.URL, data) if err != nil { slog.Error("render website HTML", "path", path, "err", err) w.Header().Set("X-Robots-Tag", "noindex") w.Header().Set("Retry-After", "5") http.Error(w, "HTML страницы временно недоступен", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") if r.URL.RawQuery != "" { w.Header().Set("X-Robots-Tag", "noindex, follow") } if hit { w.Header().Set("X-HTML-Cache", "hit") } else { w.Header().Set("X-HTML-Cache", "miss") } _, _ = w.Write(html) }