enki 74839c86bf
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / E2E Tests (push) Blocked by required conditions
more fuckingfixes
2025-08-28 11:02:53 -07:00

1109 lines
33 KiB
Go

package storage
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"torrentGateway/internal/config"
_ "github.com/mattn/go-sqlite3"
)
// Backend provides unified storage for chunks, blobs, and metadata
type Backend struct {
db *sql.DB
chunkDir string
blobDir string
chunkSize int64
config *config.Config
}
// FileMetadata represents metadata about stored files
type FileMetadata struct {
Hash string
OriginalName string
Size int64
ChunkCount int
ContentType string
StorageType string
OwnerPubkey string
AccessLevel string
InfoHash string
CreatedAt time.Time
AccessCount int64
LastAccess time.Time
}
// ChunkInfo represents information about a file chunk
type ChunkInfo struct {
FileHash string
ChunkIndex int
ChunkHash string
Size int64
Offset int64
}
// BlobInfo represents information about a Blossom blob
type BlobInfo struct {
Hash string
Size int64
CreatedAt time.Time
MimeType string
}
// NewBackend creates a new unified storage backend
func NewBackend(dbPath, chunkDir, blobDir string, chunkSize int64, cfg *config.Config) (*Backend, error) {
// Create directories
if err := os.MkdirAll(chunkDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create chunk directory: %w", err)
}
if err := os.MkdirAll(blobDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create blob directory: %w", err)
}
// Ensure database directory exists
dbDir := filepath.Dir(dbPath)
if err := os.MkdirAll(dbDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
// Open database
db, err := sql.Open("sqlite3", dbPath+"?cache=shared&mode=rwc")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
backend := &Backend{
db: db,
chunkDir: chunkDir,
blobDir: blobDir,
chunkSize: chunkSize,
config: cfg,
}
if err := backend.initializeSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize database schema: %w", err)
}
// Run migrations
if err := backend.runMigrations(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
return backend, nil
}
// initializeSchema creates the database tables
func (b *Backend) initializeSchema() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS files (
hash TEXT PRIMARY KEY,
original_name TEXT NOT NULL,
size INTEGER NOT NULL,
chunk_count INTEGER NOT NULL,
content_type TEXT,
storage_type TEXT DEFAULT 'torrent',
owner_pubkey TEXT,
access_level TEXT DEFAULT 'public',
info_hash TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
access_count INTEGER DEFAULT 0,
last_access DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_pubkey) REFERENCES users(pubkey)
)`,
`CREATE TABLE IF NOT EXISTS chunks (
file_hash TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
chunk_hash TEXT NOT NULL,
size INTEGER NOT NULL,
offset INTEGER NOT NULL,
PRIMARY KEY (file_hash, chunk_index),
FOREIGN KEY (file_hash) REFERENCES files(hash)
)`,
`CREATE TABLE IF NOT EXISTS blobs (
hash TEXT PRIMARY KEY,
size INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
mime_type TEXT,
access_count INTEGER DEFAULT 0,
last_access DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS users (
pubkey TEXT PRIMARY KEY,
display_name TEXT,
profile_image TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME DEFAULT CURRENT_TIMESTAMP,
storage_used INTEGER DEFAULT 0,
file_count INTEGER DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
FOREIGN KEY (pubkey) REFERENCES users(pubkey)
)`,
`CREATE TABLE IF NOT EXISTS admin_actions (
id INTEGER PRIMARY KEY,
admin_pubkey TEXT NOT NULL,
action_type TEXT NOT NULL,
target_id TEXT,
reason TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS banned_users (
pubkey TEXT PRIMARY KEY,
banned_by TEXT NOT NULL,
reason TEXT,
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS content_reports (
id INTEGER PRIMARY KEY,
file_hash TEXT NOT NULL,
reporter_pubkey TEXT,
reason TEXT,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (file_hash) REFERENCES files(hash)
)`,
`CREATE INDEX IF NOT EXISTS idx_files_created_at ON files(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_files_owner_pubkey ON files(owner_pubkey)`,
`CREATE INDEX IF NOT EXISTS idx_files_storage_type ON files(storage_type)`,
`CREATE INDEX IF NOT EXISTS idx_files_access_level ON files(access_level)`,
`CREATE INDEX IF NOT EXISTS idx_files_size ON files(size)`,
`CREATE INDEX IF NOT EXISTS idx_files_last_access ON files(last_access)`,
`CREATE INDEX IF NOT EXISTS idx_files_owner_created ON files(owner_pubkey, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_blobs_created_at ON blobs(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_blobs_size ON blobs(size)`,
`CREATE INDEX IF NOT EXISTS idx_blobs_mime_type ON blobs(mime_type)`,
`CREATE INDEX IF NOT EXISTS idx_chunks_file_hash ON chunks(file_hash)`,
`CREATE INDEX IF NOT EXISTS idx_chunks_chunk_hash ON chunks(chunk_hash)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_pubkey ON sessions(pubkey)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login)`,
`CREATE INDEX IF NOT EXISTS idx_admin_actions_timestamp ON admin_actions(timestamp)`,
`CREATE INDEX IF NOT EXISTS idx_admin_actions_admin_pubkey ON admin_actions(admin_pubkey)`,
`CREATE INDEX IF NOT EXISTS idx_admin_actions_action_type ON admin_actions(action_type)`,
`CREATE INDEX IF NOT EXISTS idx_banned_users_banned_at ON banned_users(banned_at)`,
`CREATE INDEX IF NOT EXISTS idx_banned_users_banned_by ON banned_users(banned_by)`,
`CREATE INDEX IF NOT EXISTS idx_content_reports_status ON content_reports(status)`,
`CREATE INDEX IF NOT EXISTS idx_content_reports_file_hash ON content_reports(file_hash)`,
`CREATE INDEX IF NOT EXISTS idx_content_reports_created_at ON content_reports(created_at)`,
}
for _, query := range queries {
if _, err := b.db.Exec(query); err != nil {
return fmt.Errorf("failed to execute schema query: %w", err)
}
}
// Migration: Add storage_type column if it doesn't exist
_, err := b.db.Exec(`ALTER TABLE files ADD COLUMN storage_type TEXT DEFAULT 'torrent'`)
if err != nil && !isColumnExistsError(err) {
return fmt.Errorf("failed to add storage_type column: %w", err)
}
// Migration: Add owner_pubkey column if it doesn't exist
_, err = b.db.Exec(`ALTER TABLE files ADD COLUMN owner_pubkey TEXT`)
if err != nil && !isColumnExistsError(err) {
return fmt.Errorf("failed to add owner_pubkey column: %w", err)
}
// Migration: Add access_level column if it doesn't exist
_, err = b.db.Exec(`ALTER TABLE files ADD COLUMN access_level TEXT DEFAULT 'public'`)
if err != nil && !isColumnExistsError(err) {
return fmt.Errorf("failed to add access_level column: %w", err)
}
// Migration: Add storage limit fields to users table
defaultStorageLimit, _ := b.config.GetDefaultUserStorageLimitBytes()
_, err = b.db.Exec(fmt.Sprintf(`ALTER TABLE users ADD COLUMN storage_limit INTEGER DEFAULT %d`, defaultStorageLimit))
if err != nil && !isColumnExistsError(err) {
return fmt.Errorf("failed to add storage_limit column: %w", err)
}
_, err = b.db.Exec(`ALTER TABLE users ADD COLUMN account_tier TEXT DEFAULT 'free'`)
if err != nil && !isColumnExistsError(err) {
return fmt.Errorf("failed to add account_tier column: %w", err)
}
_, err = b.db.Exec(`ALTER TABLE users ADD COLUMN subscription_expires DATETIME`)
if err != nil && !isColumnExistsError(err) {
return fmt.Errorf("failed to add subscription_expires column: %w", err)
}
// Create indexes for new columns after they've been added
additionalIndexes := []string{
`CREATE INDEX IF NOT EXISTS idx_users_storage_used ON users(storage_used)`,
`CREATE INDEX IF NOT EXISTS idx_users_account_tier ON users(account_tier)`,
}
for _, query := range additionalIndexes {
if _, err := b.db.Exec(query); err != nil {
// Log but don't fail if index creation fails
log.Printf("Warning: failed to create index: %v", err)
}
}
return nil
}
// isColumnExistsError checks if the error is due to column already existing
func isColumnExistsError(err error) bool {
return err != nil && (
strings.Contains(err.Error(), "duplicate column name") ||
strings.Contains(err.Error(), "column already exists"))
}
// StoreFile stores a file by splitting it into chunks
func (b *Backend) StoreFile(reader io.Reader, originalName, contentType string) (*FileMetadata, error) {
return b.StoreFileWithOwner(reader, originalName, contentType, "", "public")
}
// StoreFileWithOwner stores a file with ownership information
func (b *Backend) StoreFileWithOwner(reader io.Reader, originalName, contentType, ownerPubkey, accessLevel string) (*FileMetadata, error) {
// Create temporary file to calculate hash and size
tempFile, err := os.CreateTemp("", "upload_*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
// Copy and hash the entire file
hasher := sha256.New()
size, err := io.Copy(io.MultiWriter(tempFile, hasher), reader)
if err != nil {
return nil, fmt.Errorf("failed to copy file: %w", err)
}
fileHash := hex.EncodeToString(hasher.Sum(nil))
// Check if file already exists
if existing, _ := b.GetFileMetadata(fileHash); existing != nil {
return existing, nil
}
// Seek back to beginning for chunking
if _, err := tempFile.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("failed to seek temp file: %w", err)
}
// Store chunks
chunkCount := int((size + b.chunkSize - 1) / b.chunkSize)
chunks := make([]ChunkInfo, 0, chunkCount)
for i := 0; i < chunkCount; i++ {
chunkData := make([]byte, b.chunkSize)
chunkSize, err := io.ReadFull(tempFile, chunkData)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return nil, fmt.Errorf("failed to read chunk %d: %w", i, err)
}
// Trim to actual size
chunkData = chunkData[:chunkSize]
// Calculate chunk hash
chunkHasher := sha256.New()
chunkHasher.Write(chunkData)
chunkHash := hex.EncodeToString(chunkHasher.Sum(nil))
// Store chunk to disk
chunkPath := filepath.Join(b.chunkDir, chunkHash)
if err := os.WriteFile(chunkPath, chunkData, 0644); err != nil {
return nil, fmt.Errorf("failed to write chunk %s: %w", chunkHash, err)
}
chunk := ChunkInfo{
FileHash: fileHash,
ChunkIndex: i,
ChunkHash: chunkHash,
Size: int64(chunkSize),
Offset: int64(i) * b.chunkSize,
}
chunks = append(chunks, chunk)
}
// Store metadata in database
tx, err := b.db.Begin()
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Insert file metadata
_, err = tx.Exec(`
INSERT INTO files (hash, original_name, size, chunk_count, content_type, storage_type, owner_pubkey, access_level)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, fileHash, originalName, size, chunkCount, contentType, "torrent", ownerPubkey, accessLevel)
if err != nil {
return nil, fmt.Errorf("failed to insert file metadata: %w", err)
}
// Insert chunk metadata
for _, chunk := range chunks {
_, err = tx.Exec(`
INSERT INTO chunks (file_hash, chunk_index, chunk_hash, size, offset)
VALUES (?, ?, ?, ?, ?)
`, chunk.FileHash, chunk.ChunkIndex, chunk.ChunkHash, chunk.Size, chunk.Offset)
if err != nil {
return nil, fmt.Errorf("failed to insert chunk metadata: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
metadata := &FileMetadata{
Hash: fileHash,
OriginalName: originalName,
Size: size,
ChunkCount: chunkCount,
ContentType: contentType,
StorageType: "torrent",
OwnerPubkey: ownerPubkey,
AccessLevel: accessLevel,
CreatedAt: time.Now(),
AccessCount: 0,
LastAccess: time.Now(),
}
return metadata, nil
}
// StoreBlobAsFile stores a blob and also records it in files table for unified access
func (b *Backend) StoreBlobAsFile(reader io.Reader, originalName, contentType string) (*FileMetadata, error) {
return b.StoreBlobAsFileWithOwner(reader, originalName, contentType, "", "public")
}
// StoreBlobAsFileWithOwner stores a blob with ownership information
func (b *Backend) StoreBlobAsFileWithOwner(reader io.Reader, originalName, contentType, ownerPubkey, accessLevel string) (*FileMetadata, error) {
// Read and hash the blob
hasher := sha256.New()
tempFile, err := os.CreateTemp("", "blob_*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
// Copy and hash the entire file
size, err := io.Copy(io.MultiWriter(tempFile, hasher), reader)
if err != nil {
return nil, fmt.Errorf("failed to copy blob: %w", err)
}
blobHash := hex.EncodeToString(hasher.Sum(nil))
// Check if file already exists
if existing, _ := b.GetFileMetadata(blobHash); existing != nil {
return existing, nil
}
// Seek back to beginning for storage
if _, err := tempFile.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("failed to seek temp file: %w", err)
}
// Store blob to disk
blobPath := filepath.Join(b.blobDir, blobHash)
blobFile, err := os.Create(blobPath)
if err != nil {
return nil, fmt.Errorf("failed to create blob file: %w", err)
}
defer blobFile.Close()
if _, err := io.Copy(blobFile, tempFile); err != nil {
os.Remove(blobPath)
return nil, fmt.Errorf("failed to write blob file: %w", err)
}
// Start transaction
tx, err := b.db.Begin()
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Insert blob metadata
_, err = tx.Exec(`
INSERT INTO blobs (hash, size, mime_type)
VALUES (?, ?, ?)
`, blobHash, size, contentType)
if err != nil {
return nil, fmt.Errorf("failed to insert blob metadata: %w", err)
}
// Insert file metadata for unified access
_, err = tx.Exec(`
INSERT INTO files (hash, original_name, size, chunk_count, content_type, storage_type, owner_pubkey, access_level)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, blobHash, originalName, size, 1, contentType, "blob", ownerPubkey, accessLevel)
if err != nil {
return nil, fmt.Errorf("failed to insert file metadata: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
metadata := &FileMetadata{
Hash: blobHash,
OriginalName: originalName,
Size: size,
ChunkCount: 1,
ContentType: contentType,
StorageType: "blob",
OwnerPubkey: ownerPubkey,
AccessLevel: accessLevel,
CreatedAt: time.Now(),
AccessCount: 0,
LastAccess: time.Now(),
}
return metadata, nil
}
// GetFileMetadata retrieves metadata for a file
func (b *Backend) GetFileMetadata(hash string) (*FileMetadata, error) {
var metadata FileMetadata
err := b.db.QueryRow(`
SELECT hash, original_name, size, chunk_count, content_type,
COALESCE(storage_type, 'torrent'), COALESCE(owner_pubkey, ''),
COALESCE(access_level, 'public'), created_at, access_count, last_access
FROM files WHERE hash = ?
`, hash).Scan(
&metadata.Hash, &metadata.OriginalName, &metadata.Size,
&metadata.ChunkCount, &metadata.ContentType, &metadata.StorageType,
&metadata.OwnerPubkey, &metadata.AccessLevel, &metadata.CreatedAt,
&metadata.AccessCount, &metadata.LastAccess,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get file metadata: %w", err)
}
return &metadata, nil
}
// GetFileChunks retrieves all chunks for a file
func (b *Backend) GetFileChunks(fileHash string) ([]ChunkInfo, error) {
rows, err := b.db.Query(`
SELECT file_hash, chunk_index, chunk_hash, size, offset
FROM chunks WHERE file_hash = ? ORDER BY chunk_index
`, fileHash)
if err != nil {
return nil, fmt.Errorf("failed to query chunks: %w", err)
}
defer rows.Close()
var chunks []ChunkInfo
for rows.Next() {
var chunk ChunkInfo
if err := rows.Scan(&chunk.FileHash, &chunk.ChunkIndex, &chunk.ChunkHash, &chunk.Size, &chunk.Offset); err != nil {
return nil, fmt.Errorf("failed to scan chunk: %w", err)
}
chunks = append(chunks, chunk)
}
return chunks, nil
}
// GetChunkData retrieves raw data for a specific chunk
func (b *Backend) GetChunkData(chunkHash string) ([]byte, error) {
chunkPath := filepath.Join(b.chunkDir, chunkHash)
data, err := os.ReadFile(chunkPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read chunk %s: %w", chunkHash, err)
}
return data, nil
}
// AssembleFile reassembles a complete file from its chunks
func (b *Backend) AssembleFile(fileHash string, writer io.Writer) error {
chunks, err := b.GetFileChunks(fileHash)
if err != nil {
return fmt.Errorf("failed to get chunks: %w", err)
}
for _, chunk := range chunks {
chunkData, err := b.GetChunkData(chunk.ChunkHash)
if err != nil {
return fmt.Errorf("failed to get chunk data: %w", err)
}
if chunkData == nil {
return fmt.Errorf("chunk %s not found", chunk.ChunkHash)
}
if _, err := writer.Write(chunkData); err != nil {
return fmt.Errorf("failed to write chunk: %w", err)
}
}
// Update access statistics
_, err = b.db.Exec(`
UPDATE files SET access_count = access_count + 1, last_access = CURRENT_TIMESTAMP
WHERE hash = ?
`, fileHash)
if err != nil {
// Log but don't fail on stats update
fmt.Printf("Warning: failed to update access stats for %s: %v\n", fileHash, err)
}
return nil
}
// StoreBlob stores a Blossom blob
func (b *Backend) StoreBlob(reader io.Reader, mimeType string) (string, error) {
// Read and hash the blob
hasher := sha256.New()
tempFile, err := os.CreateTemp("", "blob_*")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
size, err := io.Copy(io.MultiWriter(tempFile, hasher), reader)
if err != nil {
return "", fmt.Errorf("failed to copy blob: %w", err)
}
blobHash := hex.EncodeToString(hasher.Sum(nil))
blobPath := filepath.Join(b.blobDir, blobHash)
// Check if blob already exists
if _, err := os.Stat(blobPath); err == nil {
return blobHash, nil
}
// Move temp file to blob location
if _, err := tempFile.Seek(0, io.SeekStart); err != nil {
return "", fmt.Errorf("failed to seek temp file: %w", err)
}
blobFile, err := os.Create(blobPath)
if err != nil {
return "", fmt.Errorf("failed to create blob file: %w", err)
}
defer blobFile.Close()
if _, err := io.Copy(blobFile, tempFile); err != nil {
os.Remove(blobPath)
return "", fmt.Errorf("failed to copy to blob file: %w", err)
}
// Store blob metadata
_, err = b.db.Exec(`
INSERT OR REPLACE INTO blobs (hash, size, mime_type)
VALUES (?, ?, ?)
`, blobHash, size, mimeType)
if err != nil {
os.Remove(blobPath)
return "", fmt.Errorf("failed to store blob metadata: %w", err)
}
return blobHash, nil
}
// GetBlobData retrieves blob data
func (b *Backend) GetBlobData(blobHash string) (io.ReadCloser, *BlobInfo, error) {
// Get blob metadata
var info BlobInfo
err := b.db.QueryRow(`
SELECT hash, size, created_at, mime_type
FROM blobs WHERE hash = ?
`, blobHash).Scan(&info.Hash, &info.Size, &info.CreatedAt, &info.MimeType)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil, nil
}
return nil, nil, fmt.Errorf("failed to get blob metadata: %w", err)
}
// Open blob file
blobPath := filepath.Join(b.blobDir, blobHash)
file, err := os.Open(blobPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, nil
}
return nil, nil, fmt.Errorf("failed to open blob file: %w", err)
}
// Update access statistics
_, err = b.db.Exec(`
UPDATE blobs SET access_count = access_count + 1, last_access = CURRENT_TIMESTAMP
WHERE hash = ?
`, blobHash)
if err != nil {
// Log but don't fail on stats update
fmt.Printf("Warning: failed to update blob access stats for %s: %v\n", blobHash, err)
}
return file, &info, nil
}
// GetChunkHashes retrieves all chunk hashes for a file in order
func (b *Backend) GetChunkHashes(fileHash string) ([]string, error) {
rows, err := b.db.Query(`
SELECT chunk_hash
FROM chunks
WHERE file_hash = ?
ORDER BY chunk_index
`, fileHash)
if err != nil {
return nil, fmt.Errorf("failed to query chunks: %w", err)
}
defer rows.Close()
var chunkHashes []string
for rows.Next() {
var chunkHash string
if err := rows.Scan(&chunkHash); err != nil {
return nil, fmt.Errorf("failed to scan chunk hash: %w", err)
}
chunkHashes = append(chunkHashes, chunkHash)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating chunks: %w", err)
}
return chunkHashes, nil
}
// GetUserFiles retrieves all files owned by a user
func (b *Backend) GetUserFiles(pubkey string) ([]*FileMetadata, error) {
rows, err := b.db.Query(`
SELECT hash, original_name, size, chunk_count, content_type,
COALESCE(storage_type, 'torrent'), COALESCE(owner_pubkey, ''),
COALESCE(access_level, 'public'), created_at, access_count, last_access
FROM files
WHERE owner_pubkey = ?
ORDER BY created_at DESC
`, pubkey)
if err != nil {
return nil, fmt.Errorf("failed to query user files: %w", err)
}
defer rows.Close()
var files []*FileMetadata
for rows.Next() {
var metadata FileMetadata
err := rows.Scan(
&metadata.Hash, &metadata.OriginalName, &metadata.Size,
&metadata.ChunkCount, &metadata.ContentType, &metadata.StorageType,
&metadata.OwnerPubkey, &metadata.AccessLevel, &metadata.CreatedAt,
&metadata.AccessCount, &metadata.LastAccess,
)
if err != nil {
return nil, fmt.Errorf("failed to scan file metadata: %w", err)
}
files = append(files, &metadata)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating files: %w", err)
}
return files, nil
}
// GetPublicFiles returns all public files in the system
func (b *Backend) GetPublicFiles() ([]*FileMetadata, error) {
rows, err := b.db.Query(`
SELECT hash, original_name, size, chunk_count, content_type,
COALESCE(storage_type, 'torrent'), COALESCE(owner_pubkey, ''),
COALESCE(access_level, 'public'), COALESCE(info_hash, ''),
created_at, access_count, last_access
FROM files
WHERE access_level = 'public'
ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("failed to query public files: %w", err)
}
defer rows.Close()
var files []*FileMetadata
for rows.Next() {
var metadata FileMetadata
err := rows.Scan(
&metadata.Hash, &metadata.OriginalName, &metadata.Size,
&metadata.ChunkCount, &metadata.ContentType, &metadata.StorageType,
&metadata.OwnerPubkey, &metadata.AccessLevel, &metadata.InfoHash,
&metadata.CreatedAt, &metadata.AccessCount, &metadata.LastAccess,
)
if err != nil {
return nil, fmt.Errorf("failed to scan file metadata: %w", err)
}
files = append(files, &metadata)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating over public files: %w", err)
}
return files, nil
}
// GetUserStats calculates storage statistics for a user
func (b *Backend) GetUserStats(pubkey string) (int64, int, error) {
var storageUsed int64
var fileCount int
err := b.db.QueryRow(`
SELECT COALESCE(SUM(size), 0), COUNT(*)
FROM files
WHERE owner_pubkey = ?
`, pubkey).Scan(&storageUsed, &fileCount)
if err != nil {
return 0, 0, fmt.Errorf("failed to get user stats: %w", err)
}
return storageUsed, fileCount, nil
}
// DeleteUserFile deletes a file owned by a user
func (b *Backend) DeleteUserFile(hash, ownerPubkey string) error {
// Verify ownership
metadata, err := b.GetFileMetadata(hash)
if err != nil {
return fmt.Errorf("failed to get file metadata: %w", err)
}
if metadata == nil {
return fmt.Errorf("file not found")
}
if metadata.OwnerPubkey != ownerPubkey {
return fmt.Errorf("permission denied: not file owner")
}
// Start transaction
tx, err := b.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Delete chunks if it's a torrent file
if metadata.StorageType == "torrent" {
// Get chunk hashes for deletion
chunkHashes, err := b.GetChunkHashes(hash)
if err != nil {
return fmt.Errorf("failed to get chunk hashes: %w", err)
}
// Delete chunk files
for _, chunkHash := range chunkHashes {
chunkPath := filepath.Join(b.chunkDir, chunkHash)
if err := os.Remove(chunkPath); err != nil && !os.IsNotExist(err) {
log.Printf("Warning: failed to delete chunk file %s: %v", chunkHash, err)
}
}
// Delete chunk metadata
_, err = tx.Exec(`DELETE FROM chunks WHERE file_hash = ?`, hash)
if err != nil {
return fmt.Errorf("failed to delete chunk metadata: %w", err)
}
}
// Delete blob file if it's a blob
if metadata.StorageType == "blob" {
blobPath := filepath.Join(b.blobDir, hash)
if err := os.Remove(blobPath); err != nil && !os.IsNotExist(err) {
log.Printf("Warning: failed to delete blob file %s: %v", hash, err)
}
// Delete blob metadata
_, err = tx.Exec(`DELETE FROM blobs WHERE hash = ?`, hash)
if err != nil {
return fmt.Errorf("failed to delete blob metadata: %w", err)
}
}
// Delete file metadata
_, err = tx.Exec(`DELETE FROM files WHERE hash = ?`, hash)
if err != nil {
return fmt.Errorf("failed to delete file metadata: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// AdminDeleteFile allows admin to delete any file without ownership check
func (b *Backend) AdminDeleteFile(hash string) error {
// Get file metadata
metadata, err := b.GetFileMetadata(hash)
if err != nil {
return fmt.Errorf("failed to get file metadata: %w", err)
}
if metadata == nil {
return fmt.Errorf("file not found")
}
// Start transaction
tx, err := b.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Delete chunks if it's a torrent file
if metadata.StorageType == "torrent" {
// Get chunk hashes for deletion
chunkHashes, err := b.GetChunkHashes(hash)
if err != nil {
return fmt.Errorf("failed to get chunk hashes: %w", err)
}
// Delete chunk files
for _, chunkHash := range chunkHashes {
chunkPath := filepath.Join(b.chunkDir, chunkHash)
if err := os.Remove(chunkPath); err != nil && !os.IsNotExist(err) {
log.Printf("Warning: failed to delete chunk file %s: %v", chunkHash, err)
}
}
// Delete chunk metadata
_, err = tx.Exec(`DELETE FROM chunks WHERE file_hash = ?`, hash)
if err != nil {
return fmt.Errorf("failed to delete chunk metadata: %w", err)
}
} else if metadata.StorageType == "blob" {
// Delete blob file
blobPath := filepath.Join(b.blobDir, hash)
if err := os.Remove(blobPath); err != nil && !os.IsNotExist(err) {
log.Printf("Warning: failed to delete blob file %s: %v", hash, err)
}
// Delete blob metadata
_, err = tx.Exec(`DELETE FROM blobs WHERE hash = ?`, hash)
if err != nil {
return fmt.Errorf("failed to delete blob metadata: %w", err)
}
}
// Delete content reports
_, err = tx.Exec(`DELETE FROM content_reports WHERE file_hash = ?`, hash)
if err != nil {
return fmt.Errorf("failed to delete content reports: %w", err)
}
// Delete file metadata
_, err = tx.Exec(`DELETE FROM files WHERE hash = ?`, hash)
if err != nil {
return fmt.Errorf("failed to delete file metadata: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// UpdateFileAccess updates the access level of a file
func (b *Backend) UpdateFileAccess(hash, ownerPubkey, accessLevel string) error {
// Verify ownership
metadata, err := b.GetFileMetadata(hash)
if err != nil {
return fmt.Errorf("failed to get file metadata: %w", err)
}
if metadata == nil {
return fmt.Errorf("file not found")
}
if metadata.OwnerPubkey != ownerPubkey {
return fmt.Errorf("permission denied: not file owner")
}
// Update access level
_, err = b.db.Exec(`UPDATE files SET access_level = ? WHERE hash = ?`, accessLevel, hash)
if err != nil {
return fmt.Errorf("failed to update file access level: %w", err)
}
return nil
}
// CheckFileAccess checks if a user can access a file based on access level
func (b *Backend) CheckFileAccess(hash, requestorPubkey string) (bool, error) {
metadata, err := b.GetFileMetadata(hash)
if err != nil {
return false, fmt.Errorf("failed to get file metadata: %w", err)
}
if metadata == nil {
return false, nil
}
switch metadata.AccessLevel {
case "public":
return true, nil
case "private":
return metadata.OwnerPubkey == requestorPubkey, nil
case "followers":
// TODO: Implement follower checking via Nostr social graph
// For now, treat as private
return metadata.OwnerPubkey == requestorPubkey, nil
default:
return false, nil
}
}
// GetDB returns the database connection for auth module
func (b *Backend) GetDB() *sql.DB {
return b.db
}
// Close closes the storage backend
func (b *Backend) Close() error {
if b.db != nil {
return b.db.Close()
}
return nil
}
// FileExistsByInfoHash checks if a file exists by its BitTorrent info hash
func (b *Backend) FileExistsByInfoHash(infoHash string) (bool, error) {
var count int
err := b.db.QueryRow(`
SELECT COUNT(*) FROM files WHERE info_hash = ?
`, infoHash).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// GetFileHashByInfoHash returns the file hash for a given info hash
func (b *Backend) GetFileHashByInfoHash(infoHash string) (string, error) {
var fileHash string
err := b.db.QueryRow(`
SELECT hash FROM files WHERE info_hash = ?
`, infoHash).Scan(&fileHash)
if err != nil {
return "", err
}
return fileHash, nil
}
// UpdateFileInfoHash updates the info_hash for a file
func (b *Backend) UpdateFileInfoHash(fileHash, infoHash string) error {
_, err := b.db.Exec(`
UPDATE files SET info_hash = ? WHERE hash = ?
`, infoHash, fileHash)
if err != nil {
return fmt.Errorf("failed to update info_hash: %w", err)
}
return nil
}
// StoreNostrEvents stores Nostr event IDs for a file
func (b *Backend) StoreNostrEvents(fileHash, nip71EventID, nip35EventID string) error {
_, err := b.db.Exec(`
UPDATE files SET nip71_event_id = ?, nip35_event_id = ? WHERE hash = ?
`, nip71EventID, nip35EventID, fileHash)
if err != nil {
return fmt.Errorf("failed to store nostr event IDs: %w", err)
}
return nil
}
// GetNostrEvents retrieves Nostr event IDs for a file
func (b *Backend) GetNostrEvents(fileHash string) (nip71EventID, nip35EventID string, err error) {
err = b.db.QueryRow(`
SELECT COALESCE(nip71_event_id, ''), COALESCE(nip35_event_id, '') FROM files WHERE hash = ?
`, fileHash).Scan(&nip71EventID, &nip35EventID)
if err != nil {
return "", "", fmt.Errorf("failed to get nostr event IDs: %w", err)
}
return nip71EventID, nip35EventID, nil
}
// runMigrations applies database schema migrations
func (b *Backend) runMigrations() error {
// Check if info_hash column exists
var columnExists bool
err := b.db.QueryRow(`
SELECT COUNT(*) > 0 FROM pragma_table_info('files') WHERE name = 'info_hash'
`).Scan(&columnExists)
if err != nil {
return fmt.Errorf("failed to check info_hash column: %w", err)
}
// Add info_hash column if it doesn't exist
if !columnExists {
_, err = b.db.Exec(`ALTER TABLE files ADD COLUMN info_hash TEXT`)
if err != nil {
return fmt.Errorf("failed to add info_hash column: %w", err)
}
log.Printf("Added info_hash column to files table")
}
// Check if nostr event ID columns exist
var nip71ColumnExists, nip35ColumnExists bool
err = b.db.QueryRow(`
SELECT COUNT(*) > 0 FROM pragma_table_info('files') WHERE name = 'nip71_event_id'
`).Scan(&nip71ColumnExists)
if err != nil {
return fmt.Errorf("failed to check nip71_event_id column: %w", err)
}
err = b.db.QueryRow(`
SELECT COUNT(*) > 0 FROM pragma_table_info('files') WHERE name = 'nip35_event_id'
`).Scan(&nip35ColumnExists)
if err != nil {
return fmt.Errorf("failed to check nip35_event_id column: %w", err)
}
// Add nostr event ID columns if they don't exist
if !nip71ColumnExists {
_, err = b.db.Exec(`ALTER TABLE files ADD COLUMN nip71_event_id TEXT`)
if err != nil {
return fmt.Errorf("failed to add nip71_event_id column: %w", err)
}
log.Printf("Added nip71_event_id column to files table")
}
if !nip35ColumnExists {
_, err = b.db.Exec(`ALTER TABLE files ADD COLUMN nip35_event_id TEXT`)
if err != nil {
return fmt.Errorf("failed to add nip35_event_id column: %w", err)
}
log.Printf("Added nip35_event_id column to files table")
}
return nil
}