Files
Algis DumbrisandClaude Opus 4.6 8e2294e19d feat: implement attachments with content-addressable storage
Add file attachment support with SHA-256 content-addressable storage,
automatic deduplication, MIME detection, and garbage collection for
orphaned files. Includes MCP tools (upload_attachment, download_attachment,
gc_attachments), REST API endpoints for Web UI, and comprehensive tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:12:27 +02:00

146 lines
3.8 KiB
Go

package attachments
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
)
// CAS implements content-addressable storage on the local filesystem.
// Files are stored at {baseDir}/{hash[0:2]}/{hash[2:4]}/{hash}.
type CAS struct {
baseDir string
logger *slog.Logger
mu sync.Mutex // protects concurrent writes of the same hash
}
// NewCAS creates a new content-addressable store rooted at baseDir.
// The directory tree is created automatically if it does not exist.
func NewCAS(baseDir string, logger *slog.Logger) (*CAS, error) {
if err := os.MkdirAll(baseDir, 0o755); err != nil {
return nil, fmt.Errorf("create CAS base directory: %w", err)
}
return &CAS{
baseDir: baseDir,
logger: logger.With("component", "cas"),
}, nil
}
// shardPath returns the full filesystem path for the given hash.
func (c *CAS) shardPath(hash string) string {
return filepath.Join(c.baseDir, hash[0:2], hash[2:4], hash)
}
// Write streams content from r, computing its SHA-256 hash while writing to
// a temporary file. On success the temp file is atomically renamed to the
// content-addressed path. If the file already exists (dedup), the temp file
// is removed and the existing hash is returned.
//
// Returns ErrEmptyFile if r yields zero bytes.
func (c *CAS) Write(r io.Reader) (hash string, size int64, err error) {
// Write to a temp file while computing the hash.
tmpFile, err := os.CreateTemp(c.baseDir, ".cas-upload-*")
if err != nil {
return "", 0, fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
// Ensure cleanup on any error path.
defer func() {
if err != nil {
tmpFile.Close()
os.Remove(tmpPath)
}
}()
hasher := sha256.New()
w := io.MultiWriter(tmpFile, hasher)
size, err = io.Copy(w, r)
if err != nil {
return "", 0, fmt.Errorf("write content: %w", err)
}
if size == 0 {
err = ErrEmptyFile
return "", 0, err
}
if err = tmpFile.Close(); err != nil {
return "", 0, fmt.Errorf("close temp file: %w", err)
}
hash = hex.EncodeToString(hasher.Sum(nil))
destPath := c.shardPath(hash)
c.mu.Lock()
defer c.mu.Unlock()
// Check for dedup: if the file already exists, skip the rename.
if _, statErr := os.Stat(destPath); statErr == nil {
os.Remove(tmpPath)
c.logger.Debug("dedup: file already exists", "hash", hash)
return hash, size, nil
}
// Create shard directories.
destDir := filepath.Dir(destPath)
if err = os.MkdirAll(destDir, 0o755); err != nil {
return "", 0, fmt.Errorf("create shard directory: %w", err)
}
// Atomic rename.
if err = os.Rename(tmpPath, destPath); err != nil {
return "", 0, fmt.Errorf("rename temp file: %w", err)
}
c.logger.Info("file stored", "hash", hash, "size", size)
return hash, size, nil
}
// Read opens the file identified by hash and returns a ReadCloser.
// Returns ErrNotFound if the file does not exist on disk.
func (c *CAS) Read(hash string) (io.ReadCloser, error) {
path := c.shardPath(hash)
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("open file: %w", err)
}
return f, nil
}
// Exists returns true if the file identified by hash exists on disk.
func (c *CAS) Exists(hash string) bool {
_, err := os.Stat(c.shardPath(hash))
return err == nil
}
// Delete removes the file identified by hash from disk.
// Returns the size of the deleted file, or 0 if the file did not exist.
func (c *CAS) Delete(hash string) (int64, error) {
path := c.shardPath(hash)
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("stat file: %w", err)
}
size := info.Size()
if err := os.Remove(path); err != nil {
return 0, fmt.Errorf("remove file: %w", err)
}
c.logger.Info("file deleted", "hash", hash, "size", size)
return size, nil
}