729 lines
20 KiB
Go
729 lines
20 KiB
Go
package files
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"verstak/internal/core/nodes"
|
|
"verstak/internal/core/storage"
|
|
"verstak/internal/core/templates"
|
|
"verstak/internal/core/util"
|
|
)
|
|
|
|
// Record represents a file entry linked to a node.
|
|
type Record struct {
|
|
ID string `json:"id"`
|
|
NodeID string `json:"node_id"`
|
|
Filename string `json:"filename"`
|
|
Path string `json:"path"` // relative to vault root
|
|
StorageMode string `json:"storage_mode"` // "vault" | "external"
|
|
Size int64 `json:"size"`
|
|
SHA256 string `json:"sha256,omitempty"`
|
|
MIME string `json:"mime,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
|
Missing bool `json:"missing"`
|
|
}
|
|
|
|
// ImportSummary describes a scanned directory before import.
|
|
type ImportSummary struct {
|
|
Files int `json:"files"`
|
|
Folders int `json:"folders"`
|
|
TotalBytes int64 `json:"totalBytes"`
|
|
IsDangerous bool `json:"isDangerous"`
|
|
DangerReason string `json:"dangerReason,omitempty"`
|
|
}
|
|
|
|
// Service provides file operations inside a vault.
|
|
type Service struct {
|
|
db *storage.DB
|
|
vaultRoot string
|
|
nodes *nodes.Repository
|
|
}
|
|
|
|
// NewService creates a file service bound to a vault.
|
|
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository) *Service {
|
|
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo}
|
|
}
|
|
|
|
func (s *Service) parentFsPath(parentID string) string {
|
|
if parentID == "" {
|
|
return ""
|
|
}
|
|
parent, err := s.nodes.GetActive(parentID)
|
|
if err != nil || parent.FsPath == "" {
|
|
return ""
|
|
}
|
|
return parent.FsPath
|
|
}
|
|
|
|
// DB returns the underlying storage.
|
|
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.
|
|
func (s *Service) AddExternal(nodeID, absPath string) (*Record, error) {
|
|
info, err := os.Stat(absPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat: %w", err)
|
|
}
|
|
absPath, _ = filepath.Abs(absPath)
|
|
return s.insertRecord(nodeID, filepath.Base(absPath), absPath, "external", info.Size(), "")
|
|
}
|
|
|
|
// CopyIntoVault copies an external file into the vault.
|
|
// The file lands at <vaultRoot>/<parentFsPath>/<filename>.
|
|
func (s *Service) CopyIntoVault(nodeID, absPath, parentFsPath string) (*Record, error) {
|
|
info, err := os.Stat(absPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat: %w", err)
|
|
}
|
|
if parentFsPath == "" {
|
|
parentFsPath = "."
|
|
}
|
|
|
|
destDir := filepath.Join(s.vaultRoot, parentFsPath)
|
|
if _, err := s.vaultPath(parentFsPath); 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)
|
|
}
|
|
|
|
filename := filepath.Base(absPath)
|
|
dest := filepath.Join(destDir, filename)
|
|
|
|
if _, err := os.Stat(dest); err == nil {
|
|
ext := filepath.Ext(filename)
|
|
name := strings.TrimSuffix(filename, ext)
|
|
dest = filepath.Join(destDir, fmt.Sprintf("%s_%d%s", name, time.Now().Unix(), ext))
|
|
filename = filepath.Base(dest)
|
|
}
|
|
|
|
hash, err := copyAndHash(absPath, dest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("copy: %w", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Get returns a file record by ID.
|
|
func (s *Service) Get(id string) (*Record, error) {
|
|
row := s.db.QueryRow(
|
|
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
|
|
created_at,updated_at,last_seen_at,missing
|
|
FROM files WHERE id = ?`, id)
|
|
return scanRecord(row)
|
|
}
|
|
|
|
// ListByNode returns all files linked to a node.
|
|
func (s *Service) ListByNode(nodeID string) ([]Record, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
|
|
created_at,updated_at,last_seen_at,missing
|
|
FROM files WHERE node_id = ? ORDER BY created_at`, nodeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanRecords(rows)
|
|
}
|
|
|
|
// MarkMissing flags a file as missing.
|
|
func (s *Service) MarkMissing(id string, missing bool) error {
|
|
m := 0
|
|
if missing {
|
|
m = 1
|
|
}
|
|
_, err := s.db.Exec(
|
|
`UPDATE files SET missing=?, updated_at=? WHERE id=?`,
|
|
m, time.Now().UTC().Format(time.RFC3339), id)
|
|
return err
|
|
}
|
|
|
|
// DeleteToTrash moves a vault file to .verstak/trash/ and removes the record.
|
|
func (s *Service) DeleteToTrash(id string) error {
|
|
rec, err := s.Get(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec.StorageMode == "vault" {
|
|
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)
|
|
}
|
|
}
|
|
_, err = s.db.Exec("DELETE FROM files WHERE id=?", id)
|
|
return err
|
|
}
|
|
|
|
// Open launches the file with the system default application.
|
|
func (s *Service) Open(id string) error {
|
|
rec, err := s.Get(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
abs, err := s.absPathSafe(rec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return openWithSystem(abs)
|
|
}
|
|
|
|
// maxPreviewSize is the maximum file size (5 MB) for inline preview.
|
|
const maxPreviewSize = 5 * 1024 * 1024
|
|
|
|
// ReadText reads a file's content as text, up to maxPreviewSize.
|
|
func (s *Service) ReadText(id string) (string, error) {
|
|
rec, err := s.Get(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if rec.Size > maxPreviewSize {
|
|
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
|
}
|
|
abs, err := s.absPathSafe(rec)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read: %w", err)
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// ReadBase64 reads a file and returns a data URI (base64-encoded).
|
|
func (s *Service) ReadBase64(id string) (string, error) {
|
|
rec, err := s.Get(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if rec.Size > maxPreviewSize {
|
|
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
|
}
|
|
abs, err := s.absPathSafe(rec)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read: %w", err)
|
|
}
|
|
mime := rec.MIME
|
|
if mime == "" {
|
|
mime = "application/octet-stream"
|
|
}
|
|
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(b)), nil
|
|
}
|
|
|
|
// 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(strPtr(parentID), nodes.TypeFile, filename, 0, "", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parentFsPath := s.parentFsPath(parentID)
|
|
dir := filepath.Join(s.vaultRoot, parentFsPath)
|
|
if parentFsPath == "" {
|
|
dir = s.vaultRoot
|
|
}
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return nil, fmt.Errorf("mkdir: %w", err)
|
|
}
|
|
dest := filepath.Join(dir, filename)
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create file: %w", err)
|
|
}
|
|
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
|
|
}
|
|
|
|
// Duplicate creates a copy of a node and its file record under the same parent.
|
|
func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|
original, err := s.nodes.GetActive(nodeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parentID := ""
|
|
if original.ParentID != nil {
|
|
parentID = *original.ParentID
|
|
}
|
|
newName := s.copyTitle(parentID, original.Title)
|
|
node, err := s.nodes.Create(strPtr(parentID), original.Type, newName, 0, "", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if original.Type == nodes.TypeFile {
|
|
records, _ := s.ListByNode(original.ID)
|
|
if len(records) > 0 {
|
|
src := &records[0]
|
|
if src.StorageMode == "vault" {
|
|
srcPath, err := s.vaultPath(src.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parentFsPath := s.parentFsPath(parentID)
|
|
dir := filepath.Join(s.vaultRoot, parentFsPath)
|
|
if parentFsPath == "" {
|
|
dir = s.vaultRoot
|
|
}
|
|
os.MkdirAll(dir, 0o750)
|
|
dst := filepath.Join(dir, newName)
|
|
hash, err := copyAndHash(srcPath, dst)
|
|
if err != nil {
|
|
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
|
|
}
|
|
} else {
|
|
// External file: create a new record pointing to the same absolute path.
|
|
_, err = s.insertRecord(node.ID, newName, src.Path, "external", src.Size, src.SHA256)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return node, nil
|
|
}
|
|
|
|
// AddPathCopy copies sourcePath (file or directory) into the vault under nodeID.
|
|
func (s *Service) AddPathCopy(nodeID, sourcePath string) ([]nodes.Node, error) {
|
|
return s.importPath(nodeID, sourcePath, true)
|
|
}
|
|
|
|
// AddPathLink links sourcePath (file or directory) without copying into vault.
|
|
func (s *Service) AddPathLink(nodeID, sourcePath string) ([]nodes.Node, error) {
|
|
return s.importPath(nodeID, sourcePath, false)
|
|
}
|
|
|
|
// PreviewImport scans sourcePath and returns a summary without importing.
|
|
func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) {
|
|
info, err := os.Stat(sourcePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return &ImportSummary{Files: 1, TotalBytes: info.Size()}, nil
|
|
}
|
|
|
|
var sum ImportSummary
|
|
err = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return filepath.SkipDir
|
|
}
|
|
if fi.IsDir() {
|
|
sum.Folders++
|
|
name := strings.ToLower(fi.Name())
|
|
if name == ".git" || name == "node_modules" || name == ".cache" {
|
|
sum.IsDangerous = true
|
|
sum.DangerReason = fmt.Sprintf("содержит %s", fi.Name())
|
|
}
|
|
return nil
|
|
}
|
|
sum.Files++
|
|
sum.TotalBytes += fi.Size()
|
|
return nil
|
|
})
|
|
if sum.Files > 1000 && !sum.IsDangerous {
|
|
sum.IsDangerous = true
|
|
sum.DangerReason = "более 1000 файлов"
|
|
}
|
|
if sum.TotalBytes > 1<<30 && !sum.IsDangerous {
|
|
sum.IsDangerous = true
|
|
sum.DangerReason = "более 1 GB"
|
|
}
|
|
return &sum, err
|
|
}
|
|
|
|
// DeleteNodeAndChildren soft-deletes a node and all descendants,
|
|
// moving vault files to trash.
|
|
func (s *Service) DeleteNodeAndChildren(nodeID string) error {
|
|
children, _ := s.nodes.ListChildren(nodeID, false)
|
|
for i := range children {
|
|
if err := s.DeleteNodeAndChildren(children[i].ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_ = 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() {
|
|
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
|
os.MkdirAll(trashDir, 0o750)
|
|
trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))
|
|
os.Rename(src, trashPath)
|
|
}
|
|
}
|
|
return s.nodes.SoftDelete(nodeID)
|
|
}
|
|
|
|
func (s *Service) deleteFileRecords(nodeID string) error {
|
|
records, err := s.ListByNode(nodeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, r := range records {
|
|
_ = s.DeleteToTrash(r.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]nodes.Node, error) {
|
|
info, err := os.Stat(sourcePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
title := s.uniqueTitle(parentID, filepath.Base(sourcePath))
|
|
node, err := s.nodes.Create(strPtr(parentID), nodes.TypeFile, title, 0, "", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if copyMode {
|
|
parentFsPath := s.parentFsPath(parentID)
|
|
_, err = s.CopyIntoVault(node.ID, sourcePath, parentFsPath)
|
|
} else {
|
|
_, err = s.AddExternal(node.ID, sourcePath)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []nodes.Node{*node}, nil
|
|
}
|
|
return s.importDir(parentID, sourcePath, info.Name(), copyMode)
|
|
}
|
|
|
|
func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) ([]nodes.Node, error) {
|
|
dirName = s.uniqueTitle(parentID, dirName)
|
|
folderNode, err := s.nodes.Create(strPtr(parentID), nodes.TypeFolder, dirName, 0, "", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parentFsPath := s.parentFsPath(parentID)
|
|
seg := templates.SafeDisplayNameToPathSegment(dirName)
|
|
folderFsPath := seg
|
|
if parentFsPath != "" {
|
|
folderFsPath = filepath.Join(parentFsPath, seg)
|
|
}
|
|
_ = s.nodes.UpdateFsPath(folderNode.ID, folderFsPath)
|
|
|
|
entries, err := os.ReadDir(sourcePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var all []nodes.Node
|
|
all = append(all, *folderNode)
|
|
|
|
for _, entry := range entries {
|
|
childPath := filepath.Join(sourcePath, entry.Name())
|
|
if entry.IsDir() {
|
|
children, err := s.importDir(folderNode.ID, childPath, entry.Name(), copyMode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, children...)
|
|
} else {
|
|
childNode, err := s.nodes.Create(strPtr(folderNode.ID), nodes.TypeFile, entry.Name(), 0, "", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if copyMode {
|
|
_, err = s.CopyIntoVault(childNode.ID, childPath, folderFsPath)
|
|
} else {
|
|
_, err = s.AddExternal(childNode.ID, childPath)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, *childNode)
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
func (s *Service) uniqueTitle(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
|
|
}
|
|
if !used[desired] {
|
|
return desired
|
|
}
|
|
for n := 2; ; n++ {
|
|
c := fmt.Sprintf("%s (%d)", desired, n)
|
|
if !used[c] {
|
|
return c
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
rec := &Record{
|
|
ID: util.UUID7(),
|
|
NodeID: nodeID,
|
|
Filename: filename,
|
|
Path: path,
|
|
StorageMode: mode,
|
|
Size: size,
|
|
SHA256: sha,
|
|
MIME: guessMIME(filename),
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,
|
|
created_at,updated_at,missing)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
|
|
rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode,
|
|
rec.Size, rec.SHA256, rec.MIME,
|
|
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
func copyAndHash(src, dest string) (string, error) {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.Create(dest)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer out.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(io.MultiWriter(out, h), in); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
|
}
|
|
|
|
func guessMIME(filename string) string {
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
switch ext {
|
|
case ".md", ".txt", ".go", ".py", ".js", ".ts", ".sh", ".sql", ".yml", ".yaml", ".json", ".toml", ".xml", ".html", ".css", ".csv", ".rst":
|
|
return "text/plain"
|
|
case ".png":
|
|
return "image/png"
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".gif":
|
|
return "image/gif"
|
|
case ".pdf":
|
|
return "application/pdf"
|
|
case ".docx":
|
|
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
case ".xlsx":
|
|
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
case ".odt":
|
|
return "application/vnd.oasis.opendocument.text"
|
|
case ".zip":
|
|
return "application/zip"
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
func openWithSystem(path string) error {
|
|
var cmd *exec.Cmd
|
|
switch runtime.GOOS {
|
|
case "linux":
|
|
cmd = exec.Command("xdg-open", path)
|
|
case "darwin":
|
|
cmd = exec.Command("open", path)
|
|
case "windows":
|
|
cmd = exec.Command("cmd", "/c", "start", "", path)
|
|
default:
|
|
return fmt.Errorf("unsupported platform")
|
|
}
|
|
return cmd.Start()
|
|
}
|
|
|
|
// --- scanning helpers ---
|
|
|
|
type scanFace interface {
|
|
Scan(dest ...interface{}) error
|
|
}
|
|
|
|
func scanRecord(s scanFace) (*Record, error) {
|
|
var r Record
|
|
var lastSeen sql.NullString
|
|
var createdStr, updatedStr string
|
|
err := s.Scan(
|
|
&r.ID, &r.NodeID, &r.Filename, &r.Path, &r.StorageMode,
|
|
&r.Size, &r.SHA256, &r.MIME,
|
|
&createdStr, &updatedStr, &lastSeen, &r.Missing)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("file not found")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
|
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
|
if lastSeen.Valid {
|
|
t, _ := time.Parse(time.RFC3339, lastSeen.String)
|
|
r.LastSeenAt = &t
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
func scanRecords(rows *sql.Rows) ([]Record, error) {
|
|
var out []Record
|
|
for rows.Next() {
|
|
r, err := scanRecord(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func strPtr(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|