File manager: selection, keyboard shortcuts, rename modal, security, localization

- Selection model: click=select, dblclick=open, Ctrl+click toggle,
  Shift+click range select, Ctrl+A, Esc to clear
- Keyboard shortcuts: Enter, Ctrl+Enter, F2, Backspace (navigate up),
  Delete/Backspace (delete with confirm)
- Rename modal replaces prompt() with validation via backend ValidateName
- Context menu: Open, Open External, Show in Folder, Rename, Duplicate,
  Copy, Cut, Delete — all with SVG icons and Russian labels
- Backend security: vaultPath/absPathSafe helpers prevent path traversal,
  validateName rejects .. / \ null bytes empty overlong names
- MoveNode auto-renames on name conflict (copy style)
- Duplicate uses (copy) (copy 2) suffix pattern
- Russian localization: all file type labels, preview messages, tooltips
- FilePreviewModal: fixed broken {/if} tag
This commit is contained in:
2026-06-01 01:35:45 +08:00
parent 2487d3bbaa
commit a4ae22c445
9 changed files with 446 additions and 125 deletions
+120 -18
View File
@@ -60,6 +60,59 @@ 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) {
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")
}
return cleaned, 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.
@@ -84,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
}
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); 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)
}
@@ -105,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
}
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)
}
@@ -149,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
return err
}
if rec.StorageMode == "vault" {
src := filepath.Join(s.vaultRoot, rec.Path)
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 err := os.Rename(src, dest); err != nil {
return fmt.Errorf("move to trash: %w", err)
}
@@ -169,11 +235,9 @@ func (s *Service) Open(id string) error {
if err != nil {
return err
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
abs, err := s.absPathSafe(rec)
if err != nil {
return err
}
return openWithSystem(abs)
}
@@ -190,11 +254,9 @@ func (s *Service) ReadText(id string) (string, error) {
if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
abs, err := s.absPathSafe(rec)
if err != nil {
return "", err
}
b, err := os.ReadFile(abs)
if err != nil {
@@ -212,11 +274,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
abs, err := s.absPathSafe(rec)
if err != nil {
return "", err
}
b, err := os.ReadFile(abs)
if err != nil {
@@ -231,6 +291,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
// 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(parentID, nodes.TypeFile, filename, "")
if err != nil {
@@ -247,6 +310,10 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
}
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
}
@@ -261,7 +328,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
if original.ParentID != nil {
parentID = *original.ParentID
}
newName := s.uniqueTitle(parentID, original.Title)
newName := s.copyTitle(parentID, original.Title)
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
if err != nil {
return nil, err
@@ -271,7 +338,10 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
if len(records) > 0 {
src := &records[0]
if src.StorageMode == "vault" {
srcPath := filepath.Join(s.vaultRoot, src.Path)
srcPath, err := s.vaultPath(src.Path)
if err != nil {
return nil, err
}
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
os.MkdirAll(dir, 0o750)
dst := filepath.Join(dir, newName)
@@ -280,6 +350,9 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
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
@@ -452,6 +525,35 @@ func (s *Service) uniqueTitle(parentID, desired string) string {
}
}
// 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) {