Changeset 0.34.0 (#208)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
565
server/handlers/export_data.go
Normal file
565
server/handlers/export_data.go
Normal file
@@ -0,0 +1,565 @@
|
||||
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"
|
||||
|
||||
"chat-switchboard/export"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/storage"
|
||||
"chat-switchboard/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},
|
||||
})
|
||||
}
|
||||
130
server/handlers/gdpr.go
Normal file
130
server/handlers/gdpr.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package handlers
|
||||
|
||||
// gdpr.go — v0.34.0 CS2
|
||||
//
|
||||
// GDPR delete endpoint: DELETE /api/v1/me
|
||||
// Allows a user to delete their own account and all associated data.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// GDPRHandler serves account deletion endpoints.
|
||||
type GDPRHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// NewGDPRHandler creates a new handler for GDPR operations.
|
||||
func NewGDPRHandler(s store.Stores) *GDPRHandler {
|
||||
return &GDPRHandler{stores: s}
|
||||
}
|
||||
|
||||
// deleteAccountRequest is the body for DELETE /api/v1/me.
|
||||
type deleteAccountRequest struct {
|
||||
Confirm string `json:"confirm"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// DeleteMyAccount permanently deletes the requesting user's account.
|
||||
// DELETE /api/v1/me
|
||||
func (h *GDPRHandler) DeleteMyAccount(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var req deleteAccountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Require explicit confirmation
|
||||
if req.Confirm != "DELETE" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "confirm field must be \"DELETE\""})
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent deleting last admin
|
||||
if user.Role == "admin" {
|
||||
adminCount, err := h.stores.Export.CountActiveAdmins(ctx)
|
||||
if err != nil {
|
||||
slog.Error("gdpr: count admins", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin count"})
|
||||
return
|
||||
}
|
||||
if adminCount <= 1 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete the last admin account"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate anonymized hash from user ID
|
||||
hash := sha256.Sum256([]byte(userID))
|
||||
anonHash := fmt.Sprintf("%x", hash[:6]) // 12 hex chars
|
||||
|
||||
// Step 1: Soft-delete/hard-delete user data
|
||||
counts, err := h.stores.Export.SoftDeleteUserData(ctx, userID)
|
||||
if err != nil {
|
||||
slog.Error("gdpr: delete user data", "error", err, "user_id", userID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user data"})
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Revoke tokens
|
||||
if err := h.stores.Export.DeleteUserTokens(ctx, userID); err != nil {
|
||||
slog.Error("gdpr: delete tokens", "error", err, "user_id", userID)
|
||||
// Continue — tokens will expire naturally
|
||||
}
|
||||
|
||||
// Step 3: Delete personal provider configs
|
||||
if deleted, err := h.stores.Providers.DeletePersonalByOwner(ctx, userID); err != nil {
|
||||
slog.Error("gdpr: delete provider configs", "error", err)
|
||||
} else {
|
||||
counts["provider_configs"] = deleted
|
||||
}
|
||||
|
||||
// Step 4: Anonymize user record
|
||||
if err := h.stores.Export.AnonymizeUser(ctx, userID, anonHash); err != nil {
|
||||
slog.Error("gdpr: anonymize user", "error", err, "user_id", userID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to anonymize user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log (before the user record is fully anonymized)
|
||||
anonUsername := "deleted-user-" + anonHash
|
||||
h.stores.Audit.Log(ctx, &models.AuditEntry{
|
||||
ActorID: &userID,
|
||||
Action: "user.gdpr_delete",
|
||||
ResourceType: "user",
|
||||
ResourceID: userID,
|
||||
Metadata: models.JSONMap{"anonymized_as": anonUsername, "deleted_counts": counts},
|
||||
})
|
||||
|
||||
slog.Info("gdpr: account deleted", "user_id", userID, "anonymized_as", anonUsername)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "account deleted",
|
||||
"anonymized_as": anonUsername,
|
||||
})
|
||||
}
|
||||
733
server/handlers/import_data.go
Normal file
733
server/handlers/import_data.go
Normal file
@@ -0,0 +1,733 @@
|
||||
package handlers
|
||||
|
||||
// import_data.go — v0.34.0 CS1
|
||||
//
|
||||
// Data import endpoint: user data import from .switchboard archives.
|
||||
// Reads the archive, validates manifest, and imports entities in
|
||||
// FK-dependency order with UUID dedup (skip if ID exists).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/export"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/storage"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// DataImportHandler serves data import endpoints.
|
||||
type DataImportHandler struct {
|
||||
stores store.Stores
|
||||
objStore storage.ObjectStore
|
||||
}
|
||||
|
||||
// NewDataImportHandler creates a new handler for data import operations.
|
||||
func NewDataImportHandler(s store.Stores, obj storage.ObjectStore) *DataImportHandler {
|
||||
return &DataImportHandler{stores: s, objStore: obj}
|
||||
}
|
||||
|
||||
// ImportMyData imports user data from a .switchboard zip archive.
|
||||
// POST /api/v1/import/me
|
||||
func (h *DataImportHandler) ImportMyData(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
// ── Receive upload ──
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate extension
|
||||
if !strings.HasSuffix(header.Filename, export.ExportExtension) && !strings.HasSuffix(header.Filename, ".zip") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .switchboard or .zip archive"})
|
||||
return
|
||||
}
|
||||
|
||||
// Size check: use MaxExportArchiveSize
|
||||
if header.Size > export.MaxExportArchiveSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Save to temp file (zip.OpenReader needs a file path)
|
||||
tmp, err := os.CreateTemp("", "switchboard-import-*.zip")
|
||||
if err != nil {
|
||||
slog.Error("import: create temp file", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
defer tmp.Close()
|
||||
|
||||
if _, err := io.Copy(tmp, file); err != nil {
|
||||
slog.Error("import: save temp file", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
|
||||
return
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
// ── Open archive ──
|
||||
ar, err := export.OpenArchive(tmp.Name())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer ar.Close()
|
||||
|
||||
manifest, err := ar.ReadManifest()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if manifest.FormatVersion > export.FormatVersion {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("unsupported format version %d (max %d)", manifest.FormatVersion, export.FormatVersion),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Read entities from archive ──
|
||||
imported := make(map[string]int)
|
||||
skipped := make(map[string]int)
|
||||
var errors []string
|
||||
|
||||
importEntity := func(name string, fn func() (int, int, error)) {
|
||||
imp, skip, err := fn()
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
|
||||
slog.Error("import: entity error", "entity", name, "error", err)
|
||||
}
|
||||
if imp > 0 {
|
||||
imported[name] = imp
|
||||
}
|
||||
if skip > 0 {
|
||||
skipped[name] = skip
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to remap user_id on entities for cross-instance import
|
||||
remapUserID := manifest.Scope != nil && manifest.Scope.UserID != "" && manifest.Scope.UserID != userID
|
||||
sourceUserID := ""
|
||||
if manifest.Scope != nil {
|
||||
sourceUserID = manifest.Scope.UserID
|
||||
}
|
||||
|
||||
// 1. Folders
|
||||
var folders []models.Folder
|
||||
if err := ar.ReadEntityJSON("folders", &folders); err == nil && len(folders) > 0 {
|
||||
if remapUserID {
|
||||
for i := range folders {
|
||||
if folders[i].UserID == sourceUserID {
|
||||
folders[i].UserID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("folders", func() (int, int, error) {
|
||||
return h.stores.Export.ImportFolders(ctx, folders)
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Projects
|
||||
var projects []models.Project
|
||||
if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 {
|
||||
if remapUserID {
|
||||
for i := range projects {
|
||||
if projects[i].OwnerID == sourceUserID {
|
||||
projects[i].OwnerID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("projects", func() (int, int, error) {
|
||||
return h.stores.Export.ImportProjects(ctx, projects)
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Workspaces
|
||||
var workspaces []models.Workspace
|
||||
if err := ar.ReadEntityJSON("workspaces", &workspaces); err == nil && len(workspaces) > 0 {
|
||||
if remapUserID {
|
||||
for i := range workspaces {
|
||||
if workspaces[i].OwnerID == sourceUserID {
|
||||
workspaces[i].OwnerID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("workspaces", func() (int, int, error) {
|
||||
return h.stores.Export.ImportWorkspaces(ctx, workspaces)
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Channels
|
||||
var channels []models.Channel
|
||||
if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 {
|
||||
if remapUserID {
|
||||
for i := range channels {
|
||||
if channels[i].UserID == sourceUserID {
|
||||
channels[i].UserID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("channels", func() (int, int, error) {
|
||||
return h.stores.Export.ImportChannels(ctx, channels)
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Channel participants
|
||||
var participants []models.ChannelParticipant
|
||||
if err := ar.ReadEntityJSON("channel_participants", &participants); err == nil && len(participants) > 0 {
|
||||
if remapUserID {
|
||||
for i := range participants {
|
||||
if participants[i].ParticipantType == "user" && participants[i].ParticipantID == sourceUserID {
|
||||
participants[i].ParticipantID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("channel_participants", func() (int, int, error) {
|
||||
return h.stores.Export.ImportChannelParticipants(ctx, participants)
|
||||
})
|
||||
}
|
||||
|
||||
// 5b. Channel models
|
||||
var channelModels []models.ChannelModel
|
||||
if err := ar.ReadEntityJSON("channel_models", &channelModels); err == nil && len(channelModels) > 0 {
|
||||
importEntity("channel_models", func() (int, int, error) {
|
||||
return h.stores.Export.ImportChannelModels(ctx, channelModels)
|
||||
})
|
||||
}
|
||||
|
||||
// 5c. Channel cursors
|
||||
var cursors []models.ChannelCursor
|
||||
if err := ar.ReadEntityJSON("channel_cursors", &cursors); err == nil && len(cursors) > 0 {
|
||||
if remapUserID {
|
||||
for i := range cursors {
|
||||
if cursors[i].UserID == sourceUserID {
|
||||
cursors[i].UserID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("channel_cursors", func() (int, int, error) {
|
||||
return h.stores.Export.ImportChannelCursors(ctx, cursors)
|
||||
})
|
||||
}
|
||||
|
||||
// 6. Messages (ordered by created_at ASC for parent_id integrity)
|
||||
var messages []models.Message
|
||||
if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 {
|
||||
if remapUserID {
|
||||
for i := range messages {
|
||||
if messages[i].ParticipantType == "user" && messages[i].ParticipantID == sourceUserID {
|
||||
messages[i].ParticipantID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("messages", func() (int, int, error) {
|
||||
return h.stores.Export.ImportMessages(ctx, messages)
|
||||
})
|
||||
}
|
||||
|
||||
// 7. Notes
|
||||
var notes []models.Note
|
||||
if err := ar.ReadEntityJSON("notes", ¬es); err == nil && len(notes) > 0 {
|
||||
if remapUserID {
|
||||
for i := range notes {
|
||||
if notes[i].UserID == sourceUserID {
|
||||
notes[i].UserID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("notes", func() (int, int, error) {
|
||||
return h.stores.Export.ImportNotes(ctx, notes)
|
||||
})
|
||||
}
|
||||
|
||||
// 8. Note links
|
||||
var noteLinks []models.ExportNoteLink
|
||||
if err := ar.ReadEntityJSON("note_links", ¬eLinks); err == nil && len(noteLinks) > 0 {
|
||||
importEntity("note_links", func() (int, int, error) {
|
||||
return h.stores.Export.ImportNoteLinks(ctx, noteLinks)
|
||||
})
|
||||
}
|
||||
|
||||
// 9. Memories
|
||||
var memories []models.Memory
|
||||
if err := ar.ReadEntityJSON("memories", &memories); err == nil && len(memories) > 0 {
|
||||
if remapUserID {
|
||||
for i := range memories {
|
||||
if memories[i].OwnerID == sourceUserID {
|
||||
memories[i].OwnerID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("memories", func() (int, int, error) {
|
||||
return h.stores.Export.ImportMemories(ctx, memories)
|
||||
})
|
||||
}
|
||||
|
||||
// 10. Project channels
|
||||
var projectChannels []models.ProjectChannel
|
||||
if err := ar.ReadEntityJSON("project_channels", &projectChannels); err == nil && len(projectChannels) > 0 {
|
||||
importEntity("project_channels", func() (int, int, error) {
|
||||
return h.stores.Export.ImportProjectChannels(ctx, projectChannels)
|
||||
})
|
||||
}
|
||||
|
||||
// 10b. Project KBs
|
||||
var projectKBs []models.ProjectKB
|
||||
if err := ar.ReadEntityJSON("project_knowledge_bases", &projectKBs); err == nil && len(projectKBs) > 0 {
|
||||
importEntity("project_knowledge_bases", func() (int, int, error) {
|
||||
return h.stores.Export.ImportProjectKBs(ctx, projectKBs)
|
||||
})
|
||||
}
|
||||
|
||||
// 10c. Project notes
|
||||
var projectNotes []models.ProjectNote
|
||||
if err := ar.ReadEntityJSON("project_notes", &projectNotes); err == nil && len(projectNotes) > 0 {
|
||||
importEntity("project_notes", func() (int, int, error) {
|
||||
return h.stores.Export.ImportProjectNotes(ctx, projectNotes)
|
||||
})
|
||||
}
|
||||
|
||||
// 11. Workspace files
|
||||
var workspaceFiles []models.WorkspaceFile
|
||||
if err := ar.ReadEntityJSON("workspace_files", &workspaceFiles); err == nil && len(workspaceFiles) > 0 {
|
||||
importEntity("workspace_files", func() (int, int, error) {
|
||||
return h.stores.Export.ImportWorkspaceFiles(ctx, workspaceFiles)
|
||||
})
|
||||
}
|
||||
|
||||
// 12. Files (metadata)
|
||||
var files []models.File
|
||||
if err := ar.ReadEntityJSON("files", &files); err == nil && len(files) > 0 {
|
||||
if remapUserID {
|
||||
for i := range files {
|
||||
if files[i].UserID == sourceUserID {
|
||||
files[i].UserID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generate storage keys for imported files
|
||||
for i := range files {
|
||||
if files[i].StorageKey == "" && files[i].ChannelID != "" {
|
||||
files[i].StorageKey = fmt.Sprintf("files/%s/%s_%s", files[i].ChannelID, files[i].ID, files[i].Filename)
|
||||
}
|
||||
}
|
||||
importEntity("files", func() (int, int, error) {
|
||||
return h.stores.Export.ImportFiles(ctx, files)
|
||||
})
|
||||
}
|
||||
|
||||
// 12b. File blobs from archive
|
||||
if h.objStore != nil {
|
||||
fileBlobs := ar.FileEntries()
|
||||
blobCount := 0
|
||||
for _, entry := range fileBlobs {
|
||||
if blobCount >= export.MaxExportFiles {
|
||||
errors = append(errors, "file blob limit reached, some files skipped")
|
||||
break
|
||||
}
|
||||
// Extract file ID from path: files/{fileID}/{filename}
|
||||
parts := strings.SplitN(strings.TrimPrefix(entry.Name, "files/"), "/", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
fileID := parts[0]
|
||||
filename := parts[1]
|
||||
|
||||
// Find matching file record for storage key
|
||||
storageKey := ""
|
||||
var contentType string
|
||||
for _, f := range files {
|
||||
if f.ID == fileID {
|
||||
storageKey = f.StorageKey
|
||||
contentType = f.ContentType
|
||||
break
|
||||
}
|
||||
}
|
||||
if storageKey == "" {
|
||||
storageKey = fmt.Sprintf("files/imported/%s_%s", fileID, filename)
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
rc, err := entry.Open()
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("file %s: open error", filepath.Base(entry.Name)))
|
||||
continue
|
||||
}
|
||||
if err := h.objStore.Put(ctx, storageKey, rc, int64(entry.UncompressedSize64), contentType); err != nil {
|
||||
rc.Close()
|
||||
errors = append(errors, fmt.Sprintf("file %s: upload error", filepath.Base(entry.Name)))
|
||||
continue
|
||||
}
|
||||
rc.Close()
|
||||
blobCount++
|
||||
}
|
||||
if blobCount > 0 {
|
||||
imported["file_blobs"] = blobCount
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Settings/Prefs
|
||||
var userModelSettings []models.UserModelSetting
|
||||
if err := ar.ReadEntityJSON("user_settings", &userModelSettings); err == nil && len(userModelSettings) > 0 {
|
||||
if remapUserID {
|
||||
for i := range userModelSettings {
|
||||
if userModelSettings[i].UserID == sourceUserID {
|
||||
userModelSettings[i].UserID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("user_settings", func() (int, int, error) {
|
||||
return h.stores.Export.ImportUserModelSettings(ctx, userModelSettings)
|
||||
})
|
||||
}
|
||||
|
||||
var notifPrefs []models.NotificationPreference
|
||||
if err := ar.ReadEntityJSON("notification_preferences", ¬ifPrefs); err == nil && len(notifPrefs) > 0 {
|
||||
if remapUserID {
|
||||
for i := range notifPrefs {
|
||||
if notifPrefs[i].UserID == sourceUserID {
|
||||
notifPrefs[i].UserID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("notification_preferences", func() (int, int, error) {
|
||||
return h.stores.Export.ImportNotifPrefs(ctx, notifPrefs)
|
||||
})
|
||||
}
|
||||
|
||||
// 14. Persona groups
|
||||
var personaGroups []models.PersonaGroup
|
||||
if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 {
|
||||
if remapUserID {
|
||||
for i := range personaGroups {
|
||||
if personaGroups[i].OwnerID == sourceUserID {
|
||||
personaGroups[i].OwnerID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("persona_groups", func() (int, int, error) {
|
||||
return h.stores.Export.ImportPersonaGroups(ctx, personaGroups)
|
||||
})
|
||||
}
|
||||
|
||||
var personaGroupMembers []models.PersonaGroupMember
|
||||
if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 {
|
||||
importEntity("persona_group_members", func() (int, int, error) {
|
||||
return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Audit log ──
|
||||
h.stores.Audit.Log(ctx, &models.AuditEntry{
|
||||
ActorID: &userID,
|
||||
Action: "user.import",
|
||||
ResourceType: "user",
|
||||
ResourceID: userID,
|
||||
Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors},
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
})
|
||||
}
|
||||
|
||||
// ImportTeam imports team data from a .switchboard archive.
|
||||
// POST /api/v1/admin/teams/:id/import
|
||||
func (h *DataImportHandler) ImportTeam(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
userID := c.GetString("user_id")
|
||||
teamID := c.Param("id")
|
||||
|
||||
// Verify team 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
|
||||
}
|
||||
|
||||
// Receive upload
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > export.MaxExportArchiveSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "switchboard-team-import-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
defer tmp.Close()
|
||||
|
||||
if _, err := io.Copy(tmp, file); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
|
||||
return
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
ar, err := export.OpenArchive(tmp.Name())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer ar.Close()
|
||||
|
||||
manifest, err := ar.ReadManifest()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if manifest.FormatVersion > export.FormatVersion {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("unsupported format version %d", manifest.FormatVersion),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
imported := make(map[string]int)
|
||||
skipped := make(map[string]int)
|
||||
var errors []string
|
||||
|
||||
importEntity := func(name string, fn func() (int, int, error)) {
|
||||
imp, skip, err := fn()
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
|
||||
slog.Error("import: team entity error", "entity", name, "error", err)
|
||||
}
|
||||
if imp > 0 {
|
||||
imported[name] = imp
|
||||
}
|
||||
if skip > 0 {
|
||||
skipped[name] = skip
|
||||
}
|
||||
}
|
||||
|
||||
// Remap team_id on entities to target team
|
||||
sourceTeamID := ""
|
||||
if manifest.Scope != nil {
|
||||
sourceTeamID = manifest.Scope.TeamID
|
||||
}
|
||||
remapTeam := sourceTeamID != "" && sourceTeamID != teamID
|
||||
|
||||
// Channels (remap team_id)
|
||||
var channels []models.Channel
|
||||
if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 {
|
||||
if remapTeam {
|
||||
for i := range channels {
|
||||
if channels[i].TeamID != nil && *channels[i].TeamID == sourceTeamID {
|
||||
channels[i].TeamID = &teamID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("channels", func() (int, int, error) {
|
||||
return h.stores.Export.ImportChannels(ctx, channels)
|
||||
})
|
||||
}
|
||||
|
||||
// Messages
|
||||
var messages []models.Message
|
||||
if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 {
|
||||
importEntity("messages", func() (int, int, error) {
|
||||
return h.stores.Export.ImportMessages(ctx, messages)
|
||||
})
|
||||
}
|
||||
|
||||
// Projects (remap team_id)
|
||||
var projects []models.Project
|
||||
if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 {
|
||||
if remapTeam {
|
||||
for i := range projects {
|
||||
if projects[i].TeamID != nil && *projects[i].TeamID == sourceTeamID {
|
||||
projects[i].TeamID = &teamID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("projects", func() (int, int, error) {
|
||||
return h.stores.Export.ImportProjects(ctx, projects)
|
||||
})
|
||||
}
|
||||
|
||||
// Persona groups (remap team_id)
|
||||
var personaGroups []models.PersonaGroup
|
||||
if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 {
|
||||
if remapTeam {
|
||||
for i := range personaGroups {
|
||||
if personaGroups[i].TeamID != nil && *personaGroups[i].TeamID == sourceTeamID {
|
||||
personaGroups[i].TeamID = &teamID
|
||||
}
|
||||
}
|
||||
}
|
||||
importEntity("persona_groups", func() (int, int, error) {
|
||||
return h.stores.Export.ImportPersonaGroups(ctx, personaGroups)
|
||||
})
|
||||
}
|
||||
|
||||
var personaGroupMembers []models.PersonaGroupMember
|
||||
if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 {
|
||||
importEntity("persona_group_members", func() (int, int, error) {
|
||||
return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers)
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
h.stores.Audit.Log(ctx, &models.AuditEntry{
|
||||
ActorID: &userID,
|
||||
Action: "team.import",
|
||||
ResourceType: "team",
|
||||
ResourceID: teamID,
|
||||
Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors},
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
})
|
||||
}
|
||||
|
||||
// ImportChatGPT imports conversations from a ChatGPT export.
|
||||
// POST /api/v1/import/chatgpt
|
||||
func (h *DataImportHandler) ImportChatGPT(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > export.MaxExportArchiveSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("file too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Detect if it's a zip or raw JSON
|
||||
var convs export.ChatGPTExport
|
||||
|
||||
if strings.HasSuffix(header.Filename, ".zip") {
|
||||
// Save to temp and extract conversations.json
|
||||
tmp, err := os.CreateTemp("", "chatgpt-import-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
defer tmp.Close()
|
||||
|
||||
if _, err := io.Copy(tmp, file); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
|
||||
return
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
ar, err := export.OpenArchive(tmp.Name())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
||||
return
|
||||
}
|
||||
defer ar.Close()
|
||||
|
||||
// Try to find conversations.json in the zip
|
||||
if err := ar.ReadEntityJSON("conversations", &convs); err != nil || len(convs) == 0 {
|
||||
// Try searching all files for conversations.json
|
||||
for _, f := range ar.FileEntries() {
|
||||
if strings.HasSuffix(f.Name, "conversations.json") {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
convs, err = export.ParseChatGPTExport(rc)
|
||||
rc.Close()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Raw JSON file
|
||||
convs, err = export.ParseChatGPTExport(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse conversations.json: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(convs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no conversations found in upload"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to Switchboard models
|
||||
result := export.ConvertChatGPTExport(convs, userID)
|
||||
|
||||
// Import channels and messages
|
||||
chImported, chSkipped, err := h.stores.Export.ImportChannels(ctx, result.Channels)
|
||||
if err != nil {
|
||||
slog.Error("chatgpt import: channels", "error", err)
|
||||
}
|
||||
|
||||
msgImported, msgSkipped, err := h.stores.Export.ImportMessages(ctx, result.Messages)
|
||||
if err != nil {
|
||||
slog.Error("chatgpt import: messages", "error", err)
|
||||
}
|
||||
|
||||
imported := map[string]int{
|
||||
"channels": chImported,
|
||||
"messages": msgImported,
|
||||
}
|
||||
skipped := map[string]int{
|
||||
"channels": chSkipped,
|
||||
"messages": msgSkipped,
|
||||
}
|
||||
|
||||
h.stores.Audit.Log(ctx, &models.AuditEntry{
|
||||
ActorID: &userID,
|
||||
Action: "user.import_chatgpt",
|
||||
ResourceType: "user",
|
||||
ResourceID: userID,
|
||||
Metadata: models.JSONMap{
|
||||
"conversations": len(convs),
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
},
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"conversations": len(convs),
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user