764 lines
25 KiB
Go
764 lines
25 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})
|
|
}
|
|
|
|
// ── Discover Groups (browse all public, non-encrypted groups) ────────────
|
|
|
|
// DiscoverGroups returns public groups the user can join, optionally filtered by category
|
|
func (h *CapsuleHandler) DiscoverGroups(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
|
|
}
|
|
|
|
category := c.Query("category") // optional filter
|
|
limitStr := c.DefaultQuery("limit", "50")
|
|
limit, _ := strconv.Atoi(limitStr)
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
query := `
|
|
SELECT g.id, g.name, g.description, g.type, g.privacy,
|
|
COALESCE(g.avatar_url, '') AS avatar_url,
|
|
g.member_count, g.is_encrypted,
|
|
COALESCE(g.settings::text, '{}') AS settings,
|
|
g.key_version, COALESCE(g.category, 'general') AS category, g.created_at,
|
|
EXISTS(SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = $1) AS is_member
|
|
FROM groups g
|
|
WHERE g.is_active = TRUE
|
|
AND g.is_encrypted = FALSE
|
|
AND g.privacy = 'public'
|
|
`
|
|
args := []interface{}{userID}
|
|
argIdx := 2
|
|
|
|
if category != "" && category != "all" {
|
|
query += fmt.Sprintf(" AND g.category = $%d", argIdx)
|
|
args = append(args, category)
|
|
argIdx++
|
|
}
|
|
|
|
query += " ORDER BY g.member_count DESC LIMIT " + strconv.Itoa(limit)
|
|
|
|
rows, err := h.pool.Query(c.Request.Context(), query, args...)
|
|
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, cat string
|
|
var memberCount, keyVersion int
|
|
var isEncrypted, isMember bool
|
|
var createdAt time.Time
|
|
if err := rows.Scan(&id, &name, &desc, &typ, &privacy, &avatarURL,
|
|
&memberCount, &isEncrypted, &settings, &keyVersion, &cat, &createdAt, &isMember); err != nil {
|
|
continue
|
|
}
|
|
groups = append(groups, gin.H{
|
|
"id": id, "name": name, "description": desc, "type": typ,
|
|
"privacy": privacy, "avatar_url": avatarURL,
|
|
"member_count": memberCount, "is_encrypted": isEncrypted,
|
|
"settings": settings, "key_version": keyVersion,
|
|
"category": cat, "created_at": createdAt, "is_member": isMember,
|
|
})
|
|
}
|
|
if groups == nil {
|
|
groups = []gin.H{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
|
}
|
|
|
|
// JoinGroup adds the authenticated user to a public, non-encrypted group
|
|
func (h *CapsuleHandler) JoinGroup(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
|
|
}
|
|
|
|
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 group exists, is public, and not encrypted
|
|
var privacy string
|
|
var isEncrypted bool
|
|
err = h.pool.QueryRow(ctx, `SELECT privacy, is_encrypted FROM groups WHERE id = $1 AND is_active = TRUE`, groupID).Scan(&privacy, &isEncrypted)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
|
return
|
|
}
|
|
if isEncrypted {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot join encrypted groups directly"})
|
|
return
|
|
}
|
|
if privacy != "public" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "this group requires an invitation"})
|
|
return
|
|
}
|
|
|
|
// Check if already a member
|
|
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.StatusConflict, gin.H{"error": "already a member"})
|
|
return
|
|
}
|
|
|
|
// Add member and increment count
|
|
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, `INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'member')`, groupID, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to join group"})
|
|
return
|
|
}
|
|
_, err = tx.Exec(ctx, `UPDATE groups SET member_count = member_count + 1 WHERE id = $1`, groupID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update count"})
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "commit failed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "joined group"})
|
|
}
|
|
|
|
// ── 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"})
|
|
}
|