package files import ( "crypto/sha256" "database/sql" "encoding/base64" "fmt" "io" "log" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "verstak/internal/core/nodes" "verstak/internal/core/storage" "verstak/internal/core/templates" "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"` } // ImportSummary describes a scanned directory before import. type ImportSummary struct { Files int `json:"files"` Folders int `json:"folders"` TotalBytes int64 `json:"totalBytes"` IsDangerous bool `json:"isDangerous"` DangerReason string `json:"dangerReason,omitempty"` } // Service provides file operations inside a vault. type Service struct { db *storage.DB vaultRoot string nodes *nodes.Repository } // NewService creates a file service bound to a vault. func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository) *Service { return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo} } func (s *Service) parentFsPath(parentID string) string { if parentID == "" { return "" } parent, err := s.nodes.GetActive(parentID) if err != nil || parent.FsPath == "" { return "" } return parent.FsPath } // DB returns the underlying storage. func (s *Service) DB() *storage.DB { return s.db } // --- security helpers --- // ValidateName is an exported wrapper for validateName. func ValidateName(name string) error { return validateName(name) } // validateName rejects filenames with path separators, relative components, // and other dangerous patterns. func validateName(name string) error { if name == "" { return fmt.Errorf("name is required") } if strings.Contains(name, "/") || strings.Contains(name, "\\") { return fmt.Errorf("name must not contain path separators") } if strings.Contains(name, "..") { return fmt.Errorf("name must not contain '..'") } if strings.Contains(name, "\x00") { return fmt.Errorf("name must not contain null bytes") } if len(name) > 255 { return fmt.Errorf("name too long (max 255)") } return nil } // vaultPath resolves a relative vault path and checks it stays within jail. func (s *Service) vaultPath(rel string) (string, error) { if rel == "" { return "", fmt.Errorf("empty path") } if filepath.IsAbs(rel) { return "", fmt.Errorf("absolute path not allowed: %s", rel) } cleaned := filepath.Clean(rel) joined := filepath.Join(s.vaultRoot, cleaned) joinedClean := filepath.Clean(joined) relToVault, err := filepath.Rel(s.vaultRoot, joinedClean) if err != nil { return "", fmt.Errorf("path escapes vault root: %s", rel) } if relToVault == ".." || strings.HasPrefix(relToVault, ".."+string(filepath.Separator)) { return "", fmt.Errorf("path escapes vault root: %s", rel) } return joinedClean, nil } // absPathSafe resolves an absolute path and checks jail if it's under vault. // For "external" mode records the path stored may be an absolute external path. // This function only checks path safety — it does not enforce that external // files must be inside the vault. func (s *Service) absPathSafe(rec *Record) (string, error) { if rec.StorageMode == "vault" { return s.vaultPath(rec.Path) } abs, err := filepath.Abs(rec.Path) if err != nil { return "", fmt.Errorf("abs: %w", err) } return filepath.Clean(abs), nil } // --- 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 //. func (s *Service) CopyIntoVault(nodeID, absPath, parentFsPath string) (*Record, error) { info, err := os.Stat(absPath) if err != nil { return nil, fmt.Errorf("stat: %w", err) } if parentFsPath == "" { parentFsPath = "." } destDir := filepath.Join(s.vaultRoot, parentFsPath) if _, err := s.vaultPath(parentFsPath); err != nil { return nil, fmt.Errorf("path safety: %w", err) } 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 _, 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) if _, err := s.vaultPath(relPath); err != nil { return nil, fmt.Errorf("path safety: %w", err) } 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, err := s.vaultPath(rec.Path) if err != nil { return err } 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) // verify trash is inside vault if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil { return err } if _, statErr := os.Stat(src); os.IsNotExist(statErr) { // File already gone — just remove the DB record. } else 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 } abs, err := s.absPathSafe(rec) if err != nil { return err } return openWithSystem(abs) } // maxPreviewSize is the maximum file size (5 MB) for inline preview. const maxPreviewSize = 5 * 1024 * 1024 // ReadText reads a file's content as text, up to maxPreviewSize. func (s *Service) ReadText(id string) (string, error) { rec, err := s.Get(id) if err != nil { return "", err } if rec.Size > maxPreviewSize { return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size) } abs, err := s.absPathSafe(rec) if err != nil { return "", err } b, err := os.ReadFile(abs) if err != nil { return "", fmt.Errorf("read: %w", err) } return string(b), nil } // ReadBase64 reads a file and returns a data URI (base64-encoded). func (s *Service) ReadBase64(id string) (string, error) { rec, err := s.Get(id) if err != nil { return "", err } if rec.Size > maxPreviewSize { return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size) } abs, err := s.absPathSafe(rec) if err != nil { return "", err } b, err := os.ReadFile(abs) if err != nil { return "", fmt.Errorf("read: %w", err) } mime := rec.MIME if mime == "" { mime = "application/octet-stream" } return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(b)), nil } // CreateEmptyFile creates a file node and an empty vault file. func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) { if err := validateName(filename); err != nil { return nil, fmt.Errorf("invalid filename: %w", err) } filename = s.uniqueTitle(parentID, filename) node, err := s.nodes.Create(strPtr(parentID), nodes.TypeFile, filename, 0, "", "") if err != nil { return nil, err } parentFsPath := s.parentFsPath(parentID) dir := filepath.Join(s.vaultRoot, parentFsPath) if parentFsPath == "" { dir = s.vaultRoot } if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("mkdir: %w", err) } dest := filepath.Join(dir, filename) f, err := os.Create(dest) if err != nil { return nil, fmt.Errorf("create file: %w", err) } f.Close() relPath, _ := filepath.Rel(s.vaultRoot, dest) // Verify dest is inside vault if _, err := s.vaultPath(relPath); err != nil { return nil, fmt.Errorf("path safety: %w", err) } _, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "") return node, err } // Duplicate creates a copy of a node and its file record under the same parent. func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) { original, err := s.nodes.GetActive(nodeID) if err != nil { return nil, err } parentID := "" if original.ParentID != nil { parentID = *original.ParentID } newName := s.copyTitle(parentID, original.Title) node, err := s.nodes.Create(strPtr(parentID), original.Type, newName, 0, "", "") if err != nil { return nil, err } if original.Type == nodes.TypeFile { records, _ := s.ListByNode(original.ID) if len(records) > 0 { src := &records[0] if src.StorageMode == "vault" { srcPath, err := s.vaultPath(src.Path) if err != nil { return nil, err } parentFsPath := s.parentFsPath(parentID) dir := filepath.Join(s.vaultRoot, parentFsPath) if parentFsPath == "" { dir = s.vaultRoot } os.MkdirAll(dir, 0o750) dst := filepath.Join(dir, newName) hash, err := copyAndHash(srcPath, dst) if err != nil { return nil, fmt.Errorf("copy file: %w", err) } relPath, _ := filepath.Rel(s.vaultRoot, dst) if _, err := s.vaultPath(relPath); err != nil { return nil, fmt.Errorf("path safety: %w", err) } _, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash) if err != nil { return nil, err } } else { // External file: create a new record pointing to the same absolute path. _, err = s.insertRecord(node.ID, newName, src.Path, "external", src.Size, src.SHA256) if err != nil { return nil, err } } } } return node, nil } // AddPathCopy copies sourcePath (file or directory) into the vault under nodeID. func (s *Service) AddPathCopy(nodeID, sourcePath string) ([]nodes.Node, error) { return s.importPath(nodeID, sourcePath, true) } // AddPathLink links sourcePath (file or directory) without copying into vault. func (s *Service) AddPathLink(nodeID, sourcePath string) ([]nodes.Node, error) { return s.importPath(nodeID, sourcePath, false) } // PreviewImport scans sourcePath and returns a summary without importing. func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) { info, err := os.Stat(sourcePath) if err != nil { return nil, fmt.Errorf("stat: %w", err) } if !info.IsDir() { return &ImportSummary{Files: 1, TotalBytes: info.Size()}, nil } var sum ImportSummary err = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, err error) error { if err != nil { return filepath.SkipDir } if fi.IsDir() { sum.Folders++ name := strings.ToLower(fi.Name()) if name == ".git" || name == "node_modules" || name == ".cache" { sum.IsDangerous = true sum.DangerReason = fmt.Sprintf("содержит %s", fi.Name()) } return nil } sum.Files++ sum.TotalBytes += fi.Size() return nil }) if sum.Files > 1000 && !sum.IsDangerous { sum.IsDangerous = true sum.DangerReason = "более 1000 файлов" } if sum.TotalBytes > 1<<30 && !sum.IsDangerous { sum.IsDangerous = true sum.DangerReason = "более 1 GB" } return &sum, err } // DeleteNodeAndChildren soft-deletes a node and all descendants, // moving vault files to trash. All DB updates happen inside a transaction // so that partial failure does not leave an inconsistent DB state. func (s *Service) DeleteNodeAndChildren(nodeID string) error { // Collect all nodes to delete bottom-up (children before parent). var toDelete []*nodes.Node var collect func(id string) collect = func(id string) { children, err := s.nodes.ListChildren(id, false) if err != nil { children = nil } for i := range children { collect(children[i].ID) } n, err := s.nodes.GetActive(id) if err != nil { return } toDelete = append(toDelete, n) } collect(nodeID) // Phase 1: FS trash moves (with rollback on partial failure). type trashMove struct{ src, dst string } var movedTrash []trashMove for _, n := range toDelete { if err := s.deleteFileRecords(n.ID); err != nil { // Undo completed trash moves. for _, mt := range movedTrash { _ = os.Rename(mt.dst, mt.src) } return fmt.Errorf("delete file records for %s: %w", n.ID, err) } if n.FsPath == "" { continue } src, err := s.vaultPath(n.FsPath) if err != nil { continue } if info, statErr := os.Stat(src); statErr != nil || !info.IsDir() { continue } trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash") if err := os.MkdirAll(trashDir, 0o750); err != nil { continue } dst := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title)) if err := os.Rename(src, dst); err != nil { for _, mt := range movedTrash { _ = os.Rename(mt.dst, mt.src) } return fmt.Errorf("trash move node %s: %w", n.ID, err) } movedTrash = append(movedTrash, trashMove{src, dst}) } // Phase 2: DB soft-deletes in a single transaction. tx, err := s.db.Begin() if err != nil { for _, mt := range movedTrash { _ = os.Rename(mt.dst, mt.src) } return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() for _, n := range toDelete { t := now() _, err := tx.Exec( `UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, t, t, n.ID) if err != nil { for _, mt := range movedTrash { _ = os.Rename(mt.dst, mt.src) } return fmt.Errorf("soft-delete %s: %w", n.ID, err) } } if err := tx.Commit(); err != nil { for _, mt := range movedTrash { if rerr := os.Rename(mt.dst, mt.src); rerr != nil { log.Printf("rollback trash move failed: %v", rerr) } } return fmt.Errorf("commit tx: %w", err) } return nil } func (s *Service) deleteFileRecords(nodeID string) error { records, err := s.ListByNode(nodeID) if err != nil { return err } var errs []string for _, r := range records { if err := s.DeleteToTrash(r.ID); err != nil { errs = append(errs, fmt.Sprintf("file %s: %v", r.ID, err)) } } if len(errs) > 0 { return fmt.Errorf("trash errors: %v", errs) } return nil } func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]nodes.Node, error) { info, err := os.Stat(sourcePath) if err != nil { return nil, fmt.Errorf("stat: %w", err) } if !info.IsDir() { title := s.uniqueTitle(parentID, filepath.Base(sourcePath)) node, err := s.nodes.Create(strPtr(parentID), nodes.TypeFile, title, 0, "", "") if err != nil { return nil, err } if copyMode { parentFsPath := s.parentFsPath(parentID) _, err = s.CopyIntoVault(node.ID, sourcePath, parentFsPath) } else { _, err = s.AddExternal(node.ID, sourcePath) } if err != nil { return nil, err } return []nodes.Node{*node}, nil } return s.importDir(parentID, sourcePath, info.Name(), copyMode) } func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) ([]nodes.Node, error) { dirName = s.uniqueTitle(parentID, dirName) folderNode, err := s.nodes.Create(strPtr(parentID), nodes.TypeFolder, dirName, 0, "", "") if err != nil { return nil, err } parentFsPath := s.parentFsPath(parentID) seg := templates.SafeDisplayNameToPathSegment(dirName) folderFsPath := seg if parentFsPath != "" { folderFsPath = filepath.Join(parentFsPath, seg) } _ = s.nodes.UpdateFsPath(folderNode.ID, folderFsPath) physPath := filepath.Join(s.vaultRoot, folderFsPath) if err := os.MkdirAll(physPath, 0o750); err != nil { return nil, fmt.Errorf("create folder for imported dir: %w", err) } entries, err := os.ReadDir(sourcePath) if err != nil { return nil, err } var all []nodes.Node all = append(all, *folderNode) for _, entry := range entries { childPath := filepath.Join(sourcePath, entry.Name()) if entry.IsDir() { children, err := s.importDir(folderNode.ID, childPath, entry.Name(), copyMode) if err != nil { return nil, err } all = append(all, children...) } else { childNode, err := s.nodes.Create(strPtr(folderNode.ID), nodes.TypeFile, entry.Name(), 0, "", "") if err != nil { return nil, err } if copyMode { _, err = s.CopyIntoVault(childNode.ID, childPath, folderFsPath) } else { _, err = s.AddExternal(childNode.ID, childPath) } if err != nil { return nil, err } all = append(all, *childNode) } } return all, nil } func (s *Service) uniqueTitle(parentID, desired string) string { children, _ := s.nodes.ListChildren(parentID, false) used := make(map[string]bool, len(children)) for i := range children { used[children[i].Title] = true } if !used[desired] { return desired } for n := 2; ; n++ { c := fmt.Sprintf("%s (%d)", desired, n) if !used[c] { return c } } } // copyTitle generates a unique "Name (copy).ext" style name for duplicates. // For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg" // For folders: "Docs" → "Docs (copy)", "Docs (copy 2)" func (s *Service) copyTitle(parentID, desired string) string { children, _ := s.nodes.ListChildren(parentID, false) used := make(map[string]bool, len(children)) for i := range children { used[children[i].Title] = true } ext := filepath.Ext(desired) base := strings.TrimSuffix(desired, ext) copyName := base + " (copy)" + ext if !used[copyName] { return copyName } for n := 2; ; n++ { candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext) if !used[candidate] { return candidate } } } // UniqueTitleCopy returns a copy-style unique name for use in conflict resolution. func (s *Service) UniqueTitleCopy(parentID, desired string) string { return s.copyTitle(parentID, desired) } // --- 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() } func now() string { return time.Now().UTC().Format(time.RFC3339) } // --- scanning helpers --- type scanFace interface { Scan(dest ...interface{}) error } func scanRecord(s scanFace) (*Record, error) { var r Record var lastSeen, sha256, mime sql.NullString var createdStr, updatedStr string err := s.Scan( &r.ID, &r.NodeID, &r.Filename, &r.Path, &r.StorageMode, &r.Size, &sha256, &mime, &createdStr, &updatedStr, &lastSeen, &r.Missing) if err == sql.ErrNoRows { return nil, fmt.Errorf("file not found") } if err != nil { return nil, err } if sha256.Valid { r.SHA256 = sha256.String } if mime.Valid { r.MIME = mime.String } 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() } func strPtr(s string) *string { if s == "" { return nil } return &s }