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:
Patrick Britton 2026-02-07 12:02:47 -06:00
parent ecc02e10cc
commit 67f74deb58
5 changed files with 113 additions and 110 deletions

View file

@ -4,8 +4,9 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@ -13,6 +14,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rs/zerolog/log"
) )
// MediaHandler uploads media to Cloudflare R2. If s3Client is provided, it uses // 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") userID := c.GetString("user_id")
if userID == "" { if userID == "" {
userID = "anon" 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 var publicURL string
reader := bytes.NewReader(cleanBytes)
if h.useS3 { 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 { } else {
publicURL, err = h.putObjectR2API(c, fileHeader, targetBucket, objectKey, targetDomain) publicURL, err = h.putObjectR2API(c, cleanBytes, contentType, targetBucket, objectKey, targetDomain)
} }
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to upload media: %v", err)}) 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, "signedUrl": publicURL,
"fileName": objectKey, "fileName": objectKey,
"file_name": objectKey, "file_name": objectKey,
"fileSize": fileHeader.Size, "fileSize": len(cleanBytes),
"file_size": fileHeader.Size, "file_size": len(cleanBytes),
"type": mediaType, "type": mediaType,
}) })
} }
func (h *MediaHandler) putObjectS3(c *gin.Context, fileHeader *multipart.FileHeader, bucket string, key string, publicDomain string) (string, error) { // stripMetadata uses ffmpeg to remove all metadata (EXIF, GPS, camera info)
src, err := fileHeader.Open() // from uploaded media before it goes to R2. Returns the cleaned bytes.
if err != nil { func stripMetadata(raw []byte, mediaType string, ext string) ([]byte, error) {
return "", err 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() ctx := c.Request.Context()
_, err = h.s3Client.PutObject(ctx, &s3.PutObjectInput{ _, err := h.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &bucket, Bucket: &bucket,
Key: &key, Key: &key,
Body: src, Body: body,
ContentType: &[]string{fileHeader.Header.Get("Content-Type")}[0], ContentType: &contentType,
}) })
if err != nil { if err != nil {
return "", err return "", err
@ -137,22 +208,11 @@ func (h *MediaHandler) putObjectS3(c *gin.Context, fileHeader *multipart.FileHea
return key, nil 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 == "" { if h.accountID == "" || h.apiToken == "" {
return "", fmt.Errorf("R2 API credentials missing") 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", endpoint := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/r2/buckets/%s/objects/%s",
h.accountID, bucket, key) 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("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} client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)

View file

@ -791,27 +791,7 @@ func (h *PostHandler) VouchBeacon(c *gin.Context) {
return return
} }
// Get beacon details for notification // Beacons are anonymous — no notifications sent to preserve privacy
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,
)
}
}
c.JSON(http.StatusOK, gin.H{"message": "Beacon vouched successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Beacon vouched successfully"})
} }
@ -830,27 +810,7 @@ func (h *PostHandler) ReportBeacon(c *gin.Context) {
return return
} }
// Get beacon details for notification // Beacons are anonymous — no notifications sent to preserve privacy
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,
)
}
}
c.JSON(http.StatusOK, gin.H{"message": "Beacon reported successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Beacon reported successfully"})
} }

View file

@ -239,6 +239,7 @@ func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string,
JOIN public.profiles pr ON p.author_id = pr.id JOIN public.profiles pr ON p.author_id = pr.id
LEFT JOIN public.post_metrics m ON p.id = m.post_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' WHERE p.author_id = $1::uuid AND p.deleted_at IS NULL AND p.status = 'active'
AND p.is_beacon = FALSE
AND ( AND (
p.author_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END -- Viewer is author p.author_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END -- Viewer is author
OR pr.is_private = FALSE -- Public profile 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) { 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 := ` query := `
SELECT 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, p.beacon_type, p.confidence_score, p.is_active_beacon,
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as long, 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
FROM public.posts p FROM public.posts p
JOIN public.profiles pr ON p.author_id = pr.id
WHERE p.is_beacon = true WHERE p.is_beacon = true
AND ST_DWithin(p.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3) AND ST_DWithin(p.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3)
AND p.status = 'active' AND p.status = 'active'
@ -505,18 +506,20 @@ func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long
for rows.Next() { for rows.Next() {
var p models.Post var p models.Post
err := rows.Scan( 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.BeaconType, &p.Confidence, &p.IsActiveBeacon, &p.Lat, &p.Long,
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Return anonymous author placeholder — no real user info
p.AuthorHandle = "Anonymous"
p.AuthorDisplayName = "Anonymous"
p.AuthorAvatarURL = ""
p.Author = &models.AuthorProfile{ p.Author = &models.AuthorProfile{
ID: p.AuthorID, Handle: "Anonymous",
Handle: p.AuthorHandle, DisplayName: "Anonymous",
DisplayName: p.AuthorDisplayName, AvatarURL: "",
AvatarURL: p.AuthorAvatarURL,
} }
beacons = append(beacons, p) beacons = append(beacons, p)
} }

View file

@ -86,28 +86,20 @@ class _BeaconBottomSheetState extends ConsumerState<BeaconBottomSheet> {
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Author info // Anonymous beacon info
Row( Row(
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 16, radius: 16,
backgroundColor: accentColor, backgroundColor: accentColor.withValues(alpha: 0.2),
backgroundImage: (post.author?.avatarUrl != null && post.author!.avatarUrl!.isNotEmpty) child: Icon(Icons.shield, size: 18, color: accentColor),
? 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,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
post.author?.displayName ?? '@${post.author?.handle ?? 'unknown'}', 'Anonymous Beacon',
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),

View file

@ -352,25 +352,14 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
} }
Widget _buildAuthorInfo() { Widget _buildAuthorInfo() {
final author = _post.author;
if (author == null) return const SizedBox.shrink();
return Padding( return Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Row( child: Row(
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 20, radius: 20,
backgroundColor: AppTheme.egyptianBlue, backgroundColor: AppTheme.egyptianBlue.withValues(alpha: 0.2),
backgroundImage: (author.avatarUrl != null && author.avatarUrl!.isNotEmpty) child: const Icon(Icons.shield, size: 22, color: AppTheme.egyptianBlue),
? 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,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@ -378,12 +367,11 @@ class _BeaconDetailScreenState extends ConsumerState<BeaconDetailScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
author.displayName ?? '@${author.handle ?? 'unknown'}', 'Anonymous Beacon',
style: AppTheme.headlineSmall, style: AppTheme.headlineSmall,
), ),
if (author.handle != null && author.handle!.isNotEmpty)
Text( Text(
'@${author.handle}', 'Beacons are posted anonymously for community safety',
style: AppTheme.bodyMedium?.copyWith( style: AppTheme.bodyMedium?.copyWith(
color: AppTheme.textDisabled, color: AppTheme.textDisabled,
), ),