880 lines
27 KiB
Go
880 lines
27 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type tokenPayload struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Login string `json:"login"`
|
||
Roles []RoleCode `json:"roles"`
|
||
ExpiresAt int64 `json:"exp"`
|
||
}
|
||
|
||
type apiError struct {
|
||
Error struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
|
||
func normalizeContentType(value string) ContentType {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "research":
|
||
return ContentTypeArticle
|
||
case "announcement":
|
||
return ContentTypeNews
|
||
default:
|
||
return ContentType(strings.TrimSpace(value))
|
||
}
|
||
}
|
||
|
||
func normalizeStatus(value string) ContentStatus {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "draft", "черновик":
|
||
return ContentStatusDraft
|
||
case "moderation", "pending", "на модерации":
|
||
return ContentStatusModeration
|
||
case "review", "на проверке":
|
||
return ContentStatusReview
|
||
case "published", "опубликовано":
|
||
return ContentStatusPublished
|
||
case "returned", "возвращен":
|
||
return ContentStatusReturned
|
||
case "archived", "архив":
|
||
return ContentStatusArchived
|
||
default:
|
||
return ContentStatus(strings.TrimSpace(value))
|
||
}
|
||
}
|
||
|
||
func routeAPI(w http.ResponseWriter, r *http.Request, cfg Config) bool {
|
||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||
if path == "" {
|
||
path = "/"
|
||
}
|
||
|
||
switch cfg.Domain {
|
||
case "auth":
|
||
return handleAuth(w, r, path)
|
||
case "user":
|
||
return handleUser(w, r, path)
|
||
case "content":
|
||
return handleContent(w, r, path)
|
||
case "taxonomy":
|
||
return handleTaxonomy(w, r, path)
|
||
case "speaker":
|
||
return handleSpeaker(w, r, path)
|
||
case "subscription":
|
||
return handleSubscription(w, r, path)
|
||
case "notification":
|
||
return handleNotification(w, r, path)
|
||
case "comment":
|
||
return handleComment(w, r, path)
|
||
case "search":
|
||
return handleSearch(w, r, path)
|
||
case "analytics":
|
||
return handleAnalytics(w, r, path)
|
||
case "audit":
|
||
return handleAudit(w, r, path)
|
||
case "media":
|
||
return handleMedia(w, r, path)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
switch {
|
||
case r.Method == http.MethodPost && path == "/auth/login":
|
||
var payload struct {
|
||
Login string `json:"login"`
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
}
|
||
if !decodeJSON(w, r, &payload) {
|
||
return true
|
||
}
|
||
login := firstNonEmpty(payload.Login, payload.Email)
|
||
if strings.TrimSpace(login) == "" || strings.TrimSpace(payload.Password) == "" {
|
||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль")
|
||
return true
|
||
}
|
||
user, ok, err := backendStore.UserByLogin(r.Context(), login)
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
if !ok {
|
||
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
|
||
return true
|
||
}
|
||
if err := checkPassword(user.PasswordHash, payload.Password); err != nil {
|
||
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
|
||
return true
|
||
}
|
||
token, err := makeToken(user)
|
||
if err != nil {
|
||
writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен")
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": user})
|
||
return true
|
||
case r.Method == http.MethodPost && path == "/auth/register":
|
||
var payload struct {
|
||
Login string `json:"login"`
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
Name string `json:"name"`
|
||
}
|
||
if !decodeJSON(w, r, &payload) {
|
||
return true
|
||
}
|
||
login := firstNonEmpty(payload.Login, payload.Email)
|
||
if strings.TrimSpace(login) == "" || len(payload.Password) < 8 {
|
||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов")
|
||
return true
|
||
}
|
||
name := strings.TrimSpace(payload.Name)
|
||
if name == "" {
|
||
name = "Демо-пользователь"
|
||
}
|
||
user, err := backendStore.AddUser(r.Context(), login, name, payload.Password)
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
token, err := makeToken(user)
|
||
if err != nil {
|
||
writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен")
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": user})
|
||
return true
|
||
case r.Method == http.MethodGet && path == "/auth/me":
|
||
user, ok := requireAuth(w, r)
|
||
if !ok {
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"user": user})
|
||
return true
|
||
case r.Method == http.MethodPost && path == "/auth/logout":
|
||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||
return true
|
||
case r.Method == http.MethodPost && path == "/auth/change-password":
|
||
user, ok := requireAuth(w, r)
|
||
if !ok {
|
||
return true
|
||
}
|
||
var payload struct {
|
||
NextPassword string `json:"nextPassword"`
|
||
}
|
||
if !decodeJSON(w, r, &payload) {
|
||
return true
|
||
}
|
||
if len(payload.NextPassword) < 8 {
|
||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Новый пароль должен быть не короче 8 символов")
|
||
return true
|
||
}
|
||
if err := backendStore.UpdatePassword(r.Context(), user.ID, payload.NextPassword); err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
switch {
|
||
case r.Method == http.MethodGet && (path == "/users" || path == "/admin/users"):
|
||
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
||
return true
|
||
}
|
||
items, err := backendStore.ListUsers(r.Context())
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
case r.Method == http.MethodGet && (path == "/roles" || path == "/admin/roles"):
|
||
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": []map[string]any{
|
||
{"code": RoleAdministrator, "permissions": []string{"*"}},
|
||
{"code": RoleEditor, "permissions": []string{"content:create", "content:update", "comments:moderate"}},
|
||
{"code": RoleManager, "permissions": []string{"content:create", "content:publish", "subscriptions:manage"}},
|
||
{"code": RoleUser, "permissions": []string{"content:read", "comments:create", "subscriptions:create"}},
|
||
}})
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
switch {
|
||
case r.Method == http.MethodGet && path == "/content":
|
||
query := r.URL.Query()
|
||
user, _ := userFromRequest(r)
|
||
items, err := backendStore.ListContent(r.Context(), ContentFilter{
|
||
Term: query.Get("q"),
|
||
Category: query.Get("category"),
|
||
ContentType: string(normalizeContentType(query.Get("type"))),
|
||
SortMode: query.Get("sort"),
|
||
Status: string(normalizeStatus(query.Get("status"))),
|
||
AuthorID: query.Get("authorId"),
|
||
Mine: query.Get("mine") == "true",
|
||
User: user,
|
||
Exclude: query.Get("exclude"),
|
||
Limit: func() int {
|
||
if query.Get("limit") == "" {
|
||
return 0
|
||
}
|
||
var n int
|
||
fmt.Sscanf(query.Get("limit"), "%d", &n)
|
||
return n
|
||
}(),
|
||
})
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
case r.Method == http.MethodGet && path == "/events":
|
||
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyEvents: true, SortMode: "newest"})
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "note": "Event представлен как тип контента до подтверждения отдельной сущности."})
|
||
return true
|
||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/content/"):
|
||
id := strings.TrimPrefix(path, "/content/")
|
||
item, ok, err := backendStore.ContentByID(r.Context(), id)
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
if !ok {
|
||
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"item": item})
|
||
return true
|
||
case r.Method == http.MethodPost && path == "/content":
|
||
user, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager)
|
||
if !ok {
|
||
return true
|
||
}
|
||
var payload ContentItem
|
||
if !decodeJSON(w, r, &payload) {
|
||
return true
|
||
}
|
||
payload.Type = normalizeContentType(string(payload.Type))
|
||
payload.Status = normalizeStatus(string(payload.Status))
|
||
payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt)
|
||
payload.Body = firstNonEmpty(payload.Body, payload.Content)
|
||
if strings.TrimSpace(payload.Title) == "" || payload.Type == "" || strings.TrimSpace(payload.Category) == "" {
|
||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите название, категорию и тип материала")
|
||
return true
|
||
}
|
||
item := ContentItem{
|
||
ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()),
|
||
Title: payload.Title,
|
||
Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"),
|
||
Body: firstNonEmpty(payload.Body, "Демо-описание материала."),
|
||
Type: payload.Type,
|
||
Category: payload.Category,
|
||
Tags: payload.Tags,
|
||
Author: user.Name,
|
||
PublishedAt: time.Now().Format("2006-01-02"),
|
||
Visibility: VisibilityAuthenticated,
|
||
Status: firstStatus(payload.Status, ContentStatusDraft),
|
||
ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"),
|
||
MediaURL: payload.MediaURL,
|
||
MediaKind: payload.MediaKind,
|
||
MimeType: payload.MimeType,
|
||
FileName: payload.FileName,
|
||
FileSize: payload.FileSize,
|
||
ModeratorComment: payload.ModeratorComment,
|
||
ReviewComment: payload.ReviewComment,
|
||
}
|
||
stored, err := backendStore.AddContent(r.Context(), item)
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"item": stored})
|
||
return true
|
||
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"):
|
||
if _, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager); !ok {
|
||
return true
|
||
}
|
||
var payload ContentItem
|
||
if !decodeJSON(w, r, &payload) {
|
||
return true
|
||
}
|
||
payload.Type = normalizeContentType(string(payload.Type))
|
||
payload.Status = normalizeStatus(string(payload.Status))
|
||
payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt)
|
||
payload.Body = firstNonEmpty(payload.Body, payload.Content)
|
||
item, ok, err := backendStore.PatchContent(r.Context(), strings.TrimPrefix(path, "/content/"), payload)
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
if !ok {
|
||
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"item": item})
|
||
return true
|
||
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/content/"):
|
||
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
||
return true
|
||
}
|
||
deleted, err := backendStore.DeleteContent(r.Context(), strings.TrimPrefix(path, "/content/"))
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
if !deleted {
|
||
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handleTaxonomy(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
if r.Method != http.MethodGet {
|
||
return false
|
||
}
|
||
switch path {
|
||
case "/categories":
|
||
items, err := backendStore.ListCategories(r.Context())
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
case "/tags":
|
||
items, err := backendStore.ListTags(r.Context())
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
if r.Method == http.MethodGet && path == "/speakers" {
|
||
items, err := backendStore.ListSpeakers(r.Context())
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
switch {
|
||
case r.Method == http.MethodGet && path == "/media":
|
||
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyMedia: true, SortMode: "newest"})
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/media/files/"):
|
||
file, ok, err := backendStore.FileByID(r.Context(), strings.TrimPrefix(path, "/media/files/"))
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
if !ok {
|
||
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден")
|
||
return true
|
||
}
|
||
w.Header().Set("Content-Type", firstNonEmpty(file.MimeType, "application/octet-stream"))
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", file.Name))
|
||
http.ServeContent(w, r, file.Name, time.Now(), bytes.NewReader(file.Data))
|
||
return true
|
||
case r.Method == http.MethodPost && path == "/media":
|
||
user, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager)
|
||
if !ok {
|
||
return true
|
||
}
|
||
|
||
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
|
||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||
writeAPIError(w, http.StatusBadRequest, "INVALID_MULTIPART", "Не удалось прочитать multipart-запрос")
|
||
return true
|
||
}
|
||
|
||
uploaded, header, err := r.FormFile("file")
|
||
if err != nil {
|
||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Прикрепите файл")
|
||
return true
|
||
}
|
||
defer uploaded.Close()
|
||
|
||
data, err := io.ReadAll(uploaded)
|
||
if err != nil || len(data) == 0 {
|
||
writeAPIError(w, http.StatusBadRequest, "INVALID_FILE", "Файл пустой или поврежден")
|
||
return true
|
||
}
|
||
|
||
mimeType := header.Header.Get("Content-Type")
|
||
if mimeType == "" || mimeType == "application/octet-stream" {
|
||
sample := data
|
||
if len(sample) > 512 {
|
||
sample = sample[:512]
|
||
}
|
||
mimeType = http.DetectContentType(sample)
|
||
}
|
||
|
||
mediaKind := inferMediaKind(mimeType, header.Filename)
|
||
contentType := ContentType(strings.TrimSpace(r.FormValue("type")))
|
||
if contentType == "" {
|
||
contentType = defaultContentTypeForMediaKind(mediaKind)
|
||
}
|
||
if !isValidContentType(contentType) {
|
||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите корректный тип материала")
|
||
return true
|
||
}
|
||
|
||
stored, err := backendStore.AddFile(r.Context(), StoredFile{
|
||
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
|
||
Name: firstNonEmpty(header.Filename, "uploaded-file"),
|
||
MimeType: mimeType,
|
||
Size: int64(len(data)),
|
||
Data: data,
|
||
})
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
|
||
category := strings.TrimSpace(r.FormValue("category"))
|
||
if category == "" {
|
||
category = defaultCategoryForMediaKind(mediaKind)
|
||
}
|
||
item := ContentItem{
|
||
ID: fmt.Sprintf("demo-media-%d", time.Now().UnixNano()),
|
||
Title: firstNonEmpty(r.FormValue("title"), stored.Name),
|
||
Lead: firstNonEmpty(r.FormValue("lead"), "Демонстрационный медиаматериал с загруженным файлом."),
|
||
Body: firstNonEmpty(r.FormValue("body"), "Файл загружен в in-memory demo-хранилище и доступен только до перезапуска сервиса."),
|
||
Type: contentType,
|
||
Category: category,
|
||
Tags: parseTags(r.FormValue("tags")),
|
||
Author: user.Name,
|
||
PublishedAt: time.Now().Format("2006-01-02"),
|
||
Visibility: VisibilityAuthenticated,
|
||
Status: ContentStatusDraft,
|
||
Views: 0,
|
||
ImageTone: "from-university-800 via-slate-700 to-sky-300",
|
||
MediaURL: "/api/media/files/" + stored.ID,
|
||
MediaKind: mediaKind,
|
||
MimeType: stored.MimeType,
|
||
FileName: stored.Name,
|
||
FileSize: stored.Size,
|
||
}
|
||
created, err := backendStore.AddContent(r.Context(), item)
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"item": created})
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func handleSearch(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
if r.Method == http.MethodGet && path == "/search" {
|
||
query := r.URL.Query()
|
||
items, err := backendStore.ListContent(r.Context(), ContentFilter{Term: query.Get("q"), Category: query.Get("category"), ContentType: query.Get("type"), SortMode: query.Get("sort")})
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func handleSubscription(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
if path != "/subscriptions" {
|
||
return false
|
||
}
|
||
user, ok := requireAuth(w, r)
|
||
if !ok {
|
||
return true
|
||
}
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": user.Subscriptions})
|
||
return true
|
||
case http.MethodPost:
|
||
var payload struct {
|
||
Target string `json:"target"`
|
||
}
|
||
if !decodeJSON(w, r, &payload) {
|
||
return true
|
||
}
|
||
if strings.TrimSpace(payload.Target) == "" {
|
||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки")
|
||
return true
|
||
}
|
||
items, err := backendStore.UpsertSubscription(r.Context(), user.ID, payload.Target)
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handleNotification(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
switch {
|
||
case r.Method == http.MethodGet && path == "/notifications":
|
||
items, err := backendStore.ListNotifications(r.Context())
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/notifications/") && strings.HasSuffix(path, "/read"):
|
||
if _, ok := requireAuth(w, r); !ok {
|
||
return true
|
||
}
|
||
id := strings.TrimSuffix(strings.TrimPrefix(path, "/notifications/"), "/read")
|
||
updated, err := backendStore.MarkNotificationRead(r.Context(), id)
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"item": updated})
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
if !strings.HasPrefix(path, "/comments/") {
|
||
return false
|
||
}
|
||
contentID := strings.TrimPrefix(path, "/comments/")
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
items, err := backendStore.ListComments(r.Context(), contentID)
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
case http.MethodPost:
|
||
user, ok := requireAuth(w, r)
|
||
if !ok {
|
||
return true
|
||
}
|
||
var payload struct {
|
||
Text string `json:"text"`
|
||
}
|
||
if !decodeJSON(w, r, &payload) {
|
||
return true
|
||
}
|
||
if strings.TrimSpace(payload.Text) == "" {
|
||
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым")
|
||
return true
|
||
}
|
||
item, err := backendStore.AddComment(r.Context(), contentID, user, strings.TrimSpace(payload.Text))
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"item": item})
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handleAnalytics(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
if r.Method != http.MethodGet || (path != "/analytics/summary" && path != "/admin/dashboard") {
|
||
return false
|
||
}
|
||
user, ok := requireAuth(w, r)
|
||
if !ok {
|
||
return true
|
||
}
|
||
items, err := backendStore.ListContent(r.Context(), ContentFilter{})
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
speakers, err := backendStore.ListSpeakers(r.Context())
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
users, err := backendStore.ListUsers(r.Context())
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
comments, err := backendStore.CountComments(r.Context())
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
totalViews := 0
|
||
moderationQueue := 0
|
||
for _, item := range items {
|
||
totalViews += item.Views
|
||
if item.Status != ContentStatusPublished {
|
||
moderationQueue++
|
||
}
|
||
}
|
||
subscribers := 0
|
||
for _, speaker := range speakers {
|
||
subscribers += speaker.Subscribers
|
||
}
|
||
if path == "/admin/dashboard" {
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"users": len(users),
|
||
"materials": len(items),
|
||
"views": totalViews,
|
||
"pending": moderationQueue,
|
||
"viewsByDay": []map[string]any{
|
||
{"label": "Пн", "value": 120},
|
||
{"label": "Вт", "value": 180},
|
||
{"label": "Ср", "value": 160},
|
||
{"label": "Чт", "value": 220},
|
||
{"label": "Пт", "value": 260},
|
||
{"label": "Сб", "value": 140},
|
||
{"label": "Вс", "value": 110},
|
||
},
|
||
"roles": []RoleCode{RoleAdministrator, RoleEditor, RoleManager, RoleUser},
|
||
})
|
||
return true
|
||
}
|
||
popular := append([]ContentItem(nil), items...)
|
||
materials := 0
|
||
for _, item := range items {
|
||
if item.Author == user.Name {
|
||
materials++
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"materials": materials, "comments": comments, "totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(users), "popular": popular})
|
||
return true
|
||
}
|
||
|
||
func handleAudit(w http.ResponseWriter, r *http.Request, path string) bool {
|
||
if r.Method == http.MethodGet && (path == "/admin/audit" || path == "/audit") {
|
||
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
|
||
return true
|
||
}
|
||
items, err := backendStore.ListAudit(r.Context())
|
||
if err != nil {
|
||
writeStoreError(w, err)
|
||
return true
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||
defer r.Body.Close()
|
||
decoder := json.NewDecoder(r.Body)
|
||
decoder.DisallowUnknownFields()
|
||
if err := decoder.Decode(target); err != nil {
|
||
writeAPIError(w, http.StatusBadRequest, "INVALID_JSON", "Некорректный JSON-запрос")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func writeAPIError(w http.ResponseWriter, status int, code, message string) {
|
||
payload := apiError{}
|
||
payload.Error.Code = code
|
||
payload.Error.Message = message
|
||
writeJSON(w, status, payload)
|
||
}
|
||
|
||
func writeStoreError(w http.ResponseWriter, err error) {
|
||
writeAPIError(w, http.StatusServiceUnavailable, "STORE_UNAVAILABLE", err.Error())
|
||
}
|
||
|
||
func makeToken(user UserProfile) (string, error) {
|
||
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles, ExpiresAt: time.Now().UTC().Add(tokenTTL()).Unix()}
|
||
return signToken(payload)
|
||
}
|
||
|
||
func userFromRequest(r *http.Request) (UserProfile, bool) {
|
||
header := r.Header.Get("Authorization")
|
||
if !strings.HasPrefix(header, "Bearer ") {
|
||
return UserProfile{}, false
|
||
}
|
||
token := strings.TrimPrefix(header, "Bearer ")
|
||
payload, err := parseToken(token)
|
||
if err != nil {
|
||
return UserProfile{}, false
|
||
}
|
||
user, ok, err := backendStore.UserByID(r.Context(), payload.ID)
|
||
if err != nil {
|
||
return UserProfile{}, false
|
||
}
|
||
if !ok {
|
||
return UserProfile{}, false
|
||
}
|
||
return user, true
|
||
}
|
||
|
||
func requireAuth(w http.ResponseWriter, r *http.Request) (UserProfile, bool) {
|
||
user, ok := userFromRequest(r)
|
||
if !ok {
|
||
writeAPIError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Требуется аутентификация")
|
||
return UserProfile{}, false
|
||
}
|
||
return user, true
|
||
}
|
||
|
||
func requireRole(w http.ResponseWriter, r *http.Request, role RoleCode) (UserProfile, bool) {
|
||
return requireAnyRole(w, r, role)
|
||
}
|
||
|
||
func requireAnyRole(w http.ResponseWriter, r *http.Request, roles ...RoleCode) (UserProfile, bool) {
|
||
user, ok := requireAuth(w, r)
|
||
if !ok {
|
||
return UserProfile{}, false
|
||
}
|
||
for _, actual := range user.Roles {
|
||
for _, expected := range roles {
|
||
if actual == expected {
|
||
return user, true
|
||
}
|
||
}
|
||
}
|
||
writeAPIError(w, http.StatusForbidden, "FORBIDDEN", "Недостаточно прав доступа")
|
||
return UserProfile{}, false
|
||
}
|
||
|
||
func firstNonEmpty(value, fallback string) string {
|
||
if strings.TrimSpace(value) == "" {
|
||
return fallback
|
||
}
|
||
return value
|
||
}
|
||
|
||
func firstStatus(value, fallback ContentStatus) ContentStatus {
|
||
if strings.TrimSpace(string(value)) == "" {
|
||
return fallback
|
||
}
|
||
return value
|
||
}
|
||
|
||
func inferMediaKind(mimeType, fileName string) string {
|
||
lowerName := strings.ToLower(fileName)
|
||
switch {
|
||
case strings.HasPrefix(mimeType, "image/"):
|
||
return "image"
|
||
case strings.HasPrefix(mimeType, "video/"):
|
||
return "video"
|
||
case strings.HasPrefix(mimeType, "audio/"):
|
||
return "audio"
|
||
case mimeType == "application/pdf" || strings.HasSuffix(lowerName, ".pdf") || strings.HasSuffix(lowerName, ".doc") || strings.HasSuffix(lowerName, ".docx") || strings.HasSuffix(lowerName, ".ppt") || strings.HasSuffix(lowerName, ".pptx") || strings.HasSuffix(lowerName, ".xls") || strings.HasSuffix(lowerName, ".xlsx") || strings.HasSuffix(lowerName, ".txt") || strings.HasSuffix(lowerName, ".rtf"):
|
||
return "document"
|
||
default:
|
||
return "other"
|
||
}
|
||
}
|
||
|
||
func defaultContentTypeForMediaKind(kind string) ContentType {
|
||
switch kind {
|
||
case "video":
|
||
return ContentTypeVideo
|
||
case "audio":
|
||
return ContentTypeAudio
|
||
case "image":
|
||
return ContentTypeGraphic
|
||
default:
|
||
return ContentTypeArticle
|
||
}
|
||
}
|
||
|
||
func defaultCategoryForMediaKind(kind string) string {
|
||
switch kind {
|
||
case "video":
|
||
return "Видео"
|
||
case "audio":
|
||
return "Аудио"
|
||
case "image":
|
||
return "Графика"
|
||
default:
|
||
return "Статьи"
|
||
}
|
||
}
|
||
|
||
func isValidContentType(contentType ContentType) bool {
|
||
switch contentType {
|
||
case ContentTypeNews, ContentTypeArticle, ContentTypeVideo, ContentTypeAudio, ContentTypeGraphic, ContentTypeEvent:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func parseTags(value string) []string {
|
||
parts := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ';' || r == '\n' })
|
||
tags := make([]string, 0, len(parts))
|
||
seen := map[string]bool{}
|
||
for _, part := range parts {
|
||
tag := strings.TrimSpace(part)
|
||
if tag == "" || seen[tag] {
|
||
continue
|
||
}
|
||
seen[tag] = true
|
||
tags = append(tags, tag)
|
||
}
|
||
if len(tags) == 0 {
|
||
return []string{"демо", "файл"}
|
||
}
|
||
return tags
|
||
}
|