- 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)
734 lines
21 KiB
Go
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"
|
|
|
|
"switchboard-core/export"
|
|
"switchboard-core/models"
|
|
"switchboard-core/storage"
|
|
"switchboard-core/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,
|
|
})
|
|
}
|