Files
plainleaf/server/disk_space_primitives.go
T
2025-10-27 17:21:50 +01:00

307 lines
7.9 KiB
Go

package server
import (
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
ignore "github.com/Diogenesoftoronto/go-gitignore"
"github.com/charlievieth/fastwalk"
"github.com/djherbis/times"
"github.com/prometheus/client_golang/prometheus"
)
// DiskSpacePrimitives implements SpacePrimitives for local disk storage
type DiskSpacePrimitives struct {
rootPath string
gitIgnore *ignore.GitIgnore
}
var _ SpacePrimitives = &DiskSpacePrimitives{}
var spaceFilesTotal = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "silverbullet_space_files_count",
Help: "Total number of files in the SilverBullet space",
})
func init() {
prometheus.MustRegister(spaceFilesTotal)
}
// NewDiskSpacePrimitives creates a new DiskSpacePrimitives instance
func NewDiskSpacePrimitives(rootPath string, gitIgnore string) (*DiskSpacePrimitives, error) {
// Resolve to absolute path
absPath, err := filepath.Abs(rootPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve root path: %w", err)
}
// Ensure the directory exists
if stat, err := os.Stat(absPath); err != nil || !stat.IsDir() {
return nil, fmt.Errorf("no such directory: %w", err)
}
ignore := ignore.CompileIgnoreLines(strings.Split(gitIgnore, "\n")...)
return &DiskSpacePrimitives{
rootPath: absPath,
gitIgnore: ignore,
}, nil
}
// safePath ensures the path is within the root directory
func (d *DiskSpacePrimitives) safePath(p string) (string, error) {
// Clean the path to resolve any .. or . components
cleanPath := filepath.Clean(p)
// If the path is absolute and tries to escape, reject it
if filepath.IsAbs(cleanPath) {
return "", ErrPathOutsideRoot
}
// Make it absolute relative to root
fullPath := filepath.Join(d.rootPath, cleanPath)
// Clean the full path again to resolve any remaining .. components
fullPath = filepath.Clean(fullPath)
// Ensure the resolved path is still within root
if !strings.HasPrefix(fullPath, d.rootPath) {
return "", ErrPathOutsideRoot
}
// Additional check: ensure we don't have .. components that could escape
relPath, err := filepath.Rel(d.rootPath, fullPath)
if err != nil {
return "", ErrPathOutsideRoot
}
if strings.HasPrefix(relPath, "..") {
return "", ErrPathOutsideRoot
}
return fullPath, nil
}
// filenameToPath converts a relative filename to an absolute path
func (d *DiskSpacePrimitives) filenameToPath(filename string) (string, error) {
return d.safePath(filename)
}
// pathToFilename converts an absolute path back to a relative filename
func (d *DiskSpacePrimitives) pathToFilename(fullPath string) string {
if !strings.HasPrefix(fullPath, d.rootPath) {
return fullPath
}
relativePath := strings.TrimPrefix(fullPath, d.rootPath)
relativePath = strings.TrimPrefix(relativePath, string(filepath.Separator))
// Normalize path separators to forward slashes
return strings.ReplaceAll(relativePath, string(filepath.Separator), "/")
}
// fileInfoToFileMeta converts os.FileInfo to FileMeta
func (d *DiskSpacePrimitives) fileInfoToFileMeta(path string, info os.FileInfo) FileMeta {
return FileMeta{
Name: path,
Size: info.Size(),
ContentType: LookupContentTypeFromPath(path),
Created: getCreationTime(info).UnixMilli(),
LastModified: info.ModTime().UnixMilli(),
Perm: "rw",
}
}
// FetchFileList implements SpacePrimitives.FetchFileList
// Returns a list of files in the space, with the following filtering rules:
// - Hidden files and directories (starting with '.') are excluded at any depth
// - Files without extensions are excluded
// - Files matching gitignore patterns are excluded
func (d *DiskSpacePrimitives) FetchFileList() ([]FileMeta, error) {
var l sync.Mutex
var allFiles []FileMeta
cnf := fastwalk.Config{
// Follow symlinks
Follow: true,
}
err := fastwalk.Walk(&cnf, d.rootPath, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
// Skip files that can't be accessed
return nil
}
// Skip hidden directories and stop traversal into them
if entry.IsDir() {
if strings.HasPrefix(entry.Name(), ".") {
return filepath.SkipDir
}
return nil
}
// Skip hidden files
if strings.HasPrefix(entry.Name(), ".") {
return nil
}
relativePath := d.pathToFilename(path)
// Exclude ignored files
if d.gitIgnore.MatchesPath(relativePath) {
return nil
}
// Skip files without extensions
if filepath.Ext(relativePath) == "" {
return nil
}
// Get file info
info, err := entry.Info()
if err != nil {
// Skip files we can't stat
return nil
}
fileMeta := d.fileInfoToFileMeta(relativePath, info)
// Mutex to handle concurrent directory walking
l.Lock()
allFiles = append(allFiles, fileMeta)
l.Unlock()
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
spaceFilesTotal.Set(float64(len(allFiles)))
return allFiles, nil
}
// GetFileMeta implements SpacePrimitives.GetFileMeta
func (d *DiskSpacePrimitives) GetFileMeta(path string) (FileMeta, error) {
localPath, err := d.filenameToPath(path)
if err != nil {
return FileMeta{}, err
}
info, err := os.Stat(localPath)
if err != nil {
if os.IsNotExist(err) {
return FileMeta{}, ErrNotFound
}
return FileMeta{}, fmt.Errorf("%w: %s", ErrCouldNotGetMeta, path)
}
return d.fileInfoToFileMeta(path, info), nil
}
// ReadFile implements SpacePrimitives.ReadFile
func (d *DiskSpacePrimitives) ReadFile(path string) ([]byte, FileMeta, error) {
localPath, err := d.filenameToPath(path)
if err != nil {
return nil, FileMeta{}, err
}
// Get file info first
info, err := os.Stat(localPath)
if err != nil {
if os.IsNotExist(err) {
return nil, FileMeta{}, ErrNotFound
}
return nil, FileMeta{}, fmt.Errorf("failed to stat file %s: %w", path, err)
}
// Read file content
data, err := os.ReadFile(localPath)
if err != nil {
return nil, FileMeta{}, fmt.Errorf("failed to read file %s: %w", path, err)
}
meta := d.fileInfoToFileMeta(path, info)
return data, meta, nil
}
// WriteFile implements SpacePrimitives.WriteFile
func (d *DiskSpacePrimitives) WriteFile(path string, data []byte, meta *FileMeta) (FileMeta, error) {
localPath, err := d.filenameToPath(path)
if err != nil {
return FileMeta{}, err
}
// Ensure parent directory exists
parentDir := filepath.Dir(localPath)
if err := os.MkdirAll(parentDir, 0755); err != nil {
return FileMeta{}, fmt.Errorf("%w: %s", ErrCouldNotWrite, path)
}
// Write file
if err := os.WriteFile(localPath, data, 0644); err != nil {
return FileMeta{}, fmt.Errorf("%w: %s", ErrCouldNotWrite, path)
}
// Set modification time if provided
if meta != nil && meta.LastModified > 0 {
modTime := time.UnixMilli(meta.LastModified)
if err := os.Chtimes(localPath, modTime, modTime); err != nil {
log.Printf("Failed to set the mtime for %s: %v", localPath, err)
}
}
// Return actual metadata
return d.GetFileMeta(path)
}
// DeleteFile implements SpacePrimitives.DeleteFile
func (d *DiskSpacePrimitives) DeleteFile(path string) error {
localPath, err := d.filenameToPath(path)
if err != nil {
return err
}
if err := os.Remove(localPath); err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return fmt.Errorf("failed to delete file %s: %w", path, err)
}
// Clean up empty parent directories
d.cleanOrphaned(localPath)
return nil
}
// cleanOrphaned removes empty parent directories up to rootPath
func (d *DiskSpacePrimitives) cleanOrphaned(deletedFilePath string) {
current := filepath.Dir(deletedFilePath)
for strings.HasPrefix(current, d.rootPath) && current != d.rootPath {
err := os.Remove(current)
if err != nil {
// Directory not empty or other error, stop cleaning
break
}
current = filepath.Dir(current)
}
}
// This is tricky and OS specific, luckily we found the "times" package that abstracts from the OS-specific details
func getCreationTime(info os.FileInfo) time.Time {
t := times.Get(info)
if t.HasBirthTime() {
return t.BirthTime()
} else {
return t.ChangeTime()
}
}