Files
67/services/internal/service/api.go
2026-06-22 22:39:08 +03:00

880 lines
27 KiB
Go
Raw 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 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
}