package perspective import ( "context" "errors" "fmt" "html" "log" "strings" "time" "github.com/go-redis/redis/v8" "gorm.io/gorm" ) const ( // MaxTextLength is the maximum allowed length for perspective text MaxTextLength = 1000 // MaxFileSize is the maximum allowed file size in bytes (5MB) MaxFileSize = 5 * 1024 * 1024 // DailySubmissionLimit is the maximum number of submissions allowed per day for unverified users DailySubmissionLimit = 5 // ValidCaptchaToken is a mock valid captcha token ValidCaptchaToken = "valid-token" // RedisKeyPrefix is the prefix for Redis keys RedisKeyPrefix = "user:%s:submissions" // RedisTTL is the time-to-live for Redis keys (24 hours) RedisTTL = 24 * time.Hour ) // Perspective represents a user perspective stored in the database type Perspective struct { ID uint `gorm:"primaryKey" json:"id"` IPFSHash string `gorm:"uniqueIndex" json:"ipfsHash"` IssueID string `json:"issueId"` UserAddress string `json:"userAddress"` IsVerified bool `json:"isVerified"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // PerspectiveService handles business logic for perspective submissions type PerspectiveService struct { redisClient *redis.Client db *gorm.DB ipfsService IPFSServiceInterface } // IPFSServiceInterface defines the interface for IPFS interactions type IPFSServiceInterface interface { PinJSON(data interface{}, name string, metadata map[string]string) (string, error) GetIPFSGatewayURL(ipfsHash string) string } // NewPerspectiveService creates a new perspective service func NewPerspectiveService(redisClient *redis.Client, db *gorm.DB, ipfsService IPFSServiceInterface) *PerspectiveService { // Auto-migrate the perspective schema db.AutoMigrate(&Perspective{}) return &PerspectiveService{ redisClient: redisClient, db: db, ipfsService: ipfsService, } } // PerspectiveData represents data about a perspective to be stored in IPFS type PerspectiveData struct { Text string `json:"text"` FileURL string `json:"fileUrl,omitempty"` IssueID string `json:"issueId"` UserAddress string `json:"userAddress"` IsVerified bool `json:"isVerified"` Timestamp time.Time `json:"timestamp"` } // SubmitPerspective processes a new perspective submission func (s *PerspectiveService) SubmitPerspective(ctx context.Context, issueID, text, fileURL, userAddress string, isVerified bool, captchaToken string) (string, error) { // Validate text length if len(text) > MaxTextLength { return "", errors.New("text exceeds maximum length of 1000 characters") } // Sanitize text to prevent injection attacks sanitizedText := sanitizeText(text) // Check file size if a file URL is provided if fileURL != "" { // In a real implementation, this would download and check the file // For now, we'll just mock the check if err := mockCheckFileSize(fileURL); err != nil { return "", err } } // If the user is not verified, apply additional checks if !isVerified { // Verify CAPTCHA token if !verifyCaptcha(captchaToken) { return "", errors.New("invalid CAPTCHA token") } // Check rate limiting if err := s.checkRateLimit(ctx, userAddress); err != nil { return "", err } // Increment submission count if err := s.incrementSubmissionCount(ctx, userAddress); err != nil { return "", err } } // Prepare perspective data for IPFS perspectiveData := PerspectiveData{ Text: sanitizedText, FileURL: fileURL, IssueID: issueID, UserAddress: userAddress, IsVerified: isVerified, Timestamp: time.Now(), } // Prepare metadata for Pinata metadata := map[string]string{ "issueId": issueID, "userAddress": userAddress, "isVerified": fmt.Sprintf("%t", isVerified), } // Upload to IPFS via Pinata ipfsHash, err := s.ipfsService.PinJSON(perspectiveData, fmt.Sprintf("perspective-%s", issueID), metadata) if err != nil { log.Printf("Failed to upload perspective to IPFS: %v", err) return "", errors.New("failed to store perspective, please try again") } // Store perspective metadata in database perspective := Perspective{ IPFSHash: ipfsHash, IssueID: issueID, UserAddress: userAddress, IsVerified: isVerified, } if err := s.db.Create(&perspective).Error; err != nil { log.Printf("Failed to store perspective metadata in database: %v", err) return "", errors.New("failed to record perspective metadata, please try again") } return ipfsHash, nil } // GetPerspective retrieves a perspective from IPFS by its hash func (s *PerspectiveService) GetPerspective(ipfsHash string) (*PerspectiveData, error) { // In a real implementation, this would fetch from IPFS // For now, return a mock response log.Printf("Mock retrieval of perspective from IPFS: %s", ipfsHash) // Check if the perspective exists in our database first var perspective Perspective if err := s.db.Where("ipfs_hash = ?", ipfsHash).First(&perspective).Error; err != nil { return nil, errors.New("perspective not found") } // TODO: Replace this with actual IPFS retrieval // This would use a HTTP client to get the JSON from the IPFS gateway // For now, return a mock perspective based on the database record return &PerspectiveData{ Text: "This is a mock perspective retrieval", IssueID: perspective.IssueID, UserAddress: perspective.UserAddress, IsVerified: perspective.IsVerified, Timestamp: perspective.CreatedAt, }, nil } // GetPerspectivesByIssue retrieves all perspectives for a specific issue func (s *PerspectiveService) GetPerspectivesByIssue(issueID string) ([]Perspective, error) { var perspectives []Perspective if err := s.db.Where("issue_id = ?", issueID).Find(&perspectives).Error; err != nil { return nil, err } return perspectives, nil } // checkRateLimit checks if a user has exceeded their daily submission limit func (s *PerspectiveService) checkRateLimit(ctx context.Context, userAddress string) error { key := fmt.Sprintf(RedisKeyPrefix, userAddress) count, err := s.redisClient.Get(ctx, key).Int() if err != nil && err != redis.Nil { log.Printf("Redis error: %v", err) return errors.New("error checking rate limit") } if count >= DailySubmissionLimit { return errors.New("rate limit exceeded") } return nil } // incrementSubmissionCount increments the user's submission count func (s *PerspectiveService) incrementSubmissionCount(ctx context.Context, userAddress string) error { key := fmt.Sprintf(RedisKeyPrefix, userAddress) // Check if the key exists, if not create it with TTL exists, err := s.redisClient.Exists(ctx, key).Result() if err != nil { log.Printf("Redis error: %v", err) return errors.New("error tracking submission count") } if exists == 0 { // Initialize counter with 1 and set TTL err = s.redisClient.Set(ctx, key, 1, RedisTTL).Err() if err != nil { log.Printf("Redis error: %v", err) return errors.New("error tracking submission count") } return nil } // Increment the counter _, err = s.redisClient.Incr(ctx, key).Result() if err != nil { log.Printf("Redis error: %v", err) return errors.New("error tracking submission count") } return nil } // verifyCaptcha validates a CAPTCHA token (mock implementation) func verifyCaptcha(token string) bool { // In a real implementation, this would verify with hCaptcha or similar // For now, just check a fixed value return token == ValidCaptchaToken } // sanitizeText sanitizes user input to prevent injection attacks func sanitizeText(text string) string { // Remove HTML tags sanitized := html.EscapeString(text) // Trim whitespace sanitized = strings.TrimSpace(sanitized) return sanitized } // mockCheckFileSize mocks a file size check func mockCheckFileSize(fileURL string) error { // In a real implementation, this would download or check headers // For a mock, just return success log.Printf("Mock file size check for: %s", fileURL) // If the URL contains "oversized", pretend it's too large if strings.Contains(fileURL, "oversized") { return errors.New("file exceeds maximum size of 5MB") } return nil }