401 lines
11 KiB
Go
401 lines
11 KiB
Go
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("<div id=\"root\">")) || !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)
|
||
}
|