diff --git a/api/openapi.json b/api/openapi.json index 6a8ddb17..c3faeb57 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -13058,6 +13058,126 @@ } }, "/links": { + "get": { + "description": "Returns a paginated, ID-ordered dependency graph for one workspace. Only non-custom-field item-to-item links with both endpoints in that workspace are returned. Cross-workspace and non-item links are excluded so link metadata from another workspace or permission domain cannot leak.", + "parameters": [ + { + "description": "Workspace ID", + "in": "query", + "name": "workspace_id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Page number (1-based)", + "in": "query", + "name": "page", + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Links per page", + "in": "query", + "name": "limit", + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_restapi_v1_handlers.PaginatedResponse" + }, + { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/models.ItemLink" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse" + } + } + }, + "description": "Invalid workspace ID or pagination" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse" + } + } + }, + "description": "Token lacks the items:read scope" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse" + } + } + }, + "description": "Workspace not found or not visible to caller" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_restapi_v1_handlers.ErrorResponse" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List a workspace's direct work-item links", + "tags": [ + "links", + "workspaces" + ] + }, "post": { "description": "Creates a link between two entities (item/page/test_case). The link type must allow the given entity types; pages must share a workspace with the source item.", "requestBody": { diff --git a/api/openapi.yaml b/api/openapi.yaml index 976e976d..6a359217 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -8412,6 +8412,81 @@ paths: tags: - links /links: + get: + description: Returns a paginated, ID-ordered dependency graph for one workspace. + Only non-custom-field item-to-item links with both endpoints in that workspace + are returned. Cross-workspace and non-item links are excluded so link metadata + from another workspace or permission domain cannot leak. + parameters: + - description: Workspace ID + in: query + name: workspace_id + required: true + schema: + type: integer + - description: Page number (1-based) + in: query + name: page + schema: + minimum: 1 + type: integer + - description: Links per page + in: query + name: limit + schema: + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/internal_restapi_v1_handlers.PaginatedResponse' + - properties: + data: + items: + $ref: '#/components/schemas/models.ItemLink' + type: array + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse' + description: Invalid workspace ID or pagination + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse' + description: Token lacks the items:read scope + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse' + description: Workspace not found or not visible to caller + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/internal_restapi_v1_handlers.ErrorResponse' + description: Internal Server Error + security: + - BearerAuth: [] + summary: List a workspace's direct work-item links + tags: + - links + - workspaces post: description: Creates a link between two entities (item/page/test_case). The link type must allow the given entity types; pages must share a workspace diff --git a/internal/restapi/v1/handlers/links.go b/internal/restapi/v1/handlers/links.go index 0906a1cc..c5284f7b 100644 --- a/internal/restapi/v1/handlers/links.go +++ b/internal/restapi/v1/handlers/links.go @@ -2,6 +2,7 @@ package handlers import ( "errors" + "math" "net/http" "strconv" "strings" @@ -48,6 +49,45 @@ type linkCreateRequest struct { const maxBatchLinkItems = 100 +// parseWorkspaceLinkPagination rejects page values whose offset would +// overflow an int before the workspace link service performs any database +// access. The shared parser remains permissive for legacy endpoints. +func parseWorkspaceLinkPagination(r *http.Request) (restapi.PaginationParams, error) { + query := r.URL.Query() + if query.Has("sort") || query.Has("order") { + return restapi.PaginationParams{}, errors.New("sort and order are not supported; links are ordered by ID ascending") + } + + pagination := restapi.PaginationParams{ + Page: restapi.DefaultPage, + Limit: restapi.DefaultLimit, + } + limitRaw := strings.TrimSpace(query.Get("limit")) + if query.Has("limit") { + limit, err := strconv.Atoi(limitRaw) + if err != nil || limit <= 0 || limit > restapi.MaxLimit { + return restapi.PaginationParams{}, errors.New("limit must be an integer between 1 and 100") + } + pagination.Limit = limit + } + pageRaw := strings.TrimSpace(query.Get("page")) + if !query.Has("page") { + pagination.Offset = 0 + return pagination, nil + } + + page, err := strconv.Atoi(pageRaw) + if err != nil || page <= 0 { + return restapi.PaginationParams{}, errors.New("page must be a positive integer") + } + if page-1 > math.MaxInt/pagination.Limit { + return restapi.PaginationParams{}, errors.New("page is too large") + } + pagination.Page = page + pagination.Offset = (page - 1) * pagination.Limit + return pagination, nil +} + type batchItemLinksResponse struct { ItemID int `json:"item_id"` Outgoing []models.ItemLink `json:"outgoing"` @@ -218,6 +258,49 @@ func (h *LinkHandler) GetLinksBatch(w http.ResponseWriter, r *http.Request) { h.RespondPaginated(w, response, pagination, total) } +// ListWorkspaceItemLinks handles GET /rest/api/v1/links?workspace_id={id}. +// +// @Summary List a workspace's direct work-item links +// @Description Returns a paginated, ID-ordered dependency graph for one workspace. Only non-custom-field item-to-item links with both endpoints in that workspace are returned. Cross-workspace and non-item links are excluded so link metadata from another workspace or permission domain cannot leak. +// @Tags links, workspaces +// @Produce json +// @Security BearerAuth +// @Param workspace_id query int true "Workspace ID" +// @Param page query int false "Page number (1-based)" minimum(1) +// @Param limit query int false "Links per page" minimum(1) maximum(100) +// @Success 200 {object} handlers.PaginatedResponse{data=[]models.ItemLink} +// @Failure 400 {object} handlers.ErrorResponse "Invalid workspace ID or pagination" +// @Failure 401 {object} handlers.ErrorResponse +// @Failure 403 {object} handlers.ErrorResponse "Token lacks the items:read scope" +// @Failure 404 {object} handlers.ErrorResponse "Workspace not found or not visible to caller" +// @Failure 500 {object} handlers.ErrorResponse +// @Router /links [get] +func (h *LinkHandler) ListWorkspaceItemLinks(w http.ResponseWriter, r *http.Request) { + user, ok := h.RequireAuth(w, r) + if !ok { + return + } + workspaceID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("workspace_id"))) + if err != nil || workspaceID <= 0 { + h.RespondError(w, r, restapi.NewAPIError(http.StatusBadRequest, restapi.ErrCodeInvalidInput, "workspace_id must be a positive integer")) + return + } + pagination, err := parseWorkspaceLinkPagination(r) + if err != nil { + h.RespondError(w, r, restapi.NewAPIError(http.StatusBadRequest, restapi.ErrCodeInvalidInput, err.Error())) + return + } + + links, total, err := h.svc.ListWorkspaceItemLinksWithChecks( + r.Context(), user.ID, workspaceID, pagination.Limit, pagination.Offset, + ) + if err != nil { + h.respondLinkServiceError(w, r, "workspace", err) + return + } + h.RespondPaginated(w, links, pagination, total) +} + // CreateLink handles POST /rest/api/v1/links // // CreateLink creates a cross-entity link. Scope: items:write. The diff --git a/internal/restapi/v1/router.go b/internal/restapi/v1/router.go index 31dc0c35..b0d39b60 100644 --- a/internal/restapi/v1/router.go +++ b/internal/restapi/v1/router.go @@ -301,6 +301,7 @@ func RegisterRoutes(deps restapi.Deps) { v1.HandleWithMiddleware("GET /link-types", linkHandler.ListLinkTypes, bearerAuth.RequirePermission("items:read")) v1.HandleWithMiddleware("GET /links/batch", linkHandler.GetLinksBatch, bearerAuth.RequirePermission("items:read")) + v1.HandleWithMiddleware("GET /links", linkHandler.ListWorkspaceItemLinks, bearerAuth.RequirePermission("items:read")) v1.HandleWithMiddleware("POST /links", linkHandler.CreateLink, bearerAuth.RequirePermission("items:write")) v1.HandleWithMiddleware("DELETE /links/{id}", linkHandler.DeleteLink, bearerAuth.RequirePermission("items:write"), router.RequireNumericID) v1.HandleWithMiddleware("GET /items/{id}/links", linkHandler.GetLinksForEntity, bearerAuth.RequirePermission("items:read"), router.RequireNumericID) diff --git a/internal/services/item_link_orchestration.go b/internal/services/item_link_orchestration.go index 665895b5..a9060f1b 100644 --- a/internal/services/item_link_orchestration.go +++ b/internal/services/item_link_orchestration.go @@ -1204,6 +1204,13 @@ func getLinksWhereContext(ctx context.Context, db database.Database, whereClause } func itemLinksWhereQuery(whereClause string) string { + return itemLinksWhereQueryWithOrder(whereClause, "lt.name, il.created_at DESC") +} + +// itemLinksWhereQueryWithOrder shares the joined response projection between +// link reads that need different stable orderings. orderBy is always supplied +// by service-owned constants, never request input. +func itemLinksWhereQueryWithOrder(whereClause, orderBy string) string { return ` SELECT il.id, il.link_type_id, il.source_type, il.source_id, il.target_type, il.target_id, il.created_by, il.created_at, @@ -1256,7 +1263,7 @@ func itemLinksWhereQuery(whereClause string) string { LEFT JOIN workspaces tpw ON tp.workspace_id = tpw.id LEFT JOIN custom_field_definitions cfd ON il.custom_field_id = cfd.id WHERE ` + whereClause + ` - ORDER BY lt.name, il.created_at DESC + ORDER BY ` + orderBy + ` ` } diff --git a/internal/services/item_link_page.go b/internal/services/item_link_page.go index 641a15af..2ee7a264 100644 --- a/internal/services/item_link_page.go +++ b/internal/services/item_link_page.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "windshift/internal/database" "windshift/internal/models" ) @@ -12,6 +13,12 @@ import ( // traversal layer can call this primitive once per breadth-first frontier. const MaxOneHopLinksPerItem = 50 +// MaxWorkspaceItemLinksPerPage bounds one workspace graph response. The +// REST API shares its general pagination ceiling, but the service applies the +// same limit so non-HTTP callers cannot accidentally request an unbounded +// graph page. +const MaxWorkspaceItemLinksPerPage = 100 + // OneHopItemLinksPage contains one anchor item's direct visible links. type OneHopItemLinksPage struct { Outgoing []models.ItemLink @@ -26,6 +33,95 @@ type itemLinkCandidate struct { outgoing bool } +// ListWorkspaceItemLinksWithChecks returns one deterministic page of direct +// work-item links whose two endpoints belong to workspaceID. It intentionally +// excludes cross-workspace and non-item links: returning either would expose +// metadata from another workspace or permission domain through the joined link +// response. +// +// The caller needs item.view on the workspace. A missing permission checker +// fails closed, just like the other checked link-list operations. +func (s *ItemLinkService) ListWorkspaceItemLinksWithChecks( + ctx context.Context, + userID, workspaceID, limit, offset int, +) ([]models.ItemLink, int, error) { + if workspaceID <= 0 { + return nil, 0, &EntityNotAccessibleError{EntityType: "workspace", EntityID: workspaceID} + } + if limit <= 0 || limit > MaxWorkspaceItemLinksPerPage { + limit = MaxWorkspaceItemLinksPerPage + } + if offset < 0 { + return nil, 0, fmt.Errorf("workspace link offset must be non-negative") + } + + if s.perm == nil { + return nil, 0, &EntityNotAccessibleError{EntityType: "workspace", EntityID: workspaceID} + } + allowed, err := s.perm.HasWorkspacePermission(userID, workspaceID, models.PermissionItemView) + if err != nil { + return nil, 0, fmt.Errorf("check workspace link-list permission: %w", err) + } + if !allowed { + return nil, 0, &EntityNotAccessibleError{EntityType: "workspace", EntityID: workspaceID} + } + + // Permission is checked before existence so callers without item.view get + // the same opaque path for present and absent workspace IDs. The separate + // existence check still prevents system administrators and stale positive + // permission-cache entries from turning a missing workspace into 200. + var exists bool + if err := s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = ?)", workspaceID).Scan(&exists); err != nil { + return nil, 0, fmt.Errorf("check workspace link-list existence: %w", err) + } + if !exists { + return nil, 0, &EntityNotAccessibleError{EntityType: "workspace", EntityID: workspaceID} + } + + return listWorkspaceItemLinksPage(ctx, s.db, workspaceID, limit, offset) +} + +func listWorkspaceItemLinksPage( + ctx context.Context, + db database.Database, + workspaceID, limit, offset int, +) ([]models.ItemLink, int, error) { + // Keeping both item joins in the predicate is the security boundary for + // this bulk surface. A link is a member of this graph only if *both* + // endpoint items are in the requested workspace. + const where = `il.source_type = 'item' AND il.target_type = 'item' + AND il.custom_field_id IS NULL + AND si.workspace_id = ? AND ti.workspace_id = ?` + + var total int + err := db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM item_links il + JOIN items si ON il.source_type = 'item' AND il.source_id = si.id + JOIN items ti ON il.target_type = 'item' AND il.target_id = ti.id + WHERE `+where, workspaceID, workspaceID).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("count workspace item links: %w", err) + } + + rows, err := db.QueryContext(ctx, + itemLinksWhereQueryWithOrder(where, "il.id ASC")+" LIMIT ? OFFSET ?", + workspaceID, workspaceID, limit, offset, + ) + if err != nil { + return nil, 0, fmt.Errorf("list workspace item links: %w", err) + } + defer func() { _ = rows.Close() }() + links, err := scanItemLinks(rows) + if err != nil { + return nil, 0, fmt.Errorf("scan workspace item links: %w", err) + } + if links == nil { + links = []models.ItemLink{} + } + return links, total, nil +} + // ListOneHopItemLinksPageWithChecks loads one direct-link page for every // anchor in a fixed number of queries. Links to items outside the caller's // accessible workspaces are excluded before per-anchor ranking.