mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
* feat(storage): add GetReader to Storage interface Adds a streaming read method to the Storage abstraction so callers can pull object bytes without forcing a full in-memory load. S3Storage wraps GetObject; LocalStorage opens the file with path-traversal and sidecar guards. Tests cover happy path, traversal rejection, sidecar rejection, and missing key. Used in the next commit by the attachment-preview proxy endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): add attachment preview proxy endpoint GET /api/attachments/{id}/content streams the raw bytes of a text-previewable attachment back to the client. Exists to (a) bypass CloudFront CORS, which is not configured on the CDN, and (b) bypass Content-Disposition: attachment which Chromium honors for iframe document loads. Media types (image/video/audio/pdf) intentionally do NOT go through this endpoint — clients render them directly from the signed CloudFront download_url, which is already served with Content-Disposition: inline. Hard cap: 2 MB. Larger files return 413. Anything outside the text whitelist returns 415. The whitelist (isTextPreviewable) mirrors the client-side dispatcher; the cross-reference comment in file.go flags the manual sync until a JSON SSOT generator lands. Response always uses Content-Type: text/plain; charset=utf-8 so a hostile HTML payload can't be re-interpreted as a document. The original MIME ships via X-Original-Content-Type for client dispatch. Cache-Control: no-store so revoked attachment access takes effect immediately on the next request. Tests cover happy path (md), extension fallback when content_type is generic, 415 (pdf), 413 (>2MB), foreign workspace (404 isolation), and the isTextPreviewable table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core/api): add getAttachmentTextContent + preview error types Adds an ApiClient method that fetches the text body of an attachment via the new /api/attachments/{id}/content proxy. Two typed errors — PreviewTooLargeError (413) and PreviewUnsupportedError (415) — let the preview modal render specific fallbacks instead of a generic failure. Refactors the private fetch() into a shared fetchRaw() helper so the new method inherits the standard infra: auth headers, 401 → handleUnauthorized recovery, X-Request-ID, error logging, and the ApiError contract. The previous draft bypassed all of these by calling window.fetch directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(views/editor): add AttachmentPreviewModal + Eye entry points In-app preview for non-image attachments. An Eye icon now sits next to the existing Download button on file cards / readonly file cards / the standalone AttachmentList. Clicking it opens a full-screen modal that dispatches by content_type: pdf: <iframe src={download_url}> — Chromium PDFium video/*: <video controls src={download_url}> — native controls audio/*: <audio controls src={download_url}> — native controls md: <ReadonlyContent> — full markdown pipeline html: <iframe srcdoc sandbox=""> — fully restricted text: <code class="hljs"> — lowlight highlight Media types render directly from the signed CloudFront download_url (server marks them inline-disposition). Text types fetch through the new /api/attachments/{id}/content proxy via TanStack Query, wrapped in useAttachmentPreview() so each entry point owns its own modal state without depending on a global Provider mount. Modal sizing: max-w-6xl × min(90vh, 100vh - 2rem) — slightly larger than create-issue's max-w-4xl since PDF / video need room, but capped to viewport on small screens. Sub-renderers use h-full to follow the fixed modal height instead of viewport-relative units. Images are intentionally NOT touched — the existing ImageLightbox (extensions/image-view.tsx) already handles them correctly. The new modal would be churn without user-visible benefit. Adds i18n keys under attachment.* (en + zh-Hans) and registers Preview/Download/Upload in the conventions glossary so future translations stay consistent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(desktop): enable Chromium PDF viewer for attachment preview Adds webPreferences.plugins: true to the main BrowserWindow so the bundled Chromium PDFium plugin activates inside iframes — required for the attachment preview modal's PDF dispatch. Default is false in Electron; without it <iframe src=*.pdf> renders blank. Security trade-off, accepted intentionally and documented inline: 1. This window already runs with webSecurity: false + sandbox: false, so plugins: true does NOT meaningfully widen the renderer's attack surface beyond what is already accepted. 2. The only PDFs that reach an iframe here are signed CloudFront URLs we ourselves issued; user-supplied URLs are routed through setWindowOpenHandler → openExternalSafely and cannot land in this renderer. 3. Chromium's PDFium plugin is itself sandboxed and only handles application/pdf — no Flash/Java/other historical plugin surfaces. If we ever tighten webSecurity / sandbox, the follow-up is to host the PDF viewer in a dedicated BrowserView with plugins scoped to that view, keeping the main renderer plugin-free. Old desktop builds ship without the preview modal, so the Eye button never appears and PDF preview is gated by the same release — zero regression risk for users on stale clients. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
513 lines
15 KiB
Go
513 lines
15 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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", "2024/01/image.jpg"},
|
|
{"local URL with workspace prefix", "/uploads/workspaces/ws-123/abc.png", "workspaces/ws-123/abc.png"},
|
|
{"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", "2024/01/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)
|
|
}
|
|
}
|
|
|
|
// TestLocalStorage_ServeFile_ContentDispositionFromSidecar verifies the fix
|
|
// for issue #2442: downloads served from /uploads/* must carry the original
|
|
// uploaded filename in Content-Disposition, mirroring the S3 Upload path's
|
|
// existing ContentDisposition behavior. Without this, browsers fall back to
|
|
// the storage-key basename (UUID + ext) for the download filename.
|
|
func TestLocalStorage_ServeFile_ContentDispositionFromSidecar(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
|
|
|
store := NewLocalStorageFromEnv()
|
|
if store == nil {
|
|
t.Fatal("NewLocalStorageFromEnv returned nil")
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
key string
|
|
contentType string
|
|
filename string
|
|
wantHeader string
|
|
}{
|
|
{
|
|
name: "attachment for non-inline type",
|
|
key: "workspaces/ws-1/abc.xlsx",
|
|
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
filename: "Bwave JE_V1.xlsx",
|
|
wantHeader: `attachment; filename="Bwave JE_V1.xlsx"`,
|
|
},
|
|
{
|
|
name: "inline for image",
|
|
key: "workspaces/ws-1/def.png",
|
|
contentType: "image/png",
|
|
filename: "screenshot 2026-05-11.png",
|
|
wantHeader: `inline; filename="screenshot 2026-05-11.png"`,
|
|
},
|
|
{
|
|
name: "filename with header-injection characters is sanitized",
|
|
key: "workspaces/ws-1/ghi.txt",
|
|
contentType: "text/plain",
|
|
filename: "weird\";name.txt",
|
|
wantHeader: `attachment; filename="weird__name.txt"`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
if _, err := store.Upload(ctx, tc.key, []byte("body"), tc.contentType, tc.filename); err != nil {
|
|
t.Fatalf("Upload failed: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/uploads/"+tc.key, nil)
|
|
rec := httptest.NewRecorder()
|
|
store.ServeFile(rec, req, tc.key)
|
|
|
|
got := rec.Header().Get("Content-Disposition")
|
|
if got != tc.wantHeader {
|
|
t.Errorf("Content-Disposition = %q, want %q", got, tc.wantHeader)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLocalStorage_ServeFile_NoSidecarFallback documents the graceful
|
|
// degradation path: files uploaded before the sidecar landed (or written
|
|
// out-of-band) have no .meta.json on disk and ServeFile must not set
|
|
// Content-Disposition — leaving the existing pre-fix behavior intact.
|
|
func TestLocalStorage_ServeFile_NoSidecarFallback(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
|
|
|
store := NewLocalStorageFromEnv()
|
|
if store == nil {
|
|
t.Fatal("NewLocalStorageFromEnv returned nil")
|
|
}
|
|
|
|
key := "legacy-no-sidecar.txt"
|
|
if err := os.WriteFile(filepath.Join(tmpDir, key), []byte("body"), 0644); err != nil {
|
|
t.Fatalf("seed write failed: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/uploads/"+key, nil)
|
|
rec := httptest.NewRecorder()
|
|
store.ServeFile(rec, req, key)
|
|
|
|
if got := rec.Header().Get("Content-Disposition"); got != "" {
|
|
t.Errorf("Content-Disposition = %q, want empty (no sidecar fallback)", got)
|
|
}
|
|
}
|
|
|
|
// TestLocalStorage_ServeFile_RejectsSidecarSuffix verifies that the sidecar
|
|
// JSON itself is not addressable via /uploads/*. The sidecar is an
|
|
// implementation detail; exposing it would turn the filename + content-type
|
|
// pair into a stable read API and make any future ACL change leakier than
|
|
// the data file it sits next to.
|
|
func TestLocalStorage_ServeFile_RejectsSidecarSuffix(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()
|
|
if _, err := store.Upload(ctx, "abc.xlsx", []byte("body"), "text/plain", "real.xlsx"); err != nil {
|
|
t.Fatalf("Upload failed: %v", err)
|
|
}
|
|
|
|
sidecarKey := "abc.xlsx" + metaSuffix
|
|
req := httptest.NewRequest(http.MethodGet, "/uploads/"+sidecarKey, nil)
|
|
rec := httptest.NewRecorder()
|
|
store.ServeFile(rec, req, sidecarKey)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Content-Disposition"); got != "" {
|
|
t.Errorf("Content-Disposition = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestLocalStorage_ServeFile_RejectsPathTraversal documents that a key
|
|
// pointing outside uploadDir is rejected before any sidecar read. Without
|
|
// this guard, readLocalMeta would attempt a disk read at <some-path>.meta.json
|
|
// before http.ServeFile's own ".." check fires on r.URL.Path.
|
|
func TestLocalStorage_ServeFile_RejectsPathTraversal(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
|
|
|
store := NewLocalStorageFromEnv()
|
|
if store == nil {
|
|
t.Fatal("NewLocalStorageFromEnv returned nil")
|
|
}
|
|
|
|
// Seed a sidecar OUTSIDE uploadDir so we'd notice if it were read: the
|
|
// header would carry "leaked.xlsx". Locating the sibling inside the
|
|
// per-test TempDir keeps the test self-contained — no real /etc reads.
|
|
parentDir := filepath.Dir(tmpDir)
|
|
leakedBase := filepath.Join(parentDir, "leaked-target")
|
|
if err := os.WriteFile(leakedBase+metaSuffix, []byte(`{"filename":"leaked.xlsx","content_type":"text/plain"}`), 0644); err != nil {
|
|
t.Fatalf("seed leaked sidecar failed: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
os.Remove(leakedBase + metaSuffix)
|
|
})
|
|
|
|
traversal := "../" + filepath.Base(leakedBase)
|
|
req := httptest.NewRequest(http.MethodGet, "/uploads/"+traversal, nil)
|
|
rec := httptest.NewRecorder()
|
|
store.ServeFile(rec, req, traversal)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Content-Disposition"); got != "" {
|
|
t.Errorf("Content-Disposition = %q, want empty (sidecar must not leak)", got)
|
|
}
|
|
}
|
|
|
|
// TestLocalStorage_Upload_SkipsSidecarWhenFilenameEmpty verifies the tighter
|
|
// Upload gate: a write with no filename has nothing useful to preserve, so
|
|
// we shouldn't litter the upload directory with content-type-only sidecars
|
|
// that ServeFile would ignore anyway.
|
|
func TestLocalStorage_Upload_SkipsSidecarWhenFilenameEmpty(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()
|
|
key := "no-filename.bin"
|
|
if _, err := store.Upload(ctx, key, []byte("body"), "application/octet-stream", ""); err != nil {
|
|
t.Fatalf("Upload failed: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(tmpDir, key+metaSuffix)); !os.IsNotExist(err) {
|
|
t.Errorf("sidecar should not exist when filename is empty, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
// TestLocalStorage_Delete_RemovesSidecar verifies the cleanup half of the
|
|
// fix: when the upload is deleted, its sidecar metadata file disappears too.
|
|
// Otherwise the upload directory grows orphan .meta.json files forever.
|
|
func TestLocalStorage_Delete_RemovesSidecar(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()
|
|
key := "deleteme.txt"
|
|
if _, err := store.Upload(ctx, key, []byte("body"), "text/plain", "original.txt"); err != nil {
|
|
t.Fatalf("Upload failed: %v", err)
|
|
}
|
|
sidecar := filepath.Join(tmpDir, key+metaSuffix)
|
|
if _, err := os.Stat(sidecar); err != nil {
|
|
t.Fatalf("sidecar should exist after Upload: %v", err)
|
|
}
|
|
|
|
store.Delete(ctx, key)
|
|
|
|
if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
|
|
t.Errorf("sidecar should be removed after Delete, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
// GetReader returns the uploaded bytes verbatim — used by the preview proxy.
|
|
func TestLocalStorage_GetReader_RoundTrip(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()
|
|
key := "preview.md"
|
|
body := []byte("# hello\nworld\n")
|
|
if _, err := store.Upload(ctx, key, body, "text/markdown", "preview.md"); err != nil {
|
|
t.Fatalf("Upload failed: %v", err)
|
|
}
|
|
|
|
rc, err := store.GetReader(ctx, key)
|
|
if err != nil {
|
|
t.Fatalf("GetReader: %v", err)
|
|
}
|
|
defer rc.Close()
|
|
got, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("io.ReadAll: %v", err)
|
|
}
|
|
if string(got) != string(body) {
|
|
t.Errorf("body = %q, want %q", got, body)
|
|
}
|
|
}
|
|
|
|
// Refuses path traversal at storage layer so callers don't need to defend it.
|
|
func TestLocalStorage_GetReader_RejectsTraversal(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
|
|
|
store := NewLocalStorageFromEnv()
|
|
if store == nil {
|
|
t.Fatal("NewLocalStorageFromEnv returned nil")
|
|
}
|
|
|
|
if rc, err := store.GetReader(context.Background(), "../../../etc/passwd"); err == nil {
|
|
rc.Close()
|
|
t.Fatal("GetReader should refuse traversal keys")
|
|
}
|
|
}
|
|
|
|
// The sidecar JSON is an internal detail. Allowing /content to read it via a
|
|
// crafted key would expose the original filename + content-type stored next
|
|
// to every upload.
|
|
func TestLocalStorage_GetReader_RejectsSidecarSuffix(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
|
|
|
store := NewLocalStorageFromEnv()
|
|
if store == nil {
|
|
t.Fatal("NewLocalStorageFromEnv returned nil")
|
|
}
|
|
|
|
if rc, err := store.GetReader(context.Background(), "some-key.txt"+metaSuffix); err == nil {
|
|
rc.Close()
|
|
t.Fatal("GetReader should refuse sidecar keys")
|
|
}
|
|
}
|
|
|
|
// Missing key surfaces as a plain error — the handler maps it to 404.
|
|
func TestLocalStorage_GetReader_MissingKey(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
|
|
|
store := NewLocalStorageFromEnv()
|
|
if store == nil {
|
|
t.Fatal("NewLocalStorageFromEnv returned nil")
|
|
}
|
|
|
|
if rc, err := store.GetReader(context.Background(), "nonexistent.txt"); err == nil {
|
|
rc.Close()
|
|
t.Fatal("GetReader should error on missing key")
|
|
}
|
|
}
|