package handlers import ( "fmt" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) type CapsuleHandler struct { pool *pgxpool.Pool } func NewCapsuleHandler(pool *pgxpool.Pool) *CapsuleHandler { return &CapsuleHandler{pool: pool} } // ── My Groups ──────────────────────────────────────────────────────────── // ListMyGroups returns all groups the authenticated user belongs to func (h *CapsuleHandler) ListMyGroups(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, err := uuid.Parse(userIDStr.(string)) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } rows, err := h.pool.Query(c.Request.Context(), ` SELECT g.id, g.name, g.description, g.type, g.privacy, g.radius_meters, COALESCE(g.avatar_url, '') AS avatar_url, g.member_count, g.is_active, g.is_encrypted, COALESCE(g.settings::text, '{}') AS settings, g.key_version, COALESCE(g.category, 'general') AS category, g.created_at, gm.role, COALESCE(gm.encrypted_group_key, '') AS encrypted_group_key FROM groups g JOIN group_members gm ON gm.group_id = g.id WHERE gm.user_id = $1 AND g.is_active = TRUE ORDER BY g.created_at DESC `, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch groups"}) return } defer rows.Close() var groups []gin.H for rows.Next() { var id uuid.UUID var name, desc, typ, privacy, avatarURL, settings, category, role, encKey string var radius, memberCount, keyVersion int var isActive, isEncrypted bool var createdAt time.Time if err := rows.Scan(&id, &name, &desc, &typ, &privacy, &radius, &avatarURL, &memberCount, &isActive, &isEncrypted, &settings, &keyVersion, &category, &createdAt, &role, &encKey); err != nil { continue } groups = append(groups, gin.H{ "id": id, "name": name, "description": desc, "type": typ, "privacy": privacy, "radius_meters": radius, "avatar_url": avatarURL, "member_count": memberCount, "is_encrypted": isEncrypted, "settings": settings, "key_version": keyVersion, "category": category, "created_at": createdAt, "role": role, "encrypted_group_key": encKey, }) } if groups == nil { groups = []gin.H{} } c.JSON(http.StatusOK, gin.H{"groups": groups}) } // CreateGroup creates a non-encrypted public or private group func (h *CapsuleHandler) CreateGroup(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, err := uuid.Parse(userIDStr.(string)) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } var req struct { Name string `json:"name"` Description string `json:"description"` Privacy string `json:"privacy"` // "public" or "private" Category string `json:"category"` // general, hobby, sports, professional, local_business, support, education } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name required"}) return } if req.Privacy != "public" && req.Privacy != "private" { req.Privacy = "public" } validCategories := map[string]bool{"general": true, "hobby": true, "sports": true, "professional": true, "local_business": true, "support": true, "education": true} if !validCategories[req.Category] { req.Category = "general" } ctx := c.Request.Context() tx, err := h.pool.Begin(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "transaction failed"}) return } defer tx.Rollback(ctx) groupType := "social" if req.Privacy == "private" { groupType = "private_social" } var groupID uuid.UUID var createdAt time.Time err = tx.QueryRow(ctx, ` INSERT INTO groups (name, description, type, privacy, is_encrypted, member_count, key_version, category) VALUES ($1, $2, $3, $4, FALSE, 1, 0, $5) RETURNING id, created_at `, req.Name, req.Description, groupType, req.Privacy, req.Category).Scan(&groupID, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) return } _, err = tx.Exec(ctx, ` INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'owner') `, groupID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add owner"}) return } if err := tx.Commit(ctx); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "commit failed"}) return } c.JSON(http.StatusCreated, gin.H{"group": gin.H{ "id": groupID, "name": req.Name, "description": req.Description, "type": groupType, "privacy": req.Privacy, "category": req.Category, "is_encrypted": false, "member_count": 1, "key_version": 0, "created_at": createdAt, }}) } // ── Public Cluster Endpoints ───────────────────────────────────────────── // ListPublicClusters returns geo-fenced clusters near the user func (h *CapsuleHandler) ListPublicClusters(c *gin.Context) { lat := c.Query("lat") lng := c.Query("long") if lat == "" || lng == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "lat and long required"}) return } rows, err := h.pool.Query(c.Request.Context(), ` SELECT id, name, description, type, privacy, radius_meters, COALESCE(avatar_url, '') AS avatar_url, member_count, is_active, is_encrypted, COALESCE(settings::text, '{}') AS settings, key_version, COALESCE(category, 'general') AS category, created_at FROM groups WHERE type IN ('geo', 'public_geo', 'neighborhood') AND is_active = TRUE AND ST_DWithin(location_center, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)::geography, 50000) ORDER BY member_count DESC LIMIT 50 `, lng, lat) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch clusters"}) return } defer rows.Close() var clusters []gin.H for rows.Next() { var id uuid.UUID var name, desc, typ, privacy, avatarURL, settings, category string var radius, memberCount, keyVersion int var isActive, isEncrypted bool var createdAt time.Time if err := rows.Scan(&id, &name, &desc, &typ, &privacy, &radius, &avatarURL, &memberCount, &isActive, &isEncrypted, &settings, &keyVersion, &category, &createdAt); err != nil { continue } clusters = append(clusters, gin.H{ "id": id, "name": name, "description": desc, "type": typ, "privacy": privacy, "radius_meters": radius, "avatar_url": avatarURL, "member_count": memberCount, "is_encrypted": isEncrypted, "settings": settings, "key_version": keyVersion, "category": category, "created_at": createdAt, }) } if clusters == nil { clusters = []gin.H{} } c.JSON(http.StatusOK, gin.H{"clusters": clusters}) } // ── Private Capsule Endpoints ──────────────────────────────────────────── // CRITICAL: The server NEVER decrypts payload. It only checks membership // and returns encrypted blobs. // CreateCapsule creates a new private encrypted group func (h *CapsuleHandler) CreateCapsule(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, err := uuid.Parse(userIDStr.(string)) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } var req struct { Name string `json:"name"` Description string `json:"description"` PublicKey string `json:"public_key"` EncryptedGroupKey string `json:"encrypted_group_key"` Settings string `json:"settings"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } if req.Name == "" || req.PublicKey == "" || req.EncryptedGroupKey == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name, public_key, and encrypted_group_key required"}) return } settings := req.Settings if settings == "" { settings = `{"chat":true,"forum":true,"files":false}` } ctx := c.Request.Context() tx, err := h.pool.Begin(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "transaction failed"}) return } defer tx.Rollback(ctx) var groupID uuid.UUID var createdAt time.Time err = tx.QueryRow(ctx, ` INSERT INTO groups (name, description, type, privacy, is_encrypted, public_key, settings, member_count, key_version) VALUES ($1, $2, 'private_capsule', 'private', TRUE, $3, $4::jsonb, 1, 1) RETURNING id, created_at `, req.Name, req.Description, req.PublicKey, settings).Scan(&groupID, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create capsule"}) return } _, err = tx.Exec(ctx, ` INSERT INTO group_members (group_id, user_id, role, encrypted_group_key, key_version) VALUES ($1, $2, 'owner', $3, 1) `, groupID, userID, req.EncryptedGroupKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add owner"}) return } // Also store in capsule_keys for the owner _, _ = tx.Exec(ctx, ` INSERT INTO capsule_keys (user_id, group_id, encrypted_key_blob, key_version) VALUES ($1, $2, $3, 1) ON CONFLICT (user_id, group_id) DO UPDATE SET encrypted_key_blob = $3, key_version = 1 `, userID, groupID, req.EncryptedGroupKey) if err := tx.Commit(ctx); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "commit failed"}) return } c.JSON(http.StatusCreated, gin.H{"capsule": gin.H{ "id": groupID, "name": req.Name, "description": req.Description, "type": "private_capsule", "is_encrypted": true, "key_version": 1, "member_count": 1, "created_at": createdAt, }}) } // GetCapsule returns capsule metadata + the user's encrypted group key func (h *CapsuleHandler) GetCapsule(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, _ := uuid.Parse(userIDStr.(string)) groupID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group ID"}) return } ctx := c.Request.Context() // Verify membership and get encrypted key var role, encKey string var keyVersion int err = h.pool.QueryRow(ctx, ` SELECT role, COALESCE(encrypted_group_key, ''), key_version FROM group_members WHERE group_id = $1 AND user_id = $2 `, groupID, userID).Scan(&role, &encKey, &keyVersion) if err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "not a member of this capsule"}) return } var name, desc, pubKey, settings string var memberCount, gKeyVersion int var isEncrypted bool var createdAt time.Time err = h.pool.QueryRow(ctx, ` SELECT name, description, COALESCE(public_key, ''), COALESCE(settings::text, '{}'), member_count, key_version, is_encrypted, created_at FROM groups WHERE id = $1 `, groupID).Scan(&name, &desc, &pubKey, &settings, &memberCount, &gKeyVersion, &isEncrypted, &createdAt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "capsule not found"}) return } c.JSON(http.StatusOK, gin.H{ "capsule": gin.H{ "id": groupID, "name": name, "description": desc, "type": "private_capsule", "is_encrypted": isEncrypted, "public_key": pubKey, "settings": settings, "member_count": memberCount, "key_version": gKeyVersion, "created_at": createdAt, }, "membership": gin.H{ "role": role, "key_version": keyVersion, }, "encrypted_group_key": encKey, }) } // PostCapsuleEntry stores a new encrypted entry (chat/forum/doc) // The server stores the blob as-is — NO parsing, NO sanitizing func (h *CapsuleHandler) PostCapsuleEntry(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, _ := uuid.Parse(userIDStr.(string)) groupID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group ID"}) return } ctx := c.Request.Context() // Verify membership var memberRole string err = h.pool.QueryRow(ctx, ` SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 `, groupID, userID).Scan(&memberRole) if err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "not a member"}) return } var req struct { IV string `json:"iv"` EncryptedPayload string `json:"encrypted_payload"` DataType string `json:"data_type"` ReplyToID *string `json:"reply_to_id"` KeyVersion int `json:"key_version"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } if req.IV == "" || req.EncryptedPayload == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "iv and encrypted_payload required"}) return } var replyTo *uuid.UUID if req.ReplyToID != nil { parsed, parseErr := uuid.Parse(*req.ReplyToID) if parseErr == nil { replyTo = &parsed } } var entryID uuid.UUID var createdAt time.Time err = h.pool.QueryRow(ctx, ` INSERT INTO capsule_entries (group_id, author_id, iv, encrypted_payload, data_type, reply_to_id, key_version) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at `, groupID, userID, req.IV, req.EncryptedPayload, req.DataType, replyTo, req.KeyVersion).Scan(&entryID, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store entry"}) return } c.JSON(http.StatusCreated, gin.H{"entry": gin.H{ "id": entryID, "group_id": groupID, "author_id": userID, "data_type": req.DataType, "key_version": req.KeyVersion, "created_at": createdAt, }}) } // GetCapsuleEntries returns encrypted entries for a capsule (paginated) func (h *CapsuleHandler) GetCapsuleEntries(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, _ := uuid.Parse(userIDStr.(string)) groupID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group ID"}) return } ctx := c.Request.Context() // Verify membership var exists bool h.pool.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2) `, groupID, userID).Scan(&exists) if !exists { c.JSON(http.StatusForbidden, gin.H{"error": "not a member"}) return } dataType := c.Query("type") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) query := ` SELECT ce.id, ce.group_id, ce.author_id, ce.iv, ce.encrypted_payload, ce.data_type, ce.reply_to_id, ce.key_version, ce.created_at, p.handle AS author_handle, COALESCE(p.display_name, '') AS author_display_name, COALESCE(p.avatar_url, '') AS author_avatar_url FROM capsule_entries ce JOIN profiles p ON p.id = ce.author_id WHERE ce.group_id = $1 AND ce.is_deleted = FALSE ` var argIdx int = 2 var args []any = []any{groupID} if dataType != "" { query += fmt.Sprintf(` AND ce.data_type = $%d`, argIdx) args = append(args, dataType) argIdx++ } query += fmt.Sprintf(` ORDER BY ce.created_at DESC LIMIT $%d OFFSET $%d`, argIdx, argIdx+1) args = append(args, limit, offset) rows, err := h.pool.Query(ctx, query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch entries"}) return } defer rows.Close() var entries []gin.H for rows.Next() { var id, gid, aid uuid.UUID var iv, payload, dt, handle, displayName, avatarURL string var replyTo *uuid.UUID var kv int var cat time.Time if err := rows.Scan(&id, &gid, &aid, &iv, &payload, &dt, &replyTo, &kv, &cat, &handle, &displayName, &avatarURL); err != nil { continue } entries = append(entries, gin.H{ "id": id, "group_id": gid, "author_id": aid, "iv": iv, "encrypted_payload": payload, "data_type": dt, "reply_to_id": replyTo, "key_version": kv, "created_at": cat, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, }) } if entries == nil { entries = []gin.H{} } c.JSON(http.StatusOK, gin.H{"entries": entries}) } // InviteToCapsule adds a member with their encrypted copy of the group key func (h *CapsuleHandler) InviteToCapsule(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, _ := uuid.Parse(userIDStr.(string)) groupID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group ID"}) return } ctx := c.Request.Context() // Only owner/admin can invite var inviterRole string err = h.pool.QueryRow(ctx, ` SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 `, groupID, userID).Scan(&inviterRole) if err != nil || (inviterRole != "owner" && inviterRole != "admin") { c.JSON(http.StatusForbidden, gin.H{"error": "only owner or admin can invite"}) return } var req struct { InviteeUserID string `json:"invitee_user_id"` EncryptedGroupKey string `json:"encrypted_group_key"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } inviteeID, err := uuid.Parse(req.InviteeUserID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid invitee_user_id"}) return } var keyVersion int h.pool.QueryRow(ctx, `SELECT key_version FROM groups WHERE id = $1`, groupID).Scan(&keyVersion) _, err = h.pool.Exec(ctx, ` INSERT INTO group_members (group_id, user_id, role, encrypted_group_key, key_version) VALUES ($1, $2, 'member', $3, $4) ON CONFLICT (group_id, user_id) DO UPDATE SET encrypted_group_key = $3, key_version = $4 `, groupID, inviteeID, req.EncryptedGroupKey, keyVersion) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to invite"}) return } // Also store in capsule_keys _, _ = h.pool.Exec(ctx, ` INSERT INTO capsule_keys (user_id, group_id, encrypted_key_blob, key_version) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, group_id) DO UPDATE SET encrypted_key_blob = $3, key_version = $4 `, inviteeID, groupID, req.EncryptedGroupKey, keyVersion) // Bump member count h.pool.Exec(ctx, ` UPDATE groups SET member_count = (SELECT COUNT(*) FROM group_members WHERE group_id = $1) WHERE id = $1 `, groupID) c.JSON(http.StatusOK, gin.H{"status": "invited"}) } // RotateKeys triggers a key rotation — admin re-encrypts and distributes new keys func (h *CapsuleHandler) RotateKeys(c *gin.Context) { userIDStr, _ := c.Get("user_id") userID, _ := uuid.Parse(userIDStr.(string)) groupID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group ID"}) return } ctx := c.Request.Context() var role string err = h.pool.QueryRow(ctx, ` SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 `, groupID, userID).Scan(&role) if err != nil || role != "owner" { c.JSON(http.StatusForbidden, gin.H{"error": "only owner can rotate keys"}) return } var req struct { NewPublicKey string `json:"new_public_key"` MemberKeys map[string]string `json:"member_keys"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } tx, err := h.pool.Begin(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "transaction failed"}) return } defer tx.Rollback(ctx) _, err = tx.Exec(ctx, ` UPDATE groups SET key_version = key_version + 1, public_key = $2, updated_at = NOW() WHERE id = $1 `, groupID, req.NewPublicKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update group key"}) return } for uid, encKey := range req.MemberKeys { memberID, parseErr := uuid.Parse(uid) if parseErr != nil { continue } tx.Exec(ctx, ` UPDATE group_members SET encrypted_group_key = $3, key_version = ( SELECT key_version FROM groups WHERE id = $1 ) WHERE group_id = $1 AND user_id = $2 `, groupID, memberID, encKey) // Also update capsule_keys tx.Exec(ctx, ` INSERT INTO capsule_keys (user_id, group_id, encrypted_key_blob, key_version) VALUES ($1, $2, $3, (SELECT key_version FROM groups WHERE id = $2)) ON CONFLICT (user_id, group_id) DO UPDATE SET encrypted_key_blob = $3, key_version = (SELECT key_version FROM groups WHERE id = $2), updated_at = NOW() `, memberID, groupID, encKey) } if err := tx.Commit(ctx); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "commit failed"}) return } c.JSON(http.StatusOK, gin.H{"status": "keys_rotated"}) }