sojorn/go-backend/internal/handlers/capsule_handler.go
2026-02-15 00:33:24 -06:00

622 lines
20 KiB
Go

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"})
}