Compare commits

..

9 Commits

Author SHA1 Message Date
Jiang Bohan
bd32966b61 fix(sweeper): add error logging and dedup for issue reset
- Log a warning when HasActiveTaskForIssue fails, matching the existing
  pattern for UpdateIssueStatus errors. Silent failures here make
  debugging DB issues unnecessarily difficult.
- Track processed issues to skip redundant GetIssue + HasActiveTaskForIssue
  queries when multiple tasks for the same issue are swept in one cycle.
2026-04-12 14:09:44 +08:00
pradeep7127
bd42552854 fix(sweeper): reset in_progress issues to todo after stale task sweep (#747)
fix(sweeper): reset in_progress issues to todo after stale task sweep
2026-04-12 14:08:54 +08:00
Bohan Jiang
31eeb00b59 fix(storage): clean up variable shadowing and dead code (#761)
- Rename `filepath` local var to `dest` in LocalStorage.Upload to avoid
  shadowing the path/filepath package import
- Remove unused detectContentType and overrideContentType functions from
  util.go (no longer needed after ServeFile switched to http.ServeFile)
2026-04-12 14:06:46 +08:00
Antar Das
d32c419b6d feat(storage): add local file storage fallback (#710)
* feat(storage): add local file storage fallback

- Add local storage implementation for file uploads
- Update .env.example with LOCAL_UPLOAD_DIR and LOCAL_UPLOAD_BASE_URL
- Integrate local storage into server router and handlers
- Add storage abstraction layer with util functions

* ♻️ refactor(storage): improve path handling and file serving

switch from path to filepath for better cross-platform support and replace manual file serving logic with http.ServeFile to enhance security against path traversal. update unit tests to use t.Setenv for cleaner environment variable management.
2026-04-12 14:04:22 +08:00
Jiayuan Zhang
f31a322978 chore: add issue templates and improve PR template (#759)
* 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.

* 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.

* 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.

* 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.

* 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:58:18 +08:00
Jiayuan Zhang
5bae3368d7 feat(landing): add install command copy block to hero section (#743)
Adds a terminal-style one-click copy block below the CTA buttons showing
the curl install command, with a copy-to-clipboard button that shows a
checkmark on success.
2026-04-12 02:42:05 +08:00
Jiayuan Zhang
f100b5b707 fix(auth): graceful email degradation for self-hosting (#742)
* fix(auth): log email send errors and gracefully degrade in non-production

In non-production environments (APP_ENV != "production"), if sending the
verification code email fails, log the error as a warning and still return
success. This lets self-hosting users log in with the master code (888888)
even when their Resend configuration is incomplete (e.g. unverified from-domain).

In production, the behavior is unchanged — email failures return 500.

Also adds guidance in .env.example about RESEND_FROM_EMAIL for self-hosters.

Closes #723

* fix(auth): remove APP_ENV degradation, keep error logging only

Remove the APP_ENV-based graceful degradation for email send failures
— it's risky if users forget to set APP_ENV=production. Instead, always
return 500 on email failure (safe for production) and rely on the error
log (slog.Error) with the actual Resend error for debugging.

Self-hosters who don't need real emails should leave RESEND_API_KEY empty
(codes print to stdout, master code 888888 works).
2026-04-12 02:30:01 +08:00
Jiayuan Zhang
701399536f feat(cli): enhance version command with JSON output and build info (#740)
Add --output json flag, build date, Go version, and OS/arch to the
version command. Update Makefile and goreleaser to inject build date.
2026-04-12 02:18:08 +08:00
Jiayuan Zhang
4ca607f888 chore: remove Apache 2.0 license badge from READMEs (#739) 2026-04-12 02:11:45 +08:00
19 changed files with 761 additions and 54 deletions

View File

@@ -22,6 +22,8 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -40,6 +42,10 @@ 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

39
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: "Bug Report"
description: Report a bug — something that's broken, crashes, or behaves incorrectly.
title: "[Bug]: "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug and what you expected instead. Screenshots, error messages, or screen recordings are welcome.
placeholder: |
When I do X, Y happens. I expected Z instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: How can we trigger this bug?
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots (optional)
description: If applicable, add screenshots or screen recordings to help explain the problem.
- type: textarea
id: context
attributes:
label: Additional context (optional)
description: Environment info, logs, or anything else that might help.
render: shell

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: true

View File

@@ -0,0 +1,26 @@
name: "Feature Request"
description: Suggest a new feature or improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: What do you want and why?
description: Describe the problem you're trying to solve or the improvement you'd like to see.
placeholder: |
I'm trying to do X but there's no way to...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution (optional)
description: If you have an idea for how this should work, describe it here.
- type: textarea
id: screenshots
attributes:
label: Screenshots / mockups (optional)
description: If applicable, add screenshots, mockups, or sketches to illustrate your idea.

View File

@@ -1,34 +1,56 @@
## What
## What does this PR do?
<!-- What does this PR do? Keep it to 1-3 sentences. -->
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
## Why
<!-- Why is this change needed? Link the related issue. -->
Closes #<!-- issue number -->
## Related Issue
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
Closes #
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Refactor / code improvement (no behavior change)
- [ ] Documentation update
- [ ] Tests (adding or improving test coverage)
- [ ] CI / infrastructure
- [ ] Other (describe below)
## Changes Made
<!-- List the specific changes. Include file paths for code changes. -->
-
## How to Test
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
1.
2.
3.
## Checklist
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
## AI Disclosure (optional)
## AI Disclosure
<!-- If AI tools were used: -->
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
**Prompt / approach:**
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
## Screenshots (optional)
<!-- If applicable, add screenshots showing the change in action. -->

2
.gitignore vendored
View File

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

View File

@@ -18,7 +18,6 @@ The open-source managed agents platform.<br/>
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)

View File

@@ -18,7 +18,6 @@
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -52,6 +53,8 @@ export function LandingHero() {
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex items-center justify-center gap-8">
@@ -87,6 +90,64 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">

View File

@@ -56,9 +56,21 @@ 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, s3, cfSigner)
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
r := chi.NewRouter()
@@ -87,6 +99,14 @@ 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,12 +134,36 @@ 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,6 +300,168 @@ 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

@@ -241,6 +241,7 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
}
if err := h.EmailService.SendVerificationCode(email, code); err != nil {
slog.Error("failed to send verification code", "email", email, "error", err)
writeError(w, http.StatusInternalServerError, "failed to send verification code")
return
}

View File

@@ -11,7 +11,6 @@ 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"
@@ -19,6 +18,7 @@ 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.S3Storage
Storage storage.Storage
CFSigner *auth.CloudFrontSigner
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, s3 *storage.S3Storage, cfSigner *auth.CloudFrontSigner) *Handler {
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, 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: s3,
Storage: store,
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

@@ -0,0 +1,114 @@
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

@@ -0,0 +1,214 @@
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, file upload disabled")
slog.Info("S3_BUCKET not set, cloud upload disabled")
return nil
}
@@ -88,21 +88,6 @@ 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 {
@@ -150,16 +135,6 @@ 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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,30 @@
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"
}