271 lines
8.0 KiB
Go
271 lines
8.0 KiB
Go
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
|
|
}
|