Changeset 0.21.1 (#86)
This commit is contained in:
74
server/database/migrations/009_v0210_workspaces.sql
Normal file
74
server/database/migrations/009_v0210_workspaces.sql
Normal file
@@ -0,0 +1,74 @@
|
||||
-- v0.21.0: Workspaces (platform storage primitive)
|
||||
--
|
||||
-- New: workspaces table (polymorphic owner: user, project, channel, team)
|
||||
-- New: workspace_files table (metadata index — filesystem is source of truth)
|
||||
|
||||
-- =========================================
|
||||
-- 1. Workspaces Table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_type VARCHAR(20) NOT NULL
|
||||
CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
|
||||
owner_id UUID NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
root_path TEXT NOT NULL, -- PVC-relative: workspaces/{id}
|
||||
max_bytes BIGINT, -- quota (NULL = system default)
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'archived', 'deleting')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_owner
|
||||
ON workspaces(owner_type, owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_status
|
||||
ON workspaces(status) WHERE status != 'deleting';
|
||||
|
||||
DROP TRIGGER IF EXISTS workspaces_updated_at ON workspaces;
|
||||
CREATE TRIGGER workspaces_updated_at BEFORE UPDATE ON workspaces
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE workspaces IS 'File workspaces bound to users, projects, channels, or teams';
|
||||
COMMENT ON COLUMN workspaces.owner_type IS 'Polymorphic owner: user, project, channel, team';
|
||||
COMMENT ON COLUMN workspaces.root_path IS 'PVC-relative path to workspace root directory';
|
||||
COMMENT ON COLUMN workspaces.max_bytes IS 'Storage quota in bytes (NULL = system default)';
|
||||
|
||||
-- =========================================
|
||||
-- 2. Workspace Files Table
|
||||
-- =========================================
|
||||
-- Metadata index for workspace files. The PVC filesystem is the source
|
||||
-- of truth; this table enables fast queries (list, filter by type, etc.)
|
||||
-- and will hold embedding references in v0.21.2.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL, -- relative path: src/main.go
|
||||
is_directory BOOLEAN NOT NULL DEFAULT false,
|
||||
content_type VARCHAR(100), -- MIME type
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
sha256 VARCHAR(64), -- content hash (NULL for dirs)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path
|
||||
ON workspace_files(workspace_id, path);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace
|
||||
ON workspace_files(workspace_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_type
|
||||
ON workspace_files(workspace_id, content_type)
|
||||
WHERE content_type IS NOT NULL;
|
||||
|
||||
DROP TRIGGER IF EXISTS workspace_files_updated_at ON workspace_files;
|
||||
CREATE TRIGGER workspace_files_updated_at BEFORE UPDATE ON workspace_files
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
COMMENT ON TABLE workspace_files IS 'Metadata index for workspace files — filesystem is source of truth';
|
||||
COMMENT ON COLUMN workspace_files.path IS 'Relative path from workspace files/ root';
|
||||
COMMENT ON COLUMN workspace_files.sha256 IS 'Content hash for change detection and dedup';
|
||||
62
server/database/migrations/sqlite/008_v0210_workspaces.sql
Normal file
62
server/database/migrations/sqlite/008_v0210_workspaces.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- v0.21.0: Workspaces (platform storage primitive) — SQLite
|
||||
|
||||
-- =========================================
|
||||
-- 1. Workspaces Table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_type TEXT NOT NULL
|
||||
CHECK (owner_type IN ('user', 'project', 'channel', 'team')),
|
||||
owner_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL,
|
||||
max_bytes INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'archived', 'deleting')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_owner
|
||||
ON workspaces(owner_type, owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_status
|
||||
ON workspaces(status);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS workspaces_updated_at AFTER UPDATE ON workspaces
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE workspaces SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- =========================================
|
||||
-- 2. Workspace Files Table
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
is_directory INTEGER NOT NULL DEFAULT 0,
|
||||
content_type TEXT,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_files_path
|
||||
ON workspace_files(workspace_id, path);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_workspace
|
||||
ON workspace_files(workspace_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_files_type
|
||||
ON workspace_files(workspace_id, content_type);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS workspace_files_updated_at AFTER UPDATE ON workspace_files
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE workspace_files SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
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 ""
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/workspace"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -150,6 +151,20 @@ func main() {
|
||||
// ── EventBus (created early — needed by role resolver) ──
|
||||
bus := events.NewBus()
|
||||
|
||||
// ── Workspace FS (v0.21.0) ──────────────
|
||||
// Provides file operations for workspace storage primitive.
|
||||
// Nil-safe: handler checks wfs != nil before operating.
|
||||
var wfs *workspace.FS
|
||||
if cfg.StoragePath != "" {
|
||||
wfs = workspace.NewFS(cfg.StoragePath+"/workspaces", stores.Workspaces)
|
||||
if err := wfs.Init(); err != nil {
|
||||
log.Printf("⚠ Workspace FS init failed: %v", err)
|
||||
wfs = nil
|
||||
} else {
|
||||
log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Role resolver for model role dispatch (needs stores + vault + bus)
|
||||
roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)
|
||||
|
||||
@@ -400,6 +415,24 @@ func main() {
|
||||
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
|
||||
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
|
||||
|
||||
// Workspaces (v0.21.0)
|
||||
if wfs != nil {
|
||||
wsH := handlers.NewWorkspaceHandler(stores, wfs)
|
||||
protected.POST("/workspaces", wsH.Create)
|
||||
protected.GET("/workspaces/:id", wsH.Get)
|
||||
protected.PATCH("/workspaces/:id", wsH.Update)
|
||||
protected.DELETE("/workspaces/:id", wsH.Delete)
|
||||
protected.GET("/workspaces/:id/files", wsH.ListFiles)
|
||||
protected.GET("/workspaces/:id/files/read", wsH.ReadFile)
|
||||
protected.PUT("/workspaces/:id/files/write", wsH.WriteFile)
|
||||
protected.DELETE("/workspaces/:id/files/delete", wsH.DeleteFileHandler)
|
||||
protected.POST("/workspaces/:id/files/mkdir", wsH.Mkdir)
|
||||
protected.POST("/workspaces/:id/archive/upload", wsH.UploadArchive)
|
||||
protected.GET("/workspaces/:id/archive/download", wsH.DownloadArchive)
|
||||
protected.POST("/workspaces/:id/reconcile", wsH.Reconcile)
|
||||
protected.GET("/workspaces/:id/stats", wsH.Stats)
|
||||
}
|
||||
|
||||
// Attachments (file upload/download)
|
||||
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||
|
||||
66
server/models/models_workspace.go
Normal file
66
server/models/models_workspace.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// =========================================
|
||||
// WORKSPACES (v0.21.0)
|
||||
// =========================================
|
||||
|
||||
// Workspace owner type constants.
|
||||
const (
|
||||
WorkspaceOwnerUser = "user"
|
||||
WorkspaceOwnerProject = "project"
|
||||
WorkspaceOwnerChannel = "channel"
|
||||
WorkspaceOwnerTeam = "team"
|
||||
)
|
||||
|
||||
// Workspace status constants.
|
||||
const (
|
||||
WorkspaceStatusActive = "active"
|
||||
WorkspaceStatusArchived = "archived"
|
||||
WorkspaceStatusDeleting = "deleting"
|
||||
)
|
||||
|
||||
// Workspace represents a file workspace bound to an owner (user, project, channel, or team).
|
||||
type Workspace struct {
|
||||
BaseModel
|
||||
OwnerType string `json:"owner_type" db:"owner_type"`
|
||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
RootPath string `json:"-" db:"root_path"` // never expose filesystem path
|
||||
MaxBytes *int64 `json:"max_bytes,omitempty" db:"max_bytes"`
|
||||
Status string `json:"status" db:"status"`
|
||||
|
||||
// Computed fields (not DB columns)
|
||||
FileCount int `json:"file_count,omitempty"`
|
||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspacePatch holds optional fields for updating a workspace.
|
||||
type WorkspacePatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
MaxBytes *int64 `json:"max_bytes,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceFile represents metadata for a single file or directory in a workspace.
|
||||
type WorkspaceFile struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
|
||||
Path string `json:"path" db:"path"`
|
||||
IsDirectory bool `json:"is_directory" db:"is_directory"`
|
||||
ContentType string `json:"content_type,omitempty" db:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
|
||||
SHA256 string `json:"sha256,omitempty" db:"sha256"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// WorkspaceStats holds aggregate storage metrics for a workspace.
|
||||
type WorkspaceStats struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
FileCount int `json:"file_count"`
|
||||
DirCount int `json:"dir_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
MaxBytes *int64 `json:"max_bytes,omitempty"`
|
||||
}
|
||||
@@ -42,6 +42,7 @@ type Stores struct {
|
||||
Projects ProjectStore
|
||||
Notifications NotificationStore
|
||||
NotifPrefs NotificationPreferenceStore
|
||||
Workspaces WorkspaceStore
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -510,6 +511,33 @@ type NotificationPreferenceStore interface {
|
||||
Delete(ctx context.Context, userID, notifType string) error
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// WORKSPACE STORE (v0.21.0)
|
||||
// =========================================
|
||||
|
||||
type WorkspaceStore interface {
|
||||
// CRUD
|
||||
Create(ctx context.Context, w *models.Workspace) error
|
||||
GetByID(ctx context.Context, id string) (*models.Workspace, error)
|
||||
Update(ctx context.Context, id string, patch models.WorkspacePatch) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Ownership lookup
|
||||
GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error)
|
||||
ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error)
|
||||
|
||||
// File index
|
||||
UpsertFile(ctx context.Context, f *models.WorkspaceFile) error
|
||||
DeleteFile(ctx context.Context, workspaceID, path string) error
|
||||
DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error
|
||||
GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error)
|
||||
ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error)
|
||||
DeleteAllFiles(ctx context.Context, workspaceID string) error
|
||||
|
||||
// Stats
|
||||
GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// SHARED TYPES
|
||||
// =========================================
|
||||
|
||||
@@ -35,5 +35,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Projects: NewProjectStore(),
|
||||
Notifications: NewNotificationStore(),
|
||||
NotifPrefs: NewNotificationPreferenceStore(),
|
||||
Workspaces: NewWorkspaceStore(),
|
||||
}
|
||||
}
|
||||
|
||||
272
server/store/postgres/workspace.go
Normal file
272
server/store/postgres/workspace.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
type WorkspaceStore struct{}
|
||||
|
||||
func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
|
||||
|
||||
// ── columns ────────────────────────────────
|
||||
|
||||
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at`
|
||||
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at`
|
||||
|
||||
// ── scanners ───────────────────────────────
|
||||
|
||||
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
||||
var w models.Workspace
|
||||
var maxBytes sql.NullInt64
|
||||
err := row.Scan(
|
||||
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
||||
&w.RootPath, &maxBytes, &w.Status,
|
||||
&w.CreatedAt, &w.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if maxBytes.Valid {
|
||||
w.MaxBytes = &maxBytes.Int64
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) {
|
||||
var f models.WorkspaceFile
|
||||
var contentType sql.NullString
|
||||
var sha256 sql.NullString
|
||||
err := row.Scan(
|
||||
&f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory,
|
||||
&contentType, &f.SizeBytes, &sha256,
|
||||
&f.CreatedAt, &f.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if contentType.Valid {
|
||||
f.ContentType = contentType.String
|
||||
}
|
||||
if sha256.Valid {
|
||||
f.SHA256 = sha256.String
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// ── Workspace CRUD ─────────────────────────
|
||||
|
||||
func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error {
|
||||
// Accept pre-generated ID or let Postgres generate one
|
||||
if w.ID != "" {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING created_at, updated_at`,
|
||||
w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath,
|
||||
w.MaxBytes, w.Status,
|
||||
).Scan(&w.CreatedAt, &w.UpdatedAt)
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workspaces (owner_type, owner_id, name, root_path, max_bytes, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
w.OwnerType, w.OwnerID, w.Name, w.RootPath,
|
||||
w.MaxBytes, w.Status,
|
||||
).Scan(&w.ID, &w.CreatedAt, &w.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`SELECT `+workspaceCols+` FROM workspaces WHERE id = $1`, id)
|
||||
return scanWorkspace(row)
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
|
||||
b := NewUpdate("workspaces")
|
||||
if patch.Name != nil {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.MaxBytes != nil {
|
||||
b.Set("max_bytes", *patch.MaxBytes)
|
||||
}
|
||||
if patch.Status != nil {
|
||||
b.Set("status", *patch.Status)
|
||||
}
|
||||
if !b.HasSets() {
|
||||
return nil
|
||||
}
|
||||
b.Set("updated_at", time.Now().UTC())
|
||||
b.Where("id", id)
|
||||
_, err := b.Exec(DB)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Ownership lookup ───────────────────────
|
||||
|
||||
func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`SELECT `+workspaceCols+` FROM workspaces
|
||||
WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting'
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
ownerType, ownerID)
|
||||
return scanWorkspace(row)
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+workspaceCols+` FROM workspaces
|
||||
WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting'
|
||||
ORDER BY created_at DESC`,
|
||||
ownerType, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Workspace
|
||||
for rows.Next() {
|
||||
w, err := scanWorkspace(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── File index ─────────────────────────────
|
||||
|
||||
func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workspace_files (workspace_id, path, is_directory, content_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (workspace_id, path) DO UPDATE SET
|
||||
is_directory = EXCLUDED.is_directory,
|
||||
content_type = EXCLUDED.content_type,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
sha256 = EXCLUDED.sha256,
|
||||
updated_at = NOW()
|
||||
RETURNING id, created_at, updated_at`,
|
||||
f.WorkspaceID, f.Path, f.IsDirectory,
|
||||
models.NullString(&f.ContentType), f.SizeBytes,
|
||||
models.NullString(&f.SHA256),
|
||||
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM workspace_files WHERE workspace_id = $1 AND path = $2`,
|
||||
workspaceID, path)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2`,
|
||||
workspaceID, prefix+"%")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`SELECT `+workspaceFileCols+` FROM workspace_files
|
||||
WHERE workspace_id = $1 AND path = $2`,
|
||||
workspaceID, path)
|
||||
return scanWorkspaceFile(row)
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if recursive {
|
||||
// All files under prefix (recursive)
|
||||
if prefix == "" {
|
||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||
WHERE workspace_id = $1 ORDER BY path`
|
||||
args = []interface{}{workspaceID}
|
||||
} else {
|
||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||
WHERE workspace_id = $1 AND path LIKE $2 ORDER BY path`
|
||||
args = []interface{}{workspaceID, prefix + "%"}
|
||||
}
|
||||
} else {
|
||||
// Direct children only: match prefix but no additional slashes
|
||||
if prefix == "" {
|
||||
// Root level: no slash in path
|
||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||
WHERE workspace_id = $1 AND path NOT LIKE '%/%' ORDER BY path`
|
||||
args = []interface{}{workspaceID}
|
||||
} else {
|
||||
// Children of prefix: starts with prefix, no additional slash after prefix
|
||||
query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files
|
||||
WHERE workspace_id = $1 AND path LIKE $2
|
||||
AND substring(path FROM %d) NOT LIKE '%%/%%'
|
||||
ORDER BY path`, len(prefix)+1)
|
||||
args = []interface{}{workspaceID, prefix + "%"}
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.WorkspaceFile
|
||||
for rows.Next() {
|
||||
f, err := scanWorkspaceFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM workspace_files WHERE workspace_id = $1`, workspaceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────
|
||||
|
||||
func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
|
||||
stats := &models.WorkspaceStats{WorkspaceID: workspaceID}
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(size_bytes), 0)
|
||||
FROM workspace_files WHERE workspace_id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch quota
|
||||
var maxBytes sql.NullInt64
|
||||
err = DB.QueryRowContext(ctx,
|
||||
`SELECT max_bytes FROM workspaces WHERE id = $1`, workspaceID,
|
||||
).Scan(&maxBytes)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
if maxBytes.Valid {
|
||||
stats.MaxBytes = &maxBytes.Int64
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -35,5 +35,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Projects: NewProjectStore(),
|
||||
Notifications: NewNotificationStore(),
|
||||
NotifPrefs: NewNotificationPreferenceStore(),
|
||||
Workspaces: NewWorkspaceStore(),
|
||||
}
|
||||
}
|
||||
|
||||
274
server/store/sqlite/workspace.go
Normal file
274
server/store/sqlite/workspace.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type WorkspaceStore struct{}
|
||||
|
||||
func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} }
|
||||
|
||||
// ── columns ────────────────────────────────
|
||||
|
||||
const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at`
|
||||
const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at`
|
||||
|
||||
// ── scanners ───────────────────────────────
|
||||
|
||||
func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) {
|
||||
var w models.Workspace
|
||||
var maxBytes sql.NullInt64
|
||||
err := row.Scan(
|
||||
&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
||||
&w.RootPath, &maxBytes, &w.Status,
|
||||
st(&w.CreatedAt), st(&w.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if maxBytes.Valid {
|
||||
w.MaxBytes = &maxBytes.Int64
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) {
|
||||
var f models.WorkspaceFile
|
||||
var contentType sql.NullString
|
||||
var sha256 sql.NullString
|
||||
err := row.Scan(
|
||||
&f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory,
|
||||
&contentType, &f.SizeBytes, &sha256,
|
||||
st(&f.CreatedAt), st(&f.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if contentType.Valid {
|
||||
f.ContentType = contentType.String
|
||||
}
|
||||
if sha256.Valid {
|
||||
f.SHA256 = sha256.String
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// ── Workspace CRUD ─────────────────────────
|
||||
|
||||
func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error {
|
||||
if w.ID == "" {
|
||||
w.ID = store.NewID()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
w.CreatedAt = now
|
||||
w.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath,
|
||||
w.MaxBytes, w.Status,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`SELECT `+workspaceCols+` FROM workspaces WHERE id = ?`, id)
|
||||
return scanWorkspace(row)
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
|
||||
b := NewUpdate("workspaces")
|
||||
if patch.Name != nil {
|
||||
b.Set("name", *patch.Name)
|
||||
}
|
||||
if patch.MaxBytes != nil {
|
||||
b.Set("max_bytes", *patch.MaxBytes)
|
||||
}
|
||||
if patch.Status != nil {
|
||||
b.Set("status", *patch.Status)
|
||||
}
|
||||
if !b.HasSets() {
|
||||
return nil
|
||||
}
|
||||
b.SetExpr("updated_at", nowExpr)
|
||||
b.Where("id", id)
|
||||
_, err := b.Exec(DB)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Ownership lookup ───────────────────────
|
||||
|
||||
func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`SELECT `+workspaceCols+` FROM workspaces
|
||||
WHERE owner_type = ? AND owner_id = ? AND status != 'deleting'
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
ownerType, ownerID)
|
||||
return scanWorkspace(row)
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+workspaceCols+` FROM workspaces
|
||||
WHERE owner_type = ? AND owner_id = ? AND status != 'deleting'
|
||||
ORDER BY created_at DESC`,
|
||||
ownerType, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Workspace
|
||||
for rows.Next() {
|
||||
w, err := scanWorkspace(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── File index ─────────────────────────────
|
||||
|
||||
func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
|
||||
if f.ID == "" {
|
||||
f.ID = store.NewID()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
f.CreatedAt = now
|
||||
f.UpdatedAt = now
|
||||
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workspace_files (id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (workspace_id, path) DO UPDATE SET
|
||||
is_directory = excluded.is_directory,
|
||||
content_type = excluded.content_type,
|
||||
size_bytes = excluded.size_bytes,
|
||||
sha256 = excluded.sha256,
|
||||
updated_at = datetime('now')`,
|
||||
f.ID, f.WorkspaceID, f.Path, f.IsDirectory,
|
||||
models.NullString(&f.ContentType), f.SizeBytes,
|
||||
models.NullString(&f.SHA256),
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM workspace_files WHERE workspace_id = ? AND path = ?`,
|
||||
workspaceID, path)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM workspace_files WHERE workspace_id = ? AND path LIKE ?`,
|
||||
workspaceID, prefix+"%")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`SELECT `+workspaceFileCols+` FROM workspace_files
|
||||
WHERE workspace_id = ? AND path = ?`,
|
||||
workspaceID, path)
|
||||
return scanWorkspaceFile(row)
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if recursive {
|
||||
if prefix == "" {
|
||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||
WHERE workspace_id = ? ORDER BY path`
|
||||
args = []interface{}{workspaceID}
|
||||
} else {
|
||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||
WHERE workspace_id = ? AND path LIKE ? ORDER BY path`
|
||||
args = []interface{}{workspaceID, prefix + "%"}
|
||||
}
|
||||
} else {
|
||||
if prefix == "" {
|
||||
query = `SELECT ` + workspaceFileCols + ` FROM workspace_files
|
||||
WHERE workspace_id = ? AND path NOT LIKE '%/%' ORDER BY path`
|
||||
args = []interface{}{workspaceID}
|
||||
} else {
|
||||
// Direct children: starts with prefix, no slash after prefix
|
||||
query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files
|
||||
WHERE workspace_id = ? AND path LIKE ?
|
||||
AND substr(path, %d) NOT LIKE '%%/%%'
|
||||
ORDER BY path`, len(prefix)+1)
|
||||
args = []interface{}{workspaceID, prefix + "%"}
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.WorkspaceFile
|
||||
for rows.Next() {
|
||||
f, err := scanWorkspaceFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM workspace_files WHERE workspace_id = ?`, workspaceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────
|
||||
|
||||
func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
|
||||
stats := &models.WorkspaceStats{WorkspaceID: workspaceID}
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(size_bytes), 0)
|
||||
FROM workspace_files WHERE workspace_id = ?`,
|
||||
workspaceID,
|
||||
).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var maxBytes sql.NullInt64
|
||||
err = DB.QueryRowContext(ctx,
|
||||
`SELECT max_bytes FROM workspaces WHERE id = ?`, workspaceID,
|
||||
).Scan(&maxBytes)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
if maxBytes.Valid {
|
||||
stats.MaxBytes = &maxBytes.Int64
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
497
server/workspace/archive.go
Normal file
497
server/workspace/archive.go
Normal file
@@ -0,0 +1,497 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── Archive Configuration ───────────────────
|
||||
|
||||
const (
|
||||
// MaxArchiveFiles is the max number of files to extract from an archive.
|
||||
MaxArchiveFiles = 10000
|
||||
|
||||
// MaxArchiveFileSize is the max size of a single file within an archive.
|
||||
MaxArchiveFileSize int64 = 100 * 1024 * 1024 // 100 MB
|
||||
)
|
||||
|
||||
// ── Extract ─────────────────────────────────
|
||||
|
||||
// ExtractArchive extracts a zip or tar.gz archive into the workspace.
|
||||
// Returns the number of files extracted. Respects workspace quota.
|
||||
// format must be "zip" or "tar.gz".
|
||||
func (fs *FS) ExtractArchive(ctx context.Context, w *models.Workspace, archivePath, format string) (int, error) {
|
||||
switch format {
|
||||
case "zip":
|
||||
return fs.extractZip(ctx, w, archivePath)
|
||||
case "tar.gz", "tgz":
|
||||
return fs.extractTarGz(ctx, w, archivePath)
|
||||
default:
|
||||
return 0, fmt.Errorf("workspace: unsupported archive format: %s", format)
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FS) extractZip(ctx context.Context, w *models.Workspace, archivePath string) (int, error) {
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("workspace: open zip: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if len(r.File) > MaxArchiveFiles {
|
||||
return 0, fmt.Errorf("workspace: archive contains %d files (max %d)", len(r.File), MaxArchiveFiles)
|
||||
}
|
||||
|
||||
// Detect common root prefix (e.g. "project-name/") and strip it
|
||||
prefix := detectCommonPrefix(zipFileNames(r.File))
|
||||
|
||||
root := fs.filesDir(w)
|
||||
count := 0
|
||||
var totalBytes int64
|
||||
|
||||
for _, f := range r.File {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return count, err
|
||||
}
|
||||
|
||||
name := stripPrefix(f.Name, prefix)
|
||||
if name == "" || name == "." {
|
||||
continue
|
||||
}
|
||||
|
||||
// Security: reject absolute paths and traversal
|
||||
if isUnsafePath(name) {
|
||||
log.Printf("workspace: skipping unsafe zip entry: %s", f.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
destPath := filepath.Join(root, filepath.FromSlash(name))
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(destPath, 0750); err != nil {
|
||||
return count, err
|
||||
}
|
||||
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||
WorkspaceID: w.ID,
|
||||
Path: name,
|
||||
IsDirectory: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Size check
|
||||
if f.UncompressedSize64 > uint64(MaxArchiveFileSize) {
|
||||
log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, f.UncompressedSize64)
|
||||
continue
|
||||
}
|
||||
|
||||
// Quota check
|
||||
quota := DefaultMaxBytes
|
||||
if w.MaxBytes != nil {
|
||||
quota = *w.MaxBytes
|
||||
}
|
||||
if totalBytes+int64(f.UncompressedSize64) > quota {
|
||||
return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota)
|
||||
}
|
||||
|
||||
// Extract file
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return count, fmt.Errorf("workspace: open zip entry %s: %w", name, err)
|
||||
}
|
||||
|
||||
n, hash, err := extractToFile(destPath, rc, MaxArchiveFileSize)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return count, fmt.Errorf("workspace: extract %s: %w", name, err)
|
||||
}
|
||||
|
||||
totalBytes += n
|
||||
count++
|
||||
|
||||
contentType := detectContentType(name, destPath)
|
||||
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||
WorkspaceID: w.ID,
|
||||
Path: name,
|
||||
IsDirectory: false,
|
||||
ContentType: contentType,
|
||||
SizeBytes: n,
|
||||
SHA256: hash,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("workspace: extracted %d files from zip (%d bytes)", count, totalBytes)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (fs *FS) extractTarGz(ctx context.Context, w *models.Workspace, archivePath string) (int, error) {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("workspace: open tar.gz: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("workspace: gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
root := fs.filesDir(w)
|
||||
count := 0
|
||||
var totalBytes int64
|
||||
|
||||
// First pass: collect names to detect common prefix
|
||||
// Since tar is streaming, we can't do two passes easily.
|
||||
// We'll detect prefix from the first entry's directory.
|
||||
var prefix string
|
||||
prefixDetected := false
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return count, err
|
||||
}
|
||||
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return count, fmt.Errorf("workspace: tar read: %w", err)
|
||||
}
|
||||
|
||||
if count >= MaxArchiveFiles {
|
||||
return count, fmt.Errorf("workspace: archive contains more than %d files", MaxArchiveFiles)
|
||||
}
|
||||
|
||||
// Detect prefix from first entry
|
||||
if !prefixDetected {
|
||||
if idx := strings.IndexByte(header.Name, '/'); idx > 0 {
|
||||
prefix = header.Name[:idx+1]
|
||||
}
|
||||
prefixDetected = true
|
||||
}
|
||||
|
||||
name := stripPrefix(header.Name, prefix)
|
||||
if name == "" || name == "." {
|
||||
continue
|
||||
}
|
||||
|
||||
if isUnsafePath(name) {
|
||||
log.Printf("workspace: skipping unsafe tar entry: %s", header.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
destPath := filepath.Join(root, filepath.FromSlash(name))
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(destPath, 0750); err != nil {
|
||||
return count, err
|
||||
}
|
||||
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||
WorkspaceID: w.ID,
|
||||
Path: name,
|
||||
IsDirectory: true,
|
||||
})
|
||||
|
||||
case tar.TypeReg:
|
||||
if header.Size > MaxArchiveFileSize {
|
||||
log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, header.Size)
|
||||
continue
|
||||
}
|
||||
|
||||
quota := DefaultMaxBytes
|
||||
if w.MaxBytes != nil {
|
||||
quota = *w.MaxBytes
|
||||
}
|
||||
if totalBytes+header.Size > quota {
|
||||
return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota)
|
||||
}
|
||||
|
||||
n, hash, extractErr := extractToFile(destPath, tr, MaxArchiveFileSize)
|
||||
if extractErr != nil {
|
||||
return count, fmt.Errorf("workspace: extract %s: %w", name, extractErr)
|
||||
}
|
||||
|
||||
totalBytes += n
|
||||
count++
|
||||
|
||||
contentType := detectContentType(name, destPath)
|
||||
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||
WorkspaceID: w.ID,
|
||||
Path: name,
|
||||
IsDirectory: false,
|
||||
ContentType: contentType,
|
||||
SizeBytes: n,
|
||||
SHA256: hash,
|
||||
})
|
||||
|
||||
default:
|
||||
// Skip symlinks, devices, etc.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("workspace: extracted %d files from tar.gz (%d bytes)", count, totalBytes)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ── Create Archive ──────────────────────────
|
||||
|
||||
// CreateArchive packages the workspace into a zip or tar.gz archive.
|
||||
// Returns a path to the temporary archive file. Caller must remove it when done.
|
||||
func (fs *FS) CreateArchive(ctx context.Context, w *models.Workspace, format string) (string, error) {
|
||||
switch format {
|
||||
case "zip":
|
||||
return fs.createZip(ctx, w)
|
||||
case "tar.gz", "tgz":
|
||||
return fs.createTarGz(ctx, w)
|
||||
default:
|
||||
return "", fmt.Errorf("workspace: unsupported archive format: %s", format)
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FS) createZip(ctx context.Context, w *models.Workspace) (string, error) {
|
||||
root := fs.filesDir(w)
|
||||
|
||||
tmp, err := os.CreateTemp("", "ws-*.zip")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
zw := zip.NewWriter(tmp)
|
||||
|
||||
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
|
||||
if info.IsDir() {
|
||||
_, err := zw.Create(rel + "/")
|
||||
return err
|
||||
}
|
||||
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = rel
|
||||
header.Method = zip.Deflate
|
||||
|
||||
writer, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(writer, f)
|
||||
return err
|
||||
})
|
||||
|
||||
if closeErr := zw.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if closeErr := tmp.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
os.Remove(tmpName)
|
||||
return "", fmt.Errorf("workspace: create zip: %w", err)
|
||||
}
|
||||
|
||||
return tmpName, nil
|
||||
}
|
||||
|
||||
func (fs *FS) createTarGz(ctx context.Context, w *models.Workspace) (string, error) {
|
||||
root := fs.filesDir(w)
|
||||
|
||||
tmp, err := os.CreateTemp("", "ws-*.tar.gz")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
gw := gzip.NewWriter(tmp)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = rel
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(tw, f)
|
||||
return err
|
||||
})
|
||||
|
||||
if closeErr := tw.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if closeErr := gw.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if closeErr := tmp.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
os.Remove(tmpName)
|
||||
return "", fmt.Errorf("workspace: create tar.gz: %w", err)
|
||||
}
|
||||
|
||||
return tmpName, nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// extractToFile writes content from a reader to a file, creating parent dirs.
|
||||
// Returns bytes written and SHA256 hash.
|
||||
func extractToFile(destPath string, r io.Reader, maxSize int64) (int64, string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0750); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
f, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
tee := io.TeeReader(io.LimitReader(r, maxSize+1), h)
|
||||
|
||||
n, err := io.Copy(f, tee)
|
||||
if err != nil {
|
||||
return n, "", err
|
||||
}
|
||||
if n > maxSize {
|
||||
os.Remove(destPath)
|
||||
return 0, "", fmt.Errorf("file exceeds max size (%d bytes)", maxSize)
|
||||
}
|
||||
|
||||
return n, hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// isUnsafePath returns true if the path contains traversal or unsafe patterns.
|
||||
func isUnsafePath(p string) bool {
|
||||
if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(p, ".") && !strings.HasPrefix(p, "./") {
|
||||
// Allow dotfiles like .gitignore, but not .. or hidden dirs as root
|
||||
// Actually, dotfiles are fine. Let them through.
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectCommonPrefix finds a shared directory prefix among file names.
|
||||
// E.g. ["project/src/a.go", "project/src/b.go"] → "project/"
|
||||
func detectCommonPrefix(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if all files share a common first directory component
|
||||
var prefix string
|
||||
for _, name := range names {
|
||||
idx := strings.IndexByte(name, '/')
|
||||
if idx < 0 {
|
||||
// File at root level — no common prefix
|
||||
return ""
|
||||
}
|
||||
dir := name[:idx+1]
|
||||
if prefix == "" {
|
||||
prefix = dir
|
||||
} else if dir != prefix {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
// stripPrefix removes the common archive prefix from a file name.
|
||||
func stripPrefix(name, prefix string) string {
|
||||
if prefix != "" {
|
||||
name = strings.TrimPrefix(name, prefix)
|
||||
}
|
||||
name = strings.TrimSuffix(name, "/")
|
||||
name = filepath.ToSlash(name)
|
||||
return cleanPath(name)
|
||||
}
|
||||
|
||||
// zipFileNames extracts file names from zip entries.
|
||||
func zipFileNames(files []*zip.File) []string {
|
||||
names := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
526
server/workspace/fs.go
Normal file
526
server/workspace/fs.go
Normal file
@@ -0,0 +1,526 @@
|
||||
// Package workspace provides filesystem operations for workspaces.
|
||||
// It operates on the PVC filesystem and keeps the DB metadata index in sync
|
||||
// via the WorkspaceStore.
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Configuration ───────────────────────────
|
||||
|
||||
const (
|
||||
// DefaultMaxBytes is the default workspace quota (500 MB).
|
||||
DefaultMaxBytes int64 = 500 * 1024 * 1024
|
||||
|
||||
// MaxSingleFileBytes is the max size for a single file write (100 MB).
|
||||
MaxSingleFileBytes int64 = 100 * 1024 * 1024
|
||||
|
||||
// filesSubdir is the subdirectory within a workspace root that holds user files.
|
||||
// Keeps metadata (.workspace.json, future .git/) out of the user's namespace.
|
||||
filesSubdir = "files"
|
||||
|
||||
// metadataFile is the workspace metadata file stored in the workspace root.
|
||||
metadataFile = ".workspace.json"
|
||||
)
|
||||
|
||||
// ── FS ──────────────────────────────────────
|
||||
|
||||
// FS provides filesystem operations for workspaces.
|
||||
// All file paths are relative to the workspace's files/ subdirectory.
|
||||
// The DB metadata index is updated on every mutating operation.
|
||||
type FS struct {
|
||||
basePath string // e.g. /data/storage/workspaces
|
||||
store store.WorkspaceStore
|
||||
}
|
||||
|
||||
// NewFS creates a workspace filesystem manager.
|
||||
func NewFS(basePath string, ws store.WorkspaceStore) *FS {
|
||||
return &FS{basePath: basePath, store: ws}
|
||||
}
|
||||
|
||||
// Init ensures the base workspaces directory exists.
|
||||
func (fs *FS) Init() error {
|
||||
return os.MkdirAll(fs.basePath, 0750)
|
||||
}
|
||||
|
||||
// ── Path Helpers ────────────────────────────
|
||||
|
||||
// filesDir returns the absolute path to a workspace's file tree root.
|
||||
func (fs *FS) filesDir(w *models.Workspace) string {
|
||||
return filepath.Join(fs.basePath, w.ID, filesSubdir)
|
||||
}
|
||||
|
||||
// absPath resolves a relative workspace path to an absolute filesystem path.
|
||||
// Returns an error if the path escapes the workspace root (traversal attack).
|
||||
func (fs *FS) absPath(w *models.Workspace, relPath string) (string, error) {
|
||||
orig := strings.TrimSpace(relPath)
|
||||
relPath = cleanPath(relPath)
|
||||
if relPath == "" {
|
||||
if orig != "" && orig != "." && orig != "./" {
|
||||
// Non-empty input was sanitized to empty — traversal attempt
|
||||
return "", fmt.Errorf("workspace: path traversal detected: %s", orig)
|
||||
}
|
||||
return fs.filesDir(w), nil
|
||||
}
|
||||
|
||||
root := fs.filesDir(w)
|
||||
abs := filepath.Join(root, relPath)
|
||||
|
||||
// Resolve symlinks and ensure we're still under root
|
||||
absResolved, err := filepath.Abs(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("workspace: invalid path: %w", err)
|
||||
}
|
||||
rootResolved, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("workspace: invalid root: %w", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(absResolved, rootResolved+string(filepath.Separator)) && absResolved != rootResolved {
|
||||
return "", fmt.Errorf("workspace: path traversal detected: %s", relPath)
|
||||
}
|
||||
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// cleanPath normalizes a relative path: removes leading slashes, cleans ./ and ../
|
||||
func cleanPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
p = path.Clean(p)
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
p = strings.TrimPrefix(p, "./")
|
||||
|
||||
// Reject paths that resolve outside root
|
||||
if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") {
|
||||
return ""
|
||||
}
|
||||
if p == "." {
|
||||
return ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ── Workspace Lifecycle ─────────────────────
|
||||
|
||||
// CreateDir creates the workspace directory structure on disk.
|
||||
// Call after the DB row is created.
|
||||
func (fs *FS) CreateDir(w *models.Workspace) error {
|
||||
filesPath := fs.filesDir(w)
|
||||
return os.MkdirAll(filesPath, 0750)
|
||||
}
|
||||
|
||||
// Destroy removes the entire workspace directory from disk.
|
||||
// Call after marking the workspace as "deleting" in DB.
|
||||
func (fs *FS) Destroy(ctx context.Context, w *models.Workspace) error {
|
||||
wsDir := filepath.Join(fs.basePath, w.ID)
|
||||
|
||||
// Remove all file index entries first
|
||||
if err := fs.store.DeleteAllFiles(ctx, w.ID); err != nil {
|
||||
log.Printf("workspace: failed to delete file index for %s: %v", w.ID, err)
|
||||
}
|
||||
|
||||
return os.RemoveAll(wsDir)
|
||||
}
|
||||
|
||||
// ── File Operations ─────────────────────────
|
||||
|
||||
// ReadFile returns a reader for the file at relPath.
|
||||
// Caller must close the returned ReadCloser.
|
||||
func (fs *FS) ReadFile(ctx context.Context, w *models.Workspace, relPath string) (io.ReadCloser, int64, error) {
|
||||
abs, err := fs.absPath(w, relPath)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, 0, fmt.Errorf("workspace: file not found: %s", relPath)
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, 0, fmt.Errorf("workspace: cannot read directory: %s", relPath)
|
||||
}
|
||||
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
// WriteFile creates or overwrites a file at relPath.
|
||||
// Creates parent directories as needed. Updates the DB metadata index.
|
||||
func (fs *FS) WriteFile(ctx context.Context, w *models.Workspace, relPath string, r io.Reader, size int64) error {
|
||||
relPath = cleanPath(relPath)
|
||||
if relPath == "" {
|
||||
return fmt.Errorf("workspace: empty file path")
|
||||
}
|
||||
|
||||
abs, err := fs.absPath(w, relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reject symlinks at destination
|
||||
if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("workspace: refusing to overwrite symlink: %s", relPath)
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
dir := filepath.Dir(abs)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return fmt.Errorf("workspace: mkdir %s: %w", filepath.Dir(relPath), err)
|
||||
}
|
||||
|
||||
// Write to temp file + rename (atomic)
|
||||
tmp, err := os.CreateTemp(dir, ".ws-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("workspace: create temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName) // cleanup on error; no-op after successful rename
|
||||
}()
|
||||
|
||||
// Write content and compute hash simultaneously
|
||||
h := sha256.New()
|
||||
tee := io.TeeReader(r, h)
|
||||
n, err := io.Copy(tmp, tee)
|
||||
if err != nil {
|
||||
return fmt.Errorf("workspace: write %s: %w", relPath, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("workspace: close temp: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
if err := os.Rename(tmpName, abs); err != nil {
|
||||
return fmt.Errorf("workspace: rename %s: %w", relPath, err)
|
||||
}
|
||||
|
||||
// Update DB index
|
||||
contentType := detectContentType(relPath, abs)
|
||||
hash := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
return fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||
WorkspaceID: w.ID,
|
||||
Path: relPath,
|
||||
IsDirectory: false,
|
||||
ContentType: contentType,
|
||||
SizeBytes: n,
|
||||
SHA256: hash,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteFile removes a file or empty directory at relPath.
|
||||
func (fs *FS) DeleteFile(ctx context.Context, w *models.Workspace, relPath string, recursive bool) error {
|
||||
relPath = cleanPath(relPath)
|
||||
if relPath == "" {
|
||||
return fmt.Errorf("workspace: cannot delete workspace root")
|
||||
}
|
||||
|
||||
abs, err := fs.absPath(w, relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Clean up DB index anyway
|
||||
fs.store.DeleteFile(ctx, w.ID, relPath)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if recursive {
|
||||
if err := os.RemoveAll(abs); err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove all files under this prefix from the index
|
||||
prefix := relPath
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
fs.store.DeleteFilesByPrefix(ctx, w.ID, prefix)
|
||||
return fs.store.DeleteFile(ctx, w.ID, relPath)
|
||||
}
|
||||
// Non-recursive: only remove empty dirs
|
||||
if err := os.Remove(abs); err != nil {
|
||||
return fmt.Errorf("workspace: directory not empty (use recursive=true): %s", relPath)
|
||||
}
|
||||
} else {
|
||||
if err := os.Remove(abs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return fs.store.DeleteFile(ctx, w.ID, relPath)
|
||||
}
|
||||
|
||||
// Mkdir creates a directory at relPath. Creates parents as needed.
|
||||
func (fs *FS) Mkdir(ctx context.Context, w *models.Workspace, relPath string) error {
|
||||
relPath = cleanPath(relPath)
|
||||
if relPath == "" {
|
||||
return nil // root always exists
|
||||
}
|
||||
|
||||
abs, err := fs.absPath(w, relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(abs, 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
||||
WorkspaceID: w.ID,
|
||||
Path: relPath,
|
||||
IsDirectory: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Stat returns file metadata without reading content.
|
||||
func (fs *FS) Stat(ctx context.Context, w *models.Workspace, relPath string) (*models.WorkspaceFile, error) {
|
||||
relPath = cleanPath(relPath)
|
||||
|
||||
// Try DB first (fast)
|
||||
f, err := fs.store.GetFile(ctx, w.ID, relPath)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Fall back to filesystem
|
||||
abs, err := fs.absPath(w, relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace: file not found: %s", relPath)
|
||||
}
|
||||
|
||||
return &models.WorkspaceFile{
|
||||
WorkspaceID: w.ID,
|
||||
Path: relPath,
|
||||
IsDirectory: info.IsDir(),
|
||||
SizeBytes: info.Size(),
|
||||
ContentType: detectContentType(relPath, abs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListDir returns files at the given prefix.
|
||||
func (fs *FS) ListDir(ctx context.Context, w *models.Workspace, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
||||
prefix = cleanPath(prefix)
|
||||
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
return fs.store.ListFiles(ctx, w.ID, prefix, recursive)
|
||||
}
|
||||
|
||||
// Tree returns all files in the workspace (recursive from root).
|
||||
func (fs *FS) Tree(ctx context.Context, w *models.Workspace) ([]models.WorkspaceFile, error) {
|
||||
return fs.store.ListFiles(ctx, w.ID, "", true)
|
||||
}
|
||||
|
||||
// ── Reconcile ───────────────────────────────
|
||||
|
||||
// Reconcile walks the filesystem and syncs the DB index.
|
||||
// Adds missing entries, removes stale entries, updates sizes/hashes.
|
||||
func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, removed, updated int, err error) {
|
||||
root := fs.filesDir(w)
|
||||
|
||||
// Walk FS and collect all paths
|
||||
fsPaths := make(map[string]os.FileInfo)
|
||||
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// Normalize to forward slashes
|
||||
rel = filepath.ToSlash(rel)
|
||||
fsPaths[rel] = info
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return 0, 0, 0, fmt.Errorf("workspace: reconcile walk: %w", err)
|
||||
}
|
||||
|
||||
// Get all DB entries
|
||||
dbFiles, err := fs.store.ListFiles(ctx, w.ID, "", true)
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("workspace: reconcile list: %w", err)
|
||||
}
|
||||
|
||||
dbPaths := make(map[string]*models.WorkspaceFile, len(dbFiles))
|
||||
for i := range dbFiles {
|
||||
dbPaths[dbFiles[i].Path] = &dbFiles[i]
|
||||
}
|
||||
|
||||
// Add missing FS entries to DB
|
||||
for relPath, info := range fsPaths {
|
||||
if _, exists := dbPaths[relPath]; !exists {
|
||||
abs := filepath.Join(root, relPath)
|
||||
f := &models.WorkspaceFile{
|
||||
WorkspaceID: w.ID,
|
||||
Path: relPath,
|
||||
IsDirectory: info.IsDir(),
|
||||
SizeBytes: info.Size(),
|
||||
ContentType: detectContentType(relPath, abs),
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if hash, hashErr := hashFile(abs); hashErr == nil {
|
||||
f.SHA256 = hash
|
||||
}
|
||||
}
|
||||
if upsertErr := fs.store.UpsertFile(ctx, f); upsertErr == nil {
|
||||
added++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove DB entries not on FS
|
||||
for dbPath := range dbPaths {
|
||||
if _, exists := fsPaths[dbPath]; !exists {
|
||||
if delErr := fs.store.DeleteFile(ctx, w.ID, dbPath); delErr == nil {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("workspace: reconcile %s: +%d -%d ~%d", w.ID, added, removed, updated)
|
||||
return added, removed, updated, nil
|
||||
}
|
||||
|
||||
// ── Content Type Detection ──────────────────
|
||||
|
||||
// detectContentType determines the MIME type from extension first,
|
||||
// then falls back to http.DetectContentType on the first 512 bytes.
|
||||
func detectContentType(relPath, absPath string) string {
|
||||
// Extension-based detection first (covers source code)
|
||||
ext := strings.ToLower(filepath.Ext(relPath))
|
||||
if ct := mimeByExtension(ext); ct != "" {
|
||||
return ct
|
||||
}
|
||||
|
||||
// Sniff the first 512 bytes
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, _ := f.Read(buf)
|
||||
if n == 0 {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return http.DetectContentType(buf[:n])
|
||||
}
|
||||
|
||||
// mimeByExtension returns MIME type for common extensions.
|
||||
// mime.TypeByExtension misses many source code types.
|
||||
func mimeByExtension(ext string) string {
|
||||
// Source code extensions that mime.TypeByExtension doesn't know about
|
||||
codeTypes := map[string]string{
|
||||
".go": "text/x-go",
|
||||
".rs": "text/x-rust",
|
||||
".py": "text/x-python",
|
||||
".rb": "text/x-ruby",
|
||||
".java": "text/x-java",
|
||||
".kt": "text/x-kotlin",
|
||||
".swift": "text/x-swift",
|
||||
".c": "text/x-c",
|
||||
".cpp": "text/x-c++",
|
||||
".h": "text/x-c",
|
||||
".hpp": "text/x-c++",
|
||||
".ts": "text/typescript",
|
||||
".tsx": "text/typescript",
|
||||
".jsx": "text/jsx",
|
||||
".vue": "text/x-vue",
|
||||
".svelte": "text/x-svelte",
|
||||
".scala": "text/x-scala",
|
||||
".pl": "text/x-perl",
|
||||
".pm": "text/x-perl",
|
||||
".lua": "text/x-lua",
|
||||
".zig": "text/x-zig",
|
||||
".nim": "text/x-nim",
|
||||
".ex": "text/x-elixir",
|
||||
".exs": "text/x-elixir",
|
||||
".sh": "text/x-shellscript",
|
||||
".bash": "text/x-shellscript",
|
||||
".zsh": "text/x-shellscript",
|
||||
".fish": "text/x-shellscript",
|
||||
".sql": "text/x-sql",
|
||||
".tf": "text/x-terraform",
|
||||
".hcl": "text/x-hcl",
|
||||
".proto": "text/x-protobuf",
|
||||
".graphql": "text/x-graphql",
|
||||
".toml": "application/toml",
|
||||
".yaml": "text/yaml",
|
||||
".yml": "text/yaml",
|
||||
".dockerfile": "text/x-dockerfile",
|
||||
".mod": "text/x-go-mod",
|
||||
".sum": "text/x-go-sum",
|
||||
".lock": "text/plain",
|
||||
".gitignore": "text/plain",
|
||||
".env": "text/plain",
|
||||
".editorconfig": "text/plain",
|
||||
}
|
||||
|
||||
if ct, ok := codeTypes[ext]; ok {
|
||||
return ct
|
||||
}
|
||||
|
||||
// Fall back to standard library
|
||||
if ct := mime.TypeByExtension(ext); ct != "" {
|
||||
return ct
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Hashing ─────────────────────────────────
|
||||
|
||||
// hashFile computes SHA256 of a file.
|
||||
func hashFile(absPath string) (string, error) {
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
388
server/workspace/fs_test.go
Normal file
388
server/workspace/fs_test.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── Path Cleaning Tests ─────────────────────
|
||||
|
||||
func TestCleanPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"src/main.go", "src/main.go"},
|
||||
{"/src/main.go", "src/main.go"},
|
||||
{"./src/main.go", "src/main.go"},
|
||||
{"src/../etc/passwd", "etc/passwd"},
|
||||
{"../etc/passwd", ""},
|
||||
{"../..", ""},
|
||||
{"..", ""},
|
||||
{".", ""},
|
||||
{"", ""},
|
||||
{" src/main.go ", "src/main.go"},
|
||||
{"src/./main.go", "src/main.go"},
|
||||
{".gitignore", ".gitignore"},
|
||||
{".env", ".env"},
|
||||
{"src/.hidden/file.go", "src/.hidden/file.go"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := cleanPath(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("cleanPath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsPathTraversal(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
fs := &FS{basePath: tmpDir}
|
||||
|
||||
w := &models.Workspace{
|
||||
BaseModel: models.BaseModel{ID: "test-workspace"},
|
||||
}
|
||||
|
||||
// Create the files dir so absPath has a real root to resolve against
|
||||
filesDir := filepath.Join(tmpDir, w.ID, filesSubdir)
|
||||
os.MkdirAll(filesDir, 0750)
|
||||
|
||||
// Valid paths should work
|
||||
validPaths := []string{
|
||||
"src/main.go",
|
||||
"README.md",
|
||||
".gitignore",
|
||||
"deeply/nested/path/file.txt",
|
||||
}
|
||||
for _, p := range validPaths {
|
||||
abs, err := fs.absPath(w, p)
|
||||
if err != nil {
|
||||
t.Errorf("absPath(%q) unexpected error: %v", p, err)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(abs, filesDir) {
|
||||
t.Errorf("absPath(%q) = %s, not under %s", p, abs, filesDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Traversal attempts should fail
|
||||
traversalPaths := []string{
|
||||
"../../../etc/passwd",
|
||||
"src/../../etc/passwd",
|
||||
}
|
||||
for _, p := range traversalPaths {
|
||||
_, err := fs.absPath(w, p)
|
||||
if err == nil {
|
||||
t.Errorf("absPath(%q) should have returned error for traversal", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content Type Detection Tests ────────────
|
||||
|
||||
func TestMimeByExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
ext string
|
||||
want string
|
||||
}{
|
||||
{".go", "text/x-go"},
|
||||
{".py", "text/x-python"},
|
||||
{".rs", "text/x-rust"},
|
||||
{".ts", "text/typescript"},
|
||||
{".sh", "text/x-shellscript"},
|
||||
{".sql", "text/x-sql"},
|
||||
{".yaml", "text/yaml"},
|
||||
{".yml", "text/yaml"},
|
||||
{".toml", "application/toml"},
|
||||
{".pl", "text/x-perl"},
|
||||
{".pm", "text/x-perl"},
|
||||
{".lua", "text/x-lua"},
|
||||
{".dockerfile", "text/x-dockerfile"},
|
||||
{".unknown", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ext, func(t *testing.T) {
|
||||
got := mimeByExtension(tt.ext)
|
||||
if got != tt.want {
|
||||
t.Errorf("mimeByExtension(%q) = %q, want %q", tt.ext, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unsafe Path Tests ───────────────────────
|
||||
|
||||
func TestIsUnsafePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
unsafe bool
|
||||
}{
|
||||
{"src/main.go", false},
|
||||
{".gitignore", false},
|
||||
{".env", false},
|
||||
{"../etc/passwd", true},
|
||||
{"/etc/passwd", true},
|
||||
{"src/../../../etc/passwd", true},
|
||||
{"\\windows\\system32", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := isUnsafePath(tt.path)
|
||||
if got != tt.unsafe {
|
||||
t.Errorf("isUnsafePath(%q) = %v, want %v", tt.path, got, tt.unsafe)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Common Prefix Detection Tests ───────────
|
||||
|
||||
func TestDetectCommonPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
names []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "common prefix",
|
||||
names: []string{"project/src/a.go", "project/src/b.go", "project/README.md"},
|
||||
want: "project/",
|
||||
},
|
||||
{
|
||||
name: "no common prefix",
|
||||
names: []string{"src/a.go", "lib/b.go"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "file at root",
|
||||
names: []string{"project/src/a.go", "Makefile"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
names: []string{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single dir",
|
||||
names: []string{"myproject/file.txt"},
|
||||
want: "myproject/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectCommonPrefix(tt.names)
|
||||
if got != tt.want {
|
||||
t.Errorf("detectCommonPrefix(%v) = %q, want %q", tt.names, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration: Write + Read Round-Trip ────
|
||||
|
||||
type mockWorkspaceStore struct {
|
||||
files map[string]*models.WorkspaceFile
|
||||
}
|
||||
|
||||
func newMockStore() *mockWorkspaceStore {
|
||||
return &mockWorkspaceStore{files: make(map[string]*models.WorkspaceFile)}
|
||||
}
|
||||
|
||||
func (m *mockWorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { return nil }
|
||||
func (m *mockWorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockWorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockWorkspaceStore) Delete(ctx context.Context, id string) error { return nil }
|
||||
func (m *mockWorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockWorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockWorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
|
||||
key := f.WorkspaceID + ":" + f.Path
|
||||
m.files[key] = f
|
||||
return nil
|
||||
}
|
||||
func (m *mockWorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
|
||||
delete(m.files, workspaceID+":"+path)
|
||||
return nil
|
||||
}
|
||||
func (m *mockWorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
|
||||
for k := range m.files {
|
||||
if strings.HasPrefix(k, workspaceID+":"+prefix) {
|
||||
delete(m.files, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockWorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
|
||||
f, ok := m.files[workspaceID+":"+path]
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
func (m *mockWorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
||||
var out []models.WorkspaceFile
|
||||
for _, f := range m.files {
|
||||
if f.WorkspaceID == workspaceID {
|
||||
if prefix == "" || strings.HasPrefix(f.Path, prefix) {
|
||||
out = append(out, *f)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *mockWorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
|
||||
for k, f := range m.files {
|
||||
if f.WorkspaceID == workspaceID {
|
||||
delete(m.files, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWriteReadRoundTrip(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
mock := newMockStore()
|
||||
wfs := NewFS(tmpDir, mock)
|
||||
|
||||
w := &models.Workspace{
|
||||
BaseModel: models.BaseModel{ID: "test-ws"},
|
||||
Status: "active",
|
||||
}
|
||||
if err := wfs.CreateDir(w); err != nil {
|
||||
t.Fatalf("CreateDir: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
content := "package main\n\nfunc main() {}\n"
|
||||
|
||||
// Write
|
||||
err := wfs.WriteFile(ctx, w, "src/main.go", strings.NewReader(content), int64(len(content)))
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
// Verify DB index was updated
|
||||
f, ok := mock.files["test-ws:src/main.go"]
|
||||
if !ok {
|
||||
t.Fatal("expected file in mock store index")
|
||||
}
|
||||
if f.ContentType != "text/x-go" {
|
||||
t.Errorf("content_type = %q, want %q", f.ContentType, "text/x-go")
|
||||
}
|
||||
if f.SizeBytes != int64(len(content)) {
|
||||
t.Errorf("size_bytes = %d, want %d", f.SizeBytes, len(content))
|
||||
}
|
||||
if f.SHA256 == "" {
|
||||
t.Error("expected non-empty sha256")
|
||||
}
|
||||
|
||||
// Read back
|
||||
rc, size, err := wfs.ReadFile(ctx, w, "src/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
if size != int64(len(content)) {
|
||||
t.Errorf("read size = %d, want %d", size, len(content))
|
||||
}
|
||||
|
||||
buf := make([]byte, size)
|
||||
if _, err := rc.Read(buf); err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if string(buf) != content {
|
||||
t.Errorf("content = %q, want %q", string(buf), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
mock := newMockStore()
|
||||
wfs := NewFS(tmpDir, mock)
|
||||
|
||||
w := &models.Workspace{
|
||||
BaseModel: models.BaseModel{ID: "test-ws"},
|
||||
}
|
||||
wfs.CreateDir(w)
|
||||
|
||||
ctx := context.Background()
|
||||
content := "hello"
|
||||
wfs.WriteFile(ctx, w, "test.txt", strings.NewReader(content), int64(len(content)))
|
||||
|
||||
// Delete
|
||||
err := wfs.DeleteFile(ctx, w, "test.txt", false)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteFile: %v", err)
|
||||
}
|
||||
|
||||
// Verify removed from index
|
||||
if _, ok := mock.files["test-ws:test.txt"]; ok {
|
||||
t.Error("expected file removed from index")
|
||||
}
|
||||
|
||||
// Verify removed from filesystem
|
||||
abs := filepath.Join(tmpDir, w.ID, filesSubdir, "test.txt")
|
||||
if _, err := os.Stat(abs); !os.IsNotExist(err) {
|
||||
t.Error("expected file removed from filesystem")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMkdir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
mock := newMockStore()
|
||||
wfs := NewFS(tmpDir, mock)
|
||||
|
||||
w := &models.Workspace{
|
||||
BaseModel: models.BaseModel{ID: "test-ws"},
|
||||
}
|
||||
wfs.CreateDir(w)
|
||||
|
||||
ctx := context.Background()
|
||||
err := wfs.Mkdir(ctx, w, "src/pkg/handlers")
|
||||
if err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
|
||||
// Verify on disk
|
||||
abs := filepath.Join(tmpDir, w.ID, filesSubdir, "src/pkg/handlers")
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Error("expected directory")
|
||||
}
|
||||
|
||||
// Verify in index
|
||||
f, ok := mock.files["test-ws:src/pkg/handlers"]
|
||||
if !ok {
|
||||
t.Fatal("expected dir in mock store")
|
||||
}
|
||||
if !f.IsDirectory {
|
||||
t.Error("expected is_directory = true")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user