mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 12:18:45 +02:00
Compare commits
1 Commits
fix/storag
...
feat/cli-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
509faab19f |
@@ -22,8 +22,6 @@ 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
|
||||
|
||||
@@ -42,10 +40,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
|
||||
|
||||
39
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
39
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,39 +0,0 @@
|
||||
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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,26 +0,0 @@
|
||||
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.
|
||||
52
.github/PULL_REQUEST_TEMPLATE.md
vendored
52
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,56 +1,34 @@
|
||||
## What does this PR do?
|
||||
## What
|
||||
|
||||
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
|
||||
<!-- What does this PR do? Keep it to 1-3 sentences. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Why is this change needed? Link the related issue. -->
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
|
||||
|
||||
Closes #
|
||||
Closes #<!-- issue number -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] 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)
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor / code improvement
|
||||
- [ ] Documentation
|
||||
- [ ] CI / infrastructure
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!-- List the specific changes. Include file paths for code changes. -->
|
||||
|
||||
-
|
||||
- [ ] Other (describe below)
|
||||
|
||||
## How to Test
|
||||
|
||||
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
|
||||
|
||||
## 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
|
||||
## AI Disclosure (optional)
|
||||
|
||||
<!-- 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. -->
|
||||
<!-- 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. -->
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -48,5 +48,3 @@ _features/
|
||||
*.dmg
|
||||
*.app
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
|
||||
@@ -18,6 +18,7 @@ The open-source managed agents platform.<br/>
|
||||
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](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)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](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)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -53,8 +52,6 @@ export function LandingHero() {
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<InstallCommand />
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center gap-8">
|
||||
@@ -90,64 +87,6 @@ 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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -241,7 +241,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user