package notes import ( "fmt" "os" "path/filepath" "strings" "time" "verstak/internal/core/files" "verstak/internal/core/nodes" "verstak/internal/core/storage" "verstak/internal/core/templates" "verstak/internal/core/util" ) // NotesFolder is the canonical name for the notes subdirectory inside a case/project. const NotesFolder = "Notes" // noteFileRoot returns the absolute path to the notes subdirectory for a given parent. // For parentless notes it returns /Notes. func noteFileRoot(vaultRoot, parentFsPath string) string { if parentFsPath != "" { return filepath.Join(vaultRoot, parentFsPath, NotesFolder) } return filepath.Join(vaultRoot, NotesFolder) } // assertContained verifies that targetPath is strictly under rootDir. // It resolves symlinks in the target path to prevent symlink-based escapes. // Returns an error if targetPath escapes rootDir via .. or symlinks. func assertContained(rootDir, targetPath string) error { cleanRoot := filepath.Clean(rootDir) cleanTarget := filepath.Clean(targetPath) // Resolve symlinks in the target path to get the real path. // If the path doesn't exist yet (e.g. for Create), we resolve as much // as possible and check the unresolved remainder separately. resolvedTarget, err := filepath.EvalSymlinks(cleanTarget) if err != nil { // Path doesn't exist — resolve the parent directory instead. dir := filepath.Dir(cleanTarget) resolvedDir, dirErr := filepath.EvalSymlinks(dir) if dirErr != nil { // Parent doesn't exist either — fall back to Clean-based check. rel, relErr := filepath.Rel(cleanRoot, cleanTarget) if relErr != nil { return fmt.Errorf("path containment check failed: %w", relErr) } if strings.HasPrefix(rel, "..") { return fmt.Errorf("path %q escapes root %q", cleanTarget, cleanRoot) } return nil } // Reconstruct target with resolved parent + original base name. resolvedTarget = filepath.Join(resolvedDir, filepath.Base(cleanTarget)) } rel, err := filepath.Rel(cleanRoot, resolvedTarget) if err != nil { return fmt.Errorf("path containment check failed: %w", err) } if strings.HasPrefix(rel, "..") { return fmt.Errorf("path %q (resolved: %q) escapes root %q", cleanTarget, resolvedTarget, cleanRoot) } return nil } // Record represents a note entry (links a node to a file). type Record struct { NodeID string `json:"node_id"` FileID string `json:"file_id"` Format string `json:"format"` Encrypted bool `json:"encrypted"` } // Service handles markdown notes. type Service struct { db *storage.DB vaultRoot string nodes *nodes.Repository files *files.Service } // NewService creates a note service. func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fileSvc *files.Service) *Service { return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc} } // Create makes a new note node, an empty .md file, and links them. func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.Record, error) { node, err := s.nodes.Create(strPtr(parentID), nodes.TypeNote, title, 0, "", "") if err != nil { return nil, nil, fmt.Errorf("create node: %w", err) } seg := templates.SafeDisplayNameToPathSegment(title) if seg == "" { seg = "note" } filename := seg + ".md" // Determine the canonical notes directory var parentFsPath string if parentID != "" { parent, err := s.nodes.GetActive(parentID) if err == nil && parent.FsPath != "" { parentFsPath = parent.FsPath } } destDir := noteFileRoot(s.vaultRoot, parentFsPath) if err := os.MkdirAll(destDir, 0o750); err != nil { return nil, nil, fmt.Errorf("mkdir: %w", err) } dest := filepath.Join(destDir, filename) // Path containment check: the resolved file must stay under destDir if err := assertContained(destDir, dest); err != nil { return nil, nil, err } if _, err := os.Stat(dest); err == nil { filename = fmt.Sprintf("%s_%s.md", seg, node.ID[:8]) dest = filepath.Join(destDir, filename) // Re-check containment after rename if err := assertContained(destDir, dest); err != nil { return nil, nil, err } } if err := os.WriteFile(dest, []byte("# "+title+"\n\n"), 0o640); err != nil { return nil, nil, fmt.Errorf("write: %w", err) } relPath, _ := filepath.Rel(s.vaultRoot, dest) fileRec, err := insertFileRecord(s.db, node.ID, filename, relPath, "vault", 0) if err != nil { return nil, nil, fmt.Errorf("insert file: %w", err) } _, err = s.db.Exec( `INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`, node.ID, fileRec.ID, "markdown") if err != nil { return nil, nil, fmt.Errorf("link note: %w", err) } return node, fileRec, nil } // Read returns the content of a note. func (s *Service) Read(nodeID string) (string, error) { var filePath, storageMode string err := s.db.QueryRow( `SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode) if err != nil { return "", fmt.Errorf("query note: %w", err) } var abs string if storageMode == "vault" { abs = filepath.Join(s.vaultRoot, filePath) } else { abs = filePath } data, err := os.ReadFile(abs) if err != nil { return "", fmt.Errorf("read: %w", err) } return string(data), nil } // Save writes new content, backing up the old version. func (s *Service) Save(nodeID, content string) error { var filePath, storageMode string err := s.db.QueryRow( `SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode) if err != nil { return fmt.Errorf("query: %w", err) } var abs string if storageMode == "vault" { abs = filepath.Join(s.vaultRoot, filePath) } else { abs = filePath } // Backup old version. if info, err := os.Stat(abs); err == nil && info.Size() > 0 { histDir := filepath.Join(s.vaultRoot, ".verstak", "history") os.MkdirAll(histDir, 0o750) name := filepath.Base(abs) backup := filepath.Join(histDir, fmt.Sprintf("%s_%d.bak", name, time.Now().Unix())) os.WriteFile(backup, mustRead(abs), 0o640) } if err := os.WriteFile(abs, []byte(content), 0o640); err != nil { return fmt.Errorf("write: %w", err) } // Update file size. info, _ := os.Stat(abs) size := int64(0) if info != nil { size = info.Size() } _, err = s.db.Exec( `UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`, size, utcNow(), filePath, storageMode) return err } // Delete soft-deletes the note node and moves the backing .md file to the // shared vault trash directory (/.verstak/trash/) using the same // trashRecord mechanism as files.DeleteNodeAndChildren. This ensures the // deleted note appears in the unified Trash UI and can be restored/permanently // deleted through the existing trash workflow. func (s *Service) Delete(nodeID string) error { // Load the note record to find the file. rec, err := s.Load(nodeID) if err != nil { return fmt.Errorf("load note: %w", err) } // Get the full file record for trashRecord. fileRec, err := s.files.Get(rec.FileID) if err != nil { return fmt.Errorf("get file record: %w", err) } // Soft-delete the node first. if err := s.nodes.SoftDelete(nodeID); err != nil { return fmt.Errorf("soft-delete node: %w", err) } // Move the .md file to the shared trash using the existing trashRecord. // This places the file in /.verstak/trash/_ // and marks the file record as missing=1 so it can be restored later. if err := s.files.TrashFile(fileRec); err != nil { return fmt.Errorf("trash file: %w", err) } return nil } // Rename changes the note title and renames the backing .md file on disk. // If a file with the target name already exists, the operation is rejected. func (s *Service) Rename(nodeID, newTitle string) error { if err := s.nodes.UpdateTitle(nodeID, newTitle); err != nil { return fmt.Errorf("update title: %w", err) } // Load the note record to find the file. rec, err := s.Load(nodeID) if err != nil { return fmt.Errorf("load note: %w", err) } // Get the current file record. var oldPath, oldFilename, storageMode string err = s.db.QueryRow( `SELECT path, filename, storage_mode FROM files WHERE id = ?`, rec.FileID, ).Scan(&oldPath, &oldFilename, &storageMode) if err != nil { return fmt.Errorf("query file: %w", err) } // Build old and new absolute paths. var oldAbs string if storageMode == "vault" { oldAbs = filepath.Join(s.vaultRoot, oldPath) } else { oldAbs = oldPath } oldDir := filepath.Dir(oldAbs) seg := templates.SafeDisplayNameToPathSegment(newTitle) if seg == "" { seg = "note" } newFilename := seg + ".md" newAbs := filepath.Join(oldDir, newFilename) // Collision check: reject if target exists and is different from source. if newAbs != oldAbs { if _, err := os.Stat(newAbs); err == nil { return fmt.Errorf("file %q already exists", newFilename) } if err := os.Rename(oldAbs, newAbs); err != nil { return fmt.Errorf("rename file: %w", err) } // Update file record. newRel, _ := filepath.Rel(s.vaultRoot, newAbs) _, err = s.db.Exec( `UPDATE files SET filename=?, path=?, updated_at=? WHERE id=?`, newFilename, newRel, utcNow(), rec.FileID, ) if err != nil { return fmt.Errorf("update file record: %w", err) } } return nil } // Load looks up the note record for a node. func (s *Service) Load(nodeID string) (*Record, error) { var rec Record var enc int err := s.db.QueryRow( `SELECT node_id, file_id, format, encrypted FROM notes WHERE node_id=?`, nodeID, ).Scan(&rec.NodeID, &rec.FileID, &rec.Format, &enc) if err != nil { return nil, err } rec.Encrypted = enc == 1 return &rec, nil } // --- helpers --- func insertFileRecord(db *storage.DB, nodeID, filename, relPath, mode string, size int64) (*files.Record, error) { rec := &files.Record{ ID: util.UUID7(), NodeID: nodeID, Filename: filename, Path: relPath, StorageMode: mode, Size: size, MIME: "text/plain", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } _, err := db.Exec( `INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime, created_at,updated_at,missing) VALUES (?,?,?,?,?,?,?,?,?,0)`, rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode, rec.Size, rec.MIME, rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339)) if err != nil { return nil, err } return rec, nil } func mustRead(path string) []byte { data, err := os.ReadFile(path) if err != nil { return nil } return data } func utcNow() string { return time.Now().UTC().Format(time.RFC3339) } func strPtr(s string) *string { if s == "" { return nil } return &s }