Changeset 0.21.1 (#86)
This commit is contained in:
466
server/handlers/workspaces.go
Normal file
466
server/handlers/workspaces.go
Normal file
@@ -0,0 +1,466 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// ── 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user