From 15e8b4602f8d946d2ab0be2d1063003ebe3f477f Mon Sep 17 00:00:00 2001 From: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:21:41 -0500 Subject: [PATCH] v0.3.1 release (#245) --- .github/workflows/regular-tests.yaml | 11 +- CHANGELOG.md | 18 + README.md | 23 +- backend/cmd/root.go | 7 +- backend/files/conditions.go | 84 +++- backend/files/file.go | 369 ++++++------------ backend/files/indexing.go | 204 ---------- backend/files/indexingFiles.go | 229 +++++++++++ backend/files/indexingSchedule.go | 120 ++++++ backend/files/indexing_test.go | 42 +- backend/files/search.go | 24 +- backend/files/search_test.go | 34 +- backend/files/sync.go | 87 +---- backend/files/sync_test.go | 82 ++-- backend/http/auth.go | 30 ++ backend/http/middleware.go | 14 +- backend/http/middleware_test.go | 27 +- backend/http/preview.go | 30 +- backend/http/public.go | 41 +- backend/http/raw.go | 20 +- backend/http/resource.go | 64 +-- backend/http/router.go | 4 +- backend/http/share.go | 4 +- .../Screenshot 2024-11-18 at 2.16.29 PM.png | Bin 73659 -> 0 bytes backend/settings/config.go | 1 - backend/swagger/docs/docs.go | 36 +- backend/swagger/docs/swagger.json | 36 +- backend/swagger/docs/swagger.yaml | 26 +- backend/test/atest | 0 backend/test/test | 0 backend/test/tests | 0 backend/utils/cache.go | 80 ++++ backend/utils/main.go | 15 + backend/utils/main_test.go | 59 +++ docs/configuration.md | 4 +- docs/contributing.md | 11 + docs/indexing.md | 189 +++++++++ docs/roadmap.md | 10 +- frontend/package.json | 11 +- frontend/src/api/files.js | 32 +- frontend/src/api/public.js | 61 ++- frontend/src/api/utils.js | 37 +- frontend/src/api/utils.test.js | 114 ++++++ frontend/src/components/Breadcrumbs.vue | 4 +- frontend/src/components/files/ListingItem.vue | 3 +- frontend/src/components/prompts/Delete.vue | 4 +- frontend/src/components/prompts/Share.vue | 88 +++-- frontend/src/router/index.ts | 68 ++-- frontend/src/store/getters.js | 26 +- frontend/src/store/mutations.js | 17 +- frontend/src/store/state.js | 1 + frontend/src/utils/auth.js | 22 +- frontend/src/utils/download.js | 46 +-- frontend/src/views/Files.vue | 9 +- frontend/src/views/Login.vue | 3 +- frontend/src/views/Share.vue | 44 ++- frontend/src/views/bars/Default.vue | 5 +- frontend/src/views/files/Editor.vue | 2 +- frontend/src/views/settings/Profile.vue | 1 - frontend/tests/mocks/setup.js | 81 ++++ frontend/vite.config.ts | 77 ++-- ....timestamp-1732411585993-fa353470e4284.mjs | 74 ++++ makefile | 5 + 63 files changed, 1783 insertions(+), 1087 deletions(-) delete mode 100644 backend/files/indexing.go create mode 100644 backend/files/indexingFiles.go create mode 100644 backend/files/indexingSchedule.go delete mode 100755 backend/myfolder/subfolder/Screenshot 2024-11-18 at 2.16.29 PM.png create mode 100755 backend/test/atest create mode 100755 backend/test/test create mode 100755 backend/test/tests create mode 100644 backend/utils/cache.go create mode 100644 backend/utils/main_test.go create mode 100644 docs/indexing.md create mode 100644 frontend/src/api/utils.test.js create mode 100644 frontend/tests/mocks/setup.js create mode 100644 frontend/vite.config.ts.timestamp-1732411585993-fa353470e4284.mjs diff --git a/.github/workflows/regular-tests.yaml b/.github/workflows/regular-tests.yaml index 7a29eaf2..148f3f0f 100644 --- a/.github/workflows/regular-tests.yaml +++ b/.github/workflows/regular-tests.yaml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.22.5' + go-version: '1.23.3' - uses: golangci/golangci-lint-action@v5 with: version: v1.60 @@ -41,4 +41,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - working-directory: frontend - run: npm i eslint && npm run lint + run: npm i && npm run lint + test-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - working-directory: frontend + run: npm i && npm run test diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ca3190..4b635ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version). +## v0.3.1 + + **New Features** + - Adds Smart Indexing by default. + + **Notes**: + - Optimized api request response times via improved caching and simplified actions. + - User information persists more reliably. + - Added [indexing doc](./docs/indexing.md) to explain the expectations around indexing and how it works. + - The index should also use less RAM than it did in v0.3.0. + + **Bugfixes**: + - Tweaked sorting by name, fixes case sensitive and numeric sorting. https://github.com/gtsteffaniak/filebrowser/issues/230 + - Fixed unnecessary authentication status checks each route change + - Fix create file action issue. + - some small javascript related issues. + - Fixes pretty big bug viewing raw content in v0.3.0 (utf format message) + ## v0.3.0 This Release focuses on the API and making it more accessible for developers to access functions without the UI. diff --git a/README.md b/README.md index 18567213..3d631a9e 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,25 @@

> [!WARNING] -> Starting with `v0.3.0` API routes have been slightly altered for friendly usage outside of the UI. +> Starting with `v0.3.0` API routes have been slightly altered for friendly usage outside of the UI. The resources api returns items in separate `files` and `folder` objects now. + +> [!WARNING] > If on windows, please use docker. The windows binary is unstable and may not work. +> [!WARNING] +> There is no stable version yet. Always check release notes for bugfixes on functionality that may have been changed. If you notice any unexpected behavior -- please open an issue to have it fixed soon. + FileBrowser Quantum is a fork of the file browser opensource project with the following changes: - 1. [x] Efficiently indexed files + 1. [x] Indexes files efficiently. See [indexing readme](./docs/indexing.md) - Real-time search results as you type - - Search Works with more type filters - - Enhanced interactive results page. - - file/folder sizes are shown in the response + - Search supports file/folder sizes and many file type filters. + - Enhanced interactive results that shows file/folder sizes. 1. [x] Revamped and simplified GUI navbar and sidebar menu. - Additional compact view mode as well as refreshed view mode styles. + - Many graphical and user experience improvements. + - right-click context menu 1. [x] Revamped and simplified configuration via `filebrowser.yml` config file. 1. [x] Better listing browsing - Switching view modes is instant @@ -33,6 +39,13 @@ FileBrowser Quantum is a fork of the file browser opensource project with the fo - Can create long-live API Tokens. - Helpful Swagger page available at `/swagger` endpoint. +Notable features that this fork *does not* have (removed): + + - jobs/runners are not supported yet (planned). + - shell commands are completely removed and will not be returning. + - themes and branding are not fully supported yet (planned). + - see feature matrix below for more. + ## About FileBrowser Quantum provides a file-managing interface within a specified directory diff --git a/backend/cmd/root.go b/backend/cmd/root.go index 68f33cd8..fda40a7d 100644 --- a/backend/cmd/root.go +++ b/backend/cmd/root.go @@ -114,10 +114,6 @@ func StartFilebrowser() { } } store, dbExists := getStore(configPath) - indexingInterval := fmt.Sprint(settings.Config.Server.IndexingInterval, " minutes") - if !settings.Config.Server.Indexing { - indexingInterval = "disabled" - } database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database) if !dbExists { database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database) @@ -127,14 +123,13 @@ func StartFilebrowser() { log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true") log.Println(database) log.Println("Sources :", settings.Config.Server.Root) - log.Println("Indexing interval :", indexingInterval) serverConfig := settings.Config.Server swagInfo := docs.SwaggerInfo swagInfo.BasePath = serverConfig.BaseURL swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo) // initialize indexing and schedule indexing ever n minutes (default 5) - go files.InitializeIndex(serverConfig.IndexingInterval, serverConfig.Indexing) + go files.InitializeIndex(serverConfig.Indexing) if err := rootCMD(store, &serverConfig); err != nil { log.Fatal("Error starting filebrowser:", err) } diff --git a/backend/files/conditions.go b/backend/files/conditions.go index 09d70b43..29860183 100644 --- a/backend/files/conditions.go +++ b/backend/files/conditions.go @@ -14,24 +14,72 @@ var AllFiletypeOptions = []string{ "archive", "video", "doc", - "dir", "text", } + +// Document file extensions var documentTypes = []string{ - ".word", - ".pdf", - ".doc", - ".docx", -} -var textTypes = []string{ - ".text", - ".sh", - ".yaml", - ".yml", - ".json", - ".env", + // Common Document Formats + ".doc", ".docx", // Microsoft Word + ".pdf", // Portable Document Format + ".odt", // OpenDocument Text + ".rtf", // Rich Text Format + + // Presentation Formats + ".ppt", ".pptx", // Microsoft PowerPoint + ".odp", // OpenDocument Presentation + + // Spreadsheet Formats + ".xls", ".xlsx", // Microsoft Excel + ".ods", // OpenDocument Spreadsheet + + // Other Document Formats + ".epub", // Electronic Publication + ".mobi", // Amazon Kindle + ".fb2", // FictionBook } +// Text-based file extensions +var textTypes = []string{ + // Common Text Formats + ".txt", + ".md", // Markdown + + // Scripting and Programming Languages + ".sh", // Bash script + ".py", // Python + ".js", // JavaScript + ".ts", // TypeScript + ".php", // PHP + ".rb", // Ruby + ".go", // Go + ".java", // Java + ".c", ".cpp", // C/C++ + ".cs", // C# + ".swift", // Swift + + // Configuration Files + ".yaml", ".yml", // YAML + ".json", // JSON + ".xml", // XML + ".ini", // INI + ".toml", // TOML + ".cfg", // Configuration file + + // Other Text-Based Formats + ".css", // Cascading Style Sheets + ".html", ".htm", // HyperText Markup Language + ".sql", // SQL + ".csv", // Comma-Separated Values + ".tsv", // Tab-Separated Values + ".log", // Log file + ".bat", // Batch file + ".ps1", // PowerShell script + ".tex", // LaTeX + ".bib", // BibTeX +} + +// Compressed file extensions var compressedFile = []string{ ".7z", ".rar", @@ -39,6 +87,12 @@ var compressedFile = []string{ ".tar", ".gz", ".xz", + ".bz2", + ".tgz", // tar.gz + ".tbz2", // tar.bz2 + ".lzma", + ".lz4", + ".zstd", } type SearchOptions struct { @@ -48,8 +102,8 @@ type SearchOptions struct { Terms []string } -func ParseSearch(value string) *SearchOptions { - opts := &SearchOptions{ +func ParseSearch(value string) SearchOptions { + opts := SearchOptions{ Conditions: map[string]bool{ "exact": strings.Contains(value, "case:exact"), }, diff --git a/backend/files/file.go b/backend/files/file.go index e254221f..8bd4bfda 100644 --- a/backend/files/file.go +++ b/backend/files/file.go @@ -13,6 +13,8 @@ import ( "net/http" "os" "path/filepath" + "sort" + "strconv" "strings" "sync" "time" @@ -22,6 +24,7 @@ import ( "github.com/gtsteffaniak/filebrowser/fileutils" "github.com/gtsteffaniak/filebrowser/settings" "github.com/gtsteffaniak/filebrowser/users" + "github.com/gtsteffaniak/filebrowser/utils" ) var ( @@ -29,34 +32,30 @@ var ( pathMutexesMu sync.Mutex // Mutex to protect the pathMutexes map ) -type ReducedItem struct { - Name string `json:"name"` - Size int64 `json:"size"` - ModTime time.Time `json:"modified"` - Type string `json:"type"` - Mode os.FileMode `json:"-"` - Content string `json:"content,omitempty"` +type ItemInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime time.Time `json:"modified"` + Type string `json:"type"` } // FileInfo describes a file. // reduced item is non-recursive reduced "Items", used to pass flat items array type FileInfo struct { - Files []ReducedItem `json:"-"` - Dirs map[string]*FileInfo `json:"-"` - Path string `json:"path"` - Name string `json:"name"` - Items []ReducedItem `json:"items"` - Size int64 `json:"size"` - Extension string `json:"-"` - ModTime time.Time `json:"modified"` - CacheTime time.Time `json:"-"` - Mode os.FileMode `json:"-"` - IsSymlink bool `json:"isSymlink,omitempty"` - Type string `json:"type"` - Subtitles []string `json:"subtitles,omitempty"` - Content string `json:"content,omitempty"` - Checksums map[string]string `json:"checksums,omitempty"` - Token string `json:"token,omitempty"` + ItemInfo + Files []ItemInfo `json:"files"` + Folders []ItemInfo `json:"folders"` + Path string `json:"path"` +} + +// for efficiency, a response will be a pointer to the data +// extra calculated fields can be added here +type ExtendedFileInfo struct { + *FileInfo + Content string `json:"content,omitempty"` + Subtitles []string `json:"subtitles,omitempty"` + Checksums map[string]string `json:"checksums,omitempty"` + Token string `json:"token,omitempty"` } // FileOptions are the options when getting a file info. @@ -66,7 +65,6 @@ type FileOptions struct { Modify bool Expand bool ReadHeader bool - Token string Checker users.Checker Content bool } @@ -75,206 +73,70 @@ func (f FileOptions) Components() (string, string) { return filepath.Dir(f.Path), filepath.Base(f.Path) } -func FileInfoFaster(opts FileOptions) (*FileInfo, error) { +func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) { index := GetIndex(rootPath) opts.Path = index.makeIndexPath(opts.Path) - + response := ExtendedFileInfo{} // Lock access for the specific path pathMutex := getMutex(opts.Path) pathMutex.Lock() defer pathMutex.Unlock() if !opts.Checker.Check(opts.Path) { - return nil, os.ErrPermission + return response, os.ErrPermission } + _, isDir, err := GetRealPath(opts.Path) if err != nil { - return nil, err + return response, err } opts.IsDir = isDir + + // TODO : whats the best way to save trips to disk here? + // disabled using cache because its not clear if this is helping or hurting // check if the file exists in the index - info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir) - if exists { - // Let's not refresh if less than a second has passed - if time.Since(info.CacheTime) > time.Second { - RefreshFileInfo(opts) //nolint:errcheck - } - if opts.Content { - content := "" - content, err = getContent(opts.Path) - if err != nil { - return info, err - } - info.Content = content - } - return info, nil - } - err = RefreshFileInfo(opts) + //info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir) + //if exists { + // err := RefreshFileInfo(opts) + // if err != nil { + // return info, err + // } + // if opts.Content { + // content := "" + // content, err = getContent(opts.Path) + // if err != nil { + // return info, err + // } + // info.Content = content + // } + // return info, nil + //} + + err = index.RefreshFileInfo(opts) if err != nil { - return nil, err + return response, err } - info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir) + info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir) if !exists { - return nil, err + return response, err } if opts.Content { content, err := getContent(opts.Path) if err != nil { - return info, err + return response, err } - info.Content = content + response.Content = content } - return info, nil -} - -func RefreshFileInfo(opts FileOptions) error { - refreshOptions := FileOptions{ - Path: opts.Path, - IsDir: opts.IsDir, - Token: opts.Token, - } - index := GetIndex(rootPath) - - if !refreshOptions.IsDir { - refreshOptions.Path = index.makeIndexPath(filepath.Dir(refreshOptions.Path)) - refreshOptions.IsDir = true - } else { - refreshOptions.Path = index.makeIndexPath(refreshOptions.Path) - } - - current, exists := index.GetMetadataInfo(refreshOptions.Path, true) - - file, err := stat(refreshOptions) - if err != nil { - return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path) - } - - //utils.PrintStructFields(*file) - result := index.UpdateMetadata(file) - if !result { - return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path) - } - if !exists { - return nil - } - if current.Size != file.Size { - index.recursiveUpdateDirSizes(filepath.Dir(refreshOptions.Path), file, current.Size) - } - return nil -} - -func stat(opts FileOptions) (*FileInfo, error) { - realPath, _, err := GetRealPath(rootPath, opts.Path) - if err != nil { - return nil, err - } - info, err := os.Lstat(realPath) - if err != nil { - return nil, err - } - file := &FileInfo{ - Path: opts.Path, - Name: filepath.Base(opts.Path), - ModTime: info.ModTime(), - Mode: info.Mode(), - Size: info.Size(), - Extension: filepath.Ext(info.Name()), - Token: opts.Token, - } - if info.IsDir() { - // Open and read directory contents - dir, err := os.Open(realPath) - if err != nil { - return nil, err - } - defer dir.Close() - - dirInfo, err := dir.Stat() - if err != nil { - return nil, err - } - index := GetIndex(rootPath) - // Check cached metadata to decide if refresh is needed - cachedParentDir, exists := index.GetMetadataInfo(opts.Path, true) - if exists && dirInfo.ModTime().Before(cachedParentDir.CacheTime) { - return cachedParentDir, nil - } - - // Read directory contents and process - files, err := dir.Readdir(-1) - if err != nil { - return nil, err - } - - file.Files = []ReducedItem{} - file.Dirs = map[string]*FileInfo{} - - var totalSize int64 - for _, item := range files { - itemPath := filepath.Join(realPath, item.Name()) - - if item.IsDir() { - itemInfo := &FileInfo{ - Name: item.Name(), - ModTime: item.ModTime(), - Mode: item.Mode(), - } - - if exists { - // if directory size was already cached use that. - cachedDir, ok := cachedParentDir.Dirs[item.Name()] - if ok { - itemInfo.Size = cachedDir.Size - } - } - file.Dirs[item.Name()] = itemInfo - totalSize += itemInfo.Size - } else { - itemInfo := ReducedItem{ - Name: item.Name(), - Size: item.Size(), - ModTime: item.ModTime(), - Mode: item.Mode(), - } - if IsSymlink(item.Mode()) { - itemInfo.Type = "symlink" - info, err := os.Stat(itemPath) - if err == nil { - itemInfo.Name = info.Name() - itemInfo.ModTime = info.ModTime() - itemInfo.Size = info.Size() - itemInfo.Mode = info.Mode() - } else { - file.Type = "invalid_link" - } - } - if file.Type != "invalid_link" { - err := itemInfo.detectType(itemPath, true, opts.Content, opts.ReadHeader) - if err != nil { - fmt.Printf("failed to detect type for %v: %v \n", itemPath, err) - } - file.Files = append(file.Files, itemInfo) - } - totalSize += itemInfo.Size - - } - } - - file.Size = totalSize - } - return file, nil + response.FileInfo = info + return response, nil } // Checksum checksums a given File for a given User, using a specific // algorithm. The checksums data is saved on File object. -func (i *FileInfo) Checksum(algo string) error { - - if i.Checksums == nil { - i.Checksums = map[string]string{} - } - fullpath := filepath.Join(i.Path, i.Name) - reader, err := os.Open(fullpath) +func GetChecksum(fullPath, algo string) (map[string]string, error) { + subs := map[string]string{} + reader, err := os.Open(fullPath) if err != nil { - return err + return subs, err } defer reader.Close() @@ -287,21 +149,21 @@ func (i *FileInfo) Checksum(algo string) error { h, ok := hashFuncs[algo] if !ok { - return errors.ErrInvalidOption + return subs, errors.ErrInvalidOption } _, err = io.Copy(h, reader) if err != nil { - return err + return subs, err } - - i.Checksums[algo] = hex.EncodeToString(h.Sum(nil)) - return nil + subs[algo] = hex.EncodeToString(h.Sum(nil)) + return subs, nil } // RealPath gets the real path for the file, resolving symlinks if supported. func (i *FileInfo) RealPath() string { - realPath, err := filepath.EvalSymlinks(i.Path) + realPath, _, _ := GetRealPath(rootPath, i.Path) + realPath, err := filepath.EvalSymlinks(realPath) if err == nil { return realPath } @@ -314,13 +176,24 @@ func GetRealPath(relativePath ...string) (string, bool, error) { combined = append(combined, strings.TrimPrefix(path, settings.Config.Server.Root)) } joinedPath := filepath.Join(combined...) + + isDir, _ := utils.RealPathCache.Get(joinedPath + ":isdir").(bool) + cached, ok := utils.RealPathCache.Get(joinedPath).(string) + if ok && cached != "" { + return cached, isDir, nil + } // Convert relative path to absolute path absolutePath, err := filepath.Abs(joinedPath) if err != nil { return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", combined, err) } // Resolve symlinks and get the real path - return resolveSymlinks(absolutePath) + realPath, isDir, err := resolveSymlinks(absolutePath) + if err == nil { + utils.RealPathCache.Set(joinedPath, realPath) + utils.RealPathCache.Set(joinedPath+":isdir", isDir) + } + return realPath, isDir, err } func DeleteFiles(absPath string, opts FileOptions) error { @@ -328,7 +201,8 @@ func DeleteFiles(absPath string, opts FileOptions) error { if err != nil { return err } - err = RefreshFileInfo(opts) + index := GetIndex(rootPath) + err = index.RefreshFileInfo(opts) if err != nil { return err } @@ -340,8 +214,9 @@ func MoveResource(realsrc, realdst string, isSrcDir bool) error { if err != nil { return err } + index := GetIndex(rootPath) // refresh info for source and dest - err = RefreshFileInfo(FileOptions{ + err = index.RefreshFileInfo(FileOptions{ Path: realsrc, IsDir: isSrcDir, }) @@ -352,7 +227,7 @@ func MoveResource(realsrc, realdst string, isSrcDir bool) error { if !isSrcDir { refreshConfig.Path = filepath.Dir(realdst) } - err = RefreshFileInfo(refreshConfig) + err = index.RefreshFileInfo(refreshConfig) if err != nil { return errors.ErrEmptyKey } @@ -364,12 +239,12 @@ func CopyResource(realsrc, realdst string, isSrcDir bool) error { if err != nil { return err } - + index := GetIndex(rootPath) refreshConfig := FileOptions{Path: realdst, IsDir: true} if !isSrcDir { refreshConfig.Path = filepath.Dir(realdst) } - err = RefreshFileInfo(refreshConfig) + err = index.RefreshFileInfo(refreshConfig) if err != nil { return errors.ErrEmptyKey } @@ -383,7 +258,8 @@ func WriteDirectory(opts FileOptions) error { if err != nil { return err } - err = RefreshFileInfo(opts) + index := GetIndex(rootPath) + err = index.RefreshFileInfo(opts) if err != nil { return errors.ErrEmptyKey } @@ -391,13 +267,10 @@ func WriteDirectory(opts FileOptions) error { } func WriteFile(opts FileOptions, in io.Reader) error { - dst := opts.Path + dst, _, _ := GetRealPath(rootPath, opts.Path) parentDir := filepath.Dir(dst) - // Split the directory from the destination path - dir := filepath.Dir(dst) - // Create the directory and all necessary parents - err := os.MkdirAll(dir, 0775) + err := os.MkdirAll(parentDir, 0775) if err != nil { return err } @@ -415,35 +288,35 @@ func WriteFile(opts FileOptions, in io.Reader) error { return err } opts.Path = parentDir - err = RefreshFileInfo(opts) - if err != nil { - return errors.ErrEmptyKey - } - return nil + opts.IsDir = true + index := GetIndex(rootPath) + return index.RefreshFileInfo(opts) } // resolveSymlinks resolves symlinks in the given path func resolveSymlinks(path string) (string, bool, error) { for { - // Get the file info + // Get the file info using os.Lstat to handle symlinks info, err := os.Lstat(path) if err != nil { - return path, false, fmt.Errorf("could not stat path: %v, %s", path, err) + return path, false, fmt.Errorf("could not stat path: %s, %v", path, err) } - // Check if it's a symlink + // Check if the path is a symlink if info.Mode()&os.ModeSymlink != 0 { // Read the symlink target target, err := os.Readlink(path) if err != nil { - return path, false, err + return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err) } - // Resolve the target relative to the symlink's directory + // Resolve the symlink's target relative to its directory + // This ensures the resolved path is absolute and correctly calculated path = filepath.Join(filepath.Dir(path), target) } else { - // Not a symlink, so return the resolved path and check if it's a directory - return path, info.IsDir(), nil + // Not a symlink, so return the resolved path and whether it's a directory + isDir := info.IsDir() + return path, isDir, nil } } } @@ -461,7 +334,7 @@ func getContent(path string) (string, error) { } stringContent := string(content) if !utf8.ValidString(stringContent) { - return "", fmt.Errorf("file is not utf8 encoded") + return "", nil } if stringContent == "" { return "empty-file-x6OlSil", nil @@ -470,21 +343,9 @@ func getContent(path string) (string, error) { } // detectType detects the file type. -func (i *ReducedItem) detectType(path string, modify, saveContent, readHeader bool) error { +func (i *ItemInfo) detectType(path string, modify, saveContent, readHeader bool) error { name := i.Name var contentErr error - var contentString string - if saveContent { - contentString, contentErr = getContent(path) - if contentErr == nil { - i.Content = contentString - } - } - - if IsNamedPipe(i.Mode) { - i.Type = "blob" - return contentErr - } ext := filepath.Ext(name) var buffer []byte @@ -533,7 +394,7 @@ func (i *ReducedItem) detectType(path string, modify, saveContent, readHeader bo } // readFirstBytes reads the first bytes of the file. -func (i *ReducedItem) readFirstBytes(path string) []byte { +func (i *ItemInfo) readFirstBytes(path string) []byte { file, err := os.Open(path) if err != nil { i.Type = "blob" @@ -551,6 +412,7 @@ func (i *ReducedItem) readFirstBytes(path string) []byte { return buffer[:n] } +// TODO add subtitles back // detectSubtitles detects subtitles for video files. //func (i *FileInfo) detectSubtitles(path string) { // if i.Type != "video" { @@ -620,3 +482,26 @@ func Exists(path string) bool { } return false } + +func (info *FileInfo) SortItems() { + sort.Slice(info.Folders, func(i, j int) bool { + // Convert strings to integers for numeric sorting if both are numeric + numI, errI := strconv.Atoi(info.Folders[i].Name) + numJ, errJ := strconv.Atoi(info.Folders[j].Name) + if errI == nil && errJ == nil { + return numI < numJ + } + // Fallback to case-insensitive lexicographical sorting + return strings.ToLower(info.Folders[i].Name) < strings.ToLower(info.Folders[j].Name) + }) + sort.Slice(info.Files, func(i, j int) bool { + // Convert strings to integers for numeric sorting if both are numeric + numI, errI := strconv.Atoi(info.Files[i].Name) + numJ, errJ := strconv.Atoi(info.Files[j].Name) + if errI == nil && errJ == nil { + return numI < numJ + } + // Fallback to case-insensitive lexicographical sorting + return strings.ToLower(info.Files[i].Name) < strings.ToLower(info.Files[j].Name) + }) +} diff --git a/backend/files/indexing.go b/backend/files/indexing.go deleted file mode 100644 index 7c1859e0..00000000 --- a/backend/files/indexing.go +++ /dev/null @@ -1,204 +0,0 @@ -package files - -import ( - "log" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/gtsteffaniak/filebrowser/settings" -) - -type Index struct { - Root string - Directories map[string]*FileInfo - NumDirs int - NumFiles int - inProgress bool - LastIndexed time.Time - mu sync.RWMutex -} - -var ( - rootPath string = "/srv" - indexes []*Index - indexesMutex sync.RWMutex -) - -func InitializeIndex(intervalMinutes uint32, schedule bool) { - if schedule { - go indexingScheduler(intervalMinutes) - } -} - -func indexingScheduler(intervalMinutes uint32) { - if settings.Config.Server.Root != "" { - rootPath = settings.Config.Server.Root - } - si := GetIndex(rootPath) - for { - startTime := time.Now() - // Set the indexing flag to indicate that indexing is in progress - si.resetCount() - // Perform the indexing operation - err := si.indexFiles("/") - // Reset the indexing flag to indicate that indexing has finished - si.inProgress = false - // Update the LastIndexed time - si.LastIndexed = time.Now() - if err != nil { - log.Printf("Error during indexing: %v", err) - } - if si.NumFiles+si.NumDirs > 0 { - timeIndexedInSeconds := int(time.Since(startTime).Seconds()) - log.Println("Successfully indexed files.") - log.Printf("Time spent indexing: %v seconds\n", timeIndexedInSeconds) - log.Printf("Files found: %v\n", si.NumFiles) - log.Printf("Directories found: %v\n", si.NumDirs) - } - // Sleep for the specified interval - time.Sleep(time.Duration(intervalMinutes) * time.Minute) - } -} - -// Define a function to recursively index files and directories -func (si *Index) indexFiles(adjustedPath string) error { - realPath := strings.TrimRight(si.Root, "/") + adjustedPath - - // Open the directory - dir, err := os.Open(realPath) - if err != nil { - si.RemoveDirectory(adjustedPath) // Remove if it can't be opened - return err - } - defer dir.Close() - - dirInfo, err := dir.Stat() - if err != nil { - return err - } - - // Skip directories that haven't been modified since the last index - if dirInfo.ModTime().Before(si.LastIndexed) { - return nil - } - - // Read directory contents - files, err := dir.Readdir(-1) - if err != nil { - return err - } - - var totalSize int64 - var numDirs, numFiles int - fileInfos := []ReducedItem{} - dirInfos := map[string]*FileInfo{} - combinedPath := adjustedPath + "/" - if adjustedPath == "/" { - combinedPath = "/" - } - - // Process each file and directory in the current directory - for _, file := range files { - itemInfo := &FileInfo{ - ModTime: file.ModTime(), - } - if file.IsDir() { - itemInfo.Name = file.Name() - itemInfo.Path = combinedPath + file.Name() - // Recursively index the subdirectory - err := si.indexFiles(itemInfo.Path) - if err != nil { - log.Printf("Failed to index directory %s: %v", itemInfo.Path, err) - continue - } - // Fetch the metadata for the subdirectory after indexing - subDirInfo, exists := si.GetMetadataInfo(itemInfo.Path, true) - if exists { - itemInfo.Size = subDirInfo.Size - totalSize += subDirInfo.Size // Add subdirectory size to the total - } - dirInfos[itemInfo.Name] = itemInfo - numDirs++ - } else { - itemInfo := &ReducedItem{ - Name: file.Name(), - ModTime: file.ModTime(), - Size: file.Size(), - Mode: file.Mode(), - } - _ = itemInfo.detectType(combinedPath+file.Name(), true, false, false) - fileInfos = append(fileInfos, *itemInfo) - totalSize += itemInfo.Size - numFiles++ - } - } - - // Create FileInfo for the current directory - dirFileInfo := &FileInfo{ - Path: adjustedPath, - Files: fileInfos, - Dirs: dirInfos, - Size: totalSize, - ModTime: dirInfo.ModTime(), - } - - // Update the current directory metadata in the index - si.UpdateMetadata(dirFileInfo) - si.NumDirs += numDirs - si.NumFiles += numFiles - - return nil -} - -func (si *Index) makeIndexPath(subPath string) string { - if strings.HasPrefix(subPath, "./") { - subPath = strings.TrimPrefix(subPath, ".") - } - if strings.HasPrefix(subPath, ".") || si.Root == subPath { - return "/" - } - // clean path - subPath = strings.TrimSuffix(subPath, "/") - // remove index prefix - adjustedPath := strings.TrimPrefix(subPath, si.Root) - // remove trailing slash - adjustedPath = strings.TrimSuffix(adjustedPath, "/") - if !strings.HasPrefix(adjustedPath, "/") { - adjustedPath = "/" + adjustedPath - } - return adjustedPath -} - -//func getParentPath(path string) string { -// // Trim trailing slash for consistency -// path = strings.TrimSuffix(path, "/") -// if path == "" || path == "/" { -// return "" // Root has no parent -// } -// -// lastSlash := strings.LastIndex(path, "/") -// if lastSlash == -1 { -// return "/" // Parent of a top-level directory -// } -// return path[:lastSlash] -//} - -func (si *Index) recursiveUpdateDirSizes(parentDir string, childInfo *FileInfo, previousSize int64) { - childDirName := filepath.Base(childInfo.Path) - if parentDir == childDirName { - return - } - dir, exists := si.GetMetadataInfo(parentDir, true) - if !exists { - return - } - dir.Dirs[childDirName] = childInfo - newSize := dir.Size - previousSize + childInfo.Size - dir.Size += newSize - si.UpdateMetadata(dir) - dir, _ = si.GetMetadataInfo(parentDir, true) - si.recursiveUpdateDirSizes(filepath.Dir(parentDir), dir, newSize) -} diff --git a/backend/files/indexingFiles.go b/backend/files/indexingFiles.go new file mode 100644 index 00000000..1ce331e5 --- /dev/null +++ b/backend/files/indexingFiles.go @@ -0,0 +1,229 @@ +package files + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gtsteffaniak/filebrowser/settings" + "github.com/gtsteffaniak/filebrowser/utils" +) + +type Index struct { + Root string + Directories map[string]*FileInfo + NumDirs uint64 + NumFiles uint64 + NumDeleted uint64 + FilesChangedDuringIndexing bool + currentSchedule int + assessment string + indexingTime int + LastIndexed time.Time + SmartModifier time.Duration + mu sync.RWMutex + scannerMu sync.Mutex +} + +var ( + rootPath string = "/srv" + indexes []*Index + indexesMutex sync.RWMutex +) + +func InitializeIndex(enabled bool) { + if enabled { + time.Sleep(time.Second) + if settings.Config.Server.Root != "" { + rootPath = settings.Config.Server.Root + } + si := GetIndex(rootPath) + log.Println("Initializing index and assessing file system complexity") + si.RunIndexing("/", false) + go si.setupIndexingScanners() + } +} + +// Define a function to recursively index files and directories +func (si *Index) indexDirectory(adjustedPath string, quick, recursive bool) error { + realPath := strings.TrimRight(si.Root, "/") + adjustedPath + + // Open the directory + dir, err := os.Open(realPath) + if err != nil { + si.RemoveDirectory(adjustedPath) // Remove, must have been deleted + return err + } + defer dir.Close() + + dirInfo, err := dir.Stat() + if err != nil { + return err + } + combinedPath := adjustedPath + "/" + if adjustedPath == "/" { + combinedPath = "/" + } + // get whats currently in cache + si.mu.RLock() + cacheDirItems := []ItemInfo{} + modChange := true // default to true + cachedDir, exists := si.Directories[adjustedPath] + if exists && quick { + modChange = dirInfo.ModTime() != cachedDir.ModTime + cacheDirItems = cachedDir.Folders + } + si.mu.RUnlock() + + // If the directory has not been modified since the last index, skip expensive readdir + // recursively check cached dirs for mod time changes as well + if !modChange && recursive { + for _, item := range cacheDirItems { + err = si.indexDirectory(combinedPath+item.Name, quick, true) + if err != nil { + fmt.Printf("error indexing directory %v : %v", combinedPath+item.Name, err) + } + } + return nil + } + + if quick { + si.mu.Lock() + si.FilesChangedDuringIndexing = true + si.mu.Unlock() + } + + // Read directory contents + files, err := dir.Readdir(-1) + if err != nil { + return err + } + + var totalSize int64 + fileInfos := []ItemInfo{} + dirInfos := []ItemInfo{} + + // Process each file and directory in the current directory + for _, file := range files { + itemInfo := &ItemInfo{ + Name: file.Name(), + ModTime: file.ModTime(), + } + if file.IsDir() { + dirPath := combinedPath + file.Name() + if recursive { + // Recursively index the subdirectory + err = si.indexDirectory(dirPath, quick, recursive) + if err != nil { + log.Printf("Failed to index directory %s: %v", dirPath, err) + continue + } + } + realDirInfo, exists := si.GetMetadataInfo(dirPath, true) + if exists { + itemInfo.Size = realDirInfo.Size + } + totalSize += itemInfo.Size + itemInfo.Type = "directory" + dirInfos = append(dirInfos, *itemInfo) + si.NumDirs++ + } else { + _ = itemInfo.detectType(combinedPath+file.Name(), true, false, false) + itemInfo.Size = file.Size() + fileInfos = append(fileInfos, *itemInfo) + totalSize += itemInfo.Size + si.NumFiles++ + } + } + // Create FileInfo for the current directory + dirFileInfo := &FileInfo{ + Path: adjustedPath, + Files: fileInfos, + Folders: dirInfos, + } + dirFileInfo.ItemInfo = ItemInfo{ + Name: dirInfo.Name(), + Type: "directory", + Size: totalSize, + ModTime: dirInfo.ModTime(), + } + + dirFileInfo.SortItems() + + // Update the current directory metadata in the index + si.UpdateMetadata(dirFileInfo) + + return nil +} + +func (si *Index) makeIndexPath(subPath string) string { + if strings.HasPrefix(subPath, "./") { + subPath = strings.TrimPrefix(subPath, ".") + } + if strings.HasPrefix(subPath, ".") || si.Root == subPath { + return "/" + } + // clean path + subPath = strings.TrimSuffix(subPath, "/") + // remove index prefix + adjustedPath := strings.TrimPrefix(subPath, si.Root) + // remove trailing slash + adjustedPath = strings.TrimSuffix(adjustedPath, "/") + if !strings.HasPrefix(adjustedPath, "/") { + adjustedPath = "/" + adjustedPath + } + return adjustedPath +} + +func (si *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int64) { + parentDir := utils.GetParentDirectoryPath(childInfo.Path) + parentInfo, exists := si.GetMetadataInfo(parentDir, true) + if !exists || parentDir == "" { + return + } + newSize := parentInfo.Size - previousSize + childInfo.Size + parentInfo.Size += newSize + si.UpdateMetadata(parentInfo) + si.recursiveUpdateDirSizes(parentInfo, newSize) +} + +func (si *Index) RefreshFileInfo(opts FileOptions) error { + refreshOptions := FileOptions{ + Path: opts.Path, + IsDir: opts.IsDir, + } + + if !refreshOptions.IsDir { + refreshOptions.Path = si.makeIndexPath(filepath.Dir(refreshOptions.Path)) + refreshOptions.IsDir = true + } else { + refreshOptions.Path = si.makeIndexPath(refreshOptions.Path) + } + err := si.indexDirectory(refreshOptions.Path, false, false) + if err != nil { + return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path) + } + file, exists := si.GetMetadataInfo(refreshOptions.Path, true) + if !exists { + return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path) + } + + current, firstExisted := si.GetMetadataInfo(refreshOptions.Path, true) + refreshParentInfo := firstExisted && current.Size != file.Size + //utils.PrintStructFields(*file) + result := si.UpdateMetadata(file) + if !result { + return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path) + } + if !exists { + return nil + } + if refreshParentInfo { + si.recursiveUpdateDirSizes(file, current.Size) + } + return nil +} diff --git a/backend/files/indexingSchedule.go b/backend/files/indexingSchedule.go new file mode 100644 index 00000000..26eedbcb --- /dev/null +++ b/backend/files/indexingSchedule.go @@ -0,0 +1,120 @@ +package files + +import ( + "log" + "time" + + "github.com/gtsteffaniak/filebrowser/settings" +) + +// schedule in minutes +var scanSchedule = []time.Duration{ + 5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan + 10 * time.Minute, + 20 * time.Minute, // [3] element is 20 minutes, reset anchor for full scan + 40 * time.Minute, + 1 * time.Hour, + 2 * time.Hour, + 3 * time.Hour, + 4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan +} + +func (si *Index) newScanner(origin string) { + fullScanAnchor := 3 + fullScanCounter := 0 // every 5th scan is a full scan + for { + // Determine sleep time with modifiers + fullScanCounter++ + sleepTime := scanSchedule[si.currentSchedule] + si.SmartModifier + if si.assessment == "simple" { + sleepTime = scanSchedule[si.currentSchedule] - si.SmartModifier + } + if settings.Config.Server.IndexingInterval > 0 { + sleepTime = time.Duration(settings.Config.Server.IndexingInterval) * time.Minute + } + + // Log and sleep before indexing + log.Printf("Next scan in %v\n", sleepTime) + time.Sleep(sleepTime) + + si.scannerMu.Lock() + if fullScanCounter == 5 { + si.RunIndexing(origin, false) // Full scan + fullScanCounter = 0 + } else { + si.RunIndexing(origin, true) // Quick scan + } + si.scannerMu.Unlock() + + // Adjust schedule based on file changes + if si.FilesChangedDuringIndexing { + // Move to at least the full-scan anchor or reduce interval + if si.currentSchedule > fullScanAnchor { + si.currentSchedule = fullScanAnchor + } else if si.currentSchedule > 0 { + si.currentSchedule-- + } + } else { + // Increment toward the longest interval if no changes + if si.currentSchedule < len(scanSchedule)-1 { + si.currentSchedule++ + } + } + if si.assessment == "simple" && si.currentSchedule > 3 { + si.currentSchedule = 3 + } + // Ensure `currentSchedule` stays within bounds + if si.currentSchedule < 0 { + si.currentSchedule = 0 + } else if si.currentSchedule >= len(scanSchedule) { + si.currentSchedule = len(scanSchedule) - 1 + } + } +} + +func (si *Index) RunIndexing(origin string, quick bool) { + prevNumDirs := si.NumDirs + prevNumFiles := si.NumFiles + if quick { + log.Println("Starting quick scan") + } else { + log.Println("Starting full scan") + si.NumDirs = 0 + si.NumFiles = 0 + } + startTime := time.Now() + si.FilesChangedDuringIndexing = false + // Perform the indexing operation + err := si.indexDirectory("/", quick, true) + if err != nil { + log.Printf("Error during indexing: %v", err) + } + // Update the LastIndexed time + si.LastIndexed = time.Now() + si.indexingTime = int(time.Since(startTime).Seconds()) + if !quick { + // update smart indexing + if si.indexingTime < 3 || si.NumDirs < 10000 { + si.assessment = "simple" + si.SmartModifier = 4 * time.Minute + log.Println("Index is small and efficient, adjusting scan interval accordingly.") + } else if si.indexingTime > 120 || si.NumDirs > 500000 { + si.assessment = "complex" + modifier := si.indexingTime / 10 // seconds + si.SmartModifier = time.Duration(modifier) * time.Minute + log.Println("Index is large and complex, adjusting scan interval accordingly.") + } else { + si.assessment = "normal" + log.Println("Index is normal, quick scan set to every 5 minutes.") + } + log.Printf("Index assessment : complexity=%v directories=%v files=%v \n", si.assessment, si.NumDirs, si.NumFiles) + if si.NumDirs != prevNumDirs || si.NumFiles != prevNumFiles { + si.FilesChangedDuringIndexing = true + } + } + log.Printf("Time Spent Indexing : %v seconds\n", si.indexingTime) +} + +func (si *Index) setupIndexingScanners() { + go si.newScanner("/") +} diff --git a/backend/files/indexing_test.go b/backend/files/indexing_test.go index 7500285a..58051f15 100644 --- a/backend/files/indexing_test.go +++ b/backend/files/indexing_test.go @@ -3,7 +3,6 @@ package files import ( "encoding/json" "math/rand" - "path/filepath" "reflect" "testing" "time" @@ -12,7 +11,7 @@ import ( ) func BenchmarkFillIndex(b *testing.B) { - InitializeIndex(5, false) + InitializeIndex(false) si := GetIndex(settings.Config.Server.Root) b.ResetTimer() b.ReportAllocs() @@ -24,11 +23,11 @@ func BenchmarkFillIndex(b *testing.B) { func (si *Index) createMockData(numDirs, numFilesPerDir int) { for i := 0; i < numDirs; i++ { dirPath := generateRandomPath(rand.Intn(3) + 1) - files := []ReducedItem{} // Slice of FileInfo + files := []ItemInfo{} // Slice of FileInfo // Simulating files and directories with FileInfo for j := 0; j < numFilesPerDir; j++ { - newFile := ReducedItem{ + newFile := ItemInfo{ Name: "file-" + getRandomTerm() + getRandomExtension(), Size: rand.Int63n(1000), // Random size ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time @@ -37,7 +36,6 @@ func (si *Index) createMockData(numDirs, numFilesPerDir int) { files = append(files, newFile) } dirInfo := &FileInfo{ - Name: filepath.Base(dirPath), Path: dirPath, Files: files, } @@ -112,37 +110,3 @@ func TestGetIndex(t *testing.T) { }) } } - -func TestInitializeIndex(t *testing.T) { - type args struct { - intervalMinutes uint32 - } - tests := []struct { - name string - args args - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - InitializeIndex(tt.args.intervalMinutes, false) - }) - } -} - -func Test_indexingScheduler(t *testing.T) { - type args struct { - intervalMinutes uint32 - } - tests := []struct { - name string - args args - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - indexingScheduler(tt.args.intervalMinutes) - }) - } -} diff --git a/backend/files/search.go b/backend/files/search.go index e77b2b01..df855fce 100644 --- a/backend/files/search.go +++ b/backend/files/search.go @@ -28,7 +28,14 @@ func (si *Index) Search(search string, scope string, sourceSession string) []sea searchOptions := ParseSearch(search) results := make(map[string]searchResult, 0) count := 0 - directories := si.getDirsInScope(scope) + var directories []string + cachedDirs, ok := utils.SearchResultsCache.Get(si.Root + scope).([]string) + if ok { + directories = cachedDirs + } else { + directories = si.getDirsInScope(scope) + utils.SearchResultsCache.Set(si.Root+scope, directories) + } for _, searchTerm := range searchOptions.Terms { if searchTerm == "" { continue @@ -38,6 +45,7 @@ func (si *Index) Search(search string, scope string, sourceSession string) []sea } si.mu.Lock() for _, dirName := range directories { + scopedPath := strings.TrimPrefix(strings.TrimPrefix(dirName, scope), "/") + "/" si.mu.Unlock() dir, found := si.GetReducedMetadata(dirName, true) si.mu.Lock() @@ -47,25 +55,22 @@ func (si *Index) Search(search string, scope string, sourceSession string) []sea if count > maxSearchResults { break } - reducedDir := ReducedItem{ + reducedDir := ItemInfo{ Name: filepath.Base(dirName), Type: "directory", Size: dir.Size, } - matches := reducedDir.containsSearchTerm(searchTerm, searchOptions) if matches { - scopedPath := strings.TrimPrefix(strings.TrimPrefix(dirName, scope), "/") + "/" results[scopedPath] = searchResult{Path: scopedPath, Type: "directory", Size: dir.Size} count++ } - // search files first - for _, item := range dir.Items { - + for _, item := range dir.Files { fullPath := dirName + "/" + item.Name + scopedPath := strings.TrimPrefix(strings.TrimPrefix(fullPath, scope), "/") if item.Type == "directory" { - fullPath += "/" + scopedPath += "/" } value, found := sessionInProgress.Load(sourceSession) if !found || value != runningHash { @@ -77,7 +82,6 @@ func (si *Index) Search(search string, scope string, sourceSession string) []sea } matches := item.containsSearchTerm(searchTerm, searchOptions) if matches { - scopedPath := strings.TrimPrefix(strings.TrimPrefix(fullPath, scope), "/") results[scopedPath] = searchResult{Path: scopedPath, Type: item.Type, Size: item.Size} count++ } @@ -103,7 +107,7 @@ func (si *Index) Search(search string, scope string, sourceSession string) []sea // returns true if the file name contains the search term // returns file type if the file name contains the search term // returns size of file/dir if the file name contains the search term -func (fi ReducedItem) containsSearchTerm(searchTerm string, options *SearchOptions) bool { +func (fi ItemInfo) containsSearchTerm(searchTerm string, options SearchOptions) bool { fileTypes := map[string]bool{} largerThan := int64(options.LargerThan) * 1024 * 1024 diff --git a/backend/files/search_test.go b/backend/files/search_test.go index 094d8f5e..2ebbcfe2 100644 --- a/backend/files/search_test.go +++ b/backend/files/search_test.go @@ -8,7 +8,7 @@ import ( ) func BenchmarkSearchAllIndexes(b *testing.B) { - InitializeIndex(5, false) + InitializeIndex(false) si := GetIndex(rootPath) si.createMockData(50, 3) // 50 dirs, 3 files per dir @@ -29,25 +29,25 @@ func BenchmarkSearchAllIndexes(b *testing.B) { func TestParseSearch(t *testing.T) { tests := []struct { input string - want *SearchOptions + want SearchOptions }{ { input: "my test search", - want: &SearchOptions{ + want: SearchOptions{ Conditions: map[string]bool{"exact": false}, Terms: []string{"my test search"}, }, }, { input: "case:exact my|test|search", - want: &SearchOptions{ + want: SearchOptions{ Conditions: map[string]bool{"exact": true}, Terms: []string{"my", "test", "search"}, }, }, { input: "type:largerThan=100 type:smallerThan=1000 test", - want: &SearchOptions{ + want: SearchOptions{ Conditions: map[string]bool{"exact": false, "larger": true, "smaller": true}, Terms: []string{"test"}, LargerThan: 100, @@ -56,7 +56,7 @@ func TestParseSearch(t *testing.T) { }, { input: "type:audio thisfile", - want: &SearchOptions{ + want: SearchOptions{ Conditions: map[string]bool{"exact": false, "audio": true}, Terms: []string{"thisfile"}, }, @@ -74,7 +74,7 @@ func TestParseSearch(t *testing.T) { } func TestSearchWhileIndexing(t *testing.T) { - InitializeIndex(5, false) + InitializeIndex(false) si := GetIndex(rootPath) searchTerms := generateRandomSearchTerms(10) @@ -89,27 +89,29 @@ func TestSearchWhileIndexing(t *testing.T) { func TestSearchIndexes(t *testing.T) { index := Index{ Directories: map[string]*FileInfo{ - "/test": {Files: []ReducedItem{{Name: "audio1.wav", Type: "audio"}}}, - "/test/path": {Files: []ReducedItem{{Name: "file.txt", Type: "text"}}}, - "/new/test": {Files: []ReducedItem{ + "/test": {Files: []ItemInfo{{Name: "audio1.wav", Type: "audio"}}}, + "/test/path": {Files: []ItemInfo{{Name: "file.txt", Type: "text"}}}, + "/new/test": {Files: []ItemInfo{ {Name: "audio.wav", Type: "audio"}, {Name: "video.mp4", Type: "video"}, {Name: "video.MP4", Type: "video"}, }}, - "/new/test/path": {Files: []ReducedItem{{Name: "archive.zip", Type: "archive"}}}, + "/new/test/path": {Files: []ItemInfo{{Name: "archive.zip", Type: "archive"}}}, "/firstDir": { - Files: []ReducedItem{ + Files: []ItemInfo{ {Name: "archive.zip", Size: 100, Type: "archive"}, }, - Dirs: map[string]*FileInfo{ - "thisIsDir": {Name: "thisIsDir", Size: 2 * 1024 * 1024}, + Folders: []ItemInfo{ + {Name: "thisIsDir", Type: "directory", Size: 2 * 1024 * 1024}, }, }, "/firstDir/thisIsDir": { - Files: []ReducedItem{ + Files: []ItemInfo{ {Name: "hi.txt", Type: "text"}, }, - Size: 2 * 1024 * 1024, + ItemInfo: ItemInfo{ + Size: 2 * 1024 * 1024, + }, }, }, } diff --git a/backend/files/sync.go b/backend/files/sync.go index 7eb35ba1..9205fe1d 100644 --- a/backend/files/sync.go +++ b/backend/files/sync.go @@ -1,10 +1,7 @@ package files import ( - "log" "path/filepath" - "sort" - "time" "github.com/gtsteffaniak/filebrowser/settings" ) @@ -13,15 +10,14 @@ import ( func (si *Index) UpdateMetadata(info *FileInfo) bool { si.mu.Lock() defer si.mu.Unlock() - info.CacheTime = time.Now() si.Directories[info.Path] = info return true } // GetMetadataInfo retrieves the FileInfo from the specified directory in the index. func (si *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) { - si.mu.RLock() - defer si.mu.RUnlock() + si.mu.Lock() + defer si.mu.Unlock() checkDir := si.makeIndexPath(target) if !isDir { checkDir = si.makeIndexPath(filepath.Dir(target)) @@ -30,50 +26,25 @@ func (si *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) if !exists { return nil, false } - if !isDir { - if checkDir == "/" { - checkDir = "" - } - baseName := filepath.Base(target) - for _, item := range dir.Files { - if item.Name == baseName { - return &FileInfo{ - Name: item.Name, - Size: item.Size, - ModTime: item.ModTime, - Type: item.Type, - Path: checkDir + "/" + item.Name, - }, true - } + if isDir { + return dir, true + } + // handle file + if checkDir == "/" { + checkDir = "" + } + baseName := filepath.Base(target) + for _, item := range dir.Files { + if item.Name == baseName { + return &FileInfo{ + Path: checkDir + "/" + item.Name, + ItemInfo: item, + }, true } - return nil, false } - cleanedItems := []ReducedItem{} - for name, item := range dir.Dirs { - cleanedItems = append(cleanedItems, ReducedItem{ - Name: name, - Size: item.Size, - ModTime: item.ModTime, - Type: "directory", - }) - } - cleanedItems = append(cleanedItems, dir.Files...) - sort.Slice(cleanedItems, func(i, j int) bool { - return cleanedItems[i].Name < cleanedItems[j].Name - }) - dirname := filepath.Base(dir.Path) - if dirname == "." { - dirname = "/" - } - // construct file info - return &FileInfo{ - Name: dirname, - Type: "directory", - Items: cleanedItems, - ModTime: dir.ModTime, - Size: dir.Size, - }, true + return nil, false + } // GetMetadataInfo retrieves the FileInfo from the specified directory in the index. @@ -91,29 +62,10 @@ func (si *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) { func (si *Index) RemoveDirectory(path string) { si.mu.Lock() defer si.mu.Unlock() + si.NumDeleted++ delete(si.Directories, path) } -func (si *Index) UpdateCount(given string) { - si.mu.Lock() - defer si.mu.Unlock() - if given == "files" { - si.NumFiles++ - } else if given == "dirs" { - si.NumDirs++ - } else { - log.Println("could not update unknown type: ", given) - } -} - -func (si *Index) resetCount() { - si.mu.Lock() - defer si.mu.Unlock() - si.NumDirs = 0 - si.NumFiles = 0 - si.inProgress = true -} - func GetIndex(root string) *Index { for _, index := range indexes { if index.Root == root { @@ -128,7 +80,6 @@ func GetIndex(root string) *Index { Directories: map[string]*FileInfo{}, NumDirs: 0, NumFiles: 0, - inProgress: false, } newIndex.Directories["/"] = &FileInfo{} indexesMutex.Lock() diff --git a/backend/files/sync_test.go b/backend/files/sync_test.go index 36333750..70a4ffad 100644 --- a/backend/files/sync_test.go +++ b/backend/files/sync_test.go @@ -34,7 +34,7 @@ func TestGetFileMetadataSize(t *testing.T) { t.Run(tt.name, func(t *testing.T) { fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, true) // Iterate over fileInfo.Items to look for expectedName - for _, item := range fileInfo.Items { + for _, item := range fileInfo.Files { // Assert the existence and the name if item.Name == tt.expectedName { assert.Equal(t, tt.expectedSize, item.Size) @@ -89,8 +89,8 @@ func TestGetFileMetadata(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, tt.isDir) - if fileInfo == nil { + fileInfo, exists := testIndex.GetReducedMetadata(tt.adjustedPath, tt.isDir) + if !exists { found := false assert.Equal(t, tt.expectedExists, found) return @@ -98,7 +98,7 @@ func TestGetFileMetadata(t *testing.T) { found := false if tt.isDir { // Iterate over fileInfo.Items to look for expectedName - for _, item := range fileInfo.Items { + for _, item := range fileInfo.Files { // Assert the existence and the name if item.Name == tt.expectedName { found = true @@ -120,9 +120,7 @@ func TestGetFileMetadata(t *testing.T) { func TestUpdateFileMetadata(t *testing.T) { info := &FileInfo{ Path: "/testpath", - Name: "testpath", - Type: "directory", - Files: []ReducedItem{ + Files: []ItemInfo{ {Name: "testfile.txt"}, {Name: "anotherfile.txt"}, }, @@ -165,9 +163,11 @@ func TestSetDirectoryInfo(t *testing.T) { Directories: map[string]*FileInfo{ "/testpath": { Path: "/testpath", - Name: "testpath", - Type: "directory", - Items: []ReducedItem{ + ItemInfo: ItemInfo{ + Name: "testpath", + Type: "directory", + }, + Files: []ItemInfo{ {Name: "testfile.txt"}, {Name: "anotherfile.txt"}, }, @@ -176,15 +176,17 @@ func TestSetDirectoryInfo(t *testing.T) { } dir := &FileInfo{ Path: "/newPath", - Name: "newPath", - Type: "directory", - Items: []ReducedItem{ + ItemInfo: ItemInfo{ + Name: "newPath", + Type: "directory", + }, + Files: []ItemInfo{ {Name: "testfile.txt"}, }, } index.UpdateMetadata(dir) storedDir, exists := index.Directories["/newPath"] - if !exists || storedDir.Items[0].Name != "testfile.txt" { + if !exists || storedDir.Files[0].Name != "testfile.txt" { t.Fatalf("expected SetDirectoryInfo to store directory info correctly") } } @@ -203,56 +205,34 @@ func TestRemoveDirectory(t *testing.T) { } } -// Test for UpdateCount -func TestUpdateCount(t *testing.T) { - index := &Index{} - index.UpdateCount("files") - if index.NumFiles != 1 { - t.Fatalf("expected NumFiles to be 1 after UpdateCount('files')") - } - if index.NumFiles != 1 { - t.Fatalf("expected NumFiles to be 1 after UpdateCount('files')") - } - index.UpdateCount("dirs") - if index.NumDirs != 1 { - t.Fatalf("expected NumDirs to be 1 after UpdateCount('dirs')") - } - index.UpdateCount("unknown") - // Just ensure it does not panic or update any counters - if index.NumFiles != 1 || index.NumDirs != 1 { - t.Fatalf("expected counts to remain unchanged for unknown type") - } - index.resetCount() - if index.NumFiles != 0 || index.NumDirs != 0 || !index.inProgress { - t.Fatalf("expected resetCount to reset counts and set inProgress to true") - } -} - func init() { testIndex = Index{ - Root: "/", - NumFiles: 10, - NumDirs: 5, - inProgress: false, + Root: "/", + NumFiles: 10, + NumDirs: 5, Directories: map[string]*FileInfo{ "/testpath": { Path: "/testpath", - Name: "testpath", - Type: "directory", - Files: []ReducedItem{ + ItemInfo: ItemInfo{ + Name: "testpath", + Type: "directory", + }, + Files: []ItemInfo{ {Name: "testfile.txt", Size: 100}, {Name: "anotherfile.txt", Size: 100}, }, }, "/anotherpath": { Path: "/anotherpath", - Name: "anotherpath", - Type: "directory", - Files: []ReducedItem{ + ItemInfo: ItemInfo{ + Name: "anotherpath", + Type: "directory", + }, + Files: []ItemInfo{ {Name: "afile.txt", Size: 100}, }, - Dirs: map[string]*FileInfo{ - "directory": {Name: "directory", Type: "directory", Size: 100}, + Folders: []ItemInfo{ + {Name: "directory", Type: "directory", Size: 100}, }, }, }, diff --git a/backend/http/auth.go b/backend/http/auth.go index abf34674..1b01d4b7 100644 --- a/backend/http/auth.go +++ b/backend/http/auth.go @@ -2,9 +2,11 @@ package http import ( "encoding/json" + libError "errors" "fmt" "log" "net/http" + "net/url" "os" "strings" "sync" @@ -12,9 +14,11 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4/request" + "golang.org/x/crypto/bcrypt" "github.com/gtsteffaniak/filebrowser/errors" "github.com/gtsteffaniak/filebrowser/settings" + "github.com/gtsteffaniak/filebrowser/share" "github.com/gtsteffaniak/filebrowser/users" "github.com/gtsteffaniak/filebrowser/utils" ) @@ -207,3 +211,29 @@ func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, p } return claim, err } + +func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) { + if l.PasswordHash == "" { + return 200, nil + } + + if r.URL.Query().Get("token") == l.Token { + return 200, nil + } + + password := r.Header.Get("X-SHARE-PASSWORD") + password, err := url.QueryUnescape(password) + if err != nil { + return http.StatusUnauthorized, err + } + if password == "" { + return http.StatusUnauthorized, nil + } + if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil { + if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return http.StatusUnauthorized, nil + } + return 401, err + } + return 200, nil +} diff --git a/backend/http/middleware.go b/backend/http/middleware.go index 2b8054d2..c6967397 100644 --- a/backend/http/middleware.go +++ b/backend/http/middleware.go @@ -26,6 +26,8 @@ type HttpResponse struct { Token string `json:"token,omitempty"` } +var FileInfoFasterFunc = files.FileInfoFaster + // Updated handleFunc to match the new signature type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) @@ -39,30 +41,30 @@ func withHashFileHelper(fn handleFunc) handleFunc { // Get the file link by hash link, err := store.Share.GetByHash(hash) if err != nil { - return http.StatusNotFound, err + return http.StatusNotFound, fmt.Errorf("share not found") } // Authenticate the share request if needed var status int if link.Hash != "" { status, err = authenticateShareRequest(r, link) if err != nil || status != http.StatusOK { - return status, err + return status, fmt.Errorf("could not authenticate share request") } } // Retrieve the user (using the public user by default) user := &users.PublicUser // Get file information with options - file, err := files.FileInfoFaster(files.FileOptions{ + file, err := FileInfoFasterFunc(files.FileOptions{ Path: filepath.Join(user.Scope, link.Path+"/"+path), Modify: user.Perm.Modify, Expand: true, ReadHeader: config.Server.TypeDetectionByHeader, Checker: user, // Call your checker function here - Token: link.Token, }) + file.Token = link.Token if err != nil { - return errToStatus(err), err + return errToStatus(err), fmt.Errorf("error fetching share from server") } // Set the file info in the `data` object @@ -89,6 +91,7 @@ func withAdminHelper(fn handleFunc) handleFunc { // Middleware to retrieve and authenticate user func withUserHelper(fn handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) { + keyFunc := func(token *jwt.Token) (interface{}, error) { return config.Auth.Key, nil } @@ -243,6 +246,7 @@ func (w *ResponseWriterWrapper) Write(b []byte) (int, error) { // LoggingMiddleware logs each request and its status code func LoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() // Wrap the ResponseWriter to capture the status code diff --git a/backend/http/middleware_test.go b/backend/http/middleware_test.go index 31264c98..a14a53fc 100644 --- a/backend/http/middleware_test.go +++ b/backend/http/middleware_test.go @@ -9,6 +9,7 @@ import ( "github.com/asdine/storm/v3" "github.com/gtsteffaniak/filebrowser/diskcache" + "github.com/gtsteffaniak/filebrowser/files" "github.com/gtsteffaniak/filebrowser/img" "github.com/gtsteffaniak/filebrowser/settings" "github.com/gtsteffaniak/filebrowser/share" @@ -37,6 +38,27 @@ func setupTestEnv(t *testing.T) { fileCache = diskcache.NewNoOp() // mocked imgSvc = img.New(1) // mocked config = &settings.Config // mocked + mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test +} + +func mockFileInfoFaster(t *testing.T) { + // Backup the original function + originalFileInfoFaster := FileInfoFasterFunc + // Defer restoration of the original function + t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster }) + + // Mock the function to skip execution + FileInfoFasterFunc = func(opts files.FileOptions) (files.ExtendedFileInfo, error) { + return files.ExtendedFileInfo{ + FileInfo: &files.FileInfo{ + Path: opts.Path, + ItemInfo: files.ItemInfo{ + Name: "mocked_file", + Size: 12345, + }, + }, + }, nil + } } func TestWithAdminHelper(t *testing.T) { @@ -197,10 +219,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) { req := newTestRequest(t, tc.share.Hash, tc.token, tc.password, tc.extraHeaders) // Serve the request - status, err := handler(recorder, req, &requestContext{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + status, _ := handler(recorder, req, &requestContext{}) // Check if the response matches the expected status code if status != tc.expectedStatusCode { diff --git a/backend/http/preview.go b/backend/http/preview.go index 3b3d050b..ac71a0a5 100644 --- a/backend/http/preview.go +++ b/backend/http/preview.go @@ -49,27 +49,23 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) ( if path == "" { return http.StatusBadRequest, fmt.Errorf("invalid request path") } - file, err := files.FileInfoFaster(files.FileOptions{ + response, err := files.FileInfoFaster(files.FileOptions{ Path: filepath.Join(d.user.Scope, path), Modify: d.user.Perm.Modify, Expand: true, ReadHeader: config.Server.TypeDetectionByHeader, Checker: d.user, }) + fileInfo := response.FileInfo if err != nil { return errToStatus(err), err } - realPath, _, err := files.GetRealPath(file.Path) - if err != nil { - return http.StatusInternalServerError, err - } - file.Path = realPath - if file.Type == "directory" { + if fileInfo.Type == "directory" { return http.StatusBadRequest, fmt.Errorf("can't create preview for directory") } - setContentDisposition(w, r, file) - if file.Type != "image" { - return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type) + setContentDisposition(w, r, fileInfo) + if fileInfo.Type != "image" { + return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", fileInfo.Type) } if (previewSize == "large" && !config.Server.ResizePreview) || @@ -77,40 +73,40 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) ( if !d.user.Perm.Download { return http.StatusAccepted, nil } - return rawFileHandler(w, r, file) + return rawFileHandler(w, r, fileInfo) } - format, err := imgSvc.FormatFromExtension(filepath.Ext(file.Name)) + format, err := imgSvc.FormatFromExtension(filepath.Ext(fileInfo.Name)) // Unsupported extensions directly return the raw data if err == img.ErrUnsupportedFormat || format == img.FormatGif { if !d.user.Perm.Download { return http.StatusAccepted, nil } - return rawFileHandler(w, r, file) + return rawFileHandler(w, r, fileInfo) } if err != nil { return errToStatus(err), err } - cacheKey := previewCacheKey(file, previewSize) + cacheKey := previewCacheKey(fileInfo, previewSize) resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey) if err != nil { return errToStatus(err), err } if !ok { - resizedImage, err = createPreview(imgSvc, fileCache, file, previewSize) + resizedImage, err = createPreview(imgSvc, fileCache, fileInfo, previewSize) if err != nil { return errToStatus(err), err } } w.Header().Set("Cache-Control", "private") - http.ServeContent(w, r, file.Path, file.ModTime, bytes.NewReader(resizedImage)) + http.ServeContent(w, r, fileInfo.RealPath(), fileInfo.ModTime, bytes.NewReader(resizedImage)) return 0, nil } func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo, previewSize string) ([]byte, error) { - fd, err := os.Open(file.Path) + fd, err := os.Open(file.RealPath()) if err != nil { return nil, err } diff --git a/backend/http/public.go b/backend/http/public.go index 42f98c9e..63e32cff 100644 --- a/backend/http/public.go +++ b/backend/http/public.go @@ -2,24 +2,19 @@ package http import ( "encoding/json" - "errors" "fmt" "net/http" - "net/url" "strings" - "golang.org/x/crypto/bcrypt" - "github.com/gtsteffaniak/filebrowser/files" "github.com/gtsteffaniak/filebrowser/settings" - "github.com/gtsteffaniak/filebrowser/share" "github.com/gtsteffaniak/filebrowser/users" _ "github.com/gtsteffaniak/filebrowser/swagger/docs" ) func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { - file, ok := d.raw.(*files.FileInfo) + file, ok := d.raw.(files.ExtendedFileInfo) if !ok { return http.StatusInternalServerError, fmt.Errorf("failed to assert type *files.FileInfo") } @@ -38,8 +33,8 @@ func publicUserGetHandler(w http.ResponseWriter, r *http.Request) { } func publicDlHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { - file, _ := d.raw.(*files.FileInfo) - if file == nil { + file, ok := d.raw.(files.ExtendedFileInfo) + if !ok { return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo") } if d.user == nil { @@ -47,36 +42,10 @@ func publicDlHandler(w http.ResponseWriter, r *http.Request, d *requestContext) } if file.Type == "directory" { - return rawDirHandler(w, r, d, file) + return rawDirHandler(w, r, d, file.FileInfo) } - return rawFileHandler(w, r, file) -} - -func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) { - if l.PasswordHash == "" { - return 200, nil - } - - if r.URL.Query().Get("token") == l.Token { - return 200, nil - } - - password := r.Header.Get("X-SHARE-PASSWORD") - password, err := url.QueryUnescape(password) - if err != nil { - return http.StatusUnauthorized, err - } - if password == "" { - return http.StatusUnauthorized, nil - } - if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil { - if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { - return http.StatusUnauthorized, nil - } - return 401, err - } - return 200, nil + return rawFileHandler(w, r, file.FileInfo) } // health godoc diff --git a/backend/http/raw.go b/backend/http/raw.go index 7bac12d1..eeee5a8f 100644 --- a/backend/http/raw.go +++ b/backend/http/raw.go @@ -99,7 +99,7 @@ func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, return http.StatusAccepted, nil } path := r.URL.Query().Get("path") - file, err := files.FileInfoFaster(files.FileOptions{ + fileInfo, err := files.FileInfoFaster(files.FileOptions{ Path: filepath.Join(d.user.Scope, path), Modify: d.user.Perm.Modify, Expand: false, @@ -109,15 +109,19 @@ func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, if err != nil { return errToStatus(err), err } - if files.IsNamedPipe(file.Mode) { - setContentDisposition(w, r, file) - return 0, nil - } - if file.Type == "directory" { - return rawDirHandler(w, r, d, file) + + // TODO, how to handle? we removed mode, is it needed? + // maybe instead of mode we use bool only two conditions are checked + //if files.IsNamedPipe(fileInfo.Mode) { + // setContentDisposition(w, r, file) + // return 0, nil + //} + + if fileInfo.Type == "directory" { + return rawDirHandler(w, r, d, fileInfo.FileInfo) } - return rawFileHandler(w, r, file) + return rawFileHandler(w, r, fileInfo.FileInfo) } func addFile(ar archiver.Writer, d *requestContext, path, commonPath string) error { diff --git a/backend/http/resource.go b/backend/http/resource.go index ba5b5adb..e165fb43 100644 --- a/backend/http/resource.go +++ b/backend/http/resource.go @@ -14,6 +14,7 @@ import ( "github.com/gtsteffaniak/filebrowser/errors" "github.com/gtsteffaniak/filebrowser/files" + "github.com/gtsteffaniak/filebrowser/utils" ) // resourceGetHandler retrieves information about a resource. @@ -31,9 +32,10 @@ import ( // @Failure 500 {object} map[string]string "Internal server error" // @Router /api/resources [get] func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { + // TODO source := r.URL.Query().Get("source") path := r.URL.Query().Get("path") - file, err := files.FileInfoFaster(files.FileOptions{ + fileInfo, err := files.FileInfoFaster(files.FileOptions{ Path: filepath.Join(d.user.Scope, path), Modify: d.user.Perm.Modify, Expand: true, @@ -44,18 +46,19 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex if err != nil { return errToStatus(err), err } - if file.Type == "directory" { - return renderJSON(w, r, file) + if fileInfo.Type == "directory" { + return renderJSON(w, r, fileInfo) } - if checksum := r.URL.Query().Get("checksum"); checksum != "" { - err := file.Checksum(checksum) + if algo := r.URL.Query().Get("checksum"); algo != "" { + checksums, err := files.GetChecksum(fileInfo.Path, algo) if err == errors.ErrInvalidOption { return http.StatusBadRequest, nil } else if err != nil { return http.StatusInternalServerError, err } + fileInfo.Checksums = checksums } - return renderJSON(w, r, file) + return renderJSON(w, r, fileInfo) } @@ -90,13 +93,13 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon ReadHeader: config.Server.TypeDetectionByHeader, Checker: d.user, } - file, err := files.FileInfoFaster(fileOpts) + fileInfo, err := files.FileInfoFaster(fileOpts) if err != nil { return errToStatus(err), err } // delete thumbnails - err = delThumbs(r.Context(), fileCache, file) + err = delThumbs(r.Context(), fileCache, fileInfo.FileInfo) if err != nil { return errToStatus(err), err } @@ -131,11 +134,10 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte return http.StatusForbidden, nil } fileOpts := files.FileOptions{ - Path: filepath.Join(d.user.Scope, path), - Modify: d.user.Perm.Modify, - Expand: false, - ReadHeader: config.Server.TypeDetectionByHeader, - Checker: d.user, + Path: filepath.Join(d.user.Scope, path), + Modify: d.user.Perm.Modify, + Expand: false, + Checker: d.user, } // Directories creation on POST. if strings.HasSuffix(path, "/") { @@ -145,7 +147,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte } return http.StatusOK, nil } - file, err := files.FileInfoFaster(fileOpts) + fileInfo, err := files.FileInfoFaster(fileOpts) if err == nil { if r.URL.Query().Get("override") != "true" { return http.StatusConflict, nil @@ -156,13 +158,17 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte return http.StatusForbidden, nil } - err = delThumbs(r.Context(), fileCache, file) + err = delThumbs(r.Context(), fileCache, fileInfo.FileInfo) if err != nil { return errToStatus(err), err } } err = files.WriteFile(fileOpts, r.Body) - return errToStatus(err), err + if err != nil { + return errToStatus(err), err + + } + return http.StatusOK, nil } // resourcePutHandler updates an existing file resource. @@ -301,7 +307,7 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext if !d.user.Perm.Rename { return errors.ErrPermissionDenied } - file, err := files.FileInfoFaster(files.FileOptions{ + fileInfo, err := files.FileInfoFaster(files.FileOptions{ Path: src, IsDir: isSrcDir, Modify: d.user.Perm.Modify, @@ -314,7 +320,7 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext } // delete thumbnails - err = delThumbs(ctx, fileCache, file) + err = delThumbs(ctx, fileCache, fileInfo.FileInfo) if err != nil { return err } @@ -345,25 +351,29 @@ func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, if source == "" { source = "/" } - file, err := files.FileInfoFaster(files.FileOptions{ - Path: source, - Checker: d.user, - }) + + value, ok := utils.DiskUsageCache.Get(source).(DiskUsageResponse) + if ok { + return renderJSON(w, r, &value) + } + + fPath, isDir, err := files.GetRealPath(d.user.Scope, source) if err != nil { return errToStatus(err), err } - fPath := file.RealPath() - if file.Type != "directory" { - return http.StatusBadRequest, fmt.Errorf("path is not a directory") + if !isDir { + return http.StatusNotFound, fmt.Errorf("not a directory: %s", source) } usage, err := disk.UsageWithContext(r.Context(), fPath) if err != nil { return errToStatus(err), err } - return renderJSON(w, r, &DiskUsageResponse{ + latestUsage := DiskUsageResponse{ Total: usage.Total, Used: usage.Used, - }) + } + utils.DiskUsageCache.Set(source, latestUsage) + return renderJSON(w, r, &latestUsage) } func inspectIndex(w http.ResponseWriter, r *http.Request) { diff --git a/backend/http/router.go b/backend/http/router.go index d7c9492f..66530165 100644 --- a/backend/http/router.go +++ b/backend/http/router.go @@ -122,7 +122,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) { router.HandleFunc(config.Server.BaseURL, indexHandler) // health - router.HandleFunc(fmt.Sprintf("GET %vhealth/", config.Server.BaseURL), healthHandler) + router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler) // Swagger router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), @@ -172,7 +172,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) { } else { // Set HTTP scheme and the default port for HTTP scheme = "http" - if config.Server.Port != 443 { + if config.Server.Port != 80 { port = fmt.Sprintf(":%d", config.Server.Port) } // Build the full URL with host and port diff --git a/backend/http/share.go b/backend/http/share.go index 96ee4e89..e04d470b 100644 --- a/backend/http/share.go +++ b/backend/http/share.go @@ -69,7 +69,7 @@ func shareGetsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) return renderJSON(w, r, []*share.Link{}) } if err != nil { - return http.StatusInternalServerError, err + return http.StatusInternalServerError, fmt.Errorf("error getting share info from server") } return renderJSON(w, r, s) } @@ -188,7 +188,7 @@ func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, e hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password: %w", err) + return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password") } return hash, 0, nil diff --git a/backend/myfolder/subfolder/Screenshot 2024-11-18 at 2.16.29 PM.png b/backend/myfolder/subfolder/Screenshot 2024-11-18 at 2.16.29 PM.png deleted file mode 100755 index 4deb950cd60b9c5b421d45c8dda87755b5b1a275..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 73659 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 1) { fileInfo = files.map(encodeURIComponent).join(","); } const params = { - path, - hash, - ...(format && { format}), - ...(token && { token }), - fileInfo + "path": removePrefix(share.path, "share"), + "hash": share.hash, + "token": share.token, + "inline": share.inline, + "files": fileInfo, }; - const url = createURL(`api/public/dl`, params, false); + const apiPath = getApiPath("api/public/dl", params); + const url = createURL(apiPath); window.open(url); } catch (err) { notify.showError(err.message || "Error downloading files"); throw err; } - - } // Get the public user data @@ -64,11 +59,7 @@ export async function getPublicUser() { // Generate a download URL export function getDownloadURL(share) { - const params = { - "path": share.path, - "hash": share.hash, - "token": share.token, - ...(share.inline && { inline: "true" }), - }; - return createURL(`api/public/dl`, params, false); + const apiPath = getApiPath("api/public/dl", share); + const url = createURL(apiPath) + return url } diff --git a/frontend/src/api/utils.js b/frontend/src/api/utils.js index a824eec7..c397622d 100644 --- a/frontend/src/api/utils.js +++ b/frontend/src/api/utils.js @@ -60,36 +60,47 @@ export async function fetchJSON(url, opts) { } } -export function createURL(endpoint, params = {}) { +export function createURL(endpoint) { let prefix = baseURL; + + // Ensure prefix ends with a single slash if (!prefix.endsWith("/")) { - prefix = prefix + "/"; + prefix += "/"; } - const url = new URL(prefix + endpoint, origin); - const searchParams = { - ...params, - }; - - for (const key in searchParams) { - url.searchParams.set(key, searchParams[key]); + // Remove leading slash from endpoint to avoid duplicate slashes + if (endpoint.startsWith("/")) { + endpoint = endpoint.substring(1); } + const url = new URL(prefix + endpoint, window.location.origin); + return url.toString(); } export function adjustedData(data, url) { data.url = url; - if (data.type == "directory") { + + if (data.type === "directory") { if (!data.url.endsWith("/")) data.url += "/"; + + // Combine folders and files into items + data.items = [...(data.folders || []), ...(data.files || [])]; + data.items = data.items.map((item, index) => { item.index = index; item.url = `${data.url}${item.name}`; - if (item.type == "directory") { + if (item.type === "directory") { item.url += "/"; } return item; }); } - return data -} \ No newline at end of file + if (data.files) { + data.files = [] + } + if (data.folders) { + data.folders = [] + } + return data; +} diff --git a/frontend/src/api/utils.test.js b/frontend/src/api/utils.test.js new file mode 100644 index 00000000..8464c90e --- /dev/null +++ b/frontend/src/api/utils.test.js @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from 'vitest'; +import { adjustedData, createURL } from './utils.js'; + +describe('adjustedData', () => { + it('should append the URL and process directory data correctly', () => { + const input = { + type: "directory", + folders: [ + { name: "folder1", type: "directory" }, + { name: "folder2", type: "directory" }, + ], + files: [ + { name: "file1.txt", type: "file" }, + { name: "file2.txt", type: "file" }, + ], + }; + + const url = "http://example.com/unit-testing/files/path/to/directory"; + + const expected = { + type: "directory", + url: "http://example.com/unit-testing/files/path/to/directory/", + folders: [], + files: [], + items: [ + { name: "folder1", type: "directory", index: 0, url: "http://example.com/unit-testing/files/path/to/directory/folder1/" }, + { name: "folder2", type: "directory", index: 1, url: "http://example.com/unit-testing/files/path/to/directory/folder2/" }, + { name: "file1.txt", type: "file", index: 2, url: "http://example.com/unit-testing/files/path/to/directory/file1.txt" }, + { name: "file2.txt", type: "file", index: 3, url: "http://example.com/unit-testing/files/path/to/directory/file2.txt" }, + ], + }; + + expect(adjustedData(input, url)).toEqual(expected); + }); + + it('should add a trailing slash to the URL if missing for a directory', () => { + const input = { type: "directory", folders: [], files: [] }; + const url = "http://example.com/base"; + + const expected = { + type: "directory", + url: "http://example.com/base/", + folders: [], + files: [], + items: [], + }; + + expect(adjustedData(input, url)).toEqual(expected); + }); + + it('should handle non-directory types without modification to items', () => { + const input = { type: "file", name: "file1.txt" }; + const url = "http://example.com/base"; + + const expected = { + type: "file", + name: "file1.txt", + url: "http://example.com/base", + }; + + expect(adjustedData(input, url)).toEqual(expected); + }); + + it('should handle missing folders and files gracefully', () => { + const input = { type: "directory" }; + const url = "http://example.com/base"; + + const expected = { + type: "directory", + url: "http://example.com/base/", + items: [], + }; + + expect(adjustedData(input, url)).toEqual(expected); + }); + + it('should handle empty input object correctly', () => { + const input = {}; + const url = "http://example.com/base"; + + const expected = { + url: "http://example.com/base", + }; + + expect(adjustedData(input, url)).toEqual(expected); + }); + +}); + + +describe('createURL', () => { + it('createURL', () => { + const url = "base"; + const expected = "http://localhost:3000/unit-testing/base" + expect(createURL(url)).toEqual(expected); + }); + it('createURL with slash', () => { + const url = "/base"; + const expected = "http://localhost:3000/unit-testing/base" + expect(createURL(url)).toEqual(expected); + }); + it('createURL with slash', () => { + const url = "/base"; + const expected = "http://localhost:3000/unit-testing/base" + expect(createURL(url)).toEqual(expected); + }); +}) + +vi.mock('@/utils/constants', () => { + return { + baseURL: "unit-testing", + }; +}); + diff --git a/frontend/src/components/Breadcrumbs.vue b/frontend/src/components/Breadcrumbs.vue index d27804e8..15405ef7 100644 --- a/frontend/src/components/Breadcrumbs.vue +++ b/frontend/src/components/Breadcrumbs.vue @@ -21,7 +21,6 @@ type="range" id="gallery-size" name="gallery-size" - :value="gallerySize" min="0" max="10" @input="updateGallerySize" @@ -62,6 +61,9 @@ export default { if (parts[0] === "") { parts.shift(); } + if (getters.currentView() == "share") { + parts.shift(); + } if (parts[parts.length - 1] === "") { parts.pop(); diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue index 90167963..32301607 100644 --- a/frontend/src/components/files/ListingItem.vue +++ b/frontend/src/components/files/ListingItem.vue @@ -76,6 +76,7 @@ import { filesApi } from "@/api"; import * as upload from "@/utils/upload"; import { state, getters, mutations } from "@/store"; // Import your custom store import { baseURL } from "@/utils/constants"; +import { router } from "@/router"; export default { name: "item", @@ -323,7 +324,7 @@ export default { mutations.addSelected(this.index); }, open() { - this.$router.push({ path: this.url }); + router.push({ path: this.url }); }, }, }; diff --git a/frontend/src/components/prompts/Delete.vue b/frontend/src/components/prompts/Delete.vue index de838584..508ed7b3 100644 --- a/frontend/src/components/prompts/Delete.vue +++ b/frontend/src/components/prompts/Delete.vue @@ -59,7 +59,7 @@ export default { if (!this.isListing) { await filesApi.remove(state.route.path); buttons.success("delete"); - showSuccess("Deleted item successfully"); + notify.showSuccess("Deleted item successfully"); this.currentPrompt?.confirm(); this.closeHovers(); @@ -79,7 +79,7 @@ export default { await Promise.all(promises); buttons.success("delete"); - showSuccess("Deleted item successfully"); + notify.showSuccess("Deleted item successfully"); mutations.setReload(true); // Handle reload as needed } catch (e) { buttons.done("delete"); diff --git a/frontend/src/components/prompts/Share.vue b/frontend/src/components/prompts/Share.vue index 5376075b..20996275 100644 --- a/frontend/src/components/prompts/Share.vue +++ b/frontend/src/components/prompts/Share.vue @@ -8,50 +8,52 @@