dana-medical-site/internal/api/website.go
2026-10-14 13:33:13 +00:00

436 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}