473 lines
12 KiB
Go
473 lines
12 KiB
Go
package files
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"mime"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
|
)
|
|
|
|
type Service struct {
|
|
vault *vault.Vault
|
|
}
|
|
|
|
func NewService(v *vault.Vault) *Service {
|
|
return &Service{vault: v}
|
|
}
|
|
|
|
func (s *Service) ListVaultFiles(relativeDir string) ([]FileEntry, error) {
|
|
root, err := s.vaultRoot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rel, err := NormalizeRelativeDir(relativeDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
full, err := s.resolve(root, rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := os.Stat(full)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("not-found: %s", rel)
|
|
}
|
|
return nil, err
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("not-directory: %s", rel)
|
|
}
|
|
|
|
dirEntries, err := os.ReadDir(full)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries := make([]FileEntry, 0, len(dirEntries))
|
|
for _, dirEntry := range dirEntries {
|
|
childRel := joinRel(rel, dirEntry.Name())
|
|
if IsReservedPathNoNormalize(childRel) {
|
|
continue
|
|
}
|
|
info, err := dirEntry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
entries = append(entries, makeEntry(childRel, info))
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (s *Service) GetVaultFileMetadata(relativePath string) (FileMetadata, error) {
|
|
root, rel, full, err := s.resolveFile(relativePath)
|
|
if err != nil {
|
|
return FileMetadata{}, err
|
|
}
|
|
if err := rejectSymlinkPath(root, rel, false); err != nil {
|
|
return FileMetadata{}, err
|
|
}
|
|
info, err := os.Lstat(full)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return FileMetadata{}, fmt.Errorf("not-found: %s", rel)
|
|
}
|
|
return FileMetadata{}, err
|
|
}
|
|
return makeMetadata(rel, info), nil
|
|
}
|
|
|
|
func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
|
|
root, rel, full, err := s.resolveFile(relativePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
|
return "", err
|
|
}
|
|
info, err := os.Lstat(full)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", fmt.Errorf("not-found: %s", rel)
|
|
}
|
|
return "", err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", fmt.Errorf("symlink-not-allowed: %s", rel)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return "", fmt.Errorf("not-regular-file: %s", rel)
|
|
}
|
|
if info.Size() > MaxTextFileBytes {
|
|
return "", fmt.Errorf("file-too-large: %s", rel)
|
|
}
|
|
data, err := os.ReadFile(full)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !utf8.Valid(data) {
|
|
return "", fmt.Errorf("not-text-file: %s", rel)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
|
|
root, rel, full, err := s.resolveFile(relativePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
|
return err
|
|
}
|
|
|
|
parent := filepath.Dir(full)
|
|
if info, err := os.Stat(parent); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
|
|
}
|
|
return err
|
|
} else if !info.IsDir() {
|
|
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
|
|
}
|
|
|
|
existing, err := os.Lstat(full)
|
|
if err == nil {
|
|
if existing.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("symlink-not-allowed: %s", rel)
|
|
}
|
|
if !existing.Mode().IsRegular() {
|
|
return fmt.Errorf("not-regular-file: %s", rel)
|
|
}
|
|
if !options.Overwrite {
|
|
return fmt.Errorf("conflict: %s", rel)
|
|
}
|
|
} else if os.IsNotExist(err) {
|
|
if !options.CreateIfMissing {
|
|
return fmt.Errorf("not-found: %s", rel)
|
|
}
|
|
} else {
|
|
return err
|
|
}
|
|
|
|
tmp, err := os.CreateTemp(parent, ".verstak-write-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
cleanup := true
|
|
defer func() {
|
|
if cleanup {
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
}()
|
|
if _, err := tmp.WriteString(content); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, full); err != nil {
|
|
return err
|
|
}
|
|
cleanup = false
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) CreateVaultFolder(relativePath string) error {
|
|
root, rel, full, err := s.resolveFile(relativePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Lstat(full); err == nil {
|
|
return fmt.Errorf("conflict: %s", rel)
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
|
return err
|
|
}
|
|
parent := filepath.Dir(full)
|
|
if info, err := os.Stat(parent); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
|
|
}
|
|
return err
|
|
} else if !info.IsDir() {
|
|
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
|
|
}
|
|
return os.Mkdir(full, 0o755)
|
|
}
|
|
|
|
func (s *Service) MoveVaultPath(fromRelativePath string, toRelativePath string, options MoveOptions) error {
|
|
root, fromRel, fromFull, err := s.resolveFile(fromRelativePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, toRel, toFull, err := s.resolveFile(toRelativePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fromRel == "" || toRel == "" {
|
|
return fmt.Errorf("invalid-path: cannot move root")
|
|
}
|
|
if err := rejectSymlinkPath(root, fromRel, true); err != nil {
|
|
return err
|
|
}
|
|
if err := rejectSymlinkPath(root, toRel, false); err != nil {
|
|
return err
|
|
}
|
|
fromInfo, err := os.Lstat(fromFull)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("not-found: %s", fromRel)
|
|
}
|
|
return err
|
|
}
|
|
if fromInfo.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("symlink-not-allowed: %s", fromRel)
|
|
}
|
|
if fromInfo.IsDir() && (toRel == fromRel || strings.HasPrefix(toRel, fromRel+"/")) {
|
|
return fmt.Errorf("move-into-self: %s -> %s", fromRel, toRel)
|
|
}
|
|
parent := filepath.Dir(toFull)
|
|
if info, err := os.Stat(parent); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("parent-not-found: %s", pathDir(toRel))
|
|
}
|
|
return err
|
|
} else if !info.IsDir() {
|
|
return fmt.Errorf("parent-not-directory: %s", pathDir(toRel))
|
|
}
|
|
if _, err := os.Lstat(toFull); err == nil && !options.Overwrite {
|
|
return fmt.Errorf("conflict: %s", toRel)
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return os.Rename(fromFull, toFull)
|
|
}
|
|
|
|
func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) {
|
|
root, rel, full, err := s.resolveFile(relativePath)
|
|
if err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
info, err := os.Lstat(full)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return TrashResult{}, fmt.Errorf("not-found: %s", rel)
|
|
}
|
|
return TrashResult{}, err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return TrashResult{}, fmt.Errorf("symlink-not-allowed: %s", rel)
|
|
}
|
|
|
|
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
|
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
|
|
trashRel := filepath.ToSlash(filepath.Join(".verstak", "trash", "files", trashID, filepath.Base(rel)))
|
|
trashFull := filepath.Join(root, filepath.FromSlash(trashRel))
|
|
if err := os.MkdirAll(filepath.Dir(trashFull), 0o755); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
if err := os.Rename(full, trashFull); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
result := TrashResult{
|
|
OriginalPath: rel,
|
|
TrashPath: trashRel,
|
|
TrashID: trashID,
|
|
DeletedAt: deletedAt,
|
|
}
|
|
meta := map[string]string{
|
|
"originalPath": rel,
|
|
"trashPath": trashRel,
|
|
"trashId": trashID,
|
|
"deletedAt": deletedAt,
|
|
"originalType": string(fileTypeFromInfo(info)),
|
|
"basename": filepath.Base(rel),
|
|
"type": string(fileTypeFromInfo(info)),
|
|
}
|
|
data, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, ".verstak", "trash", "files", trashID, "metadata.json"), data, 0o644); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) vaultRoot() (string, error) {
|
|
if s == nil || s.vault == nil {
|
|
return "", fmt.Errorf("vault-not-initialized")
|
|
}
|
|
if s.vault.GetVaultStatus() != vault.StatusOpen {
|
|
return "", fmt.Errorf("vault-not-open")
|
|
}
|
|
root := s.vault.GetVaultPath()
|
|
if root == "" {
|
|
return "", fmt.Errorf("vault-not-open")
|
|
}
|
|
return root, nil
|
|
}
|
|
|
|
func (s *Service) resolveFile(relativePath string) (string, string, string, error) {
|
|
root, err := s.vaultRoot()
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
rel, err := NormalizeRelativeFile(relativePath)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
full, err := s.resolve(root, rel)
|
|
return root, rel, full, err
|
|
}
|
|
|
|
func (s *Service) resolve(root, rel string) (string, error) {
|
|
full := filepath.Join(root, filepath.FromSlash(rel))
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
absFull, err := filepath.Abs(full)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
relToRoot, err := filepath.Rel(absRoot, absFull)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) || filepath.IsAbs(relToRoot) {
|
|
return "", fmt.Errorf("invalid-path: path-traversal")
|
|
}
|
|
return absFull, nil
|
|
}
|
|
|
|
func rejectSymlinkPath(root, rel string, includeFinal bool) error {
|
|
if rel == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(rel, "/")
|
|
limit := len(parts)
|
|
if !includeFinal {
|
|
limit--
|
|
}
|
|
current := root
|
|
for i := 0; i < limit; i++ {
|
|
current = filepath.Join(current, filepath.FromSlash(parts[i]))
|
|
info, err := os.Lstat(current)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("symlink-not-allowed: %s", strings.Join(parts[:i+1], "/"))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func makeEntry(rel string, info fs.FileInfo) FileEntry {
|
|
t := fileTypeFromInfo(info)
|
|
return FileEntry{
|
|
Name: info.Name(),
|
|
RelativePath: rel,
|
|
Type: t,
|
|
Size: sizeForType(t, info),
|
|
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
|
|
Extension: strings.TrimPrefix(filepath.Ext(info.Name()), "."),
|
|
IsHidden: strings.HasPrefix(info.Name(), "."),
|
|
IsReserved: IsReservedPathNoNormalize(rel),
|
|
CanRead: t == FileTypeFile || t == FileTypeFolder,
|
|
CanWrite: t == FileTypeFile || t == FileTypeFolder,
|
|
}
|
|
}
|
|
|
|
func makeMetadata(rel string, info fs.FileInfo) FileMetadata {
|
|
t := fileTypeFromInfo(info)
|
|
ext := strings.TrimPrefix(filepath.Ext(info.Name()), ".")
|
|
return FileMetadata{
|
|
RelativePath: rel,
|
|
Type: t,
|
|
Size: sizeForType(t, info),
|
|
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
|
|
Extension: ext,
|
|
MimeHint: mime.TypeByExtension(filepath.Ext(info.Name())),
|
|
IsText: isTextExtension(ext),
|
|
IsHidden: strings.HasPrefix(info.Name(), "."),
|
|
IsReserved: IsReservedPathNoNormalize(rel),
|
|
CanRead: t == FileTypeFile || t == FileTypeFolder,
|
|
CanWrite: t == FileTypeFile || t == FileTypeFolder,
|
|
}
|
|
}
|
|
|
|
func fileTypeFromInfo(info fs.FileInfo) FileType {
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return FileTypeSymlink
|
|
}
|
|
if info.IsDir() {
|
|
return FileTypeFolder
|
|
}
|
|
if info.Mode().IsRegular() {
|
|
return FileTypeFile
|
|
}
|
|
return FileTypeUnknown
|
|
}
|
|
|
|
func sizeForType(t FileType, info fs.FileInfo) int64 {
|
|
if t == FileTypeFolder {
|
|
return 0
|
|
}
|
|
return info.Size()
|
|
}
|
|
|
|
func isTextExtension(ext string) bool {
|
|
switch strings.ToLower(ext) {
|
|
case "txt", "md", "markdown", "json", "yaml", "yml", "toml", "csv", "log", "xml", "html", "css", "js", "ts", "svelte", "go":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func joinRel(parent, name string) string {
|
|
if parent == "" {
|
|
return name
|
|
}
|
|
return parent + "/" + name
|
|
}
|
|
|
|
func pathDir(rel string) string {
|
|
dir := pathDirSlash(rel)
|
|
if dir == "." {
|
|
return ""
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func pathDirSlash(rel string) string {
|
|
idx := strings.LastIndex(rel, "/")
|
|
if idx < 0 {
|
|
return "."
|
|
}
|
|
return rel[:idx]
|
|
}
|