436 lines
13 KiB
Go
436 lines
13 KiB
Go
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)
|
||
}
|