feat: anonymous beacons, video metadata stripping, remove beacon author exposure
- GetNearbyBeacons no longer returns author info (anonymous placeholder) - Beacons excluded from profile posts (GetPostsByAuthor) - Vouch/report notifications removed for beacon privacy - MediaHandler strips all metadata (EXIF/GPS/device) via ffmpeg before R2 upload - Flutter beacon UI shows 'Anonymous Beacon' instead of author info
This commit is contained in:
parent
ecc02e10cc
commit
67f74deb58
|
|
@ -4,8 +4,9 @@ import (
|
|||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -13,6 +14,7 @@ import (
|
|||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// MediaHandler uploads media to Cloudflare R2. If s3Client is provided, it uses
|
||||
|
|
@ -70,6 +72,8 @@ func (h *MediaHandler) Upload(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
userID = "anon"
|
||||
|
|
@ -88,11 +92,32 @@ func (h *MediaHandler) Upload(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Read the uploaded file into memory
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read uploaded file"})
|
||||
return
|
||||
}
|
||||
rawBytes, err := io.ReadAll(src)
|
||||
src.Close()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read uploaded file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Strip metadata (EXIF/GPS from images, all metadata from videos)
|
||||
cleanBytes, stripErr := stripMetadata(rawBytes, mediaType, ext)
|
||||
if stripErr != nil {
|
||||
log.Warn().Err(stripErr).Str("type", mediaType).Msg("metadata strip failed, uploading original")
|
||||
cleanBytes = rawBytes
|
||||
}
|
||||
|
||||
var publicURL string
|
||||
reader := bytes.NewReader(cleanBytes)
|
||||
if h.useS3 {
|
||||
publicURL, err = h.putObjectS3(c, fileHeader, targetBucket, objectKey, targetDomain)
|
||||
publicURL, err = h.putObjectS3(c, reader, int64(len(cleanBytes)), contentType, targetBucket, objectKey, targetDomain)
|
||||
} else {
|
||||
publicURL, err = h.putObjectR2API(c, fileHeader, targetBucket, objectKey, targetDomain)
|
||||
publicURL, err = h.putObjectR2API(c, cleanBytes, contentType, targetBucket, objectKey, targetDomain)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to upload media: %v", err)})
|
||||
|
|
@ -105,25 +130,71 @@ func (h *MediaHandler) Upload(c *gin.Context) {
|
|||
"signedUrl": publicURL,
|
||||
"fileName": objectKey,
|
||||
"file_name": objectKey,
|
||||
"fileSize": fileHeader.Size,
|
||||
"file_size": fileHeader.Size,
|
||||
"fileSize": len(cleanBytes),
|
||||
"file_size": len(cleanBytes),
|
||||
"type": mediaType,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MediaHandler) putObjectS3(c *gin.Context, fileHeader *multipart.FileHeader, bucket string, key string, publicDomain string) (string, error) {
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
// stripMetadata uses ffmpeg to remove all metadata (EXIF, GPS, camera info)
|
||||
// from uploaded media before it goes to R2. Returns the cleaned bytes.
|
||||
func stripMetadata(raw []byte, mediaType string, ext string) ([]byte, error) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg not found: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "sojorn-media-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
inPath := filepath.Join(tmpDir, "input"+ext)
|
||||
outPath := filepath.Join(tmpDir, "output"+ext)
|
||||
|
||||
if err := os.WriteFile(inPath, raw, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Run ffmpeg: strip all metadata
|
||||
var args []string
|
||||
if mediaType == "video" {
|
||||
// Copy streams without re-encoding, just drop metadata
|
||||
args = []string{"-i", inPath, "-map_metadata", "-1", "-c", "copy", "-y", outPath}
|
||||
} else {
|
||||
// For images: re-encode to strip EXIF (ffmpeg decodes + encodes, drops metadata)
|
||||
args = []string{"-i", inPath, "-map_metadata", "-1", "-y", outPath}
|
||||
}
|
||||
|
||||
cmd := exec.Command("ffmpeg", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Warn().Str("stderr", stderr.String()).Msg("ffmpeg metadata strip stderr")
|
||||
return nil, fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
|
||||
cleanBytes, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("type", mediaType).
|
||||
Int("original_size", len(raw)).
|
||||
Int("stripped_size", len(cleanBytes)).
|
||||
Msg("metadata stripped from upload")
|
||||
|
||||
return cleanBytes, nil
|
||||
}
|
||||
|
||||
func (h *MediaHandler) putObjectS3(c *gin.Context, body io.ReadSeeker, contentLength int64, contentType string, bucket string, key string, publicDomain string) (string, error) {
|
||||
ctx := c.Request.Context()
|
||||
_, err = h.s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||
_, err := h.s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
Body: src,
|
||||
ContentType: &[]string{fileHeader.Header.Get("Content-Type")}[0],
|
||||
Body: body,
|
||||
ContentType: &contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -137,22 +208,11 @@ func (h *MediaHandler) putObjectS3(c *gin.Context, fileHeader *multipart.FileHea
|
|||
return key, nil
|
||||
}
|
||||
|
||||
func (h *MediaHandler) putObjectR2API(c *gin.Context, fileHeader *multipart.FileHeader, bucket string, key string, publicDomain string) (string, error) {
|
||||
func (h *MediaHandler) putObjectR2API(c *gin.Context, fileBytes []byte, contentType string, bucket string, key string, publicDomain string) (string, error) {
|
||||
if h.accountID == "" || h.apiToken == "" {
|
||||
return "", fmt.Errorf("R2 API credentials missing")
|
||||
}
|
||||
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
fileBytes, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/r2/buckets/%s/objects/%s",
|
||||
h.accountID, bucket, key)
|
||||
|
||||
|
|
@ -162,7 +222,7 @@ func (h *MediaHandler) putObjectR2API(c *gin.Context, fileHeader *multipart.File
|
|||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+h.apiToken)
|
||||
req.Header.Set("Content-Type", fileHeader.Header.Get("Content-Type"))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
|
|
|
|||
|
|
@ -791,27 +791,7 @@ func (h *PostHandler) VouchBeacon(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Get beacon details for notification
|
||||
beacon, err := h.postRepo.GetPostByID(c.Request.Context(), beaconID, userIDStr.(string))
|
||||
if err == nil && beacon.AuthorID.String() != userIDStr.(string) {
|
||||
// Get actor details
|
||||
actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string))
|
||||
if err == nil && h.notificationService != nil {
|
||||
metadata := map[string]interface{}{
|
||||
"actor_name": actor.DisplayName,
|
||||
"beacon_id": beaconID,
|
||||
}
|
||||
h.notificationService.CreateNotification(
|
||||
c.Request.Context(),
|
||||
beacon.AuthorID.String(),
|
||||
userIDStr.(string),
|
||||
"beacon_vouch",
|
||||
&beaconID,
|
||||
nil,
|
||||
metadata,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Beacons are anonymous — no notifications sent to preserve privacy
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Beacon vouched successfully"})
|
||||
}
|
||||
|
|
@ -830,27 +810,7 @@ func (h *PostHandler) ReportBeacon(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Get beacon details for notification
|
||||
beacon, err := h.postRepo.GetPostByID(c.Request.Context(), beaconID, userIDStr.(string))
|
||||
if err == nil && beacon.AuthorID.String() != userIDStr.(string) {
|
||||
// Get actor details
|
||||
actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string))
|
||||
if err == nil && h.notificationService != nil {
|
||||
metadata := map[string]interface{}{
|
||||
"actor_name": actor.DisplayName,
|
||||
"beacon_id": beaconID,
|
||||
}
|
||||
h.notificationService.CreateNotification(
|
||||
c.Request.Context(),
|
||||
beacon.AuthorID.String(),
|
||||
userIDStr.(string),
|
||||
"beacon_report",
|
||||
&beaconID,
|
||||
nil,
|
||||
metadata,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Beacons are anonymous — no notifications sent to preserve privacy
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Beacon reported successfully"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string,
|
|||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.author_id = $1::uuid AND p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND p.is_beacon = FALSE
|
||||
AND (
|
||||
p.author_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END -- Viewer is author
|
||||
OR pr.is_private = FALSE -- Public profile
|
||||
|
|
@ -482,14 +483,14 @@ func (r *PostRepository) CreateComment(ctx context.Context, comment *models.Comm
|
|||
}
|
||||
|
||||
func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long float64, radius int) ([]models.Post, error) {
|
||||
// Beacons are anonymous: we never expose author info to the API.
|
||||
// author_id is stored internally for abuse tracking only.
|
||||
query := `
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), p.tags, p.created_at,
|
||||
p.id, p.category_id, p.body, COALESCE(p.image_url, ''), p.tags, p.created_at,
|
||||
p.beacon_type, p.confidence_score, p.is_active_beacon,
|
||||
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as long,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url
|
||||
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as long
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
WHERE p.is_beacon = true
|
||||
AND ST_DWithin(p.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3)
|
||||
AND p.status = 'active'
|
||||
|
|
@ -505,18 +506,20 @@ func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long
|
|||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
||||
&p.ID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
||||
&p.BeaconType, &p.Confidence, &p.IsActiveBeacon, &p.Lat, &p.Long,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Return anonymous author placeholder — no real user info
|
||||
p.AuthorHandle = "Anonymous"
|
||||
p.AuthorDisplayName = "Anonymous"
|
||||
p.AuthorAvatarURL = ""
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
Handle: "Anonymous",
|
||||
DisplayName: "Anonymous",
|
||||
AvatarURL: "",
|
||||
}
|
||||
beacons = append(beacons, p)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,28 +86,20 @@ class _BeaconBottomSheetState extends ConsumerState<BeaconBottomSheet> {
|
|||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Author info
|
||||
// Anonymous beacon info
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: accentColor,
|
||||
backgroundImage: (post.author?.avatarUrl != null && post.author!.avatarUrl!.isNotEmpty)
|
||||
? NetworkImage(post.author!.avatarUrl!)
|
||||
: null,
|
||||
child: (post.author?.avatarUrl == null || post.author!.avatarUrl!.isEmpty)
|
||||
? Text(
|
||||
((post.author?.displayName ?? post.author?.handle ?? '?')[0]).toUpperCase(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
)
|
||||
: null,
|
||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
||||
child: Icon(Icons.shield, size: 18, color: accentColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.author?.displayName ?? '@${post.author?.handle ?? 'unknown'}',
|
||||
'Anonymous Beacon',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -352,25 +352,14 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
|
|||
}
|
||||
|
||||
Widget _buildAuthorInfo() {
|
||||
final author = _post.author;
|
||||
if (author == null) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: AppTheme.egyptianBlue,
|
||||
backgroundImage: (author.avatarUrl != null && author.avatarUrl!.isNotEmpty)
|
||||
? NetworkImage(author.avatarUrl!)
|
||||
: null,
|
||||
child: (author.avatarUrl == null || author.avatarUrl!.isEmpty)
|
||||
? Text(
|
||||
((author.displayName ?? author.handle ?? '?')[0]).toUpperCase(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
)
|
||||
: null,
|
||||
backgroundColor: AppTheme.egyptianBlue.withValues(alpha: 0.2),
|
||||
child: const Icon(Icons.shield, size: 22, color: AppTheme.egyptianBlue),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
|
|
@ -378,16 +367,15 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
author.displayName ?? '@${author.handle ?? 'unknown'}',
|
||||
'Anonymous Beacon',
|
||||
style: AppTheme.headlineSmall,
|
||||
),
|
||||
if (author.handle != null && author.handle!.isNotEmpty)
|
||||
Text(
|
||||
'@${author.handle}',
|
||||
style: AppTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.textDisabled,
|
||||
),
|
||||
Text(
|
||||
'Beacons are posted anonymously for community safety',
|
||||
style: AppTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.textDisabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue