- Add VideoProcessor service to PostHandler for frame-based video moderation - Implement multi-frame extraction and Azure OpenAI Vision analysis for video content - Enhance VideoStitchingService with filters, speed control, and text overlays - Add image upload dialogs for group avatar and banner in GroupCreationModal - Implement navigation placeholders for mentions, hashtags, and URLs in sojornRichText
1551 lines
48 KiB
Go
1551 lines
48 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/models"
|
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
|
"gitlab.com/patrickbritton3/sojorn/go-backend/pkg/utils"
|
|
)
|
|
|
|
type PostHandler struct {
|
|
postRepo *repository.PostRepository
|
|
userRepo *repository.UserRepository
|
|
feedService *services.FeedService
|
|
assetService *services.AssetService
|
|
notificationService *services.NotificationService
|
|
moderationService *services.ModerationService
|
|
contentFilter *services.ContentFilter
|
|
openRouterService *services.OpenRouterService
|
|
linkPreviewService *services.LinkPreviewService
|
|
localAIService *services.LocalAIService
|
|
videoProcessor *services.VideoProcessor
|
|
}
|
|
|
|
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService) *PostHandler {
|
|
return &PostHandler{
|
|
postRepo: postRepo,
|
|
userRepo: userRepo,
|
|
feedService: feedService,
|
|
assetService: assetService,
|
|
notificationService: notificationService,
|
|
moderationService: moderationService,
|
|
contentFilter: contentFilter,
|
|
openRouterService: openRouterService,
|
|
linkPreviewService: linkPreviewService,
|
|
localAIService: localAIService,
|
|
videoProcessor: services.NewVideoProcessor(),
|
|
}
|
|
}
|
|
|
|
// enrichLinkPreviews populates link_preview fields on a slice of posts via batch query.
|
|
func (h *PostHandler) enrichLinkPreviews(ctx context.Context, posts []models.Post) {
|
|
if h.linkPreviewService == nil || len(posts) == 0 {
|
|
return
|
|
}
|
|
ids := make([]string, len(posts))
|
|
for i, p := range posts {
|
|
ids[i] = p.ID.String()
|
|
}
|
|
previews, err := h.linkPreviewService.EnrichPostsWithLinkPreviews(ctx, ids)
|
|
if err != nil || len(previews) == 0 {
|
|
return
|
|
}
|
|
for i := range posts {
|
|
if lp, ok := previews[posts[i].ID.String()]; ok {
|
|
posts[i].LinkPreviewURL = &lp.URL
|
|
posts[i].LinkPreviewTitle = &lp.Title
|
|
posts[i].LinkPreviewDescription = &lp.Description
|
|
signed := h.assetService.SignImageURL(lp.ImageURL)
|
|
posts[i].LinkPreviewImageURL = &signed
|
|
posts[i].LinkPreviewSiteName = &lp.SiteName
|
|
}
|
|
}
|
|
}
|
|
|
|
// enrichSinglePostLinkPreview populates link_preview fields on a single post.
|
|
func (h *PostHandler) enrichSinglePostLinkPreview(ctx context.Context, post *models.Post) {
|
|
if h.linkPreviewService == nil || post == nil {
|
|
return
|
|
}
|
|
previews, err := h.linkPreviewService.EnrichPostsWithLinkPreviews(ctx, []string{post.ID.String()})
|
|
if err != nil || len(previews) == 0 {
|
|
return
|
|
}
|
|
if lp, ok := previews[post.ID.String()]; ok {
|
|
post.LinkPreviewURL = &lp.URL
|
|
post.LinkPreviewTitle = &lp.Title
|
|
post.LinkPreviewDescription = &lp.Description
|
|
signed := h.assetService.SignImageURL(lp.ImageURL)
|
|
post.LinkPreviewImageURL = &signed
|
|
post.LinkPreviewSiteName = &lp.SiteName
|
|
}
|
|
}
|
|
|
|
func (h *PostHandler) CreateComment(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, _ := uuid.Parse(userIDStr.(string))
|
|
postID := c.Param("id")
|
|
|
|
var req struct {
|
|
Body string `json:"body" binding:"required,max=500"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
parentUUID, err := uuid.Parse(postID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid post ID"})
|
|
return
|
|
}
|
|
|
|
// Layer 0: Hard blocklist check — reject immediately, never save
|
|
if h.contentFilter != nil {
|
|
result := h.contentFilter.CheckContent(req.Body)
|
|
if result.Blocked {
|
|
strikeCount, consequence, _ := h.contentFilter.RecordStrikeWithIP(c.Request.Context(), userID, result.Category, req.Body, c.ClientIP())
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
|
"error": result.Message,
|
|
"blocked": true,
|
|
"category": result.Category,
|
|
"strikes": strikeCount,
|
|
"consequence": consequence,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
tags := utils.ExtractHashtags(req.Body)
|
|
tone := "neutral"
|
|
cis := 0.8
|
|
|
|
// AI Moderation — runs whichever engine the admin selected in ai_moderation_config
|
|
var cachedScores *services.ThreePoisonsScore
|
|
var cachedReason string
|
|
commentStatus := "active"
|
|
ctx := c.Request.Context()
|
|
|
|
{
|
|
selectedEngine := ""
|
|
if h.openRouterService != nil {
|
|
cfg, cfgErr := h.openRouterService.GetModerationConfig(ctx, "text")
|
|
if cfgErr == nil && cfg != nil && cfg.Enabled && len(cfg.Engines) > 0 {
|
|
selectedEngine = cfg.Engines[0]
|
|
}
|
|
}
|
|
|
|
switch selectedEngine {
|
|
case "local_ai":
|
|
if h.localAIService != nil && req.Body != "" {
|
|
localResult, localErr := h.localAIService.ModerateText(ctx, req.Body)
|
|
if localErr != nil {
|
|
log.Debug().Err(localErr).Msg("Local AI moderation unavailable for comment")
|
|
} else if localResult != nil && !localResult.Allowed {
|
|
commentStatus = "removed"
|
|
log.Warn().Str("reason", localResult.Reason).Msg("Comment flagged by local AI")
|
|
}
|
|
}
|
|
case "openai":
|
|
if h.moderationService != nil {
|
|
scores, reason, modErr := h.moderationService.AnalyzeContent(ctx, req.Body, []string{})
|
|
if modErr == nil {
|
|
cachedScores = scores
|
|
cachedReason = reason
|
|
cis = (scores.Hate + scores.Greed + scores.Delusion) / 3.0
|
|
cis = 1.0 - cis
|
|
tone = reason
|
|
if reason != "" {
|
|
commentStatus = "pending_moderation"
|
|
}
|
|
}
|
|
}
|
|
case "openrouter":
|
|
if h.openRouterService != nil && req.Body != "" {
|
|
textResult, textErr := h.openRouterService.ModerateText(ctx, req.Body)
|
|
if textErr == nil && textResult != nil {
|
|
if textResult.Action == "flag" {
|
|
commentStatus = "removed"
|
|
} else if textResult.Action == "nsfw" {
|
|
commentStatus = "pending_moderation"
|
|
}
|
|
if textResult.Hate > 0 || textResult.Greed > 0 || textResult.Delusion > 0 {
|
|
orCis := 1.0 - (textResult.Hate+textResult.Greed+textResult.Delusion)/3.0
|
|
cis = orCis
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
log.Debug().Msg("No moderation engine configured in admin settings")
|
|
}
|
|
}
|
|
|
|
post := &models.Post{
|
|
AuthorID: userID,
|
|
Body: req.Body,
|
|
Status: commentStatus,
|
|
ToneLabel: &tone,
|
|
CISScore: &cis,
|
|
BodyFormat: "plain",
|
|
Tags: tags,
|
|
IsBeacon: false,
|
|
IsActiveBeacon: false,
|
|
AllowChain: true,
|
|
Visibility: "public",
|
|
ChainParentID: &parentUUID,
|
|
}
|
|
|
|
if err := h.postRepo.CreatePost(c.Request.Context(), post); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
comment := &models.Comment{
|
|
ID: post.ID,
|
|
PostID: postID,
|
|
AuthorID: post.AuthorID,
|
|
Body: post.Body,
|
|
Status: post.Status,
|
|
CreatedAt: post.CreatedAt,
|
|
}
|
|
|
|
// Flag comment if needed — reuse cached scores
|
|
if h.moderationService != nil && post.Status == "pending_moderation" && cachedScores != nil {
|
|
_ = h.moderationService.FlagComment(c.Request.Context(), post.ID, cachedScores, cachedReason)
|
|
}
|
|
|
|
// Log AI moderation decision for comment — async
|
|
if h.moderationService != nil {
|
|
postID := post.ID
|
|
postStatus := post.Status
|
|
reqBody := req.Body
|
|
go func() {
|
|
decision := "pass"
|
|
if postStatus == "pending_moderation" {
|
|
decision = "flag"
|
|
}
|
|
invCis := 1.0 - cis
|
|
scores := &services.ThreePoisonsScore{Hate: invCis, Greed: 0, Delusion: 0}
|
|
h.moderationService.LogAIDecision(context.Background(), "comment", postID, userID, reqBody, scores, nil, decision, tone, "", nil)
|
|
}()
|
|
}
|
|
|
|
// Get post details for notification
|
|
rootPost, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
|
if err == nil && rootPost.AuthorID.String() != userIDStr.(string) {
|
|
// Get actor details
|
|
actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string))
|
|
if err == nil && h.notificationService != nil {
|
|
// Determine post type for proper deep linking
|
|
postType := "standard"
|
|
if rootPost.IsBeacon {
|
|
postType = "beacon"
|
|
} else if rootPost.VideoURL != nil && *rootPost.VideoURL != "" {
|
|
postType = "quip"
|
|
}
|
|
|
|
commentIDStr := comment.ID.String()
|
|
metadata := map[string]interface{}{
|
|
"actor_name": actor.DisplayName,
|
|
"post_id": postID,
|
|
"post_type": postType,
|
|
}
|
|
go h.notificationService.CreateNotification(
|
|
context.Background(),
|
|
rootPost.AuthorID.String(),
|
|
userIDStr.(string),
|
|
"comment",
|
|
&postID,
|
|
&commentIDStr,
|
|
metadata,
|
|
)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{"comment": comment})
|
|
}
|
|
|
|
func (h *PostHandler) GetNearbyBeacons(c *gin.Context) {
|
|
lat := utils.GetQueryFloat(c, "lat", 0)
|
|
long := utils.GetQueryFloat(c, "long", 0)
|
|
radius := utils.GetQueryInt(c, "radius", 16000)
|
|
|
|
beacons, err := h.postRepo.GetNearbyBeacons(c.Request.Context(), lat, long, radius)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch nearby beacons", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Transform to beacon-specific JSON with correct field names for Flutter client
|
|
results := make([]gin.H, 0, len(beacons))
|
|
for _, b := range beacons {
|
|
item := gin.H{
|
|
"id": b.ID,
|
|
"body": b.Body,
|
|
"author_id": b.AuthorID,
|
|
"beacon_type": b.BeaconType,
|
|
"confidence_score": b.Confidence,
|
|
"is_active_beacon": b.IsActiveBeacon,
|
|
"created_at": b.CreatedAt,
|
|
"image_url": b.ImageURL,
|
|
"tags": b.Tags,
|
|
"beacon_lat": b.Lat,
|
|
"beacon_long": b.Long,
|
|
"severity": b.Severity,
|
|
"incident_status": b.IncidentStatus,
|
|
"radius": b.Radius,
|
|
"vouch_count": b.LikeCount, // mapped from vouch subquery
|
|
"report_count": b.CommentCount, // mapped from report subquery
|
|
"verification_count": b.LikeCount, // vouches = verification
|
|
"status_color": beaconStatusColor(b.Confidence),
|
|
"author_handle": "Anonymous",
|
|
"author_display_name": "Anonymous",
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"beacons": results})
|
|
}
|
|
|
|
// CreateBeacon creates an anonymous beacon pin on the map.
|
|
// The author_id is stored for abuse tracking but NEVER exposed in responses.
|
|
// AI moderation runs for text + images — flagged beacons stay visible but go to admin review.
|
|
// Does NOT create a feed post.
|
|
func (h *PostHandler) CreateBeacon(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, _ := uuid.Parse(userIDStr.(string))
|
|
|
|
var req struct {
|
|
Body string `json:"body" binding:"required"`
|
|
BeaconType string `json:"beacon_type" binding:"required"`
|
|
Lat float64 `json:"lat" binding:"required"`
|
|
Long float64 `json:"long" binding:"required"`
|
|
Severity string `json:"severity"`
|
|
ImageURL *string `json:"image_url"`
|
|
TTLHours *int `json:"ttl_hours"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Lat < -90 || req.Lat > 90 || req.Long < -180 || req.Long > 180 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid coordinates"})
|
|
return
|
|
}
|
|
|
|
severity := "medium"
|
|
if req.Severity != "" {
|
|
severity = req.Severity
|
|
}
|
|
|
|
var expiresAt *time.Time
|
|
if req.TTLHours != nil && *req.TTLHours > 0 {
|
|
t := time.Now().Add(time.Duration(*req.TTLHours) * time.Hour)
|
|
expiresAt = &t
|
|
}
|
|
|
|
beaconType := req.BeaconType
|
|
lat := req.Lat
|
|
long := req.Long
|
|
|
|
post := &models.Post{
|
|
AuthorID: userID,
|
|
Body: req.Body,
|
|
Status: "active",
|
|
BodyFormat: "plain",
|
|
Tags: []string{},
|
|
IsBeacon: true,
|
|
BeaconType: &beaconType,
|
|
Lat: &lat,
|
|
Long: &long,
|
|
Severity: severity,
|
|
IncidentStatus: "active",
|
|
Radius: 500,
|
|
Confidence: 0.5,
|
|
IsActiveBeacon: true,
|
|
AllowChain: false,
|
|
Visibility: "public",
|
|
ExpiresAt: expiresAt,
|
|
ImageURL: req.ImageURL,
|
|
}
|
|
|
|
// AI Moderation — text + image. Flagged beacons stay active but get flagged for admin review.
|
|
modFlagged := false
|
|
if h.moderationService != nil {
|
|
mediaURLs := []string{}
|
|
if req.ImageURL != nil && *req.ImageURL != "" {
|
|
mediaURLs = append(mediaURLs, *req.ImageURL)
|
|
}
|
|
scores, reason, err := h.moderationService.AnalyzeContent(c.Request.Context(), req.Body, mediaURLs)
|
|
if err == nil && reason != "" {
|
|
modFlagged = true
|
|
cis := 1.0 - (scores.Hate+scores.Greed+scores.Delusion)/3.0
|
|
post.CISScore = &cis
|
|
post.ToneLabel = &reason
|
|
log.Warn().Str("reason", reason).Float64("cis", cis).Msg("Beacon flagged by moderation — stays active for admin review")
|
|
}
|
|
}
|
|
// Local AI moderation (llama-guard, on-server, free, fast)
|
|
if h.localAIService != nil && req.Body != "" {
|
|
localResult, localErr := h.localAIService.ModerateText(c.Request.Context(), req.Body)
|
|
if localErr != nil {
|
|
log.Debug().Err(localErr).Msg("Local AI moderation unavailable for beacon, falling through")
|
|
} else if localResult != nil && !localResult.Allowed {
|
|
post.Status = "removed"
|
|
modFlagged = true
|
|
log.Warn().Str("reason", localResult.Reason).Msg("Beacon flagged by local AI")
|
|
}
|
|
}
|
|
if h.openRouterService != nil {
|
|
// Try beacon-specific config first, fall back to generic
|
|
textResult, textErr := h.openRouterService.ModerateWithType(c.Request.Context(), "beacon_text", req.Body, nil)
|
|
if textResult == nil || textErr != nil {
|
|
textResult, _ = h.openRouterService.ModerateText(c.Request.Context(), req.Body)
|
|
}
|
|
if textResult != nil {
|
|
if textResult.Action == "flag" {
|
|
post.Status = "removed"
|
|
modFlagged = true
|
|
log.Warn().Msg("Beacon removed by OpenRouter — not allowed content")
|
|
} else if textResult.Action == "nsfw" {
|
|
post.IsNSFW = true
|
|
modFlagged = true
|
|
}
|
|
}
|
|
if req.ImageURL != nil && *req.ImageURL != "" && post.Status == "active" {
|
|
imgResult, imgErr := h.openRouterService.ModerateWithType(c.Request.Context(), "beacon_image", "", []string{*req.ImageURL})
|
|
if imgResult == nil || imgErr != nil {
|
|
imgResult, _ = h.openRouterService.ModerateImage(c.Request.Context(), *req.ImageURL)
|
|
}
|
|
if imgResult != nil {
|
|
if imgResult.Action == "flag" {
|
|
post.Status = "removed"
|
|
modFlagged = true
|
|
} else if imgResult.Action == "nsfw" {
|
|
post.IsNSFW = true
|
|
modFlagged = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.postRepo.CreatePost(c.Request.Context(), post); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create beacon", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Flag for admin review if moderation triggered (beacon stays active unless "removed")
|
|
if modFlagged && h.moderationService != nil && post.Status != "removed" {
|
|
_ = h.moderationService.FlagPost(c.Request.Context(), post.ID, nil, "beacon_moderation_flag")
|
|
}
|
|
|
|
log.Info().
|
|
Str("beacon_id", post.ID.String()).
|
|
Str("beacon_type", beaconType).
|
|
Str("status", post.Status).
|
|
Bool("mod_flagged", modFlagged).
|
|
Float64("lat", lat).
|
|
Float64("long", long).
|
|
Msg("Beacon created anonymously")
|
|
|
|
// Return anonymous beacon data — no author info
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"beacon": gin.H{
|
|
"id": post.ID,
|
|
"body": post.Body,
|
|
"beacon_type": beaconType,
|
|
"beacon_lat": lat,
|
|
"beacon_long": long,
|
|
"severity": severity,
|
|
"confidence_score": post.Confidence,
|
|
"is_active_beacon": true,
|
|
"incident_status": "active",
|
|
"radius": 500,
|
|
"status_color": beaconStatusColor(post.Confidence),
|
|
"image_url": post.ImageURL,
|
|
"created_at": post.CreatedAt,
|
|
"vouch_count": 0,
|
|
"report_count": 0,
|
|
},
|
|
})
|
|
}
|
|
|
|
// beaconStatusColor returns green/yellow/red based on confidence score.
|
|
func beaconStatusColor(confidence float64) string {
|
|
if confidence > 0.7 {
|
|
return "green"
|
|
} else if confidence >= 0.3 {
|
|
return "yellow"
|
|
}
|
|
return "red"
|
|
}
|
|
|
|
func (h *PostHandler) CreatePost(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
userID, _ := uuid.Parse(userIDStr.(string))
|
|
|
|
var req struct {
|
|
CategoryID *string `json:"category_id"`
|
|
Body string `json:"body" binding:"required,max=500"`
|
|
ImageURL *string `json:"image_url"`
|
|
VideoURL *string `json:"video_url"`
|
|
Thumbnail *string `json:"thumbnail_url"`
|
|
DurationMS *int `json:"duration_ms"`
|
|
AllowChain *bool `json:"allow_chain"`
|
|
ChainParentID *string `json:"chain_parent_id"`
|
|
IsBeacon bool `json:"is_beacon"`
|
|
BeaconType *string `json:"beacon_type"`
|
|
Severity *string `json:"severity"`
|
|
BeaconLat *float64 `json:"beacon_lat"`
|
|
BeaconLong *float64 `json:"beacon_long"`
|
|
TTLHours *int `json:"ttl_hours"`
|
|
IsNSFW bool `json:"is_nsfw"`
|
|
NSFWReason string `json:"nsfw_reason"`
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Layer 0: Hard blocklist check — reject immediately, never save
|
|
if h.contentFilter != nil {
|
|
result := h.contentFilter.CheckContent(req.Body)
|
|
if result.Blocked {
|
|
strikeCount, consequence, _ := h.contentFilter.RecordStrikeWithIP(c.Request.Context(), userID, result.Category, req.Body, c.ClientIP())
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
|
"error": result.Message,
|
|
"blocked": true,
|
|
"category": result.Category,
|
|
"strikes": strikeCount,
|
|
"consequence": consequence,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// 1. Check rate limit (Simplification)
|
|
trustState, err := h.userRepo.GetTrustState(c.Request.Context(), userID.String())
|
|
if err == nil && trustState.PostsToday >= 50 { // Example hard limit
|
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Daily post limit reached"})
|
|
return
|
|
}
|
|
|
|
// 2. Extract tags
|
|
tags := utils.ExtractHashtags(req.Body)
|
|
|
|
// 3. Mock Tone Check (In production, this would call a service or AI model)
|
|
tone := "neutral"
|
|
cis := 0.8
|
|
|
|
// 4. Resolve TTL
|
|
var expiresAt *time.Time
|
|
if req.TTLHours != nil && *req.TTLHours > 0 {
|
|
t := time.Now().Add(time.Duration(*req.TTLHours) * time.Hour)
|
|
expiresAt = &t
|
|
}
|
|
|
|
duration := 0
|
|
if req.DurationMS != nil {
|
|
duration = *req.DurationMS
|
|
}
|
|
|
|
allowChain := !req.IsBeacon
|
|
if req.AllowChain != nil {
|
|
allowChain = *req.AllowChain
|
|
}
|
|
|
|
if req.ChainParentID != nil && *req.ChainParentID != "" {
|
|
log.Info().
|
|
Str("chain_parent_id", *req.ChainParentID).
|
|
Bool("allow_chain", allowChain).
|
|
Msg("CreatePost with chain parent")
|
|
} else {
|
|
log.Info().
|
|
Bool("allow_chain", allowChain).
|
|
Msg("CreatePost without chain parent")
|
|
}
|
|
|
|
severity := "medium"
|
|
if req.Severity != nil && *req.Severity != "" {
|
|
severity = *req.Severity
|
|
}
|
|
|
|
post := &models.Post{
|
|
AuthorID: userID,
|
|
Body: req.Body,
|
|
Status: "active",
|
|
ToneLabel: &tone,
|
|
CISScore: &cis,
|
|
ImageURL: req.ImageURL,
|
|
VideoURL: req.VideoURL,
|
|
ThumbnailURL: req.Thumbnail,
|
|
DurationMS: duration,
|
|
BodyFormat: "plain",
|
|
Tags: tags,
|
|
IsBeacon: req.IsBeacon,
|
|
BeaconType: req.BeaconType,
|
|
Severity: severity,
|
|
IncidentStatus: "active",
|
|
Radius: 500,
|
|
Confidence: 0.5, // Initial confidence
|
|
IsActiveBeacon: req.IsBeacon,
|
|
AllowChain: allowChain,
|
|
Visibility: func() string {
|
|
if req.Visibility == "neighborhood" {
|
|
return "neighborhood"
|
|
}
|
|
return "public"
|
|
}(),
|
|
ExpiresAt: expiresAt,
|
|
IsNSFW: req.IsNSFW,
|
|
NSFWReason: req.NSFWReason,
|
|
Lat: req.BeaconLat,
|
|
Long: req.BeaconLong,
|
|
}
|
|
|
|
if req.CategoryID != nil {
|
|
catID, _ := uuid.Parse(*req.CategoryID)
|
|
post.CategoryID = &catID
|
|
}
|
|
|
|
if req.ChainParentID != nil && *req.ChainParentID != "" {
|
|
parentID, err := uuid.Parse(*req.ChainParentID)
|
|
if err == nil {
|
|
post.ChainParentID = &parentID
|
|
}
|
|
}
|
|
|
|
// 5. AI Moderation — runs whichever engine the admin selected in ai_moderation_config
|
|
userSelfLabeledNSFW := req.IsNSFW
|
|
orDecision := ""
|
|
var cachedScores *services.ThreePoisonsScore
|
|
var cachedReason string
|
|
ctx := c.Request.Context()
|
|
|
|
{
|
|
// Read admin-selected engine from ai_moderation_config
|
|
selectedEngine := "" // empty = no AI moderation configured
|
|
if h.openRouterService != nil {
|
|
cfg, cfgErr := h.openRouterService.GetModerationConfig(ctx, "text")
|
|
if cfgErr == nil && cfg != nil && cfg.Enabled && len(cfg.Engines) > 0 {
|
|
selectedEngine = cfg.Engines[0]
|
|
}
|
|
}
|
|
|
|
switch selectedEngine {
|
|
case "local_ai":
|
|
if h.localAIService != nil && req.Body != "" {
|
|
localResult, localErr := h.localAIService.ModerateText(ctx, req.Body)
|
|
if localErr != nil {
|
|
log.Debug().Err(localErr).Msg("Local AI moderation unavailable")
|
|
} else if localResult != nil && !localResult.Allowed {
|
|
post.Status = "removed"
|
|
log.Warn().Str("reason", localResult.Reason).Str("severity", localResult.Severity).Msg("Post flagged by local AI")
|
|
}
|
|
}
|
|
|
|
case "openai":
|
|
if h.moderationService != nil {
|
|
mediaURLs := []string{}
|
|
if req.ImageURL != nil && *req.ImageURL != "" {
|
|
mediaURLs = append(mediaURLs, *req.ImageURL)
|
|
}
|
|
if req.VideoURL != nil && *req.VideoURL != "" {
|
|
mediaURLs = append(mediaURLs, *req.VideoURL)
|
|
}
|
|
if req.Thumbnail != nil && *req.Thumbnail != "" {
|
|
mediaURLs = append(mediaURLs, *req.Thumbnail)
|
|
}
|
|
scores, reason, err := h.moderationService.AnalyzeContent(ctx, req.Body, mediaURLs)
|
|
if err == nil {
|
|
cachedScores = scores
|
|
cachedReason = reason
|
|
cis = (scores.Hate + scores.Greed + scores.Delusion) / 3.0
|
|
cis = 1.0 - cis
|
|
post.CISScore = &cis
|
|
post.ToneLabel = &reason
|
|
if reason != "" {
|
|
post.Status = "pending_moderation"
|
|
}
|
|
}
|
|
}
|
|
|
|
case "google":
|
|
if h.moderationService != nil && h.moderationService.HasGoogleVision() {
|
|
mediaURLs := []string{}
|
|
if req.ImageURL != nil && *req.ImageURL != "" {
|
|
mediaURLs = append(mediaURLs, *req.ImageURL)
|
|
}
|
|
if req.Thumbnail != nil && *req.Thumbnail != "" {
|
|
mediaURLs = append(mediaURLs, *req.Thumbnail)
|
|
}
|
|
if len(mediaURLs) > 0 {
|
|
scores, err := h.moderationService.AnalyzeMediaWithGoogleVision(ctx, mediaURLs)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Google Vision moderation failed")
|
|
} else {
|
|
cachedScores = scores
|
|
cis = (scores.Hate + scores.Greed + scores.Delusion) / 3.0
|
|
cis = 1.0 - cis
|
|
post.CISScore = &cis
|
|
if scores.Hate > 0.5 || scores.Delusion > 0.5 {
|
|
cachedReason = "Google Vision flagged image content"
|
|
post.Status = "removed"
|
|
log.Warn().Float64("hate", scores.Hate).Float64("delusion", scores.Delusion).Msg("Post flagged by Google Vision")
|
|
} else if scores.Hate > 0.25 || scores.Delusion > 0.25 {
|
|
post.IsNSFW = true
|
|
post.NSFWReason = "Google Vision detected potentially sensitive image content"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
case "openrouter":
|
|
if h.openRouterService != nil {
|
|
// Text moderation
|
|
if req.Body != "" {
|
|
textResult, textErr := h.openRouterService.ModerateText(ctx, req.Body)
|
|
if textErr == nil && textResult != nil {
|
|
log.Info().Str("action", textResult.Action).Msg("OpenRouter text moderation")
|
|
if textResult.Action == "flag" {
|
|
orDecision = "flag"
|
|
post.Status = "removed"
|
|
} else if textResult.Action == "nsfw" {
|
|
orDecision = "nsfw"
|
|
post.IsNSFW = true
|
|
if textResult.NSFWReason != "" {
|
|
post.NSFWReason = textResult.NSFWReason
|
|
}
|
|
}
|
|
if textResult.Hate > 0 || textResult.Greed > 0 || textResult.Delusion > 0 {
|
|
orCis := 1.0 - (textResult.Hate+textResult.Greed+textResult.Delusion)/3.0
|
|
post.CISScore = &orCis
|
|
}
|
|
}
|
|
}
|
|
// Image moderation (only if text didn't already flag)
|
|
if post.Status != "removed" && req.ImageURL != nil && *req.ImageURL != "" {
|
|
imgResult, imgErr := h.openRouterService.ModerateImage(ctx, *req.ImageURL)
|
|
if imgErr == nil && imgResult != nil {
|
|
log.Info().Str("action", imgResult.Action).Msg("OpenRouter image moderation")
|
|
if imgResult.Action == "flag" {
|
|
orDecision = "flag"
|
|
post.Status = "removed"
|
|
} else if imgResult.Action == "nsfw" && orDecision != "flag" {
|
|
orDecision = "nsfw"
|
|
post.IsNSFW = true
|
|
if imgResult.NSFWReason != "" {
|
|
post.NSFWReason = imgResult.NSFWReason
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Enhanced video moderation with frame extraction
|
|
if post.Status != "removed" && req.VideoURL != nil && *req.VideoURL != "" {
|
|
// First check thumbnail moderation
|
|
if req.Thumbnail != nil && *req.Thumbnail != "" {
|
|
vidResult, vidErr := h.openRouterService.ModerateImage(ctx, *req.Thumbnail)
|
|
if vidErr == nil && vidResult != nil {
|
|
log.Info().Str("action", vidResult.Action).Msg("OpenRouter video thumbnail moderation")
|
|
if vidResult.Action == "flag" {
|
|
orDecision = "flag"
|
|
post.Status = "removed"
|
|
} else if vidResult.Action == "nsfw" && orDecision != "flag" {
|
|
orDecision = "nsfw"
|
|
post.IsNSFW = true
|
|
if vidResult.NSFWReason != "" {
|
|
post.NSFWReason = vidResult.NSFWReason
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract and analyze video frames for deeper moderation
|
|
if post.Status != "removed" && h.videoProcessor != nil {
|
|
frameURLs, err := h.videoProcessor.ExtractFrames(ctx, *req.VideoURL, 3)
|
|
if err == nil && len(frameURLs) > 0 {
|
|
// Analyze extracted frames with Azure OpenAI Vision
|
|
if h.moderationService != nil {
|
|
_, frameReason, frameErr := h.moderationService.AnalyzeContent(ctx, "Video frame analysis", frameURLs)
|
|
if frameErr == nil && frameReason != "" {
|
|
log.Info().Str("reason", frameReason).Msg("Video frame analysis completed")
|
|
if strings.Contains(strings.ToLower(frameReason), "flag") || strings.Contains(strings.ToLower(frameReason), "remove") {
|
|
orDecision = "flag"
|
|
post.Status = "removed"
|
|
} else if strings.Contains(strings.ToLower(frameReason), "nsfw") && orDecision != "flag" {
|
|
orDecision = "nsfw"
|
|
post.IsNSFW = true
|
|
post.NSFWReason = "Video content flagged by frame analysis"
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
log.Debug().Err(err).Msg("Failed to extract video frames for moderation")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
default:
|
|
log.Debug().Msg("No moderation engine configured in admin settings")
|
|
}
|
|
}
|
|
|
|
// Create post
|
|
err = h.postRepo.CreatePost(c.Request.Context(), post)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Handle Flags — reuse cached scores from parallel moderation (no duplicate API call)
|
|
if h.moderationService != nil && (post.Status == "pending_moderation" || post.Status == "removed") {
|
|
if cachedScores != nil {
|
|
_ = h.moderationService.FlagPost(c.Request.Context(), post.ID, cachedScores, cachedReason)
|
|
}
|
|
}
|
|
|
|
// NSFW auto-reclassify: AI says NSFW but user didn't self-label → send warning
|
|
if post.IsNSFW && !userSelfLabeledNSFW && h.notificationService != nil {
|
|
go func() {
|
|
ctx := context.Background()
|
|
h.notificationService.NotifyNSFWWarning(ctx, userID.String(), post.ID.String())
|
|
log.Info().Str("post_id", post.ID.String()).Str("author_id", userID.String()).Msg("NSFW warning sent — post auto-labeled")
|
|
}()
|
|
}
|
|
|
|
// NOT ALLOWED: AI flagged → post removed, create violation, send appeal notification + email
|
|
if post.Status == "removed" && orDecision == "flag" {
|
|
go func() {
|
|
ctx := context.Background()
|
|
|
|
// Send in-app notification
|
|
if h.notificationService != nil {
|
|
h.notificationService.NotifyContentRemoved(ctx, userID.String(), post.ID.String())
|
|
}
|
|
|
|
// Create moderation violation record
|
|
if h.moderationService != nil {
|
|
h.moderationService.FlagPost(ctx, post.ID, &services.ThreePoisonsScore{Hate: 1.0}, "not_allowed")
|
|
}
|
|
|
|
// Send appeal email — get email from users table, display name from profiles
|
|
var userEmail string
|
|
h.postRepo.Pool().QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&userEmail)
|
|
profile, _ := h.userRepo.GetProfileByID(ctx, userID.String())
|
|
if userEmail != "" {
|
|
displayName := "there"
|
|
if profile != nil && profile.DisplayName != nil {
|
|
displayName = *profile.DisplayName
|
|
}
|
|
snippet := req.Body
|
|
if len(snippet) > 100 {
|
|
snippet = snippet[:100] + "..."
|
|
}
|
|
appealBody := fmt.Sprintf(
|
|
"Hi %s,\n\n"+
|
|
"Your recent post on Sojorn was removed because it was found to violate our community guidelines.\n\n"+
|
|
"Post content: \"%s\"\n\n"+
|
|
"If you believe this was a mistake, you can appeal this decision in your Sojorn app:\n"+
|
|
"Go to Profile → Settings → Appeals\n\n"+
|
|
"Our moderation team will review your appeal within 48 hours.\n\n"+
|
|
"— The Sojorn Team",
|
|
displayName, snippet,
|
|
)
|
|
log.Info().Str("email", userEmail).Msg("Sending content removal appeal email")
|
|
h.postRepo.Pool().Exec(ctx,
|
|
`INSERT INTO email_queue (to_email, subject, body, created_at) VALUES ($1, $2, $3, NOW()) ON CONFLICT DO NOTHING`,
|
|
userEmail, "Your Sojorn post was removed", appealBody,
|
|
)
|
|
}
|
|
|
|
log.Warn().Str("post_id", post.ID.String()).Str("author_id", userID.String()).Msg("Post removed by AI moderation — not allowed content")
|
|
}()
|
|
}
|
|
|
|
// Log AI moderation decision to audit log — async
|
|
if h.moderationService != nil {
|
|
postID := post.ID
|
|
postStatus := post.Status
|
|
postIsNSFW := post.IsNSFW
|
|
postCIS := post.CISScore
|
|
postTone := post.ToneLabel
|
|
reqBody := req.Body
|
|
go func() {
|
|
decision := "pass"
|
|
flagReason := ""
|
|
if postTone != nil && *postTone != "" {
|
|
flagReason = *postTone
|
|
}
|
|
if postStatus == "removed" || postStatus == "pending_moderation" {
|
|
decision = "flag"
|
|
} else if postIsNSFW {
|
|
decision = "nsfw"
|
|
}
|
|
var scores *services.ThreePoisonsScore
|
|
if postCIS != nil {
|
|
invCis := 1.0 - *postCIS
|
|
scores = &services.ThreePoisonsScore{Hate: invCis, Greed: 0, Delusion: 0}
|
|
} else {
|
|
scores = &services.ThreePoisonsScore{}
|
|
}
|
|
h.moderationService.LogAIDecision(context.Background(), "post", postID, userID, reqBody, scores, nil, decision, flagReason, orDecision, nil)
|
|
}()
|
|
}
|
|
|
|
// Auto-extract link preview — fully async, never blocks the response
|
|
if h.linkPreviewService != nil {
|
|
linkURL := services.ExtractFirstURL(req.Body)
|
|
if linkURL != "" {
|
|
postID := post.ID.String()
|
|
go func() {
|
|
bgCtx, bgCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer bgCancel()
|
|
var isOfficial bool
|
|
_ = h.postRepo.Pool().QueryRow(bgCtx, `SELECT COALESCE(is_official, false) FROM profiles WHERE id = $1`, userID).Scan(&isOfficial)
|
|
lp, lpErr := h.linkPreviewService.FetchPreview(bgCtx, linkURL, isOfficial)
|
|
if lpErr == nil && lp != nil {
|
|
h.linkPreviewService.ProxyImageToR2(bgCtx, lp)
|
|
_ = h.linkPreviewService.SaveLinkPreview(bgCtx, postID, lp)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// Check for @mentions and notify mentioned users
|
|
go func() {
|
|
if h.notificationService != nil && strings.Contains(req.Body, "@") {
|
|
postIDStr := post.ID.String()
|
|
h.notificationService.NotifyMention(c.Request.Context(), userIDStr.(string), postIDStr, req.Body)
|
|
}
|
|
}()
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"post": post,
|
|
"tags": tags,
|
|
"tone_analysis": gin.H{
|
|
"tone": tone,
|
|
"cis": cis,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (h *PostHandler) GetFeed(c *gin.Context) {
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
limit := utils.GetQueryInt(c, "limit", 20)
|
|
offset := utils.GetQueryInt(c, "offset", 0)
|
|
category := c.Query("category")
|
|
hasVideo := c.Query("has_video") == "true"
|
|
|
|
// Check user's NSFW preference
|
|
showNSFW := false
|
|
if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), userIDStr.(string)); err == nil && settings.NSFWEnabled != nil {
|
|
showNSFW = *settings.NSFWEnabled
|
|
}
|
|
|
|
posts, err := h.feedService.GetFeed(c.Request.Context(), userIDStr.(string), category, hasVideo, limit, offset, showNSFW)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feed", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.enrichLinkPreviews(c.Request.Context(), posts)
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetProfilePosts(c *gin.Context) {
|
|
authorID := c.Param("id")
|
|
if authorID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Author ID required"})
|
|
return
|
|
}
|
|
|
|
limit := utils.GetQueryInt(c, "limit", 20)
|
|
offset := utils.GetQueryInt(c, "offset", 0)
|
|
onlyChains := c.Query("chained") == "true"
|
|
|
|
viewerID := ""
|
|
if val, exists := c.Get("user_id"); exists {
|
|
viewerID = val.(string)
|
|
}
|
|
|
|
// Check viewer's NSFW preference
|
|
showNSFW := false
|
|
if viewerID != "" {
|
|
if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), viewerID); err == nil && settings.NSFWEnabled != nil {
|
|
showNSFW = *settings.NSFWEnabled
|
|
}
|
|
}
|
|
|
|
posts, err := h.postRepo.GetPostsByAuthor(c.Request.Context(), authorID, viewerID, limit, offset, onlyChains, showNSFW)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile posts", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.enrichLinkPreviews(c.Request.Context(), posts)
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetPost(c *gin.Context) {
|
|
log.Error().Msg("=== DEBUG: GetPost handler called ===")
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
// Check viewer's NSFW preference
|
|
showNSFW := false
|
|
if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), userIDStr.(string)); err == nil && settings.NSFWEnabled != nil {
|
|
showNSFW = *settings.NSFWEnabled
|
|
}
|
|
|
|
post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string), showNSFW)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"})
|
|
return
|
|
}
|
|
|
|
// Sign URL
|
|
if post.ImageURL != nil {
|
|
signed := h.assetService.SignImageURL(*post.ImageURL)
|
|
post.ImageURL = &signed
|
|
}
|
|
if post.VideoURL != nil {
|
|
signed := h.assetService.SignVideoURL(*post.VideoURL)
|
|
post.VideoURL = &signed
|
|
}
|
|
if post.ThumbnailURL != nil {
|
|
signed := h.assetService.SignImageURL(*post.ThumbnailURL)
|
|
post.ThumbnailURL = &signed
|
|
}
|
|
|
|
h.enrichSinglePostLinkPreview(c.Request.Context(), post)
|
|
c.JSON(http.StatusOK, gin.H{"post": post})
|
|
}
|
|
|
|
func (h *PostHandler) UpdatePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
Body string `json:"body" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
err := h.postRepo.UpdatePost(c.Request.Context(), postID, userIDStr.(string), req.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post updated"})
|
|
}
|
|
|
|
func (h *PostHandler) DeletePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.DeletePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post deleted"})
|
|
}
|
|
|
|
func (h *PostHandler) PinPost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
Pinned bool `json:"pinned"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
err := h.postRepo.PinPost(c.Request.Context(), postID, userIDStr.(string), req.Pinned)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to pin/unpin post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post pin status updated"})
|
|
}
|
|
|
|
func (h *PostHandler) UpdateVisibility(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
Visibility string `json:"visibility" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
err := h.postRepo.UpdateVisibility(c.Request.Context(), postID, userIDStr.(string), req.Visibility)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update visibility", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post visibility updated"})
|
|
}
|
|
|
|
func (h *PostHandler) LikePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.LikePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to like post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Send push notification to post author
|
|
go func() {
|
|
// Use Background context because the request context will be cancelled
|
|
bgCtx := context.Background()
|
|
post, err := h.postRepo.GetPostByID(bgCtx, postID, userIDStr.(string))
|
|
if err != nil || post.AuthorID.String() == userIDStr.(string) {
|
|
return // Don't notify self
|
|
}
|
|
|
|
if h.notificationService != nil {
|
|
postType := "standard"
|
|
if post.IsBeacon {
|
|
postType = "beacon"
|
|
} else if post.VideoURL != nil && *post.VideoURL != "" {
|
|
postType = "quip"
|
|
}
|
|
|
|
h.notificationService.NotifyLike(
|
|
bgCtx,
|
|
post.AuthorID.String(),
|
|
userIDStr.(string),
|
|
postID,
|
|
postType,
|
|
"❤️",
|
|
)
|
|
}
|
|
}()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post liked"})
|
|
}
|
|
|
|
func (h *PostHandler) UnlikePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.UnlikePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unlike post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
|
|
}
|
|
|
|
func (h *PostHandler) SavePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.SavePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Send push notification to post author
|
|
go func() {
|
|
bgCtx := context.Background()
|
|
post, err := h.postRepo.GetPostByID(bgCtx, postID, userIDStr.(string))
|
|
if err != nil || post.AuthorID.String() == userIDStr.(string) {
|
|
return // Don't notify self
|
|
}
|
|
|
|
actor, err := h.userRepo.GetProfileByID(bgCtx, userIDStr.(string))
|
|
if err != nil || h.notificationService == nil {
|
|
return
|
|
}
|
|
|
|
// Determine post type for proper deep linking
|
|
postType := "standard"
|
|
if post.IsBeacon {
|
|
postType = "beacon"
|
|
} else if post.VideoURL != nil && *post.VideoURL != "" {
|
|
postType = "quip"
|
|
}
|
|
|
|
metadata := map[string]interface{}{
|
|
"actor_name": actor.DisplayName,
|
|
"post_id": postID,
|
|
"post_type": postType,
|
|
}
|
|
h.notificationService.CreateNotification(
|
|
context.Background(),
|
|
post.AuthorID.String(),
|
|
userIDStr.(string),
|
|
"save",
|
|
&postID,
|
|
nil,
|
|
metadata,
|
|
)
|
|
}()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post saved"})
|
|
}
|
|
|
|
func (h *PostHandler) UnsavePost(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.UnsavePost(c.Request.Context(), postID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsave post", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Post unsaved"})
|
|
}
|
|
|
|
func (h *PostHandler) GetSavedPosts(c *gin.Context) {
|
|
userID := c.Param("id")
|
|
if userID == "" || userID == "me" {
|
|
userIDStr, exists := c.Get("user_id")
|
|
if exists {
|
|
userID = userIDStr.(string)
|
|
}
|
|
}
|
|
|
|
if userID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
|
return
|
|
}
|
|
|
|
limit := utils.GetQueryInt(c, "limit", 20)
|
|
offset := utils.GetQueryInt(c, "offset", 0)
|
|
|
|
// Check viewer's NSFW preference
|
|
showNSFW := false
|
|
if viewerID, exists := c.Get("user_id"); exists {
|
|
if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), viewerID.(string)); err == nil && settings.NSFWEnabled != nil {
|
|
showNSFW = *settings.NSFWEnabled
|
|
}
|
|
}
|
|
|
|
posts, err := h.postRepo.GetSavedPosts(c.Request.Context(), userID, limit, offset, showNSFW)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved posts", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.enrichLinkPreviews(c.Request.Context(), posts)
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetLikedPosts(c *gin.Context) {
|
|
userID := c.Param("id")
|
|
if userID == "" || userID == "me" {
|
|
userIDStr, exists := c.Get("user_id")
|
|
if exists {
|
|
userID = userIDStr.(string)
|
|
}
|
|
}
|
|
|
|
if userID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
|
|
return
|
|
}
|
|
|
|
limit := utils.GetQueryInt(c, "limit", 20)
|
|
offset := utils.GetQueryInt(c, "offset", 0)
|
|
|
|
// Check viewer's NSFW preference
|
|
showNSFW := false
|
|
if viewerID, exists := c.Get("user_id"); exists {
|
|
if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), viewerID.(string)); err == nil && settings.NSFWEnabled != nil {
|
|
showNSFW = *settings.NSFWEnabled
|
|
}
|
|
}
|
|
|
|
posts, err := h.postRepo.GetLikedPosts(c.Request.Context(), userID, limit, offset, showNSFW)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch liked posts", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.enrichLinkPreviews(c.Request.Context(), posts)
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetPostChain(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
|
|
// Check viewer's NSFW preference
|
|
showNSFW := false
|
|
if viewerID, exists := c.Get("user_id"); exists {
|
|
if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), viewerID.(string)); err == nil && settings.NSFWEnabled != nil {
|
|
showNSFW = *settings.NSFWEnabled
|
|
}
|
|
}
|
|
|
|
posts, err := h.postRepo.GetPostChain(c.Request.Context(), postID, showNSFW)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch post chain", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Sign URLs for all posts in the chain
|
|
for i := range posts {
|
|
if posts[i].ImageURL != nil {
|
|
signed := h.assetService.SignImageURL(*posts[i].ImageURL)
|
|
posts[i].ImageURL = &signed
|
|
}
|
|
if posts[i].VideoURL != nil {
|
|
signed := h.assetService.SignVideoURL(*posts[i].VideoURL)
|
|
posts[i].VideoURL = &signed
|
|
}
|
|
if posts[i].ThumbnailURL != nil {
|
|
signed := h.assetService.SignImageURL(*posts[i].ThumbnailURL)
|
|
posts[i].ThumbnailURL = &signed
|
|
}
|
|
}
|
|
|
|
h.enrichLinkPreviews(c.Request.Context(), posts)
|
|
c.JSON(http.StatusOK, gin.H{"posts": posts})
|
|
}
|
|
|
|
func (h *PostHandler) GetPostFocusContext(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
// Check viewer's NSFW preference
|
|
showNSFW := false
|
|
if settings, err := h.userRepo.GetUserSettings(c.Request.Context(), userIDStr.(string)); err == nil && settings.NSFWEnabled != nil {
|
|
showNSFW = *settings.NSFWEnabled
|
|
}
|
|
|
|
focusContext, err := h.postRepo.GetPostFocusContext(c.Request.Context(), postID, userIDStr.(string), showNSFW)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch focus context", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.signPostMedia(focusContext.TargetPost)
|
|
h.signPostMedia(focusContext.ParentPost)
|
|
for i := range focusContext.Children {
|
|
h.signPostMedia(&focusContext.Children[i])
|
|
}
|
|
|
|
// Enrich link previews for all posts in focus context
|
|
h.enrichSinglePostLinkPreview(c.Request.Context(), focusContext.TargetPost)
|
|
h.enrichSinglePostLinkPreview(c.Request.Context(), focusContext.ParentPost)
|
|
for i := range focusContext.Children {
|
|
h.enrichSinglePostLinkPreview(c.Request.Context(), &focusContext.Children[i])
|
|
}
|
|
|
|
c.JSON(http.StatusOK, focusContext)
|
|
}
|
|
|
|
func (h *PostHandler) signPostMedia(post *models.Post) {
|
|
if post == nil {
|
|
return
|
|
}
|
|
if post.ImageURL != nil {
|
|
signed := h.assetService.SignImageURL(*post.ImageURL)
|
|
post.ImageURL = &signed
|
|
}
|
|
if post.VideoURL != nil {
|
|
signed := h.assetService.SignVideoURL(*post.VideoURL)
|
|
post.VideoURL = &signed
|
|
}
|
|
if post.ThumbnailURL != nil {
|
|
signed := h.assetService.SignImageURL(*post.ThumbnailURL)
|
|
post.ThumbnailURL = &signed
|
|
}
|
|
}
|
|
|
|
func (h *PostHandler) VouchBeacon(c *gin.Context) {
|
|
beaconID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.VouchBeacon(c.Request.Context(), beaconID, userIDStr.(string))
|
|
if err != nil {
|
|
if err.Error() == "post is not a beacon" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to vouch for beacon", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Beacons are anonymous — no notifications sent to preserve privacy
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Beacon vouched successfully"})
|
|
}
|
|
|
|
func (h *PostHandler) ReportBeacon(c *gin.Context) {
|
|
beaconID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.ReportBeacon(c.Request.Context(), beaconID, userIDStr.(string))
|
|
if err != nil {
|
|
if err.Error() == "post is not a beacon" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to report beacon", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Beacons are anonymous — no notifications sent to preserve privacy
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Beacon reported successfully"})
|
|
}
|
|
|
|
func (h *PostHandler) RemoveBeaconVote(c *gin.Context) {
|
|
beaconID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
err := h.postRepo.RemoveBeaconVote(c.Request.Context(), beaconID, userIDStr.(string))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove beacon vote", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Beacon vote removed successfully"})
|
|
}
|
|
|
|
func (h *PostHandler) ToggleReaction(c *gin.Context) {
|
|
postID := c.Param("id")
|
|
userIDStr, _ := c.Get("user_id")
|
|
|
|
var req struct {
|
|
Emoji string `json:"emoji" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
emoji := strings.TrimSpace(req.Emoji)
|
|
if emoji == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"})
|
|
return
|
|
}
|
|
|
|
counts, myReactions, err := h.postRepo.ToggleReaction(c.Request.Context(), postID, userIDStr.(string), emoji)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle reaction", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Check if reaction was added (exists in myReactions)
|
|
reactionAdded := false
|
|
for _, r := range myReactions {
|
|
if r == emoji {
|
|
reactionAdded = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if reactionAdded {
|
|
go func() {
|
|
bgCtx := context.Background()
|
|
post, err := h.postRepo.GetPostByID(bgCtx, postID, userIDStr.(string))
|
|
if err != nil || post.AuthorID.String() == userIDStr.(string) {
|
|
return // Don't notify self
|
|
}
|
|
|
|
if h.notificationService != nil {
|
|
// Get actor details
|
|
actor, err := h.userRepo.GetProfileByID(bgCtx, userIDStr.(string))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
metadata := map[string]interface{}{
|
|
"actor_name": actor.DisplayName,
|
|
"post_id": postID,
|
|
"emoji": emoji,
|
|
}
|
|
|
|
// Using "like" type for now, or "quip_reaction" if quip
|
|
notifType := "like"
|
|
if post.VideoURL != nil && *post.VideoURL != "" {
|
|
notifType = "quip_reaction"
|
|
metadata["post_type"] = "quip"
|
|
} else {
|
|
metadata["post_type"] = "post"
|
|
}
|
|
|
|
h.notificationService.CreateNotification(
|
|
bgCtx,
|
|
post.AuthorID.String(),
|
|
userIDStr.(string),
|
|
notifType,
|
|
&postID,
|
|
nil,
|
|
metadata,
|
|
)
|
|
}
|
|
}()
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"reactions": counts,
|
|
"my_reactions": myReactions,
|
|
})
|
|
}
|
|
|
|
// GetSafeDomains returns the list of approved safe domains for the Flutter app.
|
|
func (h *PostHandler) GetSafeDomains(c *gin.Context) {
|
|
if h.linkPreviewService == nil {
|
|
c.JSON(http.StatusOK, gin.H{"domains": []string{}})
|
|
return
|
|
}
|
|
domains, err := h.linkPreviewService.ListSafeDomains(c.Request.Context(), "", true)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"domains": domains})
|
|
}
|
|
|
|
// CheckURLSafety checks if a URL is from a safe domain.
|
|
func (h *PostHandler) CheckURLSafety(c *gin.Context) {
|
|
urlStr := c.Query("url")
|
|
if urlStr == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "url parameter required"})
|
|
return
|
|
}
|
|
if h.linkPreviewService == nil {
|
|
c.JSON(http.StatusOK, gin.H{"safe": false, "status": "unknown"})
|
|
return
|
|
}
|
|
result := h.linkPreviewService.CheckURLSafety(c.Request.Context(), urlStr)
|
|
c.JSON(http.StatusOK, result)
|
|
}
|