mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 04:08:53 +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>
232 lines
7.5 KiB
Go
232 lines
7.5 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
type LocalStorage struct {
|
|
uploadDir string
|
|
baseURL string
|
|
}
|
|
|
|
// metaSuffix is the on-disk extension for the sidecar JSON file that
|
|
// captures an upload's original filename and sniffed content type. The
|
|
// sidecar exists so ServeFile can set Content-Disposition the way S3's
|
|
// PutObject path already does, instead of letting the browser fall back
|
|
// to the storage-key basename for the download filename.
|
|
const metaSuffix = ".meta.json"
|
|
|
|
type localMeta struct {
|
|
Filename string `json:"filename"`
|
|
ContentType string `json:"content_type"`
|
|
}
|
|
|
|
// 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) CdnDomain() string {
|
|
if s.baseURL == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(s.baseURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Hostname()
|
|
}
|
|
|
|
func (s *LocalStorage) KeyFromURL(rawURL string) string {
|
|
if s.baseURL != "" && strings.HasPrefix(rawURL, s.baseURL) {
|
|
rawURL = strings.TrimPrefix(rawURL, s.baseURL)
|
|
}
|
|
|
|
prefix := "/uploads/"
|
|
if idx := strings.Index(rawURL, prefix); idx >= 0 {
|
|
return rawURL[idx+len(prefix):]
|
|
}
|
|
if i := strings.LastIndex(rawURL, "/"); i >= 0 {
|
|
return rawURL[i+1:]
|
|
}
|
|
return rawURL
|
|
}
|
|
|
|
// GetReader opens the underlying file for streaming. Refuses keys that
|
|
// resolve outside uploadDir (defense against a stored key with traversal
|
|
// components) and refuses the sidecar suffix so /content can't be coaxed
|
|
// into leaking the .meta.json blob.
|
|
func (s *LocalStorage) GetReader(ctx context.Context, key string) (io.ReadCloser, error) {
|
|
if key == "" {
|
|
return nil, fmt.Errorf("local GetReader: empty key")
|
|
}
|
|
if strings.HasSuffix(key, metaSuffix) {
|
|
return nil, fmt.Errorf("local GetReader: refusing to serve sidecar key %q", key)
|
|
}
|
|
filePath := filepath.Join(s.uploadDir, key)
|
|
if !isUnder(s.uploadDir, filePath) {
|
|
return nil, fmt.Errorf("local GetReader: key escapes upload dir: %q", key)
|
|
}
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("local GetReader: %w", err)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
if err := os.Remove(filePath + metaSuffix); err != nil && !os.IsNotExist(err) {
|
|
slog.Error("local storage meta 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.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
|
return "", fmt.Errorf("local storage MkdirAll: %w", err)
|
|
}
|
|
if err := os.WriteFile(dest, data, 0644); err != nil {
|
|
return "", fmt.Errorf("local storage WriteFile: %w", err)
|
|
}
|
|
// Best-effort sidecar so ServeFile can restore the original filename in
|
|
// Content-Disposition. A failure here is logged but does not fail the
|
|
// upload — the file is still usable, just without the human-readable
|
|
// download name. Skip when there's no filename to preserve: a sidecar
|
|
// without a filename is dead weight, since ServeFile only reads it for
|
|
// that field.
|
|
if filename != "" {
|
|
body, _ := json.Marshal(localMeta{Filename: filename, ContentType: contentType})
|
|
if err := os.WriteFile(dest+metaSuffix, body, 0644); err != nil {
|
|
slog.Error("local storage meta write failed", "key", key, "error", 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) {
|
|
// The sidecar is an implementation detail of the local backend; refuse
|
|
// to serve it directly so /uploads/<key>.meta.json doesn't become a
|
|
// stable read API. Comes before any disk work so a path-traversal
|
|
// attempt at a .meta.json sibling can't trigger an out-of-tree read.
|
|
if strings.HasSuffix(filename, metaSuffix) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
filePath := filepath.Join(s.uploadDir, filename)
|
|
// filepath.Join cleans the path but doesn't enforce containment, so a
|
|
// caller passing "../etc/passwd" lands outside uploadDir. http.ServeFile
|
|
// rejects such requests on r.URL.Path, but readLocalMeta runs first —
|
|
// without this guard a crafted path could trigger a stray disk read on
|
|
// an arbitrary <some-path>.meta.json before the 400 lands.
|
|
if !isUnder(s.uploadDir, filePath) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
slog.Info("serving file", "filename", filename, "filepath", filePath)
|
|
|
|
// Mirror the S3 Upload path: when sidecar metadata exists for this key,
|
|
// set Content-Disposition with the original uploaded filename. Without
|
|
// it, browsers download the file under the storage-key basename (the
|
|
// UUID + extension) instead of the human-readable name the uploader
|
|
// chose. Uploads from before the sidecar landed have no .meta.json on
|
|
// disk and fall through to the existing behavior.
|
|
if meta, ok := readLocalMeta(filePath); ok && meta.Filename != "" {
|
|
safe := sanitizeFilename(meta.Filename)
|
|
disposition := "attachment"
|
|
if isInlineContentType(meta.ContentType) {
|
|
disposition = "inline"
|
|
}
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, safe))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// isUnder reports whether target resolves to a path inside dir (or equal to
|
|
// it). Both inputs are passed through filepath.Clean so trailing slashes and
|
|
// "." segments don't fool the comparison.
|
|
func isUnder(dir, target string) bool {
|
|
rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(target))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
func readLocalMeta(filePath string) (localMeta, bool) {
|
|
body, err := os.ReadFile(filePath + metaSuffix)
|
|
if err != nil {
|
|
return localMeta{}, false
|
|
}
|
|
var meta localMeta
|
|
if err := json.Unmarshal(body, &meta); err != nil {
|
|
return localMeta{}, false
|
|
}
|
|
return meta, true
|
|
}
|
|
|
|
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)
|
|
}
|