package files import ( "crypto/sha256" "database/sql" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "verstak/internal/core/storage" "verstak/internal/core/util" ) // Record represents a file entry linked to a node. type Record struct { ID string `json:"id"` NodeID string `json:"node_id"` Filename string `json:"filename"` Path string `json:"path"` // relative to vault root StorageMode string `json:"storage_mode"` // "vault" | "external" Size int64 `json:"size"` SHA256 string `json:"sha256,omitempty"` MIME string `json:"mime,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"` Missing bool `json:"missing"` } // Service provides file operations inside a vault. type Service struct { db *storage.DB vaultRoot string } // NewService creates a file service bound to a vault. func NewService(db *storage.DB, vaultRoot string) *Service { return &Service{db: db, vaultRoot: vaultRoot} } // DB returns the underlying storage. func (s *Service) DB() *storage.DB { return s.db } // --- public operations --- // AddExternal registers an external file (absolute path) without copying. func (s *Service) AddExternal(nodeID, absPath string) (*Record, error) { info, err := os.Stat(absPath) if err != nil { return nil, fmt.Errorf("stat: %w", err) } absPath, _ = filepath.Abs(absPath) return s.insertRecord(nodeID, filepath.Base(absPath), absPath, "external", info.Size(), "") } // CopyIntoVault copies an external file into the vault. // The file lands at /spaces//. func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, error) { info, err := os.Stat(absPath) if err != nil { return nil, fmt.Errorf("stat: %w", err) } if nodeSlug == "" { nodeSlug = nodeID[:8] } destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug) if err := os.MkdirAll(destDir, 0o750); err != nil { return nil, fmt.Errorf("mkdir: %w", err) } filename := filepath.Base(absPath) dest := filepath.Join(destDir, filename) // If destination exists, add a numeric suffix. if _, err := os.Stat(dest); err == nil { ext := filepath.Ext(filename) name := strings.TrimSuffix(filename, ext) dest = filepath.Join(destDir, fmt.Sprintf("%s_%d%s", name, time.Now().Unix(), ext)) filename = filepath.Base(dest) } hash, err := copyAndHash(absPath, dest) if err != nil { return nil, fmt.Errorf("copy: %w", err) } relPath, _ := filepath.Rel(s.vaultRoot, dest) return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash) } // Get returns a file record by ID. func (s *Service) Get(id string) (*Record, error) { row := s.db.QueryRow( `SELECT id,node_id,filename,path,storage_mode,size,sha256,mime, created_at,updated_at,last_seen_at,missing FROM files WHERE id = ?`, id) return scanRecord(row) } // ListByNode returns all files linked to a node. func (s *Service) ListByNode(nodeID string) ([]Record, error) { rows, err := s.db.Query( `SELECT id,node_id,filename,path,storage_mode,size,sha256,mime, created_at,updated_at,last_seen_at,missing FROM files WHERE node_id = ? ORDER BY created_at`, nodeID) if err != nil { return nil, err } defer rows.Close() return scanRecords(rows) } // MarkMissing flags a file as missing. func (s *Service) MarkMissing(id string, missing bool) error { m := 0 if missing { m = 1 } _, err := s.db.Exec( `UPDATE files SET missing=?, updated_at=? WHERE id=?`, m, time.Now().UTC().Format(time.RFC3339), id) return err } // DeleteToTrash moves a vault file to .verstak/trash/ and removes the record. func (s *Service) DeleteToTrash(id string) error { rec, err := s.Get(id) if err != nil { return err } if rec.StorageMode == "vault" { src := filepath.Join(s.vaultRoot, rec.Path) trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash") if err := os.MkdirAll(trashDir, 0o750); err != nil { return err } dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename) if err := os.Rename(src, dest); err != nil { return fmt.Errorf("move to trash: %w", err) } } _, err = s.db.Exec("DELETE FROM files WHERE id=?", id) return err } // Open launches the file with the system default application. func (s *Service) Open(id string) error { rec, err := s.Get(id) if err != nil { return err } var abs string if rec.StorageMode == "vault" { abs = filepath.Join(s.vaultRoot, rec.Path) } else { abs = rec.Path } return openWithSystem(abs) } // --- implementation details --- func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) { rec := &Record{ ID: util.UUID7(), NodeID: nodeID, Filename: filename, Path: path, StorageMode: mode, Size: size, SHA256: sha, MIME: guessMIME(filename), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } _, err := s.db.Exec( `INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime, created_at,updated_at,missing) VALUES (?,?,?,?,?,?,?,?,?,?,0)`, rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode, rec.Size, rec.SHA256, rec.MIME, rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339)) if err != nil { return nil, err } return rec, nil } func copyAndHash(src, dest string) (string, error) { in, err := os.Open(src) if err != nil { return "", err } defer in.Close() out, err := os.Create(dest) if err != nil { return "", err } defer out.Close() h := sha256.New() if _, err := io.Copy(io.MultiWriter(out, h), in); err != nil { return "", err } return fmt.Sprintf("%x", h.Sum(nil)), nil } func guessMIME(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) switch ext { case ".md", ".txt", ".go", ".py", ".js", ".ts", ".sh", ".sql", ".yml", ".yaml", ".json", ".toml", ".xml", ".html", ".css", ".csv", ".rst": return "text/plain" case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".gif": return "image/gif" case ".pdf": return "application/pdf" case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case ".odt": return "application/vnd.oasis.opendocument.text" case ".zip": return "application/zip" } return "application/octet-stream" } func openWithSystem(path string) error { var cmd *exec.Cmd switch runtime.GOOS { case "linux": cmd = exec.Command("xdg-open", path) case "darwin": cmd = exec.Command("open", path) case "windows": cmd = exec.Command("cmd", "/c", "start", "", path) default: return fmt.Errorf("unsupported platform") } return cmd.Start() } // --- scanning helpers --- type scanFace interface { Scan(dest ...interface{}) error } func scanRecord(s scanFace) (*Record, error) { var r Record var lastSeen sql.NullString var createdStr, updatedStr string err := s.Scan( &r.ID, &r.NodeID, &r.Filename, &r.Path, &r.StorageMode, &r.Size, &r.SHA256, &r.MIME, &createdStr, &updatedStr, &lastSeen, &r.Missing) if err == sql.ErrNoRows { return nil, fmt.Errorf("file not found") } if err != nil { return nil, err } r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr) if lastSeen.Valid { t, _ := time.Parse(time.RFC3339, lastSeen.String) r.LastSeenAt = &t } return &r, nil } func scanRecords(rows *sql.Rows) ([]Record, error) { var out []Record for rows.Next() { r, err := scanRecord(rows) if err != nil { return nil, err } out = append(out, *r) } return out, rows.Err() }