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 }