Compare commits

..

5 Commits

Author SHA1 Message Date
Jiayuan Zhang
9e792f9c5b chore: add screenshots section to issue templates
Add optional screenshots field to both bug report and feature request
templates so users can attach images for richer context.
2026-04-12 13:56:35 +08:00
Jiayuan Zhang
1317719d9f chore: simplify issue templates to lower submission friction
Bug report: just what happened + steps to reproduce (required),
plus an optional context field for logs/env.

Feature request: just what you want and why (required),
plus an optional proposed solution.

Removed all dropdowns, environment fields, checkboxes, and
other fields that discourage users from filing issues.
2026-04-12 13:53:18 +08:00
Jiayuan Zhang
998b08ced4 chore: simplify AI disclosure to focus on prompt sharing
Remove the review-status checklist — it was too heavy and users won't
actually do it. Instead focus on what's useful: which AI tool was used
and what prompt/approach produced the code, so the team can learn from
each other's AI workflows.
2026-04-12 13:50:47 +08:00
Jiayuan Zhang
ed7f89ed30 chore: add AI disclosure section to PR template
Since most PRs are now authored or co-authored by AI coding tools,
add a dedicated AI Disclosure section to the PR template. Includes
authorship type, tool used, and a human review checklist to ensure
AI-generated code is properly reviewed before merge.
2026-04-12 13:45:40 +08:00
Jiayuan Zhang
87e0c7af69 chore: add issue templates and improve PR template
Add GitHub issue templates (bug report, feature request) using YAML
forms, referencing hermes-agent's template structure. Update the PR
template with clearer sections for changes made, related issues, and
a more comprehensive checklist.
2026-04-12 13:25:02 +08:00
34 changed files with 147 additions and 1066 deletions

View File

@@ -42,10 +42,6 @@ CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

2
.gitignore vendored
View File

@@ -48,5 +48,3 @@ _features/
*.dmg
*.app
server/server
data/
.kilo

View File

@@ -77,8 +77,6 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
### b) One-command setup

View File

@@ -2,8 +2,6 @@ import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
export function DesktopLoginPage() {
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
return (
<div className="flex h-screen flex-col">
{/* Traffic light inset */}
@@ -13,7 +11,6 @@ export function DesktopLoginPage() {
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
lastWorkspaceId={lastWorkspaceId}
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell
}}

View File

@@ -1,13 +0,0 @@
import { existsSync } from "fs";
import { resolve } from "path";
import { config } from "dotenv";
const envCandidates = [".env.worktree", ".env"];
for (const filename of envCandidates) {
const path = resolve(process.cwd(), filename);
if (existsSync(path)) {
config({ path });
break;
}
}

View File

@@ -4,7 +4,6 @@
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
*/
import "./env";
import pg from "pg";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
@@ -22,43 +21,39 @@ export class TestApiClient {
private createdIssueIds: string[] = [];
async login(email: string, name: string) {
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
// Rate limited — code already sent recently, read it from DB
if (sendRes.status !== 429) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
}
// Step 2: Read code from database
const client = new pg.Client(DATABASE_URL);
await client.connect();
try {
// Keep each E2E login isolated so previous test runs do not trip the
// per-email send-code rate limit.
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
// Step 2: Read code from database
const result = await client.query(
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
[email],
[email]
);
if (result.rows.length === 0) {
throw new Error(`No verification code found for ${email}`);
}
const code = result.rows[0].code;
// Step 3: Verify code to get JWT
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code: result.rows[0].code }),
body: JSON.stringify({ email, code }),
});
if (!verifyRes.ok) {
throw new Error(`verify-code failed: ${verifyRes.status}`);
}
const data = await verifyRes.json();
this.token = data.token;
// Update user name if needed
@@ -69,8 +64,6 @@ export class TestApiClient {
});
}
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
return data;
} finally {
await client.end();

View File

@@ -11,14 +11,11 @@ test.describe("Issues", () => {
});
test.afterEach(async () => {
if (api) {
await api.cleanup();
}
await api.cleanup();
});
test("issues page loads with board view", async ({ page }) => {
await api.createIssue("E2E Board View " + Date.now());
await page.reload();
await expect(page.locator("text=All Issues")).toBeVisible();
// Board columns should be visible
await expect(page.locator("text=Backlog")).toBeVisible();
@@ -26,36 +23,29 @@ test.describe("Issues", () => {
await expect(page.locator("text=In Progress")).toBeVisible();
});
test("can switch from board to list view", async ({ page }) => {
const title = "E2E List Switch " + Date.now();
await api.createIssue(title);
await page.reload();
await expect(page.locator("text=Backlog")).toBeVisible();
test("can switch between board and list view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
// Switch to list view
await page.click("text=List");
await expect(page.getByText(title)).toBeVisible();
await expect(page.locator("text=All Issues")).toBeVisible();
// Switch back to board view
await page.click("text=Board");
await expect(page.locator("text=Backlog")).toBeVisible();
});
test("can create a new issue", async ({ page }) => {
const newIssueButton = page.getByRole("button", { name: "New Issue" });
await expect(newIssueButton).toBeVisible();
await newIssueButton.click();
await page.click("text=New Issue");
const title = "E2E Created " + Date.now();
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await titleInput.fill(title);
await page.getByRole("button", { name: "Create Issue" }).click();
await page.fill('input[placeholder="Issue title..."]', title);
await page.click("text=Create");
await expect(page.getByText("Issue created")).toBeVisible({ timeout: 10000 });
await expect(
page.getByRole("region", { name: /Notifications/ }).getByText(title),
).toBeVisible();
await page.getByRole("button", { name: "View issue" }).click();
await page.waitForURL(/\/issues\/[\w-]+/);
await expect(page.locator("text=Properties")).toBeVisible();
// New issue should appear on the page
await expect(page.locator(`text=${title}`).first()).toBeVisible({
timeout: 10000,
});
});
test("can navigate to issue detail page", async ({ page }) => {
@@ -64,6 +54,7 @@ test.describe("Issues", () => {
// Reload to see the new issue
await page.reload();
await expect(page.locator("text=All Issues")).toBeVisible();
// Navigate to the issue detail
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
@@ -80,15 +71,18 @@ test.describe("Issues", () => {
).toBeVisible();
});
test("can dismiss issue creation", async ({ page }) => {
await page.getByRole("button", { name: "New Issue" }).click();
test("can cancel issue creation", async ({ page }) => {
await page.click("text=New Issue");
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await expect(
page.locator('input[placeholder="Issue title..."]'),
).toBeVisible();
await page.keyboard.press("Escape");
await page.click("text=Cancel");
await expect(titleInput).not.toBeVisible();
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
await expect(
page.locator('input[placeholder="Issue title..."]'),
).not.toBeVisible();
await expect(page.locator("text=New Issue")).toBeVisible();
});
});

View File

@@ -72,6 +72,7 @@ export function createAuthStore(options: AuthStoreOptions) {
logout: () => {
storage.removeItem("multica_token");
storage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
onLogout?.();

View File

@@ -1,6 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useAuthStore } from "../auth";
import { pinKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { PinnedItem, PinnedItemType } from "../types";
@@ -8,17 +7,16 @@ import type { PinnedItem, PinnedItemType } from "../types";
export function useCreatePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
api.createPin(data),
onSuccess: (newPin) => {
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
old ? [...old, newPin] : [newPin],
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
},
});
}
@@ -26,23 +24,22 @@ export function useCreatePin() {
export function useDeletePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
api.deletePin(itemType, itemId),
onMutate: async ({ itemType, itemId }) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
},
});
}
@@ -50,20 +47,19 @@ export function useDeletePin() {
export function useReorderPins() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (reorderedPins: PinnedItem[]) => {
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
return api.reorderPins({ items });
},
onMutate: async (reorderedPins) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), reorderedPins);
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
},
});
}

View File

@@ -2,13 +2,13 @@ import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const pinKeys = {
all: (wsId: string, userId: string) => ["pins", wsId, userId] as const,
list: (wsId: string, userId: string) => [...pinKeys.all(wsId, userId), "list"] as const,
all: (wsId: string) => ["pins", wsId] as const,
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
};
export function pinListOptions(wsId: string, userId: string) {
export function pinListOptions(wsId: string) {
return queryOptions({
queryKey: pinKeys.list(wsId, userId),
queryKey: pinKeys.list(wsId),
queryFn: () => api.listPins(),
});
}

View File

@@ -102,8 +102,7 @@ export function useRealtimeSync(
},
pin: () => {
const wsId = workspaceStore.getState().workspace?.id;
const userId = authStore.getState().user?.id;
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
},
daemon: () => {
const wsId = workspaceStore.getState().workspace?.id;

View File

@@ -53,7 +53,7 @@ function IssueMention({
}) {
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const { push, openInNewTab } = useNavigation();
const { openInNewTab } = useNavigation();
const issue = issues.find((i) => i.id === issueId);
const issuePath = `/issues/${issueId}`;
@@ -61,13 +61,11 @@ function IssueMention({
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
}
return;
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
} else {
window.open(issuePath, "_blank", "noopener,noreferrer");
}
push(issuePath);
};
const cardClass =

View File

@@ -86,7 +86,7 @@ function urlTransform(url: string): string {
// ---------------------------------------------------------------------------
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
const { push, openInNewTab } = useNavigation();
const { openInNewTab } = useNavigation();
const path = `/issues/${issueId}`;
return (
<span
@@ -94,13 +94,11 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(path, label);
}
return;
if (openInNewTab) {
openInNewTab(path, label);
} else {
window.open(path, "_blank", "noopener,noreferrer");
}
push(path);
}}
>
<IssueMentionCard issueId={issueId} fallbackLabel={label} />

View File

@@ -94,10 +94,7 @@ vi.mock("../../editor", () => ({
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),
ContentEditor: forwardRef(function MockContentEditor(
{ defaultValue, onUpdate, placeholder }: any,
ref: any,
) {
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -119,10 +116,7 @@ vi.mock("../../editor", () => ({
/>
);
}),
TitleEditor: forwardRef(function MockTitleEditor(
{ defaultValue, placeholder, onBlur, onChange }: any,
ref: any,
) {
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({

View File

@@ -195,7 +195,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const id = issueId;
const router = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const workspace = useWorkspaceStore((s) => s.workspace);
// Issue navigation — read from TQ list cache
@@ -265,10 +264,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const { data: usage } = useQuery(issueUsageOptions(id));
// Pinned state
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
const createPin = useCreatePin();
const deletePin = useDeletePin();

View File

@@ -43,7 +43,6 @@ import {
SidebarFooter,
SidebarMenu,
SidebarMenuButton,
SidebarMenuAction,
SidebarMenuItem,
SidebarRail,
} from "@multica/ui/components/ui/sidebar";
@@ -64,7 +63,7 @@ import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
import { pinListOptions } from "@multica/core/pins/queries";
import { pinKeys } from "@multica/core/pins/queries";
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import type { PinnedItem } from "@multica/core/types";
@@ -94,6 +93,7 @@ function DraftDot() {
function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
const wasDragged = useRef(false);
const { push } = useNavigation();
useEffect(() => {
if (isDragging) wasDragged.current = true;
@@ -114,13 +114,12 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
>
<SidebarMenuButton
isActive={isActive}
render={<AppLink href={href} />}
onClick={(event) => {
onClick={() => {
if (wasDragged.current) {
wasDragged.current = false;
event.preventDefault();
return;
}
push(href);
}}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
@@ -130,17 +129,17 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
<FolderKanban className="size-4 shrink-0" />
)}
<span className="truncate">{label}</span>
<button
className="ml-auto opacity-0 group-hover/pin:opacity-100 transition-opacity p-0.5 rounded hover:bg-accent shrink-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnpin();
}}
>
<PinOff className="size-3 text-muted-foreground" />
</button>
</SidebarMenuButton>
<SidebarMenuAction
showOnHover
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onUnpin();
}}
>
<PinOff className="size-3 text-muted-foreground" />
</SidebarMenuAction>
</SidebarMenuItem>
);
}
@@ -159,7 +158,6 @@ interface AppSidebarProps {
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const authLogout = useAuthStore((s) => s.logout);
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
@@ -176,9 +174,10 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
enabled: !!wsId && !!userId,
const { data: pinnedItems = [] } = useQuery<PinnedItem[]>({
queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"],
queryFn: () => api.listPins(),
enabled: !!wsId,
});
const deletePin = useDeletePin();
const reorderPins = useReorderPins();

View File

@@ -1,223 +0,0 @@
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const mockPush = vi.hoisted(() => vi.fn());
const mockCreateIssue = vi.hoisted(() => vi.fn());
const mockSetDraft = vi.hoisted(() => vi.fn());
const mockClearDraft = vi.hoisted(() => vi.fn());
const mockToastCustom = vi.hoisted(() => vi.fn());
const mockToastDismiss = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
const mockDraftStore = {
draft: {
title: "",
description: "",
status: "todo" as const,
priority: "none" as const,
assigneeType: undefined,
assigneeId: undefined,
dueDate: null,
},
setDraft: mockSetDraft,
clearDraft: mockClearDraft,
};
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: mockPush }),
}));
vi.mock("@multica/core/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector?: (state: { workspace: { name: string } }) => unknown) => {
const state = { workspace: { name: "Test Workspace" } };
return selector ? selector(state) : state;
},
{ getState: () => ({ workspace: { name: "Test Workspace" } }) },
),
}));
vi.mock("@multica/core/issues/stores/draft-store", () => ({
useIssueDraftStore: Object.assign(
(selector?: (state: typeof mockDraftStore) => unknown) =>
(selector ? selector(mockDraftStore) : mockDraftStore),
{ getState: () => mockDraftStore },
),
}));
vi.mock("@multica/core/issues/mutations", () => ({
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
}));
vi.mock("@multica/core/api", () => ({
api: {},
}));
vi.mock("../editor", () => ({
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
FileDropOverlay: () => null,
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
uploadFile: vi.fn(),
}));
return (
<textarea
value={value}
placeholder={placeholder}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
/>
);
}),
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
const [value, setValue] = useState(defaultValue || "");
return (
<input
value={value}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
onChange?.(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit?.();
}}
/>
);
},
}));
vi.mock("../issues/components", () => ({
StatusIcon: ({ status }: { status: string }) => <span data-testid="status-icon">{status}</span>,
StatusPicker: () => <div data-testid="status-picker" />,
PriorityPicker: () => <div data-testid="priority-picker" />,
AssigneePicker: () => <div data-testid="assignee-picker" />,
DueDatePicker: () => <div data-testid="due-date-picker" />,
}));
vi.mock("../projects/components/project-picker", () => ({
ProjectPicker: () => <div data-testid="project-picker" />,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog-root">{children}</div>,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@multica/ui/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit" | "reset";
}) => (
<button type={type} disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
Upload file
</button>
),
}));
vi.mock("@multica/ui/lib/utils", () => ({
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" "),
}));
vi.mock("sonner", () => ({
toast: {
custom: mockToastCustom,
dismiss: mockToastDismiss,
error: mockToastError,
},
}));
import { CreateIssueModal } from "./create-issue";
describe("CreateIssueModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateIssue.mockResolvedValue({
id: "issue-123",
identifier: "TES-123",
title: "Ship create issue regression coverage",
status: "todo",
});
});
it("shows success feedback with a direct path to the new issue", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<CreateIssueModal onClose={onClose} />);
await user.type(screen.getByPlaceholderText("Issue title"), " Ship create issue regression coverage ");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "Ship create issue regression coverage",
description: undefined,
status: "todo",
priority: "none",
assignee_type: undefined,
assignee_id: undefined,
due_date: undefined,
attachment_ids: undefined,
parent_issue_id: undefined,
project_id: undefined,
});
});
expect(mockClearDraft).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
expect(mockToastCustom).toHaveBeenCalledTimes(1);
const renderToast = mockToastCustom.mock.calls[0]?.[0];
expect(typeof renderToast).toBe("function");
render(renderToast("toast-1"));
expect(screen.getByText("Issue created")).toBeInTheDocument();
expect(screen.getByText(/TES-123/)).toBeInTheDocument();
expect(screen.getByText(/Ship create issue regression coverage/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View issue" }));
expect(mockPush).toHaveBeenCalledWith("/issues/issue-123");
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
});
});

View File

@@ -7,7 +7,6 @@ import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { pinListOptions } from "@multica/core/pins";
@@ -194,7 +193,6 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
export function ProjectDetail({ projectId }: { projectId: string }) {
const wsId = useWorkspaceId();
const router = useNavigation();
const userId = useAuthStore((s) => s.user?.id);
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
@@ -203,10 +201,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const { getActorName } = useActorName();
const updateProject = useUpdateProject();
const deleteProject = useDeleteProject();
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const isPinned = pinnedItems.some((p) => p.item_type === "project" && p.item_id === projectId);
const createPin = useCreatePin();
const deletePinMut = useDeletePin();

View File

@@ -228,7 +228,7 @@ export function SearchCommand() {
setOpen(false);
push(href);
},
[push, setOpen],
[push],
);
return (

View File

@@ -1,4 +1,3 @@
import "./e2e/env";
import { defineConfig } from "@playwright/test";
export default defineConfig({

View File

@@ -56,21 +56,9 @@ func allowedOrigins() []string {
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
queries := db.New(pool)
emailSvc := service.NewEmailService()
// Initialize storage with S3 as primary, fallback to local
var store storage.Storage
s3 := storage.NewS3StorageFromEnv()
if s3 != nil {
store = s3
} else {
local := storage.NewLocalStorageFromEnv()
if local != nil {
store = local
}
}
cfSigner := auth.NewCloudFrontSignerFromEnv()
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner)
r := chi.NewRouter()
@@ -99,14 +87,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
realtime.HandleWebSocket(hub, mc, pr, w, r)
})
// Local file serving (when using local storage)
if local, ok := store.(*storage.LocalStorage); ok {
r.Get("/uploads/*", func(w http.ResponseWriter, r *http.Request) {
file := strings.TrimPrefix(r.URL.Path, "/uploads/")
local.ServeFile(w, r, file)
})
}
// Auth (public)
r.Post("/auth/send-code", h.SendCode)
r.Post("/auth/verify-code", h.VerifyCode)

View File

@@ -134,36 +134,12 @@ func broadcastFailedTasks(ctx context.Context, queries *db.Queries, bus *events.
}
affectedAgents := make(map[string]pgtype.UUID)
processedIssues := make(map[string]bool)
for _, ft := range items {
// Look up workspace ID from the issue so the event reaches the right WS room.
workspaceID := ""
if issue, err := queries.GetIssue(ctx, ft.IssueID); err == nil {
workspaceID = util.UUIDToString(issue.WorkspaceID)
// If the issue is still in_progress and no other active tasks remain,
// reset it back to todo so the daemon can pick it up again.
issueKey := util.UUIDToString(ft.IssueID)
if issue.Status == "in_progress" && !processedIssues[issueKey] {
processedIssues[issueKey] = true
hasActive, checkErr := queries.HasActiveTaskForIssue(ctx, ft.IssueID)
if checkErr != nil {
slog.Warn("runtime sweeper: failed to check active tasks for issue",
"issue_id", issueKey,
"error", checkErr,
)
} else if !hasActive {
if _, updateErr := queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
ID: ft.IssueID,
Status: "todo",
}); updateErr != nil {
slog.Warn("runtime sweeper: failed to reset stuck issue to todo",
"issue_id", issueKey,
"error", updateErr,
)
}
}
}
}
bus.Publish(events.Event{

View File

@@ -300,168 +300,6 @@ func TestSweepDispatchedStaleTask(t *testing.T) {
}
}
// TestSweepResetsInProgressIssueToTodo verifies the core fix: when the sweeper
// force-fails a stale task whose issue is still in_progress (because the daemon
// crashed mid-run), the issue is reset back to todo so the daemon can re-queue it.
//
// Without this fix the issue stays in_progress permanently — the agent never runs
// to update the status because it was never dispatched.
func TestSweepResetsInProgressIssueToTodo(t *testing.T) {
if testPool == nil {
t.Skip("no database connection")
}
ctx := context.Background()
// Use the same agent/runtime as the other sweeper tests.
var agentID, runtimeID string
err := testPool.QueryRow(ctx, `
SELECT a.id, a.runtime_id FROM agent a
JOIN member m ON m.workspace_id = a.workspace_id
JOIN "user" u ON u.id = m.user_id
WHERE u.email = $1
LIMIT 1
`, integrationTestEmail).Scan(&agentID, &runtimeID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Create an issue already in in_progress (simulates a daemon crash mid-run).
var issueID string
err = testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
SELECT $1, 'Stuck in_progress issue', 'in_progress', 'none', 'member', m.user_id, 'agent', $2
FROM member m WHERE m.workspace_id = $1 LIMIT 1
RETURNING id
`, testWorkspaceID, agentID).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Create a stale running task for the issue (3 hours old — beyond any timeout).
var taskID string
err = testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create stale task: %v", err)
}
queries := db.New(testPool)
bus := events.New()
// Fail the stale task (running timeout of 1 second — our task is 3 hours old).
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
DispatchTimeoutSecs: 300.0,
RunningTimeoutSecs: 1.0,
})
if err != nil {
t.Fatalf("FailStaleTasks failed: %v", err)
}
// Confirm our task was swept.
found := false
for _, ft := range failedTasks {
if ft.ID.Bytes == parseUUIDBytes(taskID) {
found = true
break
}
}
if !found {
t.Fatalf("expected task %s to be in failed tasks, got %v", taskID, failedTasks)
}
// This is what we're testing: issue must be reset from in_progress → todo.
broadcastFailedTasks(ctx, queries, bus, failedTasks)
var issueStatus string
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
if err != nil {
t.Fatalf("failed to query issue status: %v", err)
}
if issueStatus != "todo" {
t.Fatalf("expected issue status 'todo' after sweep, got '%s' — issue is stuck", issueStatus)
}
}
// TestSweepDoesNotResetIssueAlreadyInReview verifies that the sweeper only resets
// issues that are truly stuck in in_progress — it must not clobber issues whose
// agents already moved them forward (e.g. to in_review) before the task timed out.
func TestSweepDoesNotResetIssueAlreadyInReview(t *testing.T) {
if testPool == nil {
t.Skip("no database connection")
}
ctx := context.Background()
var agentID, runtimeID string
err := testPool.QueryRow(ctx, `
SELECT a.id, a.runtime_id FROM agent a
JOIN member m ON m.workspace_id = a.workspace_id
JOIN "user" u ON u.id = m.user_id
WHERE u.email = $1
LIMIT 1
`, integrationTestEmail).Scan(&agentID, &runtimeID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Issue already advanced to in_review by the agent before the task timed out.
var issueID string
err = testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
SELECT $1, 'Already in_review issue', 'in_review', 'none', 'member', m.user_id, 'agent', $2
FROM member m WHERE m.workspace_id = $1 LIMIT 1
RETURNING id
`, testWorkspaceID, agentID).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
var taskID string
err = testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create stale task: %v", err)
}
queries := db.New(testPool)
bus := events.New()
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
DispatchTimeoutSecs: 300.0,
RunningTimeoutSecs: 1.0,
})
if err != nil {
t.Fatalf("FailStaleTasks failed: %v", err)
}
broadcastFailedTasks(ctx, queries, bus, failedTasks)
// Issue should remain in_review — the sweeper must not clobber agent progress.
var issueStatus string
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
if err != nil {
t.Fatalf("failed to query issue status: %v", err)
}
if issueStatus != "in_review" {
t.Fatalf("expected issue status 'in_review' to be preserved, got '%s'", issueStatus)
}
}
// parseUUIDBytes converts a UUID string to the 16-byte array used by pgtype.UUID.
func parseUUIDBytes(s string) [16]byte {
s = strings.ReplaceAll(s, "-", "")

View File

@@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/middleware"
@@ -18,7 +19,6 @@ import (
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/storage"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type txStarter interface {
@@ -41,11 +41,11 @@ type Handler struct {
EmailService *service.EmailService
PingStore *PingStore
UpdateStore *UpdateStore
Storage storage.Storage
Storage *storage.S3Storage
CFSigner *auth.CloudFrontSigner
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner) *Handler {
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, s3 *storage.S3Storage, cfSigner *auth.CloudFrontSigner) *Handler {
var executor dbExecutor
if candidate, ok := txStarter.(dbExecutor); ok {
executor = candidate
@@ -61,7 +61,7 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
EmailService: emailService,
PingStore: NewPingStore(),
UpdateStore: NewUpdateStore(),
Storage: store,
Storage: s3,
CFSigner: cfSigner,
}
}
@@ -77,14 +77,14 @@ func writeError(w http.ResponseWriter, status int, msg string) {
}
// Thin wrappers around util functions (preserve existing handler code unchanged).
func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
func strToText(s string) pgtype.Text { return util.StrToText(s) }
func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
func strToText(s string) pgtype.Text { return util.StrToText(s) }
func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToString(t) }
func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) }
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
// publish sends a domain event through the event bus.
func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) {

View File

@@ -135,17 +135,16 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
return
}
days := 90
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
limit := int32(90)
if l := r.URL.Query().Get("days"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 365 {
limit = int32(parsed)
}
}
since := pgtype.Date{Time: time.Now().AddDate(0, 0, -days), Valid: true}
rows, err := h.Queries.ListRuntimeUsage(r.Context(), db.ListRuntimeUsageParams{
RuntimeID: parseUUID(runtimeID),
Since: since,
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list usage")

View File

@@ -1,9 +1,7 @@
package sanitize
import (
"fmt"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
)
@@ -13,6 +11,11 @@ var httpURL = regexp.MustCompile(`^https?://`)
// policy is a shared bluemonday policy that allows safe Markdown HTML while
// stripping dangerous elements (script, iframe, object, embed, style, on*).
//
// Note: bluemonday operates on raw text, so HTML inside Markdown code blocks
// (e.g. ```<script>```) will also be stripped. This is an acceptable trade-off
// for defense-in-depth — the primary sanitization happens in the frontend via
// rehype-sanitize which understands the Markdown AST.
var policy *bluemonday.Policy
func init() {
@@ -25,47 +28,8 @@ func init() {
policy.AllowAttrs("class").OnElements("code", "div", "span", "pre")
}
// fencedCodeBlock matches ``` or ~~~ fenced code blocks (with optional language tag).
var fencedCodeBlock = regexp.MustCompile("(?m)^(```|~~~)[^\n]*\n[\\s\\S]*?\n(```|~~~)[ \t]*$")
// inlineCode matches backtick-delimited inline code spans.
// Ordered longest-delimiter-first so triple backticks match before doubles/singles.
var inlineCode = regexp.MustCompile("```[^`]+```|``[^`]+``|`[^`]+`")
// HTML sanitizes user-provided HTML/Markdown content, stripping dangerous
// tags (script, iframe, object, embed, etc.) and event-handler attributes.
//
// Code blocks and inline code spans are preserved verbatim so that bluemonday
// does not HTML-escape their contents (e.g. && → &amp;&amp;).
func HTML(input string) string {
// 1. Extract fenced code blocks, replacing with unique placeholders.
var blocks []string
placeholder := func(i int) string { return fmt.Sprintf("\x00CODEBLOCK_%d\x00", i) }
result := fencedCodeBlock.ReplaceAllStringFunc(input, func(m string) string {
idx := len(blocks)
blocks = append(blocks, m)
return placeholder(idx)
})
// 2. Extract inline code spans.
var inlines []string
inlinePH := func(i int) string { return fmt.Sprintf("\x00INLINE_%d\x00", i) }
result = inlineCode.ReplaceAllStringFunc(result, func(m string) string {
idx := len(inlines)
inlines = append(inlines, m)
return inlinePH(idx)
})
// 3. Sanitize the non-code portions.
result = policy.Sanitize(result)
// 4. Restore inline code spans, then fenced code blocks.
for i, code := range inlines {
result = strings.Replace(result, inlinePH(i), code, 1)
}
for i, block := range blocks {
result = strings.Replace(result, placeholder(i), block, 1)
}
return result
return policy.Sanitize(input)
}

View File

@@ -80,52 +80,6 @@ func TestHTML(t *testing.T) {
input: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
want: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
},
// Code block preservation — entities must NOT be escaped inside code.
{
name: "fenced code block preserves ampersands",
input: "```\na && b\n```",
want: "```\na && b\n```",
},
{
name: "fenced code block preserves angle brackets",
input: "```html\n<div class=\"x\">hello</div>\n```",
want: "```html\n<div class=\"x\">hello</div>\n```",
},
{
name: "inline code preserves ampersands",
input: "run `a && b` in shell",
want: "run `a && b` in shell",
},
{
name: "inline code preserves angle brackets",
input: "use `x < y && y > z`",
want: "use `x < y && y > z`",
},
{
name: "double backtick inline code preserved",
input: "use ``a && b`` here",
want: "use ``a && b`` here",
},
{
name: "script in fenced code block preserved",
input: "```\n<script>alert(1)</script>\n```",
want: "```\n<script>alert(1)</script>\n```",
},
{
name: "script outside code block still stripped",
input: "hello <script>alert(1)</script> world",
want: "hello world",
},
{
name: "mixed code and non-code",
input: "text `a && b` more <script>x</script> end",
want: "text `a && b` more end",
},
{
name: "tilde fenced code block preserves content",
input: "~~~\na && b\n~~~",
want: "~~~\na && b\n~~~",
},
}
for _, tt := range tests {

View File

@@ -1,114 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
)
type LocalStorage struct {
uploadDir string
baseURL string
}
// NewLocalStorageFromEnv creates a LocalStorage from environment variables.
// Returns nil if upload directory cannot be created.
//
// Environment variables:
// - LOCAL_UPLOAD_DIR (default: "./data/uploads")
// - LOCAL_UPLOAD_BASE_URL (optional, e.g., "http://localhost:8080")
func NewLocalStorageFromEnv() *LocalStorage {
uploadDir := os.Getenv("LOCAL_UPLOAD_DIR")
if uploadDir == "" {
uploadDir = "./data/uploads"
}
if err := os.MkdirAll(uploadDir, 0755); err != nil {
slog.Error("failed to create upload directory", "dir", uploadDir, "error", err)
return nil
}
baseURL := strings.TrimSuffix(os.Getenv("LOCAL_UPLOAD_BASE_URL"), "/")
slog.Info("local storage initialized", "dir", uploadDir, "baseURL", baseURL)
return &LocalStorage{
uploadDir: uploadDir,
baseURL: baseURL,
}
}
func (s *LocalStorage) KeyFromURL(rawURL string) string {
if s.baseURL != "" && strings.HasPrefix(rawURL, s.baseURL) {
rawURL = strings.TrimPrefix(rawURL, s.baseURL)
}
prefix := "/uploads/"
if strings.HasPrefix(rawURL, prefix) {
filename := strings.TrimPrefix(rawURL, prefix)
if i := strings.LastIndex(filename, "/"); i >= 0 {
return filename[i+1:]
}
return filename
}
if i := strings.LastIndex(rawURL, "/"); i >= 0 {
return rawURL[i+1:]
}
return rawURL
}
func (s *LocalStorage) Delete(ctx context.Context, key string) {
if key == "" {
return
}
filePath := filepath.Join(s.uploadDir, key)
if err := os.Remove(filePath); err != nil {
if !os.IsNotExist(err) {
slog.Error("local storage Delete failed", "key", key, "error", err)
}
}
}
func (s *LocalStorage) DeleteKeys(ctx context.Context, keys []string) {
for _, key := range keys {
s.Delete(ctx, key)
}
}
func (s *LocalStorage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
dest := filepath.Join(s.uploadDir, key)
if err := os.WriteFile(dest, data, 0644); err != nil {
return "", fmt.Errorf("local storage WriteFile: %w", err)
}
if s.baseURL != "" {
return fmt.Sprintf("%s/uploads/%s", s.baseURL, key), nil
}
return fmt.Sprintf("/uploads/%s", key), nil
}
func (s *LocalStorage) GetFilePath(key string) string {
return filepath.Join(s.uploadDir, key)
}
func (s *LocalStorage) ServeFile(w http.ResponseWriter, r *http.Request, filename string) {
filePath := filepath.Join(s.uploadDir, filename)
slog.Info("serving file", "filename", filename, "filepath", filePath)
// Use http.ServeFile which has built-in path traversal protection
// It sanitizes the path and prevents access outside the directory
http.ServeFile(w, r, filePath)
}
func (s *LocalStorage) UploadFromReader(ctx context.Context, key string, reader io.Reader, contentType string, filename string) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", fmt.Errorf("local storage ReadAll: %w", err)
}
return s.Upload(ctx, key, data, contentType, filename)
}

View File

@@ -1,214 +0,0 @@
package storage
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestLocalStorage_Upload(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
os.Unsetenv("LOCAL_UPLOAD_BASE_URL")
// No LOCAL_UPLOAD_BASE_URL set - should return relative path
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
contentType := "text/plain"
filename := "test.txt"
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
expectedLink := "/uploads/test-key.txt"
if link != expectedLink {
t.Errorf("link = %q, want %q", link, expectedLink)
}
filePath := filepath.Join(tmpDir, "test-key.txt")
stored, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if string(stored) != string(data) {
t.Errorf("stored data = %q, want %q", stored, data)
}
}
func TestLocalStorage_Upload_WithBaseURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
contentType := "text/plain"
filename := "test.txt"
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
// When LOCAL_UPLOAD_BASE_URL is set, should return full URL
expectedLink := "http://localhost:8080/uploads/test-key.txt"
if link != expectedLink {
t.Errorf("link = %q, want %q", link, expectedLink)
}
filePath := filepath.Join(tmpDir, "test-key.txt")
stored, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if string(stored) != string(data) {
t.Errorf("stored data = %q, want %q", stored, data)
}
}
func TestLocalStorage_Delete(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
_, err := store.Upload(ctx, "delete-me.txt", data, "text/plain", "delete-me.txt")
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
filePath := filepath.Join(tmpDir, "delete-me.txt")
if _, err := os.ReadFile(filePath); err != nil {
t.Fatalf("file should exist: %v", err)
}
store.Delete(ctx, "delete-me.txt")
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
t.Errorf("file should be deleted")
}
}
func TestLocalStorage_KeyFromURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
// No baseURL set
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
tests := []struct {
name string
rawURL string
expected string
}{
{"local URL format", "/uploads/abc123.png", "abc123.png"},
{"local URL with subdir", "/uploads/2024/01/image.jpg", "image.jpg"},
{"just filename", "abc123.png", "abc123.png"},
{"full path", "/some/path/to/file.pdf", "file.pdf"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := store.KeyFromURL(tc.rawURL)
if got != tc.expected {
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
}
})
}
}
func TestLocalStorage_KeyFromURL_WithBaseURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
tests := []struct {
name string
rawURL string
expected string
}{
{"full URL format", "http://localhost:8080/uploads/abc123.png", "abc123.png"},
{"full URL with subdir", "http://localhost:8080/uploads/2024/01/image.jpg", "image.jpg"},
{"local URL format still works", "/uploads/abc123.png", "abc123.png"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := store.KeyFromURL(tc.rawURL)
if got != tc.expected {
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
}
})
}
}
func TestLocalStorage_DeleteKeys(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
keys := []string{"file1.txt", "file2.txt", "file3.txt"}
for _, key := range keys {
_, err := store.Upload(ctx, key, data, "text/plain", key)
if err != nil {
t.Fatalf("Upload %s failed: %v", key, err)
}
}
store.DeleteKeys(ctx, keys)
for _, key := range keys {
filePath := filepath.Join(tmpDir, key)
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
t.Errorf("file %s should be deleted", key)
}
}
}
func TestLocalStorage_KeyFromURL_Empty(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if got := store.KeyFromURL(""); got != "" {
t.Errorf("KeyFromURL(\"\") = %q, want empty string", got)
}
}

View File

@@ -32,7 +32,7 @@ type S3Storage struct {
func NewS3StorageFromEnv() *S3Storage {
bucket := os.Getenv("S3_BUCKET")
if bucket == "" {
slog.Info("S3_BUCKET not set, cloud upload disabled")
slog.Info("S3_BUCKET not set, file upload disabled")
return nil
}
@@ -88,6 +88,21 @@ func (s *S3Storage) storageClass() types.StorageClass {
return types.StorageClassIntelligentTiering
}
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
func sanitizeFilename(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// KeyFromURL extracts the S3 object key from a CDN or bucket URL.
// e.g. "https://multica-static.copilothub.ai/abc123.png" → "abc123.png"
func (s *S3Storage) KeyFromURL(rawURL string) string {
@@ -135,6 +150,16 @@ func (s *S3Storage) DeleteKeys(ctx context.Context, keys []string) {
}
}
// isInlineContentType returns true for media types that browsers should
// display inline (images, video, audio, PDF). Everything else triggers a
// download via Content-Disposition: attachment.
func isInlineContentType(ct string) bool {
return strings.HasPrefix(ct, "image/") ||
strings.HasPrefix(ct, "video/") ||
strings.HasPrefix(ct, "audio/") ||
ct == "application/pdf"
}
func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
safe := sanitizeFilename(filename)
disposition := "attachment"

View File

@@ -1,12 +0,0 @@
package storage
import (
"context"
)
type Storage interface {
Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error)
Delete(ctx context.Context, key string)
DeleteKeys(ctx context.Context, keys []string)
KeyFromURL(rawURL string) string
}

View File

@@ -1,30 +0,0 @@
package storage
import (
"strings"
)
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
func sanitizeFilename(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// isInlineContentType returns true for media types that browsers should
// display inline (images, video, audio, PDF). Everything else triggers a
// download via Content-Disposition: attachment.
func isInlineContentType(ct string) bool {
return strings.HasPrefix(ct, "image/") ||
strings.HasPrefix(ct, "video/") ||
strings.HasPrefix(ct, "audio/") ||
ct == "application/pdf"
}

View File

@@ -95,17 +95,17 @@ func (q *Queries) GetRuntimeUsageSummary(ctx context.Context, runtimeID pgtype.U
const listRuntimeUsage = `-- name: ListRuntimeUsage :many
SELECT id, runtime_id, date, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at, updated_at FROM runtime_usage
WHERE runtime_id = $1
AND date >= $2
ORDER BY date DESC
LIMIT $2
`
type ListRuntimeUsageParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
Since pgtype.Date `json:"since"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListRuntimeUsage(ctx context.Context, arg ListRuntimeUsageParams) ([]RuntimeUsage, error) {
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Since)
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Limit)
if err != nil {
return nil, err
}

View File

@@ -12,8 +12,8 @@ DO UPDATE SET
-- name: ListRuntimeUsage :many
SELECT * FROM runtime_usage
WHERE runtime_id = $1
AND date >= $2
ORDER BY date DESC;
ORDER BY date DESC
LIMIT $2;
-- name: GetRuntimeUsageSummary :many
SELECT provider, model,