mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
460 lines
12 KiB
Go
460 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/michael/flowreader/internal/domain"
|
|
"github.com/michael/flowreader/internal/service"
|
|
"github.com/michael/flowreader/internal/utils"
|
|
"github.com/michael/flowreader/internal/ws"
|
|
)
|
|
|
|
// ArticleHandler handles article-related HTTP requests.
|
|
type ArticleHandler struct {
|
|
articleRepo domain.ArticleRepository
|
|
feedService *service.FeedService
|
|
authService *service.AuthService
|
|
aiService *service.AIService
|
|
sanitizer *utils.ContentSanitizer
|
|
hub *ws.Hub
|
|
}
|
|
|
|
// NewArticleHandler creates a new article handler.
|
|
func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, aiService *service.AIService, hub *ws.Hub) *ArticleHandler {
|
|
return &ArticleHandler{
|
|
articleRepo: articleRepo,
|
|
feedService: feedService,
|
|
authService: authService,
|
|
aiService: aiService,
|
|
sanitizer: utils.NewContentSanitizer(),
|
|
hub: hub,
|
|
}
|
|
}
|
|
|
|
// getUserFromRequest extracts the authenticated user from the request.
|
|
func (h *ArticleHandler) getUserFromRequest(r *http.Request) (uuid.UUID, error) {
|
|
cookie, err := r.Cookie("session_id")
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
|
|
user, err := h.authService.GetUserByToken(cookie.Value)
|
|
if err != nil || user == nil {
|
|
return uuid.Nil, err
|
|
}
|
|
|
|
return user.ID, nil
|
|
}
|
|
|
|
// List handles GET /api/v1/articles
|
|
func (h *ArticleHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
// Parse query parameters
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
unreadOnly := r.URL.Query().Get("unread") == "true"
|
|
|
|
articles, err := h.articleRepo.GetByUserID(userID, limit, offset, unreadOnly)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to get articles")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, articles)
|
|
}
|
|
|
|
// ListByFeed handles GET /api/v1/feeds/{id}/articles
|
|
func (h *ArticleHandler) ListByFeed(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid feed ID")
|
|
return
|
|
}
|
|
|
|
// Verify feed ownership
|
|
_, err = h.feedService.GetFeed(feedID, userID)
|
|
if err != nil {
|
|
respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
// Parse query parameters
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
articles, err := h.articleRepo.GetByFeedID(feedID, limit, offset)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to get articles")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, articles)
|
|
}
|
|
|
|
// Get handles GET /api/v1/articles/{id}
|
|
func (h *ArticleHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid article ID")
|
|
return
|
|
}
|
|
|
|
article, err := h.articleRepo.GetByID(articleID)
|
|
if err != nil || article == nil {
|
|
respondError(w, http.StatusNotFound, "Article not found")
|
|
return
|
|
}
|
|
|
|
// Verify feed ownership
|
|
_, err = h.feedService.GetFeed(article.FeedID, userID)
|
|
if err != nil {
|
|
respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
// Sanitize content
|
|
if article.Content != "" {
|
|
article.Content = h.sanitizer.Sanitize(article.Content)
|
|
} else if article.Summary != "" {
|
|
article.Summary = h.sanitizer.Sanitize(article.Summary)
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, article)
|
|
}
|
|
|
|
// MarkRead handles POST /api/v1/articles/{id}/read
|
|
func (h *ArticleHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid article ID")
|
|
return
|
|
}
|
|
|
|
article, err := h.articleRepo.GetByID(articleID)
|
|
if err != nil || article == nil {
|
|
respondError(w, http.StatusNotFound, "Article not found")
|
|
return
|
|
}
|
|
|
|
// Verify feed ownership
|
|
_, err = h.feedService.GetFeed(article.FeedID, userID)
|
|
if err != nil {
|
|
respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
if err := h.articleRepo.MarkAsRead(articleID); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to mark as read")
|
|
return
|
|
}
|
|
|
|
// Broadcast update
|
|
if h.hub != nil {
|
|
h.hub.Broadcast("article_updated", map[string]interface{}{
|
|
"id": articleID,
|
|
"is_read": true,
|
|
})
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]bool{"is_read": true})
|
|
}
|
|
|
|
// MarkUnread handles DELETE /api/v1/articles/{id}/read
|
|
func (h *ArticleHandler) MarkUnread(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid article ID")
|
|
return
|
|
}
|
|
|
|
article, err := h.articleRepo.GetByID(articleID)
|
|
if err != nil || article == nil {
|
|
respondError(w, http.StatusNotFound, "Article not found")
|
|
return
|
|
}
|
|
|
|
// Verify feed ownership
|
|
_, err = h.feedService.GetFeed(article.FeedID, userID)
|
|
if err != nil {
|
|
respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
if err := h.articleRepo.MarkAsUnread(articleID); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to mark as unread")
|
|
return
|
|
}
|
|
|
|
// Broadcast update
|
|
if h.hub != nil {
|
|
h.hub.Broadcast("article_updated", map[string]interface{}{
|
|
"id": articleID,
|
|
"is_read": false,
|
|
})
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]bool{"is_read": false})
|
|
}
|
|
|
|
// ToggleFavorite handles POST /api/v1/articles/{id}/favorite
|
|
func (h *ArticleHandler) ToggleFavorite(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid article ID")
|
|
return
|
|
}
|
|
|
|
article, err := h.articleRepo.GetByID(articleID)
|
|
if err != nil || article == nil {
|
|
respondError(w, http.StatusNotFound, "Article not found")
|
|
return
|
|
}
|
|
|
|
// Verify feed ownership
|
|
_, err = h.feedService.GetFeed(article.FeedID, userID)
|
|
if err != nil {
|
|
respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
if err := h.articleRepo.ToggleFavorite(articleID); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to toggle favorite")
|
|
return
|
|
}
|
|
|
|
// Broadcast update
|
|
if h.hub != nil {
|
|
h.hub.Broadcast("article_updated", map[string]interface{}{
|
|
"id": articleID,
|
|
"is_favorite": !article.IsFavorite,
|
|
})
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]bool{"is_favorite": !article.IsFavorite})
|
|
}
|
|
|
|
// MarkAllRead handles POST /api/v1/feeds/{id}/read-all
|
|
func (h *ArticleHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid feed ID")
|
|
return
|
|
}
|
|
|
|
// Verify feed ownership
|
|
_, err = h.feedService.GetFeed(feedID, userID)
|
|
if err != nil {
|
|
respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
if err := h.articleRepo.MarkAllAsRead(feedID); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to mark all as read")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]string{"message": "All articles marked as read"})
|
|
}
|
|
|
|
// MarkAllReadGlobal handles POST /api/v1/articles/read-all
|
|
func (h *ArticleHandler) MarkAllReadGlobal(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
if err := h.articleRepo.MarkAllAsReadGlobal(userID); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to mark all as read")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]string{"message": "All articles marked as read"})
|
|
}
|
|
|
|
// GetFavorites handles GET /api/v1/articles/favorites
|
|
func (h *ArticleHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
articles, err := h.articleRepo.GetFavorites(userID, limit, offset)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to get favorites")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, articles)
|
|
}
|
|
|
|
// Search handles GET /api/v1/articles/search
|
|
func (h *ArticleHandler) Search(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
query := r.URL.Query().Get("q")
|
|
if query == "" {
|
|
respondJSON(w, http.StatusOK, []*domain.Article{})
|
|
return
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
articles, err := h.articleRepo.Search(userID, query, limit, offset)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to search articles")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, articles)
|
|
}
|
|
|
|
// Summarize handles POST /api/v1/articles/{id}/summarize
|
|
func (h *ArticleHandler) Summarize(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := h.getUserFromRequest(r)
|
|
if err != nil {
|
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
|
return
|
|
}
|
|
|
|
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid article ID")
|
|
return
|
|
}
|
|
|
|
article, err := h.articleRepo.GetByID(articleID)
|
|
if err != nil || article == nil {
|
|
respondError(w, http.StatusNotFound, "Article not found")
|
|
return
|
|
}
|
|
|
|
// Verify feed ownership
|
|
_, err = h.feedService.GetFeed(article.FeedID, userID)
|
|
if err != nil {
|
|
respondError(w, http.StatusForbidden, "Access denied")
|
|
return
|
|
}
|
|
|
|
// If already summarized, return it
|
|
if article.AISummary != "" {
|
|
respondJSON(w, http.StatusOK, map[string]string{"summary": article.AISummary})
|
|
return
|
|
}
|
|
|
|
// Context for AI is title + content (or summary if content empty)
|
|
content := article.Content
|
|
if content == "" {
|
|
content = article.Summary
|
|
}
|
|
|
|
aiInput := fmt.Sprintf("Titre: %s\n\nContenu: %s", article.Title, content)
|
|
|
|
// Summary generation (can be slow, but for this demo/small app we do it synchronously
|
|
// or we could use WS to notify when done. Here we follow the simple POST -> String pattern).
|
|
summary, err := h.aiService.Summarize(r.Context(), aiInput)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to generate summary: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Save to DB
|
|
if err := h.articleRepo.UpdateAISummary(articleID, summary); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to persist summary")
|
|
return
|
|
}
|
|
|
|
// Broadcast update via WebSocket
|
|
if h.hub != nil {
|
|
h.hub.Broadcast("article_updated", map[string]interface{}{
|
|
"id": articleID,
|
|
"ai_summary": summary,
|
|
})
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
|
}
|