fix: второй стабилизационный pass vault layout — sync payload, bindings, vaultPath, tests
sync_apply.go: - applyRemoteNodeCreate: полный payload (template_id/fs_path/sort_order/archived), INSERT сохраняет все поля, для folder-like создаётся физическая папка. - applyRemoteNodeUpdate: принимает fs_path/template_id/archived, физическое переименование папки при изменении title/fs_path. - applyRemoteNodeMove: принимает fs_path, обновляет parent_id+fs_path, физически перемещает папку (folder-like) или file record (note/file). bindings_nodes.go: - MoveNode: node.FsPath = newFsPath после UpdateFsPath; sync.RecordOp отправляет новый fs_path; note/file move to root — файл в vault root. - RenameNode: EntityFile для file, EntityNote для note; коллизия → генерация уникального имени; файл переименовывается только после os.Rename. - DeleteNode: единый вызов a.files.DeleteNodeAndChildren(), дублирование удалено. - Исправлен deadlock с SetMaxOpenConns(1) — Query/Exec больше не конфликтуют. files.Service.vaultPath: filepath.Rel-based проверка, sibling-prefix escape (/tmp/vault vs /tmp/vault_evil) отклоняется. VaultCheck: SQL JOIN с n.deleted_at IS NULL, чтобы удалённые узлы не показывались как missing files. Добавлены тесты: RenameFileNodeUsesEntityFile, MoveNoteToRoot, DeleteFolderLeavesVaultCheckHealthy, SyncNodeCreatePreservesFields, VaultPathSiblingPrefixEscape.
This commit is contained in:
parent
66c5c81f39
commit
20a05569ac
|
|
@ -4,12 +4,13 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/templates"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
)
|
||||
|
||||
func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
|
||||
|
|
@ -142,64 +143,7 @@ func (a *App) DeleteNode(id string) error {
|
|||
_ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "")
|
||||
_ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil)
|
||||
|
||||
// Handle physical trash based on node type
|
||||
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
||||
|
||||
if isFolderLike && n.FsPath != "" {
|
||||
// Move entire folder to trash
|
||||
src := filepath.Join(a.vault, n.FsPath)
|
||||
if info, err := os.Stat(src); err == nil && info.IsDir() {
|
||||
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
||||
_ = os.MkdirAll(trashDir, 0o750)
|
||||
trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))
|
||||
_ = os.Rename(src, trashPath)
|
||||
}
|
||||
|
||||
// Recursively soft-delete all descendants
|
||||
a.softDeleteDescendants(id)
|
||||
return a.nodes.SoftDelete(id)
|
||||
}
|
||||
|
||||
// For notes/files: move associated file to trash, then soft-delete
|
||||
if err := a.deleteNodeFileRecord(id); err != nil {
|
||||
// Log but continue with soft-delete
|
||||
}
|
||||
return a.nodes.SoftDelete(id)
|
||||
}
|
||||
|
||||
// softDeleteDescendants recursively soft-deletes all descendant nodes.
|
||||
func (a *App) softDeleteDescendants(parentID string) {
|
||||
children, err := a.nodes.ListChildren(parentID, false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, child := range children {
|
||||
a.softDeleteDescendants(child.ID)
|
||||
_ = a.nodes.SoftDelete(child.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteNodeFileRecord moves the file record for a node to trash.
|
||||
func (a *App) deleteNodeFileRecord(nodeID string) error {
|
||||
rows, err := a.db.Query(`SELECT id, path, storage_mode FROM files WHERE node_id=?`, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, path, mode string
|
||||
if err := rows.Scan(&id, &path, &mode); err != nil {
|
||||
continue
|
||||
}
|
||||
if mode == "vault" {
|
||||
src := filepath.Join(a.vault, path)
|
||||
trashDir := filepath.Join(a.vault, ".verstak", "trash")
|
||||
_ = os.MkdirAll(trashDir, 0o750)
|
||||
_ = os.Rename(src, filepath.Join(trashDir, id+"_"+filepath.Base(path)))
|
||||
}
|
||||
}
|
||||
_, err = a.db.Exec(`DELETE FROM files WHERE node_id=?`, nodeID)
|
||||
return err
|
||||
return a.files.DeleteNodeAndChildren(id)
|
||||
}
|
||||
|
||||
func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||
|
|
@ -275,35 +219,68 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Update file record filename/path
|
||||
rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID)
|
||||
if err != nil {
|
||||
return nil // non-fatal
|
||||
// Collect file records first to avoid connection deadlock (SetMaxOpenConns=1)
|
||||
type fileRec struct {
|
||||
id, path, filename string
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var fileID, filePath, oldFilename string
|
||||
if err := rows.Scan(&fileID, &filePath, &oldFilename); err != nil {
|
||||
var records []fileRec
|
||||
rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var r fileRec
|
||||
if err := rows.Scan(&r.id, &r.path, &r.filename); err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, r)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
for _, r := range records {
|
||||
if r.path == "" {
|
||||
continue
|
||||
}
|
||||
if filePath == "" {
|
||||
continue
|
||||
oldAbs := filepath.Join(a.vault, r.path)
|
||||
dir := filepath.Dir(r.path)
|
||||
var newFilename string
|
||||
if n.Type == nodes.TypeNote {
|
||||
ext := filepath.Ext(r.filename)
|
||||
if ext == "" {
|
||||
ext = ".md"
|
||||
}
|
||||
newFilename = seg + ext
|
||||
} else {
|
||||
// File node: title is the full filename
|
||||
newFilename = newTitle
|
||||
}
|
||||
oldAbs := filepath.Join(a.vault, filePath)
|
||||
ext := filepath.Ext(oldFilename)
|
||||
newFilename := seg + ext
|
||||
dir := filepath.Dir(filePath)
|
||||
newRelPath := filepath.Join(dir, newFilename)
|
||||
newAbs := filepath.Join(a.vault, newRelPath)
|
||||
|
||||
// Check for collision — generate unique name if needed
|
||||
if _, err := os.Stat(newAbs); err == nil {
|
||||
ext := filepath.Ext(newFilename)
|
||||
base := strings.TrimSuffix(newFilename, ext)
|
||||
baseSeg := templates.SafeDisplayNameToPathSegment(base)
|
||||
if baseSeg == "" {
|
||||
baseSeg = "renamed"
|
||||
}
|
||||
for n := 2; ; n++ {
|
||||
candidate := fmt.Sprintf("%s (%d)%s", baseSeg, n, ext)
|
||||
candidatePath := filepath.Join(dir, candidate)
|
||||
if _, err := os.Stat(filepath.Join(a.vault, candidatePath)); os.IsNotExist(err) {
|
||||
newFilename = candidate
|
||||
newRelPath = candidatePath
|
||||
newAbs = filepath.Join(a.vault, newRelPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rename physical file
|
||||
if _, err := os.Stat(oldAbs); err == nil {
|
||||
// Check for collision
|
||||
if _, err := os.Stat(newAbs); os.IsNotExist(err) {
|
||||
if err := os.Rename(oldAbs, newAbs); err == nil {
|
||||
_, _ = a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`,
|
||||
newFilename, newRelPath, fileID)
|
||||
}
|
||||
if err := os.Rename(oldAbs, newAbs); err == nil {
|
||||
_, _ = a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`,
|
||||
newFilename, newRelPath, r.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -314,15 +291,18 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
}
|
||||
var evType string
|
||||
var targetType string
|
||||
var syncEntity string
|
||||
if n.Type == nodes.TypeNote {
|
||||
evType = activity.TypeNoteUpdated
|
||||
targetType = activity.TargetNote
|
||||
syncEntity = syncsvc.EntityNote
|
||||
} else {
|
||||
evType = activity.TypeFileRenamed
|
||||
targetType = activity.TargetFile
|
||||
syncEntity = syncsvc.EntityFile
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"title": newTitle,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
|
@ -406,6 +386,8 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
|||
return fmt.Errorf("move folder: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
node.FsPath = newFsPath
|
||||
} else {
|
||||
// Note/file node: update parent_id and move physical file
|
||||
if newParentID == "" {
|
||||
|
|
@ -419,30 +401,39 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
|||
}
|
||||
|
||||
// Update file record path to reflect new parent
|
||||
if parent != nil && parent.FsPath != "" {
|
||||
rows, err := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var fileID, filePath string
|
||||
if err := rows.Scan(&fileID, &filePath); err != nil {
|
||||
continue
|
||||
}
|
||||
if filePath == "" {
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(filePath)
|
||||
newRelPath := filepath.Join(parent.FsPath, filename)
|
||||
oldAbs := filepath.Join(a.vault, filePath)
|
||||
newAbs := filepath.Join(a.vault, newRelPath)
|
||||
type fileMove struct {
|
||||
id, path string
|
||||
}
|
||||
var fileMoves []fileMove
|
||||
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID)
|
||||
if ferr == nil {
|
||||
for frows.Next() {
|
||||
var fm fileMove
|
||||
if err := frows.Scan(&fm.id, &fm.path); err != nil {
|
||||
continue
|
||||
}
|
||||
fileMoves = append(fileMoves, fm)
|
||||
}
|
||||
frows.Close()
|
||||
}
|
||||
|
||||
if _, err := os.Stat(oldAbs); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
|
||||
if err := os.Rename(oldAbs, newAbs); err == nil {
|
||||
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
|
||||
newRelPath, fileID)
|
||||
}
|
||||
}
|
||||
for _, fm := range fileMoves {
|
||||
if fm.path == "" {
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(fm.path)
|
||||
newRelPath := filename
|
||||
if parent != nil && parent.FsPath != "" {
|
||||
newRelPath = filepath.Join(parent.FsPath, filename)
|
||||
}
|
||||
oldAbs := filepath.Join(a.vault, fm.path)
|
||||
newAbs := filepath.Join(a.vault, newRelPath)
|
||||
|
||||
if _, err := os.Stat(oldAbs); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
|
||||
if err := os.Rename(oldAbs, newAbs); err == nil {
|
||||
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
|
||||
newRelPath, fm.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -469,9 +460,13 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
|||
syncEntity = syncsvc.EntityFile
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`)
|
||||
var opFsPath string
|
||||
if isFolderLike && node.FsPath != "" {
|
||||
opFsPath = node.FsPath
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
|
||||
"parent_id": newParentID,
|
||||
"fs_path": node.FsPath,
|
||||
"fs_path": opFsPath,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
|
|
@ -49,14 +50,18 @@ func (a *App) applyRemoteNodeOp(op syncsvc.Op) error {
|
|||
|
||||
func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Section string `json:"section"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
TemplateID string `json:"template_id"`
|
||||
FsPath string `json:"fs_path"`
|
||||
Section string `json:"section"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node create: %w", err)
|
||||
|
|
@ -88,19 +93,68 @@ func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
|
|||
if slug == "" {
|
||||
slug = nodes.Slugify(payload.Title)
|
||||
}
|
||||
|
||||
// Determine fs_path for folder-like nodes
|
||||
fsPath := payload.FsPath
|
||||
if fsPath == "" {
|
||||
isFolderLike := payload.Type != "note" && payload.Type != "file"
|
||||
if isFolderLike {
|
||||
seg := templates.SafeDisplayNameToPathSegment(payload.Title)
|
||||
if seg == "" {
|
||||
seg = "node"
|
||||
}
|
||||
if payload.ParentID != "" {
|
||||
if parent, err := a.nodes.Get(payload.ParentID); err == nil && parent.FsPath != "" {
|
||||
fsPath = filepath.Join(parent.FsPath, seg)
|
||||
}
|
||||
}
|
||||
if fsPath == "" {
|
||||
fsPath = filepath.Join(".verstak", "remote-inbox")
|
||||
}
|
||||
// Ensure unique path
|
||||
fullPath := filepath.Join(a.vault, fsPath)
|
||||
fullPath = templates.UniquePath(fullPath)
|
||||
rel, _ := filepath.Rel(a.vault, fullPath)
|
||||
fsPath = rel
|
||||
}
|
||||
}
|
||||
|
||||
archived := 0
|
||||
if payload.Archived {
|
||||
archived = 1
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,revision,device_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,0,0,?,?,1,NULL)`,
|
||||
payload.ID, parent, payload.Type, payload.Title, slug, "", "",
|
||||
section, payload.CreatedAt, payload.UpdatedAt,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1,NULL)`,
|
||||
payload.ID, parent, payload.Type, payload.Title, slug,
|
||||
payload.TemplateID, fsPath, section, payload.SortOrder, archived,
|
||||
payload.CreatedAt, payload.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create physical folder for folder-like nodes
|
||||
if fsPath != "" {
|
||||
isFolderLike := payload.Type != "note" && payload.Type != "file"
|
||||
if isFolderLike {
|
||||
physPath := filepath.Join(a.vault, fsPath)
|
||||
if err := os.MkdirAll(physPath, 0o755); err != nil {
|
||||
log.Printf("[sync] create folder for remote node %s: %v", payload.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
Title string `json:"title"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Title string `json:"title"`
|
||||
FsPath string `json:"fs_path"`
|
||||
TemplateID string `json:"template_id"`
|
||||
Archived *bool `json:"archived,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node update: %w", err)
|
||||
|
|
@ -109,20 +163,76 @@ func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error {
|
|||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
if payload.Title != "" {
|
||||
slug := nodes.Slugify(payload.Title)
|
||||
|
||||
// Get current node to check if it's folder-like
|
||||
n, err := a.nodes.Get(op.EntityID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
||||
|
||||
if payload.Title != "" || payload.FsPath != "" {
|
||||
if payload.Title != "" {
|
||||
slug := nodes.Slugify(payload.Title)
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
|
||||
payload.Title, slug, now, op.EntityID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if payload.FsPath != "" && isFolderLike {
|
||||
oldFsPath := n.FsPath
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
|
||||
payload.FsPath, now, op.EntityID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Physically rename folder if old path existed
|
||||
if oldFsPath != "" {
|
||||
oldPhys := filepath.Join(a.vault, oldFsPath)
|
||||
newPhys := filepath.Join(a.vault, payload.FsPath)
|
||||
if _, err := os.Stat(oldPhys); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
|
||||
if err := os.Rename(oldPhys, newPhys); err != nil {
|
||||
log.Printf("[sync] rename folder %s -> %s: %v", oldPhys, newPhys, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if payload.TemplateID != "" {
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE nodes SET template_id=?, updated_at=? WHERE id=?`,
|
||||
payload.TemplateID, now, op.EntityID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if payload.Archived != nil {
|
||||
v := 0
|
||||
if *payload.Archived {
|
||||
v = 1
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
|
||||
payload.Title, slug, now, op.EntityID)
|
||||
`UPDATE nodes SET archived=?, updated_at=? WHERE id=?`,
|
||||
v, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
_, err := a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID)
|
||||
|
||||
_, err = a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
FsPath string `json:"fs_path"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
|
|
@ -132,14 +242,82 @@ func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
|
|||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
|
||||
n, err := a.nodes.Get(op.EntityID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
||||
|
||||
// Update parent_id
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
|
||||
parent, now, op.EntityID)
|
||||
return err
|
||||
parent, now, op.EntityID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if payload.FsPath != "" {
|
||||
oldFsPath := n.FsPath
|
||||
|
||||
if _, err := a.db.Exec(
|
||||
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
|
||||
payload.FsPath, now, op.EntityID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isFolderLike && oldFsPath != "" {
|
||||
// Physically move folder
|
||||
oldPhys := filepath.Join(a.vault, oldFsPath)
|
||||
newPhys := filepath.Join(a.vault, payload.FsPath)
|
||||
if _, err := os.Stat(oldPhys); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
|
||||
if err := os.Rename(oldPhys, newPhys); err != nil {
|
||||
log.Printf("[sync] move folder %s -> %s: %v", oldPhys, newPhys, err)
|
||||
}
|
||||
}
|
||||
} else if !isFolderLike {
|
||||
// Move file record path for notes/files
|
||||
type fm struct {
|
||||
id, path string
|
||||
}
|
||||
var fileMoves []fm
|
||||
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, op.EntityID)
|
||||
if ferr == nil {
|
||||
for frows.Next() {
|
||||
var m fm
|
||||
if err := frows.Scan(&m.id, &m.path); err != nil {
|
||||
continue
|
||||
}
|
||||
fileMoves = append(fileMoves, m)
|
||||
}
|
||||
frows.Close()
|
||||
}
|
||||
|
||||
for _, m := range fileMoves {
|
||||
if m.path == "" {
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(m.path)
|
||||
newRelPath := filepath.Join(payload.FsPath, filename)
|
||||
oldAbs := filepath.Join(a.vault, m.path)
|
||||
newAbs := filepath.Join(a.vault, newRelPath)
|
||||
|
||||
if _, err := os.Stat(oldAbs); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
|
||||
if err := os.Rename(oldAbs, newAbs); err == nil {
|
||||
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
|
||||
newRelPath, m.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error {
|
||||
|
|
|
|||
|
|
@ -10,18 +10,18 @@ import (
|
|||
)
|
||||
|
||||
type VaultCheckResult struct {
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
NodesWithFsPath int `json:"nodes_with_fs_path"`
|
||||
FoldersOnDisk int `json:"folders_on_disk"`
|
||||
FilesOnDisk int `json:"files_on_disk"`
|
||||
FilesMissing int `json:"files_missing"`
|
||||
PathEscapeCount int `json:"path_escape_count"`
|
||||
ParentIDEmptyCount int `json:"parent_id_empty_count"`
|
||||
OrphanDescendantCount int `json:"orphan_descendant_count"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Healthy bool `json:"healthy"`
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
NodesWithFsPath int `json:"nodes_with_fs_path"`
|
||||
FoldersOnDisk int `json:"folders_on_disk"`
|
||||
FilesOnDisk int `json:"files_on_disk"`
|
||||
FilesMissing int `json:"files_missing"`
|
||||
PathEscapeCount int `json:"path_escape_count"`
|
||||
ParentIDEmptyCount int `json:"parent_id_empty_count"`
|
||||
OrphanDescendantCount int `json:"orphan_descendant_count"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
func (a *App) VaultCheck() (*VaultCheckResult, error) {
|
||||
|
|
@ -103,7 +103,7 @@ func (a *App) VaultCheck() (*VaultCheckResult, error) {
|
|||
}
|
||||
|
||||
// Check file records
|
||||
rows, err := a.db.Query(`SELECT id, node_id, path, storage_mode FROM files`)
|
||||
rows, err := a.db.Query(`SELECT f.id, f.node_id, f.path, f.storage_mode FROM files f LEFT JOIN nodes n ON f.node_id = n.id WHERE n.deleted_at IS NULL`)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("query files: %v", err))
|
||||
result.Healthy = false
|
||||
|
|
|
|||
|
|
@ -347,6 +347,191 @@ func TestVaultLayout_VaultCheck(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_RenameFileNodeUsesEntityFile(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default")
|
||||
|
||||
// Create a file node with file record
|
||||
fileNode, err := app.nodes.Create(&parent.ID, "file", "test.txt", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create file node: %v", err)
|
||||
}
|
||||
src := filepath.Join(vault, "src.txt")
|
||||
os.WriteFile(src, []byte("data"), 0o640)
|
||||
_, err = app.files.CopyIntoVault(fileNode.ID, src, parent.FsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("copy into vault: %v", err)
|
||||
}
|
||||
|
||||
// Rename the file node
|
||||
if err := app.RenameNode(fileNode.ID, "renamed.txt"); err != nil {
|
||||
t.Fatalf("rename file node: %v", err)
|
||||
}
|
||||
|
||||
// Check the recorded sync op
|
||||
ops, err := app.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("get ops: %v", err)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, op := range ops {
|
||||
if op.EntityID == fileNode.ID && op.OpType == syncsvc.OpUpdate {
|
||||
if op.EntityType != syncsvc.EntityFile {
|
||||
t.Errorf("expected EntityFile, got %q", op.EntityType)
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected sync OpUpdate for file node")
|
||||
}
|
||||
|
||||
// Verify physical file was renamed on disk
|
||||
records, _ := app.files.ListByNode(fileNode.ID)
|
||||
if len(records) > 0 && records[0].Filename != "renamed.txt" {
|
||||
t.Errorf("expected filename 'renamed.txt', got %q", records[0].Filename)
|
||||
}
|
||||
oldPath := filepath.Join(vault, parent.FsPath, "test.txt")
|
||||
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
||||
t.Error("expected old file name to not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_MoveNoteToRoot(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default")
|
||||
noteNode, _, err := app.notes.Create(parent.ID, "MyNote", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
// Verify note file exists in parent folder
|
||||
notePath := filepath.Join(vault, "Parent", "MyNote.md")
|
||||
altPath := filepath.Join(vault, "Parent", "Моя_заметка.md")
|
||||
if _, err := os.Stat(notePath); os.IsNotExist(err) {
|
||||
if _, err2 := os.Stat(altPath); os.IsNotExist(err2) {
|
||||
// Try other variants
|
||||
entries, _ := os.ReadDir(filepath.Join(vault, "Parent"))
|
||||
t.Logf("note file candidates: %v", listNames(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// Move note to root
|
||||
if err := app.MoveNode(noteNode.ID, ""); err != nil {
|
||||
t.Fatalf("move note to root: %v", err)
|
||||
}
|
||||
|
||||
// Verify note node has no parent
|
||||
n, err := app.nodes.GetActive(noteNode.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get note: %v", err)
|
||||
}
|
||||
if !n.IsRoot() {
|
||||
t.Error("expected note to be root after move")
|
||||
}
|
||||
|
||||
// Verify file record path was updated
|
||||
records, _ := app.files.ListByNode(noteNode.ID)
|
||||
if len(records) > 0 {
|
||||
expectedFileBase := filepath.Base(records[0].Path)
|
||||
// The file should be at vault root, not inside Parent/
|
||||
if records[0].Path != expectedFileBase {
|
||||
t.Errorf("expected file path at root, got %q", records[0].Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_DeleteFolderLeavesVaultCheckHealthy(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Create folder structure with files
|
||||
parent, _ := app.CreateNodeFromTemplate("", "ToDelete", "folder.default")
|
||||
child, _ := app.CreateNodeFromTemplate(parent.ID, "Child", "folder.default")
|
||||
|
||||
// Create a file inside the folder
|
||||
src := filepath.Join(vault, "test.txt")
|
||||
os.WriteFile(src, []byte("data"), 0o640)
|
||||
fileNode, err := app.nodes.Create(&child.ID, "file", "file.txt", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create file node: %v", err)
|
||||
}
|
||||
app.files.CopyIntoVault(fileNode.ID, src, child.FsPath)
|
||||
|
||||
// Create a note inside the folder
|
||||
app.notes.Create(child.ID, "Note", "")
|
||||
|
||||
// Delete via GUI
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
// VaultCheck should be healthy
|
||||
result, err := app.VaultCheck()
|
||||
if err != nil {
|
||||
t.Fatalf("vault check: %v", err)
|
||||
}
|
||||
if !result.Healthy {
|
||||
t.Errorf("expected healthy vault after delete, errors: %v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_SyncNodeCreatePreservesFields(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
// Simulate a remote node create op with all fields
|
||||
op := syncsvc.Op{
|
||||
EntityType: syncsvc.EntityNode,
|
||||
EntityID: "test-remote-id-1",
|
||||
OpType: syncsvc.OpCreate,
|
||||
PayloadJSON: `{
|
||||
"id": "test-remote-id-1",
|
||||
"parent_id": "",
|
||||
"type": "folder",
|
||||
"title": "RemoteFolder",
|
||||
"slug": "remote-folder",
|
||||
"template_id": "folder.default",
|
||||
"fs_path": "RemoteFolder",
|
||||
"section": "",
|
||||
"sort_order": 5,
|
||||
"archived": false,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}`,
|
||||
}
|
||||
|
||||
if err := app.applyRemoteNodeCreate(op); err != nil {
|
||||
t.Fatalf("apply remote create: %v", err)
|
||||
}
|
||||
|
||||
// Verify the node was created with all fields
|
||||
n, err := app.nodes.Get("test-remote-id-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get node: %v", err)
|
||||
}
|
||||
if n.TemplateID != "folder.default" {
|
||||
t.Errorf("expected template_id 'folder.default', got %q", n.TemplateID)
|
||||
}
|
||||
if n.FsPath != "RemoteFolder" {
|
||||
t.Errorf("expected fs_path 'RemoteFolder', got %q", n.FsPath)
|
||||
}
|
||||
if n.SortOrder != 5 {
|
||||
t.Errorf("expected sort_order 5, got %d", n.SortOrder)
|
||||
}
|
||||
if n.Archived {
|
||||
t.Error("expected archived false")
|
||||
}
|
||||
|
||||
// Verify physical folder was created
|
||||
physPath := filepath.Join(app.vault, "RemoteFolder")
|
||||
if _, err := os.Stat(physPath); os.IsNotExist(err) {
|
||||
t.Error("expected physical folder to be created")
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func listNames(entries []os.DirEntry) []string {
|
||||
|
|
|
|||
|
|
@ -102,12 +102,24 @@ func validateName(name string) error {
|
|||
|
||||
// vaultPath resolves a relative vault path and checks it stays within jail.
|
||||
func (s *Service) vaultPath(rel string) (string, error) {
|
||||
abs := filepath.Join(s.vaultRoot, rel)
|
||||
cleaned := filepath.Clean(abs)
|
||||
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
|
||||
return "", fmt.Errorf("path escapes vault root")
|
||||
if rel == "" {
|
||||
return "", fmt.Errorf("empty path")
|
||||
}
|
||||
return cleaned, nil
|
||||
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.
|
||||
|
|
@ -447,11 +459,13 @@ func (s *Service) DeleteNodeAndChildren(nodeID string) error {
|
|||
}
|
||||
}
|
||||
_ = s.deleteFileRecords(nodeID)
|
||||
// Move physical folder to trash if the node has fs_path
|
||||
n, err := s.nodes.GetActive(nodeID)
|
||||
if err == nil && n.FsPath != "" {
|
||||
src := filepath.Join(s.vaultRoot, n.FsPath)
|
||||
if info, err := os.Stat(src); err == nil && info.IsDir() {
|
||||
src, vaultErr := s.vaultPath(n.FsPath)
|
||||
if vaultErr != nil {
|
||||
src = filepath.Join(s.vaultRoot, n.FsPath)
|
||||
}
|
||||
if info, statErr := os.Stat(src); statErr == nil && info.IsDir() {
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
os.MkdirAll(trashDir, 0o750)
|
||||
trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))
|
||||
|
|
|
|||
|
|
@ -305,6 +305,42 @@ func TestPreviewImportDir(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVaultPathSiblingPrefixEscape(t *testing.T) {
|
||||
vaultRoot := "/tmp/vault"
|
||||
svc := &Service{vaultRoot: vaultRoot}
|
||||
|
||||
// Normal path should pass
|
||||
_, err := svc.vaultPath("some/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected OK, got: %v", err)
|
||||
}
|
||||
|
||||
// Sibling-prefix escape should fail
|
||||
// vault=/tmp/vault, path goes to /tmp/vault_evil -> should be rejected
|
||||
_, err = svc.vaultPath("../vault_evil/file.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for sibling-prefix escape, got nil")
|
||||
}
|
||||
|
||||
// Direct escape with ../..
|
||||
_, err = svc.vaultPath("../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for path escape, got nil")
|
||||
}
|
||||
|
||||
// Absolute path
|
||||
_, err = svc.vaultPath("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path, got nil")
|
||||
}
|
||||
|
||||
// Empty path
|
||||
_, err = svc.vaultPath("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessMIME(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a.md": "text/plain",
|
||||
|
|
|
|||
Loading…
Reference in New Issue