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/workspaces.go
2026-03-01 20:35:10 +00:00

535 lines
14 KiB
Go

package handlers
import (
"context"
"database/sql"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/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, 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
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
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 ""
}