460 lines
13 KiB
Go
460 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"goyco/internal/database"
|
|
"goyco/internal/dto"
|
|
"goyco/internal/repositories"
|
|
"goyco/internal/security"
|
|
"goyco/internal/services"
|
|
"goyco/internal/validation"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgconn"
|
|
)
|
|
|
|
type PostHandler struct {
|
|
postRepo repositories.PostRepository
|
|
titleFetcher services.TitleFetcher
|
|
voteService *services.VoteService
|
|
postQueries *services.PostQueries
|
|
}
|
|
|
|
func NewPostHandler(postRepo repositories.PostRepository, titleFetcher services.TitleFetcher, voteService *services.VoteService) *PostHandler {
|
|
return &PostHandler{
|
|
postRepo: postRepo,
|
|
titleFetcher: titleFetcher,
|
|
voteService: voteService,
|
|
postQueries: services.NewPostQueries(postRepo, voteService),
|
|
}
|
|
}
|
|
|
|
type PostResponse = CommonResponse
|
|
|
|
// @Summary Get posts
|
|
// @Description Get a list of posts with pagination. Posts include vote statistics (up_votes, down_votes, score) and current user's vote status.
|
|
// @Tags posts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param limit query int false "Number of posts to return" default(20)
|
|
// @Param offset query int false "Number of posts to skip" default(0)
|
|
// @Success 200 {object} PostResponse "Posts retrieved successfully with vote statistics"
|
|
// @Failure 400 {object} PostResponse "Invalid pagination parameters"
|
|
// @Failure 500 {object} PostResponse "Internal server error"
|
|
// @Router /api/posts [get]
|
|
func (h *PostHandler) GetPosts(w http.ResponseWriter, r *http.Request) {
|
|
limit, offset := parsePagination(r)
|
|
|
|
opts := services.QueryOptions{
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
|
|
ctx := NewVoteContext(r)
|
|
|
|
posts, err := h.postQueries.GetAll(opts, ctx)
|
|
if err != nil {
|
|
SendErrorResponse(w, "Failed to fetch posts", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
postDTOs := dto.ToPostDTOs(posts)
|
|
SendSuccessResponse(w, "Posts retrieved successfully", map[string]any{
|
|
"posts": postDTOs,
|
|
"count": len(postDTOs),
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
// @Summary Get a single post
|
|
// @Description Get a post by ID with vote statistics and current user's vote status
|
|
// @Tags posts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path int true "Post ID"
|
|
// @Success 200 {object} PostResponse "Post retrieved successfully with vote statistics"
|
|
// @Failure 400 {object} PostResponse "Invalid post ID"
|
|
// @Failure 404 {object} PostResponse "Post not found"
|
|
// @Failure 500 {object} PostResponse "Internal server error"
|
|
// @Router /api/posts/{id} [get]
|
|
func (h *PostHandler) GetPost(w http.ResponseWriter, r *http.Request) {
|
|
postID, ok := ParseUintParam(w, r, "id", "Post")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := NewVoteContext(r)
|
|
|
|
post, err := h.postQueries.GetByID(postID, ctx)
|
|
if !HandleRepoError(w, err, "Post") {
|
|
return
|
|
}
|
|
|
|
postDTO := dto.ToPostDTO(post)
|
|
SendSuccessResponse(w, "Post retrieved successfully", postDTO)
|
|
}
|
|
|
|
// @Summary Create a new post
|
|
// @Description Create a new post with URL and optional title
|
|
// @Tags posts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param request body dto.CreatePostRequest true "Post data"
|
|
// @Success 201 {object} PostResponse
|
|
// @Failure 400 {object} PostResponse "Invalid request data or validation failed"
|
|
// @Failure 401 {object} PostResponse "Authentication required"
|
|
// @Failure 409 {object} PostResponse "URL already submitted"
|
|
// @Failure 502 {object} PostResponse "Failed to fetch title from URL"
|
|
// @Failure 500 {object} PostResponse "Internal server error"
|
|
// @Router /api/posts [post]
|
|
func (h *PostHandler) CreatePost(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
if !DecodeJSONRequest(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
req.Title = security.SanitizeInput(req.Title)
|
|
req.URL = security.SanitizeURL(req.URL)
|
|
req.Content = security.SanitizePostContent(req.Content)
|
|
|
|
if req.URL == "" {
|
|
SendErrorResponse(w, "URL is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.Title) > 200 {
|
|
SendErrorResponse(w, "Title must be no more than 200 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.Content) > 10000 {
|
|
SendErrorResponse(w, "Content must be no more than 10,000 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
userID, ok := RequireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
title := req.Title
|
|
|
|
if title == "" && h.titleFetcher != nil {
|
|
titleCtx, cancel := context.WithTimeout(r.Context(), 7*time.Second)
|
|
defer cancel()
|
|
|
|
fetchedTitle, err := h.titleFetcher.FetchTitle(titleCtx, req.URL)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, services.ErrUnsupportedScheme):
|
|
SendErrorResponse(w, "Only HTTP and HTTPS URLs are supported", http.StatusBadRequest)
|
|
case errors.Is(err, services.ErrTitleNotFound):
|
|
SendErrorResponse(w, "Title could not be extracted from the provided URL", http.StatusBadRequest)
|
|
default:
|
|
SendErrorResponse(w, "Failed to fetch title from URL", http.StatusBadGateway)
|
|
}
|
|
return
|
|
}
|
|
|
|
title = fetchedTitle
|
|
}
|
|
|
|
if title == "" {
|
|
SendErrorResponse(w, "Title is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(title) < 3 {
|
|
SendErrorResponse(w, "Title must be at least 3 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
post := &database.Post{
|
|
Title: title,
|
|
URL: req.URL,
|
|
Content: req.Content,
|
|
AuthorID: &userID,
|
|
}
|
|
|
|
if err := h.postRepo.Create(post); err != nil {
|
|
if errMsg, status := translatePostCreateError(err); status != 0 {
|
|
SendErrorResponse(w, errMsg, status)
|
|
return
|
|
}
|
|
|
|
SendErrorResponse(w, "Failed to create post", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
postDTO := dto.ToPostDTO(post)
|
|
SendCreatedResponse(w, "Post created successfully", postDTO)
|
|
}
|
|
|
|
// @Summary Search posts
|
|
// @Description Search posts by title or content keywords. Results include vote statistics and current user's vote status.
|
|
// @Tags posts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param q query string false "Search term"
|
|
// @Param limit query int false "Number of posts to return" default(20)
|
|
// @Param offset query int false "Number of posts to skip" default(0)
|
|
// @Success 200 {object} PostResponse "Search results with vote statistics"
|
|
// @Failure 400 {object} PostResponse "Invalid search parameters"
|
|
// @Failure 500 {object} PostResponse "Internal server error"
|
|
// @Router /api/posts/search [get]
|
|
func (h *PostHandler) SearchPosts(w http.ResponseWriter, r *http.Request) {
|
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
limit, offset := parsePagination(r)
|
|
|
|
opts := services.QueryOptions{
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
|
|
ctx := NewVoteContext(r)
|
|
|
|
posts, err := h.postQueries.GetSearch(query, opts, ctx)
|
|
if err != nil {
|
|
if searchErr, ok := err.(*repositories.SearchError); ok {
|
|
SendErrorResponse(w, "Invalid search query: "+searchErr.Message, http.StatusBadRequest)
|
|
return
|
|
}
|
|
SendErrorResponse(w, "Failed to search posts", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
postDTOs := dto.ToPostDTOs(posts)
|
|
SendSuccessResponse(w, "Search results retrieved successfully", map[string]any{
|
|
"posts": postDTOs,
|
|
"count": len(postDTOs),
|
|
"query": query,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
// @Summary Update a post
|
|
// @Description Update the title and content of a post owned by the authenticated user
|
|
// @Tags posts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path int true "Post ID"
|
|
// @Param request body dto.UpdatePostRequest true "Post update data"
|
|
// @Success 200 {object} PostResponse "Post updated successfully"
|
|
// @Failure 400 {object} PostResponse "Invalid request data or validation failed"
|
|
// @Failure 401 {object} PostResponse "Authentication required"
|
|
// @Failure 403 {object} PostResponse "Not authorized to update this post"
|
|
// @Failure 404 {object} PostResponse "Post not found"
|
|
// @Failure 500 {object} PostResponse "Internal server error"
|
|
// @Router /api/posts/{id} [put]
|
|
func (h *PostHandler) UpdatePost(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := RequireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
postID, ok := ParseUintParam(w, r, "id", "Post")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
post, err := h.postRepo.GetByID(postID)
|
|
if !HandleRepoError(w, err, "Post") {
|
|
return
|
|
}
|
|
|
|
if post.AuthorID == nil || *post.AuthorID != userID {
|
|
SendErrorResponse(w, "You can only edit your own posts", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
if !DecodeJSONRequest(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
req.Title = security.SanitizeInput(req.Title)
|
|
req.Content = security.SanitizePostContent(req.Content)
|
|
|
|
if len(req.Title) > 200 {
|
|
SendErrorResponse(w, "Title must be no more than 200 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.Content) > 10000 {
|
|
SendErrorResponse(w, "Content must be no more than 10,000 characters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := validation.ValidateTitle(req.Title); err != nil {
|
|
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := validation.ValidateContent(req.Content); err != nil {
|
|
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
post.Title = req.Title
|
|
post.Content = req.Content
|
|
|
|
if err := h.postRepo.Update(post); err != nil {
|
|
SendErrorResponse(w, "Failed to update post", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
postDTO := dto.ToPostDTO(post)
|
|
SendSuccessResponse(w, "Post updated successfully", postDTO)
|
|
}
|
|
|
|
// @Summary Delete a post
|
|
// @Description Delete a post owned by the authenticated user
|
|
// @Tags posts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path int true "Post ID"
|
|
// @Success 200 {object} PostResponse "Post deleted successfully"
|
|
// @Failure 400 {object} PostResponse "Invalid post ID"
|
|
// @Failure 401 {object} PostResponse "Authentication required"
|
|
// @Failure 403 {object} PostResponse "Not authorized to delete this post"
|
|
// @Failure 404 {object} PostResponse "Post not found"
|
|
// @Failure 500 {object} PostResponse "Internal server error"
|
|
// @Router /api/posts/{id} [delete]
|
|
func (h *PostHandler) DeletePost(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := RequireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
postID, ok := ParseUintParam(w, r, "id", "Post")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
post, err := h.postRepo.GetByID(postID)
|
|
if !HandleRepoError(w, err, "Post") {
|
|
return
|
|
}
|
|
|
|
if post.AuthorID == nil || *post.AuthorID != userID {
|
|
SendErrorResponse(w, "You can only delete your own posts", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := h.voteService.DeleteVotesByPostID(postID); err != nil {
|
|
SendErrorResponse(w, "Failed to delete post votes", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := h.postRepo.Delete(postID); err != nil {
|
|
SendErrorResponse(w, "Failed to delete post", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
SendSuccessResponse(w, "Post deleted successfully", nil)
|
|
}
|
|
|
|
// @Summary Fetch title from URL
|
|
// @Description Fetch the HTML title for the provided URL
|
|
// @Tags posts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param url query string true "URL to inspect"
|
|
// @Success 200 {object} PostResponse "Title fetched successfully"
|
|
// @Failure 400 {object} PostResponse "Invalid URL or URL parameter missing"
|
|
// @Failure 501 {object} PostResponse "Title fetching is not available"
|
|
// @Failure 502 {object} PostResponse "Failed to fetch title from URL"
|
|
// @Router /api/posts/title [get]
|
|
func (h *PostHandler) FetchTitleFromURL(w http.ResponseWriter, r *http.Request) {
|
|
if h.titleFetcher == nil {
|
|
SendErrorResponse(w, "Title fetching is not available", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
|
|
requestedURL := strings.TrimSpace(r.URL.Query().Get("url"))
|
|
if requestedURL == "" {
|
|
SendErrorResponse(w, "URL query parameter is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
titleCtx, cancel := context.WithTimeout(r.Context(), 7*time.Second)
|
|
defer cancel()
|
|
|
|
title, err := h.titleFetcher.FetchTitle(titleCtx, requestedURL)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, services.ErrUnsupportedScheme):
|
|
SendErrorResponse(w, "Only HTTP and HTTPS URLs are supported", http.StatusBadRequest)
|
|
case errors.Is(err, services.ErrTitleNotFound):
|
|
SendErrorResponse(w, "Title could not be extracted from the provided URL", http.StatusBadRequest)
|
|
default:
|
|
SendErrorResponse(w, "Failed to fetch title from URL", http.StatusBadGateway)
|
|
}
|
|
return
|
|
}
|
|
|
|
SendSuccessResponse(w, "Title fetched successfully", map[string]string{
|
|
"title": title,
|
|
})
|
|
}
|
|
|
|
func translatePostCreateError(err error) (string, int) {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) {
|
|
switch pgErr.Code {
|
|
case "23505":
|
|
return "This URL has already been submitted.", http.StatusConflict
|
|
case "23503":
|
|
return "Author account not found. Please sign in again.", http.StatusUnauthorized
|
|
}
|
|
}
|
|
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "UNIQUE constraint") || strings.Contains(errStr, "duplicate") {
|
|
return "This URL has already been submitted.", http.StatusConflict
|
|
}
|
|
|
|
return "", 0
|
|
}
|
|
|
|
func (h *PostHandler) MountRoutes(r chi.Router, config RouteModuleConfig) {
|
|
public := r
|
|
if config.GeneralRateLimit != nil {
|
|
public = config.GeneralRateLimit(r)
|
|
}
|
|
public.Get("/posts", h.GetPosts)
|
|
public.Get("/posts/search", h.SearchPosts)
|
|
public.Get("/posts/title", h.FetchTitleFromURL)
|
|
public.Get("/posts/{id}", h.GetPost)
|
|
|
|
protected := r
|
|
if config.AuthMiddleware != nil {
|
|
protected = r.With(config.AuthMiddleware)
|
|
}
|
|
if config.GeneralRateLimit != nil {
|
|
protected = config.GeneralRateLimit(protected)
|
|
}
|
|
protected.Post("/posts", h.CreatePost)
|
|
protected.Put("/posts/{id}", h.UpdatePost)
|
|
protected.Delete("/posts/{id}", h.DeletePost)
|
|
}
|