This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/import_data.go
Jeffrey Smith d16bb93177 Changeset 0.34.0 (#208)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-20 00:02:37 +00:00

734 lines
21 KiB
Go

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", &notes); 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", &noteLinks); 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", &notifPrefs); 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,
})
}