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:
mirivlad 2026-06-01 01:35:45 +08:00
parent 2487d3bbaa
commit a4ae22c445
9 changed files with 446 additions and 125 deletions

View File

@ -315,7 +315,30 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
return a.nodes.UpdateTitle(nodeID, newTitle)
}
func (a *App) ValidateName(name string) error {
return files.ValidateName(name)
}
func (a *App) MoveNode(nodeID, newParentID string) error {
// Check for name conflict at destination
destChildren, err := a.nodes.ListChildren(newParentID, false)
if err != nil {
return err
}
node, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
for i := range destChildren {
if destChildren[i].Title == node.Title {
// Conflict: auto-rename
newName := a.files.UniqueTitleCopy(newParentID, node.Title)
if err := a.nodes.UpdateTitle(nodeID, newName); err != nil {
return err
}
break
}
}
return a.nodes.Move(nodeID, newParentID, 0)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
<script type="module" crossorigin src="/assets/main-B8EIu1OK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bo58X7Pc.css">
</head>
<body>
<div id="app"></div>

View File

@ -72,6 +72,11 @@
let confirmAction = null
let cancelAction = null
let showRename = false
let renameId = ''
let renameValue = ''
let renameError = ''
const tabs = [
{ id: 'overview', label: 'Обзор' },
{ id: 'notes', label: 'Заметки' },
@ -282,19 +287,8 @@
}
function renameItem(id) {
const name = prompt('Новое имя:')
if (!name || !name.trim()) return
renameSubmit(id, name.trim())
}
async function renameSubmit(id, newName) {
try {
await wailsCall('RenameNode', id, newName)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
} catch (e) {
error = String(e)
}
const item = fileItems.find(x => x.id === id)
if (item) openRename(item.id, item.name)
}
function cutItem(id) {
@ -343,6 +337,20 @@
selectedIds = fileItems.map(x => x.id)
}
function rangeSelect(id) {
if (fileItems.length === 0) return
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
const lastIdx = fileItems.findIndex(x => x.id === lastId)
const curIdx = fileItems.findIndex(x => x.id === id)
if (lastIdx === -1 || curIdx === -1) return
const start = Math.min(lastIdx, curIdx)
const end = Math.max(lastIdx, curIdx)
const range = fileItems.slice(start, end + 1).map(x => x.id)
const set = new Set(selectedIds)
range.forEach(x => set.add(x))
selectedIds = [...set]
}
function clearSelection() {
selectedIds = []
}
@ -421,15 +429,106 @@
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c') { e.preventDefault(); copySelected() }
else if (e.key === 'x') { e.preventDefault(); cutSelected() }
else if (e.key === 'v') { e.preventDefault(); pasteItem() }
else if (e.key === 'a') { e.preventDefault(); selectAll() }
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
} else if (e.key === 'Enter') {
e.preventDefault()
openSelected()
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
if (previewItem) { e.preventDefault(); closePreview(); return }
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
// Backspace without selection → navigate up
if (e.key === 'Backspace' && folderStack.length > 0) {
e.preventDefault()
navigateBack()
return
}
} else if (e.key === 'Escape') {
if (previewItem) { closePreview(); return }
if (selectedIds.length > 0) { clearSelection(); return }
} else if (e.key === 'F2') {
e.preventDefault()
openRenameForSelection()
}
}
function openSelected() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
if (item.type === 'folder') {
navigateToFolder(item.id)
} else {
openPreview(item)
}
}
}
}
function openSelectedExternal() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item && item.fileId) {
wailsCall('OpenFile', item.fileId)
}
}
}
// ===== Rename modal =====
function openRename(id, currentName) {
renameId = id
renameValue = currentName
renameError = ''
showRename = true
}
function openRenameForSelection() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
openRename(item.id, item.name)
}
}
}
async function submitRename() {
const name = renameValue.trim()
if (!name) { renameError = 'Имя не может быть пустым'; return }
// Validate name via backend
try {
await wailsCall('ValidateName', name)
} catch (e) {
renameError = 'Недопустимое имя'
return
}
showRename = false
renameId = ''
try {
await wailsCall('RenameNode', renameId, name)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
} catch (e) {
error = String(e)
}
}
function cancelRename() {
showRename = false
renameId = ''
renameValue = ''
renameError = ''
}
function onRenameKeydown(e) {
if (e.key === 'Enter') submitRename()
else renameError = ''
}
// ===== Confirm modal =====
function openConfirm(opts) {
@ -832,7 +931,7 @@
</div>
{:else}
{#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
const i = e.detail
if (i === 0) {
folderStack = []
@ -847,7 +946,7 @@
Back
</button>
{:else}
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
{/if}
{#if fileItems.length === 0}
@ -877,6 +976,7 @@
on:navigate={(e) => navigateToFolder(e.detail)}
on:preview={(e) => openPreview(e.detail)}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
on:delete={(e) => deleteFile(e.detail)}
on:rename={(e) => renameItem(e.detail.id)}
on:duplicate={(e) => duplicateItem(e.detail)}
@ -884,6 +984,7 @@
on:copy={(e) => copyItem(e.detail)}
on:selectOne={(e) => selectOne(e.detail)}
on:toggleSelect={(e) => toggleSelection(e.detail)}
on:rangeSelect={(e) => rangeSelect(e.detail)}
/>
{/each}
</div>
@ -1007,6 +1108,26 @@
</div>
{/if}
{#if showRename}
<div class="modal-overlay" on:click|self={cancelRename}>
<div class="modal">
<h3>Переименовать</h3>
<div class="form-group">
<label>Новое имя</label>
<input type="text" bind:value={renameValue}
on:keydown={onRenameKeydown} />
</div>
{#if renameError}
<div class="rename-error">{renameError}</div>
{/if}
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
<button class="btn" on:click={cancelRename}>Отмена</button>
</div>
</div>
</div>
{/if}
{#if showConfirm}
<ConfirmModal
title={confirmTitle}
@ -1159,4 +1280,6 @@
.import-summary { margin-bottom: 16px; }
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
</style>

View File

@ -1,7 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte'
import FileIcon from './lib/FileIcon.svelte'
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
export let item
export let selected = false
@ -13,6 +13,7 @@
const kind = getFileKind(item)
const isFolder = item.type === 'folder'
const fileType = formatFileType(item)
let menuOpen = false
let clickTimer = null
@ -25,7 +26,7 @@
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
// Double click
// Double click → открыть
if (isFolder) {
dispatch('navigate', item.id)
} else {
@ -34,37 +35,23 @@
} else {
clickTimer = setTimeout(() => {
clickTimer = null
// Single click: select
if (selected) {
// Already selected: navigate/preview
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
} else {
dispatch('selectOne', item.id)
}
// Single click → выделить
dispatch('selectOne', item.id)
}, 250)
}
}
}
function handleKeydown(e) {
if (e.key === 'Enter') {
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
}
}
function handleOpenExternal() {
dispatch('openExternal', item.fileId)
}
function handleShowInFolder() {
dispatch('showInFolder', item.id)
}
function handleDelete() {
menuOpen = false
dispatch('delete', { id: item.id, type: item.type })
}
@ -88,6 +75,15 @@
dispatch('copy', item.id)
}
function handleOpen() {
menuOpen = false
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
}
function toggleMenu() {
menuOpen = !menuOpen
}
@ -107,48 +103,48 @@
function handleDrop(e) {
if (onDrop && isFolder) onDrop(e, item.id)
}
function oncontextmenu(e) {
e.preventDefault()
menuOpen = true
}
</script>
<svelte:window on:click={closeMenu}/>
<div class="file-row"
class:file-row--selected={selected}
class:file-row--dragover={false}
role="button"
tabindex="0"
draggable="true"
on:click={handleClick}
on:keydown={handleKeydown}
on:contextmenu={oncontextmenu}
on:dragstart={handleDragStart}
on:dragover={handleDragOver}
on:drop={handleDrop}
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
<div class="file-row-icon">
<FileIcon {kind} size={22}/>
</div>
<div class="file-row-body">
<div class="file-row-name" title={item.name}>{item.name}</div>
<div class="file-row-meta">
{#if isFolder}
<span>Folder</span>
{:else}
<span>{fileType}</span>
{#if !isFolder}
<span class="meta-sep">·</span>
<span>{formatFileSize(item.size)}</span>
{#if item.mime}
<span class="meta-sep">·</span>
<span>{formatMimeType(item.mime)}</span>
{/if}
{/if}
</div>
</div>
<div class="file-row-actions">
{#if !isFolder}
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
@ -156,21 +152,21 @@
</svg>
</button>
{:else}
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
</button>
{/if}
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
@ -183,10 +179,42 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
<div class="menu" on:click|stopPropagation role="menu">
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
<button class="menu-item" on:click={handleOpen} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
Открыть
</button>
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Открыть во внешней программе
</button>
{#if isFolder}
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
Показать в проводнике
</button>
{/if}
<div class="menu-sep"></div>
<button class="menu-item" on:click={handleRename} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Переименовать
</button>
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Дублировать
</button>
<button class="menu-item" on:click={handleCut} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
Вырезать
</button>
<button class="menu-item" on:click={handleCopy} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Копировать
</button>
<div class="menu-sep"></div>
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Удалить
</button>
</div>
{/if}
@ -201,6 +229,7 @@
transition: background 0.12s;
min-height: 52px;
user-select: none;
position: relative;
}
.file-row:hover {
@ -314,17 +343,19 @@
margin-top: 4px;
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 6px;
border-radius: 8px;
padding: 4px;
z-index: 100;
min-width: 140px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
min-width: 220px;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
.menu-item {
display: block;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
padding: 7px 10px;
border: none;
background: transparent;
color: #ccc;
@ -337,10 +368,25 @@
.menu-item:hover {
background: #2a2a3c;
color: #fff;
}
.menu-item-danger {
color: #ff6b6b;
}
.menu-item-danger:hover {
background: #3a2222;
}
.menu-item:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
.menu-sep {
height: 1px;
background: #2a2a3c;
margin: 4px 8px;
}
</style>

View File

@ -43,7 +43,7 @@
</div>
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
<div class="preview-actions">
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
@ -60,11 +60,11 @@
</header>
<div class="preview-body">
{#if loading}
<div class="preview-status"><p>Loading preview...</p></div>
<div class="preview-status"><p>Загрузка...</p></div>
{:else if error}
<div class="preview-status">
<p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{:else if showImage && content}
<div class="preview-image-container">
@ -79,14 +79,14 @@
</div>
{:else}
<div class="preview-status">
<p>PDF preview is not available in this environment.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
<p>Предпросмотр PDF недоступен.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{/if}
{:else}
<div class="preview-status">
<p>Preview is not available for this file type.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
<p>Предпросмотр недоступен для этого типа файлов.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{/if}
</div>

View File

@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
}
const mimeLabels = {
'image/jpeg': 'JPEG image',
'image/png': 'PNG image',
'image/gif': 'GIF image',
'image/webp': 'WebP image',
'image/svg+xml': 'SVG image',
'image/bmp': 'BMP image',
'image/tiff': 'TIFF image',
'image/avif': 'AVIF image',
'application/pdf': 'PDF document',
'application/msword': 'Word document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
'application/vnd.ms-excel': 'Excel spreadsheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
'application/zip': 'ZIP archive',
'application/gzip': 'GZIP archive',
'application/x-tar': 'TAR archive',
'application/x-7z-compressed': '7z archive',
'application/x-rar-compressed': 'RAR archive',
'text/plain': 'Text file',
'text/html': 'HTML file',
'text/css': 'CSS file',
'text/javascript': 'JavaScript file',
'application/json': 'JSON file',
'application/xml': 'XML file',
'application/x-yaml': 'YAML file',
'application/octet-stream': 'Binary file',
'application/x-msdos-program': 'Executable',
'inode/directory': 'Folder',
'image/jpeg': 'Изображение JPEG',
'image/png': 'Изображение PNG',
'image/gif': 'Изображение GIF',
'image/webp': 'Изображение WebP',
'image/svg+xml': 'Изображение SVG',
'image/bmp': 'Изображение BMP',
'image/tiff': 'Изображение TIFF',
'image/avif': 'Изображение AVIF',
'application/pdf': 'PDF документ',
'application/msword': 'Документ Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
'application/vnd.ms-excel': 'Таблица Excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
'application/zip': 'ZIP архив',
'application/gzip': 'GZIP архив',
'application/x-tar': 'TAR архив',
'application/x-7z-compressed': '7z архив',
'application/x-rar-compressed': 'RAR архив',
'text/plain': 'Текстовый файл',
'text/html': 'HTML файл',
'text/css': 'CSS файл',
'text/javascript': 'JavaScript файл',
'application/json': 'JSON файл',
'application/xml': 'XML файл',
'application/x-yaml': 'YAML файл',
'application/octet-stream': 'Бинарный файл',
'application/x-msdos-program': 'Исполняемый файл',
'inode/directory': 'Папка',
}
export function formatMimeType(mime) {
if (!mime) return 'Unknown'
if (!mime) return 'Неизвестно'
return mimeLabels[mime] || mime
}
export function formatFileType(item) {
if (item.type === 'folder') return 'Папка'
const mime = (item.mime || '').toLowerCase()
if (mimeLabels[mime]) return mimeLabels[mime]
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
if (ext) return ext.toUpperCase()
return 'Файл'
}
export function getFileKind(item) {
if (item.type === 'folder') return 'folder'
const mime = (item.mime || '').toLowerCase()

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) {