v0.3.2 release (#250)
This commit is contained in:
parent
a79d44582f
commit
266a76459d
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -2,6 +2,21 @@
|
|||
|
||||
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**
|
||||
|
|
26
README.md
26
README.md
|
@ -9,21 +9,19 @@
|
|||
<img width="800" src="https://github.com/user-attachments/assets/e4a47229-66f8-4838-9575-dd2413596688" title="Main Screenshot">
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 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.
|
||||
> [!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]
|
||||
> If on windows, please use docker. The windows binary is unstable and may not work.
|
||||
|
||||
> [!WARNING]
|
||||
> There is no stable version yet. Always check release notes for bugfixes on functionality that may have been changed. If you notice any unexpected behavior -- please open an issue to have it fixed soon.
|
||||
> - 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] Indexes files efficiently. See [indexing readme](./docs/indexing.md)
|
||||
- Real-time search results as you type
|
||||
- Search supports file/folder sizes and many file type filters.
|
||||
- Enhanced interactive results that shows file/folder sizes.
|
||||
- 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.
|
||||
|
@ -35,16 +33,17 @@ FileBrowser Quantum is a fork of the file browser opensource project with the fo
|
|||
- 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 returning.
|
||||
- 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
|
||||
|
||||
|
@ -147,18 +146,19 @@ Not using docker (not recommended), download your binary from releases and run w
|
|||
|
||||
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 the 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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -97,15 +97,12 @@ func (si *Index) RunIndexing(origin string, quick bool) {
|
|||
if si.indexingTime < 3 || si.NumDirs < 10000 {
|
||||
si.assessment = "simple"
|
||||
si.SmartModifier = 4 * time.Minute
|
||||
log.Println("Index is small and efficient, adjusting scan interval accordingly.")
|
||||
} else if si.indexingTime > 120 || si.NumDirs > 500000 {
|
||||
si.assessment = "complex"
|
||||
modifier := si.indexingTime / 10 // seconds
|
||||
si.SmartModifier = time.Duration(modifier) * time.Minute
|
||||
log.Println("Index is large and complex, adjusting scan interval accordingly.")
|
||||
} else {
|
||||
si.assessment = "normal"
|
||||
log.Println("Index is normal, quick scan set to every 5 minutes.")
|
||||
}
|
||||
log.Printf("Index assessment : complexity=%v directories=%v files=%v \n", si.assessment, si.NumDirs, si.NumFiles)
|
||||
if si.NumDirs != prevNumDirs || si.NumFiles != prevNumFiles {
|
||||
|
|
|
@ -44,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{}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
@ -91,7 +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
|
||||
}
|
||||
|
@ -221,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
|
||||
|
@ -243,36 +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,
|
||||
|
@ -281,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
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
|
|
|
@ -63,7 +63,7 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
|||
if fileInfo.Type == "directory" {
|
||||
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
|
||||
}
|
||||
setContentDisposition(w, r, fileInfo)
|
||||
setContentDisposition(w, r, fileInfo.Name)
|
||||
if fileInfo.Type != "image" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", fileInfo.Type)
|
||||
}
|
||||
|
@ -150,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
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ func publicDlHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
|||
}
|
||||
|
||||
if file.Type == "directory" {
|
||||
return rawDirHandler(w, r, d, file.FileInfo)
|
||||
return rawFilesHandler(w, r, d, []string{file.Path})
|
||||
}
|
||||
|
||||
return rawFileHandler(w, r, file.FileInfo)
|
||||
|
|
|
@ -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,150 +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")
|
||||
fileInfo, 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
|
||||
}
|
||||
|
||||
// TODO, how to handle? we removed mode, is it needed?
|
||||
// maybe instead of mode we use bool only two conditions are checked
|
||||
//if files.IsNamedPipe(fileInfo.Mode) {
|
||||
// setContentDisposition(w, r, file)
|
||||
// return 0, nil
|
||||
//}
|
||||
|
||||
if fileInfo.Type == "directory" {
|
||||
return rawDirHandler(w, r, d, fileInfo.FileInfo)
|
||||
}
|
||||
|
||||
return rawFileHandler(w, r, fileInfo.FileInfo)
|
||||
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() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
names, err := file.Readdirnames(0)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
fPath := filepath.Join(path, name)
|
||||
err = addFile(ar, d, fPath, commonPath)
|
||||
if tarWriter != nil {
|
||||
header, err := tar.FileInfoHeader(info, realPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to archive %s: %v", fPath, err)
|
||||
return err
|
||||
}
|
||||
header.Name = archivePath
|
||||
if err = tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
return 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
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
|
@ -383,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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -338,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'.
|
||||
|
@ -353,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
|
||||
|
@ -383,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:
|
||||
|
|
|
@ -51,19 +51,21 @@ export async function put(url, content = "") {
|
|||
}
|
||||
}
|
||||
|
||||
export function download(format, ...files) {
|
||||
export function download(format, files) {
|
||||
if (format != "zip") {
|
||||
format = "tar.gz"
|
||||
}
|
||||
try {
|
||||
let path = "";
|
||||
let fileargs = "";
|
||||
if (files.length === 1) {
|
||||
path = removePrefix(files[0], "files")
|
||||
fileargs = removePrefix(files[0], "files")
|
||||
} else {
|
||||
for (let file of files) {
|
||||
fileargs += removePrefix(file,"files") + ",";
|
||||
}
|
||||
fileargs = fileargs.substring(0, fileargs.length - 1);
|
||||
}
|
||||
const apiPath = getApiPath("api/raw", { path: path, files: fileargs, algo: format });
|
||||
const apiPath = getApiPath("api/raw", { files: fileargs, algo: format });
|
||||
const url = window.origin+apiPath
|
||||
window.open(url);
|
||||
} catch (err) {
|
||||
|
@ -153,10 +155,9 @@ export async function checksum(url, algo) {
|
|||
}
|
||||
|
||||
export function getDownloadURL(path, inline) {
|
||||
|
||||
try {
|
||||
const params = {
|
||||
path: removePrefix(path,"files"),
|
||||
files: removePrefix(path,"files"),
|
||||
...(inline && { inline: "true" }),
|
||||
};
|
||||
const apiPath = getApiPath("api/raw", params);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -3,151 +3,48 @@
|
|||
<!-- 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="createPath(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">
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<input class="sizeInput" v-model="largerThan" type="number" placeholder="number" />
|
||||
<p>MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading icon when search is ongoing -->
|
||||
<p v-show="isEmpty && isRunning" id="renew">
|
||||
|
@ -181,7 +78,7 @@
|
|||
<!-- 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="createPath(s.path)">
|
||||
<router-link :to="s.path">
|
||||
<i v-if="s.type == 'directory'" class="material-icons folder-icons">
|
||||
folder
|
||||
</i>
|
||||
|
@ -215,7 +112,6 @@ import ButtonGroup from "./ButtonGroup.vue";
|
|||
import { search } from "@/api";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getApiPath } from "@/utils/url";
|
||||
|
||||
var boxes = {
|
||||
folder: { label: "folders", icon: "folder" },
|
||||
|
@ -251,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: {
|
||||
|
@ -291,6 +191,9 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
showOptions() {
|
||||
return !this.hiddenOptions || !this.isMobile
|
||||
},
|
||||
isMobile() {
|
||||
return state.isMobile;
|
||||
},
|
||||
|
@ -316,7 +219,6 @@ export default {
|
|||
if (this.ongoing) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return this.value === ""
|
||||
? this.$t("search.typeToSearch")
|
||||
: this.$t("search.pressToSearch");
|
||||
|
@ -339,8 +241,17 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
createPath(path) {
|
||||
return getApiPath(`files/${path}`)
|
||||
enableOptions() {
|
||||
this.hiddenOptions = false
|
||||
this.toggleOptionButton = [
|
||||
{ label: "Hide Options" },
|
||||
];
|
||||
},
|
||||
disableOptions() {
|
||||
this.hiddenOptions = true
|
||||
this.toggleOptionButton = [
|
||||
{ label: "Show Options" },
|
||||
];
|
||||
},
|
||||
humanSize(size) {
|
||||
return getHumanReadableFilesize(size);
|
||||
|
@ -373,7 +284,6 @@ export default {
|
|||
},
|
||||
close(event) {
|
||||
this.value = "";
|
||||
|
||||
event.stopPropagation();
|
||||
mutations.closeHovers();
|
||||
},
|
||||
|
@ -395,6 +305,7 @@ export default {
|
|||
},
|
||||
resetSearchFilters() {
|
||||
this.searchTypes = "";
|
||||
this.hiddenOptions = true;
|
||||
},
|
||||
removeFromTypes(string) {
|
||||
if (string == null || string == "") {
|
||||
|
@ -461,7 +372,7 @@ export default {
|
|||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#result-desktop > #result-list {
|
||||
#results>#result-list {
|
||||
max-height: 80vh;
|
||||
width: 35em;
|
||||
overflow: scroll;
|
||||
|
@ -471,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;
|
||||
|
@ -496,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;
|
||||
|
@ -504,8 +415,8 @@ export default {
|
|||
}
|
||||
|
||||
#search #result-list.active {
|
||||
width: 65em !important;
|
||||
max-width: 85vw !important;
|
||||
width: 1000px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
|
@ -607,7 +518,7 @@ body.rtl #search #result {
|
|||
direction: ltr;
|
||||
}
|
||||
|
||||
#search #result > div > *:first-child {
|
||||
#search #result>div>*:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
@ -617,7 +528,7 @@ body.rtl #search #result {
|
|||
}
|
||||
|
||||
/* Search Results */
|
||||
body.rtl #search #result ul > * {
|
||||
body.rtl #search #result ul>* {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
@ -729,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;
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -8,6 +8,7 @@ import Errors from "@/views/Errors.vue";
|
|||
import { baseURL, name } from "@/utils/constants";
|
||||
import { getters, state } from "@/store";
|
||||
import { mutations } from "@/store";
|
||||
import { validateLogin } from "@/utils/auth";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
const titles = {
|
||||
|
@ -124,12 +125,15 @@ function isSameRoute(to: RouteLocation, from: RouteLocation) {
|
|||
}
|
||||
|
||||
router.beforeResolve(async (to, from, next) => {
|
||||
console.log("Navigating to", to.path,from.path);
|
||||
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;
|
||||
|
|
|
@ -1,6 +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" ,
|
||||
|
@ -16,6 +17,9 @@ export const getters = {
|
|||
return state.user.darkMode === true;
|
||||
},
|
||||
isLoggedIn: () => {
|
||||
if (noAuth) {
|
||||
return true
|
||||
}
|
||||
if (state.user !== null && state.user?.username != undefined && state.user?.username != "publicUser") {
|
||||
return true;
|
||||
}
|
||||
|
@ -123,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;
|
||||
},
|
||||
|
|
|
@ -4,6 +4,7 @@ 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) => {
|
||||
|
@ -219,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) => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { notify } from "@/notify"
|
|||
|
||||
export default function download() {
|
||||
if (getters.isSingleFileSelected()) {
|
||||
filesApi.download(null, getters.selectedDownloadUrl());
|
||||
filesApi.download(null, [getters.selectedDownloadUrl()]);
|
||||
return;
|
||||
}
|
||||
mutations.showHover({
|
||||
|
@ -20,7 +20,7 @@ export default function download() {
|
|||
files.push(state.route.path);
|
||||
}
|
||||
try {
|
||||
filesApi.download(format, ...files);
|
||||
filesApi.download(format, files);
|
||||
notify.showSuccess("download started");
|
||||
} catch (e) {
|
||||
notify.showError("error downloading", e);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -27,6 +27,10 @@ vi.mock('@/store', () => {
|
|||
email: '',
|
||||
avatarUrl: '',
|
||||
},
|
||||
sorting: {
|
||||
by: 'name',
|
||||
asc: true,
|
||||
},
|
||||
},
|
||||
req: {
|
||||
sorting: {
|
||||
|
|
2
makefile
2
makefile
|
@ -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 .
|
||||
|
|
Loading…
Reference in New Issue