- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
566 lines
18 KiB
Go
566 lines
18 KiB
Go
package handlers
|
|
|
|
// export_data.go — v0.34.0 CS0
|
|
//
|
|
// Data export endpoints: user data export (GDPR "download my data")
|
|
// and team data export (admin). Streams .switchboard zip archives
|
|
// directly to the HTTP response.
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/export"
|
|
"switchboard-core/models"
|
|
"switchboard-core/storage"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// DataExportHandler serves data export endpoints.
|
|
type DataExportHandler struct {
|
|
stores store.Stores
|
|
objStore storage.ObjectStore
|
|
}
|
|
|
|
// NewDataExportHandler creates a new handler for data export operations.
|
|
func NewDataExportHandler(s store.Stores, obj storage.ObjectStore) *DataExportHandler {
|
|
return &DataExportHandler{stores: s, objStore: obj}
|
|
}
|
|
|
|
// ExportMyData streams the requesting user's data as a .switchboard zip.
|
|
// GET /api/v1/export/me
|
|
func (h *DataExportHandler) ExportMyData(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
userID := c.GetString("user_id")
|
|
|
|
// ── Fetch user ──
|
|
user, err := h.stores.Users.GetByID(ctx, userID)
|
|
if err != nil || user == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
|
|
// ── Fetch all user-scoped entities ──
|
|
channels, err := h.stores.Export.UserChannels(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch channels", "error", err, "user_id", userID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export channels"})
|
|
return
|
|
}
|
|
|
|
channelIDs := make([]string, len(channels))
|
|
for i, ch := range channels {
|
|
channelIDs[i] = ch.ID
|
|
}
|
|
|
|
messages, err := h.stores.Export.UserMessages(ctx, channelIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch messages", "error", err, "user_id", userID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export messages"})
|
|
return
|
|
}
|
|
|
|
participants, err := h.stores.Export.UserChannelParticipants(ctx, channelIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch participants", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export participants"})
|
|
return
|
|
}
|
|
|
|
channelModels, err := h.stores.Export.UserChannelModels(ctx, channelIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch channel models", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export channel models"})
|
|
return
|
|
}
|
|
|
|
cursors, err := h.stores.Export.UserChannelCursors(ctx, userID, channelIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch cursors", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export cursors"})
|
|
return
|
|
}
|
|
|
|
notes, err := h.stores.Export.UserNotes(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch notes", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export notes"})
|
|
return
|
|
}
|
|
|
|
noteIDs := make([]string, len(notes))
|
|
for i, n := range notes {
|
|
noteIDs[i] = n.ID
|
|
}
|
|
|
|
noteLinks, err := h.stores.Export.UserNoteLinks(ctx, noteIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch note links", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export note links"})
|
|
return
|
|
}
|
|
|
|
memories, err := h.stores.Export.UserMemories(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch memories", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export memories"})
|
|
return
|
|
}
|
|
|
|
projects, err := h.stores.Export.UserProjects(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch projects", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export projects"})
|
|
return
|
|
}
|
|
|
|
projectIDs := make([]string, len(projects))
|
|
for i, p := range projects {
|
|
projectIDs[i] = p.ID
|
|
}
|
|
|
|
projectChannels, err := h.stores.Export.UserProjectChannels(ctx, projectIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch project channels", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project channels"})
|
|
return
|
|
}
|
|
|
|
projectKBs, err := h.stores.Export.UserProjectKBs(ctx, projectIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch project KBs", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project KBs"})
|
|
return
|
|
}
|
|
|
|
projectNotes, err := h.stores.Export.UserProjectNotes(ctx, projectIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch project notes", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project notes"})
|
|
return
|
|
}
|
|
|
|
workspaces, err := h.stores.Export.UserWorkspaces(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch workspaces", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workspaces"})
|
|
return
|
|
}
|
|
|
|
workspaceIDs := make([]string, len(workspaces))
|
|
for i, w := range workspaces {
|
|
workspaceIDs[i] = w.ID
|
|
}
|
|
|
|
workspaceFiles, err := h.stores.Export.UserWorkspaceFiles(ctx, workspaceIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch workspace files", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workspace files"})
|
|
return
|
|
}
|
|
|
|
files, err := h.stores.Export.UserFiles(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch files", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export files"})
|
|
return
|
|
}
|
|
|
|
folders, err := h.stores.Export.UserFolders(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch folders", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export folders"})
|
|
return
|
|
}
|
|
|
|
userModelSettings, err := h.stores.Export.UserModelSettings(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch user model settings", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export user settings"})
|
|
return
|
|
}
|
|
|
|
notifPrefs, err := h.stores.Export.UserNotifPrefs(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch notification prefs", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export notification prefs"})
|
|
return
|
|
}
|
|
|
|
usageEntries, err := h.stores.Export.UserUsageEntries(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch usage entries", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export usage entries"})
|
|
return
|
|
}
|
|
|
|
personaGroups, err := h.stores.Export.UserPersonaGroups(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("export: fetch persona groups", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona groups"})
|
|
return
|
|
}
|
|
|
|
pgIDs := make([]string, len(personaGroups))
|
|
for i, pg := range personaGroups {
|
|
pgIDs[i] = pg.ID
|
|
}
|
|
|
|
personaGroupMembers, err := h.stores.Export.UserPersonaGroupMembers(ctx, pgIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch persona group members", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona group members"})
|
|
return
|
|
}
|
|
|
|
// ── Build manifest ──
|
|
sanitizedUser := export.SanitizeUser(user)
|
|
sanitizedChannels := export.SanitizeChannels(channels)
|
|
sanitizedMessages := export.SanitizeMessages(messages)
|
|
sanitizedChannelModels := export.SanitizeChannelModels(channelModels)
|
|
sanitizedUserModelSettings := export.SanitizeUserModelSettings(userModelSettings)
|
|
sanitizedUsageEntries := export.SanitizeUsageEntries(usageEntries)
|
|
sanitizedWorkspaces := export.SanitizeWorkspaces(workspaces)
|
|
|
|
counts := map[string]int{
|
|
"users": 1,
|
|
"channels": len(channels),
|
|
"messages": len(messages),
|
|
"channel_participants": len(participants),
|
|
"channel_models": len(channelModels),
|
|
"channel_cursors": len(cursors),
|
|
"notes": len(notes),
|
|
"note_links": len(noteLinks),
|
|
"memories": len(memories),
|
|
"projects": len(projects),
|
|
"project_channels": len(projectChannels),
|
|
"project_knowledge_bases": len(projectKBs),
|
|
"project_notes": len(projectNotes),
|
|
"workspaces": len(workspaces),
|
|
"workspace_files": len(workspaceFiles),
|
|
"files": len(files),
|
|
"folders": len(folders),
|
|
"user_settings": len(userModelSettings),
|
|
"notification_preferences": len(notifPrefs),
|
|
"usage_entries": len(usageEntries),
|
|
"persona_groups": len(personaGroups),
|
|
"persona_group_members": len(personaGroupMembers),
|
|
}
|
|
|
|
manifest := &export.Manifest{
|
|
Version: "0.34.0",
|
|
FormatVersion: export.FormatVersion,
|
|
ExportType: "user",
|
|
CreatedAt: time.Now().UTC(),
|
|
ExportedBy: userID,
|
|
EntityCounts: counts,
|
|
Scope: &export.ManifestScope{UserID: userID},
|
|
}
|
|
|
|
// ── Stream zip to response ──
|
|
filename := fmt.Sprintf("switchboard-export-%s%s", userID[:8], export.ExportExtension)
|
|
c.Header("Content-Type", export.ExportContentType)
|
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
|
|
aw := export.NewArchiveWriter(c.Writer)
|
|
defer aw.Close()
|
|
|
|
if err := aw.WriteManifest(manifest); err != nil {
|
|
slog.Error("export: write manifest", "error", err)
|
|
return
|
|
}
|
|
|
|
// Write entity JSON files
|
|
writeEntity := func(name string, data interface{}) {
|
|
if _, err := aw.WriteEntityJSON(name, data); err != nil {
|
|
slog.Error("export: write entity", "name", name, "error", err)
|
|
}
|
|
}
|
|
|
|
writeEntity("users", []interface{}{sanitizedUser})
|
|
writeEntity("channels", sanitizedChannels)
|
|
writeEntity("messages", sanitizedMessages)
|
|
writeEntity("channel_participants", participants)
|
|
writeEntity("channel_models", sanitizedChannelModels)
|
|
writeEntity("channel_cursors", cursors)
|
|
writeEntity("notes", notes)
|
|
writeEntity("note_links", noteLinks)
|
|
writeEntity("memories", memories)
|
|
writeEntity("projects", projects)
|
|
writeEntity("project_channels", projectChannels)
|
|
writeEntity("project_knowledge_bases", projectKBs)
|
|
writeEntity("project_notes", projectNotes)
|
|
writeEntity("workspaces", sanitizedWorkspaces)
|
|
writeEntity("workspace_files", workspaceFiles)
|
|
writeEntity("folders", folders)
|
|
writeEntity("user_settings", sanitizedUserModelSettings)
|
|
writeEntity("notification_preferences", notifPrefs)
|
|
writeEntity("usage_entries", sanitizedUsageEntries)
|
|
writeEntity("persona_groups", personaGroups)
|
|
writeEntity("persona_group_members", personaGroupMembers)
|
|
|
|
// Write file metadata (without storage_key — already excluded by json:"-" tag)
|
|
writeEntity("files", files)
|
|
|
|
// ── Stream file blobs ──
|
|
var warnings []string
|
|
if h.objStore != nil {
|
|
fileCount := 0
|
|
for _, f := range files {
|
|
if fileCount >= export.MaxExportFiles {
|
|
warnings = append(warnings, "file blob limit reached, some files skipped")
|
|
break
|
|
}
|
|
if f.SizeBytes > export.MaxExportFileSize {
|
|
warnings = append(warnings, fmt.Sprintf("file %s too large (%d bytes), skipped", f.Filename, f.SizeBytes))
|
|
continue
|
|
}
|
|
rc, _, _, err := h.objStore.Get(ctx, f.StorageKey)
|
|
if err != nil {
|
|
warnings = append(warnings, fmt.Sprintf("file %s not found in storage, skipped", f.Filename))
|
|
continue
|
|
}
|
|
zipPath := f.ID + "/" + f.Filename
|
|
if _, err := aw.WriteFile(zipPath, rc); err != nil {
|
|
rc.Close()
|
|
warnings = append(warnings, fmt.Sprintf("file %s write error: %v, skipped", f.Filename, err))
|
|
continue
|
|
}
|
|
rc.Close()
|
|
fileCount++
|
|
}
|
|
}
|
|
|
|
if len(warnings) > 0 {
|
|
writeEntity("export_warnings", warnings)
|
|
}
|
|
|
|
// Audit log
|
|
h.stores.Audit.Log(ctx, &models.AuditEntry{
|
|
ActorID: &userID,
|
|
Action: "user.export",
|
|
ResourceType: "user",
|
|
ResourceID: userID,
|
|
Metadata: models.JSONMap{"entity_counts": counts},
|
|
})
|
|
}
|
|
|
|
// ExportTeam streams a team's data as a .switchboard zip.
|
|
// GET /api/v1/admin/teams/:id/export
|
|
func (h *DataExportHandler) ExportTeam(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
userID := c.GetString("user_id")
|
|
teamID := c.Param("id")
|
|
|
|
// Fetch team to verify it exists
|
|
team, err := h.stores.Teams.GetByID(ctx, teamID)
|
|
if err != nil || team == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
|
return
|
|
}
|
|
|
|
// Fetch all team-scoped entities
|
|
channels, err := h.stores.Export.TeamChannels(ctx, teamID)
|
|
if err != nil {
|
|
slog.Error("export: fetch team channels", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team channels"})
|
|
return
|
|
}
|
|
|
|
channelIDs := make([]string, len(channels))
|
|
for i, ch := range channels {
|
|
channelIDs[i] = ch.ID
|
|
}
|
|
|
|
messages, err := h.stores.Export.UserMessages(ctx, channelIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch team messages", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team messages"})
|
|
return
|
|
}
|
|
|
|
members, err := h.stores.Export.TeamMembers(ctx, teamID)
|
|
if err != nil {
|
|
slog.Error("export: fetch team members", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team members"})
|
|
return
|
|
}
|
|
|
|
personas, err := h.stores.Export.TeamPersonas(ctx, teamID)
|
|
if err != nil {
|
|
slog.Error("export: fetch team personas", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team personas"})
|
|
return
|
|
}
|
|
|
|
personaIDs := make([]string, len(personas))
|
|
for i, p := range personas {
|
|
personaIDs[i] = p.ID
|
|
}
|
|
|
|
personaKBs, err := h.stores.Export.TeamPersonaKBs(ctx, personaIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch persona KBs", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona KBs"})
|
|
return
|
|
}
|
|
|
|
kbs, err := h.stores.Export.TeamKnowledgeBases(ctx, teamID)
|
|
if err != nil {
|
|
slog.Error("export: fetch team KBs", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team KBs"})
|
|
return
|
|
}
|
|
|
|
kbIDs := make([]string, len(kbs))
|
|
for i, kb := range kbs {
|
|
kbIDs[i] = kb.ID
|
|
}
|
|
|
|
kbDocs, err := h.stores.Export.TeamKBDocuments(ctx, kbIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch KB docs", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export KB documents"})
|
|
return
|
|
}
|
|
|
|
workflows, err := h.stores.Export.TeamWorkflows(ctx, teamID)
|
|
if err != nil {
|
|
slog.Error("export: fetch workflows", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflows"})
|
|
return
|
|
}
|
|
|
|
workflowIDs := make([]string, len(workflows))
|
|
for i, w := range workflows {
|
|
workflowIDs[i] = w.ID
|
|
}
|
|
|
|
workflowVersions, err := h.stores.Export.TeamWorkflowVersions(ctx, workflowIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch workflow versions", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflow versions"})
|
|
return
|
|
}
|
|
|
|
workflowStages, err := h.stores.Export.TeamWorkflowStages(ctx, workflowIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch workflow stages", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflow stages"})
|
|
return
|
|
}
|
|
|
|
groups, err := h.stores.Export.TeamGroups(ctx, teamID)
|
|
if err != nil {
|
|
slog.Error("export: fetch groups", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export groups"})
|
|
return
|
|
}
|
|
|
|
groupIDs := make([]string, len(groups))
|
|
for i, g := range groups {
|
|
groupIDs[i] = g.ID
|
|
}
|
|
|
|
groupMembers, err := h.stores.Export.TeamGroupMembers(ctx, groupIDs)
|
|
if err != nil {
|
|
slog.Error("export: fetch group members", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export group members"})
|
|
return
|
|
}
|
|
|
|
resourceGrants, err := h.stores.Export.TeamResourceGrants(ctx, teamID)
|
|
if err != nil {
|
|
slog.Error("export: fetch resource grants", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export resource grants"})
|
|
return
|
|
}
|
|
|
|
projects, err := h.stores.Export.TeamProjects(ctx, teamID)
|
|
if err != nil {
|
|
slog.Error("export: fetch team projects", "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team projects"})
|
|
return
|
|
}
|
|
|
|
// Sanitize
|
|
sanitizedChannels := export.SanitizeChannels(channels)
|
|
sanitizedMessages := export.SanitizeMessages(messages)
|
|
sanitizedPersonas := export.SanitizePersonas(personas)
|
|
sanitizedWorkflows := export.SanitizeWorkflows(workflows)
|
|
|
|
counts := map[string]int{
|
|
"team": 1,
|
|
"channels": len(channels),
|
|
"messages": len(messages),
|
|
"members": len(members),
|
|
"personas": len(personas),
|
|
"persona_kbs": len(personaKBs),
|
|
"knowledge_bases": len(kbs),
|
|
"kb_documents": len(kbDocs),
|
|
"workflows": len(workflows),
|
|
"workflow_versions": len(workflowVersions),
|
|
"workflow_stages": len(workflowStages),
|
|
"groups": len(groups),
|
|
"group_members": len(groupMembers),
|
|
"resource_grants": len(resourceGrants),
|
|
"projects": len(projects),
|
|
}
|
|
|
|
manifest := &export.Manifest{
|
|
Version: "0.34.0",
|
|
FormatVersion: export.FormatVersion,
|
|
ExportType: "team",
|
|
CreatedAt: time.Now().UTC(),
|
|
ExportedBy: userID,
|
|
EntityCounts: counts,
|
|
Scope: &export.ManifestScope{TeamID: teamID},
|
|
}
|
|
|
|
filename := fmt.Sprintf("switchboard-team-%s%s", teamID[:8], export.ExportExtension)
|
|
c.Header("Content-Type", export.ExportContentType)
|
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
|
|
aw := export.NewArchiveWriter(c.Writer)
|
|
defer aw.Close()
|
|
|
|
if err := aw.WriteManifest(manifest); err != nil {
|
|
slog.Error("export: write manifest", "error", err)
|
|
return
|
|
}
|
|
|
|
writeEntity := func(name string, data interface{}) {
|
|
if _, err := aw.WriteEntityJSON(name, data); err != nil {
|
|
slog.Error("export: write entity", "name", name, "error", err)
|
|
}
|
|
}
|
|
|
|
writeEntity("team", []interface{}{team})
|
|
writeEntity("channels", sanitizedChannels)
|
|
writeEntity("messages", sanitizedMessages)
|
|
writeEntity("members", members)
|
|
writeEntity("personas", sanitizedPersonas)
|
|
writeEntity("persona_knowledge_bases", personaKBs)
|
|
writeEntity("knowledge_bases", kbs)
|
|
writeEntity("kb_documents", kbDocs)
|
|
writeEntity("workflows", sanitizedWorkflows)
|
|
writeEntity("workflow_versions", workflowVersions)
|
|
writeEntity("workflow_stages", workflowStages)
|
|
writeEntity("groups", groups)
|
|
writeEntity("group_members", groupMembers)
|
|
writeEntity("resource_grants", resourceGrants)
|
|
writeEntity("projects", projects)
|
|
|
|
h.stores.Audit.Log(ctx, &models.AuditEntry{
|
|
ActorID: &userID,
|
|
Action: "team.export",
|
|
ResourceType: "team",
|
|
ResourceID: teamID,
|
|
Metadata: models.JSONMap{"entity_counts": counts},
|
|
})
|
|
}
|