Compare commits

...
7 Commits
85 changed files with 2474 additions and 1609 deletions

No files matched your search

+9 -2
View File
@@ -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
+33
View File
@@ -2,6 +2,39 @@
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.2
**New Features**
- Mobile search has the same features as desktop.
**Notes**:
- Added compression. Helpful for browsing folders with a large number of items. Considering https://github.com/gtsteffaniak/filebrowser/issues/201 resolved, although future pagination support will still come.
- Compressed download options limited to `.zip` and `.tar.gz`
- right-click context menu stays in view.
**Bugfixes**:
- search result links when non-default baseUrl configured
- frontend sort bug squashed https://github.com/gtsteffaniak/filebrowser/issues/230
- bug which caused "noauth" method not to work after v0.3.0 routes update
## 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.
+32 -15
View File
@@ -9,30 +9,42 @@
<img width="800" src="https://github.com/user-attachments/assets/e4a47229-66f8-4838-9575-dd2413596688" title="Main Screenshot">
</p>
> [!Note]
> 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]
> Starting with `v0.3.0` API routes have been slightly altered for friendly usage outside of the UI.
> If on windows, please use docker. The windows binary is unstable and may not work.
> - There is no stable version yet. Always check release notes for bug fixes on functionality that may have been changed. If you notice any unexpected behavior -- please open an issue to have it fixed soon.
> - If on windows, please use docker. The windows binary is unstable and may not work.
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 show 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
- Folder sizes are shown as well
- Changing Sort order is instant
- The entire directory is loaded in 1/3 the time
1. Developer API support
1. [x] Developer API support
- 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 returned.
- themes and branding are not fully supported yet (planned).
- see feature matrix below for more.
- pagination for directory items, so large directories with more than 100,000 items may be slow to load or not load at all.
## About
FileBrowser Quantum provides a file-managing interface within a specified directory
@@ -132,24 +144,29 @@ Not using docker (not recommended), download your binary from releases and run w
## Command Line Usage
There are very few commands available. There are 3 actions done via command line:
There are very few commands available. There are 3 actions done via the command line:
1. Running the program, as shown on the install step. The only argument used is the config file, if you choose to override default "filebrowser.yaml"
1. Running the program, as shown in the install step. The only argument used is the config file if you choose to override the default "filebrowser.yaml"
2. Checking the version info via `./filebrowser version`
3. Updating the DB, which currently only supports adding users via `./filebrowser set -u username,password [-a] [-s "example/scope"]`
## API Usage
FileBrowser Quantum comes with a swagger page that can be accessed from the API section of settings or by going to `/swagger` to see the full list:
FileBrowser Quantum allows for the creation of API tokens which can create users, access file information, and update user settings just like what can be done from the UI. You can create API tokens from the settings page via "API Management" section. This section will only show up if the user has "API" permissions, which can be granted by editing the user in user management.
Regardless of whether a user has API permissions, anyone can visit the swagger page which is found at `/swagger`. This swagger page uses a short-live token (2-hour exp) that the UI uses, but allows for quick access to all the API's and their described usage and requirements:
![image](https://github.com/user-attachments/assets/12abd1f6-21d3-4437-98ed-9b0da6cf2c73)
You use the token as a bearer token. For example in postman:
When using the API outside of swagger, you will need to set the API token as a bearer token authentication type. This means the authorization header will look like `Authorization: Bearer <token>`. For example in Postman:
Successful Request:
<img width="500" alt="image" src="https://github.com/user-attachments/assets/4f18fa8a-8d87-4f40-9dc7-3d4407769b59">
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4f18fa8a-8d87-4f40-9dc7-3d4407769b59"></p>
Failed Request
<img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a">
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a"></p>
## Configuration
@@ -185,12 +202,12 @@ webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
ftp support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 22 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
+1 -6
View File
@@ -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)
}
+69 -15
View File
@@ -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"),
},
+126 -242
View File
@@ -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,69 @@ 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 +148,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 +175,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 +200,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 +213,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 +226,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 +238,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 +257,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 +266,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 +287,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 +333,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 +342,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 +393,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 +411,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 +481,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)
})
}
+96
View File
@@ -3,6 +3,7 @@ package files
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
@@ -71,3 +72,98 @@ func Test_GetRealPath(t *testing.T) {
})
}
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input FileInfo
expected FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "10"},
{Name: "2"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "File2"},
{Name: "File10"},
{Name: "File1"},
{Name: "banana"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
{Name: "2"},
{Name: "10"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10"},
{Name: "File2"},
},
},
},
{
name: "Only Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "dog"},
{Name: "Cat"},
{Name: "apple"},
},
Files: []ItemInfo{
{Name: "Zebra"},
{Name: "apple"},
{Name: "cat"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
{Name: "apple"},
{Name: "Cat"},
{Name: "dog"},
},
Files: []ItemInfo{
{Name: "apple"},
{Name: "cat"},
{Name: "Zebra"},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.input.SortItems()
getNames := func(items []ItemInfo) []string {
names := []string{}
for _, folder := range items {
names = append(names, folder.Name)
}
return names
}
actualFolderNames := getNames(test.input.Folders)
expectedFolderNames := getNames(test.expected.Folders)
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
}
actualFileNames := getNames(test.input.Files)
expectedFileNames := getNames(test.expected.Files)
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
}
})
}
}
-204
View File
@@ -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)
}
+229
View File
@@ -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 si.Root == subPath || 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
}
+117
View File
@@ -0,0 +1,117 @@
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
} else if si.indexingTime > 120 || si.NumDirs > 500000 {
si.assessment = "complex"
modifier := si.indexingTime / 10 // seconds
si.SmartModifier = time.Duration(modifier) * time.Minute
} else {
si.assessment = "normal"
}
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("/")
}
+21 -68
View File
@@ -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,
}
@@ -46,45 +44,6 @@ func (si *Index) createMockData(numDirs, numFilesPerDir int) {
}
}
func generateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
for i := 0; i < levels; i++ {
dirName += "/" + getRandomTerm()
}
return dirName
}
func getRandomTerm() string {
wordbank := []string{
"hi", "test", "other", "name",
"cool", "things", "more", "items",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func getRandomExtension() string {
wordbank := []string{
".txt", ".mp3", ".mov", ".doc",
".mp4", ".bak", ".zip", ".jpg",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func generateRandomSearchTerms(numTerms int) []string {
// Generate random search terms
searchTerms := make([]string, numTerms)
for i := 0; i < numTerms; i++ {
searchTerms[i] = getRandomTerm()
}
return searchTerms
}
// JSONBytesEqual compares the JSON in two byte slices.
func JSONBytesEqual(a, b []byte) (bool, error) {
var j, j2 interface{}
@@ -113,36 +72,30 @@ func TestGetIndex(t *testing.T) {
}
}
func TestInitializeIndex(t *testing.T) {
type args struct {
intervalMinutes uint32
}
func TestMakeIndexPath(t *testing.T) {
tests := []struct {
name string
args args
name string
subPath string
expected string
}{
// TODO: Add test cases.
{"Root path returns slash", "/", "/"},
{"Dot-prefixed returns slash", ".", "/"},
{"Double-dot prefix ignored", "./", "/"},
{"Dot prefix followed by text", "./test", "/test"},
{"Dot prefix followed by text", ".test", "/.test"},
{"Hidden file at root", "/.test", "/.test"},
{"Trailing slash removed", "/test/", "/test"},
{"Subpath without root prefix", "/other/test", "/other/test"},
{"Complex nested paths", "/nested/path", "/nested/path"},
}
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)
si := &Index{Root: "/"}
result := si.makeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("makeIndexPath(%q) = %q; want %q", tt.name, result, tt.expected)
}
})
}
}
+71
View File
@@ -0,0 +1,71 @@
package files
import (
"time"
"math/rand"
)
func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
dir := FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
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
Type: "blob",
}
dir.Folders = append(dir.Folders, newFile)
}
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
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
Type: "blob",
}
dir.Files = append(dir.Files, newFile)
}
return dir
}
func generateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
for i := 0; i < levels; i++ {
dirName += "/" + getRandomTerm()
}
return dirName
}
func getRandomTerm() string {
wordbank := []string{
"hi", "test", "other", "name",
"cool", "things", "more", "items",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func getRandomExtension() string {
wordbank := []string{
".txt", ".mp3", ".mov", ".doc",
".mp4", ".bak", ".zip", ".jpg",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func generateRandomSearchTerms(numTerms int) []string {
// Generate random search terms
searchTerms := make([]string, numTerms)
for i := 0; i < numTerms; i++ {
searchTerms[i] = getRandomTerm()
}
return searchTerms
}
+14 -10
View File
@@ -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
+19 -17
View File
@@ -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,
},
},
},
}
@@ -216,7 +218,7 @@ func TestSearchIndexes(t *testing.T) {
for _, tt := range tests {
t.Run(tt.search, func(t *testing.T) {
result := index.Search(tt.search, tt.scope, "")
assert.Equal(t, tt.expectedResult, result)
assert.ElementsMatch(t, tt.expectedResult, result)
})
}
}
+19 -68
View File
@@ -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()
+31 -51
View File
@@ -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},
},
},
},
+1 -11
View File
@@ -7,11 +7,9 @@ require (
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
github.com/goccy/go-yaml v1.14.3
github.com/goccy/go-yaml v1.15.6
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/google/go-cmp v0.6.0
github.com/marusama/semaphore/v2 v2.5.0
github.com/mholt/archiver/v3 v3.5.1
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/afero v1.11.0
github.com/spf13/cobra v1.8.1
@@ -26,9 +24,7 @@ require (
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/go-errors/errors v1.5.1 // indirect
@@ -41,16 +37,10 @@ require (
github.com/golang/snappy v0.0.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
golang.org/x/net v0.31.0 // indirect
+2 -37
View File
@@ -4,9 +4,6 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -14,9 +11,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
@@ -53,8 +47,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/goccy/go-yaml v1.14.3 h1:8tVD+aqqPLWisSEhM+6wWoiURWXCx6BwaTKS6ZeITgM=
github.com/goccy/go-yaml v1.14.3/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.15.6 h1:gy5kf1yjMia3/c3wWD+u1z3lU5XlhpT8FZGaLJU9cOA=
github.com/goccy/go-yaml v1.15.6/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
@@ -67,10 +61,8 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -79,14 +71,6 @@ github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJS
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -96,16 +80,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/marusama/semaphore/v2 v2.5.0 h1:o/1QJD9DBYOWRnDhPwDVAXQn6mQYD0gZaS1Tpx6DJGM=
github.com/marusama/semaphore/v2 v2.5.0/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -130,16 +104,8 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -205,7 +171,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+30
View File
@@ -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
}
+54 -16
View File
@@ -1,16 +1,19 @@
package http
import (
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/runner"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
)
@@ -26,6 +29,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 +44,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 +94,15 @@ 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) {
if settings.Config.Auth.Method == "noauth" {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(config.Server.Root, "admin")
if err != nil {
return http.StatusInternalServerError, err
}
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
return config.Auth.Key, nil
}
@@ -218,6 +232,7 @@ type ResponseWriterWrapper struct {
http.ResponseWriter
StatusCode int
wroteHeader bool
PayloadSize int
}
// WriteHeader captures the status code and ensures it's only written once
@@ -240,35 +255,37 @@ func (w *ResponseWriterWrapper) Write(b []byte) (int, error) {
return w.ResponseWriter.Write(b)
}
// LoggingMiddleware logs each request and its status code
// 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
// Wrap the ResponseWriter to capture the status code.
wrappedWriter := &ResponseWriterWrapper{ResponseWriter: w, StatusCode: http.StatusOK}
// Call the next handler
// Call the next handler.
next.ServeHTTP(wrappedWriter, r)
// Determine the color based on the status code
// Determine the color based on the status code.
color := "\033[32m" // Default green color
if wrappedWriter.StatusCode >= 300 && wrappedWriter.StatusCode < 500 {
color = "\033[33m" // Yellow for client errors (4xx)
} else if wrappedWriter.StatusCode >= 500 {
color = "\033[31m" // Red for server errors (5xx)
}
// Capture the full URL path including the query parameters
// Capture the full URL path including the query parameters.
fullURL := r.URL.Path
if r.URL.RawQuery != "" {
fullURL += "?" + r.URL.RawQuery
}
// Log the request and its status code
// Log the request, status code, and response size.
log.Printf("%s%-7s | %3d | %-15s | %-12s | \"%s\"%s",
color,
r.Method,
wrappedWriter.StatusCode, // Now capturing the correct status
wrappedWriter.StatusCode, // Captured status code
r.RemoteAddr,
time.Since(start).String(),
fullURL,
@@ -277,15 +294,36 @@ func LoggingMiddleware(next http.Handler) http.Handler {
})
}
func renderJSON(w http.ResponseWriter, _ *http.Request, data interface{}) (int, error) {
func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}) (int, error) {
marsh, err := json.Marshal(data)
if err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err := w.Write(marsh); err != nil {
return http.StatusInternalServerError, err
// Calculate size in KB
payloadSizeKB := len(marsh) / 1024
// Check if the client accepts gzip encoding and hasn't explicitly disabled it
if acceptsGzip(r) && payloadSizeKB > 10 {
// Enable gzip compression
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
if _, err := gz.Write(marsh); err != nil {
return http.StatusInternalServerError, err
}
} else {
// Normal response without compression
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err := w.Write(marsh); err != nil {
return http.StatusInternalServerError, err
}
}
return 0, nil
}
func acceptsGzip(r *http.Request) bool {
ae := r.Header.Get("Accept-Encoding")
return ae != "" && strings.Contains(ae, "gzip")
}
+23 -4
View File
@@ -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 {
+28 -17
View File
@@ -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.Name)
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
}
@@ -154,3 +150,18 @@ func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo,
func previewCacheKey(f *files.FileInfo, previewSize string) string {
return fmt.Sprintf("%x%x%x", f.RealPath(), f.ModTime.Unix(), previewSize)
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
realPath, _, _ := files.GetRealPath(file.Path)
fd, err := os.Open(realPath)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
setContentDisposition(w, r, file.Name)
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil
}
+5 -36
View File
@@ -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 rawFilesHandler(w, r, d, []string{file.Path})
}
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
+153 -155
View File
@@ -1,79 +1,27 @@
package http
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"errors"
"io"
"log"
"net/http"
"net/url"
"os"
gopath "path"
"path/filepath"
"strings"
"github.com/mholt/archiver/v3"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/fileutils"
"github.com/gtsteffaniak/filebrowser/users"
)
func slashClean(name string) string {
if name == "" || name[0] != '/' {
name = "/" + name
}
return gopath.Clean(name)
}
func parseQueryFiles(r *http.Request, f *files.FileInfo, _ *users.User) ([]string, error) {
var fileSlice []string
names := strings.Split(r.URL.Query().Get("files"), ",")
if len(names) == 0 {
fileSlice = append(fileSlice, f.Path)
} else {
for _, name := range names {
name, err := url.QueryUnescape(strings.Replace(name, "+", "%2B", -1)) //nolint:govet
if err != nil {
return nil, err
}
name = slashClean(name)
fileSlice = append(fileSlice, filepath.Join(f.Path, name))
}
}
return fileSlice, nil
}
// nolint: goconst,nolintlint
func parseQueryAlgorithm(r *http.Request) (string, archiver.Writer, error) {
// TODO: use enum
switch r.URL.Query().Get("algo") {
case "zip", "true", "":
return ".zip", archiver.NewZip(), nil
case "tar":
return ".tar", archiver.NewTar(), nil
case "targz":
return ".tar.gz", archiver.NewTarGz(), nil
case "tarbz2":
return ".tar.bz2", archiver.NewTarBz2(), nil
case "tarxz":
return ".tar.xz", archiver.NewTarXz(), nil
case "tarlz4":
return ".tar.lz4", archiver.NewTarLz4(), nil
case "tarsz":
return ".tar.sz", archiver.NewTarSz(), nil
default:
return "", nil, errors.New("format not implemented")
}
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.FileInfo) {
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline")
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(file.Name))
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
}
}
@@ -83,146 +31,196 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.F
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the file or directory"
// @Param files query string false "Comma-separated list of specific files within the directory (optional)"
// @Param files query string true "Comma-separated list of specific files within the directory (required)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip', 'tar', 'targz', 'tarbz2', 'tarxz', 'tarlz4', 'tarsz'. Default is 'zip'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Download permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/raw [get]
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
path := r.URL.Query().Get("path")
file, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
})
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)
}
return rawFileHandler(w, r, file)
files := r.URL.Query().Get("files")
return rawFilesHandler(w, r, d, strings.Split(files, ","))
}
func addFile(ar archiver.Writer, d *requestContext, path, commonPath string) error {
if !d.user.Check(path) {
func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer) error {
realPath, _, _ := files.GetRealPath(d.user.Scope, path)
if !d.user.Check(realPath) {
return nil
}
info, err := os.Stat(path)
info, err := os.Stat(realPath)
if err != nil {
return err
}
if !info.IsDir() && !info.Mode().IsRegular() {
return nil
if info.IsDir() {
// Walk through directory contents
return filepath.Walk(realPath, func(filePath string, fileInfo os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(filepath.Dir(realPath), filePath) // Relative path including base dir
if err != nil {
return err
}
if fileInfo.IsDir() {
if tarWriter != nil {
header := &tar.Header{
Name: relPath + "/",
Mode: 0755,
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
}
return tarWriter.WriteHeader(header)
}
if zipWriter != nil {
_, err := zipWriter.Create(relPath + "/")
return err
}
return nil
}
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
})
}
file, err := os.Open(path)
// Add a single file if it's not a directory
return addSingleFile(realPath, filepath.Base(realPath), zipWriter, tarWriter)
}
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
file, err := os.Open(realPath)
if err != nil {
return err
}
defer file.Close()
if path != commonPath {
filename := strings.TrimPrefix(path, commonPath)
filename = strings.TrimPrefix(filename, string(filepath.Separator))
err = ar.Write(archiver.File{
FileInfo: archiver.FileInfo{
FileInfo: info,
CustomName: filename,
},
ReadCloser: file,
})
if err != nil {
return err
}
info, err := os.Stat(realPath)
if err != nil {
return err
}
if info.IsDir() {
names, err := file.Readdirnames(0)
if tarWriter != nil {
header, err := tar.FileInfoHeader(info, realPath)
if err != nil {
return err
}
header.Name = archivePath
if err = tarWriter.WriteHeader(header); err != nil {
return err
}
_, err = io.Copy(tarWriter, file)
return err
}
for _, name := range names {
fPath := filepath.Join(path, name)
err = addFile(ar, d, fPath, commonPath)
if err != nil {
log.Printf("Failed to archive %s: %v", fPath, err)
}
if zipWriter != nil {
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = archivePath
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(writer, file)
return err
}
return nil
}
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, fileList []string) (int, error) {
filePath := fileList[0]
fileName := filepath.Base(filePath)
realPath, isDir, err := files.GetRealPath(d.user.Scope, filePath)
if err != nil {
return http.StatusInternalServerError, err
}
if len(fileList) == 1 && !isDir {
fd, err2 := os.Open(realPath)
if err2 != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
// Get file information
fileInfo, err3 := fd.Stat()
if err3 != nil {
return http.StatusInternalServerError, err
}
// Set headers and serve the file
setContentDisposition(w, r, fileName)
w.Header().Set("Cache-Control", "private")
// Serve the content
http.ServeContent(w, r, fileName, fileInfo.ModTime(), fd)
return 0, nil
}
algo := r.URL.Query().Get("algo")
var extension string
switch algo {
case "zip", "true", "":
extension = ".zip"
case "tar.gz":
extension = ".tar.gz"
default:
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(realPath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
downloadFileName := url.PathEscape(baseDirName + "." + algo)
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+downloadFileName)
// Create the archive and stream it directly to the response
if extension == ".zip" {
err = createZip(w, d, fileList...)
} else {
err = createTarGz(w, d, fileList...)
}
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
func createZip(w io.Writer, d *requestContext, filenames ...string) error {
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter)
if err != nil {
log.Printf("Failed to add %s to ZIP: %v", fname, err)
}
}
return nil
}
func rawDirHandler(w http.ResponseWriter, r *http.Request, d *requestContext, file *files.FileInfo) (int, error) {
filenames, err := parseQueryFiles(r, file, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
extension, ar, err := parseQueryAlgorithm(r)
if err != nil {
return http.StatusInternalServerError, err
}
func createTarGz(w io.Writer, d *requestContext, filenames ...string) error {
gzWriter := gzip.NewWriter(w)
defer gzWriter.Close()
err = ar.Create(w)
if err != nil {
return http.StatusInternalServerError, err
}
defer ar.Close()
commonDir := fileutils.CommonPrefix(filepath.Separator, filenames...)
name := filepath.Base(commonDir)
if name == "." || name == "" || name == string(filepath.Separator) {
name = file.Name
}
// Prefix used to distinguish a filelist generated
// archive from the full directory archive
if len(filenames) > 1 {
name = "_" + name
}
name += extension
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(name))
tarWriter := tar.NewWriter(gzWriter)
defer tarWriter.Close()
for _, fname := range filenames {
err = addFile(ar, d, fname, commonDir)
err := addFile(fname, d, tarWriter, nil)
if err != nil {
log.Printf("Failed to archive %s: %v", fname, err)
log.Printf("Failed to add %s to TAR.GZ: %v", fname, err)
}
}
return 0, nil
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
realPath, _, _ := files.GetRealPath(file.Path)
fd, err := os.Open(realPath)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
setContentDisposition(w, r, file)
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil
return nil
}
+50 -27
View File
@@ -8,12 +8,14 @@ import (
"os"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/shirou/gopsutil/v3/disk"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/utils"
)
// resourceGetHandler retrieves information about a resource.
@@ -31,9 +33,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 +47,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 +94,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 +135,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 +148,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 +159,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 +308,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 +321,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 +352,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) {
@@ -373,3 +384,15 @@ func inspectIndex(w http.ResponseWriter, r *http.Request) {
info, _ := index.GetReducedMetadata(path, isDir)
renderJSON(w, r, info) // nolint:errcheck
}
func mockData(w http.ResponseWriter, r *http.Request) {
d := r.URL.Query().Get("numDirs")
f := r.URL.Query().Get("numFiles")
NumDirs, err := strconv.Atoi(d)
numFiles, err2 := strconv.Atoi(f)
if err != nil || err2 != nil {
return
}
mockDir := files.CreateMockData(NumDirs, numFiles)
renderJSON(w, r, mockDir) // nolint:errcheck
}
+3 -2
View File
@@ -96,6 +96,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
api.HandleFunc("GET /preview", withUser(previewHandler))
if version.Version == "testing" || version.Version == "untracked" {
api.HandleFunc("GET /inspectIndex", inspectIndex)
api.HandleFunc("GET /mockData", mockData)
}
// Share routes
@@ -122,7 +123,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 +173,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
+2 -2
View File
@@ -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
+11 -2
View File
@@ -2,6 +2,7 @@ package http
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
@@ -118,8 +119,16 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
givenUserId := uint(num)
if givenUserId == d.user.ID || !d.user.Perm.Admin {
return http.StatusForbidden, nil
if givenUserId == d.user.ID {
return http.StatusForbidden, fmt.Errorf("cannot delete your own user")
}
if !d.user.Perm.Admin {
return http.StatusForbidden, fmt.Errorf("cannot delete users without admin permissions")
}
if givenUserId == 1 {
return http.StatusForbidden, fmt.Errorf("cannot delete the default admin user")
}
// Delete the user
+24 -5
View File
@@ -11,7 +11,6 @@ import (
"github.com/disintegration/imaging"
"github.com/dsoprea/go-exif/v3"
"github.com/marusama/semaphore/v2"
exifcommon "github.com/dsoprea/go-exif/v3/common"
)
@@ -21,12 +20,32 @@ var ErrUnsupportedFormat = errors.New("unsupported image format")
// Service
type Service struct {
sem semaphore.Semaphore
sem chan struct{}
}
// New initializes the service with a specified number of workers (concurrency limit).
func New(workers int) *Service {
return &Service{
sem: semaphore.New(workers),
sem: make(chan struct{}, workers), // Buffered channel to limit concurrency.
}
}
// acquire blocks until a worker is available or the context is canceled.
func (s *Service) acquire(ctx context.Context) error {
select {
case s.sem <- struct{}{}: // Reserve a worker.
return nil
case <-ctx.Done(): // Context canceled or deadline exceeded.
return ctx.Err()
}
}
// release frees up a worker slot.
func (s *Service) release() {
select {
case <-s.sem: // Free a worker slot.
default:
// Shouldn't happen, but guard against releasing more than acquired.
}
}
@@ -136,10 +155,10 @@ func WithQuality(quality Quality) Option {
}
func (s *Service) Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...Option) error {
if err := s.sem.Acquire(ctx, 1); err != nil {
if err := s.acquire(ctx); err != nil {
return err
}
defer s.sem.Release(1)
defer s.release()
format, wrappedReader, err := s.detectFormat(in)
if err != nil {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

-1
View File
@@ -66,7 +66,6 @@ func setDefaults() Settings {
EnableThumbnails: true,
ResizePreview: false,
EnableExec: false,
IndexingInterval: 5,
Port: 80,
NumImageProcessors: 4,
BaseURL: "",
+12 -45
View File
@@ -114,17 +114,11 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "Path to the file or directory",
"name": "path",
"description": "Comma-separated list of specific files within the directory (required)",
"name": "files",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Comma-separated list of specific files within the directory (optional)",
"name": "files",
"in": "query"
},
{
"type": "boolean",
"description": "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'.",
@@ -133,7 +127,7 @@ const docTemplate = `{
},
{
"type": "string",
"description": "Compression algorithm for archiving multiple files or directories. Options: 'zip', 'tar', 'targz', 'tarbz2', 'tarxz', 'tarlz4', 'tarsz'. Default is 'zip'.",
"description": "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'.",
"name": "algo",
"in": "query"
}
@@ -172,15 +166,6 @@ const docTemplate = `{
}
}
},
"415": {
"description": "Unsupported file type for preview",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -1155,22 +1140,16 @@ const docTemplate = `{
"files.FileInfo": {
"type": "object",
"properties": {
"checksums": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"content": {
"type": "string"
},
"isSymlink": {
"type": "boolean"
},
"items": {
"files": {
"type": "array",
"items": {
"$ref": "#/definitions/files.ReducedItem"
"$ref": "#/definitions/files.ItemInfo"
}
},
"folders": {
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"modified": {
@@ -1185,26 +1164,14 @@ const docTemplate = `{
"size": {
"type": "integer"
},
"subtitles": {
"type": "array",
"items": {
"type": "string"
}
},
"token": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"files.ReducedItem": {
"files.ItemInfo": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"modified": {
"type": "string"
},
+12 -45
View File
@@ -103,17 +103,11 @@
"parameters": [
{
"type": "string",
"description": "Path to the file or directory",
"name": "path",
"description": "Comma-separated list of specific files within the directory (required)",
"name": "files",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Comma-separated list of specific files within the directory (optional)",
"name": "files",
"in": "query"
},
{
"type": "boolean",
"description": "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'.",
@@ -122,7 +116,7 @@
},
{
"type": "string",
"description": "Compression algorithm for archiving multiple files or directories. Options: 'zip', 'tar', 'targz', 'tarbz2', 'tarxz', 'tarlz4', 'tarsz'. Default is 'zip'.",
"description": "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'.",
"name": "algo",
"in": "query"
}
@@ -161,15 +155,6 @@
}
}
},
"415": {
"description": "Unsupported file type for preview",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -1144,22 +1129,16 @@
"files.FileInfo": {
"type": "object",
"properties": {
"checksums": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"content": {
"type": "string"
},
"isSymlink": {
"type": "boolean"
},
"items": {
"files": {
"type": "array",
"items": {
"$ref": "#/definitions/files.ReducedItem"
"$ref": "#/definitions/files.ItemInfo"
}
},
"folders": {
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"modified": {
@@ -1174,26 +1153,14 @@
"size": {
"type": "integer"
},
"subtitles": {
"type": "array",
"items": {
"type": "string"
}
},
"token": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"files.ReducedItem": {
"files.ItemInfo": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"modified": {
"type": "string"
},
+10 -33
View File
@@ -1,17 +1,13 @@
definitions:
files.FileInfo:
properties:
checksums:
additionalProperties:
type: string
type: object
content:
type: string
isSymlink:
type: boolean
items:
files:
items:
$ref: '#/definitions/files.ReducedItem'
$ref: '#/definitions/files.ItemInfo'
type: array
folders:
items:
$ref: '#/definitions/files.ItemInfo'
type: array
modified:
type: string
@@ -21,19 +17,11 @@ definitions:
type: string
size:
type: integer
subtitles:
items:
type: string
type: array
token:
type: string
type:
type: string
type: object
files.ReducedItem:
files.ItemInfo:
properties:
content:
type: string
modified:
type: string
name:
@@ -350,14 +338,10 @@ paths:
description: Returns the raw content of a file, multiple files, or a directory.
Supports downloading files as archives in various formats.
parameters:
- description: Path to the file or directory
in: query
name: path
required: true
type: string
- description: Comma-separated list of specific files within the directory (optional)
- description: Comma-separated list of specific files within the directory (required)
in: query
name: files
required: true
type: string
- description: If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults
to 'attachment'.
@@ -365,8 +349,7 @@ paths:
name: inline
type: boolean
- description: 'Compression algorithm for archiving multiple files or directories.
Options: ''zip'', ''tar'', ''targz'', ''tarbz2'', ''tarxz'', ''tarlz4'',
''tarsz''. Default is ''zip''.'
Options: ''zip'' and ''tar.gz''. Default is ''zip''.'
in: query
name: algo
type: string
@@ -395,12 +378,6 @@ paths:
additionalProperties:
type: string
type: object
"415":
description: Unsupported file type for preview
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
View File
Whitespace-only changes.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+80
View File
@@ -0,0 +1,80 @@
package utils
import (
"sync"
"time"
)
var (
DiskUsageCache = newCache(30*time.Second, 24*time.Hour)
RealPathCache = newCache(48*time.Hour, 72*time.Hour)
SearchResultsCache = newCache(15*time.Second, time.Hour)
)
func newCache(expires time.Duration, cleanup time.Duration) *KeyCache {
newCache := KeyCache{
data: make(map[string]cachedValue),
expiresAfter: expires, // default
}
go newCache.cleanupExpiredJob(cleanup)
return &newCache
}
type KeyCache struct {
data map[string]cachedValue
mu sync.RWMutex
expiresAfter time.Duration
}
type cachedValue struct {
value interface{}
expiresAt time.Time
}
func (c *KeyCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(c.expiresAfter),
}
}
func (c *KeyCache) SetWithExp(key string, value interface{}, exp time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(exp),
}
}
func (c *KeyCache) Get(key string) interface{} {
c.mu.RLock()
defer c.mu.RUnlock()
cached, ok := c.data[key]
if !ok || time.Now().After(cached.expiresAt) {
return nil
}
return cached.value
}
func (c *KeyCache) cleanupExpired() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, cached := range c.data {
if now.After(cached.expiresAt) {
delete(c.data, key)
}
}
}
// should automatically run for all cache types as part of init.
func (c *KeyCache) cleanupExpiredJob(frequency time.Duration) {
ticker := time.NewTicker(frequency)
defer ticker.Stop()
for range ticker.C {
c.cleanupExpired()
}
}
+15
View File
@@ -69,3 +69,18 @@ func PrintStructFields(v interface{}) {
fmt.Printf("Field: %s, %s\n", fieldType.Name, fieldValue)
}
}
func GetParentDirectoryPath(path string) string {
if path == "/" || path == "" {
return ""
}
path = strings.TrimSuffix(path, "/") // Remove trailing slash if any
lastSlash := strings.LastIndex(path, "/")
if lastSlash == -1 {
return "" // No parent directory for a relative path without slashes
}
if lastSlash == 0 {
return "/" // If the last slash is the first character, return root
}
return path[:lastSlash]
}
+59
View File
@@ -0,0 +1,59 @@
package utils
import (
"testing"
)
func TestGetParentDirectoryPath(t *testing.T) {
tests := []struct {
input string
expectedOutput string
}{
{input: "/", expectedOutput: ""}, // Root directory
{input: "/subfolder", expectedOutput: "/"}, // Single subfolder
{input: "/sub/sub/", expectedOutput: "/sub"}, // Nested subfolder with trailing slash
{input: "/subfolder/", expectedOutput: "/"}, // Relative path with trailing slash
{input: "", expectedOutput: ""}, // Empty string treated as root
{input: "/sub/subfolder", expectedOutput: "/sub"}, // Double slash in path
{input: "/sub/subfolder/deep/nested/", expectedOutput: "/sub/subfolder/deep"}, // Double slash in path
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
actualOutput := GetParentDirectoryPath(test.input)
if actualOutput != test.expectedOutput {
t.Errorf("\n\tinput %q\n\texpected %q\n\tgot %q",
test.input, test.expectedOutput, actualOutput)
}
})
}
}
func TestCapitalizeFirst(t *testing.T) {
tests := []struct {
input string
expectedOutput string
}{
{input: "", expectedOutput: ""}, // Empty string
{input: "a", expectedOutput: "A"}, // Single lowercase letter
{input: "A", expectedOutput: "A"}, // Single uppercase letter
{input: "hello", expectedOutput: "Hello"}, // All lowercase
{input: "Hello", expectedOutput: "Hello"}, // Already capitalized
{input: "123hello", expectedOutput: "123hello"}, // Non-alphabetic first character
{input: "hELLO", expectedOutput: "HELLO"}, // Mixed case
{input: " hello", expectedOutput: " hello"}, // Leading space, no capitalization
{input: "hello world", expectedOutput: "Hello world"}, // Phrase with spaces
{input: " hello world", expectedOutput: " hello world"}, // Phrase with leading space
{input: "123 hello world", expectedOutput: "123 hello world"}, // Numbers before text
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
actualOutput := CapitalizeFirst(test.input)
if actualOutput != test.expectedOutput {
t.Errorf("\n\tinput %q\n\texpected %q\n\tgot %q",
test.input, test.expectedOutput, actualOutput)
}
})
}
}
+1 -3
View File
@@ -10,7 +10,6 @@ Here is an expanded config file which includes all possible configurations:
server:
CreateUserDir: false
UserHomeBasePath: ""
indexingInterval: 5
indexing: true
numImageProcessors: 4
socket: ""
@@ -71,7 +70,6 @@ Here are the defaults if nothing is set:
server:
enableThumbnails: true
enableExec: false
indexingInterval: 5
port: 80
numImageProcessors: 4
baseURL: ""
@@ -109,7 +107,7 @@ userDefaults:
### Server configuration settings
- `indexingInterval`: This is the time in minutes the system waits before checking for filesystem changes. Default: `5`
- `indexingInterval`: This optional paramter disables smart indexing and specifies a time in minutes the system waits before checking for filesystem changes. See [indexing readme](indexing.md) for more information.
- `indexing`: This enables or disables indexing. (Note: search will not work without indexing) Default: `true`
+11
View File
@@ -1,2 +1,13 @@
# Contributing Guide
If you would like to contribute, please open a pull request against main or the latest `dev_` branch thats currently in progress.
A PR is required to have:
1. A clear description about why it was opened
2. A short title that best describes the issue.
3. Test evidence for anything that is not self evident or covered by unit tests.
Unit tests should be updated to pass before merging. So, the best way to handle this is to create a fork and test your changes there, then merge to this repo. You can also create a draft pull request if it is not fully ready.
Please don't hesitate to open an issue for any ideas you have, but cannot contribute directly for whatever reason.
+189
View File
@@ -0,0 +1,189 @@
# About Indexing on FileBrowser Quantum
The most significant feature is the index, this document intends to demystify how it works so you can understand how to ensure your index closely matches the current state of your filesystem.
## How does the index work?
The approach used by this repo includes filesystem watchers that periodically scan the directory tree for changes. By default, this uses a smart scan strategy, but you can also configure a set interval in your config file.
The `scan interval` is the break time between scans and does not include the time a scan takes. A typical scan can vary dramatically, but here are some expectations for SSD-based disks:
| # folders | # files | time to index | memory usage (RAM) |
|---|---|---|---|
| 10,000 | 10,000 | ~ 0-5 seconds | 15 MB |
| 2,000 | 250,000 | ~ 0-5 seconds | 300 MB |
| 50,000 | 50,000 | ~ 5-30 seconds | 150 MB |
| 250,000 | 10,000 | ~ 2-5 minutes | 300 MB |
| 500,000 | 500,000 | ~ 5+ minutes | 500+ MB |
### Smart Scanning
1. There is a floating `smart scan interval` that ranges from **1 minute - 4 hours** depending on the complexity of your filesystem
2. The smart interval changes based on how often it discovers changed files:
- ```
// 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
}
```
3. The `smart scan interval` performs a `quick scan` 4 times in a row, followed by a 5th `full scan` which completely rebuilds the index.
- A `quick scan` is limited to detecting directory changes, but is 10x faster than a full scan. Here is what a quick scan can see:
1. New files or folders created.
2. Files or folders deleted.
3. Renaming of files or folders.
- A quick scan **cannot** detect when a file has been updated, for example when you save a file and the size increases.
- A `full scan` is a complete re-indexing. This is always more disk and computationally intense but will capture individual file changes.
4. The `smart scan interval` changes based on several things. A `simple` complexity enables scans every 1 minute if changes happen frequently and a maximum full scan interval of every 100 minutes. `high` complexity indicates a minimum scanning interval of 10 minutes.
- **under 10,000 folders** or **Under 3 seconds** to index is awlays considered `simple` complexity.
- **more than 500,000 folders** or **Over 2 minutes** to index is always considered `high` complexity.
### Manual Scanning Interval
If you don't like the behavior of smart scanning, you can configure set intervals instead by setting `indexingInterval` to a number greater than 0. This will make FileBrowser Quantum always scan at the given interval in minutes.
The scan behavior is still 4 quick scans at a given interval, followed by a 5th full scan.
### System requirements
You can expect FileBrowser Quantum to use 100 MB of RAM for a typical installation. If you have many files and folders then the requirement could climb to multiple Gigabytes. Please monitor your system on the first run to know your specific requirements.
### Why does FileBrowser Quantum index the way it does?
The smart indexing method uses filesystem scanners because it allows a low-footprint design that can cater to individual filesystem complexity. There are a few options for monitoring a filesystem for changes:
1. **Option 1**: Recursive Traversal with ReadDir
- This is quite computationally intensive but creates an accurate record of the filesystem
- Requires periodic scanning to remain updated
- Low overhead and straightforward implementation.
2. **Option 2**: Use File System Monitoring (Real-Time or Periodic Check) such as `fsnotify`
- This allows for event-based reactions to filesystem changes.
- Requires extra overhead.
- Relies on OS level features and behavior differs between OS's
- Requires OS-level configuration to ulimits in order to properly watch a large filesystem.
3. **Option 3**: Directory Metadata Heuristics.
- Using ModTime to determine when directory structures change.
- Has minimal insight into actual file changes.
- Much faster to scan for changes than Recursive transversal.
Ultimately, FileBrowser Quantum uses a combination of 1 and 3 to perform index updates. Using something like fsnotify is a non-starter for large filesystems, where it would require manual host OS tuning to work at all. Besides, I can essentially offer the same behavior by creating "watchers" for top-level folders (a feature to come in the future). However, right now there is a single root-level watcher that works over the entire index.
The main disadvantage of the approach is the delay caused by the scanning interval.
### How to manually refresh the index?
There is currently no way to manually trigger a new full indexing. This will come in a future release when the "jobs" functionality is added back.
However, if you want to force-refresh a certain directory, this happens every time you **view it** in the UI or via the resources API.
This also means the resources API is always up to date with the current status of the filesystem. When you "look" at a specific folder, you are causing the index to be refreshed at that location.
### What information does the index have?
You can see what the index looks like by using the resources API via the GET method, which returns individual directory information -- all of this information is stored in the index.
Here is an example:
```
{
"name": "filebrowser",
"size": 274467914,
"modified": "2024-11-23T19:18:57.68013727-06:00",
"type": "directory",
"files": [
{
"name": ".dockerignore",
"size": 73,
"modified": "2024-11-20T18:14:44.91135413-06:00",
"type": "blob"
},
{
"name": ".DS_Store",
"size": 6148,
"modified": "2024-11-22T14:45:15.901211088-06:00",
"type": "blob"
},
{
"name": ".gitignore",
"size": 455,
"modified": "2024-11-23T19:18:57.616132373-06:00",
"type": "blob"
},
{
"name": "CHANGELOG.md",
"size": 9325,
"modified": "2024-11-23T19:18:57.616646332-06:00",
"type": "text"
},
{
"name": "Dockerfile",
"size": 769,
"modified": "2024-11-23T19:18:57.616941333-06:00",
"type": "blob"
},
{
"name": "Dockerfile.playwright",
"size": 542,
"modified": "2024-11-23T19:18:57.617151875-06:00",
"type": "blob"
},
{
"name": "makefile",
"size": 1311,
"modified": "2024-11-23T19:18:57.68017352-06:00",
"type": "blob"
},
{
"name": "README.md",
"size": 10625,
"modified": "2024-11-23T19:18:57.617464334-06:00",
"type": "text"
}
],
"folders": [
{
"name": ".git",
"size": 60075460,
"modified": "2024-11-24T14:44:42.52180215-06:00",
"type": "directory"
},
{
"name": ".github",
"size": 11584,
"modified": "2024-11-20T18:14:44.911805335-06:00",
"type": "directory"
},
{
"name": "backend",
"size": 29247172,
"modified": "2024-11-23T19:18:57.667109624-06:00",
"type": "directory"
},
{
"name": "docs",
"size": 14272,
"modified": "2024-11-24T13:46:12.082024018-06:00",
"type": "directory"
},
{
"name": "frontend",
"size": 185090178,
"modified": "2024-11-24T14:44:39.880678934-06:00",
"type": "directory"
}
],
"path": "/filebrowser"
}
```
### Can I disable the index and still use FileBrowser Quantum?
You can disable the index by setting `indexing: false` in your config file. You will still be able to browse your files, but the search will not work and you may run into issues as it's not intended to be used without indexing.
I'm not sure why you would run it like this, if you have a good reason please open an issue request on how you would like it to work -- and why you would run it without the index.
+5 -5
View File
@@ -1,18 +1,18 @@
# Planned Roadmap
upcoming 0.3.x releases:
upcoming 0.3.x releases, ordered by priority:
- More filetype icons and refreshed icons.
- more filetype previews - eg. office, photoshop, vector, 3d files.
- Enable mobile search with same features as desktop
- Enable mobile search with same features as desktop
- Theme configuration from settings
- File synchronization improvements
- more filetype previews
- introduce jobs as replacement to runners.
- Add Job status to the sidebar
- index status.
- Job status from users
- upload status
- opentelemetry metrics
- simple search/filter for current listings.
- Enable mobile search with same features as desktop
Unplanned Future releases:
- multiple sources https://github.com/filebrowser/filebrowser/issues/2514
+7 -4
View File
@@ -13,10 +13,11 @@
"build-docker": "vite build",
"watch": "vite build --watch",
"typecheck": "vue-tsc -p ./tsconfig.json --noEmit",
"lint": "npm run typecheck && eslint src/",
"lint": "eslint --ext .js,.vue,ts src",
"lint:fix": "eslint --fix src/",
"format": "prettier --write .",
"test": "npx playwright test"
"integration-test": "npx playwright test",
"test": "vitest run "
},
"dependencies": {
"ace-builds": "^1.24.2",
@@ -32,15 +33,17 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@playwright/test": "^1.42.1",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.42.1",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"eslint": "^8.57.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.24.0",
"jsdom": "^25.0.1",
"vite": "^5.2.7",
"vite-plugin-compression2": "^1.0.0",
"vitest": "^2.1.5",
"vue-tsc": "^2.0.7"
}
}
+27 -30
View File
@@ -1,17 +1,17 @@
import { createURL, fetchURL, adjustedData} from "./utils";
import { baseURL } from "@/utils/constants";
import { removePrefix,getApiPath } from "@/utils/url.js";
import { fetchURL, adjustedData } from "./utils";
import { removePrefix, getApiPath } from "@/utils/url.js";
import { state } from "@/store";
import { notify } from "@/notify";
// Notify if errors occur
export async function fetchFiles(url, content = false) {
try {
url = removePrefix(url,"files");
const apiPath = getApiPath("api/resources",{path: url, content: content});
let path = removePrefix(url, "files");
const apiPath = getApiPath("api/resources",{path: path, content: content});
const res = await fetchURL(apiPath);
const data = await res.json();
return adjustedData(data,url);
const adjusted = adjustedData(data, url);
return adjusted;
} catch (err) {
notify.showError(err.message || "Error fetching data");
throw err;
@@ -51,27 +51,22 @@ export async function put(url, content = "") {
}
}
export function download(format, ...files) {
export function download(format, files) {
if (format != "zip") {
format = "tar.gz"
}
try {
let url = `${baseURL}/api/raw`;
let fileargs = "";
if (files.length === 1) {
url += "?path="+removePrefix(files[0], "files");
fileargs = removePrefix(files[0], "files")
} else {
let arg = "";
for (let file of files) {
arg += removePrefix(file,"files") + ",";
fileargs += removePrefix(file,"files") + ",";
}
arg = arg.substring(0, arg.length - 1);
arg = encodeURIComponent(arg);
url += `?files=${arg}`;
fileargs = fileargs.substring(0, fileargs.length - 1);
}
if (format) {
url += `&algo=${format}`;
}
const apiPath = getApiPath("api/raw", { files: fileargs, algo: format });
const url = window.origin+apiPath
window.open(url);
} catch (err) {
notify.showError(err.message || "Error downloading files");
@@ -162,10 +157,11 @@ export async function checksum(url, algo) {
export function getDownloadURL(path, inline) {
try {
const params = {
path: path,
files: removePrefix(path,"files"),
...(inline && { inline: "true" }),
};
return createURL("api/raw", params);
const apiPath = getApiPath("api/raw", params);
return window.origin+apiPath
} catch (err) {
notify.showError(err.message || "Error getting download URL");
throw err;
@@ -180,8 +176,8 @@ export function getPreviewURL(path, size, modified) {
key: Date.parse(modified),
inline: "true",
};
return createURL("api/preview", params);
const apiPath = getApiPath("api/preview", params);
return window.origin+apiPath
} catch (err) {
notify.showError(err.message || "Error getting preview URL");
throw err;
@@ -190,13 +186,14 @@ export function getPreviewURL(path, size, modified) {
export function getSubtitlesURL(file) {
try {
const params = {
inline: "true",
};
const subtitles = [];
for (const sub of file.subtitles) {
subtitles.push(createURL("api/raw" + sub, params));
const params = {
inline: "true",
path: sub
};
const apiPath = getApiPath("api/raw", params);
return window.origin+apiPath
}
return subtitles;
+26 -37
View File
@@ -1,53 +1,47 @@
import { createURL, adjustedData } from "./utils";
import { getApiPath } from "@/utils/url.js";
import { adjustedData } from "./utils";
import { getApiPath, removePrefix } from "@/utils/url.js";
import { notify } from "@/notify";
// Fetch public share data
export async function fetchPub(path, hash, password = "") {
try {
const params = { path, hash }
const apiPath = getApiPath("api/public/share", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
},
});
const params = { path, hash }
const apiPath = getApiPath("api/public/share", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
},
});
if (!response.ok) {
const error = new Error("Failed to connect to the server.");
error.status = response.status;
throw error;
}
let data = await response.json()
return adjustedData(data, `${hash}${path}`);
} catch (err) {
notify.showError(err.message || "Error fetching public share data");
throw err;
if (!response.ok) {
const error = new Error("Failed to connect to the server.");
error.status = response.status;
throw error;
}
let data = await response.json()
const adjusted = adjustedData(data, getApiPath(`share/${hash}${path}`));
return adjusted
}
// Download files with given parameters
export function download(path, hash, token, format, ...files) {
export function download(share, ...files) {
try {
let fileInfo = files[0]
if (files.length > 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);
window.open(url);
const apiPath = getApiPath("api/public/dl", params);
window.open(window.origin+apiPath)
} catch (err) {
notify.showError(err.message || "Error downloading files");
throw err;
}
}
// Get the public user data
@@ -64,11 +58,6 @@ 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);
return window.origin+apiPath
}
+11 -9
View File
@@ -1,15 +1,17 @@
import { fetchURL, fetchJSON, createURL, adjustedData } from "./utils";
import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
export async function list() {
return fetchJSON("api/shares");
const apiPath = getApiPath("api/shares");
return fetchJSON(apiPath);
}
export async function get(path, hash) {
try {
const params = { path, hash };
const url = createURL(`api/share`, params, false);
let data = fetchJSON(url);
const apiPath = getApiPath("api/share",params);
let data = fetchJSON(apiPath);
return adjustedData(data, `api/share${path}`);
} catch (err) {
notify.showError(err.message || "Error fetching data");
@@ -19,25 +21,25 @@ export async function get(path, hash) {
export async function remove(hash) {
const params = { hash };
const url = createURL(`api/share`, params, false);
await fetchURL(url, {
const apiPath = getApiPath("api/share",params);
await fetchURL(apiPath, {
method: "DELETE",
});
}
export async function create(path, password = "", expires = "", unit = "hours") {
const params = { path };
const url = createURL(`api/share`, params, false);
const apiPath = getApiPath("api/share",params);
let body = "{}";
if (password != "" || expires !== "" || unit !== "hours") {
body = JSON.stringify({ password: password, expires: expires, unit: unit });
}
return fetchJSON(url, {
return fetchJSON(apiPath, {
method: "POST",
body: body,
});
}
export function getShareURL(share) {
return createURL("share/" + share.hash, {}, false);
return window.origin+getApiPath(`share/${share.hash}`);
}
+2 -1
View File
@@ -60,7 +60,8 @@ export function deleteApiKey(params) {
export async function create(user) {
try {
const res = await fetchURL(`api/users`, {
const apiPath = getApiPath("api/users");
const res = await fetchURL(apiPath, {
method: "POST",
body: JSON.stringify({
what: "user",
+21 -27
View File
@@ -1,6 +1,5 @@
import { state } from "@/store";
import { renew, logout } from "@/utils/auth";
import { baseURL } from "@/utils/constants";
import { notify } from "@/notify";
export async function fetchURL(url, opts, auth = true) {
@@ -24,9 +23,11 @@ export async function fetchURL(url, opts, auth = true) {
...rest,
});
} catch (e) {
console.error(e)
const error = new Error("000 No connection");
error.status = res.status;
let message = e;
if (e == "TypeError: Failed to fetch") {
message = "Failed to connect to the server, is it still running?";
}
const error = new Error(message);
throw error;
}
@@ -35,7 +36,7 @@ export async function fetchURL(url, opts, auth = true) {
}
if (res.status < 200 || res.status > 299) {
const error = new Error(await res.text());
let error = new Error(await res.text());
error.status = res.status;
if (auth && res.status == 401) {
@@ -58,36 +59,29 @@ export async function fetchJSON(url, opts) {
}
}
export function createURL(endpoint, params = {}) {
let prefix = baseURL;
if (!prefix.endsWith("/")) {
prefix = prefix + "/";
}
const url = new URL(prefix + endpoint, origin);
const searchParams = {
...params,
};
for (const key in searchParams) {
url.searchParams.set(key, searchParams[key]);
}
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
}
if (data.files) {
data.files = []
}
if (data.folders) {
data.folders = []
}
return data;
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from 'vitest';
import { adjustedData } 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);
});
});
vi.mock('@/utils/constants', () => {
return {
baseURL: "unit-testing",
};
});
+3 -1
View File
@@ -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();
+1 -1
View File
@@ -80,6 +80,7 @@ export default {
.button-group {
margin: 1em;
display: flex;
flex-wrap: wrap;
border: 1px solid #ccc;
border-radius: 1em;
overflow: hidden;
@@ -93,7 +94,6 @@ button {
border: none;
background: #f5f5f5;
transition: background-color 0.3s;
/* Add borders */
border-right: 1px solid #ccc;
}
+19 -4
View File
@@ -122,14 +122,12 @@ export default {
// Ensure the context menu stays within the viewport
return Math.min(
this.posY,
window.innerHeight - (this.$refs.contextMenu?.clientHeight ?? 0)
);
},
left() {
return Math.min(
this.posX,
window.innerWidth - (this.$refs.contextMenu?.clientWidth ?? 0)
);
},
@@ -160,9 +158,26 @@ export default {
return mutations.showHover(value);
},
setPositions() {
console.log("Setting positions");
const contextProps = getters.currentPrompt().props;
this.posX = contextProps.posX;
this.posY = contextProps.posY;
let tempX = contextProps.posX;
let tempY = contextProps.posY;
// Assuming the screen width and height (adjust values based on your context)
const screenWidth = window.innerWidth; // or any fixed width depending on your app's layout
const screenHeight = window.innerHeight; // or any fixed height depending on your app's layout
// if x is too close to the right edge, move it to the left by 400px
if (tempX > screenWidth - 200) {
tempX -= 200;
}
// if y is too close to the bottom edge, move it up by 400px
if (tempY > screenHeight - 400) {
tempY -= 400;
}
this.posX = tempX;
this.posY = tempY;
},
toggleMultipleSelection() {
mutations.setMultiple(!state.multiple);
+57 -141
View File
@@ -3,150 +3,47 @@
<!-- Search input section -->
<div id="input" @click="open">
<!-- Close button visible when search is active -->
<button
v-if="active"
class="action"
@click="close"
:aria-label="$t('buttons.close')"
:title="$t('buttons.close')"
>
<button v-if="active" class="action" @click="close" :aria-label="$t('buttons.close')"
:title="$t('buttons.close')">
<i class="material-icons">close</i>
</button>
<!-- Search icon when search is not active -->
<i v-else class="material-icons">search</i>
<!-- Input field for search -->
<input
id="main-input"
class="main-input"
type="text"
@keyup.exact="keyup"
@input="submit"
ref="input"
:autofocus="active"
v-model.trim="value"
:aria-label="$t('search.search')"
:placeholder="$t('search.search')"
/>
</div>
<!-- Search results for mobile -->
<div v-if="isMobile && active" id="result" :class="{ hidden: !active }" ref="result">
<div id="result-list">
<div class="button" style="width: 100%">Search Context: {{ getContext }}</div>
<!-- List of search results -->
<ul v-show="results.length > 0">
<li v-for="(s, k) in results" :key="k" class="search-entry">
<router-link :to="s.path">
<i v-if="s.type == 'directory'" class="material-icons folder-icons">
folder
</i>
<i v-else-if="s.type == 'audio'" class="material-icons audio-icons">
volume_up
</i>
<i v-else-if="s.type == 'image'" class="material-icons image-icons">
photo
</i>
<i v-else-if="s.type == 'video'" class="material-icons video-icons">
movie
</i>
<i v-else-if="s.type == 'archive'" class="material-icons archive-icons">
archive
</i>
<i v-else class="material-icons file-icons"> insert_drive_file </i>
<span class="text-container">
{{ basePath(s.path, s.type == "directory") }}<b>{{ baseName(s.path) }}</b>
</span>
<div class="filesize">{{ humanSize(s.size) }}</div>
</router-link>
</li>
</ul>
<!-- Loading icon when search is ongoing -->
<p v-show="isEmpty && isRunning" id="renew">
<i class="material-icons spin">autorenew</i>
</p>
<!-- Message when no results are found -->
<div v-show="isEmpty && !isRunning">
<div class="searchPrompt" v-show="isEmpty && !isRunning">
<p>{{ noneMessage }}</p>
</div>
</div>
<div v-if="isEmpty">
<!-- Reset filters button -->
<button
class="mobile-boxes"
v-if="value.length === 0 && !showBoxes"
@click="resetSearchFilters()"
>
Reset filters
</button>
<!-- Box types when no search input is present -->
<div v-if="value.length === 0 && showBoxes">
<div class="boxes">
<h3>{{ $t("search.types") }}</h3>
<div>
<div
class="mobile-boxes"
tabindex="0"
v-for="(v, k) in boxes"
:key="k"
role="button"
@click="addToTypes('type:' + k)"
:aria-label="v.label"
>
<i class="material-icons">{{ v.icon }}</i>
<p>{{ v.label }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<input id="main-input" class="main-input" type="text" @keyup.exact="keyup" @input="submit" ref="input"
:autofocus="active" v-model.trim="value" :aria-label="$t('search.search')" :placeholder="$t('search.search')" />
</div>
<!-- Search results for desktop -->
<div v-show="!isMobile && active" id="result-desktop" ref="result">
<div v-show="active" id="results" ref="result">
<div class="searchContext">Search Context: {{ getContext }}</div>
<div id="result-list">
<div>
<div v-if="!isMobile && active">
<!-- Button groups for filtering search results -->
<ButtonGroup
:buttons="folderSelect"
@button-clicked="addToTypes"
@remove-button-clicked="removeFromTypes"
@disableAll="folderSelectClicked()"
@enableAll="resetButtonGroups()"
/>
<ButtonGroup
:buttons="typeSelect"
@button-clicked="addToTypes"
@remove-button-clicked="removeFromTypes"
:isDisabled="isTypeSelectDisabled"
/>
<!-- Inputs for filtering by file size -->
<div class="sizeConstraints">
<div class="sizeInputWrapper">
<p>Smaller Than:</p>
<input
class="sizeInput"
v-model="smallerThan"
type="number"
min="0"
placeholder="number"
/>
<p>MB</p>
</div>
<div class="sizeInputWrapper">
<p>Larger Than:</p>
<input
class="sizeInput"
v-model="largerThan"
type="number"
placeholder="number"
/>
<p>MB</p>
<div v-if="active">
<div v-if="isMobile">
<ButtonGroup :buttons="toggleOptionButton" @button-clicked="enableOptions" @remove-button-clicked="disableOptions" />
</div>
<div v-show="showOptions">
<!-- Button groups for filtering search results -->
<ButtonGroup :buttons="folderSelect" @button-clicked="addToTypes" @remove-button-clicked="removeFromTypes"
@disableAll="folderSelectClicked()" @enableAll="resetButtonGroups()" />
<ButtonGroup :buttons="typeSelect" @button-clicked="addToTypes" @remove-button-clicked="removeFromTypes"
:isDisabled="isTypeSelectDisabled" />
<!-- Inputs for filtering by file size -->
<div class="sizeConstraints">
<div class="sizeInputWrapper">
<p>Smaller Than:</p>
<input class="sizeInput" v-model="smallerThan" type="number" min="0" placeholder="number" />
<p>MB</p>
</div>
<div class="sizeInputWrapper">
<p>Larger Than:</p>
<input class="sizeInput" v-model="largerThan" type="number" placeholder="number" />
<p>MB</p>
</div>
</div>
</div>
</div>
</div>
<!-- Loading icon when search is ongoing -->
@@ -209,6 +106,7 @@
</div>
</div>
</template>
<script>
import ButtonGroup from "./ButtonGroup.vue";
import { search } from "@/api";
@@ -249,11 +147,15 @@ export default {
{ label: "Documents", value: "type:doc" },
{ label: "Archives", value: "type:archive" },
],
toggleOptionButton: [
{ label: "Show Options" },
],
value: "",
ongoing: false,
results: [],
reload: false,
scrollable: null,
hiddenOptions: true,
};
},
watch: {
@@ -289,6 +191,9 @@ export default {
},
},
computed: {
showOptions() {
return !this.hiddenOptions || !this.isMobile
},
isMobile() {
return state.isMobile;
},
@@ -314,7 +219,6 @@ export default {
if (this.ongoing) {
return "";
}
return this.value === ""
? this.$t("search.typeToSearch")
: this.$t("search.pressToSearch");
@@ -337,6 +241,18 @@ export default {
},
},
methods: {
enableOptions() {
this.hiddenOptions = false
this.toggleOptionButton = [
{ label: "Hide Options" },
];
},
disableOptions() {
this.hiddenOptions = true
this.toggleOptionButton = [
{ label: "Show Options" },
];
},
humanSize(size) {
return getHumanReadableFilesize(size);
},
@@ -368,7 +284,6 @@ export default {
},
close(event) {
this.value = "";
event.stopPropagation();
mutations.closeHovers();
},
@@ -390,6 +305,7 @@ export default {
},
resetSearchFilters() {
this.searchTypes = "";
this.hiddenOptions = true;
},
removeFromTypes(string) {
if (string == null || string == "") {
@@ -456,7 +372,7 @@ export default {
word-wrap: break-word;
}
#result-desktop > #result-list {
#results>#result-list {
max-height: 80vh;
width: 35em;
overflow: scroll;
@@ -466,7 +382,7 @@ export default {
background-color: unset;
}
#result-desktop {
#results {
-webkit-animation: SlideDown 0.5s forwards;
animation: SlideDown 0.5s forwards;
border-radius: 1m;
@@ -491,7 +407,7 @@ export default {
flex-direction: column;
}
#search.active #result-desktop ul li a {
#search.active #results ul li a {
display: flex;
align-items: center;
padding: 0.3em 0;
@@ -499,8 +415,8 @@ export default {
}
#search #result-list.active {
width: 65em !important;
max-width: 85vw !important;
width: 1000px;
max-width: 100vw;
}
/* Animations */
@@ -602,7 +518,7 @@ body.rtl #search #result {
direction: ltr;
}
#search #result > div > *:first-child {
#search #result>div>*:first-child {
margin-top: 0;
}
@@ -612,7 +528,7 @@ body.rtl #search #result {
}
/* Search Results */
body.rtl #search #result ul > * {
body.rtl #search #result ul>* {
direction: ltr;
text-align: left;
}
@@ -724,8 +640,8 @@ body.rtl #search .boxes h3 {
.sizeConstraints {
display: flex;
flex-wrap: wrap;
flex-direction: row;
flex-wrap: nowrap;
align-content: center;
margin: 1em;
justify-content: center;
@@ -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 });
},
},
};
+2 -2
View File
@@ -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");
@@ -28,12 +28,7 @@ export default {
return {
formats: {
zip: "zip",
tar: "tar",
targz: "tar.gz",
tarbz2: "tar.bz2",
tarxz: "tar.xz",
tarlz4: "tar.lz4",
tarsz: "tar.sz",
},
};
},
+45 -43
View File
@@ -8,50 +8,52 @@
<template v-if="listing">
<div class="card-content">
<table>
<tr>
<th>#</th>
<th>{{ $t("settings.shareDuration") }}</th>
<th></th>
<th></th>
</tr>
<tbody>
<tr>
<th>#</th>
<th>{{ $t("settings.shareDuration") }}</th>
<th></th>
<th></th>
</tr>
<tr v-for="link in links" :key="link.hash">
<td>{{ link.hash }}</td>
<td>
<template v-if="link.expire !== 0">{{ humanTime(link.expire) }}</template>
<template v-else>{{ $t("permanent") }}</template>
</td>
<td class="small">
<button
class="action copy-clipboard"
:data-clipboard-text="buildLink(link)"
:aria-label="$t('buttons.copyToClipboard')"
:title="$t('buttons.copyToClipboard')"
>
<i class="material-icons">content_paste</i>
</button>
</td>
<td class="small" v-if="hasDownloadLink()">
<button
class="action copy-clipboard"
:data-clipboard-text="buildDownloadLink(link)"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')"
>
<i class="material-icons">content_paste_go</i>
</button>
</td>
<td class="small">
<button
class="action"
@click="deleteLink($event, link)"
:aria-label="$t('buttons.delete')"
:title="$t('buttons.delete')"
>
<i class="material-icons">delete</i>
</button>
</td>
</tr>
<tr v-for="link in links" :key="link.hash">
<td>{{ link.hash }}</td>
<td>
<template v-if="link.expire !== 0">{{ humanTime(link.expire) }}</template>
<template v-else>{{ $t("permanent") }}</template>
</td>
<td class="small">
<button
class="action copy-clipboard"
:data-clipboard-text="buildLink(link)"
:aria-label="$t('buttons.copyToClipboard')"
:title="$t('buttons.copyToClipboard')"
>
<i class="material-icons">content_paste</i>
</button>
</td>
<td class="small" v-if="hasDownloadLink()">
<button
class="action copy-clipboard"
:data-clipboard-text="buildDownloadLink(link)"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')"
>
<i class="material-icons">content_paste_go</i>
</button>
</td>
<td class="small">
<button
class="action"
@click="deleteLink($event, link)"
:aria-label="$t('buttons.delete')"
:title="$t('buttons.delete')"
>
<i class="material-icons">delete</i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
+4 -5
View File
@@ -203,17 +203,16 @@ input.sizeInput:disabled {
.sizeInputWrapper {
border-radius: 1em;
margin-left: 0.5em;
margin-right: 0.5em;
margin: .5em;
display: -ms-flexbox;
display: flex;
background-color: rgb(245, 245, 245);
padding: 0.25em;
height: 3em;
background-color: #f5f5f5;
padding: .25em;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
border: 1px solid #ccc;
justify-content: center;
}
.sizeInput {
+2 -2
View File
@@ -264,11 +264,11 @@
}
/* Result desktop */
.dark-mode #result-desktop #result-list {
.dark-mode #results #result-list {
max-height: unset;
}
/* Result desktop background */
.dark-mode #result-desktop {
.dark-mode #results {
background-color: var(--background);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export function showPopup(type, message) {
if (apiMessage &&
Object.prototype.hasOwnProperty.call(apiMessage, "status") &&
Object.prototype.hasOwnProperty.call(apiMessage, "message")) {
popupContent.textContent = "Errors " + apiMessage.status + ": " + apiMessage.message;
popupContent.textContent = "Error " + apiMessage.status + ": " + apiMessage.message;
}
} catch (error) {
popupContent.textContent = message;
+34 -38
View File
@@ -7,9 +7,8 @@ import Settings from "@/views/Settings.vue";
import Errors from "@/views/Errors.vue";
import { baseURL, name } from "@/utils/constants";
import { getters, state } from "@/store";
import { recaptcha, loginPage } from "@/utils/constants";
import { validateLogin } from "@/utils/auth";
import { mutations } from "@/store";
import { validateLogin } from "@/utils/auth";
import i18n from "@/i18n";
const titles = {
@@ -106,8 +105,12 @@ const routes = [
},
{
path: "/:catchAll(.*)*",
redirect: (to: RouteLocation) =>
`/files/${[...to.params.catchAll].join("/")}`,
redirect: (to: RouteLocation) => {
const path = Array.isArray(to.params.catchAll)
? to.params.catchAll.join("/")
: to.params.catchAll || "";
return `/files/${path}`;
},
},
];
@@ -116,45 +119,31 @@ const router = createRouter({
routes,
});
async function initAuth() {
if (loginPage && !getters.isShare()) {
await validateLogin();
}
if (recaptcha) {
await new Promise<void>((resolve) => {
const check = () => {
if (typeof window.grecaptcha === "undefined") {
setTimeout(check, 100);
} else {
resolve();
}
};
check();
});
}
// Helper function to check if a route resolves to itself
function isSameRoute(to: RouteLocation, from: RouteLocation) {
return to.path === from.path && JSON.stringify(to.params) === JSON.stringify(from.params);
}
router.beforeResolve(async (to, from, next) => {
mutations.closeHovers()
if (isSameRoute(to, from)) {
console.warn("Avoiding recursive navigation to the same route.");
return next(false);
}
if (state != null && state.user != null && !('username' in state.user)) {
await validateLogin();
}
// Set the page title using i18n
const title = i18n.global.t(titles[to.name as keyof typeof titles]);
document.title = title + " - " + name;
mutations.setRoute(to)
if (to.path.endsWith("/login") && getters.isLoggedIn()) {
next({ path: "/files/" });
return;
}
// this will only be null on first route
if (to.name != "Login") {
try {
await initAuth();
} catch (error) {
console.error(error);
}
}
// Update store with the current route
mutations.setRoute(to);
// Handle auth requirements
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!getters.isLoggedIn()) {
console.log("not logged in");
next({
path: "/login",
query: { redirect: to.fullPath },
@@ -162,15 +151,22 @@ router.beforeResolve(async (to, from, next) => {
return;
}
// Handle admin-only routes
if (to.matched.some((record) => record.meta.requiresAdmin)) {
if (state.user === null || !getters.isAdmin()) {
if (!state.user || !getters.isAdmin()) {
next({ path: "/403" });
return;
}
}
}
// Redirect logged-in users from login page
if (to.path.endsWith("/login") && getters.isLoggedIn()) {
next({ path: "/files/" });
return;
}
next();
});
export { router, router as default };
export { router, router as default };
+28 -7
View File
@@ -1,5 +1,7 @@
import { removePrefix } from "@/utils/url.js";
import { state } from "./state.js";
import { mutations } from "./mutations.js";
import { noAuth } from "@/utils/constants.js";
export const getters = {
isCardView: () => (state.user.viewMode == "gallery" || state.user.viewMode == "normal" ) && getters.currentView() == "listingView" ,
@@ -15,14 +17,33 @@ export const getters = {
return state.user.darkMode === true;
},
isLoggedIn: () => {
return state.user !== null && state.user?.username != undefined && state.user?.username != "publicUser";
if (noAuth) {
return true
}
if (state.user !== null && state.user?.username != undefined && state.user?.username != "publicUser") {
return true;
}
const userData = localStorage.getItem("userData");
if (userData == undefined) {
return false;
}
try {
const userInfo = JSON.parse(userData);
if (userInfo.username != "publicUser") {
mutations.setCurrentUser(userInfo);
return true;
}
} catch (error) {
return false;
}
return false
},
isAdmin: () => state.user.perm?.admin == true,
isFiles: () => state.route.name === "Files",
isListing: () => getters.isFiles() && state.req.type === "directory",
selectedCount: () => Array.isArray(state.selected) ? state.selected.length : 0,
getFirstSelected: () => state.req.items[state.selected[0]],
isSingleFileSelected: () => getters.selectedCount() === 1 && !state.req.items[state.selected[0]]?.type == "directory",
isSingleFileSelected: () => getters.selectedCount() === 1 && getters.getFirstSelected()?.type != "directory",
selectedDownloadUrl() {
let selectedItem = state.selected[0]
return state.req.items[selectedItem].url;
@@ -87,6 +108,9 @@ export const getters = {
if (typeof getters.currentPromptName() === "string" && !getters.isStickySidebar()) {
visible = false;
}
if (getters.currentView() == "editor" || getters.currentView() == "preview") {
visible = false;
}
return visible
},
isStickySidebar: () => {
@@ -103,10 +127,7 @@ export const getters = {
return sticky
},
showOverlay: () => {
if (!getters.isLoggedIn()) {
return false
}
const hasPrompt = getters.currentPrompt() !== null && getters.currentPromptName() !== "more";
const hasPrompt = getters.currentPrompt() !== null && getters.currentPromptName() !== "more"
const shouldOverlaySidebar = getters.isSidebarVisible() && !getters.isStickySidebar()
return hasPrompt || shouldOverlaySidebar;
},
@@ -114,7 +135,7 @@ export const getters = {
return removePrefix(state.route.path,trimModifier)
},
currentView: () => {
const pathname = state.route.path.toLowerCase()
const pathname = getters.routePath()
if (pathname.startsWith(`/settings`)) {
return "settings"
} else if (pathname.startsWith(`/share`)) {
+17 -11
View File
@@ -4,12 +4,13 @@ import router from "@/router";
import { emitStateChanged } from './eventBus'; // Import the function from eventBus.js
import { usersApi } from "@/api";
import { notify } from "@/notify";
import { sortedItems } from "@/utils/sort.js";
export const mutations = {
setGallerySize: (value) => {
state.user.gallerySize = value
emitStateChanged();
usersApi.update(state.user,['gallerySize']);
usersApi.update(state.user, ['gallerySize']);
},
setActiveSettingsView: (value) => {
state.activeSettingsView = value;
@@ -102,12 +103,17 @@ export const mutations = {
emitStateChanged();
},
setCurrentUser: (value) => {
localStorage.setItem("userData", undefined);
// If value is null or undefined, emit state change and exit early
if (!value) {
state.user = value;
emitStateChanged();
return;
}
if (value.username != "publicUser") {
localStorage.setItem("userData", JSON.stringify(value));
}
// Ensure locale exists and is valid
if (!value.locale) {
value.locale = i18n.detectLocale(); // Default to detected locale if missing
@@ -153,6 +159,7 @@ export const mutations = {
emitStateChanged();
},
updateCurrentUser: (value) => {
localStorage.setItem("userData", undefined);
// Ensure the input is a valid object
if (typeof value !== "object" || value === null) return;
@@ -180,9 +187,12 @@ export const mutations = {
}
// Update users if there's any change in state.user
if (JSON.stringify(state.user) !== JSON.stringify(previousUser)) {
usersApi.update(state.user,Object.keys(value));
usersApi.update(state.user, Object.keys(value));
}
if (state.user.username != "publicUser") {
localStorage.setItem("userData", JSON.stringify(state.user));
}
// Emit state change event
emitStateChanged();
},
@@ -210,15 +220,7 @@ export const mutations = {
emitStateChanged();
},
updateListingItems: () => {
state.req.items.sort((a, b) => {
const valueA = a[state.user.sorting.by];
const valueB = b[state.user.sorting.by];
if (state.user.sorting.asc) {
return valueA > valueB ? 1 : -1;
} else {
return valueA < valueB ? 1 : -1;
}
});
state.req.items = sortedItems(state.req.items,state.user.sorting.by)
emitStateChanged();
},
updateClipboard: (value) => {
@@ -232,5 +234,9 @@ export const mutations = {
state.clipboard.items = [];
emitStateChanged();
},
setSharePassword: (value) => {
state.sharePassword = value;
emitStateChanged();
}
};
+1
View File
@@ -43,6 +43,7 @@ export const state = reactive({
items: [],
},
jwt: "",
sharePassword: "",
loading: [],
reload: false,
selected: [],
+21 -1
View File
@@ -2,6 +2,7 @@ import { mutations, getters } from "@/store";
import router from "@/router";
import { usersApi } from "@/api";
import { getApiPath } from "@/utils/url.js";
import { recaptcha, loginPage } from "@/utils/constants";
export async function setNewToken(token) {
@@ -99,4 +100,23 @@ export function logout() {
// .split('; ')
// .find(row => row.startsWith(name + '='))
// ?.split('=')[1];
//}
//}
export async function initAuth() {
if (loginPage && !getters.isShare()) {
console.log("validating login");
await validateLogin();
}
if (recaptcha) {
await new Promise((resolve) => {
const check = () => {
if (typeof window.grecaptcha === "undefined") {
setTimeout(check, 100);
} else {
resolve();
}
};
check();
});
}
}
-1
View File
@@ -24,7 +24,6 @@ const settings = [
{ id: 'api', label: 'API Keys', component: 'ApiKeys', perm: { api: true } },
{ id: 'global', label: 'Global', component: 'GlobalSettings', perm: { admin: true } },
{ id: 'users', label: 'User Management', component: 'UserManagement', perm: { admin: true } },
]
export {
+23 -23
View File
@@ -3,28 +3,28 @@ import { filesApi } from "@/api";
import { notify } from "@/notify"
export default function download() {
if (getters.isSingleFileSelected()) {
filesApi.download(null, getters.selectedDownloadUrl());
return;
}
mutations.showHover({
name: "download",
confirm: (format) => {
mutations.closeHovers();
let files = [];
if (state.selected.length > 0) {
for (let i of state.selected) {
files.push(state.req.items[i].url);
}
} else {
files.push(state.route.path);
if (getters.isSingleFileSelected()) {
filesApi.download(null, [getters.selectedDownloadUrl()]);
return;
}
mutations.showHover({
name: "download",
confirm: (format) => {
mutations.closeHovers();
let files = [];
if (state.selected.length > 0) {
for (let i of state.selected) {
files.push(state.req.items[i].url);
}
try {
filesApi.download(format, ...files);
notify.showSuccess("download started");
} catch (e) {
notify.showError("error downloading", e);
}
},
});
} else {
files.push(state.route.path);
}
try {
filesApi.download(format, files);
notify.showSuccess("download started");
} catch (e) {
notify.showError("error downloading", e);
}
},
});
}
+33
View File
@@ -0,0 +1,33 @@
import { state } from "@/store";
export function sortedItems(items = [], sortby="name") {
return items.sort((a, b) => {
const valueA = a[sortby];
const valueB = b[sortby];
if (sortby === "name") {
// Handle sorting for "name" field
const isNumericA = !isNaN(valueA);
const isNumericB = !isNaN(valueB);
if (isNumericA && isNumericB) {
// Compare numeric strings as numbers
return state.user.sorting.asc
? parseFloat(valueA) - parseFloat(valueB)
: parseFloat(valueB) - parseFloat(valueA);
}
// Compare non-numeric values as strings
return state.user.sorting.asc
? valueA.localeCompare(valueB)
: valueB.localeCompare(valueA);
}
// Default sorting for other fields
if (state.user.sorting.asc) {
return valueA > valueB ? 1 : -1;
} else {
return valueA < valueB ? 1 : -1;
}
});
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { sortedItems } from './sort.js';
describe('testSort', () => {
it('sort items by name correctly', () => {
const input = [
{ name: "zebra" },
{ name: "1" },
{ name: "10" },
{ name: "Apple" },
{ name: "2" },
]
const expected = [
{ name: "1" },
{ name: "2" },
{ name: "10" },
{ name: "Apple" },
{ name: "zebra" }
]
expect(sortedItems(input, "name")).toEqual(expected);
});
it('sort items by size correctly', () => {
const input = [
{ size: "10" },
{ size: "0" },
{ size: "5000" },
]
const expected = [
{ size: "0" },
{ size: "10" },
{ size: "5000" }
]
expect(sortedItems(input, "size")).toEqual(expected);
});
it('sort items by date correctly', () => {
const now = new Date();
const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000);
const tenMinutesFromNow = new Date(now.getTime() + 10 * 60 * 1000);
const input = [
{ date: now },
{ date: tenMinutesAgo },
{ date: tenMinutesFromNow },
]
const expected = [
{ date: tenMinutesAgo },
{ date: now },
{ date: tenMinutesFromNow }
]
expect(sortedItems(input, "date")).toEqual(expected);
});
});
+3
View File
@@ -47,6 +47,9 @@ export default {
};
export function removePrefix(path, prefix) {
if (path === undefined) {
return ""
}
if (prefix != "") {
prefix = "/" + trimSlashes(prefix)
}
+6 -3
View File
@@ -61,9 +61,7 @@ export default {
$route: "fetchData",
reload(value) {
if (value) {
console.log("reloading fetch data");
this.fetchData();
console.log("reloading fetch data done", state.req);
}
},
},
@@ -94,7 +92,12 @@ export default {
let res = await filesApi.fetchFiles(getters.routePath());
// If not a directory, fetch content
if (res.type != "directory") {
res = await filesApi.fetchFiles(getters.routePath(), true);
let content = false;
// only check content for blob or text files
if (res.type == "blob" || res.type == "text") {
content = true;
}
res = await filesApi.fetchFiles(getters.routePath(), content);
}
data = res;
// Verify if the fetched path matches the current route
+16 -1
View File
@@ -1,6 +1,11 @@
<template v-if="isLoggedIn">
<div>
<div v-show="showOverlay" @click="resetPrompts" class="overlay"></div>
<div
v-show="showOverlay"
@contextmenu.prevent="onOverlayRightClick"
@click="resetPrompts"
class="overlay"
></div>
<div v-if="progress" class="progress">
<div v-bind:style="{ width: this.progress + '%' }"></div>
</div>
@@ -118,6 +123,16 @@ export default {
},
},
methods: {
onOverlayRightClick(event) {
// Example: Show a custom context menu
mutations.showHover({
name: "ContextMenu", // Assuming ContextMenu is a component you've already imported
props: {
posX: event.clientX,
posY: event.clientY,
},
});
},
updateIsMobile() {
mutations.setMobile();
},
+2 -1
View File
@@ -44,7 +44,7 @@
<script>
import router from "@/router";
import { state } from "@/store";
import { signupLogin, login } from "@/utils/auth";
import { signupLogin, login, initAuth } from "@/utils/auth";
import {
name,
logoURL,
@@ -114,6 +114,7 @@ export default {
await signupLogin(this.username, this.password);
}
await login(this.username, this.password, captcha);
await initAuth();
router.push({ path: redirect });
} catch (e) {
console.error(e);
+7 -2
View File
@@ -5,7 +5,7 @@
v-for="setting in settings"
:key="setting.id + '-main'"
:id="setting.id + '-main'"
@click="setView(setting.id + '-main')"
@click="handleClick($event, setting.id + '-main')"
>
<!-- Dynamically render the component based on the setting -->
<component v-if="shouldShow(setting)" :is="setting.component"></component>
@@ -77,13 +77,18 @@ export default {
methods: {
shouldShow(setting) {
const perm = setting?.perm || {};
// Check if all keys in setting.perm exist in state.user.perm and have truthy values
return Object.keys(perm).every((key) => state.user.perm[key]);
},
setView(view) {
if (state.activeSettingsView === view) return;
mutations.setActiveSettingsView(view);
},
handleClick(event, view) {
// Allow propagation if the click is on a link or a child element with default behavior
const target = event.target.closest("a, router-link");
if (target) return; // Let the browser/router handle the navigation
this.setView(view); // Call the setView method for other clicks
},
},
};
</script>
+28 -16
View File
@@ -166,26 +166,15 @@ export default {
hash: null,
subPath: "",
clip: null,
token: "",
};
},
watch: {
$route() {
let urlPath = getters.routePath();
// Step 1: Split the path by '/'
let parts = urlPath.split("/");
// Step 2: Assign hash to the second part (index 2) and join the rest for subPath
this.hash = parts[1];
this.subPath = "/" + parts.slice(2).join("/");
this.fetchData();
},
},
created() {
let urlPath = getters.routePath();
// Step 1: Split the path by '/'
let parts = urlPath.split("/");
// Step 2: Assign hash to the second part (index 2) and join the rest for subPath
this.hash = parts[1];
this.subPath = "/" + parts.slice(2).join("/");
this.fetchData();
},
mounted() {
@@ -251,6 +240,7 @@ export default {
return publicApi.getDownloadURL({
path: this.subPath,
hash: this.hash,
token: this.token,
inline: inline,
});
},
@@ -258,9 +248,20 @@ export default {
return window.btoa(unescape(encodeURIComponent(name)));
},
async fetchData() {
let urlPath = getters.routePath("share");
// Step 1: Split the path by '/'
let parts = urlPath.split("/");
// Step 2: Assign hash to the second part (index 2) and join the rest for subPath
this.hash = parts[1];
this.subPath = "/" + parts.slice(2).join("/");
// Set loading to true and reset the error.
mutations.setLoading("share", true);
this.error = null;
if (this.password == "" || this.password == null) {
this.password = localStorage.getItem("sharepass:" + this.hash);
} else {
localStorage.setItem("sharepass:" + this.hash, this.password);
}
// Reset view information.
if (!getters.isLoggedIn()) {
let userData = await publicApi.getPublicUser();
@@ -273,11 +274,11 @@ export default {
try {
let file = await publicApi.fetchPub(this.subPath, this.hash, this.password);
file.hash = this.hash;
this.token = file.token;
mutations.updateRequest(file);
document.title = `${file.name} - ${document.title}`;
} catch (error) {
this.error = error;
notify.showError(error);
}
mutations.setLoading("share", false);
@@ -296,7 +297,13 @@ export default {
},
download() {
if (getters.isSingleFileSelected()) {
public_api.download(this.subPath, this.hash, null, getters.selectedDownloadUrl());
const share = {
path: his.subPath,
hash: this.hash,
token: this.token,
format: null,
};
publicApi.download(share, getters.selectedDownloadUrl());
return;
}
mutations.showHover({
@@ -309,8 +316,13 @@ export default {
for (let i of this.selected) {
files.push(state.req.items[i].path);
}
public_api.download(this.subPath, this.hash, format, ...files);
const share = {
path: this.subPath,
hash: this.hash,
token: this.token,
format: format,
};
publicApi.download(share, ...files);
},
});
},
+4 -1
View File
@@ -1,6 +1,6 @@
<template>
<header>
<action icon="close" :label="$t('buttons.close')" @action="close()" />
<action v-if="notShare" icon="close" :label="$t('buttons.close')" @action="close()" />
<title v-if="isSettings" class="topTitle">Settings</title>
<title v-else class="topTitle">{{ req.name }}</title>
</header>
@@ -37,6 +37,9 @@ export default {
},
computed: {
notShare() {
return getters.currentView() != "share";
},
isSettings() {
return getters.isSettings();
},
+1 -1
View File
@@ -80,7 +80,7 @@ export default {
},
methods: {
handleEditorValueRequest() {
filesApi.put(state.route.path, this.editor.getValue());
filesApi.put(getters.routePath("files"), this.editor.getValue());
},
back() {
let uri = url.removeLastDir(state.route.path) + "/";
+1 -1
View File
@@ -287,7 +287,7 @@ export default {
window.addEventListener("scroll", this.scrollEvent);
window.addEventListener("resize", this.windowsResize);
this.$el.addEventListener("click", this.clickClear);
this.$el.addEventListener("contextmenu", this.openContext);
window.addEventListener("contextmenu", this.openContext);
if (!state.user.perm?.create) return;
this.$el.addEventListener("dragover", this.preventDefault);
-1
View File
@@ -40,7 +40,6 @@
type="range"
id="gallary-size"
name="gallary-size"
:value="gallerySize"
min="0"
max="10"
/>
+85
View File
@@ -0,0 +1,85 @@
import { vi } from 'vitest';
vi.mock('@/store', () => {
return {
state: {
activeSettingsView: "",
isMobile: false,
showSidebar: false,
usage: {
used: "0 B",
total: "0 B",
usedPercentage: 0,
},
editor: null,
user: {
gallarySize: 0,
stickySidebar: false,
locale: "en",
viewMode: "normal",
hideDotfiles: false,
perm: {},
rules: [],
permissions: {},
darkMode: false,
profile: {
username: '',
email: '',
avatarUrl: '',
},
sorting: {
by: 'name',
asc: true,
},
},
req: {
sorting: {
by: 'name',
asc: true,
},
items: [],
numDirs: 0,
numFiles: 0,
},
previewRaw: "",
oldReq: {},
clipboard: {
key: "",
items: [],
},
jwt: "",
loading: [],
reload: false,
selected: [],
multiple: false,
upload: {
uploads: {},
queue: [],
progress: [],
sizes: [],
},
prompts: [],
show: null,
showConfirm: null,
route: {},
settings: {
signup: false,
createUserDir: false,
userHomeBasePath: "",
rules: [],
frontend: {
disableExternal: false,
disableUsedPercentage: false,
name: "",
files: "",
},
},
},
};
});
vi.mock('@/utils/constants', () => {
return {
baseURL: "http://example.com",
};
});
+35 -42
View File
@@ -23,51 +23,44 @@ const resolve = {
// https://vitejs.dev/config/
export default defineConfig(({ command }) => {
if (command === "serve") {
return {
plugins,
resolve,
server: {
proxy: {
"/api/command": {
target: "ws://127.0.0.1:8080",
ws: true,
},
"/api": "http://127.0.0.1:8080",
// command === 'build'
return {
plugins,
resolve,
base: "",
build: {
rollupOptions: {
input: {
index: path.resolve(__dirname, "./public/index.html"),
},
},
};
} else {
// command === 'build'
return {
plugins,
resolve,
base: "",
build: {
rollupOptions: {
input: {
index: path.resolve(__dirname, "./public/index.html"),
},
output: {
manualChunks: (id) => {
if (id.includes("i18n/")) {
return "i18n";
}
},
output: {
manualChunks: (id) => {
if (id.includes("i18n/")) {
return "i18n";
}
},
},
},
experimental: {
renderBuiltUrl(filename, { hostType }) {
if (hostType === "js") {
return { runtime: `window.__prependStaticUrl("${filename}")` };
} else if (hostType === "html") {
return `{{ .StaticURL }}/${filename}`;
} else {
return { relative: true };
}
},
},
experimental: {
renderBuiltUrl(filename, { hostType }) {
if (hostType === "js") {
return { runtime: `window.__prependStaticUrl("${filename}")` };
} else if (hostType === "html") {
return `{{ .StaticURL }}/${filename}`;
} else {
return { relative: true };
}
},
};
}
},
test: {
globals: true,
include: ["src/**/*.test.js"], // Explicitly include test files only
exclude: ["src/**/*.vue"], // Exclude Vue files unless tested directly
environment: "jsdom", // jsdom environment
setupFiles: "tests/mocks/setup.js", // Setup file for tests
},
};
});
@@ -0,0 +1,74 @@
// vite.config.ts
import path from "node:path";
import { defineConfig } from "file:///Users/steffag/git/personal/filebrowser/frontend/node_modules/vite/dist/node/index.js";
import vue from "file:///Users/steffag/git/personal/filebrowser/frontend/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import VueI18nPlugin from "file:///Users/steffag/git/personal/filebrowser/frontend/node_modules/@intlify/unplugin-vue-i18n/lib/vite.mjs";
import { compression } from "file:///Users/steffag/git/personal/filebrowser/frontend/node_modules/vite-plugin-compression2/dist/index.mjs";
var __vite_injected_original_dirname = "/Users/steffag/git/personal/filebrowser/frontend";
var plugins = [
vue(),
VueI18nPlugin({
include: [path.resolve(__vite_injected_original_dirname, "./src/i18n/**/*.json")]
}),
compression({
include: /\.(js|woff2|woff)(\?.*)?$/i,
deleteOriginalAssets: true
})
];
var resolve = {
alias: {
"@": path.resolve(__vite_injected_original_dirname, "src")
}
};
var vite_config_default = defineConfig(({ command }) => {
if (command === "serve") {
return {
plugins,
resolve,
server: {
proxy: {
"/api/command": {
target: "ws://127.0.0.1:8080",
ws: true
},
"/api": "http://127.0.0.1:8080"
}
}
};
} else {
return {
plugins,
resolve,
base: "",
build: {
rollupOptions: {
input: {
index: path.resolve(__vite_injected_original_dirname, "./public/index.html")
},
output: {
manualChunks: (id) => {
if (id.includes("i18n/")) {
return "i18n";
}
}
}
}
},
experimental: {
renderBuiltUrl(filename, { hostType }) {
if (hostType === "js") {
return { runtime: `window.__prependStaticUrl("${filename}")` };
} else if (hostType === "html") {
return `{{ .StaticURL }}/${filename}`;
} else {
return { relative: true };
}
}
}
};
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvc3RlZmZhZy9naXQvcGVyc29uYWwvZmlsZWJyb3dzZXIvZnJvbnRlbmRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9zdGVmZmFnL2dpdC9wZXJzb25hbC9maWxlYnJvd3Nlci9mcm9udGVuZC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMvc3RlZmZhZy9naXQvcGVyc29uYWwvZmlsZWJyb3dzZXIvZnJvbnRlbmQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiO1xuaW1wb3J0IHZ1ZSBmcm9tIFwiQHZpdGVqcy9wbHVnaW4tdnVlXCI7XG5pbXBvcnQgVnVlSTE4blBsdWdpbiBmcm9tIFwiQGludGxpZnkvdW5wbHVnaW4tdnVlLWkxOG4vdml0ZVwiO1xuaW1wb3J0IHsgY29tcHJlc3Npb24gfSBmcm9tIFwidml0ZS1wbHVnaW4tY29tcHJlc3Npb24yXCI7XG5cbmNvbnN0IHBsdWdpbnMgPSBbXG4gIHZ1ZSgpLFxuICBWdWVJMThuUGx1Z2luKHtcbiAgICBpbmNsdWRlOiBbcGF0aC5yZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyYy9pMThuLyoqLyouanNvblwiKV0sXG4gIH0pLFxuICBjb21wcmVzc2lvbih7XG4gICAgaW5jbHVkZTogL1xcLihqc3x3b2ZmMnx3b2ZmKShcXD8uKik/JC9pLFxuICAgIGRlbGV0ZU9yaWdpbmFsQXNzZXRzOiB0cnVlLFxuICB9KSxcbl07XG5cbmNvbnN0IHJlc29sdmUgPSB7XG4gIGFsaWFzOiB7XG4gICAgXCJAXCI6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwic3JjXCIpLFxuICB9LFxufTtcblxuLy8gaHR0cHM6Ly92aXRlanMuZGV2L2NvbmZpZy9cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZygoeyBjb21tYW5kIH0pID0+IHtcbiAgaWYgKGNvbW1hbmQgPT09IFwic2VydmVcIikge1xuICAgIHJldHVybiB7XG4gICAgICBwbHVnaW5zLFxuICAgICAgcmVzb2x2ZSxcbiAgICAgIHNlcnZlcjoge1xuICAgICAgICBwcm94eToge1xuICAgICAgICAgIFwiL2FwaS9jb21tYW5kXCI6IHtcbiAgICAgICAgICAgIHRhcmdldDogXCJ3czovLzEyNy4wLjAuMTo4MDgwXCIsXG4gICAgICAgICAgICB3czogdHJ1ZSxcbiAgICAgICAgICB9LFxuICAgICAgICAgIFwiL2FwaVwiOiBcImh0dHA6Ly8xMjcuMC4wLjE6ODA4MFwiLFxuICAgICAgICB9LFxuICAgICAgfSxcbiAgICB9O1xuICB9IGVsc2Uge1xuICAgIC8vIGNvbW1hbmQgPT09ICdidWlsZCdcbiAgICByZXR1cm4ge1xuICAgICAgcGx1Z2lucyxcbiAgICAgIHJlc29sdmUsXG4gICAgICBiYXNlOiBcIlwiLFxuICAgICAgYnVpbGQ6IHtcbiAgICAgICAgcm9sbHVwT3B0aW9uczoge1xuICAgICAgICAgIGlucHV0OiB7XG4gICAgICAgICAgICBpbmRleDogcGF0aC5yZXNvbHZlKF9fZGlybmFtZSwgXCIuL3B1YmxpYy9pbmRleC5odG1sXCIpLFxuICAgICAgICAgIH0sXG4gICAgICAgICAgb3V0cHV0OiB7XG4gICAgICAgICAgICBtYW51YWxDaHVua3M6IChpZCkgPT4ge1xuICAgICAgICAgICAgICBpZiAoaWQuaW5jbHVkZXMoXCJpMThuL1wiKSkge1xuICAgICAgICAgICAgICAgIHJldHVybiBcImkxOG5cIjtcbiAgICAgICAgICAgICAgfVxuICAgICAgICAgICAgfSxcbiAgICAgICAgICB9LFxuICAgICAgICB9LFxuICAgICAgfSxcbiAgICAgIGV4cGVyaW1lbnRhbDoge1xuICAgICAgICByZW5kZXJCdWlsdFVybChmaWxlbmFtZSwgeyBob3N0VHlwZSB9KSB7XG4gICAgICAgICAgaWYgKGhvc3RUeXBlID09PSBcImpzXCIpIHtcbiAgICAgICAgICAgIHJldHVybiB7IHJ1bnRpbWU6IGB3aW5kb3cuX19wcmVwZW5kU3RhdGljVXJsKFwiJHtmaWxlbmFtZX1cIilgIH07XG4gICAgICAgICAgfSBlbHNlIGlmIChob3N0VHlwZSA9PT0gXCJodG1sXCIpIHtcbiAgICAgICAgICAgIHJldHVybiBge3sgLlN0YXRpY1VSTCB9fS8ke2ZpbGVuYW1lfWA7XG4gICAgICAgICAgfSBlbHNlIHtcbiAgICAgICAgICAgIHJldHVybiB7IHJlbGF0aXZlOiB0cnVlIH07XG4gICAgICAgICAgfVxuICAgICAgICB9LFxuICAgICAgfSxcbiAgICB9O1xuICB9XG59KTtcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBa1UsT0FBTyxVQUFVO0FBQ25WLFNBQVMsb0JBQW9CO0FBQzdCLE9BQU8sU0FBUztBQUNoQixPQUFPLG1CQUFtQjtBQUMxQixTQUFTLG1CQUFtQjtBQUo1QixJQUFNLG1DQUFtQztBQU16QyxJQUFNLFVBQVU7QUFBQSxFQUNkLElBQUk7QUFBQSxFQUNKLGNBQWM7QUFBQSxJQUNaLFNBQVMsQ0FBQyxLQUFLLFFBQVEsa0NBQVcsc0JBQXNCLENBQUM7QUFBQSxFQUMzRCxDQUFDO0FBQUEsRUFDRCxZQUFZO0FBQUEsSUFDVixTQUFTO0FBQUEsSUFDVCxzQkFBc0I7QUFBQSxFQUN4QixDQUFDO0FBQ0g7QUFFQSxJQUFNLFVBQVU7QUFBQSxFQUNkLE9BQU87QUFBQSxJQUNMLEtBQUssS0FBSyxRQUFRLGtDQUFXLEtBQUs7QUFBQSxFQUNwQztBQUNGO0FBR0EsSUFBTyxzQkFBUSxhQUFhLENBQUMsRUFBRSxRQUFRLE1BQU07QUFDM0MsTUFBSSxZQUFZLFNBQVM7QUFDdkIsV0FBTztBQUFBLE1BQ0w7QUFBQSxNQUNBO0FBQUEsTUFDQSxRQUFRO0FBQUEsUUFDTixPQUFPO0FBQUEsVUFDTCxnQkFBZ0I7QUFBQSxZQUNkLFFBQVE7QUFBQSxZQUNSLElBQUk7QUFBQSxVQUNOO0FBQUEsVUFDQSxRQUFRO0FBQUEsUUFDVjtBQUFBLE1BQ0Y7QUFBQSxJQUNGO0FBQUEsRUFDRixPQUFPO0FBRUwsV0FBTztBQUFBLE1BQ0w7QUFBQSxNQUNBO0FBQUEsTUFDQSxNQUFNO0FBQUEsTUFDTixPQUFPO0FBQUEsUUFDTCxlQUFlO0FBQUEsVUFDYixPQUFPO0FBQUEsWUFDTCxPQUFPLEtBQUssUUFBUSxrQ0FBVyxxQkFBcUI7QUFBQSxVQUN0RDtBQUFBLFVBQ0EsUUFBUTtBQUFBLFlBQ04sY0FBYyxDQUFDLE9BQU87QUFDcEIsa0JBQUksR0FBRyxTQUFTLE9BQU8sR0FBRztBQUN4Qix1QkFBTztBQUFBLGNBQ1Q7QUFBQSxZQUNGO0FBQUEsVUFDRjtBQUFBLFFBQ0Y7QUFBQSxNQUNGO0FBQUEsTUFDQSxjQUFjO0FBQUEsUUFDWixlQUFlLFVBQVUsRUFBRSxTQUFTLEdBQUc7QUFDckMsY0FBSSxhQUFhLE1BQU07QUFDckIsbUJBQU8sRUFBRSxTQUFTLDhCQUE4QixRQUFRLEtBQUs7QUFBQSxVQUMvRCxXQUFXLGFBQWEsUUFBUTtBQUM5QixtQkFBTyxvQkFBb0IsUUFBUTtBQUFBLFVBQ3JDLE9BQU87QUFDTCxtQkFBTyxFQUFFLFVBQVUsS0FBSztBQUFBLFVBQzFCO0FBQUEsUUFDRjtBQUFBLE1BQ0Y7QUFBQSxJQUNGO0FBQUEsRUFDRjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
+6 -1
View File
@@ -6,7 +6,7 @@ setup:
fi
update:
cd backend && go get -u && go mod tidy && cd ../frontend && npm update
cd backend && go get -u ./... && go mod tidy && cd ../frontend && npm update
build:
docker build --build-arg="VERSION=testing" --build-arg="REVISION=n/a" -t gtstef/filebrowser .
@@ -35,8 +35,13 @@ lint: lint-backend lint-frontend
test: test-backend test-frontend
check-all: lint test
test-backend:
cd backend && go test -race -timeout=10s ./...
test-frontend:
cd frontend && npm run test
test-frontend-playwright:
docker build -t gtstef/filebrowser-tests -f Dockerfile.playwright .