271 lines
8.0 KiB
Go
Raw Permalink Normal View History

2025-03-25 03:52:30 -04:00
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
}