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

168 lines
4.7 KiB
Go

package attachments
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
)
// SQLiteStore implements Store backed by modernc.org/sqlite.
type SQLiteStore struct {
db *sql.DB
logger *slog.Logger
}
// NewSQLiteStore creates a new SQLite-backed attachment metadata store.
func NewSQLiteStore(db *sql.DB, logger *slog.Logger) *SQLiteStore {
return &SQLiteStore{
db: db,
logger: logger.With("component", "attachment-store"),
}
}
// InsertMetadata inserts a new attachment metadata row.
func (s *SQLiteStore) InsertMetadata(ctx context.Context, a *Attachment) error {
const query = `INSERT INTO attachments (hash, original_filename, size, mime_type, message_id, uploaded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
now := time.Now().UTC()
result, err := s.db.ExecContext(ctx, query,
a.Hash,
a.OriginalFilename,
a.Size,
a.MIMEType,
a.MessageID,
a.UploadedBy,
now,
)
if err != nil {
return fmt.Errorf("insert attachment metadata: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("get last insert id: %w", err)
}
a.ID = id
a.CreatedAt = now
s.logger.Debug("attachment metadata inserted",
"id", id,
"hash", a.Hash,
"filename", a.OriginalFilename,
)
return nil
}
// GetByHash returns all attachment metadata rows matching the given hash.
func (s *SQLiteStore) GetByHash(ctx context.Context, hash string) ([]*Attachment, error) {
const query = `SELECT id, hash, original_filename, size, mime_type, message_id, uploaded_by, created_at
FROM attachments WHERE hash = ? ORDER BY created_at DESC`
rows, err := s.db.QueryContext(ctx, query, hash)
if err != nil {
return nil, fmt.Errorf("query attachments by hash: %w", err)
}
defer rows.Close()
return scanAttachments(rows)
}
// GetByMessageID returns all attachment metadata rows for a given message.
func (s *SQLiteStore) GetByMessageID(ctx context.Context, messageID int64) ([]*Attachment, error) {
const query = `SELECT id, hash, original_filename, size, mime_type, message_id, uploaded_by, created_at
FROM attachments WHERE message_id = ? ORDER BY created_at ASC`
rows, err := s.db.QueryContext(ctx, query, messageID)
if err != nil {
return nil, fmt.Errorf("query attachments by message_id: %w", err)
}
defer rows.Close()
return scanAttachments(rows)
}
// DeleteByHash removes all metadata rows matching the given hash.
func (s *SQLiteStore) DeleteByHash(ctx context.Context, hash string) error {
const query = `DELETE FROM attachments WHERE hash = ?`
result, err := s.db.ExecContext(ctx, query, hash)
if err != nil {
return fmt.Errorf("delete attachment metadata: %w", err)
}
n, _ := result.RowsAffected()
s.logger.Debug("attachment metadata deleted", "hash", hash, "rows", n)
return nil
}
// FindOrphanHashes returns hashes that have no valid message reference.
// A hash is orphaned if all its metadata rows have message_id IS NULL or
// the referenced message no longer exists.
func (s *SQLiteStore) FindOrphanHashes(ctx context.Context) ([]string, error) {
const query = `SELECT DISTINCT a.hash FROM attachments a
WHERE a.message_id IS NULL
OR a.message_id NOT IN (SELECT id FROM messages)
GROUP BY a.hash
HAVING COUNT(CASE WHEN a.message_id IN (SELECT id FROM messages) THEN 1 END) = 0`
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("find orphan hashes: %w", err)
}
defer rows.Close()
var hashes []string
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, fmt.Errorf("scan orphan hash: %w", err)
}
hashes = append(hashes, h)
}
return hashes, rows.Err()
}
// CountReferences returns the number of metadata rows referencing the hash.
func (s *SQLiteStore) CountReferences(ctx context.Context, hash string) (int64, error) {
const query = `SELECT COUNT(*) FROM attachments WHERE hash = ?`
var count int64
if err := s.db.QueryRowContext(ctx, query, hash).Scan(&count); err != nil {
return 0, fmt.Errorf("count references: %w", err)
}
return count, nil
}
// scanAttachments scans rows into a slice of Attachment pointers.
func scanAttachments(rows *sql.Rows) ([]*Attachment, error) {
var attachments []*Attachment
for rows.Next() {
a := &Attachment{}
var createdAtStr string
if err := rows.Scan(
&a.ID,
&a.Hash,
&a.OriginalFilename,
&a.Size,
&a.MIMEType,
&a.MessageID,
&a.UploadedBy,
&createdAtStr,
); err != nil {
return nil, fmt.Errorf("scan attachment: %w", err)
}
// Parse the created_at timestamp.
if t, err := time.Parse("2006-01-02 15:04:05", createdAtStr); err == nil {
a.CreatedAt = t
} else if t, err := time.Parse(time.RFC3339, createdAtStr); err == nil {
a.CreatedAt = t
}
attachments = append(attachments, a)
}
return attachments, rows.Err()
}