175 lines
5.9 KiB
Go
175 lines
5.9 KiB
Go
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, ""), " ", ""))
|
||
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)
|
||
}
|
||
}
|