507 lines
13 KiB
Go
507 lines
13 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)
|
|
}
|
|
|
|
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 ""
|
|
}
|