verstak-desktop/internal/core/files/service.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]
}