From 67f74deb584e059e3b0a5a99c71a28075f2a2503 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Sat, 7 Feb 2026 12:02:47 -0600 Subject: [PATCH] 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 --- go-backend/internal/handlers/media_handler.go | 112 ++++++++++++++---- go-backend/internal/handlers/post_handler.go | 44 +------ .../internal/repository/post_repository.go | 23 ++-- .../screens/beacon/beacon_bottom_sheet.dart | 16 +-- .../screens/beacon/beacon_detail_screen.dart | 28 ++--- 5 files changed, 113 insertions(+), 110 deletions(-) diff --git a/go-backend/internal/handlers/media_handler.go b/go-backend/internal/handlers/media_handler.go index 2bb4cfc..b3117a7 100644 --- a/go-backend/internal/handlers/media_handler.go +++ b/go-backend/internal/handlers/media_handler.go @@ -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) diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index 968e369..6e23a83 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -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"}) } diff --git a/go-backend/internal/repository/post_repository.go b/go-backend/internal/repository/post_repository.go index 4ca9136..97b1ee0 100644 --- a/go-backend/internal/repository/post_repository.go +++ b/go-backend/internal/repository/post_repository.go @@ -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) } diff --git a/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart b/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart index 3ad0c5f..0f98e15 100644 --- a/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart +++ b/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart @@ -86,28 +86,20 @@ class _BeaconBottomSheetState extends ConsumerState { 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, ), diff --git a/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart b/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart index ece1690..f092f10 100644 --- a/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart +++ b/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart @@ -352,25 +352,14 @@ class _BeaconDetailScreenState extends ConsumerState } 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 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, ), + ), ], ), ),