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"
|
"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)
|
||||||
|
|
|
||||||
|
|
@ -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"})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,16 +367,15 @@ 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(
|
'Beacons are posted anonymously for community safety',
|
||||||
'@${author.handle}',
|
style: AppTheme.bodyMedium?.copyWith(
|
||||||
style: AppTheme.bodyMedium?.copyWith(
|
color: AppTheme.textDisabled,
|
||||||
color: AppTheme.textDisabled,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue