Files
67/services/internal/service/api.go
2026-06-15 00:20:48 +03:00

703 lines
22 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/base64"
"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"`
}
type apiError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
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"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &payload) {
return true
}
if strings.TrimSpace(payload.Login) == "" || strings.TrimSpace(payload.Password) == "" {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль")
return true
}
user, ok := backendStore.userByLogin(payload.Login)
if !ok {
user, _ = backendStore.userByLogin("demo_admin")
}
writeJSON(w, http.StatusOK, map[string]any{"token": makeToken(user), "user": user})
return true
case r.Method == http.MethodPost && path == "/auth/register":
var payload struct {
Login string `json:"login"`
Password string `json:"password"`
Name string `json:"name"`
}
if !decodeJSON(w, r, &payload) {
return true
}
if strings.TrimSpace(payload.Login) == "" || len(payload.Password) < 8 {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов")
return true
}
name := strings.TrimSpace(payload.Name)
if name == "" {
name = "Демо-пользователь"
}
user := backendStore.addUser(payload.Login, name)
writeJSON(w, http.StatusCreated, map[string]any{"token": makeToken(user), "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":
if _, ok := requireAuth(w, r); !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
}
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
}
backendStore.mu.RLock()
items := append([]UserProfile(nil), backendStore.Users...)
backendStore.mu.RUnlock()
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":
backendStore.mu.RLock()
items := append([]ContentItem(nil), backendStore.Content...)
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case r.Method == http.MethodGet && path == "/events":
backendStore.mu.RLock()
items := make([]ContentItem, 0)
for _, item := range backendStore.Content {
if item.Type == ContentTypeEvent {
items = append(items, item)
}
}
backendStore.mu.RUnlock()
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 := backendStore.contentByID(id)
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
}
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: 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,
}
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
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
}
item, ok := backendStore.patchContent(strings.TrimPrefix(path, "/content/"), payload)
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
}
if !backendStore.deleteContent(strings.TrimPrefix(path, "/content/")) {
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
}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
switch path {
case "/categories":
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Categories...)})
return true
case "/tags":
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Tags...)})
return true
default:
return false
}
}
func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool {
if r.Method == http.MethodGet && path == "/speakers" {
backendStore.mu.RLock()
items := append([]Speaker(nil), backendStore.Speakers...)
backendStore.mu.RUnlock()
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":
backendStore.mu.RLock()
items := make([]ContentItem, 0)
for _, item := range backendStore.Content {
if item.Type == ContentTypeVideo || item.Type == ContentTypeAudio || item.Type == ContentTypeGraphic {
items = append(items, item)
}
}
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case r.Method == http.MethodGet && strings.HasPrefix(path, "/media/files/"):
file, ok := backendStore.fileByID(strings.TrimPrefix(path, "/media/files/"))
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 := backendStore.addFile(StoredFile{
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
Name: firstNonEmpty(header.Filename, "uploaded-file"),
MimeType: mimeType,
Size: int64(len(data)),
Data: data,
})
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,
}
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
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()
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.searchContent(query.Get("q"), query.Get("category"), query.Get("type"), query.Get("sort"))})
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
}
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.upsertSubscription(user.ID, payload.Target)})
return true
default:
return false
}
}
func handleNotification(w http.ResponseWriter, r *http.Request, path string) bool {
switch {
case r.Method == http.MethodGet && path == "/notifications":
backendStore.mu.RLock()
items := append([]NotificationItem(nil), backendStore.Notifications...)
backendStore.mu.RUnlock()
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")
backendStore.mu.Lock()
var updated *NotificationItem
for index := range backendStore.Notifications {
if backendStore.Notifications[index].ID == id {
backendStore.Notifications[index].Read = true
updated = &backendStore.Notifications[index]
break
}
}
backendStore.mu.Unlock()
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:
backendStore.mu.RLock()
items := make([]CommentItem, 0)
for _, comment := range backendStore.Comments {
if comment.ContentID == contentID {
items = append(items, comment)
}
}
backendStore.mu.RUnlock()
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
}
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addComment(contentID, user, strings.TrimSpace(payload.Text))})
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
}
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
totalViews := 0
moderationQueue := 0
for _, item := range backendStore.Content {
totalViews += item.Views
if item.Status != ContentStatusPublished {
moderationQueue++
}
}
subscribers := 0
for _, speaker := range backendStore.Speakers {
subscribers += speaker.Subscribers
}
if path == "/admin/dashboard" {
writeJSON(w, http.StatusOK, map[string]any{"users": len(backendStore.Users), "content": len(backendStore.Content), "moderationQueue": moderationQueue, "roles": []RoleCode{RoleAdministrator, RoleEditor, RoleManager, RoleUser}})
return true
}
popular := append([]ContentItem(nil), backendStore.Content...)
writeJSON(w, http.StatusOK, map[string]any{"totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(backendStore.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
}
backendStore.mu.RLock()
items := append([]AuditItem(nil), backendStore.Audit...)
backendStore.mu.RUnlock()
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 makeToken(user UserProfile) string {
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles}
bytes, _ := json.Marshal(payload)
// Demo token only. Production must use signed JWT or another verified token format.
return "demo-token-" + base64.RawURLEncoding.EncodeToString(bytes)
}
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 ")
if token == "demo-token-local-fallback" {
user, ok := backendStore.userByLogin("demo_admin")
return user, ok
}
if !strings.HasPrefix(token, "demo-token-") {
return UserProfile{}, false
}
encoded := strings.TrimPrefix(token, "demo-token-")
bytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return UserProfile{}, false
}
var payload tokenPayload
if err := json.Unmarshal(bytes, &payload); err != nil {
return UserProfile{}, false
}
user := UserProfile{ID: payload.ID, Name: payload.Name, Login: payload.Login, Roles: payload.Roles}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
for _, stored := range backendStore.Users {
if stored.ID == user.ID {
user.Subscriptions = append([]string(nil), stored.Subscriptions...)
break
}
}
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 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
}