mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 04:08:53 +02:00
* fix(projects): accept SSH repo URLs for github_repo resources (#2484) The project resource validator rejected anything that wasn't http(s), so workspace repos configured with an SSH remote (ssh:// or the scp-like `git@host:owner/repo.git` shorthand) could not be attached to a project. Both forms are valid git remotes and the daemon hands the URL straight to `git clone`, so the API has no reason to require https specifically. Relax the validator to accept http/https/ssh/git schemes and the scp-like shorthand, while still rejecting pasted garbage (no scheme, missing host, missing path, ftp://, file://, etc.). Co-authored-by: multica-agent <github@multica.ai> * fix(projects): reject scp-like URLs with '@' after ':' to avoid panic isValidGitRepoURL indexed '@' and ':' independently, then sliced s[at+1 : colon]. For inputs without '://' where '@' appears after the first ':' (e.g. `host:org/repo@branch`), `at+1 > colon` triggered a slice-bounds panic instead of a 400. Guard the slice and treat such inputs as malformed. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
327 lines
10 KiB
Go
327 lines
10 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"`
|
|
}
|
|
|
|
// 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)
|
|
default:
|
|
return nil, fmt.Errorf("unknown resource_type %q", resourceType)
|
|
}
|
|
}
|
|
|
|
type githubRepoRef struct {
|
|
URL string `json:"url"`
|
|
DefaultBranchHint string `json:"default_branch_hint,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)
|
|
out, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|