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>
168 lines
4.7 KiB
Go
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()
|
|
}
|