yao/openapi/workspace/workspace.go
Max 59004de3e8 feat(grpc, openapi): enhance server keepalive settings and improve sorting in responses
- Added keepalive parameters to the gRPC server configuration to manage connection health more effectively.
- Implemented sorting logic in the computer and sandbox response handling to ensure consistent ordering by display name and last active timestamp.
- Refactored node display name retrieval for better clarity and reuse across different functions.

Made-with: Cursor
2026-03-13 12:08:32 +08:00

425 lines
10 KiB
Go

package workspace
import (
"context"
"encoding/base64"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"sort"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
ws "github.com/yaoapp/yao/workspace"
)
// Attach registers workspace management routes on the given group.
// - GET / — list workspaces (filtered by owner from token)
// - POST / — create workspace (owner from token)
// - GET /:id — get workspace (owner check)
// - PUT /:id — update workspace (owner check)
// - DELETE /:id — delete workspace (owner check)
// - GET /:id/files — list files
// - GET /:id/files/*path — read file
// - PUT /:id/files/*path — write file
// - DELETE /:id/files/*path — delete file
// - POST /:id/mkdir — create directory
// - POST /:id/rename — rename file/directory
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
group.Use(oauth.Guard)
group.GET("", handleList)
group.GET("/options", handleOptions)
group.POST("", handleCreate)
group.GET("/:id", handleGet)
group.PUT("/:id", handleUpdate)
group.DELETE("/:id", handleDelete)
group.GET("/:id/files", handleListFiles)
group.GET("/:id/files/*path", handleReadFile)
group.PUT("/:id/files/*path", handleWriteFile)
group.DELETE("/:id/files/*path", handleDeleteFile)
group.POST("/:id/mkdir", handleMkdir)
group.POST("/:id/rename", handleRename)
}
// resolveOwner returns TeamID if present, otherwise UserID.
func resolveOwner(authInfo *types.AuthorizedInfo) string {
if authInfo != nil && authInfo.TeamID != "" {
return authInfo.TeamID
}
if authInfo != nil {
return authInfo.UserID
}
return ""
}
// checkWSOwner verifies the caller owns the workspace.
func checkWSOwner(c *gin.Context, w *ws.Workspace, owner string) bool {
if owner == "" {
return true
}
if w.Owner != "" && w.Owner != owner {
c.JSON(http.StatusForbidden, gin.H{"error": "no permission to access this workspace"})
return false
}
return true
}
// --- request / response types ---
type createRequest struct {
ID string `json:"id,omitempty"`
Name string `json:"name" binding:"required"`
Node string `json:"node" binding:"required"`
Labels map[string]string `json:"labels,omitempty"`
}
type updateRequest struct {
Name *string `json:"name,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
type mkdirRequest struct {
Path string `json:"path" binding:"required"`
}
type renameRequest struct {
OldPath string `json:"old_path" binding:"required"`
NewPath string `json:"new_path" binding:"required"`
}
type workspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Node string `json:"node"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toResponse(w *ws.Workspace) workspaceResponse {
return workspaceResponse{
ID: w.ID,
Name: w.Name,
Owner: w.Owner,
Node: w.Node,
Labels: w.Labels,
CreatedAt: w.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: w.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func mgr() *ws.Manager {
return ws.M()
}
// resolveAndCheckWS fetches the workspace and verifies owner permission.
func resolveAndCheckWS(c *gin.Context) (*ws.Workspace, bool) {
m := mgr()
if m == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace service not available"})
return nil, false
}
w, err := m.Get(context.Background(), c.Param("id"))
if err != nil {
if err == ws.ErrNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return nil, false
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return nil, false
}
authInfo := authorized.GetInfo(c)
if !checkWSOwner(c, w, resolveOwner(authInfo)) {
return nil, false
}
return w, true
}
// --- handlers ---
func handleList(c *gin.Context) {
m := mgr()
if m == nil {
response.RespondWithSuccess(c, http.StatusOK, []workspaceResponse{})
return
}
authInfo := authorized.GetInfo(c)
owner := resolveOwner(authInfo)
list, err := m.List(context.Background(), ws.ListOptions{
Owner: owner,
Node: c.Query("node"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make([]workspaceResponse, 0, len(list))
for _, w := range list {
result = append(result, toResponse(w))
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt > result[j].CreatedAt
})
response.RespondWithSuccess(c, http.StatusOK, result)
}
// handleOptions returns workspace options for the InputArea selector.
// Reuses the same logic as handleList (Manager.List with owner+node filter).
// Separated as a dedicated endpoint for clear API responsibility boundary.
func handleOptions(c *gin.Context) {
m := mgr()
if m == nil {
response.RespondWithSuccess(c, http.StatusOK, []workspaceResponse{})
return
}
authInfo := authorized.GetInfo(c)
owner := resolveOwner(authInfo)
list, err := m.List(context.Background(), ws.ListOptions{
Owner: owner,
Node: c.Query("node"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make([]workspaceResponse, 0, len(list))
for _, w := range list {
result = append(result, toResponse(w))
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt > result[j].CreatedAt
})
response.RespondWithSuccess(c, http.StatusOK, result)
}
func handleCreate(c *gin.Context) {
m := mgr()
if m == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace service not available"})
return
}
var req createRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authInfo := authorized.GetInfo(c)
owner := resolveOwner(authInfo)
w, err := m.Create(context.Background(), ws.CreateOptions{
ID: req.ID,
Name: req.Name,
Owner: owner,
Node: req.Node,
Labels: req.Labels,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response.RespondWithSuccess(c, http.StatusCreated, toResponse(w))
}
func handleGet(c *gin.Context) {
w, ok := resolveAndCheckWS(c)
if !ok {
return
}
response.RespondWithSuccess(c, http.StatusOK, toResponse(w))
}
func handleUpdate(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
var req updateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
w, err := mgr().Update(context.Background(), c.Param("id"), ws.UpdateOptions{
Name: req.Name,
Labels: req.Labels,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response.RespondWithSuccess(c, http.StatusOK, toResponse(w))
}
func handleDelete(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
force := c.Query("force") == "true"
if err := mgr().Delete(context.Background(), c.Param("id"), force); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func handleListFiles(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
dir := c.DefaultQuery("path", ".")
entries, err := mgr().ListDir(context.Background(), c.Param("id"), dir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response.RespondWithSuccess(c, http.StatusOK, entries)
}
func handleReadFile(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
path := c.Param("path")
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
fmt.Printf("[workspace] handleReadFile id=%s path=%q\n", c.Param("id"), path)
data, err := mgr().ReadFile(context.Background(), c.Param("id"), path)
if err != nil {
fmt.Printf("[workspace] ReadFile error: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
fmt.Printf("[workspace] ReadFile ok, size=%d, encoding=%q\n", len(data), c.Query("encoding"))
if c.Query("encoding") == "base64" {
response.RespondWithSuccess(c, http.StatusOK, gin.H{
"content": base64.StdEncoding.EncodeToString(data),
"encoding": "base64",
})
return
}
ext := filepath.Ext(path)
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
fmt.Printf("[workspace] serving ext=%q mime=%q size=%d\n", ext, mimeType, len(data))
c.Data(http.StatusOK, mimeType, data)
}
func handleWriteFile(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
path := c.Param("path")
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
data, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
return
}
if err := mgr().WriteFile(context.Background(), c.Param("id"), path, data, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func handleDeleteFile(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
path := c.Param("path")
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
if err := mgr().Remove(context.Background(), c.Param("id"), path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func handleMkdir(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
var req mkdirRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := mgr().MkdirAll(context.Background(), c.Param("id"), req.Path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func handleRename(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
var req renameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := mgr().Rename(context.Background(), c.Param("id"), req.OldPath, req.NewPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}