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, "