Compare commits

..

4 Commits

Author SHA1 Message Date
Jiang Bohan
80956e71b6 fix(handler): add cycle detection to BatchUpdateIssues parent_issue_id handling
BatchUpdateIssues was missing the ancestor-walk cycle detection that
single UpdateIssue has. This allowed creating circular parent
relationships (e.g. A→B→A) via the batch API. Added the same
depth-limited walk (up to 10 ancestors) to detect and skip issues
that would create cycles, consistent with UpdateIssue behavior.
2026-04-12 23:01:41 +08:00
Qiaochu Hu
0c2102b951 fix(handler): fix batch operations and error handling bugs (#779)
fix(handler): fix batch operations and error handling bugs
2026-04-12 23:00:40 +08:00
zerion-925
0c28d3cd08 fix: add randomUUID fallback for non-secure contexts (#749)
* fix: fallback when crypto.randomUUID is unavailable

* fix(core): remove Math.random UUID fallback and add tests

---------

Co-authored-by: Zerion <dev@take-app.local>
2026-04-12 22:51:56 +08:00
Jiang Bohan
7312b5650c fix(server): fix ListRuntimeUsage to filter by date range instead of row count (#765)
Replace LIMIT $2 with AND date >= $2 in ListRuntimeUsage query. When a
runtime uses multiple models each day has multiple rows, so a row LIMIT
silently returns fewer days than requested.

Also fixes displayName warnings in issue-detail test mocks and adds
missing setOpen to useCallback deps in search-command.

Co-authored-by: jayavibhavnk <jaya11vibhav@gmail.com>
Closes #731
2026-04-12 22:46:07 +08:00
7 changed files with 137 additions and 6 deletions

View File

@@ -2,6 +2,7 @@ import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -69,7 +70,7 @@ export function resolveRouteIcon(pathname: string): string {
const DEFAULT_PATH = "/issues";
function createId(): string {
return crypto.randomUUID();
return createSafeId();
}
function makeTab(path: string, title: string, icon: string): Tab {

View File

@@ -52,6 +52,7 @@ import type {
ReorderPinsRequest,
} from "../types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
export interface ApiClientOptions {
logger?: Logger;
@@ -108,7 +109,7 @@ export class ApiClient {
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const rid = crypto.randomUUID().slice(0, 8);
const rid = createRequestId();
const start = Date.now();
const method = init?.method ?? "GET";
@@ -610,7 +611,7 @@ export class ApiClient {
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
const rid = crypto.randomUUID().slice(0, 8);
const rid = createRequestId();
const start = Date.now();
this.logger.info("→ POST /api/upload-file", { rid });

View File

@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequestId, createSafeId, generateUUID } from "./utils";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("utils id helpers", () => {
it("generateUUID returns a valid UUID v4", () => {
const id = generateUUID();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
it("createSafeId falls back when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {
getRandomValues: (arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) arr[i] = i;
return arr;
},
});
const id = createSafeId();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
it("createRequestId defaults to length 8 and respects custom length", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-4abc-8def-1234567890ab");
expect(createRequestId()).toBe("12345678");
expect(createRequestId(12)).toBe("123456781234");
});
});

View File

@@ -8,3 +8,43 @@ export function timeAgo(dateStr: string): string {
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function generateUUID(): string {
const cryptoObj = globalThis.crypto;
if (!cryptoObj?.getRandomValues) {
throw new Error("Secure UUID generation requires crypto.getRandomValues");
}
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; // version 4
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; // variant 1
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Generate an id that prefers crypto.randomUUID but falls back in non-secure contexts.
*/
export function createSafeId(): string {
const cryptoObj = globalThis.crypto;
if (cryptoObj?.randomUUID) {
try {
return cryptoObj.randomUUID();
} catch {
// Fall through to fallback.
}
}
return generateUUID();
}
/** Request id helper used for logs/tracing headers. */
export function createRequestId(length = 8): string {
return createSafeId().replace(/-/g, "").slice(0, length);
}

View File

@@ -1,6 +1,7 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { createSafeId } from "@multica/core/utils";
/** Find and remove a fileCard node by uploadId. */
@@ -109,7 +110,7 @@ export async function uploadAndInsertFile(
}
} else {
// Non-image: insert skeleton fileCard → upload → finalize with real URL
const uploadId = crypto.randomUUID();
const uploadId = createSafeId();
const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId };
const insertContent = { type: "fileCard", attrs: cardAttrs };
if (pos !== undefined) {

View File

@@ -412,7 +412,12 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
}
// Fetch user info from Google.
userInfoReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
userInfoReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
slog.Error("failed to create userinfo request", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
userInfoReq.Header.Set("Authorization", "Bearer "+gToken.AccessToken)
userInfoResp, err := http.DefaultClient.Do(userInfoReq)

View File

@@ -1252,6 +1252,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
AssigneeID: prevIssue.AssigneeID,
DueDate: prevIssue.DueDate,
ParentIssueID: prevIssue.ParentIssueID,
ProjectID: prevIssue.ProjectID,
}
if req.Updates.Title != nil {
@@ -1295,6 +1296,50 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
}
if _, ok := rawUpdates["parent_issue_id"]; ok {
if req.Updates.ParentIssueID != nil {
newParentID := parseUUID(*req.Updates.ParentIssueID)
// Cannot set self as parent.
if uuidToString(newParentID) == issueID {
continue
}
// Validate parent exists in the same workspace.
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
ID: newParentID,
WorkspaceID: prevIssue.WorkspaceID,
}); err != nil {
continue
}
// Cycle detection: walk up from the new parent to ensure we don't reach this issue.
cycleDetected := false
cursor := newParentID
for depth := 0; depth < 10; depth++ {
ancestor, err := h.Queries.GetIssue(r.Context(), cursor)
if err != nil || !ancestor.ParentIssueID.Valid {
break
}
if uuidToString(ancestor.ParentIssueID) == issueID {
cycleDetected = true
break
}
cursor = ancestor.ParentIssueID
}
if cycleDetected {
continue
}
params.ParentIssueID = newParentID
} else {
params.ParentIssueID = pgtype.UUID{Valid: false}
}
}
if _, ok := rawUpdates["project_id"]; ok {
if req.Updates.ProjectID != nil {
params.ProjectID = parseUUID(*req.Updates.ProjectID)
} else {
params.ProjectID = pgtype.UUID{Valid: false}
}
}
// Enforce agent visibility for batch assignment.
if req.Updates.AssigneeType != nil && *req.Updates.AssigneeType == "agent" && req.Updates.AssigneeID != nil {
if ok, _ := h.canAssignAgent(r.Context(), r, *req.Updates.AssigneeID, workspaceID); !ok {
@@ -1372,11 +1417,16 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) {
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
if err := h.Queries.DeleteIssue(r.Context(), parseUUID(issueID)); err != nil {
// Collect attachment URLs before CASCADE delete to clean up S3 objects.
attachmentURLs, _ := h.Queries.ListAttachmentURLsByIssueOrComments(r.Context(), issue.ID)
if err := h.Queries.DeleteIssue(r.Context(), issue.ID); err != nil {
slog.Warn("batch delete issue failed", "issue_id", issueID, "error", err)
continue
}
h.deleteS3Objects(r.Context(), attachmentURLs)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": issueID})
deleted++