Files
FlowReader/internal/handler/article.go
T
MichaelandClaude Opus 4.8 3402e53954 sécurité : correctifs XSS, SSRF, CSWSH, rate-limit et durcissement
- XSS stocké (critique) : sanitisation bluemonday sur tous les endpoints
  d'articles (List, ListByFeed, Favorites, Search, Get), pas seulement Get
- SSRF (élevé) : nouveau utils/safehttp.go (ValidateExternalURL + client durci
  via Dialer.Control, bloque IP privées/loopback/link-local/metadata,
  anti-DNS-rebinding et limite de redirections) appliqué à l'extracteur et au parser
- WebSocket CSWSH (élevé) : politique same-origin + override WS_ALLOWED_ORIGINS
- rate-limiting (moyen) : token-bucket en mémoire sur /auth/*
- token de session retiré du corps JSON (json:"-"), livré uniquement par le
  cookie HttpOnly
- cookie Secure correct derrière un reverse-proxy (X-Forwarded-Proto +
  override COOKIE_SECURE)
- admin : interdiction de supprimer son propre compte
- en-têtes de sécurité (nosniff, X-Frame-Options, Referrer-Policy,
  Permissions-Policy)

Note : backend non compilé localement (pas de toolchain Go) ; à valider via Docker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:21:43 +02:00

492 lines
13 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
extractor *utils.ContentExtractor
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(),
extractor: utils.NewContentExtractor(),
hub: hub,
}
}
// sanitizeArticle cleans the user-facing HTML fields of a single article to
// prevent stored XSS from malicious feeds. AISummary is rendered as plain text
// by the client, so only Content and Summary need sanitization.
func (h *ArticleHandler) sanitizeArticle(a *domain.Article) {
if a == nil {
return
}
if a.Content != "" {
a.Content = h.sanitizer.Sanitize(a.Content)
}
if a.Summary != "" {
a.Summary = h.sanitizer.Sanitize(a.Summary)
}
}
// sanitizeArticles cleans a slice of articles in place.
func (h *ArticleHandler) sanitizeArticles(articles []*domain.Article) {
for _, a := range articles {
h.sanitizeArticle(a)
}
}
// 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
}
h.sanitizeArticles(articles)
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
}
h.sanitizeArticles(articles)
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 user-facing HTML content (defense against stored XSS).
h.sanitizeArticle(article)
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
}
h.sanitizeArticles(articles)
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
}
h.sanitizeArticles(articles)
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
}
// Try to extract full content from URL if available
if article.URL != "" {
fullContent, err := h.extractor.Extract(r.Context(), article.URL)
if err == nil && len(fullContent) > len(content) {
content = "--- CONTENU COMPLET EXTRAIT DU SITE WEB ---\n" + fullContent
}
}
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})
}