991 lines
38 KiB
Go
991 lines
38 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/kandev/kandev/internal/authz"
|
|
canvasservice "github.com/kandev/kandev/internal/canvas"
|
|
"github.com/kandev/kandev/internal/common/constants"
|
|
"github.com/kandev/kandev/internal/orchestrator"
|
|
"github.com/kandev/kandev/internal/plugins"
|
|
"github.com/kandev/kandev/internal/plugins/instances"
|
|
"github.com/kandev/kandev/internal/plugins/webapp"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
|
userstore "github.com/kandev/kandev/internal/user/store"
|
|
"github.com/kandev/kandev/internal/worktree/copyfiles"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// canvasHTTPHandler is the workspace-authorized browser API for canvas metadata
|
|
// and host operations. Source transfer and publishing stay on the agent MCP
|
|
// path; browsers receive only metadata and short-lived runtime capabilities.
|
|
type canvasHTTPHandler struct {
|
|
canvases *canvasservice.Service
|
|
plugins *plugins.Service
|
|
tasks *taskservice.Service
|
|
editor *canvasEditService
|
|
distribution *canvasservice.DistributionService
|
|
}
|
|
|
|
type canvasWorkspaceOwnerAccess interface {
|
|
AuthorizeWorkspaceAccess(context.Context, string) error
|
|
GetWorkspace(context.Context, string) (*models.Workspace, error)
|
|
}
|
|
|
|
type canvasEditTaskStore interface {
|
|
AuthorizeWorkspaceAccess(context.Context, string) error
|
|
GetWorkspace(context.Context, string) (*models.Workspace, error)
|
|
CreateTask(context.Context, *taskservice.CreateTaskRequest) (taskservice.CreateTaskResult, error)
|
|
DeleteTask(context.Context, string) error
|
|
}
|
|
|
|
type canvasEditCanvasStore interface {
|
|
Get(context.Context, string) (*canvasservice.Canvas, error)
|
|
}
|
|
|
|
type canvasEditReleaseStore interface {
|
|
GetRelease(context.Context, string) (instances.Release, error)
|
|
}
|
|
|
|
type canvasEditArtifactStore interface {
|
|
ReadFiles(webapp.Artifact) (map[string][]byte, error)
|
|
}
|
|
|
|
type canvasEditSessionStore interface {
|
|
SetSessionMetadataKey(context.Context, string, string, interface{}) error
|
|
}
|
|
|
|
type canvasEditSessionLauncher interface {
|
|
LaunchSession(context.Context, *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error)
|
|
PromptTask(context.Context, string, string, string, string, bool, []v1.MessageAttachment, bool) (*orchestrator.PromptResult, error)
|
|
}
|
|
|
|
type canvasEditResult struct {
|
|
TaskID string `json:"task_id"`
|
|
SessionID string `json:"session_id"`
|
|
CanvasID string `json:"canvas_id"`
|
|
AgentExecutionID string `json:"agent_execution_id,omitempty"`
|
|
}
|
|
|
|
type canvasEditError struct {
|
|
status int
|
|
code string
|
|
message string
|
|
cause error
|
|
}
|
|
|
|
func (e *canvasEditError) Error() string {
|
|
if e == nil {
|
|
return "canvas edit failed"
|
|
}
|
|
if e.cause == nil {
|
|
return e.message
|
|
}
|
|
return fmt.Sprintf("%s: %v", e.message, e.cause)
|
|
}
|
|
|
|
func newCanvasEditError(status int, code, message string, cause error) *canvasEditError {
|
|
return &canvasEditError{status: status, code: code, message: message, cause: cause}
|
|
}
|
|
|
|
type canvasEditService struct {
|
|
tasks canvasEditTaskStore
|
|
canvases canvasEditCanvasStore
|
|
releases canvasEditReleaseStore
|
|
artifacts canvasEditArtifactStore
|
|
launcher canvasEditSessionLauncher
|
|
sessions canvasEditSessionStore
|
|
executions canvasEditExecutionResolver
|
|
}
|
|
|
|
func newCanvasEditService(
|
|
tasks canvasEditTaskStore,
|
|
canvases canvasEditCanvasStore,
|
|
releases canvasEditReleaseStore,
|
|
artifacts canvasEditArtifactStore,
|
|
launcher canvasEditSessionLauncher,
|
|
sessions canvasEditSessionStore,
|
|
executions canvasEditExecutionResolver,
|
|
) *canvasEditService {
|
|
return &canvasEditService{
|
|
tasks: tasks, canvases: canvases, releases: releases,
|
|
artifacts: artifacts, launcher: launcher, sessions: sessions,
|
|
executions: executions,
|
|
}
|
|
}
|
|
|
|
func (s *canvasEditService) start(ctx context.Context, canvasID, requestedPrompt string) (canvasEditResult, error) {
|
|
if s == nil || s.tasks == nil || s.canvases == nil || s.releases == nil ||
|
|
s.artifacts == nil || s.launcher == nil || s.sessions == nil || s.executions == nil {
|
|
return canvasEditResult{}, newCanvasEditError(http.StatusServiceUnavailable, "canvas_edit_unavailable", "canvas editing is unavailable", nil)
|
|
}
|
|
item, entries, err := s.loadEditableCanvas(ctx, canvasID)
|
|
if err != nil {
|
|
return canvasEditResult{}, err
|
|
}
|
|
agentProfileID, executorID, err := s.editWorkspaceDefaults(ctx, item.WorkspaceID)
|
|
if err != nil {
|
|
return canvasEditResult{}, err
|
|
}
|
|
task, err := s.createEditTask(ctx, item, agentProfileID, executorID, requestedPrompt)
|
|
if err != nil {
|
|
return canvasEditResult{}, err
|
|
}
|
|
launchResponse, err := s.launchEditSession(ctx, task, item, agentProfileID, executorID, entries, requestedPrompt)
|
|
if err != nil {
|
|
return s.rollbackEditTask(task.ID, err)
|
|
}
|
|
return canvasEditResult{
|
|
TaskID: task.ID, SessionID: launchResponse.SessionID,
|
|
CanvasID: item.ID, AgentExecutionID: launchResponse.AgentExecutionID,
|
|
}, nil
|
|
}
|
|
|
|
func (s *canvasEditService) rollbackEditTask(taskID string, err error) (canvasEditResult, error) {
|
|
var editErr *canvasEditError
|
|
if !errors.As(err, &editErr) || editErr == nil {
|
|
editErr = newCanvasEditError(http.StatusInternalServerError, "canvas_edit_failed", "canvas edit session could not be started", err)
|
|
}
|
|
if cleanupErr := s.rollbackTask(taskID); cleanupErr != nil {
|
|
editErr.cause = errors.Join(editErr.cause, fmt.Errorf("rollback canvas edit task: %w", cleanupErr))
|
|
}
|
|
return canvasEditResult{}, editErr
|
|
}
|
|
|
|
func (s *canvasEditService) loadEditableCanvas(ctx context.Context, canvasID string) (*canvasservice.Canvas, []copyfiles.Entry, error) {
|
|
item, err := s.canvases.Get(ctx, strings.TrimSpace(canvasID))
|
|
if err != nil || item == nil {
|
|
return nil, nil, newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas was not found", nil)
|
|
}
|
|
if err := s.tasks.AuthorizeWorkspaceAccess(ctx, item.WorkspaceID); err != nil {
|
|
return nil, nil, newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas was not found", nil)
|
|
}
|
|
if item.ScopeKind != canvasservice.ScopeWorkspace || item.TaskID != "" {
|
|
return nil, nil, newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas was not found", nil)
|
|
}
|
|
if item.Status != instances.StatusActive || item.ActiveReleaseID == "" {
|
|
return nil, nil, newCanvasEditError(http.StatusConflict, "pending_first_release", "canvas has no active release to edit", nil)
|
|
}
|
|
if item.ActiveReleaseStatus != instances.ValidationValid {
|
|
return nil, nil, newCanvasEditError(http.StatusConflict, "canvas_not_editable", "canvas active release is not valid", nil)
|
|
}
|
|
release, err := s.releases.GetRelease(ctx, item.ActiveReleaseID)
|
|
if err != nil || release.InstanceID != item.PluginInstanceID || release.ValidationStatus != instances.ValidationValid {
|
|
return nil, nil, newCanvasEditError(http.StatusConflict, "canvas_not_editable", "canvas active release is unavailable", err)
|
|
}
|
|
files, err := s.artifacts.ReadFiles(webapp.Artifact{Digest: release.PackageDigest, RelativePath: release.ArtifactPath, Bytes: release.ArtifactBytes, Available: true})
|
|
if err != nil {
|
|
return nil, nil, newCanvasEditError(http.StatusConflict, "source_unavailable", "canvas active release source is unavailable", err)
|
|
}
|
|
entries := canvasEditSourceEntries(item.ID, files)
|
|
if len(entries) == 0 {
|
|
return nil, nil, newCanvasEditError(http.StatusConflict, "source_unavailable", "canvas active release source is empty", nil)
|
|
}
|
|
return item, entries, nil
|
|
}
|
|
|
|
func (s *canvasEditService) editWorkspaceDefaults(ctx context.Context, workspaceID string) (string, string, error) {
|
|
workspace, err := s.tasks.GetWorkspace(ctx, workspaceID)
|
|
if err != nil || workspace == nil {
|
|
return "", "", newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas workspace was not found", nil)
|
|
}
|
|
agentProfileID := workspaceDefaultID(workspace.DefaultAgentProfileID)
|
|
if agentProfileID == "" {
|
|
return "", "", newCanvasEditError(http.StatusBadRequest, "agent_profile_required", "workspace has no default agent profile configured", nil)
|
|
}
|
|
return agentProfileID, workspaceDefaultID(workspace.DefaultExecutorID), nil
|
|
}
|
|
|
|
func (s *canvasEditService) createEditTask(ctx context.Context, item *canvasservice.Canvas, agentProfileID, executorID, requestedPrompt string) (*models.Task, error) {
|
|
metadata := map[string]interface{}{
|
|
"origin": canvasEditOrigin,
|
|
"canvas_id": item.ID,
|
|
"canvas_release_id": item.ActiveReleaseID,
|
|
models.MetaKeyAgentProfileID: agentProfileID,
|
|
}
|
|
if executorID != "" {
|
|
metadata[models.MetaKeyExecutorID] = executorID
|
|
}
|
|
result, err := s.tasks.CreateTask(ctx, &taskservice.CreateTaskRequest{
|
|
WorkspaceID: item.WorkspaceID,
|
|
Title: "Edit canvas: " + item.Title,
|
|
Description: strings.TrimSpace(requestedPrompt),
|
|
IsEphemeral: true,
|
|
Origin: canvasEditOrigin,
|
|
Metadata: metadata,
|
|
})
|
|
if err == nil && result.Task != nil && result.Outcome == taskservice.CreateTaskOutcomeCreated {
|
|
return result.Task, nil
|
|
}
|
|
if err == nil {
|
|
err = errors.New("canvas edit task was not created")
|
|
}
|
|
return nil, newCanvasEditError(http.StatusInternalServerError, "task_create_failed", "canvas edit task could not be created", err)
|
|
}
|
|
|
|
func (s *canvasEditService) launchEditSession(ctx context.Context, task *models.Task, item *canvasservice.Canvas, agentProfileID, executorID string, entries []copyfiles.Entry, requestedPrompt string) (*orchestrator.LaunchSessionResponse, error) {
|
|
launchResponse, err := s.launcher.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{
|
|
TaskID: task.ID, Intent: orchestrator.IntentStart,
|
|
AgentProfileID: agentProfileID, ExecutorID: executorID,
|
|
})
|
|
if err != nil || launchResponse == nil || launchResponse.SessionID == "" || launchResponse.AgentExecutionID == "" {
|
|
if err == nil {
|
|
err = errors.New("canvas edit session launch returned no execution")
|
|
}
|
|
return nil, newCanvasEditError(http.StatusInternalServerError, "session_start_failed", "canvas edit session could not be started", err)
|
|
}
|
|
target := canvasEditSessionTarget{Origin: canvasEditOrigin, TaskID: task.ID, CanvasID: item.ID, ReleaseID: item.ActiveReleaseID}
|
|
if err := s.sessions.SetSessionMetadataKey(ctx, launchResponse.SessionID, canvasEditSessionTargetMetadataKey, target); err != nil {
|
|
return nil, newCanvasEditError(http.StatusInternalServerError, "session_target_failed", "canvas edit session target could not be recorded", err)
|
|
}
|
|
if err := s.materializeEditSource(ctx, launchResponse.AgentExecutionID, entries); err != nil {
|
|
return nil, newCanvasEditError(http.StatusInternalServerError, "source_materialization_failed", "canvas source could not be materialized", err)
|
|
}
|
|
prompt := canvasEditPrompt(*item, promptSourceRoot(item.ID), requestedPrompt)
|
|
if _, err := s.launcher.PromptTask(ctx, task.ID, launchResponse.SessionID, prompt, "", false, nil, false); err != nil {
|
|
return nil, newCanvasEditError(http.StatusInternalServerError, "prompt_dispatch_failed", "canvas edit instructions could not be sent", err)
|
|
}
|
|
return launchResponse, nil
|
|
}
|
|
|
|
func (s *canvasEditService) materializeEditSource(ctx context.Context, executionID string, entries []copyfiles.Entry) error {
|
|
copier, err := s.executions.ResolveAgentCtl(executionID)
|
|
if err != nil || copier == nil {
|
|
if err == nil {
|
|
err = errors.New("agentctl client is unavailable")
|
|
}
|
|
return err
|
|
}
|
|
response, err := copier.CopyFiles(ctx, "", entries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !response.Present {
|
|
return errors.New("copy-files returned no response")
|
|
}
|
|
if len(response.Warnings) > 0 {
|
|
return fmt.Errorf("copy-files returned warnings: %s", strings.Join(response.Warnings, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *canvasEditService) rollbackTask(taskID string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout)
|
|
defer cancel()
|
|
return s.tasks.DeleteTask(ctx, taskID)
|
|
}
|
|
|
|
func workspaceDefaultID(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(*value)
|
|
}
|
|
|
|
func canvasEditSourceEntries(canvasID string, files map[string][]byte) []copyfiles.Entry {
|
|
paths := make([]string, 0, len(files))
|
|
for name := range files {
|
|
paths = append(paths, name)
|
|
}
|
|
sort.Strings(paths)
|
|
entries := make([]copyfiles.Entry, 0, len(paths))
|
|
for _, name := range paths {
|
|
entries = append(entries, copyfiles.Entry{
|
|
RelPath: filepath.ToSlash(filepath.Join(canvasSourceRoot(canvasID), filepath.FromSlash(name))),
|
|
Mode: os.FileMode(0o600),
|
|
Content: files[name],
|
|
})
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func promptSourceRoot(canvasID string) string {
|
|
return canvasSourceRoot(canvasID)
|
|
}
|
|
|
|
func canvasEditPrompt(item canvasservice.Canvas, sourceRoot, requestedPrompt string) string {
|
|
requestedPrompt = strings.TrimSpace(requestedPrompt)
|
|
if requestedPrompt == "" {
|
|
requestedPrompt = "Review the current canvas and make a useful improvement."
|
|
}
|
|
grants := "none"
|
|
if len(item.EffectiveGrants) > 0 {
|
|
values := make([]string, 0, len(item.EffectiveGrants))
|
|
for _, grant := range item.EffectiveGrants {
|
|
resource := grant.Resource
|
|
if grant.PermissionKind == "network" {
|
|
resource = grant.NetworkOrigin
|
|
}
|
|
values = append(values, grant.PermissionKind+":"+resource+" (scope "+grant.ScopeCeiling+")")
|
|
}
|
|
grants = strings.Join(values, ", ")
|
|
}
|
|
return fmt.Sprintf(
|
|
"Edit canvas %q. If the core authoring bundle is not already available, read it once with read_canvas_authoring_skill_kandev without a path. Inspect the current source in %s, use only these effective grants: %s, apply the requested change, validate the result, and publish it with publish_canvas_kandev using canvas_id %s and source_path %s. Requested change: %s",
|
|
item.Title, sourceRoot, grants, item.ID, sourceRoot, requestedPrompt,
|
|
)
|
|
}
|
|
|
|
// registerCanvasRoutes mounts the feature-gated canvas host API. This helper
|
|
// is called only from registerRoutes when features.canvases is enabled.
|
|
func registerCanvasRoutes(p routeParams) {
|
|
if p.router == nil || p.services == nil || p.services.Canvas == nil || p.services.Plugins == nil {
|
|
return
|
|
}
|
|
h := &canvasHTTPHandler{
|
|
canvases: p.services.Canvas,
|
|
plugins: p.services.Plugins,
|
|
tasks: p.taskSvc,
|
|
distribution: p.services.CanvasDistribution,
|
|
}
|
|
if p.taskSvc != nil && p.taskRepo != nil && p.orchestratorSvc != nil && p.lifecycleMgr != nil {
|
|
instanceStore := p.services.Plugins.Instances()
|
|
artifactStore := p.services.Plugins.WebArtifacts()
|
|
if instanceStore != nil && artifactStore != nil {
|
|
h.editor = newCanvasEditService(
|
|
p.taskSvc, p.services.Canvas, instanceStore, artifactStore,
|
|
p.orchestratorSvc, p.taskRepo,
|
|
lifecycleCanvasExecutionResolver{manager: p.lifecycleMgr},
|
|
)
|
|
}
|
|
}
|
|
// Keep the canonical resource-shaped paths alongside the short aliases.
|
|
// The aliases make the API convenient for direct hosts while preserving a
|
|
// predictable path for the web client.
|
|
p.router.GET("/api/v1/tasks/:id/canvases", h.listTask)
|
|
p.router.GET("/api/v1/canvases/task/:taskID", h.listTask)
|
|
p.router.GET("/api/v1/canvases/tasks/:taskID", h.listTask)
|
|
p.router.GET("/api/v1/workspaces/:id/canvases", h.listWorkspace)
|
|
p.router.GET("/api/v1/canvases/workspace/:workspaceID", h.listWorkspace)
|
|
p.router.GET("/api/v1/canvases/workspaces/:workspaceID", h.listWorkspace)
|
|
registerCanvasDistributionRoutes(p.router, h)
|
|
p.router.GET("/api/v1/canvases/:canvasID", h.get)
|
|
p.router.PATCH("/api/v1/canvases/:canvasID", h.rename)
|
|
p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases)
|
|
p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview)
|
|
p.router.POST("/api/v1/canvases/:canvasID/promotion", h.promote)
|
|
p.router.GET("/api/v1/canvases/:canvasID/workspace-data-preview", h.workspaceDataPreview)
|
|
p.router.POST("/api/v1/canvases/:canvasID/workspace-data", h.enableWorkspaceData)
|
|
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/approve", h.approveRelease)
|
|
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/reject", h.rejectRelease)
|
|
p.router.POST("/api/v1/canvases/:canvasID/rollback", h.rollback)
|
|
p.router.POST("/api/v1/canvases/:canvasID/edit", h.edit)
|
|
p.router.POST("/api/v1/canvases/:canvasID/runtime", h.runtime)
|
|
p.router.GET("/api/v1/canvases/:canvasID/runtime", h.runtime)
|
|
p.router.POST("/api/v1/canvases/:canvasID/archive", h.archive)
|
|
p.router.POST("/api/v1/canvases/:canvasID/restore", h.restore)
|
|
p.router.POST("/api/v1/canvases/:canvasID/remove", h.remove)
|
|
p.router.DELETE("/api/v1/canvases/:canvasID", h.remove)
|
|
}
|
|
|
|
type canvasHTTPResponse struct {
|
|
ID string `json:"id"`
|
|
PluginInstanceID string `json:"plugin_instance_id"`
|
|
PluginID string `json:"plugin_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
OriginTaskID string `json:"origin_task_id,omitempty"`
|
|
ScopeKind string `json:"scope_kind"`
|
|
DataScopeKind string `json:"data_scope_kind"`
|
|
Title string `json:"title"`
|
|
CreatedBySessionID string `json:"created_by_session_id,omitempty"`
|
|
PromotedByUserID string `json:"promoted_by_user_id,omitempty"`
|
|
PromotedAt *time.Time `json:"promoted_at,omitempty"`
|
|
Status string `json:"status"`
|
|
ActiveReleaseID string `json:"active_release_id,omitempty"`
|
|
ActiveReleaseStatus string `json:"active_release_status,omitempty"`
|
|
ActiveReleaseError string `json:"active_release_error,omitempty"`
|
|
GrantGeneration int64 `json:"grant_generation,omitempty"`
|
|
EffectiveGrants []canvasservice.GrantProjection `json:"effective_grants,omitempty"`
|
|
ActiveRelease *canvasReleaseResponse `json:"active_release,omitempty"`
|
|
PendingRelease *canvasReleaseResponse `json:"pending_release,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type canvasReleaseResponse struct {
|
|
ID string `json:"id"`
|
|
PackageID string `json:"package_id,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Author string `json:"author,omitempty"`
|
|
License string `json:"license,omitempty"`
|
|
SourceMode string `json:"source_mode,omitempty"`
|
|
MinKandevVersion string `json:"min_kandev_version,omitempty"`
|
|
RepoURL string `json:"repo_url,omitempty"`
|
|
PackageDigest string `json:"package_digest,omitempty"`
|
|
ValidationStatus string `json:"validation_status"`
|
|
ValidationError string `json:"validation_error,omitempty"`
|
|
Permissions *canvasservice.PermissionSummary `json:"permissions,omitempty"`
|
|
MissingPermissions []string `json:"missing_permissions,omitempty"`
|
|
PermissionDigest string `json:"permission_digest,omitempty"`
|
|
SourceActorKind string `json:"source_actor_kind,omitempty"`
|
|
SourceUserID string `json:"source_user_id,omitempty"`
|
|
SourceTaskID string `json:"source_task_id,omitempty"`
|
|
SourceTaskTitle string `json:"source_task_title,omitempty"`
|
|
SourceSessionID string `json:"source_session_id,omitempty"`
|
|
SourceSessionName string `json:"source_session_name,omitempty"`
|
|
ProtocolVersion int `json:"protocol_version,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func canvasResponse(value canvasservice.Canvas) canvasHTTPResponse {
|
|
result := canvasHTTPResponse{
|
|
ID: value.ID,
|
|
PluginInstanceID: value.PluginInstanceID,
|
|
PluginID: value.PluginID,
|
|
WorkspaceID: value.WorkspaceID,
|
|
TaskID: value.TaskID,
|
|
OriginTaskID: value.OriginTaskID,
|
|
ScopeKind: value.ScopeKind,
|
|
DataScopeKind: value.DataScopeKind,
|
|
Title: value.Title,
|
|
CreatedBySessionID: value.CreatedBySessionID,
|
|
PromotedByUserID: value.PromotedByUserID,
|
|
PromotedAt: value.PromotedAt,
|
|
Status: value.Status,
|
|
ActiveReleaseID: value.ActiveReleaseID,
|
|
ActiveReleaseStatus: value.ActiveReleaseStatus,
|
|
ActiveReleaseError: value.ActiveReleaseError,
|
|
GrantGeneration: value.GrantGeneration,
|
|
EffectiveGrants: value.EffectiveGrants,
|
|
CreatedAt: value.CreatedAt,
|
|
UpdatedAt: value.UpdatedAt,
|
|
}
|
|
if value.ActiveRelease != nil {
|
|
result.ActiveRelease = releaseResponseFromMetadata(value.ActiveRelease)
|
|
}
|
|
if value.PendingRelease != nil {
|
|
result.PendingRelease = releaseResponseFromMetadata(value.PendingRelease)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) listTask(c *gin.Context) {
|
|
taskID := strings.TrimSpace(canvasRouteParam(c, "id", "taskID"))
|
|
taskSvc := h.tasks
|
|
if taskSvc == nil {
|
|
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
|
|
return
|
|
}
|
|
task, err := taskSvc.GetTask(c.Request.Context(), taskID)
|
|
if err != nil || task == nil {
|
|
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
|
|
return
|
|
}
|
|
if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), task.WorkspaceID); err != nil {
|
|
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
|
|
return
|
|
}
|
|
includeArchived, ok := canvasIncludeArchived(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
canvases, err := h.canvases.ListForTask(c.Request.Context(), task.WorkspaceID, taskID, includeArchived)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"canvases": mapCanvasResponses(canvases)})
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) listWorkspace(c *gin.Context) {
|
|
workspaceID := strings.TrimSpace(canvasRouteParam(c, "id", "workspaceID"))
|
|
taskSvc := h.tasks
|
|
if taskSvc == nil {
|
|
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
|
|
return
|
|
}
|
|
if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), workspaceID); err != nil {
|
|
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
|
|
return
|
|
}
|
|
includeArchived, ok := canvasIncludeArchived(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
canvases, err := h.canvases.ListWorkspaceCanvases(c.Request.Context(), workspaceID, includeArchived)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"canvases": mapCanvasResponses(canvases)})
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) get(c *gin.Context) {
|
|
canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if !h.authorizeWorkspace(c, canvas.WorkspaceID) {
|
|
return
|
|
}
|
|
response := canvasResponse(*canvas)
|
|
h.decorateCanvasReleaseSources(c.Request.Context(), canvas.WorkspaceID, response.ActiveRelease, response.PendingRelease)
|
|
writeCanvasJSON(c, http.StatusOK, response)
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) rename(c *gin.Context) {
|
|
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if !h.authorizeWorkspaceManage(c, item.WorkspaceID) {
|
|
return
|
|
}
|
|
var request struct {
|
|
Title string `json:"title"`
|
|
}
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
|
return
|
|
}
|
|
updated, err := h.canvases.Rename(c.Request.Context(), item.ID, request.Title)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) releases(c *gin.Context) {
|
|
canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if !h.authorizeWorkspace(c, canvas.WorkspaceID) {
|
|
return
|
|
}
|
|
instanceStore := h.plugins.Instances()
|
|
instance, err := instanceStore.Get(c.Request.Context(), canvas.PluginInstanceID)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
grants, err := instanceStore.ListGrants(c.Request.Context(), canvas.PluginInstanceID)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
list, err := instanceStore.ListReleases(c.Request.Context(), canvas.PluginInstanceID)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
result := make([]canvasReleaseResponse, 0, len(list))
|
|
for _, release := range list {
|
|
response := releaseResponse(release, instance.ScopeKind, grants)
|
|
h.decorateCanvasReleaseSources(c.Request.Context(), canvas.WorkspaceID, &response)
|
|
result = append(result, response)
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"releases": result})
|
|
}
|
|
|
|
func releaseResponse(release instances.Release, scope string, grants []instances.Grant) canvasReleaseResponse {
|
|
return *releaseResponseFromMetadata(canvasservice.ReleaseMetadataForHTTP(release, scope, grants))
|
|
}
|
|
|
|
func releaseResponseFromMetadata(value *canvasservice.ReleaseMetadata) *canvasReleaseResponse {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return &canvasReleaseResponse{
|
|
ID: value.ID, PackageDigest: value.PackageDigest, ValidationStatus: value.ValidationStatus,
|
|
PackageID: value.PackageID, Version: value.Version, DisplayName: value.DisplayName,
|
|
Description: value.Description, Author: value.Author, License: value.License,
|
|
SourceMode: value.SourceMode, MinKandevVersion: value.MinKandevVersion, RepoURL: value.RepoURL,
|
|
ValidationError: value.ValidationError, Permissions: value.Permissions,
|
|
MissingPermissions: value.MissingPermissions, PermissionDigest: value.PermissionDigest,
|
|
SourceActorKind: value.SourceActorKind, SourceUserID: value.SourceUserID,
|
|
SourceTaskID: value.SourceTaskID, SourceSessionID: value.SourceSessionID,
|
|
ProtocolVersion: value.ProtocolVersion, CreatedAt: value.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) promotionPreview(c *gin.Context) {
|
|
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if item == nil || !h.authorizeWorkspace(c, item.WorkspaceID) {
|
|
return
|
|
}
|
|
preview, err := h.canvases.PromotionPreview(c.Request.Context(), item.ID)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if preview == nil || preview.Canvas == nil {
|
|
return
|
|
}
|
|
sourceLabels := projectCanvasSourceLabels(
|
|
c.Request.Context(),
|
|
h.tasks,
|
|
item.WorkspaceID,
|
|
preview.SourceTaskID,
|
|
preview.SourceSessionID,
|
|
)
|
|
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
|
|
"canvas_id": preview.Canvas.ID,
|
|
"title": preview.Canvas.Title,
|
|
"origin_task_id": preview.Canvas.OriginTaskID,
|
|
"active_release": releaseResponseFromCanvas(preview.Canvas),
|
|
"source_actor_kind": preview.SourceActorKind,
|
|
"source_user_id": preview.SourceUserID,
|
|
"source_task_id": preview.SourceTaskID,
|
|
"source_task_title": sourceLabels.TaskTitle,
|
|
"source_session_id": preview.SourceSessionID,
|
|
"source_session_name": sourceLabels.SessionName,
|
|
"permissions": preview.Permissions,
|
|
"active_release_id": preview.ActiveReleaseID,
|
|
"permission_digest": preview.PermissionDigest,
|
|
"grant_generation": preview.GrantGeneration,
|
|
"current_scope": preview.CurrentScope,
|
|
"target_scope": preview.TargetScope,
|
|
"current_data_scope_kind": preview.CurrentDataScope,
|
|
"target_data_scope_kind": preview.TargetDataScope,
|
|
"placement": preview.Placement,
|
|
})
|
|
}
|
|
|
|
func releaseResponseFromCanvas(value *canvasservice.Canvas) *canvasReleaseResponse {
|
|
if value == nil || value.ActiveRelease == nil {
|
|
return nil
|
|
}
|
|
return releaseResponseFromMetadata(value.ActiveRelease)
|
|
}
|
|
|
|
type canvasPromotionRequest struct {
|
|
ExpectedReleaseID string `json:"expected_release_id"`
|
|
ExpectedPermissionDigest string `json:"expected_permission_digest"`
|
|
ExpectedGrantGeneration *int64 `json:"expected_grant_generation"`
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) promote(c *gin.Context) {
|
|
canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if !h.authorizeWorkspace(c, canvas.WorkspaceID) {
|
|
return
|
|
}
|
|
var request canvasPromotionRequest
|
|
if c.Request.Body == nil || c.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.ExpectedReleaseID) == "" || strings.TrimSpace(request.ExpectedPermissionDigest) == "" || request.ExpectedGrantGeneration == nil || *request.ExpectedGrantGeneration < 0 {
|
|
writeCanvasError(c, http.StatusBadRequest, "promotion_review_required", nil)
|
|
return
|
|
}
|
|
updated, err := h.canvases.PromoteCanvasReviewed(c.Request.Context(), canvas.ID, canvasRuntimeUser(c), request.ExpectedReleaseID, request.ExpectedPermissionDigest, *request.ExpectedGrantGeneration)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) workspaceDataPreview(c *gin.Context) {
|
|
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) {
|
|
return
|
|
}
|
|
preview, err := h.canvases.WorkspaceDataPreview(c.Request.Context(), item.ID)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
|
|
"canvas": canvasResponse(*preview.Canvas),
|
|
"active_release_id": preview.ActiveReleaseID,
|
|
"permission_digest": preview.PermissionDigest,
|
|
"grant_generation": preview.GrantGeneration,
|
|
"current_data_scope_kind": preview.CurrentDataScopeKind,
|
|
"target_data_scope_kind": preview.TargetDataScopeKind,
|
|
"permissions": preview.Permissions,
|
|
})
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) enableWorkspaceData(c *gin.Context) {
|
|
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) {
|
|
return
|
|
}
|
|
var request canvasPromotionRequest
|
|
if c.Request.Body == nil || c.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.ExpectedReleaseID) == "" || strings.TrimSpace(request.ExpectedPermissionDigest) == "" || request.ExpectedGrantGeneration == nil || *request.ExpectedGrantGeneration < 0 {
|
|
writeCanvasError(c, http.StatusBadRequest, "workspace_data_review_required", nil)
|
|
return
|
|
}
|
|
updated, err := h.canvases.EnableWorkspaceDataReviewed(
|
|
c.Request.Context(), item.ID, canvasRuntimeUser(c), request.ExpectedReleaseID,
|
|
request.ExpectedPermissionDigest, *request.ExpectedGrantGeneration,
|
|
)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) approveRelease(c *gin.Context) {
|
|
h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, userID string) (*canvasservice.Canvas, error) {
|
|
return h.canvases.ApproveRelease(ctx, canvasID, releaseID, userID)
|
|
})
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) rejectRelease(c *gin.Context) {
|
|
h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, _ string) (*canvasservice.Canvas, error) {
|
|
return h.canvases.RejectRelease(ctx, canvasID, releaseID)
|
|
})
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) releaseMutation(c *gin.Context, mutation func(context.Context, string, string, string) (*canvasservice.Canvas, error)) {
|
|
canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if !h.authorizeWorkspace(c, canvas.WorkspaceID) {
|
|
return
|
|
}
|
|
updated, err := mutation(c.Request.Context(), canvas.ID, c.Param("releaseID"), canvasRuntimeUser(c))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) rollback(c *gin.Context) {
|
|
canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if !h.authorizeWorkspace(c, canvas.WorkspaceID) {
|
|
return
|
|
}
|
|
var body struct {
|
|
ReleaseID string `json:"release_id"`
|
|
}
|
|
if c.Request.Body != nil {
|
|
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
|
return
|
|
}
|
|
}
|
|
updated, err := h.canvases.RollbackRelease(c.Request.Context(), canvas.ID, body.ReleaseID)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) edit(c *gin.Context) {
|
|
if h.editor == nil {
|
|
writeCanvasError(c, http.StatusServiceUnavailable, "canvas_edit_unavailable", nil)
|
|
return
|
|
}
|
|
var request struct {
|
|
Prompt string `json:"prompt,omitempty"`
|
|
}
|
|
if c.Request.Body != nil {
|
|
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) {
|
|
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
|
return
|
|
}
|
|
}
|
|
result, err := h.editor.start(c.Request.Context(), c.Param("canvasID"), request.Prompt)
|
|
if err != nil {
|
|
var editErr *canvasEditError
|
|
if errors.As(err, &editErr) && editErr != nil {
|
|
writeCanvasError(c, editErr.status, editErr.code, nil)
|
|
return
|
|
}
|
|
writeCanvasError(c, http.StatusInternalServerError, "canvas_edit_failed", nil)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, result)
|
|
}
|
|
|
|
type canvasRuntimeRequest struct {
|
|
WebAppKey string `json:"web_app_key"`
|
|
Placement string `json:"placement"`
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) runtime(c *gin.Context) {
|
|
canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if !h.authorizeWorkspace(c, canvas.WorkspaceID) {
|
|
return
|
|
}
|
|
if canvas.Status != instances.StatusActive || canvas.ActiveReleaseID == "" {
|
|
writeCanvasError(c, http.StatusConflict, "pending_first_release", map[string]interface{}{"status": canvas.Status})
|
|
return
|
|
}
|
|
if canvas.ActiveReleaseStatus != instances.ValidationValid {
|
|
writeCanvasError(c, http.StatusConflict, canvasHostError(canvas.ActiveReleaseStatus), map[string]interface{}{"status": canvas.ActiveReleaseStatus, "error": canvas.ActiveReleaseError})
|
|
return
|
|
}
|
|
var request canvasRuntimeRequest
|
|
if c.Request.Method == http.MethodPost {
|
|
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, http.ErrNotSupported) && !errors.Is(err, io.EOF) {
|
|
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
|
return
|
|
}
|
|
}
|
|
path, release, app, binding, err := h.runtimeBinding(c, *canvas, request)
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
|
|
"runtime_url": path,
|
|
"release_id": release.ID,
|
|
"web_app_key": app.Key,
|
|
"placement": appPlacement(app, request.Placement, canvas.ScopeKind),
|
|
"expires_in_seconds": int(webapp.RuntimeTokenTTL / time.Second),
|
|
"canvas": canvasResponse(*canvas),
|
|
"binding": map[string]interface{}{"scope_kind": binding.ScopeKind, "grant_generation": binding.GrantGeneration},
|
|
})
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) archive(c *gin.Context) {
|
|
h.mutate(c, func(ctx context.Context, id string) (*canvasservice.Canvas, error) {
|
|
return h.canvases.Archive(ctx, id)
|
|
})
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) restore(c *gin.Context) {
|
|
h.mutate(c, func(ctx context.Context, id string) (*canvasservice.Canvas, error) {
|
|
return h.canvases.Restore(ctx, id)
|
|
})
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) mutate(c *gin.Context, mutation func(context.Context, string) (*canvasservice.Canvas, error)) {
|
|
// Keeping mutation routing in one place prevents archive and restore from
|
|
// accidentally gaining different authorization behavior.
|
|
canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if !h.authorizeWorkspace(c, canvas.WorkspaceID) {
|
|
return
|
|
}
|
|
updated, err := mutation(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) remove(c *gin.Context) {
|
|
canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
|
if err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
if !h.authorizeWorkspace(c, canvas.WorkspaceID) {
|
|
return
|
|
}
|
|
if err := h.canvases.Remove(c.Request.Context(), c.Param("canvasID")); err != nil {
|
|
h.writeError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) authorizeWorkspace(c *gin.Context, workspaceID string) bool {
|
|
taskSvc := h.tasks
|
|
if taskSvc == nil {
|
|
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
|
|
return false
|
|
}
|
|
if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), workspaceID); err != nil {
|
|
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) authorizeWorkspaceManage(c *gin.Context, workspaceID string) bool {
|
|
if !h.authorizeWorkspace(c, workspaceID) {
|
|
return false
|
|
}
|
|
if err := h.tasks.AuthorizeWorkspaceScope(c.Request.Context(), workspaceID, authz.ScopeWorkspaceManage); err != nil {
|
|
writeCanvasError(c, http.StatusForbidden, "forbidden", nil)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *canvasHTTPHandler) authorizeWorkspaceOwner(c *gin.Context, workspaceID string) bool {
|
|
owner, err := checkCanvasWorkspaceOwner(c.Request.Context(), h.tasks, workspaceID, canvasRuntimeUser(c))
|
|
if err != nil {
|
|
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
|
|
return false
|
|
}
|
|
if !owner {
|
|
writeCanvasError(c, http.StatusForbidden, "workspace_owner_required", nil)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func checkCanvasWorkspaceOwner(ctx context.Context, access canvasWorkspaceOwnerAccess, workspaceID, userID string) (bool, error) {
|
|
if access == nil {
|
|
return false, errors.New("canvas workspace access is unavailable")
|
|
}
|
|
if err := access.AuthorizeWorkspaceAccess(ctx, workspaceID); err != nil {
|
|
return false, err
|
|
}
|
|
workspace, err := access.GetWorkspace(ctx, workspaceID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if workspace == nil {
|
|
return false, errors.New("canvas workspace was not found")
|
|
}
|
|
actorID := strings.TrimSpace(userID)
|
|
ownerID := strings.TrimSpace(workspace.OwnerID)
|
|
return actorID != "" && (ownerID == actorID || (ownerID == "" && actorID == userstore.DefaultUserID)), nil
|
|
}
|
|
|
|
func canvasRouteParam(c *gin.Context, names ...string) string {
|
|
for _, name := range names {
|
|
if value := c.Param(name); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|