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

175 lines
5.9 KiB
Go
Raw Permalink 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"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"dana/internal/storage"
)
// Новости и статьи редактируются в админке одинаково; различаются только видом записи.
var postKindLabels = map[string]string{"news": "новость", "article": "статью"}
// authHandler — обработчик админки, которому requireAuth передаёт вошедшего пользователя.
type authHandler = func(http.ResponseWriter, *http.Request, *storage.AdminUser)
const maxPostBody = 2 << 20 // текст статьи с разметкой; картинки грузятся отдельно
var tagRE = regexp.MustCompile(`<[^>]*>`)
type postRequest struct {
Slug string `json:"slug"`
Title string `json:"title"`
Lead string `json:"lead"`
HTML string `json:"html"`
ImageID *int64 `json:"image_id"`
PublishedAt string `json:"published_at"`
Published bool `json:"published"`
SEOTitle string `json:"seo_title"`
SEODesc string `json:"seo_description"`
SEOH1 string `json:"seo_h1"`
}
// parsePublishedAt принимает дату «2025-05-28» (полдень по Москве) или полную метку времени,
// которую админка возвращает без изменений, если дату не трогали.
func parsePublishedAt(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Now(), nil
}
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return t, nil
}
moscow := time.FixedZone("MSK", 3*60*60)
return time.ParseInLocation("2006-01-02 15:04", raw+" 12:00", moscow)
}
func decodePost(w http.ResponseWriter, r *http.Request, kind string) (storage.PostInput, bool) {
var req postRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPostBody)).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос или слишком большой текст")
return storage.PostInput{}, false
}
in := storage.PostInput{
Kind: kind,
Title: strings.TrimSpace(req.Title),
Lead: strings.TrimSpace(req.Lead),
ImageID: req.ImageID,
Published: req.Published,
SEOTitle: strings.TrimSpace(req.SEOTitle),
SEODesc: strings.TrimSpace(req.SEODesc),
SEOH1: strings.TrimSpace(req.SEOH1),
}
if in.Title == "" {
fail(w, http.StatusUnprocessableEntity, "validation", "Укажите заголовок")
return in, false
}
if utf8.RuneCountInString(in.Title) > 300 {
fail(w, http.StatusUnprocessableEntity, "validation", "Заголовок длиннее 300 символов")
return in, false
}
slug, valid := resolveSlug(w, req.Slug, in.Title)
if !valid {
return in, false
}
in.Slug = slug
html := strings.TrimSpace(req.HTML)
text := strings.TrimSpace(strings.ReplaceAll(tagRE.ReplaceAllString(html, ""), "&nbsp;", ""))
if in.Published && text == "" && !strings.Contains(html, "<img") {
fail(w, http.StatusUnprocessableEntity, "validation", "Чтобы опубликовать "+postKindLabels[kind]+", добавьте текст")
return in, false
}
// Разметка хранится как есть: сайт пропускает её через белый список тегов при выводе.
blocks, _ := json.Marshal([]map[string]string{{"type": "html", "html": html}})
if html == "" {
blocks = []byte("[]")
}
in.Blocks = blocks
at, err := parsePublishedAt(req.PublishedAt)
if err != nil {
fail(w, http.StatusUnprocessableEntity, "validation", "Дата публикации в формате ГГГГ-ММ-ДД")
return in, false
}
in.PublishedAt = at
return in, true
}
func (a *API) adminPosts(kind string) authHandler {
return func(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
list, err := a.store.AdminPosts(r.Context(), kind)
if err != nil {
handleErr(w, err)
return
}
ok(w, list, map[string]any{"total": len(list)})
}
}
func (a *API) adminCreatePost(kind string) authHandler {
return func(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
in, valid := decodePost(w, r, kind)
if !valid {
return
}
id, err := a.store.AdminCreatePost(r.Context(), in)
if err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "create", kind, id, clientIP(r))
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "slug": in.Slug}})
}
}
func (a *API) adminUpdatePost(kind string) authHandler {
return func(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
return
}
in, valid := decodePost(w, r, kind)
if !valid {
return
}
oldSlug, err := a.store.PublishedPostSlug(r.Context(), kind, id)
if err != nil {
handleErr(w, err)
return
}
if err := a.store.AdminUpdatePost(r.Context(), id, in); err != nil {
handleErr(w, err)
return
}
if oldSlug != "" && in.Published {
section := map[string]string{"news": "/news/", "article": "/articles/"}[kind]
a.keepOldPath(r.Context(), section+oldSlug+"/", section+in.Slug+"/")
}
a.store.Audit(r.Context(), u.ID, "update", kind, id, clientIP(r))
ok(w, map[string]any{"id": id, "slug": in.Slug}, nil)
}
}
func (a *API) adminDeletePost(kind string) authHandler {
return func(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
return
}
if err := a.store.AdminDeletePost(r.Context(), kind, id); err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "delete", kind, id, clientIP(r))
ok(w, map[string]any{"id": id}, nil)
}
}