Files
DocNest/backend/internal/handlers/share.go
M1ngdaXie 8ae7fd96e8 feat: Implement error handling and response structure for API
- Added standardized error response structure in `errors.go` for consistent error handling across the API.
- Implemented specific response functions for various HTTP status codes (400, 401, 403, 404, 500) to enhance error reporting.
- Introduced validation error handling to provide detailed feedback on input validation issues.

test: Add comprehensive tests for share handler functionality

- Created a suite of tests for share handler endpoints, covering scenarios for creating, listing, deleting shares, and managing share links.
- Included tests for permission checks, validation errors, and edge cases such as unauthorized access and invalid document IDs.

chore: Set up test utilities and database for integration tests

- Established a base handler suite for common setup tasks in tests, including database initialization and teardown.
- Implemented test data seeding to facilitate consistent testing across different scenarios.

migration: Add public sharing support in the database schema

- Modified the `documents` table to include `share_token` and `is_public` columns for managing public document sharing.
- Added constraints to ensure data integrity, preventing public documents from lacking a share token.
2026-01-05 15:25:46 -08:00

292 lines
6.9 KiB
Go

package handlers
import (
"fmt"
"net/http"
"os" // Add this
"github.com/M1ngdaXie/realtime-collab/internal/auth"
"github.com/M1ngdaXie/realtime-collab/internal/models"
"github.com/M1ngdaXie/realtime-collab/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ShareHandler struct {
store store.Store
}
func NewShareHandler(store store.Store) *ShareHandler {
return &ShareHandler{store: store}
}
// CreateShare creates a new document share
func (h *ShareHandler) CreateShare(c *gin.Context) {
userID := auth.GetUserFromContext(c)
if userID == nil {
respondUnauthorized(c, "Authentication required")
return
}
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondInvalidID(c, "Invalid document ID format")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
respondForbidden(c, "Only document owner can share documents")
return
}
var req models.CreateShareRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondWithValidationError(c, err)
return
}
// Get user by email
targetUser, err := h.store.GetUserByEmail(c.Request.Context(), req.UserEmail)
if err != nil || targetUser == nil {
respondNotFound(c, "user")
return
}
// Create or update share
share, isNew, err := h.store.CreateDocumentShare(
c.Request.Context(),
documentID,
targetUser.ID,
req.Permission,
userID,
)
if err != nil {
respondInternalError(c, "Failed to create share", err)
return
}
// Return 201 for new share, 200 for updated share
statusCode := http.StatusOK
if isNew {
statusCode = http.StatusCreated
}
c.JSON(statusCode, share)
}
// ListShares lists all shares for a document
func (h *ShareHandler) ListShares(c *gin.Context) {
userID := auth.GetUserFromContext(c)
if userID == nil {
respondUnauthorized(c, "Authentication required")
return
}
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondInvalidID(c, "Invalid document ID format")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
respondForbidden(c, "Only document owner can view shares")
return
}
shares, err := h.store.ListDocumentShares(c.Request.Context(), documentID)
if err != nil {
respondInternalError(c, "Failed to list shares", err)
return
}
c.JSON(http.StatusOK, models.ShareListResponse{Shares: shares})
}
// DeleteShare removes a share
func (h *ShareHandler) DeleteShare(c *gin.Context) {
userID := auth.GetUserFromContext(c)
if userID == nil {
respondUnauthorized(c, "Authentication required")
return
}
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondInvalidID(c, "Invalid document ID format")
return
}
targetUserID, err := uuid.Parse(c.Param("userId"))
if err != nil {
respondInvalidID(c, "Invalid user ID format")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
respondForbidden(c, "Only document owner can delete shares")
return
}
err = h.store.DeleteDocumentShare(c.Request.Context(), documentID, targetUserID)
if err != nil {
respondInternalError(c, "Failed to delete share", err)
return
}
c.Status(204)
}
// CreateShareLink generates a public share link
func (h *ShareHandler) CreateShareLink(c *gin.Context) {
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondInvalidID(c, "Invalid document ID format")
return
}
userID := auth.GetUserFromContext(c)
if userID == nil {
respondUnauthorized(c, "Authentication required")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
respondForbidden(c, "Only document owner can create share links")
return
}
// Parse request body
var req struct {
Permission string `json:"permission" binding:"required,oneof=view edit"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondWithValidationError(c, err)
return
}
// Generate share token
token, err := h.store.GenerateShareToken(c.Request.Context(), documentID, req.Permission)
if err != nil {
respondInternalError(c, "Failed to generate share link", err)
return
}
// Get frontend URL from env
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "http://localhost:5173"
}
shareURL := fmt.Sprintf("%s/editor/%s?share=%s", frontendURL, documentID.String(), token)
c.JSON(http.StatusOK, gin.H{
"url": shareURL,
"token": token,
"permission": req.Permission,
})
}
// GetShareLink retrieves the current public share link
func (h *ShareHandler) GetShareLink(c *gin.Context) {
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondInvalidID(c, "Invalid document ID format")
return
}
userID := auth.GetUserFromContext(c)
if userID == nil {
respondUnauthorized(c, "Authentication required")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
respondForbidden(c, "Only document owner can view share links")
return
}
token, exists, err := h.store.GetShareToken(c.Request.Context(), documentID)
if err != nil {
respondInternalError(c, "Failed to get share link", err)
return
}
if !exists {
respondNotFound(c, "share link")
return
}
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "http://localhost:5173"
}
shareURL := fmt.Sprintf("%s/editor/%s?share=%s", frontendURL, documentID.String(), token)
c.JSON(http.StatusOK, gin.H{
"url": shareURL,
"token": token,
})
}
// RevokeShareLink removes the public share link
func (h *ShareHandler) RevokeShareLink(c *gin.Context) {
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondInvalidID(c, "Invalid document ID format")
return
}
userID := auth.GetUserFromContext(c)
if userID == nil {
respondUnauthorized(c, "Authentication required")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
respondForbidden(c, "Only document owner can revoke share links")
return
}
err = h.store.RevokeShareToken(c.Request.Context(), documentID)
if err != nil {
respondInternalError(c, "Failed to revoke share link", err)
return
}
// c.JSON(http.StatusOK, gin.H{"message": "Share link revoked successfully"})
c.Status(204)
}