Files
multica/server/internal/handler/project_resource.go

568 lines
19 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// ProjectResourceResponse is the JSON shape returned by the project resource API.
type ProjectResourceResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
WorkspaceID string `json:"workspace_id"`
ResourceType string `json:"resource_type"`
ResourceRef json.RawMessage `json:"resource_ref"`
Label *string `json:"label"`
Position int32 `json:"position"`
CreatedAt string `json:"created_at"`
CreatedBy *string `json:"created_by"`
}
func projectResourceToResponse(r db.ProjectResource) ProjectResourceResponse {
ref := json.RawMessage(r.ResourceRef)
if len(ref) == 0 {
ref = json.RawMessage("{}")
}
return ProjectResourceResponse{
ID: uuidToString(r.ID),
ProjectID: uuidToString(r.ProjectID),
WorkspaceID: uuidToString(r.WorkspaceID),
ResourceType: r.ResourceType,
ResourceRef: ref,
Label: textToPtr(r.Label),
Position: r.Position,
CreatedAt: timestampToString(r.CreatedAt),
CreatedBy: uuidToPtr(r.CreatedBy),
}
}
// CreateProjectResourceRequest is the body for POST /api/projects/{id}/resources.
type CreateProjectResourceRequest struct {
ResourceType string `json:"resource_type"`
ResourceRef json.RawMessage `json:"resource_ref"`
Label *string `json:"label"`
Position *int32 `json:"position"`
}
// UpdateProjectResourceRequest is the body for PUT /api/projects/{id}/resources/{resourceId}.
// resource_type cannot change after creation — pick a new type by deleting and
// re-adding. Every field is optional; omitted fields keep their current value.
type UpdateProjectResourceRequest struct {
ResourceRef json.RawMessage `json:"resource_ref"`
Label *string `json:"label"`
Position *int32 `json:"position"`
}
// validateAndNormalizeResourceRef checks the payload for a known resource_type.
// New types are added here without schema migration; unknown types are rejected
// at the API boundary so a typo can't slip through and produce a resource the
// daemon/UI doesn't understand.
func validateAndNormalizeResourceRef(resourceType string, ref json.RawMessage) (json.RawMessage, error) {
if len(ref) == 0 {
return nil, errors.New("resource_ref is required")
}
switch resourceType {
case "github_repo":
return validateGithubRepoRef(ref)
case "local_directory":
return validateLocalDirectoryRef(ref)
default:
return nil, fmt.Errorf("unknown resource_type %q", resourceType)
}
}
type githubRepoRef struct {
URL string `json:"url"`
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
Ref string `json:"ref,omitempty"`
}
func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
var payload githubRepoRef
if err := json.Unmarshal(ref, &payload); err != nil {
return nil, fmt.Errorf("invalid github_repo payload: %w", err)
}
payload.URL = strings.TrimSpace(payload.URL)
if payload.URL == "" {
return nil, errors.New("github_repo: url is required")
}
if !isValidGitRepoURL(payload.URL) {
return nil, errors.New("github_repo: url must be a valid http(s) or ssh git URL")
}
payload.DefaultBranchHint = strings.TrimSpace(payload.DefaultBranchHint)
payload.Ref = strings.TrimSpace(payload.Ref)
out, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return out, nil
}
// localDirectoryRef is the JSONB shape stored for resource_type=local_directory.
// It pins a project to an existing directory on a specific user machine, so
// agent tasks run in-place rather than in an isolated git worktree. The
// daemon_id scopes the path to one daemon registration — the same string path
// on a different machine is a different resource. The optional label is a
// human-readable hint used by the UI; the row-level project_resource.label
// column remains the generic column for any resource type.
type localDirectoryRef struct {
LocalPath string `json:"local_path"`
DaemonID string `json:"daemon_id"`
Label string `json:"label,omitempty"`
}
func validateLocalDirectoryRef(ref json.RawMessage) (json.RawMessage, error) {
var payload localDirectoryRef
if err := json.Unmarshal(ref, &payload); err != nil {
return nil, fmt.Errorf("invalid local_directory payload: %w", err)
}
payload.LocalPath = strings.TrimSpace(payload.LocalPath)
if payload.LocalPath == "" {
return nil, errors.New("local_directory: local_path is required")
}
if !isAbsoluteLocalPath(payload.LocalPath) {
return nil, errors.New("local_directory: local_path must be an absolute path")
}
payload.DaemonID = strings.TrimSpace(payload.DaemonID)
if payload.DaemonID == "" {
return nil, errors.New("local_directory: daemon_id is required")
}
payload.Label = strings.TrimSpace(payload.Label)
out, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return out, nil
}
// isAbsoluteLocalPath checks the path looks absolute on either POSIX or
// Windows daemons. The server can't know which OS the daemon runs on, so we
// accept the union: a leading "/" (POSIX), a UNC prefix "\\", or a drive
// letter like "C:\" or "C:/". The daemon still verifies existence at run
// time — this is a typo guard, not a filesystem check.
func isAbsoluteLocalPath(s string) bool {
if s == "" {
return false
}
if s[0] == '/' {
return true
}
if strings.HasPrefix(s, `\\`) {
return true
}
if len(s) >= 3 && isDriveLetter(s[0]) && s[1] == ':' && (s[2] == '\\' || s[2] == '/') {
return true
}
return false
}
func isDriveLetter(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}
// isValidGitRepoURL accepts the three forms a user can paste from GitHub's
// "Code" menu: https://, ssh:// (with explicit scheme), and the scp-like
// shorthand `git@host:owner/repo.git`. The check is intentionally lax — we are
// guarding against pasted garbage like "not-a-url", not enforcing a strict
// grammar — because the actual fetch happens client-side via `git clone` and
// the user gets a clearer error from git than from us.
func isValidGitRepoURL(s string) bool {
if u, err := url.Parse(s); err == nil && u.Host != "" {
switch u.Scheme {
case "http", "https", "ssh", "git":
return true
}
}
// scp-like ssh shorthand: [user@]host:path with a non-empty host and path,
// and no spaces. Reject anything that looks like a URL with a scheme
// (those should go through url.Parse above).
if strings.Contains(s, " ") || strings.Contains(s, "://") {
return false
}
colon := strings.Index(s, ":")
if colon <= 0 || colon == len(s)-1 {
return false
}
// In scp-like ssh shorthand `[user@]host:path`, `@` is only meaningful
// as a user separator before the first ':'. If '@' appears at or after
// the colon it is not the user separator — reject as malformed rather
// than guess (and avoid a slice-bounds panic from blindly slicing).
at := strings.Index(s, "@")
if at >= colon {
return false
}
hostStart := 0
if at >= 0 {
hostStart = at + 1
}
host := s[hostStart:colon]
path := s[colon+1:]
if host == "" || path == "" {
return false
}
return true
}
// loadProjectForResource resolves the project, enforces workspace ownership,
// and returns its DB row. Used by all project_resource handlers.
func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, projectIDParam string) (db.Project, bool) {
projectUUID, ok := parseUUIDOrBadRequest(w, projectIDParam, "project id")
if !ok {
return db.Project{}, false
}
wsUUID, ok := parseUUIDOrBadRequest(w, h.resolveWorkspaceID(r), "workspace id")
if !ok {
return db.Project{}, false
}
project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: projectUUID, WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return db.Project{}, false
}
return project, true
}
// ListProjectResources returns the resources attached to a project.
func (h *Handler) ListProjectResources(w http.ResponseWriter, r *http.Request) {
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
resources, err := h.Queries.ListProjectResources(r.Context(), project.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list project resources")
return
}
resp := make([]ProjectResourceResponse, len(resources))
for i, res := range resources {
resp[i] = projectResourceToResponse(res)
}
writeJSON(w, http.StatusOK, map[string]any{"resources": resp, "total": len(resp)})
}
// CreateProjectResource attaches a new resource to a project.
func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request) {
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req CreateProjectResourceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
req.ResourceType = strings.TrimSpace(req.ResourceType)
if req.ResourceType == "" {
writeError(w, http.StatusBadRequest, "resource_type is required")
return
}
normalizedRef, err := validateAndNormalizeResourceRef(req.ResourceType, req.ResourceRef)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if conflict, err := h.findLocalDirectoryConflict(r.Context(), project.ID, req.ResourceType, normalizedRef, pgtype.UUID{}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to check existing resources")
return
} else if conflict {
writeError(w, http.StatusConflict, "this daemon already has a local_directory attached to the project; remove it before adding another")
return
}
var label pgtype.Text
if req.Label != nil && strings.TrimSpace(*req.Label) != "" {
label = pgtype.Text{String: strings.TrimSpace(*req.Label), Valid: true}
}
var position int32
if req.Position != nil {
position = *req.Position
} else {
// Append after existing resources.
count, _ := h.Queries.CountProjectResources(r.Context(), project.ID)
position = int32(count)
}
creator, _ := h.parseUserUUIDOrZero(userID)
resource, err := h.Queries.CreateProjectResource(r.Context(), db.CreateProjectResourceParams{
ProjectID: project.ID,
WorkspaceID: project.WorkspaceID,
ResourceType: req.ResourceType,
ResourceRef: normalizedRef,
Label: label,
Position: position,
CreatedBy: creator,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "this resource is already attached to the project")
return
}
writeError(w, http.StatusInternalServerError, "failed to create project resource")
return
}
resp := projectResourceToResponse(resource)
h.publish(
protocol.EventProjectResourceCreated,
uuidToString(project.WorkspaceID),
"member",
userID,
map[string]any{"resource": resp, "project_id": uuidToString(project.ID)},
)
writeJSON(w, http.StatusCreated, resp)
}
// UpdateProjectResource edits an existing resource's ref/label/position.
// resource_type is immutable — re-pointing a resource at a different type is
// almost always a different conceptual entity, so the caller should delete and
// re-add instead. Omitted fields keep their current value, including the
// `label` JSON null vs. missing distinction (missing = keep, explicit "" =
// clear).
func (h *Handler) UpdateProjectResource(w http.ResponseWriter, r *http.Request) {
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id")
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
existing, err := h.Queries.GetProjectResourceInWorkspace(r.Context(), db.GetProjectResourceInWorkspaceParams{
ID: resourceUUID, WorkspaceID: project.WorkspaceID,
})
if err != nil {
writeError(w, http.StatusNotFound, "project resource not found")
return
}
if uuidToString(existing.ProjectID) != uuidToString(project.ID) {
writeError(w, http.StatusNotFound, "project resource not found")
return
}
// Decode into a raw map first so we can tell "field omitted" from
// "field present with zero value" — the label clear case in particular
// relies on this distinction.
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
nextRef := json.RawMessage(existing.ResourceRef)
if rawRef, ok := raw["resource_ref"]; ok {
normalized, err := validateAndNormalizeResourceRef(existing.ResourceType, rawRef)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
nextRef = normalized
}
if conflict, err := h.findLocalDirectoryConflict(r.Context(), project.ID, existing.ResourceType, nextRef, existing.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to check existing resources")
return
} else if conflict {
writeError(w, http.StatusConflict, "another local_directory on this daemon is already attached to the project")
return
}
nextLabel := existing.Label
if rawLabel, ok := raw["label"]; ok {
var labelStr *string
if err := json.Unmarshal(rawLabel, &labelStr); err != nil {
writeError(w, http.StatusBadRequest, "label must be a string or null")
return
}
if labelStr == nil || strings.TrimSpace(*labelStr) == "" {
nextLabel = pgtype.Text{}
} else {
nextLabel = pgtype.Text{String: strings.TrimSpace(*labelStr), Valid: true}
}
}
nextPosition := existing.Position
if rawPos, ok := raw["position"]; ok {
var pos *int32
if err := json.Unmarshal(rawPos, &pos); err != nil {
writeError(w, http.StatusBadRequest, "position must be an integer")
return
}
if pos != nil {
nextPosition = *pos
}
}
updated, err := h.Queries.UpdateProjectResource(r.Context(), db.UpdateProjectResourceParams{
ID: existing.ID,
ResourceRef: nextRef,
Label: nextLabel,
Position: nextPosition,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "this resource is already attached to the project")
return
}
writeError(w, http.StatusInternalServerError, "failed to update project resource")
return
}
resp := projectResourceToResponse(updated)
h.publish(
protocol.EventProjectResourceUpdated,
uuidToString(project.WorkspaceID),
"member",
userID,
map[string]any{"resource": resp, "project_id": uuidToString(project.ID)},
)
writeJSON(w, http.StatusOK, resp)
}
// findLocalDirectoryConflict enforces "at most one local_directory resource
// per (project, daemon)". The daemon picks the first matching daemon_id row
// out of a task's resources (findLocalDirectoryAssignment), so letting a
// project carry two rows for the same daemon would mean the agent silently
// writes into whichever happens to come back first — a safety hazard for a
// feature that operates directly on the user's real working directory.
//
// The DB-level UNIQUE(project_id, resource_type, resource_ref) constraint
// alone is not enough here: it only fires on full ref-JSON equality, so a
// different local_path or even a typoed label on the same daemon would slip
// through. We do the daemon-scoped check here in application code instead.
//
// `excludeID` lets the update path ignore the row being edited.
func (h *Handler) findLocalDirectoryConflict(ctx context.Context, projectID pgtype.UUID, resourceType string, normalizedRef json.RawMessage, excludeID pgtype.UUID) (bool, error) {
if resourceType != "local_directory" {
return false, nil
}
var incoming localDirectoryRef
if err := json.Unmarshal(normalizedRef, &incoming); err != nil {
return false, err
}
rows, err := h.Queries.ListProjectResources(ctx, projectID)
if err != nil {
return false, err
}
for _, row := range rows {
if row.ResourceType != "local_directory" {
continue
}
if excludeID.Valid && uuidToString(row.ID) == uuidToString(excludeID) {
continue
}
var existing localDirectoryRef
if err := json.Unmarshal(row.ResourceRef, &existing); err != nil {
continue
}
// Daemon-scoped uniqueness: one local_directory per daemon per
// project. Different daemons can each carry one row (one per
// user device); the daemon-side resolver routes each daemon to
// its own assignment by daemon_id.
if existing.DaemonID == incoming.DaemonID {
return true, nil
}
}
return false, nil
}
// DeleteProjectResource removes a resource from a project.
func (h *Handler) DeleteProjectResource(w http.ResponseWriter, r *http.Request) {
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id")
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
resource, err := h.Queries.GetProjectResourceInWorkspace(r.Context(), db.GetProjectResourceInWorkspaceParams{
ID: resourceUUID, WorkspaceID: project.WorkspaceID,
})
if err != nil {
writeError(w, http.StatusNotFound, "project resource not found")
return
}
if uuidToString(resource.ProjectID) != uuidToString(project.ID) {
writeError(w, http.StatusNotFound, "project resource not found")
return
}
if err := h.Queries.DeleteProjectResource(r.Context(), resource.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete project resource")
return
}
h.publish(
protocol.EventProjectResourceDeleted,
uuidToString(project.WorkspaceID),
"member",
userID,
map[string]any{
"project_id": uuidToString(project.ID),
"resource_id": uuidToString(resource.ID),
},
)
w.WriteHeader(http.StatusNoContent)
}
// parseUserUUIDOrZero converts a user ID string to a pgtype.UUID, returning a
// zero value on any error so the caller can store NULL for created_by when the
// authenticated principal is not a workspace member (e.g. internal-server use).
func (h *Handler) parseUserUUIDOrZero(userID string) (pgtype.UUID, bool) {
if userID == "" {
return pgtype.UUID{}, false
}
u, err := parseUUIDLoose(userID)
if err != nil {
return pgtype.UUID{}, false
}
return u, true
}
// parseUUIDLoose mirrors util.ParseUUID but lives here to avoid pulling util
// into a tiny one-off helper. Keep the body minimal.
func parseUUIDLoose(s string) (pgtype.UUID, error) {
var u pgtype.UUID
if err := u.Scan(s); err != nil {
return pgtype.UUID{}, err
}
return u, nil
}
// listProjectResourcesForProject is a small helper used by the daemon claim
// handler to attach project resources to outgoing tasks.
func (h *Handler) listProjectResourcesForProject(ctx context.Context, projectID pgtype.UUID) []db.ProjectResource {
if !projectID.Valid {
return nil
}
rows, err := h.Queries.ListProjectResources(ctx, projectID)
if err != nil {
return nil
}
return rows
}