package handlers import ( "context" "database/sql" "fmt" "io" "log" "net/http" "os" "strings" "github.com/gin-gonic/gin" "chat-switchboard/models" "chat-switchboard/store" "chat-switchboard/workspace" ) // ── Request Types ─────────────────────────── type createWorkspaceRequest struct { Name string `json:"name" binding:"required,max=200"` OwnerType string `json:"owner_type" binding:"required,oneof=user project channel team"` OwnerID string `json:"owner_id" binding:"required"` MaxBytes *int64 `json:"max_bytes,omitempty"` } type updateWorkspaceRequest = models.WorkspacePatch // ── Handler ───────────────────────────────── // WorkspaceHandler handles workspace CRUD, file operations, and archive management. type WorkspaceHandler struct { stores store.Stores wfs *workspace.FS } // NewWorkspaceHandler creates a new workspace handler. func NewWorkspaceHandler(s store.Stores, wfs *workspace.FS) *WorkspaceHandler { return &WorkspaceHandler{stores: s, wfs: wfs} } // ── Workspace CRUD ────────────────────────── func (h *WorkspaceHandler) Create(c *gin.Context) { var req createWorkspaceRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Authorization: verify the caller owns or can access the owner entity if !h.canAccessOwner(c, req.OwnerType, req.OwnerID) { c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) return } w := &models.Workspace{ OwnerType: req.OwnerType, OwnerID: req.OwnerID, Name: req.Name, MaxBytes: req.MaxBytes, Status: models.WorkspaceStatusActive, } // Pre-generate ID so root_path can be set before DB insert. // Works for both Postgres (overrides gen_random_uuid()) and SQLite (store.NewID()). w.ID = store.NewID() w.RootPath = "workspaces/" + w.ID if err := h.stores.Workspaces.Create(c.Request.Context(), w); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace"}) log.Printf("workspace create: %v", err) return } // Create directory on disk if err := h.wfs.CreateDir(w); err != nil { log.Printf("workspace mkdir: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace directory"}) return } c.JSON(http.StatusCreated, w) } func (h *WorkspaceHandler) Get(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } // Enrich with stats stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID) if err == nil { w.FileCount = stats.FileCount w.TotalBytes = stats.TotalBytes } c.JSON(http.StatusOK, w) } // List returns all workspaces accessible to the current user. // Includes user-owned workspaces and those owned by the user's teams. func (h *WorkspaceHandler) List(c *gin.Context) { userID := getUserID(c) ctx := c.Request.Context() // Fetch user-owned workspaces userWs, err := h.stores.Workspaces.ListByOwner(ctx, "user", userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } all := make([]models.Workspace, 0, len(userWs)) all = append(all, userWs...) // Also include team-owned workspaces teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID) for _, tid := range teamIDs { teamWs, err := h.stores.Workspaces.ListByOwner(ctx, "team", tid) if err == nil { all = append(all, teamWs...) } } c.JSON(http.StatusOK, gin.H{"data": all}) } func (h *WorkspaceHandler) Update(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } var req updateWorkspaceRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Workspaces.Update(c.Request.Context(), w.ID, req); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workspace"}) return } // Re-fetch to return the updated object updated, err := h.stores.Workspaces.GetByID(c.Request.Context(), w.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload workspace"}) return } c.JSON(http.StatusOK, updated) } func (h *WorkspaceHandler) Delete(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } // Mark as deleting status := models.WorkspaceStatusDeleting h.stores.Workspaces.Update(c.Request.Context(), w.ID, models.WorkspacePatch{Status: &status}) // Async cleanup (use background context — request ctx will be cancelled) go func() { ctx := context.Background() if err := h.wfs.Destroy(ctx, w); err != nil { log.Printf("workspace destroy %s: %v", w.ID, err) } h.stores.Workspaces.Delete(ctx, w.ID) log.Printf("workspace deleted: %s", w.ID) }() c.JSON(http.StatusOK, gin.H{"ok": true}) } // ── File Operations ───────────────────────── func (h *WorkspaceHandler) ListFiles(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } path := c.Query("path") recursive := c.Query("recursive") == "true" files, err := h.wfs.ListDir(c.Request.Context(), w, path, recursive) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return } if files == nil { files = []models.WorkspaceFile{} } c.JSON(http.StatusOK, gin.H{"data": files}) } func (h *WorkspaceHandler) ReadFile(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } path := c.Query("path") if path == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) return } rc, size, err := h.wfs.ReadFile(c.Request.Context(), w, path) if err != nil { if strings.Contains(err.Error(), "not found") { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } defer rc.Close() // Detect content type for response header f, _ := h.wfs.Stat(c.Request.Context(), w, path) contentType := "application/octet-stream" if f != nil && f.ContentType != "" { contentType = f.ContentType } c.Header("Content-Length", fmt.Sprintf("%d", size)) c.DataFromReader(http.StatusOK, size, contentType, rc, nil) } func (h *WorkspaceHandler) WriteFile(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } path := c.Query("path") if path == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) return } // Quota check stats, _ := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID) quota := workspace.DefaultMaxBytes if w.MaxBytes != nil { quota = *w.MaxBytes } if stats != nil && stats.TotalBytes+c.Request.ContentLength > quota { c.JSON(http.StatusRequestEntityTooLarge, gin.H{ "error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota), }) return } if err := h.wfs.WriteFile(c.Request.Context(), w, path, c.Request.Body, c.Request.ContentLength); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "path": path}) } func (h *WorkspaceHandler) DeleteFileHandler(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } path := c.Query("path") if path == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) return } recursive := c.Query("recursive") == "true" if err := h.wfs.DeleteFile(c.Request.Context(), w, path, recursive); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *WorkspaceHandler) Mkdir(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } path := c.Query("path") if path == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) return } if err := h.wfs.Mkdir(c.Request.Context(), w, path); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"ok": true, "path": path}) } // ── Archive Operations ────────────────────── func (h *WorkspaceHandler) UploadArchive(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } // Accept multipart file upload file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"}) return } defer file.Close() // Detect format from filename format := detectArchiveFormat(header.Filename) if format == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"}) return } // Save to temp file (archive extraction needs seekable file for zip) tmp, err := os.CreateTemp("", "ws-upload-*") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) return } tmpName := tmp.Name() defer os.Remove(tmpName) if _, err := io.Copy(tmp, file); err != nil { tmp.Close() c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) return } tmp.Close() count, err := h.wfs.ExtractArchive(c.Request.Context(), w, tmpName, format) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "ok": true, "files_extracted": count, }) } func (h *WorkspaceHandler) DownloadArchive(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } format := c.DefaultQuery("format", "zip") if format != "zip" && format != "tar.gz" && format != "tgz" { c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"}) return } archivePath, err := h.wfs.CreateArchive(c.Request.Context(), w, format) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer os.Remove(archivePath) ext := "zip" contentType := "application/zip" if format == "tar.gz" || format == "tgz" { ext = "tar.gz" contentType = "application/gzip" } filename := fmt.Sprintf("%s.%s", w.Name, ext) c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) c.File(archivePath) _ = contentType // c.File sets the content type from the file } // ── Reconcile + Stats ─────────────────────── func (h *WorkspaceHandler) Reconcile(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } added, removed, updated, err := h.wfs.Reconcile(c.Request.Context(), w) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "added": added, "removed": removed, "updated": updated, }) } func (h *WorkspaceHandler) Stats(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) return } c.JSON(http.StatusOK, stats) } // IndexStatus returns indexing progress for all files in the workspace (v0.21.2). func (h *WorkspaceHandler) IndexStatus(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { return } ctx := c.Request.Context() files, err := h.stores.Workspaces.ListFiles(ctx, w.ID, "", true) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return } // Aggregate counts by status counts := map[string]int{ "pending": 0, "indexing": 0, "ready": 0, "error": 0, "skipped": 0, } totalChunks := 0 for _, f := range files { if f.IsDirectory { continue } status := f.IndexStatus if status == "" { status = "pending" } counts[status]++ totalChunks += f.ChunkCount } c.JSON(http.StatusOK, gin.H{ "workspace_id": w.ID, "indexing_enabled": w.IndexingEnabled, "status_counts": counts, "total_chunks": totalChunks, }) } // ── Authorization Helpers ─────────────────── // loadAndAuthorize loads a workspace by :id param and checks user access. func (h *WorkspaceHandler) loadAndAuthorize(c *gin.Context) (*models.Workspace, bool) { wsID := c.Param("id") ctx := c.Request.Context() w, err := h.stores.Workspaces.GetByID(ctx, wsID) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workspace"}) } return nil, false } if !h.canAccessOwner(c, w.OwnerType, w.OwnerID) { c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) return nil, false } return w, true } // canAccessOwner checks if the current user can access the workspace's owner entity. func (h *WorkspaceHandler) canAccessOwner(c *gin.Context, ownerType, ownerID string) bool { userID := getUserID(c) ctx := c.Request.Context() switch ownerType { case models.WorkspaceOwnerUser: return ownerID == userID case models.WorkspaceOwnerChannel: // User must own the channel ch, err := h.stores.Channels.GetByID(ctx, ownerID) if err != nil { return false } return ch.UserID == userID case models.WorkspaceOwnerProject: teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID) ok, _ := h.stores.Projects.UserCanAccess(ctx, userID, ownerID, teamIDs) return ok case models.WorkspaceOwnerTeam: // User must be a member of the team _, err := h.stores.Teams.GetMember(ctx, ownerID, userID) return err == nil default: return false } } // ── Helpers ───────────────────────────────── // detectArchiveFormat returns "zip" or "tar.gz" based on filename, or "" if unsupported. func detectArchiveFormat(filename string) string { lower := strings.ToLower(filename) if strings.HasSuffix(lower, ".zip") { return "zip" } if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") { return "tar.gz" } return "" }